解决方案
Agent 中间件与护栏

Agent 中间件与安全护栏:限流、脱敏、重试与降级实测

Agent 上线前,几乎每个团队都会被问到同样四个问题:模型陷入循环怎么办、敏感信息会不会被发给模型、模型或工具偶发失败要不要重试、主模型挂了能不能降级。LangChain v1 的 create_agent 把这些都做成了中间件,文档里每个中间件只有一段配置示例,边界行为并没有写。

本页写给准备把 Agent 推上线的开发者:用四组可复现的脚本,把限流、脱敏、重试、降级的边界行为实测一遍,最后给出一张按风险选择中间件的决策表。

适用版本与验证信息

项目内容
验证日期2026-10-26
Python3.12.12
包版本langchain==1.4.7、langchain-core==1.7.0、langgraph==1.2.17
模型脚本化模型:按预设逻辑返回固定消息或固定异常,不联网、不需要 API Key
官方来源内置 Middleware (opens in a new tab)、Middleware 概览 (opens in a new tab)、自定义 Middleware (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16)
实验代码站点仓库 labs/agent-middleware-guardrails/,依赖锁定在 labs/requirements/v1.txt

官方文档对部分中间件标注了最低版本,例如 ToolErrorMiddleware 需要 langchain>=1.3.14,PIIMiddleware 对流式输出的脱敏需要 langchain>=1.3.2;本页的版本满足这些要求。使用更低版本时,请先对照官方说明。

本页验证的是中间件的控制流与边界行为。脚本化模型不会像真实模型那样“读懂”错误消息,所以凡是涉及“模型看到错误后会怎么做”的结论,都只在脚本里模拟了两种极端行为,真实模型介于两者之间。

先说结论

  1. 想让失控的循环停下来,用 ModelCallLimitMiddleware。 run_limit=3 时,模型被调用 3 次后结束:exit_behavior="end"(默认)正常返回,最后一条消息是固定的限流文本,不是答案;"error" 抛出 ModelCallLimitExceededError(实验 1)。
  2. ToolCallLimitMiddleware 默认只是拦住工具执行,不会结束循环。 默认的 continue 模式把超限的调用变成错误消息,工具只执行了 2 次,但模型照样一直被调用,直到 recursion_limit 报错;只有模型看到错误后自己收手,运行才会结束。要硬停止,用 exit_behavior="error"(实验 1)。
  3. thread_limit 按线程累计。 同一个 thread_id 超限后,后续调用不再请求模型,直接返回固定文本;换一个线程不受影响(实验 1)。
  4. 中间件会消耗 recursion_limit。 3 轮工具调用加最终答案,无中间件时最小可用的 recursion_limit 是 8,加 ModelCallLimitMiddleware 变成 16,加 PIIMiddleware 也是 16,两者叠加是 24;只使用 wrap_* 钩子的重试中间件不增加步数(实验 2)。
  5. PIIMiddleware 默认只处理用户输入。 工具返回结果和模型输出需要显式打开 apply_to_tool_results、apply_to_output,实测工具返回的邮箱会原样进入下一次模型调用。脱敏后的文本也是写进状态的文本,原文不会保留(实验 3)。
  6. 重试默认“吞掉”最终失败。 on_failure 默认是 continue:重试耗尽后不抛异常,而是产生一条错误文本消息。位于它外层的降级中间件因此看不到异常,降级不会触发。把重试放在降级外层,或者让重试用 on_failure="error"(实验 4)。
  7. 有副作用的工具不要无脑重试。 默认的 retry_on 会重试几乎所有异常,一次逻辑调用实测真实执行了 3 次;如果副作用发生在失败之前,就会重复发生(实验 4)。

共用的测试替身

所有实验共享下面的模块:一个会真实记录执行次数的 search 工具,一个会一直请求工具的循环模型,一个只回答不调用工具的模型,以及一个会按设定次数抛异常的“不稳定模型”。

common.py
"""Test doubles shared by the middleware labs: no network access and no API key."""
from typing import Any, List, Optional, Sequence
 
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool
 
TOOL_RUNS: List[str] = []  # one entry per real execution of the tool body
 
 
@tool
def search(query: str) -> str:
    """Search the knowledge base."""
    TOOL_RUNS.append(query)
    return f"result for {query}"
 
 
