深入学 LangChain 官方文档(十六)Built-in 与 Custom Middleware

深入学 LangChain 官方文档(十六)Built-in 与 Custom Middleware

本篇对应的官方文档

  • Middleware overview:支撑 Middleware 在 Agent loop 与 compiled LangGraph 中的运行位置。
  • Prebuilt middleware:支撑 PII、调用限额、重试、错误处理等 Built-in 能力及配置边界。
  • Custom middleware:支撑 node-style / wrap-style Hook、handler 控制、自定义实现与多 Middleware 顺序。

本篇讲解范围

本篇用客服 Agent 串起敏感信息处理、调用限额、工具重试和自定义审计,讲清通用治理能力怎样接入 Agent 生命周期,以及组合顺序为什么属于运行合同。Human-in-the-loop 的四类审批决定不再重复;知识库、Embedding、Vector Store 与 RAG 留给下一篇。

客服 Agent 的第一版通常很好写:system prompt 里提醒不要泄露隐私,工具函数里捕获网络异常,调用方再记录耗时。随着需求增加,规则会散落到三处:Prompt 负责一部分,工具负责一部分,外围服务再补一部分。

问题不只是代码重复。同一封邮件在进入模型前脱敏了,却可能在工具结果或流式事件中再次暴露;一个查询工具自己重试三次,外层调用方又重试两次,最终可能发出六次请求。每条局部规则都"看起来正确",组合后却失去统一边界。

左侧的 Prompt、Tool 和调用方各自维护治理逻辑,策略覆盖面与执行顺序难以确认;右侧把通用控制挂到 Agent 生命周期 Hook,同一条消息、模型调用和工具调用都能在明确位置接受检查。

Middleware 的价值不是把所有业务代码搬进一个列表,而是为横切策略提供稳定接入点:脱敏、限额、重试、降级、日志和 Guardrail 不再依赖每个工具作者自觉复制。

一、Middleware 运行在 Agent loop 里面

create_agent 返回的是已编译的 LangGraph。Middleware 不是 Agent 外面的反向代理,它的 Hook 会成为这张图内部的节点或调用包装层。

Agent 开始一次 invocation 后,会在模型与工具之间循环:模型生成普通回答或 tool calls,工具执行后用 ToolMessage 回传,模型再决定是否继续。沿着这条循环观察 Hook 的包围范围和运行频率,就能看出一次性校验与逐轮控制为什么不能放在同一位置。

before_agentafter_agent 包住一次完整 invocation;before_modelafter_model 会随循环重复;wrap_model_callwrap_tool_call 则直接包住真实调用。Hook 的频率不同,放错位置会让一次性校验被重复执行,或让逐次限制只检查一次。

当整个 Agent 被放进更大的 StateGraph 作为节点或子图时,这些 Hook 仍会运行。Middleware 与 Agent 是一个编译后的执行单元,不需要在外层工作流里重新手工调用一次。

二、Node-style 负责时点,Wrap-style 控制调用

Custom Middleware 提供两种 Hook 风格。

Node-style Hook 在固定时点按顺序运行:before_agent 在一次调用开始前执行一次,before_model 在每次模型调用前执行,after_model 在每次模型响应后执行,after_agent 在 Agent 结束时执行一次。它们适合验证、日志、State 更新和 jump_to 跳转。

Wrap-style Hook 环绕一次真实调用:wrap_model_call 包住模型,wrap_tool_call 包住工具。它接收 request 和 handler,由 Middleware 决定是否、何时、调用几次 handler。

Node-style 像执行路径上的检查站,到点运行并可更新 State;Wrap-style 像包裹真实调用的控制层,可以短路、变换请求、调用一次或多次 handler。前者回答"在哪个时点检查",后者回答"这次调用怎样发生"。

只想在 Agent 开始时验证租户状态,不需要包住每次模型调用;想实现模型降级或缓存,必须控制 handler,单纯 before_model 无法接住异常并再次调用另一个模型。

三、先按问题选择 Built-in

LangChain 已提供一组 provider-agnostic Middleware。选择时应从失败模式出发,而不是看到类名就全部加入列表。

  • 对话即将超过上下文窗口,使用 Summarization 或 Context Editing。
  • 模型或工具调用次数可能失控,使用 Model Call Limit 或 Tool Call Limit。
  • 主模型暂时不可用,使用 Model Fallback 或 Model Retry。
  • 外部 API 有短暂网络错误,使用 Tool Retry;想把最终异常变成模型可见的受控消息,再组合 Tool Error。
  • 输入、输出或流式通道可能泄露邮箱、卡号与密钥,使用 PII Detection。
  • 高风险工具必须等人批准,使用上一篇的 Human-in-the-loop。

把这些问题按行与对应 Built-in 对齐,重点是先确认失败模式,再选择只承担该职责的标准策略。

