第 4 章 / 共 14 章
中间件:不改 agent 内部就能管住它
4.1 需求是怎么一条条加进来的
退款助手上线一周,你会陆续收到这些要求:
- 财务:单次会话最多调五次模型,别烧钱。
- 法务:工单里的邮箱和手机号不能原样发给模型。
- 客服主管:金额超 500 的退款,执行前必须弹给人确认。
- 你自己:某个外部接口偶尔超时,希望自动重试两次。
- 运维:每次工具调用前后都要打一行日志。
在没有中间件的年代,这五条都得塞进那个 while 循环里,或者塞进旧版的 pre_model_hook / post_model_hook 参数——两个钩子,五个需求,你懂的。
1.x 的答案是把它们变成一个列表:
agent = create_agent(
model=..., tools=[...],
middleware=[
PIIMiddleware("email", strategy="redact"),
ModelCallLimitMiddleware(run_limit=5),
HumanInTheLoopMiddleware(interrupt_on={"execute_refund": True}),
log_tool_calls, # 自定义的
],
)
官方对它的定位很清楚:中间件不是一个独立的运行时,这些钩子就跑在 create_agent 返回的那张 LangGraph 图里面。
4.2 六个钩子和它们的执行时机
中间件基类提供六个钩子,分两类。
顺序型(跑到就执行,返回 state 增量):
| 钩子 | 什么时候跑 | 典型用途 |
|---|---|---|
before_agent | 每次调用开始时一次 | 载入记忆、校验输入 |
before_model | 每次调模型之前 | 改 prompt、裁剪消息 |
after_model | 每次模型返回之后 | 校验输出、加护栏 |
after_agent | 每次调用结束时一次 | 落库、清理 |
包裹型(拿到 handler,可以调 0 次、1 次或多次):
| 钩子 | 什么时候跑 | 典型用途 |
|---|---|---|
wrap_model_call | 包住每一次模型调用 | 换模型、重试、改请求 |
wrap_tool_call | 包住每一次工具调用 | 拦截、改参数、兜异常 |
每个钩子都有同步和异步两个版本(before_model / abefore_model,以此类推)。

4.3 内置中间件清单
langchain.agents.middleware 实际导出的内置中间件(本机实测的导出列表):
| 中间件 | 解决什么 |
|---|---|
ToolErrorMiddleware | 工具抛异常时转成给模型看的错误消息,而不是崩掉 |
ToolRetryMiddleware / ModelRetryMiddleware | 指数退避重试 |
ModelFallbackMiddleware | 主模型失败时切备用模型 |
SummarizationMiddleware | 上下文接近上限时自动摘要 |
HumanInTheLoopMiddleware | 暂停等人批准 |
ModelCallLimitMiddleware / ToolCallLimitMiddleware | 限调用次数 |
PIIMiddleware | 检测并处理个人信息 |
ContextEditingMiddleware | 裁剪或清空历史工具调用 |
TodoListMiddleware | 给 agent 加任务规划与追踪 |
LLMToolSelectorMiddleware | 工具太多时先用模型筛一遍 |
ProviderToolSearchMiddleware | 把工具藏到厂商的服务端工具搜索后面,按需暴露 |
ShellToolMiddleware | 给 agent 一个持久 shell 会话 |
FilesystemFileSearchMiddleware | 提供文件 Glob / Grep 搜索 |
LLMToolEmulator | 测试时用模型模拟工具执行 |
几个真实签名(本机实测):
ModelCallLimitMiddleware(*, thread_limit=None, run_limit=None, exit_behavior='end')
ToolCallLimitMiddleware(*, tool_name=None, thread_limit=None, run_limit=None, exit_behavior='continue')
PIIMiddleware(pii_type, *, strategy='redact', detector=None,
apply_to_input=True, apply_to_output=False, apply_to_tool_results=False)
SummarizationMiddleware(model, *, trigger=None, keep=('messages', 20), ...)
PII 中间件的三个开关也要看清:默认只处理输入(apply_to_input=True),输出和工具结果都是关的。如果你担心的是「订单接口返回的手机号被写进模型上下文」,要显式打开 apply_to_tool_results=True。
SummarizationMiddleware 的 trigger 接受 ('fraction', 0.8)、('tokens', 100000)、('messages', 40) 三种形式,keep 默认保留最近 20 条消息。
4.4 写一个自己的:拦在工具前面
装饰器形式最省事:
from langchain.agents.middleware import wrap_tool_call
@wrap_tool_call
def log_tool_calls(request, handler):
print("[中间件] 准备执行:", request.tool_call["name"], request.tool_call["args"])
result = handler(request)
print("[中间件] 执行完成")
return result
request.tool_call 是一个字典,含 name、args、id。handler(request) 才是真正执行工具的那一步——你可以不调它,直接返回一条自造的 ToolMessage,工具就完全不会执行。官方给的错误处理写法正是这个思路:
@wrap_tool_call
def handle_tool_errors(request, handler):
try:
return handler(request)
except Exception as e:
return ToolMessage(
content=f"工具执行失败,请检查参数后重试。({e})",
tool_call_id=request.tool_call["id"],
)
需要跳出循环时用 before_model 加 can_jump_to:
from langchain.agents.middleware import before_model
from langchain.messages import AIMessage
@before_model(can_jump_to=["end"])
def stop_if_too_long(state, runtime):
if len(state["messages"]) >= 50:
return {"messages": [AIMessage("这条会话太长了,请新开一条。")], "jump_to": "end"}
return None
4.5 旧代码里的钩子该怎么翻译
如果你手上有 0.x 的 agent 代码,对照这张表改(来自官方迁移指南):
| 旧写法 | 新写法 |
|---|---|
prompt= | system_prompt= |
prompt=callable(动态提示词) | @dynamic_prompt 中间件 |
pre_model_hook= | before_model 中间件(摘要场景直接用 SummarizationMiddleware) |
post_model_hook= | after_model 中间件(审批场景直接用 HumanInTheLoopMiddleware) |
state_modifier= | 中间件 |
ToolNode(handle_tool_errors=...) | @wrap_tool_call |
tools=ToolNode([...]) | tools=[...],不再接受 ToolNode |
预绑定模型 model.bind_tools([...]) | 不再支持,直接传模型和 tools= |
| state 用 Pydantic 模型或 dataclass | 只支持 TypedDict,继承 langchain.agents.AgentState |
流式事件里的节点名 "agent" | 改成了 "model" |
config["configurable"] 传静态上下文 | context= 参数 + context_schema= |
最后一行的「节点名从 agent 改成 model」特别容易漏——如果你有按节点名过滤流式事件的前端代码,升级之后它会安静地什么都收不到。