class LoopingModel(BaseChatModel):
    """Requests the `search` tool on every call.
 
    With stop_on_error=True it gives a final answer as soon as the last message is a
    ToolMessage with status "error", which imitates a model that reacts to error feedback.
    """
 
    stop_on_error: bool = False
    calls: int = 0
 
    @property
    def _llm_type(self) -> str:
        return "looping"
 
    def bind_tools(self, tools: Sequence[Any], **kwargs: Any):
        return self
 
    def _generate(
        self,
        messages: List[BaseMessage],
        stop: Optional[List[str]] = None,
        run_manager: Any = None,
        **kwargs: Any,
    ) -> ChatResult:
        self.calls += 1
        last = messages[-1]
        if self.stop_on_error and isinstance(last, ToolMessage) and last.status == "error":
            message = AIMessage(content="giving up after the tool reported an error")
        else:
            message = AIMessage(
                content="",
                tool_calls=[{"name": "search", "args": {"query": f"q{self.calls}"}, "id": f"call_{self.calls}"}],
            )
        return ChatResult(generations=[ChatGeneration(message=message)])
 
 
class AnsweringModel(BaseChatModel):
    """Always answers directly, and remembers every message list it was shown."""
 
    seen: List[List[BaseMessage]] = []
    calls: int = 0
 
    @property
    def _llm_type(self) -> str:
        return "answering"
 
    def bind_tools(self, tools: Sequence[Any], **kwargs: Any):
        return self
 
    def _generate(
        self,
        messages: List[BaseMessage],
        stop: Optional[List[str]] = None,
        run_manager: Any = None,
        **kwargs: Any,
    ) -> ChatResult:
        self.calls += 1
        self.seen.append(list(messages))
        return ChatResult(generations=[ChatGeneration(message=AIMessage(content=f"answer {self.calls}"))])
 
 
class FlakyModel(BaseChatModel):
    """Fails the first `failures` calls with `error`, then answers with its own name."""
 
    label: str = "model"
    failures: int = 0
    error: Any = ConnectionError
    attempts: int = 0
 
    @property
    def _llm_type(self) -> str:
        return "flaky"
 
    def bind_tools(self, tools: Sequence[Any], **kwargs: Any):
        return self
 
    def _generate(
        self,
        messages: List[BaseMessage],
        stop: Optional[List[str]] = None,
        run_manager: Any = None,
        **kwargs: Any,
    ) -> ChatResult:
        self.attempts += 1
        if self.attempts <= self.failures:
            raise self.error(f"{self.label} attempt {self.attempts} failed")
        return ChatResult(generations=[ChatGeneration(message=AIMessage(content=f"answered by {self.label}"))])

实验 1:失控循环怎么停

循环模型每次都请求 search 工具,永远不给最终答案。下面用不同的限流配置让它运行,统计模型被调用的次数、工具真实执行的次数和结局。最后一段演示 thread_limit。

loop_limits.py
"""How do ModelCallLimitMiddleware and ToolCallLimitMiddleware end a runaway tool loop?"""
from common import TOOL_RUNS, AnsweringModel, LoopingModel
from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware, ToolCallLimitMiddleware
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
 
from common import search
 
 
def run(label, middleware, stop_on_error=False):
    TOOL_RUNS.clear()
    model = LoopingModel(stop_on_error=stop_on_error)
    agent = create_agent(model, tools=[search], middleware=middleware)
    try:
        result = agent.invoke({"messages": [HumanMessage("find it")]}, {"recursion_limit": 20})
        last = result["messages"][-1]
        outcome = f"finished, last message: {last.type}: {last.content!r}"
    except Exception as exc:
        outcome = f"raised {type(exc).__name__}"
    print(f"{label:<44} model_calls={model.calls:<2} tool_runs={len(TOOL_RUNS)}  {outcome}")
 
 
run("no limits (recursion_limit=20)", [])
run("ModelCallLimit run_limit=3, end", [ModelCallLimitMiddleware(run_limit=3)])
run("ModelCallLimit run_limit=3, error", [ModelCallLimitMiddleware(run_limit=3, exit_behavior="error")])
run("ToolCallLimit run_limit=2, continue", [ToolCallLimitMiddleware(run_limit=2)])
run("ToolCallLimit run_limit=2, continue, model reacts", [ToolCallLimitMiddleware(run_limit=2)], stop_on_error=True)
run("ToolCallLimit run_limit=2, error", [ToolCallLimitMiddleware(run_limit=2, exit_behavior="error")])
 
print()
print("thread_limit=2 with a checkpointer, one thread:")
model = AnsweringModel()
agent = create_agent(
    model,
    tools=[],
    checkpointer=InMemorySaver(),
    middleware=[ModelCallLimitMiddleware(thread_limit=2)],
)
for turn in range(1, 4):
    before = model.calls
    result = agent.invoke({"messages": [HumanMessage(f"turn {turn}")]}, {"configurable": {"thread_id": "t1"}})
    last = result["messages"][-1]
    print(f"  turn {turn}: model_calls_this_turn={model.calls - before} last={last.content!r}")