上下文、成本、韧性、隐私和人工审批对应不同失败模式。Built-in Middleware 把这些常见治理问题封装成标准策略模块;只有当前风险存在时才加入,并为每项配置明确范围与退出行为。

PIIMiddlewareredactmaskhashblock 语义不同。客服聊天中的邮箱可以在送入模型前 redact,信用卡可以 mask,疑似 API key 可以直接 block。开启 apply_to_output=True 时,当前文档还支持对流式 wire output 做脱敏;这项能力要求对应 LangChain 版本,不能只升级示例代码而不核对运行依赖。

四、把 Built-in 与 Custom 放进同一个 Agent

下面的代码组合四层策略:邮箱输入输出脱敏、单次 Agent 运行的模型调用上限、只读订单查询的暂时性错误重试,以及一个自定义工具审计 Hook。

python 复制代码
import os
import time
from collections.abc import Callable

from langchain.agents import create_agent
from langchain.agents.middleware import (
    ModelCallLimitMiddleware,
    PIIMiddleware,
    ToolRetryMiddleware,
    wrap_tool_call,
)
from langchain.messages import ToolMessage
from langchain.tools import tool
from langchain.tools.tool_node import ToolCallRequest
from langchain_openai import ChatOpenAI
from langgraph.types import Command


# 作用:模拟只读订单查询;真实实现应设置超时并返回受控字段。
@tool
def get_order(order_id: str) -> str:
    return f"订单 {order_id} 当前状态为已支付。"


