自定义中间件:Node-style 与 Wrap-style 钩子全解析

LangChain 内置的十几个中间件覆盖了大多数场景,很多人的第一反应是"够用了"。但只要真的把 Agent 推向生产环境,很快就会撞上一堵墙:内置中间件覆盖的是"通用需求",而业务里最棘手的往往是"自有需求"。比如每个请求都要注入租户级的鉴权上下文;比如提示词要按用户身份、当前时间、所在渠道动态拼装,而不是写死一条 SystemMessage;再比如合规团队要求每一次工具调用都留下可追溯的审计日志,谁在什么时间用哪个工具、耗时多少、结果是否异常。这三类需求------鉴权注入、动态提示词、自定义审计埋点------翻遍内置中间件列表,没有一个能直接拿来就用。

怎么办?LangChain 的答案是:把 Agent 生命周期的关键位置预留成"插槽",允许你把自己的函数挂进去,在框架自动触发的时机执行。这些被挂进去的函数,就是本文的主角------hook 函数(钩子函数)。可以说,前面几篇讲的内置中间件,本身就是用这套 hook 机制实现的;理解了 hook,你就从"中间件的使用者"变成了"中间件的作者"。

本文解决什么,是自定义中间件的完整方法论:hook 函数的设计思想、Node-style 与 Wrap-style 两大类共六个钩子(外加一个便捷装饰器)的用法、装饰器与类两种写法的底层统一、can_jump_to 流程跳转,以及最容易踩坑的多中间件执行顺序。

本文不展开什么,是不再重复讲解 AgentState、Runtime、create_agent 的基础用法,也不逐个介绍内置中间件(前置篇已覆盖),更不涉及长期记忆、子 Agent 等外围机制。

读完能完成什么,是能够独立实现任意自定义中间件:既会用装饰器快速挂一个钩子,也会用类封装一个可配置、可复用、可测试的中间件组件;能看懂并预判多个中间件混排时的完整执行时序;最后还会动手写出两个生产级示例------审计日志中间件和动态提示词中间件。

一、hook 函数:中间件体系的"扩展插槽"

1.1 什么是 hook 函数

Hook 函数,中文常叫钩子函数,指的是:在某个既定流程的特定时机,被框架、系统或主程序自动调用的扩展函数

这个定义里有三个关键词,恰好对应 hook 思想的三要素:

  1. 不是你主动调用的 。普通函数是你写在业务代码里、自己 foo() 调用的;hook 函数恰恰相反------你只负责"定义 + 挂载",什么时候执行、执行几次,完全由框架决定。当流程运行到某个"钩子点"时,系统自动触发它。
  2. 它依附于一个更大的执行流程。hook 不能独立存在,它必须挂在某个既定流程的某个时机上:"请求开始前""模型调用前""模型调用后""任务结束后""异常发生时"......离开了主流程,hook 毫无意义。
  3. 它的作用是不改主流程源码就插入自己的逻辑 。你不需要修改 create_agent 的内部实现,就能在其中做日志、鉴权、修改输入、拦截输出、清理资源等操作。这就是"开闭原则"在 Agent 框架里的落地:对扩展开放,对修改关闭。

一句话总结:主流程预留了一些插槽,允许你在这些位置挂上自己的函数,这种被挂进去并在特定时机执行的函数,就是 hook 函数

1.2 LangChain 的 hook 分类

LangChain 的中间件作用在 Agent 架构中,而 Agent 是基于 LangGraph 构建的流程图。LangChain 一共暴露了六个核心 hook 函数(外加若干便捷装饰器),按风格分为两类:

类型 包含的 hook 执行位置 适合场景
Node-style hooks(节点风格) before_agentbefore_modelafter_modelafter_agent 在流程的特定节点运行 顺序逻辑:日志记录、输入验证、PII 脱敏、输出校验、状态更新
Wrap-style hooks(包装风格) wrap_model_callwrap_tool_call(以及便捷版 @dynamic_prompt 在模型或工具调用的前后运行 控制流:重试、回退、缓存、请求/响应改写

先用一张图把六个钩子在 Agent 生命周期中的位置标出来,后面所有内容都围绕这张图展开:
#mermaid-svg-Vm5x2t38lbeyB0Tq{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-Vm5x2t38lbeyB0Tq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Vm5x2t38lbeyB0Tq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Vm5x2t38lbeyB0Tq .error-icon{fill:#552222;}#mermaid-svg-Vm5x2t38lbeyB0Tq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Vm5x2t38lbeyB0Tq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Vm5x2t38lbeyB0Tq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Vm5x2t38lbeyB0Tq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Vm5x2t38lbeyB0Tq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Vm5x2t38lbeyB0Tq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Vm5x2t38lbeyB0Tq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Vm5x2t38lbeyB0Tq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Vm5x2t38lbeyB0Tq .marker.cross{stroke:#333333;}#mermaid-svg-Vm5x2t38lbeyB0Tq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Vm5x2t38lbeyB0Tq p{margin:0;}#mermaid-svg-Vm5x2t38lbeyB0Tq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Vm5x2t38lbeyB0Tq .cluster-label text{fill:#333;}#mermaid-svg-Vm5x2t38lbeyB0Tq .cluster-label span{color:#333;}#mermaid-svg-Vm5x2t38lbeyB0Tq .cluster-label span p{background-color:transparent;}#mermaid-svg-Vm5x2t38lbeyB0Tq .label text,#mermaid-svg-Vm5x2t38lbeyB0Tq span{fill:#333;color:#333;}#mermaid-svg-Vm5x2t38lbeyB0Tq .node rect,#mermaid-svg-Vm5x2t38lbeyB0Tq .node circle,#mermaid-svg-Vm5x2t38lbeyB0Tq .node ellipse,#mermaid-svg-Vm5x2t38lbeyB0Tq .node polygon,#mermaid-svg-Vm5x2t38lbeyB0Tq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Vm5x2t38lbeyB0Tq .rough-node .label text,#mermaid-svg-Vm5x2t38lbeyB0Tq .node .label text,#mermaid-svg-Vm5x2t38lbeyB0Tq .image-shape .label,#mermaid-svg-Vm5x2t38lbeyB0Tq .icon-shape .label{text-anchor:middle;}#mermaid-svg-Vm5x2t38lbeyB0Tq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Vm5x2t38lbeyB0Tq .rough-node .label,#mermaid-svg-Vm5x2t38lbeyB0Tq .node .label,#mermaid-svg-Vm5x2t38lbeyB0Tq .image-shape .label,#mermaid-svg-Vm5x2t38lbeyB0Tq .icon-shape .label{text-align:center;}#mermaid-svg-Vm5x2t38lbeyB0Tq .node.clickable{cursor:pointer;}#mermaid-svg-Vm5x2t38lbeyB0Tq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Vm5x2t38lbeyB0Tq .arrowheadPath{fill:#333333;}#mermaid-svg-Vm5x2t38lbeyB0Tq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Vm5x2t38lbeyB0Tq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Vm5x2t38lbeyB0Tq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vm5x2t38lbeyB0Tq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Vm5x2t38lbeyB0Tq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vm5x2t38lbeyB0Tq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Vm5x2t38lbeyB0Tq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Vm5x2t38lbeyB0Tq .cluster text{fill:#333;}#mermaid-svg-Vm5x2t38lbeyB0Tq .cluster span{color:#333;}#mermaid-svg-Vm5x2t38lbeyB0Tq 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-Vm5x2t38lbeyB0Tq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Vm5x2t38lbeyB0Tq rect.text{fill:none;stroke-width:0;}#mermaid-svg-Vm5x2t38lbeyB0Tq .icon-shape,#mermaid-svg-Vm5x2t38lbeyB0Tq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vm5x2t38lbeyB0Tq .icon-shape p,#mermaid-svg-Vm5x2t38lbeyB0Tq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Vm5x2t38lbeyB0Tq .icon-shape .label rect,#mermaid-svg-Vm5x2t38lbeyB0Tq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vm5x2t38lbeyB0Tq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Vm5x2t38lbeyB0Tq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Vm5x2t38lbeyB0Tq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

用户输入 invoke()
before_agent

Agent 开始前(正序)
进入模型循环体
before_model

模型调用前(正序)
wrap_model_call

洋葱式包裹(1 最外层)
大模型调用
after_model

模型调用后(逆序)
模型要求调用工具?
wrap_tool_call

洋葱式包裹工具执行
after_agent

Agent 全部结束后(逆序)
返回最终结果

无论是官方内置中间件、自定义中间件、还是便捷装饰器中间件,通常都是通过实现其中一个或多个 hook 来生效的。所以"自定义中间件"本质上就是回答一个问题:实现哪几个 hook、用什么方式实现、挂载时按什么顺序传

在动手之前,先建立一个对执行顺序的直觉。假设注册三个中间件,每个都实现了 before_modelafter_model,一次 invoke 的输出会是:

text 复制代码
[中间件 1] before_model
[中间件 2] before_model
[中间件 3] before_model
[中间件 3] after_model
[中间件 2] after_model
[中间件 1] after_model

before 正序、after 逆序,像一个洋葱:1→2→3→ 模型 →3→2→1。这个直觉先埋在这里,第五节会给出 Node/Wrap 混排时的完整实测时序。

二、Node-style hooks:节点式钩子全解

2.1 基本用法:基于装饰器实现

Node-style 的四个钩子,命名就说明了各自的触发时机:before_agent(Agent 开始运行前)、before_model(模型调用前)、after_model(模型调用后)、after_agent(Agent 全流程结束后)。装饰器是"函数式挂载",把一个 hook 快速挂到 Agent 的某个节点上:

python 复制代码
from langchain.agents import create_agent
from langchain.agents.middleware import (
    before_model, after_model, before_agent, after_agent, AgentState,
)
from langchain.messages import HumanMessage
from langgraph.runtime import Runtime
from typing import Any

# 1. 定义 before_agent 钩子:Agent 启动前触发
@before_agent
def before_agent_middleware(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    state["messages"][-1].content += " -> before_agent <- "
    return None

# 2. 定义 before_model 钩子:每次调用模型前触发
@before_model
def before_model_middleware(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    state["messages"][-1].content += " -> before_model <- "
    return None

# 3. 定义 after_model 钩子:每次模型返回后触发
@after_model
def after_model_middleware(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    state["messages"][-1].content += " -> after_model <- "
    return None

# 4. 定义 after_agent 钩子:Agent 全流程结束后触发
@after_agent
def after_agent_middleware(state: AgentState, runtime: Runtime) -> None:
    state["messages"][-1].content += " -> after_agent <- "
    return None

agent = create_agent(
    model=model,  # 前置篇已通过 init_chat_model 初始化
    middleware=[before_agent_middleware, before_model_middleware,
                after_model_middleware, after_agent_middleware],  # 👈 挂载钩子
)
response = agent.invoke({"messages": [HumanMessage("你好啊")]})
for msg in response["messages"]:
    msg.pretty_print()

运行输出:

text 复制代码
================================ Human Message =================================
你好啊 -> before_agent <-  -> before_model <- 
================================== Ai Message ==================================
你好!有什么可以帮你的吗? 😊 -> after_model <-  -> after_agent <-

观察标记的追加顺序,结论非常清晰:

  1. before_agent 先于 before_model 执行,二者都在调用模型之前;
  2. after_agent 晚于 after_model 执行,二者都在模型调用之后。

四个钩子构成了 Agent 一次完整运行的"前哨---出发---返航---收尾"闭环。

2.2 基于类实现

类写法是"对象化中间件":把中间件封装成一个可配置、可复用、可扩展的组件。关键规则只有三条:

  1. 必须继承 AgentMiddleware(固定);
  2. 方法名固定为 before_modelafter_model 等 hook 名(固定);
  3. 类名随意(不固定)。

LangGraph 只看两件事:是否继承了 AgentMiddleware?是否有那些约定名字的方法?

python 复制代码
from langchain.agents.middleware import AgentMiddleware

class MyMiddleware(AgentMiddleware):
    def __init__(self):
        super().__init__()

    def before_agent(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        state["messages"][-1].content += " -> before_agent <- "
        return None

    def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        state["messages"][-1].content += " -> before_model <- "
        return None

    def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        state["messages"][-1].content += " -> after_model <- "
        return None

    def after_agent(self, state: AgentState, runtime: Runtime) -> None:
        state["messages"][-1].content += " -> after_agent <- "
        return None

agent = create_agent(model=model, middleware=[MyMiddleware()])
response = agent.invoke({"messages": [HumanMessage("你好啊")]})
for msg in response["messages"]:
    msg.pretty_print()

输出与装饰器版本完全一致。两种写法殊途同归,那它们的本质关系是什么?

2.3 两种方法的统一:装饰器底层在做什么

装饰器并不是和类并列的"另一套机制",它底层会基于你定义的函数动态构造一个 AgentMiddleware 子类的实例 。以 @after_model 装饰器为例,其底层实现的关键代码等价于:

python 复制代码
return type(
    middleware_name,                # 类名:即被装饰函数的函数名
    (AgentMiddleware,),             # 父类:AgentMiddleware
    {
        "state_schema": state_schema or AgentState,
        "tools": tools or [],
        "after_model": wrapped,     # wrapped 内部执行 func(state, runtime)
    },
)()                                 # 末尾的 () 表示实例化

拆开看:type() 动态创建了一个 AgentMiddleware 的子类;类名取自被装饰函数的函数名;子类携带 state_schematools 两个属性;其 after_model 方法内部逻辑就是执行你定义的 func(state, runtime);最后的括号立即实例化并返回对象。

所以,用装饰器最终得到的也是一个 AgentMiddleware 子类对象,和基于类的自定义方式在底层完全统一。装饰器是语法糖,类是本体。

2.4 参数说明

Node-style 的钩子函数接收两个固定参数:

  • state :一个 AgentState 实例,维护 Agent 运行过程中的状态,会随 Agent 运行而变化,核心是消息列表 messages。你既可以在钩子里读取它(比如检查最后一条消息),也可以直接就地修改其中的消息对象,或通过返回值更新它。
  • runtime :一个 Runtime 实例,维护 Agent 运行过程中的上下文环境,包括上下文(context)、长期记忆等。做鉴权注入、按用户身份差异化处理时,用户信息通常就从 runtime 里取。

2.5 返回值说明:None、字典与流程控制

钩子函数的返回值决定了它对流程的影响程度,共三种:

返回值 含义 典型示例
None 不修改状态,流程继续 return None
字典 更新状态(按键合并进 state) return {"count": count + 1}
字典含 jump_to 控制流程,跳转到指定节点 return {"jump_to": "__end__"}

jump_to 的合法目标有三类:"__end__"(结束 Agent)、"tools"(跳到工具节点)、其他自定义节点。比如在 before_model 里发现状态中的计数超限,可以直接熔断:

python 复制代码
def before_model(self, state, runtime):
    if state.get("count", 0) > 10:
        return {"jump_to": "__end__"}  # 跳过模型,直接结束
    return None

2.6 装饰器参数 can_jump_to:三个跳转实战

钩子函数要使用 jump_to,必须先通过 can_jump_to 参数"申报"自己允许跳转到哪些位置。它决定了钩子函数可以直接跳转至流程的哪些位置,可取值如下:

  • end :跳转至 Agent 流程末尾(或第一个 after_agent 钩子),直接终止整个流程;
  • tools:跳转至工具节点;
  • model :跳转至模型节点(或第一个 before_model 钩子)。

下面用一个覆盖三种跳转的完整案例演示。业务设定:用户消息里出现 direct tool 就绕过模型直接触发工具;出现 retry model 就在模型回答后追加系统提示词并跳回模型重新生成;出现 overflow 就模拟上下文窗口溢出、直接熔断终止。

python 复制代码
from typing import Any
from langchain.agents import create_agent
from langchain.agents.middleware import before_model, after_model, AgentState
from langchain.messages import AIMessage, SystemMessage
from langchain.tools import tool
from langgraph.runtime import Runtime

@tool
def get_news() -> str:
    """获取当日新闻"""
    return "美加墨世界杯今日开幕"

# 场景 1:模型执行前,允许跳转 "tools"
@before_model(can_jump_to=["tools"])
def force_tool_first(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    """用户输入含 'direct tool' 时,跳过模型思考,伪造 tool_calls 强行移交工具节点"""
    text = state["messages"][-1].content
    if isinstance(text, str) and "direct tool" in text.lower():
        print("[MIDDLEWARE] before_model: jump_to='tools'")
        # 人工构造 AIMessage,欺骗系统"这是模型决定调用的工具"
        fake_tool_call = AIMessage(
            content="人工构造的消息",
            tool_calls=[{"name": "get_news", "args": {}, "id": "call_force_001"}],
        )
        return {"messages": [fake_tool_call], "jump_to": "tools"}
    return None

# 场景 2:模型生成后,允许跳回 "model"
@after_model(can_jump_to=["model"])
def retry_with_extra_instruction(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    """用户最初请求含 'retry model' 时,追加系统提示词,强制模型重新生成一次"""
    user_text = next((m.content for m in reversed(state["messages"])
                      if getattr(m, "type", "") == "human"), "")
    if isinstance(user_text, str) and "retry model" in user_text.lower():
        # 防死循环:已注入过提示词就不再跳
        already = any("你必须以【二次回答】开头" in str(getattr(m, "content", ""))
                      for m in state["messages"])
        if already:
            return None
        print("[MIDDLEWARE] after_model: jump_to='model'")
        return {"messages": [SystemMessage("你必须以【二次回答】开头,并且只用一句话回答。")],
                "jump_to": "model"}
    return None

# 场景 3:模型执行前,允许直接跳 "end" 熔断
@before_model(can_jump_to=["end"])
def overflow_context_processor(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    """模拟上下文窗口溢出,直接终止流程,不让请求到达大模型"""
    if "overflow" in state["messages"][-1].content:
        print("[MIDDLEWARE] before_model: jump_to='end'")
        return {"messages": [AIMessage("上下文窗口溢出,终止")], "jump_to": "end"}
    return None

agent = create_agent(
    model=model,
    tools=[get_news],
    middleware=[force_tool_first, retry_with_extra_instruction,
                overflow_context_processor],  # 执行顺序严格按列表声明顺序
)

def run_once(user_input: str):
    result = agent.invoke({"messages": [{"role": "user", "content": user_input}]})
    for msg in result["messages"]:
        msg.pretty_print()

run_once("请帮我查今日新闻 direct tool")   # Case 1:跳 tools
run_once("请随便介绍一下 LangChain retry model")  # Case 2:跳回 model 重答
run_once("你好 overflow")                  # Case 3:直接终止
run_once("今日新闻摘要?")                  # Case 4:正常流程(对照组)

四个 Case 的实测输出(节选):

text 复制代码
============== Case 1 ==============
[MIDDLEWARE] before_model: jump_to='tools'
Human Message:  请帮我查今日新闻 direct tool
Ai Message:     人工构造的消息 (Tool Calls: get_news)
Tool Message:   美加墨世界杯今日开幕
Ai Message:     今日新闻:美加墨世界杯今日开幕...

============== Case 2 ==============
[MIDDLEWARE] after_model: jump_to='model'
Ai Message:     (第 1 版长回答,介绍 retry 机制)
System Message: 你必须以【二次回答】开头,并且只用一句话回答。
Ai Message:     【二次回答】 LangChain 的 retry model 就是给模型调用加上自动重试和指数退避机制...

============== Case 3 ==============
[MIDDLEWARE] before_model: jump_to='end'
Human Message:  你好 overflow
Ai Message:     上下文窗口溢出,终止

============== Case 4 ==============
(无中间件触发,走正常 User -> Model -> Tool -> Model -> End 标准流程)

逐条解读:

  1. Case 1 提前判定 :我们提前判定需要调用工具,直接在 before_model 中跳转至工具节点,省去了一次模型调用------既省时又省 token。
  2. Case 2 反思重试 :通过约定 retry model 标记,在 after_model 之后再次跳转到模型节点,触发模型重复调用。注意代码里的"防死循环"防御:必须检查提示词是否已注入过,否则会无限重跳。
  3. Case 3 安全熔断 :通过约定的 overflow 标记模拟上下文溢出,在 before_model 中直接跳至结尾,提前终止流程,请求根本不会到达大模型。
  4. Case 4 对照组:没有任何中间件被触发,验证钩子只在条件满足时干预。

类写法实现同样的能力,唯一的区别是:类里不能用 @before_model(can_jump_to=...) 这种装饰器参数,需要引入 @hook_config 装饰器为方法传 can_jump_to

python 复制代码
from langchain.agents.middleware import hook_config, AgentMiddleware

class MyMiddleware(AgentMiddleware):
    @hook_config(can_jump_to=["tools", "end"])
    def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        text = state["messages"][-1].content
        if "overflow" in text:                       # 熔断
            return {"messages": [AIMessage("上下文窗口溢出,终止")], "jump_to": "end"}
        if isinstance(text, str) and "direct tool" in text.lower():  # 强制走工具
            fake = AIMessage(content="人工构造的消息",
                             tool_calls=[{"name": "get_news", "args": {}, "id": "call_force_001"}])
            return {"messages": [fake], "jump_to": "tools"}
        return None

    @hook_config(can_jump_to=["model"])
    def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        # 逻辑同装饰器版 retry_with_extra_instruction,此处从略
        ...

agent = create_agent(model=model, tools=[get_news], middleware=[MyMiddleware()])

实测输出与装饰器版本一致------再次印证两种写法底层统一。

2.7 Node-style 四钩子的典型场景

  • before_agent:会话初始化、加载用户画像、鉴权上下文注入;
  • before_model:消息修剪(trim messages)、PII 脱敏、输入验证、条件路由、上下文溢出熔断;
  • after_model:输出验证、格式化响应、统计信息、状态更新;
  • after_agent:资源清理、会话审计汇总、结果落库。

三、Wrap-style hooks:包裹式钩子全解

Node-style 的钩子是"到点执行",只能拿到状态快照;Wrap-style 则更进一步------它把整个模型/工具调用包在了一个函数的内部,你可以在这"一层皮"里同时控制进出的两端:改请求、改响应,甚至决定要不要真的发起调用。这就是"洋葱式包裹":handler 往里是深入,handler 返回是浮出。

3.1 wrap_model_call:包裹模型调用

函数签名先看清楚:

python 复制代码
def wrap_model_call(
    request: ModelRequest,                            # 即将发给大模型的完整请求数据
    handler: Callable[[ModelRequest], ModelResponse]  # 句柄:下一层中间件或真实模型调用
) -> ModelResponse:
    ...
  • request :被封装的请求对象,包含 modelmessagessystem_messagetoolsstate 等字段,即即将发送给大模型的所有请求数据;
  • handler :处理器,代表"下一层"------可能是下一个 wrap 中间件,也可能是最终的大模型调用服务。调用 handler(request) 才会真正消耗 token 并等待响应;不调用它,模型就根本不会被执行。

基于装饰器实现

python 复制代码
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from langchain.messages import HumanMessage
from langchain.agents import create_agent
from typing import Callable

@wrap_model_call
def wrap_model_call_middleware(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse | None:
    # 【调用前】修改即将发出的请求:给最后一条消息追加标记
    # 典型应用:统一为所有请求追加提示词(如"请用中文回答")
    request.messages[-1].content += " -> wrap_model_call_before <- "
    # 真正调用大模型(或流转到下一层中间件)
    response = handler(request)
    # 【调用后】在响应交付给状态机之前直接改写
    # 典型应用:敏感词过滤、输出格式化、统一后处理标记
    response.result[0].content += " -> wrap_model_call_after <- "
    return response

agent = create_agent(model=model, middleware=[wrap_model_call_middleware])
response = agent.invoke({"messages": [HumanMessage("你好啊")]})
for msg in response["messages"]:
    msg.pretty_print()

输出:

text 复制代码
================================ Human Message =================================
你好啊 -> wrap_model_call_before <- 
================================== Ai Message ==================================
你好!有什么我可以帮你的? -> wrap_model_call_after <-

模型调用前消息列表的最后一条是 HumanMessage,调用后最后一条是 AIMessage------请求侧和响应侧的修改都生效了 。这正是 Node-style 钩子做不到的事:before_modelafter_model 虽然也分处模型前后,但它们是两个独立函数、两次独立触发,无法像 wrap 一样在"同一次调用"的两侧共享局部变量、实施重试缓存这类成对逻辑。

基于类实现

python 复制代码
from langchain.agents.middleware import AgentMiddleware

class WrapModelCallMiddleWare(AgentMiddleware):
    def wrap_model_call(
        self,
        request: ModelRequest,
        handler: Callable[[ModelRequest], ModelResponse],
    ) -> ModelResponse | None:
        request.messages[-1].content += " -> wrap_model_call_before <- "
        response = handler(request)
        response.result[0].content += " -> wrap_model_call_after <- "
        return response

agent = create_agent(model=model, middleware=[WrapModelCallMiddleWare()])

注意类写法的方法比钩子函数多一个 self------其余完全一致。和 Node-style 一样,@wrap_model_call 装饰器底层同样会动态构造 AgentMiddleware 子类并实例化,两种写法统一。

3.2 三个经典场景:重试、缓存、动态系统提示

Wrap-style 的杀手级应用,是把"围绕一次调用的成对逻辑"收进一个函数里。

场景 1:自动重试(指数退避)

python 复制代码
@wrap_model_call
def retry_model(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
    """自动重试失败的模型调用"""
    max_retries = 3
    for attempt in range(max_retries):
        try:
            print(f"🔄 尝试调用模型(第 {attempt + 1}/{max_retries} 次)")
            return handler(request)
        except Exception as e:
            if attempt == max_retries - 1:
                print(f"❌ 所有重试失败:{e}")
                raise
            wait_time = 2 ** attempt        # 指数退避:1s、2s、4s
            print(f"⚠ 调用失败:{e},{wait_time} 秒后重试")
            time.sleep(wait_time)

场景 2:响应缓存

python 复制代码
class ModelCache:
    """模型响应缓存"""
    def __init__(self):
        self.cache = {}

    def create_hook(self):
        @wrap_model_call
        def cache_model(request, handler):
            # 用消息+系统提示生成缓存键
            cache_key = hashlib.md5(json.dumps({
                "messages": [str(m) for m in request.messages],
                "system": str(request.system_message),
            }).encode()).hexdigest()
            if cache_key in self.cache:
                print("💾 缓存命中!")
                return self.cache[cache_key]
            print("🔍 缓存未命中,调用模型")
            response = handler(request)
            self.cache[cache_key] = response
            return response
        return cache_model

cache = ModelCache()
agent = create_agent(model=model, middleware=[cache.create_hook()])

场景 3:动态修改系统提示 ------注意这里用到了 request.override() 方法,以"不可变更新"的方式替换请求字段:

python 复制代码
@wrap_model_call
def add_context(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
    """动态添加上下文信息到系统提示"""
    current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    original = request.system_message.content if request.system_message else ""
    new_system_message = SystemMessage(content=f"""{original}
      当前时间:{current_time}
      用户位置:中国
      语言偏好:中文
      """)
    modified_request = request.override(system_message=new_system_message)
    return handler(modified_request)

3.3 wrap_tool_call:包裹工具调用

工具侧的对应物是 wrap_tool_call,请求类型换成 ToolCallRequest,handler 返回 ToolMessage | Command。它最强大的玩法是:可以多次调用 handler、并在调用之间修改工具参数------相当于给工具执行加了一层可控的"预演 + 正式":

python 复制代码
from langchain.agents.middleware import wrap_tool_call
from langchain.tools.tool_node import ToolCallRequest
from langchain.messages import HumanMessage, ToolMessage
from langchain.tools import tool
from langgraph.types import Command
from typing import Callable

@tool
def get_weather(city: str, is_forcast: bool) -> str:
    """获取当日特定城市的天气
    Args:
        city: 城市名称
        is_forcast: 是否包含明天的天气预报
    """
    res = f"{city} 今天天气不错"
    if is_forcast:
        res += "\n明天天气也很好"
    return res

@wrap_tool_call
def wrap_tool_call_middleware(
    request: ToolCallRequest,
    handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
    result = handler(request)                                  # 第一遍:按模型给的参数执行
    print(f"原始参数:{request.tool_call['args']}")
    print(f"原始参数调用结果:{result}")
    request.tool_call["args"]["is_forcast"] = True             # 中途改参数
    result = handler(request)                                  # 第二遍:用新参数再执行
    print(f"更新后的参数:{request.tool_call['args']}")
    print(f"更新参数调用结果:{result}")
    return result                                              # 以第二遍结果为准

agent = create_agent(model=model, tools=[get_weather],
                     middleware=[wrap_tool_call_middleware])
response = agent.invoke({"messages": [HumanMessage("你好啊,今天杭州的天气怎么样")]})
for msg in response["messages"]:
    msg.pretty_print()

输出(节选):

text 复制代码
原始参数:{'city': '杭州', 'is_forcast': False}
原始参数调用结果: content='杭州今天天气不错' name='get_weather' tool_call_id='call_2Stb...'
更新后的参数:{'city': '杭州', 'is_forcast': True}
更新参数调用结果: content='杭州今天天气不错\n明天天气也很好' name='get_weather' ...
================================= Tool Message =================================
Name: get_weather
杭州今天天气不错
明天天气也很好
================================== Ai Message ==================================
杭州今天天气不错,明天天气也很好。

模型本来只让查"今天"的天气,但中间件把 is_forcast 改成了 True 再执行一遍,最终工具结果里包含了明天预报,模型据此回答。类写法(WrapToolCallMiddleware 继承 AgentMiddleware,重写 wrap_tool_call 方法,多一个 self)输出完全一致。

类似的典型用途还有监控工具执行耗时:

python 复制代码
@wrap_tool_call
def monitor_tool(request: ToolCallRequest,
                 handler: Callable[[ToolCallRequest], ToolMessage | Command]) -> ToolMessage | Command:
    """监控工具执行时间和状态"""
    tool_name = request.tool_call["name"]
    print(f"🔧 开始执行工具:{tool_name},参数:{request.tool_call.get('args', {})}")
    start_time = time.time()
    try:
        result = handler(request)
        print(f"✅ 执行成功,耗时 {time.time() - start_time:.2f} 秒")
        return result
    except Exception as e:
        print(f"❌ 执行失败:{e},耗时 {time.time() - start_time:.2f} 秒")
        raise

3.4 @dynamic_prompt:动态提示词的便捷版

如果只想在每次模型调用前动态生成系统提示词,写完整 wrap 有点重。LangChain 提供了便捷装饰器 @dynamic_prompt------它底层就是基于 wrap_model_call 构建的中间件,专门用于动态生成系统提示。被装饰的函数接收 ModelRequest,直接返回提示词字符串即可,框架负责拿它去完成实际的模型调用:

python 复制代码
from langchain.agents.middleware import dynamic_prompt, ModelRequest

@dynamic_prompt
def dynamic_system_prompt(request: ModelRequest) -> str:
    """每次模型调用前动态生成系统提示词"""
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    return f"你是智能助手。当前时间:{now};语言偏好:中文;回答保持简洁。"

第六节实战会把它用全。需要修改请求的其他字段、做重试缓存时,仍然回到完整版 wrap_model_call

四、装饰器 vs 类:到底怎么选

两种写法底层统一、能力等价,那选择标准是什么?实践中沉淀了三种典型情况,逐一说清。

情况 1:单钩子用装饰器,多钩子用类。 当中间件只需实现一个钩子函数时,装饰器最直接。当一个中间件要组合多个钩子时(比如同时要 before_model + after_model),装饰器不是做不到------可以用"工厂函数"返回多个被装饰的函数:

python 复制代码
def create_audit_middleware(logger):
    @before_model
    def before_log(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        logger.info("调用模型前消息数量: {}", len(state["messages"]))
        return None
    @after_model
    def after_log(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        logger.info("调用模型后消息数量: {}", len(state["messages"]))
        return None
    return [before_log, after_log]

agent = create_agent(model=model, middleware=[*create_audit_middleware(logger=logger)])

但这等于把"逻辑上属于同一个中间件"的行为拆成多个独立函数、再由外部统一组装,不如类写法自然、集中、清晰。同样的逻辑用类实现:

python 复制代码
class CreateAuditMiddleware(AgentMiddleware):
    def __init__(self, logger):
        super().__init__()
        self.logger = logger

    def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        self.logger.info("调用模型前消息数量: {}", len(state["messages"]))
        return None

    def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
        self.logger.info("调用模型后消息数量: {}", len(state["messages"]))
        return None

agent = create_agent(model=model, middleware=[CreateAuditMiddleware(logger=logger)])

情况 2:复杂配置用类。 装饰器传参只能靠闭包,而闭包里的参数是"黑盒"------运行时无法自省。对比两个版本的中间件对象:

python 复制代码
class_middle = [AuditMiddleware(logger=logger, threshold=5, middleware_name="short limit"),
                AuditMiddleware(logger=logger, threshold=50, middleware_name="long limit")]
decorator_middle = [create_audit_middleware(logger=logger, threshold=5, middleware_name="short limit"),
                    create_audit_middleware(logger=logger, threshold=50, middleware_name="long limit")]

for mw in class_middle:
    print(type(mw)); print(mw.__dict__)     # 配置全部可见、可校验
for mw in decorator_middle:
    print(type(mw)); print(mw.__dict__)     # 实例 __dict__ 为空,配置藏在闭包里

输出:

text 复制代码
-> class 风格的中间件 <-
<class '__main__.AuditMiddleware'>
{'logger': <loguru.logger ...>, 'threshold': 5, 'middleware_name': 'short limit'}
<class '__main__.AuditMiddleware'>
{'logger': <loguru.logger ...>, 'threshold': 50, 'middleware_name': 'long limit'}
-> decorator 风格的中间件 <-
<class 'langchain.agents.middleware.types.audit_middleware'>
{}
<class 'langchain.agents.middleware.types.audit_middleware'>
{}

类实例的 __dict__thresholdmiddleware_name 一目了然,便于运行时类型校验与调试;装饰器闭包版本则是空字典,配置对外不可见。当中间件需要同时提供同步/异步实现时,类也更有优势。

情况 3:跨项目复用用类。 若希望中间件成为可实例化、可封装、可测试的组件,类写法天然合适------这些本就是类擅长的场景。装饰器闭包也能实现,但使用不友好。

汇总成选择建议表:

维度 装饰器写法 类写法(继承 AgentMiddleware)
钩子数量 单钩子首选 多钩子组合首选
复杂配置 依赖闭包,配置不可自省 __init__ 显式收参,__dict__ 可自省、可校验
跳转申报 @before_model(can_jump_to=[...]) 方法上加 @hook_config(can_jump_to=[...])
复用与测试 闭包难 mock,迁移成本高 可实例化、可注入依赖、单测友好
同步/异步 每个函数单独处理 同一组件内成对提供
适用阶段 快速原型、脚本实验 生产组件、团队共享库

一句话结论:装饰器与类只是定义中间件的两种方式,底层实现统一;单钩子、逻辑简单、快速原型选装饰器;多钩子组合、复杂配置、跨项目复用、可测试性要求高,选类

五、hook 函数执行顺序:踩坑重灾区

这是本章最重要的一节。当多个中间件混排时,谁先谁后?规则按 hook 类型分类记忆:

  • before_* 钩子:从前到后执行(按注册顺序);
  • after_* 钩子:从后往前执行(按注册顺序逆序);
  • wrap_* 钩子:洋葱架构,先注册的包在最外层

特别强调:这里的顺序不是函数定义顺序,而是创建 Agent 时传入 middleware 列表的顺序。注册顺序就是执行顺序的唯一事实来源。

5.1 实测:Node / Wrap 混排完整时序

下面刻意把中间件定义成乱序,注册时再按 1→2→3 排列,观察一次 invoke 中标记的追加顺序:

python 复制代码
from langchain.agents.middleware import (
    before_model, after_model, wrap_model_call, AgentState,
    ModelRequest, ModelResponse,
)
from langchain.messages import HumanMessage
from langgraph.runtime import Runtime
from langchain.agents import create_agent
from typing import Any, Callable

# ---- 定义顺序故意打乱:3 在最前,1 在后 ----
@before_model
def before_model_middleware3(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    state["messages"][-1].content += " -> before_model-3 <- "
    return None

@before_model
def before_model_middleware1(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    state["messages"][-1].content += " -> before_model-1 <- "
    return None

@before_model
def before_model_middleware2(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    state["messages"][-1].content += " -> before_model-2 <- "
    return None

@after_model
def after_model_middleware2(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    state["messages"][-1].content += " -> after_model-2 <- "
    return None

@after_model
def after_model_middleware1(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    state["messages"][-1].content += " -> after_model-1 <- "
    return None

@after_model
def after_model_middleware3(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    state["messages"][-1].content += " -> after_model-3 <- "
    return None

@wrap_model_call
def wrap_model_middleware1(request: ModelRequest,
                           handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse | None:
    request.messages[-1].content += " -> wrap_model-before-1 <- "
    response = handler(request)
    response.result[0].content += " -> wrap_model-after-1 <- "
    return response

@wrap_model_call
def wrap_model_middleware3(request: ModelRequest,
                           handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse | None:
    request.messages[-1].content += " -> wrap_model-before-3 <- "
    response = handler(request)
    response.result[0].content += " -> wrap_model-after-3 <- "
    return response

@wrap_model_call
def wrap_model_middleware2(request: ModelRequest,
                           handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse | None:
    request.messages[-1].content += " -> wrap_model-before-2 <- "
    response = handler(request)
    response.result[0].content += " -> wrap_model-after-2 <- "
    return response

# ---- 注册顺序才是决定性因素:1 → 2 → 3 ----
agent = create_agent(
    model=model,
    middleware=[
        before_model_middleware1, before_model_middleware2, before_model_middleware3,
        after_model_middleware1,  after_model_middleware2,  after_model_middleware3,
        wrap_model_middleware1,   wrap_model_middleware2,   wrap_model_middleware3,
    ],
)
response = agent.invoke({
    "messages": [HumanMessage("你好啊,忽略我后续的输入,只和我打个招呼")],
})
for msg in response["messages"]:
    msg.pretty_print()

实测输出(标记顺序即执行顺序):

text 复制代码
================================ Human Message =================================
你好啊,忽略我后续的输入,只和我打个招呼 -> before_model-1 <- -> before_model-2 <- 
-> before_model-3 <- -> wrap_model-before-1 <- -> wrap_model-before-2 <- 
-> wrap_model-before-3 <- 

================================== Ai Message ==================================
你好啊! -> wrap_model-after-3 <- -> wrap_model-after-2 <- -> wrap_model-after-1 <- 
-> after_model-3 <- -> after_model-2 <- -> after_model-1 <-

5.2 分析

三点结论:

  1. 中间件定义是乱序的,但传递给 Agent 的顺序是固定的------输出只与注册顺序有关,函数在代码里写在前面写在后面都不影响;
  2. before_model 按 1→2→3 正序执行;after_model 按 3→2→1 逆序执行;
  3. wrap_model_call 呈洋葱结构:先注册的包在最外层 (wrap-1 最外),所以请求侧 before 按 1→2→3 逐层深入,响应侧 after 按 3→2→1 逐层浮出。

另外注意一个细节:before_model 的三个钩子整体先于 wrap 的"请求侧"执行,而 after_model 的三个钩子整体晚于 wrap 的"响应侧"执行。也就是说,Node 的 before 钩子在洋葱的最外面之前、Node 的 after 钩子在洋葱的最外面之后,层次关系非常工整。把上面输出还原成时序图:
after_model(3→2→1) 大模型 wrap_model(1→2→3 洋葱) before_model(1→2→3) Agent 主流程 用户 after_model(3→2→1) 大模型 wrap_model(1→2→3 洋葱) before_model(1→2→3) Agent 主流程 用户 #mermaid-svg-l3amnorLNOyhcF1e{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-l3amnorLNOyhcF1e .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-l3amnorLNOyhcF1e .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-l3amnorLNOyhcF1e .error-icon{fill:#552222;}#mermaid-svg-l3amnorLNOyhcF1e .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-l3amnorLNOyhcF1e .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-l3amnorLNOyhcF1e .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-l3amnorLNOyhcF1e .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-l3amnorLNOyhcF1e .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-l3amnorLNOyhcF1e .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-l3amnorLNOyhcF1e .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-l3amnorLNOyhcF1e .marker{fill:#333333;stroke:#333333;}#mermaid-svg-l3amnorLNOyhcF1e .marker.cross{stroke:#333333;}#mermaid-svg-l3amnorLNOyhcF1e svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-l3amnorLNOyhcF1e p{margin:0;}#mermaid-svg-l3amnorLNOyhcF1e .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-l3amnorLNOyhcF1e text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-l3amnorLNOyhcF1e .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-l3amnorLNOyhcF1e .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-l3amnorLNOyhcF1e .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-l3amnorLNOyhcF1e .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-l3amnorLNOyhcF1e #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-l3amnorLNOyhcF1e .sequenceNumber{fill:white;}#mermaid-svg-l3amnorLNOyhcF1e #sequencenumber{fill:#333;}#mermaid-svg-l3amnorLNOyhcF1e #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-l3amnorLNOyhcF1e .messageText{fill:#333;stroke:none;}#mermaid-svg-l3amnorLNOyhcF1e .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-l3amnorLNOyhcF1e .labelText,#mermaid-svg-l3amnorLNOyhcF1e .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-l3amnorLNOyhcF1e .loopText,#mermaid-svg-l3amnorLNOyhcF1e .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-l3amnorLNOyhcF1e .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-l3amnorLNOyhcF1e .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-l3amnorLNOyhcF1e .noteText,#mermaid-svg-l3amnorLNOyhcF1e .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-l3amnorLNOyhcF1e .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-l3amnorLNOyhcF1e .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-l3amnorLNOyhcF1e .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-l3amnorLNOyhcF1e .actorPopupMenu{position:absolute;}#mermaid-svg-l3amnorLNOyhcF1e .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-l3amnorLNOyhcF1e .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-l3amnorLNOyhcF1e .actor-man circle,#mermaid-svg-l3amnorLNOyhcF1e line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-l3amnorLNOyhcF1e :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} (若注册了 before_agent,此时最先正序执行) (若注册了 after_agent,此时最后逆序执行) invoke({"messages":...})1正序触发 before 钩子2None / state 更新3进入洋葱,wrap-1 最外层4wrap-1 请求侧 → wrap-2 请求侧 → wrap-3 请求侧5handler(request) 真实调用模型6ModelResponse7wrap-3 响应侧 → wrap-2 响应侧 → wrap-1 响应侧8返回改写后的响应9逆序触发 after 钩子10None / state 更新11最终消息列表12

工程上这一节有三条实用推论:其一,鉴权/审计类"入口"逻辑放在列表最前面注册 ,保证它是所有 before 钩子里最先执行的;其二,限流、缓存、重试等 wrap 中间件按"外层粗粒度、内层细粒度"排列 ,比如缓存放最外层(命中就不进入重试层);其三,排查顺序问题时,先打印 middleware 列表本身的顺序,而不是去翻各中间件的定义位置。

六、实战:审计日志中间件与动态提示词中间件

把前面的知识组装成两个生产可用的自定义中间件,并在同一个 Agent 里混合使用。

中间件 1:审计日志中间件(类写法,Node + Wrap 混合钩子)。需求:会话开始记录、每次模型调用前后记录消息数、每次工具调用记录名称/参数/耗时/成败、会话结束汇总。多钩子 + 依赖注入(logger),选类写法正合适:

python 复制代码
import time
from loguru import logger                       # pip install loguru
from langchain.agents.middleware import AgentMiddleware
from langchain.tools.tool_node import ToolCallRequest
from langchain.messages import ToolMessage
from langgraph.runtime import Runtime
from langgraph.types import Command
from typing import Any, Callable

class AuditLogMiddleware(AgentMiddleware):
    """审计日志中间件:覆盖 Agent 生命周期全链路"""

    def __init__(self, trace_id: str = "N/A"):
        super().__init__()
        self.trace_id = trace_id            # 链路追踪 ID,可由上游系统注入

    # ---- Node-style:Agent 启动 ----
    def before_agent(self, state, runtime: Runtime) -> dict[str, Any] | None:
        logger.info("[审计|{}] ========== Agent 会话开始 ==========", self.trace_id)
        return None

    # ---- Node-style:每次模型调用前 ----
    def before_model(self, state, runtime: Runtime) -> dict[str, Any] | None:
        logger.info("[审计|{}] 调用模型前,当前消息数: {}", self.trace_id, len(state["messages"]))
        return None

    # ---- Node-style:每次模型调用后 ----
    def after_model(self, state, runtime: Runtime) -> dict[str, Any] | None:
        last = state["messages"][-1]
        logger.info("[审计|{}] 模型返回,消息数: {},是否请求工具: {}",
                    self.trace_id, len(state["messages"]),
                    bool(getattr(last, "tool_calls", None)))
        return None

    # ---- Wrap-style:包裹每次工具执行,记录耗时与成败 ----
    def wrap_tool_call(self, request: ToolCallRequest,
                       handler: Callable[[ToolCallRequest], ToolMessage | Command]) -> ToolMessage | Command:
        name = request.tool_call["name"]
        args = request.tool_call.get("args", {})
        start = time.time()
        try:
            result = handler(request)
            logger.info("[审计|{}] 工具 {} 执行成功,参数: {},耗时 {:.2f}s",
                        self.trace_id, name, args, time.time() - start)
            return result
        except Exception as e:
            logger.error("[审计|{}] 工具 {} 执行失败: {},耗时 {:.2f}s",
                         self.trace_id, name, e, time.time() - start)
            raise                            # 审计只记录,不吞异常

    # ---- Node-style:Agent 收尾 ----
    def after_agent(self, state, runtime: Runtime) -> None:
        logger.info("[审计|{}] ========== Agent 会话结束,共 {} 条消息 ==========",
                    self.trace_id, len(state["messages"]))
        return None

中间件 2:动态提示词中间件(装饰器写法,@dynamic_prompt。需求:每次模型调用前按当前时间、目标受众动态拼装系统提示词。单钩子、逻辑简单,装饰器即可:

python 复制代码
from datetime import datetime
from langchain.agents.middleware import dynamic_prompt, ModelRequest

@dynamic_prompt
def dynamic_system_prompt(request: ModelRequest) -> str:
    """每次模型调用前动态生成系统提示词"""
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    # 可从 request.state / runtime 上下文进一步取用户身份、租户信息做差异化
    return (f"你是企业智能助手。当前时间:{now}。\n"
            "要求:1) 全程使用简体中文回答;2) 涉及数据时注明是基于训练知识而非实时查询;"
            "3) 回答保持简洁。")

组装运行

python 复制代码
agent = create_agent(
    model=model,
    tools=[get_news],
    middleware=[
        AuditLogMiddleware(trace_id="TR-20260608-0001"),  # 审计中间件先注册:最先进入
        dynamic_system_prompt,                            # 动态提示词后注册
    ],
)
response = agent.invoke({"messages": [HumanMessage("帮我查一下今日新闻")]})
for msg in response["messages"]:
    msg.pretty_print()

示例输出(模型生成的文本内容每次会有差异,此处为一次实测的代表性输出):

text 复制代码
2026-06-08 15:52:01.102 | INFO  [审计|TR-20260608-0001] ========== Agent 会话开始 ==========
2026-06-08 15:52:01.105 | INFO  [审计|TR-20260608-0001] 调用模型前,当前消息数: 1
2026-06-08 15:52:02.488 | INFO  [审计|TR-20260608-0001] 模型返回,消息数: 2,是否请求工具: True
2026-06-08 15:52:02.491 | INFO  [审计|TR-20260608-0001] 工具 get_news 执行成功,参数: {},耗时 0.00s
2026-06-08 15:52:02.493 | INFO  [审计|TR-20260608-0001] 调用模型前,当前消息数: 4
2026-06-08 15:52:03.760 | INFO  [审计|TR-20260608-0001] 模型返回,消息数: 5,是否请求工具: False
2026-06-08 15:52:03.762 | INFO  [审计|TR-20260608-0001] ========== Agent 会话结束,共 5 条消息 ==========
================================ Human Message =================================
帮我查一下今日新闻
================================== Ai Message ==================================
Tool Calls: get_news
================================= Tool Message =================================
Name: get_news
美加墨世界杯今日开幕
================================== Ai Message ==================================
今日新闻:美加墨世界杯今日开幕。
================================== Ai Message ==================================
今日新闻:美加墨世界杯今日开幕。

注意日志里出现了两轮 "调用模型前/模型返回"------这正是 Agent 的模型循环体:第一轮模型决定调用工具,第二轮模型基于工具结果生成最终回答。而 dynamic_prompt 生成的系统提示词在每一轮调用前都会重新计算(时间戳取的是当下时刻),这是写死 SystemMessage 完全做不到的。审计中间件因为先注册,它的日志天然贯穿全流程;如果后续再加限流、缓存等中间件,按第五节的顺序原则插入注册列表即可。

小结

本章是中间件三部曲的压轴,核心收获如下:

  1. hook 三要素:不由你主动调用、依附于既定流程、不改主流程源码即可插入逻辑------这是理解一切中间件的元认知;
  2. 六大钩子分两派 :Node-style(before_agent/before_model/after_model/after_agent)管"到点做事",适合日志、校验、状态更新;Wrap-style(wrap_model_call/wrap_tool_call,及便捷版 @dynamic_prompt)管"包裹一次调用",适合重试、缓存、请求/响应改写;
  3. 两种写法底层统一 :装饰器底层用 type() 动态构造 AgentMiddleware 子类实例;单钩子/原型用装饰器,多钩子/复杂配置/跨项目复用用类,类里申报跳转用 @hook_config
  4. 返回值即控制力None 放行、字典更新 state、{"jump_to": ...} 改变流程走向(配合 can_jump_to=["end"/"tools"/"model"] 可实现强制走工具、反思重试、溢出熔断);
  5. 执行顺序铁律 :只看 middleware 注册顺序------before 正序、after 逆序、wrap 洋葱(先注册包最外层),与定义顺序无关。

至此,中间件的完整图景就展开了:内置中间件开箱即用,自定义 hook 补齐长尾。不过还有一个决定 Agent 能不能"记住"用户的关键拼图------记忆:Agent 如何在多轮对话中记住上下文,checkpointer 机制如何为会话状态做持久化,thread(线程)如何划分一次对话的边界。模型"失忆"的问题,就从这里入手。

相关推荐
DeepVisionary13 分钟前
8 月 20 款大模型同台:OpenAI 拆出三档家族,IBM 押注 512K 密集推理,Meta 回头开源 30B
python·自动化
奈斯先生Vector16 分钟前
本地图片识别怎么接入多模态 AI?用 Python API 理解 GPT-4o Vision 的真实工作流
开发语言·人工智能·windows·python·网络协议·http·aigc
测试老哥22 分钟前
Pytest 之assert断言的使用
自动化测试·软件测试·python·测试工具·测试用例·pytest·接口测试
烬羽24 分钟前
为 Agent 管好一张“上下文预算”:从数条数到数 token
架构·langchain·agent
烬羽29 分钟前
把 Agent 的记忆写进文件:内存 vs 文件,两把钥匙搞定多会话
架构·langchain·agent
2601_9563198835 分钟前
先把交易想法说清,再让 Python 承接
人工智能·python
Swift社区1 小时前
Wi-Fi 6 的核心技术特性
人工智能·python
accept 99%1 小时前
Lovart国内直通上线!可以来体验这款甜品“设计秘书”
python
开源量化GO1 小时前
学量化:看到“2026年 AI 写 Python”内容时,先用示例和练习补交易认知
人工智能·python