before = model.calls
result = agent.invoke({"messages": [HumanMessage("other thread")]}, {"configurable": {"thread_id": "t2"}})
print(f"  new thread t2: model_calls_this_turn={model.calls - before} last={result['messages'][-1].content!r}")
no limits (recursion_limit=20)               model_calls=10 tool_runs=10  raised GraphRecursionError
ModelCallLimit run_limit=3, end              model_calls=3  tool_runs=3  finished, last message: ai: 'Model call limits exceeded: run limit (3/3)'
ModelCallLimit run_limit=3, error            model_calls=3  tool_runs=3  raised ModelCallLimitExceededError
ToolCallLimit run_limit=2, continue          model_calls=9  tool_runs=2  raised GraphRecursionError
ToolCallLimit run_limit=2, continue, model reacts model_calls=4  tool_runs=2  finished, last message: ai: 'giving up after the tool reported an error'
ToolCallLimit run_limit=2, error             model_calls=3  tool_runs=2  raised ToolCallLimitExceededError

thread_limit=2 with a checkpointer, one thread:
  turn 1: model_calls_this_turn=1 last='answer 1'
  turn 2: model_calls_this_turn=1 last='answer 2'
  turn 3: model_calls_this_turn=0 last='Model call limits exceeded: thread limit (2/2)'
  new thread t2: model_calls_this_turn=1 last='answer 3'

对照着看:

  • 没有任何限制:循环一直跑到 recursion_limit=20 才以 GraphRecursionError 终止,期间工具真实执行了 10 次;
  • ModelCallLimit,end:第 3 次模型调用后结束,工具执行了 3 次,最后一条是限流文本 Model call limits exceeded: run limit (3/3)。这是正常返回而不是异常,如果直接把最后一条消息展示给用户,用户看到的是这行文本;
  • ModelCallLimit,error:同样在 3 次后停止,但以 ModelCallLimitExceededError 抛出,由调用方决定怎么办;
  • ToolCallLimit,默认 continue:工具只执行了 2 次,但模型被调用了 9 次,最后仍以 GraphRecursionError 结束。超限的调用被换成了错误消息,循环模型不理会它们,继续发起调用;
  • 同一配置,但模型看到错误后收手:模型调用 4 次后给出最终答案,运行正常结束。这说明 continue 模式依赖模型对错误消息的反应;
  • ToolCallLimit,error:第 3 次工具请求时抛出 ToolCallLimitExceededError,工具只执行了 2 次;
  • thread_limit=2:前两轮各调用 1 次模型,第 3 轮模型调用次数为 0,返回 Model call limits exceeded: thread limit (2/2);换成线程 t2 又能正常调用。官方文档说明线程级限制需要 checkpointer,本实验使用了 InMemorySaver。

实验 2:中间件让每一轮多花几步

recursion_limit 限制的是图的执行步数,而节点式钩子(before_model、after_model)本身就是图里的节点。下面用 3 轮工具调用加最终答案的固定流程,分别挂上不同的中间件,数节点运行次数,并直接找出能让整个运行通过的最小 recursion_limit:

recursion_cost.py
"""How many graph steps does one model -> tool cycle cost once middleware is attached?"""
import sys
from pathlib import Path
 
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "agent-vs-langgraph"))
 
from common import search
from langchain.agents import create_agent
from langchain.agents.middleware import (
    ModelCallLimitMiddleware,
    ModelRetryMiddleware,
    PIIMiddleware,
    ToolCallLimitMiddleware,
    ToolRetryMiddleware,
    wrap_tool_call,
)
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.errors import GraphRecursionError
from shared import ScriptedChatModel
 
CYCLES = 3
 
 
@wrap_tool_call
def passthrough(request, handler):
    return handler(request)
 
 
def script():
    calls = [
        AIMessage(content="", tool_calls=[{"name": "search", "args": {"query": f"q{i}"}, "id": f"c{i}"}])
        for i in range(CYCLES)
    ]
    return calls + [AIMessage(content="done")]
 
 
def build(middleware):
    return create_agent(ScriptedChatModel(script=script()), tools=[search], middleware=middleware)
 
 
def count_steps(middleware):
    chunks = list(build(middleware).stream({"messages": [HumanMessage("go")]}, stream_mode="updates"))
    return len(chunks)
 
 
def smallest_passing_limit(middleware):
    """Find the smallest recursion_limit under which the whole run completes."""
    for limit in range(1, 100):
        try:
            build(middleware).invoke({"messages": [HumanMessage("go")]}, {"recursion_limit": limit})
            return limit
        except GraphRecursionError:
            continue
    raise RuntimeError("no passing recursion_limit below 100")
 
 
CASES = [
    ("no middleware", []),
    ("ModelCallLimit", [ModelCallLimitMiddleware(run_limit=50)]),
    ("ToolCallLimit", [ToolCallLimitMiddleware(run_limit=50)]),
    ("PII (email, input only)", [PIIMiddleware("email")]),
    ("ModelRetry + ToolRetry (wrap hooks only)", [ModelRetryMiddleware(), ToolRetryMiddleware()]),
    ("custom @wrap_tool_call", [passthrough]),
    ("ModelCallLimit + PII", [ModelCallLimitMiddleware(run_limit=50), PIIMiddleware("email")]),
]
 