# 作用:记录每次工具尝试的名称、结果和耗时,不修改工具返回值。
@wrap_tool_call
def audit_tool_call(
    request: ToolCallRequest,
    handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
    started_at = time.perf_counter()
    tool_name = request.tool_call["name"]
    try:
        result = handler(request)
        print(f"tool={tool_name} status=success")
        return result
    except Exception:
        print(f"tool={tool_name} status=error")
        raise
    finally:
        elapsed_ms = (time.perf_counter() - started_at) * 1000
        print(f"tool={tool_name} elapsed_ms={elapsed_ms:.1f}")


model = ChatOpenAI(
    model="qwen3.7-plus",
    api_key=os.environ["MODEL_API_KEY"],
    base_url=os.environ["MODEL_BASE_URL"],
)

agent = create_agent(
    model=model,
    tools=[get_order],
    middleware=[
        PIIMiddleware(
            "email",
            strategy="redact",
            apply_to_input=True,
            apply_to_output=True,
        ),
        ModelCallLimitMiddleware(run_limit=6, exit_behavior="end"),
        ToolRetryMiddleware(
            max_retries=2,
            tools=["get_order"],
            retry_on=(ConnectionError, TimeoutError),
            on_failure="continue",
        ),
        audit_tool_call,
    ],
)

result = agent.invoke(
    {
        "messages": [
            {
                "role": "user",
                "content": "我的邮箱是 user@example.com,请查询订单 A-2048",
            }
        ]
    }
)

print(result["messages"][-1].content)

PII 层在内容进入和离开 Agent 时处理邮箱;模型调用上限阻止一次运行无限循环;ToolRetryMiddleware 只重试 get_order 的连接和超时异常;audit_tool_call 包住每次真实工具尝试并保留异常传播。

请求先经过 PII 处理,Agent loop 受模型调用上限约束;进入 get_order 后,Retry 决定是否再次调用 handler,Custom Audit 记录每次尝试。每层只承担一个横切职责,订单事实仍由工具连接的业务服务提供。

这里故意只给只读查询加重试。若 refund_order 没有幂等键,简单套用 Tool Retry 可能提交多次退款。Middleware 能发起多次 handler 调用,却不能替外部系统生成正确的幂等合同。

五、Handler 调用次数就是行为语义

Wrap-style 的关键不在装饰器写法,而在 handler 被调用几次。

调用零次表示短路。缓存命中、策略阻断或已有结果时,Middleware 可以直接返回,不访问真实模型或工具。

调用一次是正常路径。Middleware 可以先用 request.override(...) 生成新请求,再把它交给 handler。请求对象应通过官方覆盖接口修改,避免原地变更影响其他层。

调用多次表示重试、候选比较或降级。每次 handler 都可能触发真实成本与副作用,必须限定异常类型、最大次数、退避和最终失败行为。

零次 handler 对应短路,一次对应正常调用,多次对应重试或降级。调用次数不是实现细节:它直接决定外部请求次数、成本、日志数量和副作用风险。

如果 Custom Middleware 只想监控,不应吞掉异常或改变结果;如果想重试,优先使用 Built-in Retry,并把可重试异常与副作用幂等写清。只有 Built-in 无法表达业务条件时,才值得自己控制 handler。

六、多 Middleware 的顺序会改变结果

Middleware 列表不是无序集合。对 middleware=[m1, m2, m3]

  • before_*m1 → m2 → m3 运行。
  • wrap_* 像函数调用一样嵌套,m1 包住 m2m2 再包住 m3 和真实调用。
  • after_*m3 → m2 → m1 反向运行。

将三段顺序放在同一条进入---调用---退出路径中,外层与内层各自能观察到什么就会变得明确。

Before 从外到内依次进入,Wrap 形成嵌套调用栈,After 再由内向外退出。外层能观察后续层的整体结果,内层只能看到更靠近真实调用的请求与异常。改变列表顺序,会改变日志覆盖范围、异常由谁接住以及短路发生在哪一层。

例如,审计放在 Retry 外层时,一次业务调用可能只记录一个最终结果;审计放在 Retry 内层时,每次重试尝试都能留下记录。两种都可能合理,但必须由审计目标决定,而不是碰巧写成某个顺序。

官方 Built-in 文档也给出了 Tool Retry 与 Tool Error 的组合要求:Retry 耗尽后要把异常继续交给 Error 层,Error 再将其转换为受控 ToolMessage。若前一层提前把错误吞成普通字符串,后一层就失去判断依据。

七、Custom Middleware 只补业务差异

装饰器适合单一、无复杂状态的 Hook;继承 AgentMiddleware 更适合需要初始化配置、自定义 State schema、stream transformer 或多个 Hook 的可复用策略。

无论哪种写法,都应满足三个边界。第一,Middleware 只保存横切策略,不把订单计算、权限判定等权威业务逻辑搬出服务端。第二,request、State 和返回值的修改使用明确接口并可被测试。第三,短路、重试、跳转和异常处理都要有可观察结果。

Custom Middleware 最常见的合理用途,是补充组织特有的审计字段、租户路由、动态工具筛选或内部策略服务接入。若代码只是在重新实现邮箱正则、通用指数退避或模型调用计数,应先回头检查 Built-in 是否已经覆盖。

八、上线前按四步检查组合

一套 Middleware 组合可以按以下顺序审查。

先列失败模式:究竟要处理隐私、成本、上下文、暂时性异常还是高风险副作用。没有风险对象,就不要先选类名。

再选 Built-in:确认配置范围、版本要求、退出行为和异常类型。通用能力优先复用官方实现。

然后补 Custom:只实现 Built-in 无法表达的业务差异,并写清 handler 调用次数、request 修改和 State 更新。

最后验证顺序:为正常、短路、重试耗尽、异常、流式输出和副作用工具分别建立测试,确认每一层看到的请求与结果符合预期。

生产决策从失败模式开始,依次经过 Built-in 复用、Custom 补差、顺序设计和组合测试。跳过前面的风险定义,Middleware 列表越长,越难证明实际执行路径。

这五步共同形成一份可复现的运行合同:每层职责、顺序和异常路径都能被单独验证。

总结:把顺序当成可测试的运行合同

Built-in Middleware 提供已经标准化的治理积木,Custom Middleware 提供业务差异的扩展点。两者共同依赖同一套生命周期:Node-style 在明确时点执行,Wrap-style 通过 handler 控制真实模型或工具调用。

判断一层 Middleware 是否合格,可以问四个问题:它处理哪个失败模式,运行在什么 Hook,handler 会调用几次,它与相邻层的先后顺序是否有测试。回答不了其中任何一个,代码即使能运行,也很难证明组合行为安全。

到这里,Agent 已经能够实时暴露运行、连接外部能力、审批高风险动作,并把脱敏、限额和韧性策略接入生命周期。下一步要解决的是另一类缺口:模型参数和流程都受控,却仍然不知道企业私有资料。下一篇将进入 Knowledge Base 与 RAG,追踪 Document、Embedding、Vector Store 和 Retriever 怎样把外部知识送进模型上下文。

相关推荐
老刘说AI2 小时前
AI服务核心: 高并发原理与性能监控调优
人工智能·神经网络·langchain·llama·持续部署
展示猪肝3 小时前
LangChain学习笔记(一):基础入门与核心概念详解
langchain
TheBestRucy5 小时前
RAG知识库问答系统落地:从向量检索到上下文增强的全链路实践
人工智能·python·langchain·aigc·交互
早点睡啊Y6 小时前
深入学 LangChain 官方文档(十五)Human-in-the-loop 与 Guardrails
langchain
中微极客7 小时前
2026年生产级RAG技术栈选型:LangChain+Cohere Rerank实战
人工智能·langchain
只一8 小时前
拿捏大模型输出随机性:Temperature、Top-K 原理 + LangChain 工程落地实战
javascript·langchain
GuWen_yue9 小时前
Cursor黑盒拆解!1套LangChain.js手写Mini编程Agent,自动生成React项目,效率提升60%
javascript·react.js·langchain
玉宇夕落9 小时前
LangChain 工作流中的温度、Top-K、Top-P
langchain
Esaka_Forever9 小时前
LLM 大语言模型 vs Agent 智能体:核心区别 + LangChain 学习必要性
langchain