精读 LangChain 官方文档(十)Middleware 首讲
本篇对应的官方文档
- Middleware overview:Middleware 在 Agent 执行中的位置、适用场景与内置/自定义入口。
- Custom middleware:node-style、wrap-style hooks,状态更新、执行顺序与短路规则。
- Prebuilt middleware:摘要、审批、调用限制、fallback、PII、重试等内置能力。
本篇讲解范围
本篇主要解释 Middleware 为什么存在、六类核心 hook 如何进入 Agent loop、模型与工具调用分别怎样被治理,以及多层 Middleware 的组合顺序。自定义 stream transformer、provider-specific middleware、每个内置类的全部参数和生产部署细节留给后续专题。
上一篇把信息保存和再次读取的边界理清了,真正上线时却还有一层麻烦:规则写明了,不代表每次执行都一定守得住。日志该在哪里记录,敏感信息由谁过滤,模型超时后要不要重试,高金额退款又该怎样在真实执行前停下来等待审批?
把这些要求继续写进 system prompt,或者逐个塞进工具函数,小型演示通常还能应付。工具一多,问题就暴露出来了:模型可能漏掉“必须先审批”的文字要求;十几个工具开始重复日志与异常转换;规则升级还得逐处排查。
业务逻辑和执行政策纠缠在一起,测试也只能从整条 Agent 链路下手。
Middleware 就放在这个缺口里。它不替代 Agent loop,也不创造新的业务能力,而是在模型调用、工具调用和整个 Agent 生命周期的明确位置接过一部分控制权。
退款工具仍然负责退款;能否继续、怎样记录、失败后返回什么,以及本轮模型能看到哪些 prompt 或工具,则由对应的 Middleware 处理。
这篇仍用退款客服 Agent 串起整条链路。先看横切规则为什么容易散落,再定位 Middleware 的 hooks,随后分别进入模型调用和工具调用,最后把组合顺序、Human-in-the-loop 与错误包装接成一套可以验证的治理闭环。
模型与工具仍在 Agent loop 中往返,日志、权限、重试、审批和上下文调整则落在调用边界。Middleware 改变的是“这一步怎样执行”,业务工具本身的领域职责没有被重新包装。
先确认这层控制权放在哪里,后面的 hook 才不会只剩一组 API 名字。
一、Middleware 解决的是横切规则,不是业务功能
一个退款 Agent 可能同时使用查订单、读政策、计算金额和提交退款四个工具。现在给系统加上三条要求:所有外部调用都记录耗时;金额超过 1000 元必须人工确认;接口超时要转换成模型能够处理的错误结果。
直接改工具看起来最快,代价会随着工具数量一起增长。
职责首先开始重复。查订单和提交退款都要复制计时、日志与异常处理,新工具接入时还得记得照搬。
更危险的是规则并不完整:prompt 里的“高金额先审批”只是给模型的指令,不能代替执行边界上的强制拦截。等审批阈值、日志字段或重试策略变化,多个工具、多个 Agent 又要同步修改,遗漏几乎不可避免。
这时需要把三种职责分开:
- 工具回答“这项业务动作怎样完成”;
- prompt 回答“模型应该怎样思考和表达”;
- Middleware 回答“执行经过某个关键边界时必须应用什么政策”。
公共代码也不是都要搬进 Middleware。订单格式转换、退款资格判断仍然属于领域服务。只有一项策略横跨多个模型调用、业务工具或 Agent,并且需要统一生效时,才值得提升为拦截层。
审批、日志和错误处理一旦散落在 prompt、工具与业务接口里,任意遗漏都可能形成绕行路径。把执行政策收回统一边界后,工具只保留业务输入与结果,同一策略也能独立测试并被多个 Agent 复用。
遇到新需求时,可以先问三个问题:它会不会跨越多个业务工具?它是否必须在模型之外强制执行?它是否需要观察或改变 Agent 的执行状态?三个答案里有两个为“是”,通常就已经进入 Middleware 的职责范围。
二、六类 hook 把控制权放到 Agent 生命周期中
官方自定义 Middleware 提供两种 hook 风格。node-style hooks 在固定执行节点运行,包括before_agent、before_model、after_model、after_agent。
wrap-style hooks 包裹每一次具体调用,包括wrap_model_call和wrap_tool_call。
六类 hook 分别对应不同的生命周期语义:
before_agent:一次invoke刚开始,适合初始化运行级字段或做入口检查;before_model:每轮模型调用前都会经过,适合校验消息、更新 State 或决定跳转;wrap_model_call:真正包住模型调用,可修改请求、短路、缓存、重试或 fallback;after_model:模型响应已经进入执行链,适合统计、校验或根据结果更新 State;wrap_tool_call:包住每个工具调用,可做权限、审批、重试和错误转换;after_agent:本次 Agent 执行结束,适合收口审计和运行级汇总。
假设退款 Agent 处理一次请求时调用模型三次、工具两次。before_agent和after_agent各运行一次。
before_model、wrap_model_call、after_model会跟着三次模型调用重复;wrap_tool_call只跟着两次工具执行进入。
hook 选错以后,最先出问题的往往不是语法,而是执行次数。一次运行只该做一次的初始化可能在循环里反复发生,原本想统计每次工具调用的逻辑也可能只在入口记了一笔。
before_agent / after_agent包住整次 invocation,模型相关 hooks 随 Agent loop 重复,wrap_tool_call则只在模型真正发起工具调用时进入。执行次数来自生命周期语义,不能靠代码排列顺序猜。
这也把 Middleware 与第 07、08 篇接了起来。State、Runtime Context 和 Store 负责承载信息,Context Engineering 决定哪些信息在什么时点进入模型。
Middleware 则给这些决定提供可执行的生命周期入口。比如,before_model可以根据 State 更新摘要,wrap_model_call可以按 Runtime Context 过滤工具;容器职责并没有因此被替换。
三、node-style 管固定节点,wrap-style 管一次调用
两种风格都能在模型前后运行代码,所以很容易被当成两套相似写法。真正的分界不在“谁先谁后”,而在它是否掌握被包裹调用的控制权。
node-style hook 接收 State 与 Runtime,在固定节点按顺序运行。它可以返回字典,让字段通过 State reducer 合并;配置允许时也可以返回jump_to,转向model、tools或end。
消息数量检查、状态计数、输入验证和运行日志通常适合放在这里,因为它们只需要在节点到达时观察或更新状态。
wrap-style hook 接收 request 和handler。这个handler代表下一层 Middleware 或真正的模型、工具,当前层可以决定是否调用它。调用一次是正常透传,多次调用可以实现重试,完全不调用则意味着缓存命中、拒绝或短路。
正因为握有这层控制权,wrap-style 更适合 fallback、动态模型选择、工具筛选、超时处理和权限拦截。
node-style 在 Agent 图确定的固定站点读取或合并 State;wrap-style 把下一层handler包在内部,可以继续、重试、替换或停止调用。前者适合顺序性的状态动作,后者才拥有真实调用的控制权。
两种 hook 的状态更新合同也不完全相同。node-style 可以直接返回字典。
wrap_model_call若要同时写 State,需要按官方合同返回带Command的ExtendedModelResponse;wrap_tool_call则可以直接返回Command。
如果只因为它们都属于 Middleware 就随手返回字典,代码看起来可能没问题,更新却不会按预期进入图的 reducer。
所以写自定义 Middleware 之前,先把四件事问清楚:它按整次运行、每次模型调用还是每次工具调用触发?是否要控制handler?
是否更新 State?异常继续向外抛,还是转换成 Agent 能处理的结果?答案确定以后,hook 类型通常也就确定了。
四、Model Middleware 改的是本次模型请求
模型侧的横切需求大多围绕四类对象展开:system message、messages、model 和 tools。
官方示例通过ModelRequest.override(...)构造本次调用的新请求,再交给handler;Agent 的原始配置不需要被永久改写。
拿客服权限来说,同一个 Agent 既服务普通用户,也服务企业管理员。管理员可以看到内部工单工具,普通用户只能使用公开查询工具。
没有必要为此维护两套几乎相同的 Agent,可以在wrap_model_call中读取 Runtime Context 的角色,只把本次请求允许使用的工具传给模型。
不过,“模型没看到工具”只能减少误选,不能成为最终权限边界。真实工具仍然要在服务端校验身份和权限。
动态 prompt 走的也是同一条路径。Middleware 读取 State 或 Runtime Context,在现有SystemMessage.content_blocks后追加当前租户的规则,再把 override 后的 request 交给下一层。
这次改动只属于瞬时 model context;如果顺手写入长期 State,下一轮就可能再次追加相同内容。
这里真正要盯住的是两件事:请求改动只对当前调用生效,控制权还会继续向内传递。
Middleware 根据 State 与 Runtime Context 改写本次ModelRequest,handler向内执行并返回ModelResponse。动态 prompt、工具筛选和模型切换不会改写 Agent 的永久配置。
如果只想记录模型返回了什么,after_model已经够用;需要在调用失败时切换模型,才轮到wrap_model_call,因为它持有handler和异常边界。
把所有逻辑都塞进 wrap hook 的确能跑,后果却是职责混在一起、嵌套越来越难测。能用权限更小的 hook 解决,就不要默认选择功能最强的那个。
五、Tool Middleware 守住真实动作的边界
模型调用和工具调用承担的风险并不相同。模型说得不理想,通常还有机会重试或改写;工具却可能已经发出邮件、扣减库存,甚至提交退款。wrap_tool_call正好站在 Agent 从推理走向真实动作的边界上,它不只是一个通用异常装饰器。
ToolCallRequest里带着工具名、参数与调用标识。执行前,Middleware 可以检查用户权限、参数范围和审批状态,再决定是否调用handler(request)。
执行后,它可以记录结果、转换已知异常,或者返回ToolMessage,让模型拿到可以继续处理的失败信息。
退款场景里,查询类工具可以自动执行,issue_refund遇到高风险条件则必须暂停。官方内置的HumanInTheLoopMiddleware会按工具名匹配interrupt_on配置,并依靠 checkpointer 保存中断状态。
审批完成后恢复的是原来的线程,不需要再让模型猜一遍是否应该退款。
真正决定风险的是顺序:先审批,再执行真实动作,最后把合法结果送回循环。
工具请求先经过权限与审批策略,再抵达真实业务函数;成功结果以ToolMessage返回 Agent loop,已知错误也能转换成带原调用 ID 的工具结果。高风险写操作一旦可能已经发生,重试就必须服从业务幂等合同。
“自动重试”在这里尤其要克制。查询接口遇到明确超时或限流时,做有限重试通常合理;提交退款却不同——服务端可能已经成功,只是客户端没有收到响应。此时盲目再调一次,就可能制造重复动作。
Middleware 能控制调用次数,却无法凭空知道业务副作用是否已经发生。幂等键、事务状态和结果查询接口,仍然要由业务系统提供。
六、内置 Middleware 是经过命名的常见政策
LangChain 已经把一批高频需求做成内置 Middleware,包括 Summarization、Human-in-the-loop、Model/Tool call limit、Model fallback 和 PII detection。
此外还有 Tool retry、Model retry、Tool error、工具选择与上下文编辑等能力。
这些名字对应的是已经被识别出来的常见执行政策,不是要求全部装进同一个 Agent。
使用内置能力当然能少写代码,但更重要的是,策略的输入、状态字段和退出行为有了明确配置。
比如ModelCallLimitMiddleware可以分别限制单次运行和线程累计调用。
ToolCallLimitMiddleware可以面向全部工具或指定工具,还能决定超限后继续、报错还是结束。Human-in-the-loop 则明确依赖 checkpointer,因为没有持久状态,中断就无从恢复。
上下文过长交给摘要和编辑,失控循环交给调用限制,外部不稳定交给 retry 与 fallback,敏感动作交给 PII 与审批。先从失败类型定位政策,再选择组件,比按类名逐个试用更容易形成可解释的组合。
不过,标注为“production-ready”并不等于加进列表就完成了生产治理。调用限制的阈值要经过压测,PII 规则得匹配业务地区和字段,fallback 模型要验证结构化输出兼容性,审批还需要超时、撤销与审计。
内置 Middleware 给出了稳定落点,策略参数和执行后果依然由业务负责。
自定义能力也最好保持同样的单一职责。一个 Middleware 如果同时包办动态 prompt、权限、计费、重试和日志,很快又会变成另一个难以拆解的 Agent。
拆开以后,每层只回答一个问题,也更容易独立验证:给定 request 与 State,它会不会继续调用 handler,会返回什么,异常又怎样传播。
七、组合顺序决定谁先进入、谁最后收口
Middleware 列表并不是简单地从上到下各执行一次。官方的顺序合同很明确:before_*从前往后运行;wrap hooks 像函数一样逐层嵌套,第一个 Middleware 包住后面的全部层;after_*与返回路径则从后往前展开。
假设列表是[audit, approval, error_adapter]。请求先进入 audit,再进入 approval,随后抵达 error adapter 和真实工具。
返回时方向相反:内层结果先交给 error adapter,再逐层回到 approval 与 audit。
因此,外层 audit 能观察包含内部处理在内的总耗时,内层 error adapter 则更靠近原始异常。
进入路径按列表顺序向内,wrap hook 形成洋葱式嵌套,after 和返回路径反向展开。外层覆盖的调用范围更完整,内层更接近真实模型或工具;任意一层短路、重试或吞掉异常,都会改变外层最终看到的结果。
顺序设计至少要核对三组相互作用:日志是在敏感信息脱敏前还是后记录;计数限制统计原始请求,还是把重试后的真实调用也算进去;缓存命中以后,权限和审计是否仍然生效。没有明确答案时,这个列表只是在正常路径上碰巧工作,错误路径很可能绕开关键政策。
短路也不是写一句“直接 return”就结束了。node-style 通过允许的jump_to改变图流向,wrap-style 可以不调用 handler,直接返回缓存或拒绝结果。
无论走哪条路径,下游都要收到合法的消息或状态结构,审计层也应能区分真实执行、缓存命中、审批拒绝和策略阻断。
八、用一个退款 Agent 串起审批与错误边界
前面的边界放到一起后,代码需要证明两件事:issue_refund在真实执行前会进入人工审批;工具发生已知超时时,会转换成与原 tool call 配对的ToolMessage。示例没有自动重试退款写操作,因为能否重试取决于业务幂等合同。
fromcollections.abcimportCallablefromlangchain.agentsimportcreate_agentfromlangchain.agents.middlewareimportHumanInTheLoopMiddleware,wrap_tool_callfromlangchain.messagesimportToolMessagefromlangchain.toolsimporttoolfromlangchain.tools.tool_nodeimportToolCallRequestfromlangchain_openaiimportChatOpenAIfromlanggraph.checkpoint.memoryimportInMemorySaverfromlanggraph.typesimportCommand@tooldefissue_refund(order_id:str,amount:float)->str:"""提交退款;生产实现必须使用 order_id 或独立幂等键防止重复写入。"""returnf"退款已受理:{order_id},金额{amount:.2f}元"@wrap_tool_calldefconvert_known_tool_errors(request:ToolCallRequest,handler:Callable[[ToolCallRequest],ToolMessage|Command],)->ToolMessage|Command:"""执行工具并把已知超时转换为可被 Agent 继续处理的工具结果。"""try:returnhandler(request)exceptTimeoutError:returnToolMessage(content="退款服务暂时无响应;请先查询订单状态,不要直接重复提交。",tool_call_id=request.tool_call["id"],)model=ChatOpenAI(model="qwen3.7-plus",api_key="YOUR_API_KEY",base_url="YOUR_OPENAI_COMPATIBLE_ENDPOINT",)agent=create_agent(model=model,tools=[issue_refund],checkpointer=InMemorySaver(),middleware=[convert_known_tool_errors,HumanInTheLoopMiddleware(interrupt_on={"issue_refund":{"allowed_decisions":["approve","edit","reject"],}}),],)result=agent.invoke({"messages":[{"role":"user","content":"为订单 A-2048 退款 1280 元"}]},config={"configurable":{"thread_id":"refund-A-2048"}},)执行从用户消息进入 Agent loop。模型若选择issue_refund,工具请求先经过外层错误适配器,再被 Human-in-the-loop 拦下。
执行暂停在审批位置,checkpointer 保存当前线程;审批方批准、编辑参数或拒绝以后,恢复过程才可能进入真实退款工具。
如果工具抛出明确的TimeoutError,外层 Middleware 会生成带原tool_call_id的结果,工具调用与返回消息仍然保持配对。
这里有三个边界不能被“代码能跑”掩盖。首次调用返回的result可能表示中断状态,并不等于退款已经成功,应用层必须读取并呈现 interrupt。
错误转换以后,模型可以解释下一步,但订单状态仍要回业务系统查询。InMemorySaver也只适合本地演示,生产审批要使用能够跨进程恢复的 checkpointer。
把执行顺序连起来,就是“请求进入—策略拦截—人工决策—业务执行—合法结果—审计收口”。
tool call 只是模型提出的执行请求,审批决定是否放行,业务工具负责幂等写入,错误适配器维持消息合同,checkpointer 保存可恢复的中断状态。少了任何一层,都可能把“模型想退款”误当成“退款已完成”。
这套闭环可靠不可靠,要去拒绝、异常和恢复路径里验证,不能只看正常退款是否走通。
九、生产验收要检查正常路径,也要检查绕行路径
Middleware 上线前只跑一次成功示例,几乎发现不了真正危险的问题。拒绝、超时、重试、短路和恢复,才是执行政策最容易被绕开的地方。
可以沿着五组问题复核:
- 触发范围:策略按 invocation、model call 还是 tool call 生效,次数是否与预期一致?
- 组合顺序:脱敏、日志、计数、缓存、重试和审批的先后是否会产生绕行?
- 状态合同:返回字典、
Command、ExtendedModelResponse或ToolMessage是否匹配当前 hook,reducer 是否会正确合并? - 副作用边界:写工具失败后能否重试,是否存在幂等键,超时究竟代表失败还是结果未知?
- 恢复与审计:interrupt 能否跨请求恢复,拒绝和编辑是否留痕,用户是否能看到真实的等待与失败状态?
单元测试不必每次都拉起完整模型。直接构造 request、State 和假的 handler,就能检查 handler 被调用几次、override 是否正确、异常有没有转换,以及短路返回的结构是否合法。
到了集成测试,再覆盖真实 Agent loop 和 checkpointer 恢复。这样一来,策略本身的问题和整条执行链的问题不会混在一起。
十、回到主线:把执行政策放回它该在的位置
Middleware 并不是为了让 Agent 代码显得更“框架化”。日志、权限、审批、重试、上下文调整和调用限制同时出现时,系统需要一个不依赖模型记忆、也不用污染每个业务工具的执行位置,这才是它要解决的问题。
把全文收回一条判断链:先确认需求是否属于横切政策,再按触发粒度选择生命周期 hook。只观察或更新固定节点时用 node-style,需要控制真实调用时用 wrap-style。能复用内置能力就先复用,自定义层保持单一职责;最后从顺序、状态合同、异常传播、副作用和恢复路径验证组合。
做到这里,Agent loop 仍然是“模型决定—工具执行—结果返回”的循环,但执行政策已经不再散落。
接下来还有一个同样现实的问题:模型需要的答案并不总在当前上下文里。外部知识怎样进入这条循环,又怎样避免把“检索到了内容”误当成“拿到了可靠答案”?下一篇进入 Retrieval,继续处理这条知识接入边界。