print(f"{CYCLES} tool cycles plus the final answer")
for label, middleware in CASES:
    steps = count_steps(middleware)
    limit = smallest_passing_limit(middleware)
    print(f"{label:<42} node_runs={steps:<3} smallest_passing_recursion_limit={limit}")
 
print()
print("nodes that run for one tool cycle plus the final answer")
for label, middleware in (
    ("ModelCallLimit", [ModelCallLimitMiddleware(run_limit=50)]),
    ("ToolCallLimit", [ToolCallLimitMiddleware(run_limit=50)]),
    ("PII", [PIIMiddleware("email")]),
):
    model = ScriptedChatModel(script=[script()[0], script()[-1]])
    agent = create_agent(model, tools=[search], middleware=middleware)
    names = [next(iter(chunk)) for chunk in agent.stream({"messages": [HumanMessage("go")]}, stream_mode="updates")]
    print(f"{label}: {' > '.join(name.split('.')[-1] for name in names)}")
3 tool cycles plus the final answer
no middleware                              node_runs=7   smallest_passing_recursion_limit=8
ModelCallLimit                             node_runs=15  smallest_passing_recursion_limit=16
ToolCallLimit                              node_runs=11  smallest_passing_recursion_limit=12
PII (email, input only)                    node_runs=15  smallest_passing_recursion_limit=16
ModelRetry + ToolRetry (wrap hooks only)   node_runs=7   smallest_passing_recursion_limit=8
custom @wrap_tool_call                     node_runs=7   smallest_passing_recursion_limit=8
ModelCallLimit + PII                       node_runs=23  smallest_passing_recursion_limit=24

nodes that run for one tool cycle plus the final answer
ModelCallLimit: before_model > model > after_model > tools > before_model > model > after_model
ToolCallLimit: model > after_model > tools > model > after_model
PII: before_model > model > after_model > tools > before_model > model > after_model

这是一份基准数据:

  • 无中间件需要 8,比节点运行次数 7 多 1,表里每一行都是这样;
  • 末尾打印的节点顺序显示:ModelCallLimitMiddleware 和 PIIMiddleware 在每次模型调用前后各多一个节点(before_model 与 after_model),4 次模型调用共多 8 步,所以是 16;
  • ToolCallLimitMiddleware 只多一个 after_model 节点,共多 4 步,所以是 12;
  • 两个中间件叠加是 24;
  • 只用 wrap_model_call、wrap_tool_call 的中间件(重试和自定义 @wrap_tool_call)不新增节点,仍是 8。

实践含义:从没有中间件的 Agent 加上限流或脱敏之后,原来调好的 recursion_limit 可能会更早报 GraphRecursionError。上线前请用本页的方法在你的组合下重新测一次。具体报错的诊断见故障诊断中心。

实验 3:PII 脱敏,哪些路径不在保护范围内

下面的脚本用一个会记录“模型实际看到什么”的脚本化模型,检查四种策略、一个自定义正则,以及工具结果和模型输出这两条默认不处理的路径:

pii_guard.py
"""What does PIIMiddleware actually change, and which paths does it leave alone?"""
import sys
from pathlib import Path
from typing import Any, List, Optional
 
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "agent-vs-langgraph"))
 
from langchain.agents import create_agent
from langchain.agents.middleware import PIIMiddleware
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
from langchain_core.outputs import ChatResult
from langchain_core.tools import tool
from shared import ScriptedChatModel
 
 
class RecordingModel(ScriptedChatModel):
    """Scripted model that remembers the message list of every call."""
 
    seen: List[List[BaseMessage]] = []
 
    def _generate(self, messages: List[BaseMessage], stop: Optional[List[str]] = None, run_manager: Any = None, **kwargs: Any) -> ChatResult:
        self.seen.append(list(messages))
        return super()._generate(messages, stop, run_manager, **kwargs)
 
 
@tool
def lookup_customer(customer_id: str) -> str:
    """Return the customer record."""
    return "customer 7 contact: [email protected]"
 
 
def answer_only(text: str) -> RecordingModel:
    return RecordingModel(script=[AIMessage(content=text)])
 
 
def show_input(label: str, middleware, user_text: str) -> None:
    model = answer_only("ok")
    agent = create_agent(model, tools=[], middleware=middleware)
    try:
        result = agent.invoke({"messages": [HumanMessage(user_text)]})
    except Exception as exc:
        print(f"{label:<34} raised {type(exc).__name__}")
        return
    seen = model.seen[0][0].content
    stored = result["messages"][0].content
    print(f"{label:<34} model saw: {seen!r} | stored: {stored!r}")
 
 
