RSS

第 4 章 / 共 14 章

中间件:不改 agent 内部就能管住它

约 8 分钟 更新于

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章:中间件:不改 agent 内部就能管住它
图 4.1:六个钩子把 agent 循环切成了可插拔的段落。顺序型钩子只能在段落之间做事,包裹型钩子能决定那一段要不要跑、跑几次——这是两者最本质的差别。

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」特别容易漏——如果你有按节点名过滤流式事件的前端代码,升级之后它会安静地什么都收不到。

4.6 练习与检查点