Agent 中间件与安全护栏:限流、脱敏、重试与降级实测
Agent 上线前,几乎每个团队都会被问到同样四个问题:模型陷入循环怎么办、敏感信息会不会被发给模型、模型或工具偶发失败要不要重试、主模型挂了能不能降级。LangChain v1 的 create_agent 把这些都做成了中间件,文档里每个中间件只有一段配置示例,边界行为并没有写。
本页写给准备把 Agent 推上线的开发者:用四组可复现的脚本,把限流、脱敏、重试、降级的边界行为实测一遍,最后给出一张按风险选择中间件的决策表。
适用版本与验证信息
| 项目 | 内容 |
|---|---|
| 验证日期 | 2026-10-26 |
| Python | 3.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;本页的版本满足这些要求。使用更低版本时,请先对照官方说明。
本页验证的是中间件的控制流与边界行为。脚本化模型不会像真实模型那样“读懂”错误消息,所以凡是涉及“模型看到错误后会怎么做”的结论,都只在脚本里模拟了两种极端行为,真实模型介于两者之间。
先说结论
- 想让失控的循环停下来,用
ModelCallLimitMiddleware。run_limit=3时,模型被调用 3 次后结束:exit_behavior="end"(默认)正常返回,最后一条消息是固定的限流文本,不是答案;"error"抛出ModelCallLimitExceededError(实验 1)。 ToolCallLimitMiddleware默认只是拦住工具执行,不会结束循环。 默认的continue模式把超限的调用变成错误消息,工具只执行了 2 次,但模型照样一直被调用,直到recursion_limit报错;只有模型看到错误后自己收手,运行才会结束。要硬停止,用exit_behavior="error"(实验 1)。thread_limit按线程累计。 同一个thread_id超限后,后续调用不再请求模型,直接返回固定文本;换一个线程不受影响(实验 1)。- 中间件会消耗
recursion_limit。 3 轮工具调用加最终答案,无中间件时最小可用的recursion_limit是 8,加ModelCallLimitMiddleware变成 16,加PIIMiddleware也是 16,两者叠加是 24;只使用wrap_*钩子的重试中间件不增加步数(实验 2)。 PIIMiddleware默认只处理用户输入。 工具返回结果和模型输出需要显式打开apply_to_tool_results、apply_to_output,实测工具返回的邮箱会原样进入下一次模型调用。脱敏后的文本也是写进状态的文本,原文不会保留(实验 3)。- 重试默认“吞掉”最终失败。
on_failure默认是continue:重试耗尽后不抛异常,而是产生一条错误文本消息。位于它外层的降级中间件因此看不到异常,降级不会触发。把重试放在降级外层,或者让重试用on_failure="error"(实验 4)。 - 有副作用的工具不要无脑重试。 默认的
retry_on会重试几乎所有异常,一次逻辑调用实测真实执行了 3 次;如果副作用发生在失败之前,就会重复发生(实验 4)。
共用的测试替身
所有实验共享下面的模块:一个会真实记录执行次数的 search 工具,一个会一直请求工具的循环模型,一个只回答不调用工具的模型,以及一个会按设定次数抛异常的“不稳定模型”。
"""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。
"""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:
"""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 脱敏,哪些路径不在保护范围内
下面的脚本用一个会记录“模型实际看到什么”的脚本化模型,检查四种策略、一个自定义正则,以及工具结果和模型输出这两条默认不处理的路径:
"""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 并关闭抖动:
"""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 次后,异常到达外层,被转换成自定义的错误消息,运行继续。官方文档对这种组合有同样的顺序说明。
按风险选中间件:决策表
| 要防的风险 | 用什么 | 关键参数 | 实测要点 |
|---|---|---|---|
| 模型陷入循环、成本失控 | ModelCallLimitMiddleware | run_limit,需要跨轮限制时加 thread_limit | end 返回固定文本而不是答案;线程限制需要 checkpointer(实验 1) |
| 某个昂贵或有副作用的工具被反复调用 | ToolCallLimitMiddleware | tool_name、run_limit、exit_behavior | 默认 continue 不会结束循环;要硬停止用 error(实验 1) |
| 敏感信息进入模型或日志 | PIIMiddleware | strategy、apply_to_tool_results、apply_to_output | 默认只管输入;mask 邮箱保留用户名;脱敏后原文不保留(实验 3) |
| 网络抖动等临时故障 | ModelRetryMiddleware、ToolRetryMiddleware | max_retries、retry_on、on_failure | 默认重试几乎所有异常;continue 吞掉最终失败(实验 4) |
| 主模型整体不可用 | ModelFallbackMiddleware | 按顺序列出降级模型 | 与重试组合时注意顺序和 on_failure(实验 4) |
| 工具异常不想让整个运行失败 | ToolErrorMiddleware 或 @wrap_tool_call | on_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等其他内置中间件,也只检验了内置的邮箱和信用卡检测。结论应当作为“框架在这些边界上的行为”,而不是对任何真实模型的承诺。
相关阅读
- 先判断该不该用
create_agent:create_agent 还是 LangGraph?; - 工具定义与异常传播:工具调用 schema 排障;
- 线上报错对号入座:LangChain v1 与 LangGraph 生产故障诊断中心;
- 重试之后的幂等和重放:LangGraph checkpoint 与故障恢复。
来源
- 内置 Middleware (opens in a new tab):Model call limit、Tool call limit、PII detection、Model retry、Tool retry、Model fallback、Tool error 的参数说明与默认值;
- Middleware 概览 (opens in a new tab):中间件的用途与挂载方式;
- 自定义 Middleware (opens in a new tab):节点式与包装式钩子,以及多个中间件的执行顺序。
以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/agent-middleware-guardrails/ 在上表版本下的实际运行。