print("input handling")
mail = "write to [email protected] please"
show_input("email, redact", [PIIMiddleware("email", strategy="redact")], mail)
show_input("email, mask", [PIIMiddleware("email", strategy="mask")], mail)
show_input("email, hash", [PIIMiddleware("email", strategy="hash")], mail)
show_input("email, block", [PIIMiddleware("email", strategy="block")], mail)
show_input("credit card, mask", [PIIMiddleware("credit_card", strategy="mask")], "card 4111111111111111 expires soon")
show_input(
    "custom regex EMP-\\d{6}, redact",
    [PIIMiddleware("employee_id", detector=r"EMP-\d{6}", strategy="redact")],
    "ticket for EMP-123456",
)
show_input("email rule, text without email", [PIIMiddleware("email")], "no personal data here")
 
print()
print("tool results")
for flag in (False, True):
    model = RecordingModel(
        script=[
            AIMessage(content="", tool_calls=[{"name": "lookup_customer", "args": {"customer_id": "7"}, "id": "c1"}]),
            AIMessage(content="done"),
        ]
    )
    agent = create_agent(
        model,
        tools=[lookup_customer],
        middleware=[PIIMiddleware("email", strategy="redact", apply_to_tool_results=flag)],
    )
    agent.invoke({"messages": [HumanMessage("who is customer 7?")]})
    tool_message = next(m for m in model.seen[1] if isinstance(m, ToolMessage))
    print(f"apply_to_tool_results={flag!s:<5} model saw tool result: {tool_message.content!r}")
 
print()
print("model output")
for flag in (False, True):
    model = answer_only("reach [email protected] for details")
    agent = create_agent(model, tools=[], middleware=[PIIMiddleware("email", strategy="redact", apply_to_output=flag)])
    result = agent.invoke({"messages": [HumanMessage("who can help?")]})
    print(f"apply_to_output={flag!s:<5} final message: {result['messages'][-1].content!r}")
input handling
email, redact                      model saw: 'write to [REDACTED_EMAIL] please' | stored: 'write to [REDACTED_EMAIL] please'
email, mask                        model saw: 'write to alice@****.com please' | stored: 'write to alice@****.com please'
email, hash                        model saw: 'write to <email_hash:ff8d9819> please' | stored: 'write to <email_hash:ff8d9819> please'
email, block                       raised PIIDetectionError
credit card, mask                  model saw: 'card ************1111 expires soon' | stored: 'card ************1111 expires soon'
custom regex EMP-\d{6}, redact     model saw: 'ticket for [REDACTED_EMPLOYEE_ID]' | stored: 'ticket for [REDACTED_EMPLOYEE_ID]'
email rule, text without email     model saw: 'no personal data here' | stored: 'no personal data here'

tool results
apply_to_tool_results=False model saw tool result: 'customer 7 contact: [email protected]'
apply_to_tool_results=True  model saw tool result: 'customer 7 contact: [REDACTED_EMAIL]'

model output
apply_to_output=False final message: 'reach [email protected] for details'
apply_to_output=True  final message: 'reach [REDACTED_EMAIL] for details'
  • redact:邮箱替换为 [REDACTED_EMAIL];模型看到的和状态里存的是同一份脱敏文本,原文不会保留。如果审计需要原文,要在进入 Agent 之前自己留存;
  • mask:邮箱被处理成 alice@****.com,用户名部分仍然可见,信用卡则只保留末四位。选择 mask 之前,先确认业务上能接受保留用户名;
  • hash:替换成形如 <email_hash:…> 的确定性哈希,同一个邮箱总是得到同一个值,便于关联但不可读;
  • block:命中即抛 PIIDetectionError;
  • 自定义检测:detector 传正则,employee_id 这类自定义类型会变成 [REDACTED_EMPLOYEE_ID];
  • 工具结果:apply_to_tool_results 默认是 False,此时工具返回的 [email protected] 原样进入了下一次模型调用;打开之后才被替换;
  • 模型输出:apply_to_output 默认是 False,模型回复里的邮箱原样出现在最终消息中;打开之后才被替换。

所以“加了 PIIMiddleware 就没有敏感信息出站”这个判断是错的。涉及客户数据的工具,请同时打开 apply_to_tool_results,并用本页的脚本在你的数据形态上验证。官方文档还指出,apply_to_output=True 在 langchain>=1.3.2 时会同时处理流式输出。

实验 4:重试、降级与它们的顺序

最后一组实验同时回答三个问题:默认重试会重试什么,降级与重试按什么顺序组合,有副作用的工具重试会发生什么。为了让脚本快速且确定,重试延迟设为 0 并关闭抖动:

retry_and_fallback.py
"""Model retry, model fallback and tool retry: attempts, ordering and side effects."""
import sys
from pathlib import Path
 
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "agent-vs-langgraph"))
 
