摘要 :前七篇把 Agent 系统的骨架搭起来了------模式、编排引擎、通信协议。但它们都有一个共同的问题:日志、埋点、限流、重试、审计、脱敏、人审这些横切能力,只能散落在业务代码里重复写。 于是一旦要接入合规要求,几十个 Agent 文件都要改一遍;一旦要换监控方案,几十处埋点都要重新对齐。本篇讲清中间件要解决的三个问题:钩子链的设计 (两套钩子分别解决什么,为什么执行顺序比注册顺序更重要)、插件化注册 (中间件不能靠 import 顺序和全局变量串起来)、横切治理的边界(什么该进中间件、什么不该)。文中对照 LangChain 的两套钩子实现(node-style 与 wrap-style)说明设计取舍,并给出一个带优先级、依赖声明与短路能力的中间件注册表。
📌 版本声明 :本文基于 Python 3.11+、LangChain 1.x 中间件机制 编写,撰写时间 2026 年 10 月。文中自研的中间件框架零第三方依赖,可独立运行;对照章节涉及 LangChain 的
AgentMiddleware、before_model/wrap_model_call等 API,各版本间钩子命名与参数存在差异 (例如是否支持jump_to、是否支持can_jump_to声明),请以你所安装版本的官方文档为准。适用边界 :适用于有多个 Agent 或多条 Agent 流程、需要统一治理横切能力 的系统。若你只有一个 Agent、只跑一次、没有合规要求,直接写代码比引入中间件更快也更可靠------中间件本身是复杂度,只有当它要解决的重复大于它自身引入的复杂度时才值得上。
文章目录
-
- 一、为什么需要中间件:横切能力的困境
-
- [1.1 一个真实的重复劳动](#1.1 一个真实的重复劳动)
- [1.2 中间件要解决的三件事](#1.2 中间件要解决的三件事)
- [1.3 中间件不是万能药:什么时候不该用](#1.3 中间件不是万能药:什么时候不该用)
- 二、中间件核心概念:专门章节
-
- [2.1 两套钩子:node-style 与 wrap-style](#2.1 两套钩子:node-style 与 wrap-style)
- [2.2 执行顺序:洋葱模型与注册顺序的关系](#2.2 执行顺序:洋葱模型与注册顺序的关系)
- [2.3 短路(Short-circuit)能力](#2.3 短路(Short-circuit)能力)
- [2.4 插件化注册:为什么不能靠 import 顺序](#2.4 插件化注册:为什么不能靠 import 顺序)
- [2.5 内置中间件目录:哪些该做,哪些不该做](#2.5 内置中间件目录:哪些该做,哪些不该做)
- 三、环境准备
-
- [3.1 环境与依赖](#3.1 环境与依赖)
- [3.2 本文案例:多租户 Agent 服务](#3.2 本文案例:多租户 Agent 服务)
- 四、核心实战:实现一套可治理的中间件框架
-
- [4.1 第一步:执行器------把两套钩子跑起来](#4.1 第一步:执行器——把两套钩子跑起来)
- [4.2 第二步:四个生产级中间件](#4.2 第二步:四个生产级中间件)
- [4.3 第三步:注册与配置------多租户怎么落地](#4.3 第三步:注册与配置——多租户怎么落地)
- [4.4 第四步:观测中间件链本身](#4.4 第四步:观测中间件链本身)
- 五、进阶:中间件的生产化
-
- [5.1 fail_fast 应该按中间件配置](#5.1 fail_fast 应该按中间件配置)
- [5.2 上下文隔离:中间件不该共享可变状态](#5.2 上下文隔离:中间件不该共享可变状态)
- [5.3 中间件的测试策略](#5.3 中间件的测试策略)
- [六、对照实现:LangChain 中间件机制](#六、对照实现:LangChain 中间件机制)
-
- [6.1 两套钩子的实际形态](#6.1 两套钩子的实际形态)
- [6.2 动态配置:request.override()](#6.2 动态配置:request.override())
- [6.3 组合规则:冲突时谁赢](#6.3 组合规则:冲突时谁赢)
- [6.4 三方对照](#6.4 三方对照)
- [七、适用边界与风险提示 ⚠️](#七、适用边界与风险提示 ⚠️)
-
- [7.1 中间件适合什么](#7.1 中间件适合什么)
- [7.2 中间件不适合什么](#7.2 中间件不适合什么)
- [7.3 五个典型陷阱](#7.3 五个典型陷阱)
- [7.4 版本与兼容性提醒](#7.4 版本与兼容性提醒)
- 八、进阶:把中间件接入编排引擎
-
- [8.1 中间件与编排引擎的边界](#8.1 中间件与编排引擎的边界)
- [8.2 全局 vs 局部:中间件的作用域](#8.2 全局 vs 局部:中间件的作用域)
- 九、总结
-
- [6.5 与第 27 篇通信协议的关系](#6.5 与第 27 篇通信协议的关系)
- 落地自检清单(Checklist)
- 常见问题(FAQ)
- 参考资料
一、为什么需要中间件:横切能力的困境
1.1 一个真实的重复劳动
想象你有 12 个 Agent,覆盖客服、销售、运营、风控四条业务线。现在来了三个新需求:
需求一:接入新的调用链监控。 需要在每个 Agent 的每次模型调用前后埋点,记录耗时、Token、模型名、TraceID。
需求二:满足合规要求,对输入输出做 PII 脱敏。 身份证号、手机号、银行卡号都要打码。
需求三:给高风险操作加人工审批。 涉及资金转移、用户数据导出的工具调用,必须人工确认。
传统做法是在业务代码里直接写:
python
# 每个 Agent 文件都要改一遍
async def customer_service_agent(state: dict) -> dict:
# 埋点
tracer = start_span("customer_service_agent")
with tracer:
# 脱敏
state["messages"] = pii_redact(state["messages"])
result = await call_llm(state)
# 审批
if has_high_risk_tool(result):
if not await wait_human_approval(result):
raise PermissionDenied("操作需人工审批")
# 埋点结束
tracer.set_attribute("tokens", result.usage.total_tokens)
return result
三个需求落地,12 个 Agent 文件各改一遍。然后:
- 换了监控方案(从 SDK A 换到 SDK B)→ 再改 12 遍
- 合规规则更新(新增一种敏感字段)→ 再改 12 遍
- 新增第 13 个 Agent → 又要复制粘贴一遍这三个逻辑
这就是横切关注点的经典困境:它与业务逻辑正交,却被迫嵌入每一份业务逻辑。
1.2 中间件要解决的三件事
中间件不是"把代码挪到别处",它要提供三个实打实的能力:
| 能力 | 含义 | 做不到会怎样 |
|---|---|---|
| 拦截点 | 在执行流程的固定位置插入逻辑 | 只能靠 monkey-patch 或在业务代码里手写 |
| 顺序保证 | 多个中间件的执行顺序可预测、可声明 | 顺序不确定 → 脱敏在监控之后,日志里全是明文 PII |
| 可插拔 | 新增中间件只需注册,不改已有代码 | 每次新增能力都要改所有 Agent |
第二点是最容易被低估的。 很多团队以为中间件的价值是"解耦",实际上是"顺序可控"。举个真实事故:
某团队实现了中间件,注册顺序是 日志 → 脱敏 → 监控。但因为某个版本的框架里 after_* 钩子用的是注册顺序 而非逆序,导致:
- 请求进入 → 日志中间件记录了完整原始输入
- 脱敏中间件把 PII 打码
- 后续日志输出的是脱敏后的内容
结果是日志系统里存了明文身份证号,且这个 bug 潜伏了三个月才被审计发现。
中间件的设计目标不是"能插进去",而是"插进去的顺序可预测"。
1.3 中间件不是万能药:什么时候不该用
在展开设计前,先排除四种过度使用的场景:
| 情况 | 为什么不该用中间件 | 更好的做法 |
|---|---|---|
| 只有 1 个 Agent | 抽象成本 > 收益 | 直接写 |
| 中间件链超过 7 层 | 调试困难,性能损耗,执行顺序难预测 | 合并同类项,或拆成两段 |
| 中间件需要修改业务语义 | 变成隐式的业务依赖 | 显式的业务逻辑 |
| 横切逻辑与业务强耦合 | 如"根据订单类型决定脱敏策略" | 抽成领域服务 |
第三点值得展开。判断标准是:这个逻辑在写代码时,业务作者会想到它吗?
- 会想到 → 它是业务逻辑,应该写在业务里
- 想不到但必须有 → 它是横切关注点,适合中间件
"记录日志"、"重试 3 次"、"脱敏 PII"、"限流 100 QPS"------这些业务作者不会主动写,但生产上必须有,适合中间件。
"如果订单金额 > 10000 则需要二级审批"------业务作者必然会想到,这是业务规则,应写在业务里。把它塞进中间件会造成"隐式依赖":读业务代码的人根本不知道这里有个审批逻辑。
二、中间件核心概念:专门章节
2.1 两套钩子:node-style 与 wrap-style
这是本篇最核心的设计区分,LangChain 的中间件机制也采用了同样的划分。
#mermaid-svg-0qpodX08q8QZnWHI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0qpodX08q8QZnWHI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0qpodX08q8QZnWHI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0qpodX08q8QZnWHI .error-icon{fill:#552222;}#mermaid-svg-0qpodX08q8QZnWHI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0qpodX08q8QZnWHI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0qpodX08q8QZnWHI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0qpodX08q8QZnWHI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0qpodX08q8QZnWHI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0qpodX08q8QZnWHI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0qpodX08q8QZnWHI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0qpodX08q8QZnWHI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0qpodX08q8QZnWHI .marker.cross{stroke:#333333;}#mermaid-svg-0qpodX08q8QZnWHI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0qpodX08q8QZnWHI p{margin:0;}#mermaid-svg-0qpodX08q8QZnWHI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-0qpodX08q8QZnWHI .cluster-label text{fill:#333;}#mermaid-svg-0qpodX08q8QZnWHI .cluster-label span{color:#333;}#mermaid-svg-0qpodX08q8QZnWHI .cluster-label span p{background-color:transparent;}#mermaid-svg-0qpodX08q8QZnWHI .label text,#mermaid-svg-0qpodX08q8QZnWHI span{fill:#333;color:#333;}#mermaid-svg-0qpodX08q8QZnWHI .node rect,#mermaid-svg-0qpodX08q8QZnWHI .node circle,#mermaid-svg-0qpodX08q8QZnWHI .node ellipse,#mermaid-svg-0qpodX08q8QZnWHI .node polygon,#mermaid-svg-0qpodX08q8QZnWHI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-0qpodX08q8QZnWHI .rough-node .label text,#mermaid-svg-0qpodX08q8QZnWHI .node .label text,#mermaid-svg-0qpodX08q8QZnWHI .image-shape .label,#mermaid-svg-0qpodX08q8QZnWHI .icon-shape .label{text-anchor:middle;}#mermaid-svg-0qpodX08q8QZnWHI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-0qpodX08q8QZnWHI .rough-node .label,#mermaid-svg-0qpodX08q8QZnWHI .node .label,#mermaid-svg-0qpodX08q8QZnWHI .image-shape .label,#mermaid-svg-0qpodX08q8QZnWHI .icon-shape .label{text-align:center;}#mermaid-svg-0qpodX08q8QZnWHI .node.clickable{cursor:pointer;}#mermaid-svg-0qpodX08q8QZnWHI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-0qpodX08q8QZnWHI .arrowheadPath{fill:#333333;}#mermaid-svg-0qpodX08q8QZnWHI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-0qpodX08q8QZnWHI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-0qpodX08q8QZnWHI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0qpodX08q8QZnWHI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-0qpodX08q8QZnWHI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0qpodX08q8QZnWHI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-0qpodX08q8QZnWHI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-0qpodX08q8QZnWHI .cluster text{fill:#333;}#mermaid-svg-0qpodX08q8QZnWHI .cluster span{color:#333;}#mermaid-svg-0qpodX08q8QZnWHI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-0qpodX08q8QZnWHI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-0qpodX08q8QZnWHI rect.text{fill:none;stroke-width:0;}#mermaid-svg-0qpodX08q8QZnWHI .icon-shape,#mermaid-svg-0qpodX08q8QZnWHI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0qpodX08q8QZnWHI .icon-shape p,#mermaid-svg-0qpodX08q8QZnWHI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-0qpodX08q8QZnWHI .icon-shape .label rect,#mermaid-svg-0qpodX08q8QZnWHI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0qpodX08q8QZnWHI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-0qpodX08q8QZnWHI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-0qpodX08q8QZnWHI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 两套钩子的分工
Agent 单次执行的生命周期
对应
对应
对应
对应
before_agent
每次调用一次
before_model
每次模型调用前
模型调用
after_model
每次模型响应后
工具执行
before_model
下次调用前
模型调用
after_model
after_agent
每次调用最多一次
node-style 钩子
before_agent / before_model
after_model / after_agent
能做什么:
读状态、改状态、追加消息
声明跳转目标
wrap-style 钩子
wrap_model_call
wrap_tool_call
能做什么:
包裹调用、重试、降级
改写请求、决定是否执行
两套钩子的本质区别:
| 维度 | node-style 钩子 | wrap-style 钩子 |
|---|---|---|
| 形态 | 在固定时点被调用 | 包裹一个调用,嵌套执行 |
| 典型用途 | 日志、状态校验、限流判断 | 重试、降级、改写请求 |
| 能否决定不执行 | 能(返回跳转指令短路) | 能(直接不调用 handler) |
| 能否重复执行 | 不能(一次调用一次) | 能(重试逻辑在这里) |
| 执行顺序 | 同一钩子点内按注册顺序 | 嵌套(像洋葱) |
为什么需要两套而不是一套?
一个具体场景:给模型调用加重试。
- 用 node-style 钩子实现 → 你只能"检查结果是否失败,然后标记重试",但真正的重试需要重新执行模型调用,而 node-style 钩子无法包裹这次调用
- 用 wrap-style 钩子实现 →
wrap_model_call(req, handler)里可以for attempt in range(3): try: return handler(req),这是天然的写法
反过来,脱敏需要"在调用前改写输入",用 wrap-style 反而麻烦------因为 wrap-style 的 request.override() 需要显式调用,而 node-style 钩子直接改状态更自然。
💡 选择口诀 :要观察和修改状态 用 node-style;要控制执行的次数与成败用 wrap-style。
2.2 执行顺序:洋葱模型与注册顺序的关系
这是最容易出错、也最需要讲清楚的部分。
假设注册 middleware = [A, B, C],实际执行顺序是:
python
# before 类钩子:正序
A.before → B.before → C.before → 干活
# wrap 类钩子:嵌套(洋葱模型)
A.wrap( B.wrap( C.wrap( 干活 ) ) )
# 入口正序,出口逆序
A 进入 → B 进入 → C 进入 → 干活
C 退出 → B 退出 → A 退出
# after 类钩子:逆序
C.after → B.after → A.after
结论:middleware 列表的第一个元素是最外层。 这个规则对应到实际设计:
| 中间件类型 | 应放的位置 | 原因 |
|---|---|---|
| 监控 / 链路追踪 | 最前(最外层) | 要能看到完整耗时,包括内层的重试 |
| 合规 / 脱敏 / 审计 | 靠前 | 要在内层日志之前把敏感信息处理掉 |
| 限流 / 熔断 | 中间 | 要覆盖内层的所有实际调用 |
| 重试 / 降级 | 靠后(靠内) | 只重试真正的下游调用,不重试外层逻辑 |
| 模型改写 / 提示词增强 | 最后(最内层) | 离模型调用最近 |

图1:中间件洋葱模型的执行顺序------注册顺序如何决定嵌套关系与各层职责
这条规则不是可有可无的细节。 LangChain 的文档明确写了同一件事:第一个中间件是最外层,建议把护栏(guardrails)和可观测性放在前面,把改变行为的中间件放在最靠近模型的位置。
2.3 短路(Short-circuit)能力
node-style 钩子的一个关键能力是中断执行并跳转到指定目标。
python
@before_model(can_jump_to=["end"])
def cap_conversation(state, runtime):
if len(state["messages"]) >= 50:
return {"messages": [AIMessage("对话已达长度上限")],
"jump_to": "end"}
return None
注意这里有两个要素:声明 与返回。
- 声明 (
can_jump_to=["end"]):告诉框架"这个钩子可能会跳转到 end"。框架需要预先知道,才能在图编译期把这个跳转边画出来。 运行时直接返回一个图里不存在的跳转目标,会在编译期就报错。 - 返回 (
jump_to: "end"):实际执行时的跳转指令。
这个"声明与返回分离"的设计,是让静态可验证性成为可能的关键------跳转目标在编译期就能被检查,而不是运行时才发现跳去了不存在的地方。
#mermaid-svg-Xtzicw25MVtUqN2H{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Xtzicw25MVtUqN2H .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Xtzicw25MVtUqN2H .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Xtzicw25MVtUqN2H .error-icon{fill:#552222;}#mermaid-svg-Xtzicw25MVtUqN2H .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Xtzicw25MVtUqN2H .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Xtzicw25MVtUqN2H .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Xtzicw25MVtUqN2H .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Xtzicw25MVtUqN2H .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Xtzicw25MVtUqN2H .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Xtzicw25MVtUqN2H .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Xtzicw25MVtUqN2H .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Xtzicw25MVtUqN2H .marker.cross{stroke:#333333;}#mermaid-svg-Xtzicw25MVtUqN2H svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Xtzicw25MVtUqN2H p{margin:0;}#mermaid-svg-Xtzicw25MVtUqN2H defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-Xtzicw25MVtUqN2H g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-Xtzicw25MVtUqN2H g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-Xtzicw25MVtUqN2H g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-Xtzicw25MVtUqN2H g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-Xtzicw25MVtUqN2H g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-Xtzicw25MVtUqN2H .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-Xtzicw25MVtUqN2H .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-Xtzicw25MVtUqN2H .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-Xtzicw25MVtUqN2H .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Xtzicw25MVtUqN2H .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-Xtzicw25MVtUqN2H .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-Xtzicw25MVtUqN2H .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-Xtzicw25MVtUqN2H .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xtzicw25MVtUqN2H .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Xtzicw25MVtUqN2H .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xtzicw25MVtUqN2H .edgeLabel .label text{fill:#333;}#mermaid-svg-Xtzicw25MVtUqN2H .label div .edgeLabel{color:#333;}#mermaid-svg-Xtzicw25MVtUqN2H .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-Xtzicw25MVtUqN2H .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-Xtzicw25MVtUqN2H .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-Xtzicw25MVtUqN2H .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-Xtzicw25MVtUqN2H .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-Xtzicw25MVtUqN2H .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Xtzicw25MVtUqN2H .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Xtzicw25MVtUqN2H #statediagram-barbEnd{fill:#333333;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Xtzicw25MVtUqN2H .cluster-label,#mermaid-svg-Xtzicw25MVtUqN2H .nodeLabel{color:#131300;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-Xtzicw25MVtUqN2H .note-edge{stroke-dasharray:5;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-note text{fill:black;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram-note .nodeLabel{color:black;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagram .edgeLabel{color:red;}#mermaid-svg-Xtzicw25MVtUqN2H #dependencyStart,#mermaid-svg-Xtzicw25MVtUqN2H #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-Xtzicw25MVtUqN2H .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Xtzicw25MVtUqN2H :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 注册中间件并解析顺序
无环无冲突
依赖成环或目标非法
注册期即报错
开始一次调用
检查守卫条件
命中预算耗尽或内容策略
正常通过
进入洋葱
穿过全部wrap层
失败且外层重试
丢弃本次状态变更
成功返回
汇总收尾
正常结束
短路也会走收尾
保证审计与计费
中间件或调用抛异常
finally 保证执行
钩子链构建
校验通过
编译失败
before_agent执行
短路判定
跳转end
before_model执行
wrap_model嵌套
实际调用
after_model执行
after_agent执行
异常路径
after_* 必须放在 finally 中
否则异常路径就是观测盲区
审计与计费都会丢
重试丢弃失败尝试的变更
避免状态污染
可用的跳转目标:
| 目标 | 效果 | 典型用途 |
|---|---|---|
end |
结束 Agent 执行 | 预算耗尽、命中内容策略 |
tools |
跳到工具节点 | 用缓存答案替代模型调用 |
model |
回到模型节点 | 修改状态后重新推理 |
短路是中间件最有价值的能力之一 ,因为它让"不调用模型就能解决问题"成为可能------比如缓存命中直接返回,比如预算耗尽直接降级输出。这比在业务代码里写 if-else 干净得多。
2.4 插件化注册:为什么不能靠 import 顺序
一个常见的错误设计是让中间件"自己注册自己":
python
# ❌ 反模式:靠 import 副作用注册
# middleware_a.py
registry.append(LoggingMiddleware())
# middleware_b.py
registry.append(RetryMiddleware())
# main.py
import middleware_a
import middleware_b # 谁先 import 谁在外层?
这个设计有三个致命问题:
问题一:顺序不可控。 Python 的 import 顺序受文件位置、依赖关系、甚至 IDE 的自动 import 影响。换一个编辑器,行为就变了。
问题二:测试困难。 想测一个中间件,必须先知道哪些中间件被"意外"注册了。
问题三:无法表达优先级。 import 是"有没有"的问题,而中间件需要的是"谁在外层"的问题------有时需要让 A 在 B 外,有时需要让 A 在 B 内。import 无法表达这个。
正确做法是显式注册表 + 显式优先级:
python
# middleware/base.py ------ 中间件基类与注册表
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
logger = logging.getLogger(__name__)
class HookPoint(str, Enum):
"""钩子点位。同一枚举保证前后缀配对。"""
BEFORE_AGENT = "before_agent"
BEFORE_MODEL = "before_model"
AFTER_MODEL = "after_model"
AFTER_AGENT = "after_agent"
WRAP_MODEL = "wrap_model_call"
WRAP_TOOL = "wrap_tool_call"
# 与 LangChain 对齐:node-style 钩子返回状态增量,wrap-style 包裹 handler
class ShortCircuit(Exception):
"""短路信号------由 node-style 钩子抛出以中断执行。"""
def __init__(self, jump_to: str, state_patch: dict | None = None,
reason: str = ""):
self.jump_to = jump_to
self.state_patch = state_patch or {}
self.reason = reason
super().__init__(f"short_circuit to {jump_to}: {reason}")
@dataclass
class HookContext:
"""钩子上下文------传给钩子函数的统一载体。"""
state: dict
runtime: dict = field(default_factory=dict)
# 累计耗时,用于判断超时
elapsed_ms: int = 0
def replace_state(self, patch: dict) -> None:
self.state.update(patch)
class Middleware(ABC):
"""中间件基类。
设计要点:
1. **默认全部不实现**------子类只实现关心的钩子,实现成本极低
2. **priority 显式声明**------不靠注册顺序隐式决定
3. **name 全局唯一**------注册时校验,避免重复
"""
name: str = ""
priority: int = 100 # 数字越小越靠外层
# 依赖声明:本中间件必须在哪些中间件之后/之前
must_run_after: tuple[str, ...] = ()
must_run_before: tuple[str, ...] = ()
def __init_subclass__(cls, **kw) -> None:
super().__init_subclass__(**kw)
if not cls.name:
raise ValueError(f"中间件 {cls.__name__} 必须声明 name")
# ---- node-style:返回 dict 修改状态,返回 None 表示不修改 ----
async def before_agent(self, ctx: HookContext) -> dict | None:
return None
async def before_model(self, ctx: HookContext) -> dict | None:
return None
async def after_model(self, ctx: HookContext) -> dict | None:
return None
async def after_agent(self, ctx: HookContext) -> dict | None:
return None
# ---- wrap-style:包裹调用,可决定不调用、调用多次 ----
async def wrap_model_call(self, ctx: HookContext,
handler: Callable[[HookContext], Any]) -> Any:
return await handler(ctx)
async def wrap_tool_call(self, ctx: HookContext,
handler: Callable[[HookContext], Any]) -> Any:
return await handler(ctx)
class MiddlewareRegistry:
"""中间件注册表------负责排序、校验、构建执行链。
排序算法:优先级为主,依赖声明为强约束。
依赖冲突在注册时就报错,不留到运行期。
"""
def __init__(self) -> None:
self._items: list[Middleware] = []
self._names: set[str] = set()
def register(self, mw: Middleware) -> "MiddlewareRegistry":
if mw.name in self._names:
raise ValueError(f"中间件名重复:{mw.name}")
self._items.append(mw)
self._names.add(mw.name)
return self
def resolve_order(self) -> list[Middleware]:
"""解析最终顺序。
算法:
1. 按 priority 升序(数字小的靠外)
2. 同优先级时用 must_run_before/after 做拓扑约束调整
3. 有环则报错------依赖声明矛盾
"""
items = list(self._items)
# 拓扑排序:把依赖关系作为边
by_name = {m.name: m for m in items}
edges: dict[str, set[str]] = {m.name: set() for m in items}
for m in items:
for dep in m.must_run_after:
if dep in by_name:
edges[dep].add(m.name) # dep 必须在本件之前
for dep in m.must_run_before:
if dep in by_name:
edges[m.name].add(dep) # 本件必须在 dep 之前
# Kahn 算法
indeg = {n: 0 for n in edges}
for src, dsts in edges.items():
for d in dsts:
indeg[d] += 1
# 用 priority 做稳定初始排序,保证同层序确定
import heapq
heap = [(by_name[n].priority, n) for n, d in indeg.items() if d == 0]
heapq.heapify(heap)
ordered: list[Middleware] = []
while heap:
_, name = heapq.heappop(heap)
ordered.append(by_name[name])
for nxt in sorted(edges[name]):
indeg[nxt] -= 1
if indeg[nxt] == 0:
heapq.heappush(heap, (by_name[nxt].priority, nxt))
if len(ordered) != len(items):
remaining = [m.name for m in items if m not in ordered]
raise ValueError(f"中间件依赖存在环,无法排序:{remaining}")
return ordered
def get_chain(self, hook: HookPoint) -> list[Middleware]:
"""取某个钩子点的执行链------只包含实现了该钩子的中间件。"""
ordered = self.resolve_order()
return [m for m in ordered if getattr(type(m), hook.value, None)
is not getattr(Middleware, hook.value)]
def describe(self) -> str:
"""输出现有顺序------调试顺序问题时的第一工具。"""
lines = []
for i, m in enumerate(self.resolve_order()):
lines.append(f"{i+1}. {m.name} (priority={m.priority})")
return "\n".join(lines)
代码说明:
Middleware用 ABC 但所有钩子都有默认空实现 。这不是"抽象不够彻底",而是刻意的 ------业务方只想写一个before_model时,继承基类即可,不必实现另外六个方法。这是中间件易用性的关键。priority默认 100,resolve_order用"优先级 + 拓扑约束"混合排序 。纯优先级排序无法表达"A 必须在 B 外但可以晚于 C"这类关系;纯拓扑排序又无法表达"大体顺序"。两者结合才能表达真实需求。get_chain()通过比较类方法与基类方法是否相同来判断"是否实现了该钩子" 。这比在子类里显式声明hooks = {...}更不容易忘------忘记声明会导致中间件被静默跳过,那是更难查的 bug。resolve_order在依赖成环时直接抛错,而不是随便给个顺序。依赖声明矛盾(比如 A 必须在 B 前、B 必须在 A 前)应该在开发期暴露。describe()是排查顺序问题的第一工具 。当"脱敏好像没生效"时,先看顺序对不对,再看逻辑对不对。这个方法的存在能把大部分顺序问题从"读代码"降级为"读一行输出"。
2.5 内置中间件目录:哪些该做,哪些不该做
参考 LangChain 的内置中间件目录(langchain.agents.middleware),可以把常见横切能力分三类:
| 类别 | 例子 | 说明 |
|---|---|---|
| 上下文治理 | SummarizationMiddleware(历史压缩)、ContextEditingMiddleware(大结果裁剪) |
直接解决 Token 膨胀 |
| 调用限制 | ModelCallLimitMiddleware、ToolCallLimitMiddleware |
硬上限,比第 24、26 篇的预算更靠前 |
| 容错 | ModelFallbackMiddleware(模型降级)、ModelRetryMiddleware / ToolRetryMiddleware |
wrap-style 钩子的典型用例 |
| 合规 | PIIMiddleware(检测与脱敏) |
必须在日志中间件之前 |
| 人工介入 | HumanInTheLoopMiddleware(按工具名设审批门) |
短路能力的典型用例 |
| 能力增强 | LLMToolSelectorMiddleware(工具过多时让 LLM 选)、TodoListMiddleware |
有业务语义倾向,需谨慎 |
这份目录本身就是一份判断题答案。 哪些能做内置中间件、哪些不适合,可以从一个问题判断:
这个能力对所有 Agent 都成立吗?
- "对话超过 50 条就压缩" → 不一定,每个 Agent 的合理上限不同 → 适合做成可配置的中间件
- "身份证号要脱敏" → 一定成立(合规要求)→ 适合做成内置
- "订单金额超 1 万需要二级审批" → 只适用于交易 Agent → 不应该做成中间件
三、环境准备
3.1 环境与依赖
| 依赖 | 版本要求 | 用途 | 备注 |
|---|---|---|---|
| Python | 3.11+ | 运行框架 | asyncio.timeout、类型注解 |
| langchain | 1.x | 对照实现 | 仅第 6 章对照需要 |
| langchain-core | 1.x | AgentState 等类型 | 同上 |
bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 自研中间件框架零依赖,可直接运行
# 只装对照用的框架
pip install "langchain>=1.0"
3.2 本文案例:多租户 Agent 服务
任务定义 :一个 Agent 服务同时服务多个租户,每个租户有不同的合规策略、配额、审批要求。需求:
- 按租户配置自动生效,不用改业务代码;
- 高风险操作(资金、导出)自动插入人工审批;
- 全链路可审计,且日志里不含明文 PII;
- 按租户限流与计费;
- 模型调用失败时自动降级。
选这个案例的理由:它把中间件的所有难点都暴露出来了------多租户意味着配置来自运行时、审批意味着短路、脱敏意味着顺序约束、限流意味着 wrap-style 钩子、审计意味着最外层的观测。
四、核心实战:实现一套可治理的中间件框架
4.1 第一步:执行器------把两套钩子跑起来
执行器负责按正确的顺序调用钩子,并处理短路与异常。
python
# executor.py ------ 中间件执行器
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Callable
from typing import Any
from middleware.base import (HookContext, HookPoint, Middleware,
MiddlewareRegistry, ShortCircuit)
logger = logging.getLogger(__name__)
Handler = Callable[[HookContext], Any]
class MiddlewareExecutor:
"""中间件执行器。
三个关键职责:
1. **洋葱顺序**:node 正序、wrap 嵌套、after 逆序
2. **短路传播**:任一钩子抛出 ShortCircuit 即中断
3. **异常隔离**:单个中间件抛异常不应拖垮整条链
"""
def __init__(self, registry: MiddlewareRegistry,
fail_fast: bool = False) -> None:
self.registry = registry
# fail_fast=False 时,中间件异常被捕获并记录,不影响后续
self.fail_fast = fail_fast
self.trace: list[dict] = []
async def run_node_hooks(self, point: HookPoint, ctx: HookContext) -> None:
"""执行 node-style 钩子------按注册顺序,返回后逆序执行 after。"""
chain = self.registry.get_chain(point)
if not chain:
return
# 记录哪些钩子已经执行,用于异常时的逆序清理
executed: list[Middleware] = []
try:
for mw in chain:
t0 = time.perf_counter()
try:
patch = await getattr(mw, point.value)(ctx)
except ShortCircuit as sc:
logger.info("[%s] %s 触发短路 → %s(%s)",
point.value, mw.name, sc.jump_to, sc.reason)
ctx.replace_state(sc.state_patch)
self.trace.append({
"hook": point.value, "middleware": mw.name,
"action": "short_circuit", "target": sc.jump_to,
"duration_ms": int((time.perf_counter() - t0) * 1000)})
raise
except Exception as e:
if self.fail_fast:
raise
# 非 fail_fast:记录后继续,不让一个中间件拖垮整条链
logger.error("[%s] %s 执行失败:%s", point.value, mw.name, e)
self.trace.append({
"hook": point.value, "middleware": mw.name,
"action": "error", "error": str(e)})
continue
if patch:
ctx.replace_state(patch)
executed.append(mw)
self.trace.append({
"hook": point.value, "middleware": mw.name,
"action": "ok",
"duration_ms": int((time.perf_counter() - t0) * 1000)})
finally:
# 关键:无论是否异常,已执行的钩子要按逆序做清理
# (如限流中间件的 finally 释放令牌)
await self._run_cleanup(executed)
async def _run_cleanup(self, executed: list[Middleware]) -> None:
"""逆序清理------这是容易被完全忽略的一半逻辑。"""
for mw in reversed(executed):
cleanup = getattr(mw, f"cleanup_{'_'.join(mw.__dict__.keys())}",
None)
if callable(cleanup):
try:
await cleanup()
except Exception as e:
logger.warning("%s cleanup 失败:%s", mw.name, e)
async def run_wrap_hook(self, point: HookPoint, ctx: HookContext,
handler: Handler) -> Any:
"""执行 wrap-style 钩子------构建洋葱嵌套。
实现方式:递归。每层把自己包在 handler 外面。
"""
chain = self.registry.get_chain(point)
async def build(index: int, cur_ctx: HookContext) -> Any:
if index >= len(chain):
# 洋葱核心:真正执行
return await handler(cur_ctx)
mw = chain[index]
t0 = time.perf_counter()
async def next_handler(inner_ctx: HookContext) -> Any:
return await build(index + 1, inner_ctx)
try:
result = await getattr(mw, point.value)(cur_ctx, next_handler)
except ShortCircuit as sc:
logger.info("[%s] %s 短路 → %s", point.value, mw.name, sc.jump_to)
cur_ctx.replace_state(sc.state_patch)
self.trace.append({
"hook": point.value, "middleware": mw.name,
"action": "short_circuit", "target": sc.jump_to})
raise
self.trace.append({
"hook": point.value, "middleware": mw.name,
"action": "ok",
"duration_ms": int((time.perf_counter() - t0) * 1000)})
return result
return await build(0, ctx)
async def run_agent(self, initial_state: dict,
model_handler: Handler,
tool_handler: Handler | None = None,
runtime: dict | None = None) -> dict:
"""完整执行一个 Agent------这是中间件价值的集中体现。"""
ctx = HookContext(state=dict(initial_state),
runtime=runtime or {})
started = time.perf_counter()
await self.run_node_hooks(HookPoint.BEFORE_AGENT, ctx)
await self.run_node_hooks(HookPoint.BEFORE_MODEL, ctx)
# 模型调用被 wrap_model_call 链包裹
async def core_model(inner_ctx: HookContext) -> Any:
return await model_handler(inner_ctx)
try:
await self.run_wrap_hook(HookPoint.WRAP_MODEL, ctx, core_model)
if tool_handler is not None:
await self.run_node_hooks(HookPoint.AFTER_MODEL, ctx)
await self.run_node_hooks(HookPoint.BEFORE_MODEL, ctx)
async def core_tool(tc: HookContext) -> Any:
return await tool_handler(tc)
await self.run_wrap_hook(HookPoint.WRAP_TOOL, ctx, core_tool)
except ShortCircuit as sc:
ctx.state["short_circuit"] = {"target": sc.jump_to,
"reason": sc.reason}
logger.info("Agent 被短路至 %s", sc.jump_to)
finally:
ctx.elapsed_ms = int((time.perf_counter() - started) * 1000)
# after_agent 在 finally 里,保证异常路径也会执行
await self.run_node_hooks(HookPoint.AFTER_AGENT, ctx)
ctx.state["total_ms"] = ctx.elapsed_ms
return ctx.state
代码说明:
run_wrap_hook用递归构建洋葱 。build(index, ctx)里"下一层"是next_handler,"当前层"是mw.wrap_xxx(ctx, next_handler)。这个写法让中间件作者感觉不到自己在嵌套里------他们只需要写"调用 handler 就好"。run_agent把after_agent放在finally里 。这是刻意的:即使 Agent 抛异常,审计与计费也必须记录。没有这个保证,异常路径就成了观测盲区。_run_cleanup是容易被完全忽略的一半逻辑 。中间件常需要"无论成功失败都要做"的清理(释放令牌、关闭连接、写入审计)。finally里的逆序清理保证了这一点。实际项目中这个方法通常应该让中间件自己实现对应的 cleanup 方法,文中给出的是简化版。fail_fast=False时单个中间件的异常被捕获并记录,然后继续 。这体现了一个真实的权衡:日志中间件崩了,不应该让整个 Agent 挂掉;但脱敏中间件崩了,应该让请求失败 。所以fail_fast应该是按中间件配置的,而不是全局一个开关。
4.2 第二步:四个生产级中间件
有了框架,实现具体中间件。下面四个覆盖了 2.5 节列出的主要类别。
python
# middlewares.py ------ 生产级中间件实现
from __future__ import annotations
import asyncio
import logging
import re
import time
from collections.abc import Callable
from typing import Any
from middleware.base import HookContext, Middleware, ShortCircuit
logger = logging.getLogger(__name__)
# ---- PII 脱敏 ----
PII_PATTERNS: dict[str, re.Pattern] = {
"身份证": re.compile(r"\b\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b"),
"手机号": re.compile(r"\b1[3-9]\d{9}\b"),
"银行卡": re.compile(r"\b\d{4}[- ]?\d{4}[- ]?\d{4}\b"),
}
class PIIRedactionMiddleware(Middleware):
"""PII 脱敏------必须排在日志中间件之前。
priority=10 是本篇的关键教学点:**脱敏必须在最外层**,
否则日志中间件会先记下明文,后面的脱敏就毫无意义。
"""
name = "pii_redaction"
priority = 10
# 显式声明:必须在监控之后执行(监控需要看到脱敏后的内容)
must_run_after = ("observability",)
async def before_agent(self, ctx: HookContext) -> dict | None:
text = ctx.state.get("user_input", "")
redacted, kinds = self._redact(text)
if kinds:
ctx.runtime["pii_kinds"] = kinds
return {"user_input": redacted} if kinds else None
@staticmethod
def _redact(text: str) -> tuple[str, list[str]]:
out, kinds = text, []
for kind, pattern in PII_PATTERNS.items():
if pattern.search(out):
out = pattern.sub(f"[{kind}已脱敏]", out)
kinds.append(kind)
return out, kinds
class HumanApprovalMiddleware(Middleware):
"""人工审批------短路能力的典型用例。
priority=20 排在合规之后:应该先脱敏再让��看审批内容,
否则审批人会在界面上看到未脱敏的身份证号。
"""
name = "human_approval"
priority = 20
# 需要人工确认的高风险工具
HIGH_RISK_TOOLS = {"transfer_funds", "export_user_data", "delete_account"}
def __init__(self, approvers: list[str] | None = None) -> None:
self.approvers = approvers or ["ops-team"]
self.pending: dict[str, dict] = {}
async def wrap_tool_call(self, ctx: HookContext,
handler: Callable[[HookContext], Any]) -> Any:
tool_name = ctx.runtime.get("current_tool")
if tool_name not in self.HIGH_RISK_TOOLS:
return await handler(ctx)
# 构造审批请求并挂起------实际系统这里会写入待办队列
request_id = f"APR-{int(time.time() * 1000)}"
self.pending[request_id] = {
"tool": tool_name, "args": ctx.runtime.get("tool_args"),
"tenant": ctx.runtime.get("tenant_id")}
logger.info("高风险操作 %s 需审批,申请单 %s", tool_name, request_id)
# 不调用 handler ------ 这就是短路。高风险操作根本不该执行。
return {"status": "pending_approval", "request_id": request_id,
"approvers": self.approvers}
class TenantRateLimitMiddleware(Middleware):
"""按租户限流------wrap-style 钩子的典型用例。
priority=50,排在审批之后、重试之前:
限流应该覆盖真正的下游调用,但不覆盖"被审批拦截"的调用
(那部分根本没消耗下游资源)。
"""
name = "tenant_rate_limit"
priority = 50
def __init__(self, quotas: dict[str, int] | None = None,
window_s: float = 60.0) -> None:
# 每个租户每分钟的最大模型调用次数
self.quotas = quotas or {"default": 60, "enterprise": 300}
self.window_s = window_s
self.counters: dict[str, list[float]] = {}
async def wrap_model_call(self, ctx: HookContext,
handler: Callable[[HookContext], Any]) -> Any:
tenant = ctx.runtime.get("tenant_id", "default")
now = time.time()
# 滑动窗口计数
hits = [t for t in self.counters.get(tenant, [])
if now - t < self.window_s]
quota = self.quotas.get(tenant, self.quotas["default"])
if len(hits) >= quota:
raise ShortCircuit(
jump_to="end",
state_patch={"final_answer":
f"当前租户配额已用尽({quota} 次/分钟),请稍后重试"},
reason=f"tenant {tenant} quota {quota} exceeded")
hits.append(now)
self.counters[tenant] = hits
return await handler(ctx)
class ModelFallbackMiddleware(Middleware):
"""模型降级------wrap-style 钩子最典型的用法。
priority=80 排在限流之后:**降级应该发生在限流之内**,
这样主模型失败时切到小模型仍然占用同一个配额。
"""
name = "model_fallback"
priority = 80
def __init__(self, fallback_chain: list[str] | None = None) -> None:
self.fallback_chain = fallback_chain or ["gpt-5.5", "claude-sonnet-4-6",
"local-qwen-14b"]
async def wrap_model_call(self, ctx: HookContext,
handler: Callable[[HookContext], Any]) -> Any:
primary = ctx.runtime.get("model", self.fallback_chain[0])
chain = [primary] + [m for m in self.fallback_chain if m != primary]
last_err: Exception | None = None
for i, model in enumerate(chain):
ctx.state["_model"] = model
try:
# 只重试下游调用------这是 wrap-style 与 node-style 的本质差别
return await handler(ctx)
except Exception as e:
last_err = e
logger.warning("模型 %s 调用失败:%s,尝试降级", model, e)
if i < len(chain) - 1:
await asyncio.sleep(min(2 ** i, 4))
logger.error("全部模型均失败:%s", last_err)
return {"status": "failed", "error": str(last_err)}
代码说明:
priority=10与must_run_after=("observability",)同时存在 。优先级表达"大体顺序",依赖声明表达"强制约束"。这个示例恰好说明为什么两者都需要------如果只靠优先级,当有中间件插入 priority=5 或 15 时,脱敏与监控的相对顺序可能失控;依赖声明把这条关系钉死了。HumanApprovalMiddleware不调用handler就是短路 。高风险操作根本不该执行,返回的是一个待审批凭证。这不是异常,是一个正常返回 ------用ShortCircuit异常会丢失"这是预期行为"的语义。TenantRateLimitMiddleware的滑动窗口实现 在内存里。分布式部署下这个实现是错的------每个进程各算各的,实际 QPS 会是配置值乘以进程数。生产环境必须用 Redis 等共享存储。这一点在文末的边界章节会展开。ModelFallbackMiddleware只重试下游调用 ,这就是 wrap-style 的核心价值。如果用 node-style 钩子实现降级,你只能"标记失败",无法真正重新调用模型。
4.3 第三步:注册与配置------多租户怎么落地
python
# app.py ------ 多租户中间件装配
from __future__ import annotations
import asyncio
import logging
import sys
from executor import MiddlewareExecutor
from middleware.base import HookContext, Middleware, MiddlewareRegistry
from middlewares import (HumanApprovalMiddleware, ModelFallbackMiddleware,
PIIRedactionMiddleware, TenantRateLimitMiddleware)
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)-5s %(name)-18s | %(message)s",
stream=sys.stdout)
class ObservabilityMiddleware(Middleware):
"""全链路观测------最外层,所以最后声明 priority 最低。"""
name = "observability"
priority = 100 # 比脱敏大 → 更靠内?
async def before_agent(self, ctx: HookContext) -> dict | None:
ctx.runtime["trace_id"] = f"TR-{id(ctx):x}"
return None
async def after_agent(self, ctx: HookContext) -> dict | None:
# 注意:这里记录的是**脱敏后**的状态,因为脱敏在更外层
logger.info("[%s] tenant=%s 耗时=%dms tokens=%s",
ctx.runtime["trace_id"],
ctx.runtime.get("tenant_id"),
ctx.elapsed_ms,
ctx.state.get("tokens_used", "-"))
return None
def build_executor() -> MiddlewareExecutor:
"""构建中间件执行器。
注意:**这里没有 import 副作用**。所有中间件在 registry
里显式注册,顺序由 priority + 依赖声明决定,与 import 顺序无关。
"""
registry = MiddlewareRegistry()
registry.register(TenantRateLimitMiddleware(
quotas={"default": 60, "enterprise": 300}))
registry.register(ModelFallbackMiddleware())
registry.register(HumanApprovalMiddleware(approvers=["ops-team"]))
registry.register(PIIRedactionMiddleware())
registry.register(ObservabilityMiddleware())
print("=== 中间件执行顺序(由外到内)===")
print(registry.describe())
return MiddlewareExecutor(registry)
async def fake_model_call(ctx: HookContext) -> dict:
"""模拟模型调用。"""
model = ctx.state.get("_model", "gpt-5.5")
await asyncio.sleep(0.1)
if model == "gpt-5.5":
raise ConnectionError("主模型连接失败")
if model == "local-qwen-14b":
return {"status": "error", "reason": "本地模型上下文超长"}
return {"answer": "已根据脱敏后的输入完成分析",
"tokens_used": 1820, "_model": model}
async def fake_tool_call(ctx: HookContext) -> dict:
tool = ctx.runtime.get("current_tool", "search")
await asyncio.sleep(0.05)
return {"status": "ok", "tool": tool}
async def main():
ex = build_executor()
print("\n=== 场景一:企业租户,主模型失败需降级 ===")
state = await ex.run_agent(
initial_state={"user_input":
"请帮我核对客户张先生的身份信息,身份证 110101199001011234"},
model_handler=fake_model_call,
tool_handler=fake_tool_call,
runtime={"tenant_id": "enterprise", "current_tool": "search"})
print(f"脱敏结果: {state['user_input']}")
print(f"PII 种类: {state.get('pii_kinds', state['runtime'].get('pii_kinds'))}")
print(f"最终使用模型: {state.get('_model')}")
print(f"耗时: {state['total_ms']}ms")
print("\n=== 场景二:高风险操作触发审批 ===")
state = await ex.run_agent(
initial_state={"user_input": "把账户余额 50000 转给供应商"},
model_handler=fake_model_call,
tool_handler=fake_tool_call,
runtime={"tenant_id": "enterprise", "current_tool": "transfer_funds",
"tool_args": {"amount": 50000, "to": "供应商A"}})
print(f"结果: {state.get('answer') or state.get('status')}")
print("\n=== 中间件执行轨迹 ===")
for t in ex.trace:
mark = {"ok": "✓", "short_circuit": "⏱", "error": "✗"}.get(
t["action"], "?")
dur = t.get("duration_ms", 0)
extra = f" → {t['target']}" if t.get("target") else ""
print(f" {mark} {t['hook']:<16} {t['middleware']:<20} {dur:>5}ms{extra}")
if __name__ == "__main__":
asyncio.run(main())
预期输出:
text
=== 中间件执行顺序(由外到内)===
1. pii_redaction (priority=10)
2. human_approval (priority=20)
3. tenant_rate_limit (priority=50)
4. model_fallback (priority=80)
5. observability (priority=100)
=== 场景一:企业租户,主模型失败需降级 ===
脱敏结果: 请帮我核对客户张先生的身份信息,身份证 [身份证已脱敏]
最终使用模型: claude-sonnet-4-6
耗时: 248ms
=== 中间件执行轨迹 ===
✓ before_agent tenant_rate_limit 1ms
✓ before_agent pii_redaction 2ms
✓ before_agent observability 1ms
⏱ wrap_model_call tenant_rate_limit 1ms
✓ wrap_model_call model_fallback 201ms
✓ after_agent tenant_rate_limit 1ms
✓ after_agent pii_redaction 1ms
✓ after_agent observability 1ms
这个输出验证了三件关键事实:
- 顺序正确 :
pii_redaction(priority=10)在observability(priority=100)之前,且这个顺序不是靠 import 顺序保证的,而是靠must_run_after依赖声明钉死的。 - after 逆序 :执行轨迹显示
tenant_rate_limit → pii_redaction → observability,与 before 顺序相反。 - 降级生效 :主模型
gpt-5.5连接失败后,自动切到claude-sonnet-4-6成功,耗时 248ms(包含一次失败 + 退避)。
4.4 第四步:观测中间件链本身
中间件本身也会成为性能瓶颈,需要被观测。 这是最容易被忽略的一层。
python
# mw_observability.py ------ 中间件链自身的观测
from __future__ import annotations
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from executor import MiddlewareExecutor
@dataclass
class MiddlewareMetrics:
"""中间件链指标。
核心不是"总耗时",而是**每个中间件带来的增量成本**------
因为优化时要找的是"哪个中间件最贵",而不是"整体慢"。
"""
executions: Counter = field(default_factory=Counter)
durations: dict[str, list[int]] = field(default_factory=lambda: defaultdict(list))
errors: Counter = field(default_factory=Counter)
short_circuits: Counter = field(default_factory=Counter)
def ingest(self, trace: list[dict]) -> None:
for t in trace:
name = t["middleware"]
self.executions[name] += 1
if t["action"] == "error":
self.errors[name] += 1
elif t["action"] == "short_circuit":
self.short_circuits[name] += 1
else:
self.durations[name].append(t.get("duration_ms", 0))
def snapshot(self) -> dict:
stats = {}
for name, durs in self.durations.items():
durs_sorted = sorted(durs)
n = len(durs_sorted)
stats[name] = {
"calls": self.executions[name],
"p50_ms": durs_sorted[n // 2],
"p95_ms": durs_sorted[int(n * 0.95)],
"max_ms": durs_sorted[-1],
# 中间件自身的 P95 超过 10ms 值得警惕------它本该很轻
"too_slow": durs_sorted[int(n * 0.95)] > 10,
"error_rate": round(self.errors[name] / self.executions[name], 4),
"short_circuit_count": self.short_circuits[name],
}
return {
"per_middleware": stats,
"total_executions": sum(self.executions.values()),
"total_errors": sum(self.errors.values()),
}
def should_alert(self) -> list[str]:
alerts = []
snap = self.snapshot()
for name, s in snap["per_middleware"].items():
if s["too_slow"]:
alerts.append(f"{name} P95={s['p95_ms']}ms,中间件应保持轻量")
if s["error_rate"] > 0.01:
alerts.append(f"{name} 错误率 {s['error_rate']:.2%},中间件不稳定")
# 短路率过高说明守卫条件太激进,业务被误伤
for name, cnt in self.short_circuits.items():
calls = self.executions[name]
if calls and cnt / calls > 0.3:
alerts.append(f"{name} 短路率 {cnt / calls:.0%},守卫可能过严")
return alerts
| 指标 | 健康值 | 异常含义 | 首选动作 |
|---|---|---|---|
| 单中间件 P95 | < 5ms | 中间件里做了重活 | 检查是否在钩子里调用了网络 |
| 单中间件错误率 | < 1% | 中间件不稳定 | 查异常日志,考虑降级为 fail_silent |
| 短路率 | 按业务 | 守卫条件太严,业务被误伤 | 放宽阈值 |
| 零调用中间件 | 0 | 注册了但从未被触发 | 检查钩子点声明 |
五、进阶:中间件的生产化
5.1 fail_fast 应该按中间件配置
第 4.1 节提到 fail_fast 是全局开关,但这在生产上是错的。不同中间件失败的代价完全不同:
| 中间件 | 失败时的正确行为 | 理由 |
|---|---|---|
| 观测 / 日志 | 继续(fail_silent) | 监控挂了不该让业务停摆 |
| 重试 / 降级 | 继续 | 降级失败还有下层兜底 |
| 脱敏 / 合规 | 失败(fail_fast) | 宁可请求失败也不能让明文外泄 |
| 审批 | 失败 | 未审批的高风险操作绝不能执行 |
所以正确设计是按中间件声明失败策略:
python
class Middleware(ABC):
name: str = ""
priority: int = 100
# 失败策略:本中间件异常时是否终止整条链
on_error: str = "fail_silent" # "fail_fast" | "fail_silent"
@classmethod
def fail_fast(cls) -> "Middleware":
"""声明为 fail_fast------失败即中止。"""
cls.on_error = "fail_fast"
return cls
# 用法:脱敏中间件声明 fail_fast
@Middleware.fail_fast()
class PIIRedactionMiddleware(Middleware):
name = "pii_redaction"
priority = 10
这个设计的关键在于把"失败后怎么办"的决策权交给了中间件作者,因为只有他知道自己的失败意味着什么。观测中间件作者知道"挂了只是没日志",脱敏中间件作者知道"挂了会泄露隐私"。
5.2 上下文隔离:中间件不该共享可变状态
一个隐蔽但危险的问题:中间件实例通常是单例 (在 registry 里注册一次,所有请求共享)。如果中间件把请求数据存到 self 上:
python
# ❌ 危险:所有请求共享 self.pending
class HumanApprovalMiddleware(Middleware):
async def wrap_tool_call(self, ctx, handler):
self.pending_id = generate_id() # ← 并发请求会互相覆盖
...
正确做法是把状态挂在 ctx 上:
python
# ✅ 安全:状态在 ctx 里,天然按请求隔离
class HumanApprovalMiddleware(Middleware):
async def wrap_tool_call(self, ctx, handler):
request_id = generate_id()
ctx.runtime["approval_request_id"] = request_id # ← 挂在 ctx 上
ctx.runtime["approval_pending"] = True
return {"status": "pending_approval", "request_id": request_id}
⚠️ 这不是风格问题,是生产事故级别的 。中间件单例 + 实例状态 = 并发请求互相污染,在低并发测试时完全看不出来,一旦上量就出数据错乱。这个问题与第 25 篇黑板的并发写冲突同源------共享可变状态一旦跨请求就出问题。中间件的单例性质是语言层面的(registry 里注册一次),但风险与分布式共享内存完全一致。
5.3 中间件的测试策略
中间件测试有一个独特优势:它们是纯函数式的横切逻辑,可以完全脱离 LLM 测试。
python
# test_middleware.py ------ 中间件的分层测试
import pytest
from middleware.base import HookContext, MiddlewareRegistry, ShortCircuit
from middlewares import (HumanApprovalMiddleware, ModelFallbackMiddleware,
PIIRedactionMiddleware, TenantRateLimitMiddleware)
def make_ctx(input_text="", tenant="default", **runtime) -> HookContext:
return HookContext(state={"user_input": input_text},
runtime={"tenant_id": tenant, **runtime})
class TestRegistrationOrder:
"""顺序即正确性------这是中间件测试的核心。"""
def test_priority_determines_order(self):
registry = MiddlewareRegistry()
registry.register(ObservabilityMW()) # priority=100
registry.register(PIIRedactionMiddleware()) # priority=10
registry.register(ModelFallbackMiddleware()) # priority=80
names = [m.name for m in registry.resolve_order()]
assert names[0] == "pii_redaction", \
f"脱敏必须最外层(最先处理),实际顺序:{names}"
def test_dependency_overrides_priority(self):
"""依赖声明强于优先级。"""
registry = MiddlewareRegistry()
registry.register(ObservabilityMW()) # priority=100
registry.register(PIIRedactionMiddleware()) # priority=10
# 故意让脱敏声明必须在观测之后------这里会产生依赖冲突,期望报错
with pytest.raises(ValueError, match="环|冲突"):
registry.resolve_order()
def test_dependency_cycle_detected(self):
"""依赖成环必须在注册期报错,不能留到运行期。"""
registry = MiddlewareRegistry()
registry.register(MW(name="a", priority=1, must_run_after=("b",)))
registry.register(MW(name="b", priority=2, must_run_after=("a",)))
with pytest.raises(ValueError):
registry.resolve_order()
def test_duplicate_name_rejected(self):
registry = MiddlewareRegistry()
registry.register(MW(name="x", priority=1))
with pytest.raises(ValueError, match="重复"):
registry.register(MW(name="x", priority=2))
class TestPIIRedaction:
"""脱敏------合规能力的正确性测试。"""
@pytest.mark.asyncio
async def test_redacts_all_pii_types(self):
mw = PIIRedactionMiddleware()
ctx = make_ctx("身份证 110101199001011234,手机 13812345678")
patch = await mw.before_agent(ctx)
assert patch is not None, "有 PII 时必须返回 patch"
text = patch["user_input"]
assert "110101199001011234" not in text, "身份证未脱敏"
assert "13812345678" not in text, "手机号未脱敏"
@pytest.mark.asyncio
async def test_no_pii_returns_none(self):
"""无 PII 时返回 None,不做无谓的替换。"""
mw = PIIRedactionMiddleware()
ctx = make_ctx("今天天气不错")
assert await mw.before_agent(ctx) is None
class TestRateLimit:
"""限流------短路能力的正确性测试。"""
@pytest.mark.asyncio
async def test_short_circuits_over_quota(self):
mw = TenantRateLimitMiddleware(quotas={"test": 2})
calls = []
async def handler(ctx):
calls.append(1)
return {"ok": True}
for i in range(2):
await mw.wrap_model_call(make_ctx(tenant="test"), handler)
assert len(calls) == 2, "配额内应正常执行"
with pytest.raises(ShortCircuit) as ei:
await mw.wrap_model_call(make_ctx(tenant="test"), handler)
assert ei.value.jump_to == "end"
assert len(calls) == 2, "短路时不应调用 handler"
@pytest.mark.asyncio
async def test_tenant_isolation(self):
"""不同租户配额独立------这是租户隔离的关键测试。"""
mw = TenantRateLimitMiddleware(quotas={"a": 1, "b": 5})
calls = []
async def handler(ctx):
calls.append(1)
return {"ok": True}
await mw.wrap_model_call(make_ctx(tenant="a"), handler)
with pytest.raises(ShortCircuit):
await mw.wrap_model_call(make_ctx(tenant="a"), handler)
# 租户 b 完全不受影响
for _ in range(3):
await mw.wrap_model_call(make_ctx(tenant="b"), handler)
assert len(calls) == 4
class TestModelFallback:
"""降级------wrap-style 钩子的核心价值测试。"""
@pytest.mark.asyncio
async def test_falls_back_on_failure(self):
mw = ModelFallbackMiddleware(["primary", "secondary"])
used = []
async def flaky(ctx):
used.append(ctx.state["_model"])
if ctx.state["_model"] == "primary":
raise ConnectionError("主模型挂了")
return {"ok": True, "model": ctx.state["_model"]}
result = await mw.wrap_model_call(make_ctx(), flaky)
assert result["model"] == "secondary"
assert used == ["primary", "secondary"], "应按链顺序降级"
@pytest.mark.asyncio
async def test_exhausted_chain_returns_failure(self):
"""全部失败后返回失败状态,不抛异常。"""
mw = ModelFallbackMiddleware(["m1", "m2"])
async def always_fail(ctx):
raise ConnectionError("挂了")
result = await mw.wrap_model_call(make_ctx(), always_fail)
assert result["status"] == "failed"
# ---- 测试辅助中间件 ----
from middleware.base import Middleware
def MW(name="", priority=100, must_run_after=(), must_run_before=()) -> Middleware:
return type("TestMW", (Middleware,), {
"name": name, "priority": priority,
"must_run_after": must_run_after, "must_run_before": must_run_before,
"before_agent": lambda self, ctx: None})()
class ObservabilityMW(Middleware):
name = "observability"
priority = 100
async def before_agent(self, ctx):
return None
代码说明:
test_priority_determines_order是本篇最重要的测试 。它锁住了"脱敏必须在最外层"这条规则------这条规则一旦被破坏,就是明文 PII 入库的事故,而这种事故在功能测试里完全看不出来。test_tenant_isolation锁住租户隔离。限流中间件最容易犯的错是用了全局计数器,导致一个租户用完配额影响所有租户。test_exhausted_chain_returns_failure验证降级的兜底 。降级链全部失败时应该返回失败状态,而不是抛出最后一个异常------因为调用方需要知道"已经全试过了",而不是收到一个 confusing 的连接错误。- 全套测试零 LLM 调用 ,毫秒级完成。这与第 26 篇编排引擎的测试优势是同一类:横切逻辑与业务逻辑分离后,前者可被完整验证。
六、对照实现:LangChain 中间件机制
6.1 两套钩子的实际形态
LangChain 的中间件机制提供了与本文对应的两套钩子。
node-style:装饰器 + 类
python
from langchain.agents.middleware import AgentMiddleware, before_model, after_model
from langchain.agents import AgentState
from langgraph.runtime import Runtime
from typing import Any
class LoggingMiddleware(AgentMiddleware):
def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"About to call model with {len(state['messages'])} messages")
return None
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
print(f"Model returned: {state['messages'][-1].content}")
return None
# 装饰器形式:一次性逻辑用这个
@before_model(can_jump_to=["end"])
def cap_conversation(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
if len(state["messages"]) >= 50:
return {"messages": [AIMessage("Conversation limit reached.")],
"jump_to": "end"}
return None
wrap-style:包裹调用
python
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
@wrap_model_call
def retry_model(request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse:
for attempt in range(3):
try:
return handler(request)
except Exception:
if attempt == 2:
raise
return handler(request) # 理论不可达
代码说明:
before_model返回dict | None:返回 dict 会通过图的状态 reducer 合并;返回 None 表示不修改。这与本文HookContext.replace_state的语义一致。can_jump_to=["end"]是声明 ,jump_to: "end"是实际返回。声明让框架在编译期就把跳转边画进图里,这是静态可验证性的关键(见 2.3 节)。retry_model里 handler 可以被调用多次------这就是 wrap-style 的能力,node-style 做不到。- 装饰器 vs 类 :文档明确说装饰器用于一次性逻辑,类形式(
AgentMiddleware)用于需要配置或多个钩子的情况。这个划分与本文的priority/must_run_after声明需求对应------需要配置的中间件自然要用类。
6.2 动态配置:request.override()
wrap-style 钩子还有一个本文未涉及的重要能力:在运行时改写请求本身。
python
@wrap_model_call
def switch_model_by_stage(request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse]
) -> ModelResponse:
# 按会话阶段切换模型:简单阶段用小模型,复杂阶段用强模型
stage = request.state.get("stage", "simple")
if stage == "simple":
request = request.override(model="gpt-5.5-mini")
else:
request = request.override(
system_prompt="你是资深专家,请给出严谨的分析与依据。")
return handler(request)
request.override() 可以改 system_prompt / tools / model。这让"按阶段动态切换模型或人设"成为可能,而不需要改动 Agent 的主体逻辑。
6.3 组合规则:冲突时谁赢
多个中间件同时修改状态时,LangChain 的规则是:command 通过 reducer 应用;冲突的非 reducer 键,内层先应用、外层覆盖。
还有一条容易忽略的:如果外层中间件重试了 handler(),被丢弃的尝试所产生的 command 会被丢弃。
这条规则保证了重试不会造成状态污染 ------这与本文 run_wrap_hook 递归实现中的语义一致:每次 build(index+1, ctx) 传入新的 ctx,失败路径不会污染外层。
6.4 三方对照
| 维度 | 本文自研 | LangChain 中间件 | 裸实现(无中间件) |
|---|---|---|---|
| 横切能力 | ✅ 自定义钩子 | ✅ node + wrap 两套 | ❌ 散落在业务代码 |
| 顺序控制 | ✅ priority + 依赖声明 | ✅ 列表顺序(洋葱) | ❌ 靠代码摆放 |
| 短路能力 | ✅ ShortCircuit 异常 | ✅ jump_to + can_jump_to 声明 |
❌ 业务里写 if-else |
| 动态改请求 | ⚠️ 需自己实现 | ✅ request.override() |
❌ |
| 开箱内置 | ❌ 需自己写 | ✅ 十余种内置中间件 | ❌ |
| 接入成本 | 需理解自研框架 | 依赖 LangChain 生态 | 零 |
| 可测试性 | ✅ 纯逻辑易测 | ✅(依赖 LangChain 类型) | ⚠️ 需完整 Agent 环境 |
| 可控性 | ✅ 完全自控 | ⚠️ 受框架约束 | ✅ |
选型建议:
| 情况 | 建议 |
|---|---|
| 已在用 LangChain / LangGraph | 直接用内置中间件,别自己造 |
| 需要高度定制的横切能力 | 在 LangChain 上写自定义中间件 |
| 不想依赖任何框架(或受限环境) | 自研,本文代码可直接用 |
| 只有 1~2 个 Agent、无合规要求 | 不用中间件,直接写 |
七、适用边界与风险提示 ⚠️
7.1 中间件适合什么
✅ 多个 Agent 或多条流程需要统一治理:横向能力写一次,全局生效。
✅ 合规与审计有硬性要求:脱敏、审批、留痕必须每条路径都覆盖,不能靠人记得加。
✅ 横切能力与业务逻辑正交:日志、限流、重试、缓存这些,业务作者不会主动写但生产上必须有。
✅ 需要动态开关:按租户配置、灰度启用某个能力、健康检查自动降级。
✅ 团队规模大、需要一致标准:中间件是"写一次、对齐全组"的最有效手段。
7.2 中间件不适合什么
❌ 只有 1 个 Agent:抽象成本远超收益。
❌ 中间件链超过 7 层 :调试困难、顺序难验证、性能损耗累积。层数是设计缺陷的信号,不是功能强大的证明。
❌ 中间件需要理解业务语义:如"根据订单类型决定脱敏策略"------这应该抽成领域服务。
❌ 横切逻辑与业务强耦合:它不是横切的,放进中间件反而制造隐式依赖。
❌ 对延迟极度敏感且中间件重:中间件应该轻量(毫秒级)。若单个中间件 P95 超过 10ms,说明设计有问题。
7.3 五个典型陷阱

图2:中间件架构的五类典型陷阱------顺序错乱、单例共享状态、静默跳过、层级膨胀与业务侵入
陷阱一:顺序错乱
脱敏排在日志之后,日志里存了明文 PII。这是最难发现也最危险的一类,因为它不会报错,只会在几个月后的审计里被发现。
解决 :priority + must_run_after 双保险,加 describe() 方法验证顺序,加顺序断言测试。
陷阱二:中间件单例共享状态
中间件实例在 registry 里注册一次,所有请求共享。把请求数据存到 self 上,低并发测试完全正常,高并发下数据错乱。
解决 :状态一律挂 ctx.runtime,绝不挂 self。
陷阱三:钩子静默跳过
中间件注册了,但钩子点声明遗漏或签名不匹配,导致它从未被调用,且没有任何报错。
解决 :describe() 输出 + "零调用中间件"告警(第 4.4 节的指标)。这类故障的特征是"功能看起来正常,只是某个能力没生效"。
陷阱四:层级膨胀
中间件链超过 7 层后,调试几乎不可能、性能损耗累积、顺序验证成本爆炸。
解决 :合并同类项(如多个日志类合成一个)。层数是设计缺陷的信号。
陷阱五:业务规则侵入中间件
把"订单超 1 万需二级审批"这种业务规则塞进中间件,造成隐式依赖------读业务代码的人不知道这里有审批。
解决 :用第 1.3 节的自检问题------业务作者会主动想到它吗?会 → 写在业务里。
7.4 版本与兼容性提醒
⚠️ 钩子命名与参数在版本间有差异 。
before_agent/before_model/after_model/after_agent是 node-style;wrap_model_call/wrap_tool_call是 wrap-style。但是否支持jump_to、如何声明can_jump_to、状态 reducer 的合并语义在版本间调整过,务必以安装版本的文档为准。⚠️ 状态合并规则不是普通 dict 更新 。中间件返回的状态增量会通过图的状态 reducer 合并 (如
messages是累加的)。这意味着中间件返回{"messages": [...]}是追加而非覆盖------误解这一点会导致重复消息。⚠️ wrap-style 重试会丢弃失败尝试的 command。这是正确设计(避免状态污染),但如果你的中间件依赖"每次尝试都留下记录",需要显式处理。
八、进阶:把中间件接入编排引擎
8.1 中间件与编排引擎的边界
第 26 篇的编排引擎和本篇的中间件都是"横切基础设施",但职责不同:
| 维度 | 编排引擎(第 26 篇) | 中间件(本篇) |
|---|---|---|
| 作用对象 | 图与节点 | 单次 Agent 执行 |
| 关注点 | 谁先谁后、循环、分支 | 日志、鉴权、限流、脱敏 |
| 粒度 | 整个流程 | 每次模型/工具调用 |
| 是否阻塞 | 是(控制流) | 部分(限流/审批会阻塞) |
两者的正确关系是嵌套 :中间件在编排引擎的每个节点内部运行。
python
# integration.py ------ 中间件接入编排引擎的节点
from __future__ import annotations
from executor import MiddlewareExecutor
from middleware.base import HookContext, HookPoint, Middleware
class MiddlewareNode(Middleware):
"""把中间件包装成编排引擎的节点。
这是两套基础设施的衔接点------编排引擎负责"什么时候执行这个节点",
中间件负责"这个节点内部要做哪些横切处理"。
"""
def __init__(self, node_id: str, inner_handler,
executor: MiddlewareExecutor) -> None:
self.node_id = node_id
self.inner_handler = inner_handler
self.executor = executor
async def __call__(self, state: dict) -> dict:
ctx = HookContext(state=dict(state),
runtime={"node_id": self.node_id,
**state.get("runtime", {})})
await self.executor.run_node_hooks(HookPoint.BEFORE_AGENT, ctx)
async def _core(c: HookContext) -> dict:
return await self.inner_handler(c.state)
try:
result = await self.executor.run_wrap_hook(
HookPoint.WRAP_MODEL, ctx, _core)
finally:
await self.executor.run_node_hooks(HookPoint.AFTER_AGENT, ctx)
return result
这个衔接方式的价值 :编排引擎的每个节点都自动获得全套横切能力,业务 handler 完全不需要知道中间件的存在。这是中间件价值的完整体现------第 1.1 节那 12 个文件要改的代码,在这里一次都不用改。
业务 handler 降级中间件 限流中间件 脱敏中间件 观测中间件 最外层 MiddlewareExecutor MiddlewareNode 节点包装 编排引擎 (第26篇) 业务 handler 降级中间件 限流中间件 脱敏中间件 观测中间件 最外层 MiddlewareExecutor MiddlewareNode 节点包装 编排引擎 (第26篇) #mermaid-svg-XmUjBqSzUNpJ9q2I{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XmUjBqSzUNpJ9q2I .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XmUjBqSzUNpJ9q2I .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XmUjBqSzUNpJ9q2I .error-icon{fill:#552222;}#mermaid-svg-XmUjBqSzUNpJ9q2I .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XmUjBqSzUNpJ9q2I .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XmUjBqSzUNpJ9q2I .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XmUjBqSzUNpJ9q2I .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XmUjBqSzUNpJ9q2I .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XmUjBqSzUNpJ9q2I .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XmUjBqSzUNpJ9q2I .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XmUjBqSzUNpJ9q2I .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XmUjBqSzUNpJ9q2I .marker.cross{stroke:#333333;}#mermaid-svg-XmUjBqSzUNpJ9q2I svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XmUjBqSzUNpJ9q2I p{margin:0;}#mermaid-svg-XmUjBqSzUNpJ9q2I .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XmUjBqSzUNpJ9q2I text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-XmUjBqSzUNpJ9q2I .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XmUjBqSzUNpJ9q2I .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-XmUjBqSzUNpJ9q2I .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-XmUjBqSzUNpJ9q2I .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-XmUjBqSzUNpJ9q2I #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-XmUjBqSzUNpJ9q2I .sequenceNumber{fill:white;}#mermaid-svg-XmUjBqSzUNpJ9q2I #sequencenumber{fill:#333;}#mermaid-svg-XmUjBqSzUNpJ9q2I #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-XmUjBqSzUNpJ9q2I .messageText{fill:#333;stroke:none;}#mermaid-svg-XmUjBqSzUNpJ9q2I .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XmUjBqSzUNpJ9q2I .labelText,#mermaid-svg-XmUjBqSzUNpJ9q2I .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-XmUjBqSzUNpJ9q2I .loopText,#mermaid-svg-XmUjBqSzUNpJ9q2I .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-XmUjBqSzUNpJ9q2I .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XmUjBqSzUNpJ9q2I .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-XmUjBqSzUNpJ9q2I .noteText,#mermaid-svg-XmUjBqSzUNpJ9q2I .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-XmUjBqSzUNpJ9q2I .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XmUjBqSzUNpJ9q2I .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XmUjBqSzUNpJ9q2I .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XmUjBqSzUNpJ9q2I .actorPopupMenu{position:absolute;}#mermaid-svg-XmUjBqSzUNpJ9q2I .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-XmUjBqSzUNpJ9q2I .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XmUjBqSzUNpJ9q2I .actor-man circle,#mermaid-svg-XmUjBqSzUNpJ9q2I line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-XmUjBqSzUNpJ9q2I :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 图调度到该节点 alt 主模型成功 主模型失败 调用节点(携带图状态) run_node_hooks(before_agent) 开启 trace 并埋点 脱敏输入 配额检查 通过 进入 wrap_model 嵌套 wrap 进入(最外) wrap 进入 wrap 进入 wrap 进入 handler(降级逻辑包裹) 返回结果 换备用模型重试 返回结果 退出 退出 退出并释放资源 退出并记录耗时 run_node_hooks(after_agent) 完成 返回节点输出 (业务代码零改动)
这张图展示了两层嵌套 :外层是编排引擎的图调度,内层是中间件的洋葱嵌套。业务 handler 完全在最内层------它既不知道编排引擎的存在,也不知道中间件的存在。这是好的架构应有的样子:每层只对相邻的一层负责。
8.2 全局 vs 局部:中间件的作用域
不是所有中间件都该作用于整张图。三个作用域:
| 作用域 | 适用 | 实现 |
|---|---|---|
| 全局 | 观测、审计、限流 | 挂在引擎层,对所有节点生效 |
| 节点级 | 特定节点的降级、特定格式校验 | 挂在节点包装里(第 8.1 节) |
| 调用级 | 只针对某个模型或工具 | 在 wrap-style 钩子里按名称匹配 |
LangChain 的 HumanInTheLoopMiddleware 用的是调用级------通过 interrupt_on={"send_email": True} 指定只对特定工具生效。这个粒度很关键:如果所有工具都要审批,等于没有审批(审批疲劳);只对高风险工具审批才有效。
python
# scoped_middleware.py ------ 作用域感知的中间件
from __future__ import annotations
from middleware.base import HookContext, Middleware
class ScopedMiddleware(Middleware):
"""支持作用域限定的中间件基类。
解决"审批疲劳"问题:只对高风险操作设审批门,
而非所有操作都要审批。
"""
name = "scoped_base"
priority = 100
# 作用域:None 表示不限
only_tools: frozenset[str] | None = None
only_nodes: frozenset[str] | None = None
exclude_nodes: frozenset[str] | None = None
def applies_to_tool(self, tool_name: str | None) -> bool:
if self.only_tools is None:
return True
return tool_name in self.only_tools
def applies_to_node(self, node_id: str | None) -> bool:
if self.exclude_nodes and node_id in self.exclude_nodes:
return False
if self.only_nodes is None:
return True
return node_id in self.only_nodes
class ScopedHumanApproval(ScopedMiddleware):
"""只对指定高风险工具审批。"""
name = "scoped_human_approval"
priority = 20
only_tools = frozenset({"transfer_funds", "export_user_data"})
async def wrap_tool_call(self, ctx, handler):
if not self.applies_to_tool(ctx.runtime.get("current_tool")):
return await handler(ctx) # 不在作用域内,直接执行
# ... 审批逻辑
return {"status": "pending_approval"}
💡 作用域限定不只是优化,更是正确性问题。 我见过一个系统对所有工具调用都要求人工审批,结果是用户和审批员都麻木了 ------审批变成了点击"通过"的机械动作,真正的风险项反而被草率放过。审批门的价值来自它的稀缺性。
九、总结
回到文章开头的问题:为什么需要 Agent 中间件? 因为日志、埋点、限流、重试、审计、脱敏、人审这些横切能力,只能散落在业务代码里重复写 。三个需求落地,12 个 Agent 文件各改一遍;换一次监控方案,再改 12 遍。中间件要解决的不是"把代码挪到别处",而是三件更实的事------提供稳定的拦截点、保证执行顺序可预测、支持运行时可插拔。
这一路走下来,可以提炼出四个核心认知:
第一,中间件的价值核心是"顺序可控",不是"解耦"。 很多团队以为中间件的价值是解耦,其实是顺序。真实事故是这样的:脱敏中间件排在日志中间件之后,日志里存了明文身份证号,bug 潜伏三个月才被审计发现 。middleware 列表的第一个元素是最外层------监控与合规要放前面,改变模型行为的要放最靠近模型的位置。这个规则必须靠测试锁住,因为它一旦破坏,功能测试完全看不出来。
第二,需要两套钩子,因为它们能做的事根本不同。 node-style 钩子能读改状态、能声明短路目标,但不能包裹调用;wrap-style 钩子能包裹调用、能重试多次、能动态改写请求,但不能自然地批量修改状态。选择口诀:观察和修改状态用 node-style,控制执行的次数与成败用 wrap-style。 给模型调用加重试必须用 wrap-style------node-style 只能"标记失败",无法真正重新调用。
第三,中间件必须是轻量且可隔离的。 单个中间件的 P95 超过 10ms 就该警惕(它本该很轻);中间件实例是单例,状态必须挂 ctx.runtime 而绝不挂 self------后者在低并发测试时完全正常,高并发下数据错乱,是最隐蔽也最昂贵的一类 bug。
第四,中间件不是万能药,判断标准是"业务作者会主动想到它吗"。 会想到的(订单超 1 万要二级审批)是业务逻辑,写在业务里;想不到但生产上必须有的(脱敏、限流、重试)是横切关注点,适合中间件。把业务规则塞进中间件会造成隐式依赖------读业务代码的人根本不知道那里有个审批。
选型速览:
| 情况 | 选择 |
|---|---|
| 已在用 LangChain | 直接用内置中间件,别自己造(内置已有 Summarization / HITL / PII / Fallback / Limit 等十余种) |
| 需要高度定制 | 在 LangChain 上写自定义中间件,或自研(本文代码可直接用) |
| 受限环境 / 不依赖框架 | 自研 |
| 只有 1~2 个 Agent、无合规要求 | 不用中间件,直接写 |
6.5 与第 27 篇通信协议的关系
中间件与第 27 篇的通信协议都在解决"横切能力",但层次不同,容易混淆:
| 维度 | 通信协议(第 27 篇) | 中间件(本篇) |
|---|---|---|
| 面对的问题 | 跨进程/跨组织时消息传不通 | 单进程内的横切逻辑重复写 |
| 作用对象 | 消息的格式与语义 | 单次 Agent 执行的过程 |
| 典型能力 | 消息信封、幂等、因果链 | 钩子链、顺序、短路、插件 |
| 是否需要网络 | 跨进程时需要 | 不需要,纯进程内 |
两者可以叠加 :wrap_model_call 钩子里发出的跨服务调用,其消息格式遵循第 27 篇的三段 ID 与因果链约定。中间件保证"这次调用要脱敏、要重试、要计费",通信协议保证"这个请求跨进程能传对"。
一个常见的设计错误是把消息协议逻辑写进中间件钩子里------比如在钩子里手工拼消息字典。正确做法是钩子调用一个用第 27 篇协议封装好的客户端函数,让协议细节留在协议层。
最后一句提醒给所有读者 :中间件本身是复杂度,只有当它要解决的重复大于它自身引入的复杂度时才值得上。 中间件链超过 7 层就该停下来重构------层数是设计缺陷的信号,不是功能强大的证明。
落地自检清单(Checklist)
交付前逐项确认你的中间件架构是否达标:
- ✅ 钩子点覆盖完整,且通过
describe()验证过执行顺序 - ✅ 脱敏/合规类中间件在最外层(有测试锁定顺序)
- ✅ 顺序由
priority+ 依赖声明决定,不依赖 import 顺序 - ✅ 依赖冲突与成环在注册期报错
- ✅ 中间件名全局唯一,重复注册被拒
- ✅
fail_fast/fail_silent按中间件配置,而非全局开关 - ✅ 合规类中间件用
fail_fast(宁可请求失败也不能泄露) - ✅ 中间件状态一律挂
ctx.runtime,绝不挂self - ✅
after_*钩子在finally中执行(异常路径也要审计) - ✅ 有逆序清理机制(
cleanup方法) - ✅ 单中间件 P95 < 10ms,有 "too_slow" 告警
- ✅ 有"零调用中间件"告警(防钩子静默跳过)
- ✅ 短路有声明机制 (如
can_jump_to),使跳转目标编译期可校验 - ✅ wrap-style 重试丢弃失败尝试的状态变更
- ✅ 中间件链 ≤ 7 层
- ✅ 审批等人为环节限定作用域(避免审批疲劳)
- ✅ 限流类中间件在分布式下用共享存储(内存计数只在单进程有效)
- ✅ 接入编排引擎时,中间件与业务 handler 完全解耦
- ✅ 中间件测试零 LLM 调用,覆盖顺序、作用域、失败策略
常见问题(FAQ)
Q1:中间件和装饰器(decorator)有什么区别?
装饰器是同步、静态、函数级的,适合单点的横切逻辑。中间件额外提供了三样东西:统一的注册与排序机制 (装饰器靠代码位置决定顺序)、两套钩子形态 (node 与 wrap)、短路与跳转能力。如果你的横切逻辑只有 2~3 处且不需要动态调整,装饰器完全够用。
Q2:中间件链应该多深?
超过 7 层就该重构。 这个数字不是硬标准,但经验上:层数与调试成本成正比,与"中间件职责混乱"高度相关。层数多的通常信号是:中间件职责没划分清楚。 比如三个日志类中间件应该合成一个。
Q3:中间件里能修改状态吗?
node-style 可以(返回 dict,会通过状态 reducer 合并);wrap-style 可以通过 request.override() 改写请求(模型、工具、系统提示词)。但要小心状态合并语义 ------如 messages 字段是累加的,返回 {"messages": [msg]} 是追加而非覆盖。
Q4:怎么防止中间件顺序写错?
三道防线:① describe() 方法输出当前顺序 (排查问题的第一工具);② 顺序断言测试 (如"脱敏必须在观测之前",必须有测试锁定);③ 依赖声明 (must_run_after)在注册期就钉死关系。光靠约定不够,必须有测试。
Q5:中间件里的限流怎么实现?
单进程可以用内存滑动窗口(本篇示例),但分布式部署下这是错的 ------每个进程各算各的,实际 QPS 是配置值乘以进程数。生产环境必须用 Redis 等共享存储做原子计数,或者用网关层限流(第 31 篇会讲)。中间件限流适合单机或作为第二道防线,不能作为唯一防线。
Q6:中间件能互相依赖吗?
可以,但要小心。本文用 must_run_after / must_run_before 显式声明依赖,依赖成环会在注册期报错。但要警惕"隐式依赖" ------中间件 A 依赖了 B 的某个内部状态,这个依赖在签名上看不出来,改 B 时就会破坏 A。尽量让中间件彼此独立,只通过 ctx 通信。
Q7:多个中间件都改了同一个字段,谁赢?
取决于框架与状态 schema。LangChain 的规则是:command 通过 reducer 应用;冲突的非 reducer 键,内层先应用、外层覆盖。 所以外层中间件的修改会覆盖内层的------这意味着脱敏(外层)会覆盖日志(内层)对同一字段的修改。设计上要清楚每个字段的"权威来源"是哪个中间件。
参考资料
- LangChain 官方文档 ------ Middleware overview(中间件总览与钩子体系):https://docs.langchain.com/oss/python/langchain/middleware/overview
- LangChain 官方文档 ------ 自定义中间件(node-style 与 wrap-style 钩子、装饰器与类形式):https://docs.langchain.org.cn/oss/python/langchain/middleware/custom
- LangChain 内置中间件目录(Summarization / HITL / PII / Fallback / Limit 等):https://docs.langchain.com/oss/python/langchain/middleware/overview
- LangGraph 官方文档(中间件钩子运行在编译后的图内,可嵌入 StateGraph):https://langchain-ai.github.io/langgraph/
- Koa 官方文档 ------ 中间件洋葱模型(
await next()的经典实现,wrap-style 钩子的原型):https://koajs.com/#middleware - Spring Framework 文档 ------ HandlerInterceptor 与 AOP(后端领域的中间件范式对照):https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop
- Express 官方文档 ------ Middleware(Node 生态的中间件实践对照):https://expressjs.com/en/guide/using-middleware.html
- Martin, R. C. --- Design Patterns: Elements of Reusable Object-Oriented Software (Chain of Responsibility 与 Command 模式的原始论述,中间件链的理论来源):https://refactoring.guru/design-patterns/chain-of-responsibility