Agent 中间件架构:钩子链、插件注册与横切治理

摘要 :前七篇把 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 局部:中间件的作用域)
    • 九、总结
    • 参考资料

一、为什么需要中间件:横切能力的困境

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_* 钩子用的是注册顺序 而非逆序,导致:

  1. 请求进入 → 日志中间件记录了完整原始输入
  2. 脱敏中间件把 PII 打码
  3. 后续日志输出的是脱敏后的内容

结果是日志系统里存了明文身份证号,且这个 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 服务同时服务多个租户,每个租户有不同的合规策略、配额、审批要求。需求:

  1. 按租户配置自动生效,不用改业务代码;
  2. 高风险操作(资金、导出)自动插入人工审批;
  3. 全链路可审计,且日志里不含明文 PII;
  4. 按租户限流与计费;
  5. 模型调用失败时自动降级。

选这个案例的理由:它把中间件的所有难点都暴露出来了------多租户意味着配置来自运行时、审批意味着短路、脱敏意味着顺序约束、限流意味着 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

这个输出验证了三件关键事实:

  1. 顺序正确 :pii_redaction(priority=10)在 observability(priority=100)之前,且这个顺序不是靠 import 顺序保证的,而是靠 must_run_after 依赖声明钉死的。
  2. after 逆序 :执行轨迹显示 tenant_rate_limit → pii_redaction → observability,与 before 顺序相反。
  3. 降级生效 :主模型 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 键,内层先应用、外层覆盖。 所以外层中间件的修改会覆盖内层的------这意味着脱敏(外层)会覆盖日志(内层)对同一字段的修改。设计上要清楚每个字段的"权威来源"是哪个中间件。


参考资料

  1. LangChain 官方文档 ------ Middleware overview(中间件总览与钩子体系):https://docs.langchain.com/oss/python/langchain/middleware/overview
  2. LangChain 官方文档 ------ 自定义中间件(node-style 与 wrap-style 钩子、装饰器与类形式):https://docs.langchain.org.cn/oss/python/langchain/middleware/custom
  3. LangChain 内置中间件目录(Summarization / HITL / PII / Fallback / Limit 等):https://docs.langchain.com/oss/python/langchain/middleware/overview
  4. LangGraph 官方文档(中间件钩子运行在编译后的图内,可嵌入 StateGraph):https://langchain-ai.github.io/langgraph/
  5. Koa 官方文档 ------ 中间件洋葱模型(await next() 的经典实现,wrap-style 钩子的原型):https://koajs.com/#middleware
  6. Spring Framework 文档 ------ HandlerInterceptor 与 AOP(后端领域的中间件范式对照):https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop
  7. Express 官方文档 ------ Middleware(Node 生态的中间件实践对照):https://expressjs.com/en/guide/using-middleware.html
  8. Martin, R. C. --- Design Patterns: Elements of Reusable Object-Oriented Software (Chain of Responsibility 与 Command 模式的原始论述,中间件链的理论来源):https://refactoring.guru/design-patterns/chain-of-responsibility
相关推荐
欣欣之王来了2 小时前
开发环境准备:Node.js、npm、VS Code安装配置
前端·学习·架构·项目·vue3教程
Dawson Zhu2 小时前
《Agentic Design Patterns》第 11 章导读:目标设定与监控(Goal Setting and Monitoring)
人工智能·语言模型·架构·aigc·agi
容小智2 小时前
金融投研引入工作智能体(Agent):如何穿透概念核算真实商业价值?
大数据·人工智能·ai·金融·agent
玩AI的奶茶2 小时前
24GB 显存能跑多大的模型?参数量、精度与显存占用对照表
人工智能·python·算法·ai·aigc·gpu算力·算力租赁
Wang's Blog2 小时前
Java 项目部署之 Docker工具快速入门: Docker 架构拆解:镜像、容器、守护进程与 Registry
java·docker·架构
FYKJ_20103 小时前
django个性化新闻推荐35173-计算机课程设计、毕业设计
java·spring boot·后端·python·架构·django·课程设计
java_logo3 小时前
Docker 部署 OpenViking:轻松搭建 AI Agent 上下文数据库平台
数据库·人工智能·docker·agent·火山引擎·openviking·轩辕镜像
RoboWizard3 小时前
搭配W790芯片组的超频服务器内存(架构选型)FAQ合集
运维·服务器·架构
谢亮_vipxieliang3 小时前
Go Gin 中间件与参数验证:从入门到实战
中间件·golang·gin