from common import FlakyModel
from langchain.agents import create_agent
from langchain.agents.middleware import (
    ModelFallbackMiddleware,
    ModelRetryMiddleware,
    ToolErrorMiddleware,
    ToolRetryMiddleware,
)
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import tool
from shared import ScriptedChatModel
 
ASK = {"messages": [HumanMessage("hello")]}
NO_DELAY = dict(initial_delay=0.0, jitter=False)
 
 
def run_model(label, models, middleware):
    agent = create_agent(models[0], tools=[], middleware=middleware)
    try:
        last = agent.invoke(ASK)["messages"][-1].content
        outcome = f"finished: {last!r}"
    except Exception as exc:
        outcome = f"raised {type(exc).__name__}"
    attempts = ", ".join(f"{m.label}={m.attempts}" for m in models)
    print(f"{label:<44} attempts[{attempts}]  {outcome}")
 
 
print("model retry")
m = FlakyModel(label="primary", failures=2)
run_model("no middleware, 2 failures", [m], [])
m = FlakyModel(label="primary", failures=2)
run_model("ModelRetry max_retries=2, 2 failures", [m], [ModelRetryMiddleware(max_retries=2, **NO_DELAY)])
m = FlakyModel(label="primary", failures=3)
run_model("ModelRetry max_retries=2, 3 failures", [m], [ModelRetryMiddleware(max_retries=2, **NO_DELAY)])
m = FlakyModel(label="primary", failures=3)
run_model(
    "ModelRetry on_failure=error, 3 failures",
    [m],
    [ModelRetryMiddleware(max_retries=2, on_failure="error", **NO_DELAY)],
)
m = FlakyModel(label="primary", failures=9, error=ValueError)
run_model("ModelRetry default retry_on, ValueError", [m], [ModelRetryMiddleware(max_retries=2, on_failure="error", **NO_DELAY)])
m = FlakyModel(label="primary", failures=9, error=ValueError)
run_model(
    "ModelRetry retry_on=(ConnectionError,), ValueError",
    [m],
    [ModelRetryMiddleware(max_retries=2, retry_on=(ConnectionError,), on_failure="error", **NO_DELAY)],
)
 
print()
print("model fallback and ordering (primary always fails)")
p, f = FlakyModel(label="primary", failures=99), FlakyModel(label="fallback")
run_model("ModelFallback only", [p, f], [ModelFallbackMiddleware(f)])
p, f = FlakyModel(label="primary", failures=99), FlakyModel(label="fallback")
run_model(
    "[ModelRetry(1), ModelFallback]",
    [p, f],
    [ModelRetryMiddleware(max_retries=1, **NO_DELAY), ModelFallbackMiddleware(f)],
)
p, f = FlakyModel(label="primary", failures=99), FlakyModel(label="fallback")
run_model(
    "[ModelFallback, ModelRetry(1)]",
    [p, f],
    [ModelFallbackMiddleware(f), ModelRetryMiddleware(max_retries=1, **NO_DELAY)],
)
p, f = FlakyModel(label="primary", failures=99), FlakyModel(label="fallback")
run_model(
    "[ModelFallback, ModelRetry(1, on_failure=error)]",
    [p, f],
    [ModelFallbackMiddleware(f), ModelRetryMiddleware(max_retries=1, on_failure="error", **NO_DELAY)],
)
p, f1, f2 = (FlakyModel(label="primary", failures=99), FlakyModel(label="fallback1", failures=99), FlakyModel(label="fallback2"))
run_model("ModelFallback with two fallbacks", [p, f1, f2], [ModelFallbackMiddleware(f1, f2)])
p, f = FlakyModel(label="primary", failures=99), FlakyModel(label="fallback", failures=99)
run_model("ModelFallback, every model fails", [p, f], [ModelFallbackMiddleware(f)])
 
SIDE_EFFECTS = []
 
 
@tool
def charge(order_id: str) -> str:
    """Charge the customer for an order."""
    SIDE_EFFECTS.append(order_id)  # the side effect happens before the failure
    if len(SIDE_EFFECTS) <= FAIL_FIRST:
        raise RuntimeError("payment gateway timeout")
    return f"charged {order_id}"
 
 
def run_tool(label, middleware, fail_first):
    global FAIL_FIRST
    FAIL_FIRST = fail_first
    SIDE_EFFECTS.clear()
    model = ScriptedChatModel(
        script=[
            AIMessage(content="", tool_calls=[{"name": "charge", "args": {"order_id": "A1"}, "id": "c1"}]),
            AIMessage(content="done"),
        ]
    )
    agent = create_agent(model, tools=[charge], middleware=middleware)
    try:
        messages = agent.invoke(ASK)["messages"]
        tool_message = next(m for m in messages if m.type == "tool")
        outcome = f"finished, tool message ({tool_message.status}): {tool_message.content!r}"
    except Exception as exc:
        outcome = f"raised {type(exc).__name__}"
    print(f"{label:<46} real_executions={len(SIDE_EFFECTS)}  {outcome}")
 
 
print()
print("tool retry (one logical call, side effect before the failure)")
run_tool("no middleware, fails once", [], fail_first=1)
run_tool("ToolRetry max_retries=2, fails twice", [ToolRetryMiddleware(max_retries=2, **NO_DELAY)], fail_first=2)
run_tool("ToolRetry max_retries=2, always fails", [ToolRetryMiddleware(max_retries=2, **NO_DELAY)], fail_first=99)
run_tool(
    "ToolRetry on_failure=error, always fails",
    [ToolRetryMiddleware(max_retries=2, on_failure="error", **NO_DELAY)],
    fail_first=99,
)
run_tool(
    "ToolRetry max_retries=0, always fails",
    [ToolRetryMiddleware(max_retries=0, **NO_DELAY)],
    fail_first=99,
)
 
 
def on_error(exc, request):
    return f"{request.tool_call['name']} failed with {type(exc).__name__}"
 
 
run_tool(
    "[ToolError, ToolRetry(on_failure=error)], fails",
    [ToolErrorMiddleware(on_error), ToolRetryMiddleware(max_retries=2, on_failure="error", **NO_DELAY)],
    fail_first=99,
)
model retry
no middleware, 2 failures                    attempts[primary=1]  raised ConnectionError
ModelRetry max_retries=2, 2 failures         attempts[primary=3]  finished: 'answered by primary'
ModelRetry max_retries=2, 3 failures         attempts[primary=3]  finished: 'Model call failed after 3 attempts with ConnectionError: primary attempt 3 failed'
ModelRetry on_failure=error, 3 failures      attempts[primary=3]  raised ConnectionError
ModelRetry default retry_on, ValueError      attempts[primary=3]  raised ValueError
ModelRetry retry_on=(ConnectionError,), ValueError attempts[primary=1]  raised ValueError

model fallback and ordering (primary always fails)
ModelFallback only                           attempts[primary=1, fallback=1]  finished: 'answered by fallback'
[ModelRetry(1), ModelFallback]               attempts[primary=1, fallback=1]  finished: 'answered by fallback'
[ModelFallback, ModelRetry(1)]               attempts[primary=2, fallback=0]  finished: 'Model call failed after 2 attempts with ConnectionError: primary attempt 2 failed'
[ModelFallback, ModelRetry(1, on_failure=error)] attempts[primary=2, fallback=1]  finished: 'answered by fallback'
ModelFallback with two fallbacks             attempts[primary=1, fallback1=1, fallback2=1]  finished: 'answered by fallback2'
ModelFallback, every model fails             attempts[primary=1, fallback=1]  raised ConnectionError

tool retry (one logical call, side effect before the failure)
no middleware, fails once                      real_executions=1  raised RuntimeError
ToolRetry max_retries=2, fails twice           real_executions=3  finished, tool message (success): 'charged A1'
ToolRetry max_retries=2, always fails          real_executions=3  finished, tool message (error): "Tool 'charge' failed after 3 attempts with RuntimeError: payment gateway timeout. Please try again."
ToolRetry on_failure=error, always fails       real_executions=3  raised RuntimeError
ToolRetry max_retries=0, always fails          real_executions=1  finished, tool message (error): "Tool 'charge' failed after 1 attempt with RuntimeError: payment gateway timeout. Please try again."
[ToolError, ToolRetry(on_failure=error)], fails real_executions=3  finished, tool message (error): 'charge failed with RuntimeError'

模型重试:

  • 失败 2 次、max_retries=2 时第 3 次成功,共尝试 3 次;
  • 失败 3 次时重试耗尽,默认 continue 没有抛异常,最终消息是一段文本 Model call failed after 3 attempts with ConnectionError: …;改成 on_failure="error" 才会抛出原异常;
  • 默认的 retry_on 连 ValueError 也重试了 3 次,而 ValueError 通常是代码错误,重试没有意义。把 retry_on 限制成 (ConnectionError,) 之后只尝试 1 次。官方文档说明,默认值会重试可重试的模型错误和所有未分类的异常。

降级与顺序(列表里排在前面的中间件在外层):

  • 只配降级:主模型失败 1 次,降级模型成功;
  • [重试, 降级]:重试在外层,降级先处理了失败,重试没有机会触发,主模型 1 次、降级模型 1 次;
  • [降级, 重试]:重试在内层,主模型被尝试 2 次,然后重试以默认的 continue 把异常换成了错误文本,降级模型一次都没被调用,最终消息是错误文本;
  • [降级, 重试(on_failure="error")]:重试在内层且重试耗尽后抛异常,降级这才接手:主模型 2 次,降级模型 1 次,成功;
  • 所有模型都失败时,最后以原异常抛出。

工具重试:

  • 默认配置下,工具连续失败 2 次后第 3 次成功,真实执行了 3 次;如果副作用(本例是写入扣款记录)发生在失败点之前,一次逻辑调用就产生了 3 次副作用;
  • 一直失败时,continue 模式给模型返回一条 status="error" 的工具消息,error 模式抛出异常;
  • max_retries=0 等于不重试,只尝试 1 次;
  • 把 ToolErrorMiddleware 放在外层、ToolRetryMiddleware(on_failure="error") 放在内层:重试 3 次后,异常到达外层,被转换成自定义的错误消息,运行继续。官方文档对这种组合有同样的顺序说明。

按风险选中间件:决策表

要防的风险用什么关键参数实测要点
模型陷入循环、成本失控ModelCallLimitMiddlewarerun_limit,需要跨轮限制时加 thread_limitend 返回固定文本而不是答案;线程限制需要 checkpointer(实验 1)
某个昂贵或有副作用的工具被反复调用ToolCallLimitMiddlewaretool_name、run_limit、exit_behavior默认 continue 不会结束循环;要硬停止用 error(实验 1)
敏感信息进入模型或日志PIIMiddlewarestrategy、apply_to_tool_results、apply_to_output默认只管输入;mask 邮箱保留用户名;脱敏后原文不保留(实验 3)
网络抖动等临时故障ModelRetryMiddleware、ToolRetryMiddlewaremax_retries、retry_on、on_failure默认重试几乎所有异常;continue 吞掉最终失败(实验 4)
主模型整体不可用ModelFallbackMiddleware按顺序列出降级模型与重试组合时注意顺序和 on_failure(实验 4)
工具异常不想让整个运行失败ToolErrorMiddleware 或 @wrap_tool_callon_error、tools能处理任意异常;搭配 ToolRetryMiddleware(on_failure="error") 时放在外层(实验 4、工具调用排障)
高风险动作需要人工确认HumanInTheLoopMiddleware需要 checkpointer本页未验证,见 create_agent 还是 LangGraph?

排障对照表

现象最可能的原因怎么验证处理
运行正常结束,但最后一条消息是 Model call limits exceeded: …ModelCallLimitMiddleware 的 end 模式触发看消息内容里的限制数字调大限制,或改成 error 并在调用方处理(实验 1)
加了中间件后更早出现 GraphRecursionError节点式钩子消耗了递归步数对照实验 2,用同样的方法找最小可用值按实测结果调高 recursion_limit(实验 2)
ToolCallLimit 已触发,Agent 仍在不停调用模型默认 continue 只拦截工具,不终止循环统计模型调用次数与工具真实执行次数加 ModelCallLimit,或把 exit_behavior 改成 error(实验 1)
日志已脱敏,但工具返回的客户信息进了模型apply_to_tool_results 默认关闭打印模型实际收到的消息列表打开 apply_to_tool_results(实验 3)
降级模型从未被使用,最终回复是 Model call failed after N attempts内层重试用默认 continue,把异常吞成了文本看最终消息是否是错误文本重试用 on_failure="error",或把重试放到降级外层(实验 4)
重试之后出现重复扣款或重复写入有副作用的工具被自动重试统计工具真实执行次数副作用工具不要配重试,或用 tools 参数限定只对只读工具重试,并做幂等,见 LangGraph checkpoint 与故障恢复(实验 4)
对 ValueError 等代码错误重试了多次默认 retry_on 过宽看重试日志里的异常类型用 retry_on 明确列出可重试的异常(实验 4)

失败与回滚

  • 配置集中管理:把中间件列表放在一个函数里统一构造,限流、脱敏、重试各自独立成项。回滚就是回退这个函数的改动,不需要改业务代码;
  • 改动后重录:任何调整(限制数字、策略、顺序)之后,在你的真实组合上重新执行本页的方法,尤其是实验 2 的最小 recursion_limit 和实验 4 的组合顺序;
  • 有外部副作用时:重试和降级不会回滚已经发生的副作用,必须由工具自己保证幂等;
  • 局限:脚本化模型没有真实的延迟、限速和内容;重试用了 0 延迟,没有验证退避时间;本页没有验证异步调用路径、SummarizationMiddleware 等其他内置中间件,也只检验了内置的邮箱和信用卡检测。结论应当作为“框架在这些边界上的行为”,而不是对任何真实模型的承诺。

相关阅读

来源

以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/agent-middleware-guardrails/ 在上表版本下的实际运行。

维护信息

  • 编辑:LangChain 中文网编辑部(内容标准见编辑与纠错说明);
  • 最近验证:2026-10-26;
  • 复现:先执行 labs/setup-envs.sh v1 重建环境,再执行 python3 labs/run.py --lab agent-middleware-guardrails;
  • 发现错误请按联系我们反馈。