LangChain 应用开发(十三):自定义中间件与 Hook
2026/9/17 0:45:38 网站建设 项目流程

目录

一、回顾 Middleware 与 Hook

1. 什么是 Hook

2. LangChain Hook 的两大分类

二、Node-style Hooks

1. 什么是 Node-style Hook

2. 使用装饰器定义 Hook

3. 使用类定义

三、深入理解 Node-style Hook

1. Node-style Hook 能做什么

2. 改变执行路径与 can_jump_to

3. 代码案例

四、Wrap-style Hooks

1. Node-style 与 Wrap-style

2. wrap_model_call:掌控大模型调用

3. wrap_tool_call:掌控工具执行

五、装饰器与类实现的统一

1. 装饰器写法的本质

2. 为什么提供两种方式

六、装饰器还是 Middleware 类

七、Hook 的执行顺序

1. 单个 Middleware 的生命周期

2. 多个 Middleware 的拦截顺序

总结


一、回顾 Middleware 与 Hook

在上篇中,我们拆解了 HumanInTheLoopMiddleware、PIIMiddleware 以及 TodoListMiddleware 等内置中间件。在不需要修改 LLM 提示词或工具内部代码的前提下,完成了数据脱敏、人工审批与任务规划

但面对特定业务逻辑时,内置中间件显然不够用。要实现任意精细度的拦截,就需要掌握自定义 Middleware 的内核原理:Hook(钩子)


1. 什么是 Hook

Hook(钩子)是 Agent 在执行生命周期中暴露给开发者的拦截接口

一个完整的 Agent 推导循环包含多个固定阶段:接收用户输入、构建模型请求、调用 LLM、解析工具指令、执行工具调用以及更新状态记录。Hook 就像是在这条流水线的关键位置预留的插槽,允许我们在特定时机插入自定义代码,以读取修改乃至改变Agent 的执行路径

引入自定义 Middleware 的根本原因在于:实现业务逻辑与底层 Agent 推导架构的解耦。无需改动模型配置或工具代码,即可像挂载插件一样随插随用


2. LangChain Hook 的两大分类

LangChain 将 Hook 分为两套核心机制:Node-style HooksWrap-style Hooks

Middleware │ ┌────────────┴────────────┐ ↓ ↓ Node-style Hooks Wrap-style Hooks │ │ 在特定节点执行 包裹一次调用 │ │ before / after ... wrap_model_call wrap_tool_call

Node-style 更像 "执行到这里时顺便做点什么",Wrap-style 更像 "把原来的执行过程整个包起来"

  • Node-style Hooks:基于生命周期节点的单点拦截。Agent 执行到特定的状态节点时(如模型调用前 before_model)触发运行,适合做日志记录、状态校验或控制跳转

  • Wrap-style Hooks:基于闭包与控制权移交的环绕拦截。它不只是在两端做加法,而是将整个模型或工具的调用过程直接包裹(Wrap)起来,由你的代码控制原始操作何时执行执行几次(如重试机制)以及如何捕获异常

二、Node-style Hooks

与传统后端框架中的生命周期钩子类似,Node-style Hooks作用于 Agent 图流程中的固定节点。当 Agent 执行流动到特定阶段时,对应注册的 Hook 函数就会被自动触发


1. 什么是 Node-style Hook

Node-style Hook 的核心在于节点控制。Agent 在单次调用或推导循环中,按固定轨迹运行,Hook 则插入在这些关键节点的前后:

按 Agent 执行生命周期排列,常用 Hook 分为两类:

  • 任务级钩子(单次运行触发一次)

    • before_agent:Agent 接收到用户请求、启动推导图之前执行,适合用于上下文初始化或全局权限校验

    • after_agent:Agent 完成所有推导与工具执行并准备输出最终结果时执行,适合清理资源或进行最终审计

  • 循环级钩子(每次推导循环触发)

    • before_model:Agent 在将消息发送给 LLM 之前触发,常用于动态修改 Prompt、删减过长上下文或拦截校验

    • after_model:大模型返回响应后立即触发,常用于校验输出格式、捕获敏感内容或记录耗时


2. 使用装饰器定义 Hook

使用装饰器是实现单点拦截最轻量的方式。以下是 @before_model 的典型用法:

from langchain.agents.middleware import before_model from langgraph.pregel.types import StateT from langgraph.runtime import Runtime from typing import Any, Optional @before_model def log_and_guard(state: dict[str, Any], runtime: Runtime) -> Optional[dict[str, Any]]: # 打印当前消息历史数量 messages = state.get("messages", []) print(f"[Log] 准备调用模型,当前消息数量: {len(messages)}") # 若消息数量异常,可注入预警信息 if len(messages) > 20: return {"system_notice": "上下文过多,请注意压缩"} return None

理解装饰器定义的 Hook,只需看透三个核心要素:

  • @before_model:这个函数什么时候执行?挂载此装饰器后,每次 Agent 循环准备发起 LLM 请求前,该函数都会被自动调用

  • state / runtime:能拿到什么?

    • state:当前的 Agent 状态字典(如包含对话历史 messages 或自定义状态属性)

    • runtime:当前 Agent 的运行环境上下文(如配置参数、Thread ID、数据存储资源等)

  • return:能修改什么?

    • 返回dict:该字典会通过状态图的 Reducer 机制合并更新到 Agent State 中

    • 返回None:不做任何状态修改,仅执行只读操作(如日志上报、埋点)

    • 返回控制指令:更改控制流直接跳过后续节点


3. 使用类定义

当逻辑变复杂或需要跨节点协同时,应使用继承 AgentMiddleware 的类来实现:

class MyMiddleware(AgentMiddleware): def __init__(self): super().__init__() def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None: print(" -> before_model <- ") return None def after_model(self, state: AgentState, runtime: Runtime) -> dict[str,Any] | None: print(" -> after_model <- ") return None agent = create_agent( model=model, middleware=[MyMiddleware()], ) agent.invoke({ "messages": [ {"role": "user", "content": "你好,请介绍一下你自己"} ] })

输出结果:

无论采用装饰器写法,还是类定义写法(继承 AgentMiddleware 并重写 before_model 方法),两者的底层机制完全等价——它们本质上都是向 Agent 系统的生命周期节点注册拦截逻辑

三、深入理解 Node-style Hook

理解 Node-style Hook 的关键,在于不要把它当成死板的 "参数手册",而是明白它能为 Agent 注入什么能力


1. Node-style Hook 能做什么

归纳来看,Node-style Hook 赋予了开发者以下几个维度的控制力:

Node-style Hook │ ├── 读取当前 State ────────► 监控/埋点/状态审计 │ ├── 修改 State ───────────► 注入动态 Prompt/更新元数据 │ ├── 修改上下文 ───────────► 裁剪过长 Messages 历史 │ └── 改变后续执行路径 ─────► 条件跳转/拦截阻断 (can_jump_to)

2. 改变执行路径与 can_jump_to

在常规的推导生命周期中,Agent 严格按照预设链路运行:

before_model ──► Model 调用 ──► after_model ──► Tool 调用

但在真实业务场景中,中间件经常需要打破这一默认路径。例如:

  • 缓存命中:在 before_model 中发现用户提问已在 Redis 中缓存,无需调用 LLM,想直接输出答案并结束

  • 安全阻断:在 before_model 中检测到违规输入,需要跳过 LLM 和 Tool 执行,直接跳转至结束阶段并返回提示

这就是can_jump_to解决的痛点

LangChain Agent 底层由LangGraph图引擎驱动。can_jump_to 本质上决定了:该 Middleware 是否有权限修改 Agent 图模型的控制流 Edge(条件边)

当中间件配置了 can_jump_to,且 Hook 返回了特定的控制跳转信号时, Agent 图引擎将立即终止原本顺位执行的下一个节点,直接将控制权移交给指定的受控节点。这极大地提升了 Agent 在应对复杂异常和业务分支时的灵活性


3. 代码案例

can_jump_to 接收一个列表,用于在编译 Agent 控制图时显式声明允许跳转的目标节点,从而建立条件边。目前支持的三个标准合法值包括:

  • 'end':直接跳转至结束。跳过后续的大模型推导与工具执行,直接终止流程或进入 after_agent 阶段。适用于安全拦截、触发表单熔断、命中缓存直接返回结果等场景

  • 'tools':直接跳转至工具节点。绕过 LLM 的思考与路由阶段,直接将执行流移交给 Tool 执行节点。适用于基于规则的指令匹配

  • 'model':强制跳转回模型节点。重新触发 LLM 的推导循环。适用于修正上下文后要求模型重新推理

在 Hook 中触发跳转时,需要在装饰器声明 can_jump_to 的同时,在 Hook 返回值字典中指定 "jump_to" 目标

装饰器写法(敏感词阻断与早退)

# 1. 显式声明该 Hook 允许跳转至 'end' 节点 @before_model(can_jump_to=["end"]) def sensitive_word_guard(state: dict[str, Any], runtime: Runtime) -> Optional[dict[str, Any]]: messages = state.get("messages", []) if not messages: return None last_user_msg = messages[-1].content # 检测到违规输入,拦截并提前结束 if "退款账号密码" in last_user_msg: print("[Guard] 检测到高风险提问,强行拦截!") return { # 注入提示消息 "messages": [AIMessage(content="安全提示:请勿在对话框中输入账户密码信息。")], # 控制流指令:直接跳过 LLM 和 Tool,跳转至 end "jump_to": "end" } return None

类写法

如果通过类继承 AgentMiddleware,需要借助 @hook_config 装饰器为具体的 Hook 方法标记 can_jump_to 权限:

class TokenQuotaMiddleware(AgentMiddleware): def __init__(self, max_messages: int = 30): self.max_messages = max_messages # 使用 hook_config 标记 can_jump_to 声明条件跳转路径 @hook_config(can_jump_to=["end"]) def before_model(self, state: dict[str, Any], runtime: Runtime) -> Optional[dict[str, Any]]: messages = state.get("messages", []) # 超过最大对话轮数,中断 Agent 并直接结束 if len(messages) >= self.max_messages: return { "messages": [AIMessage(content="已达到单次会话消息上限,请发起新对话。")], "jump_to": "end" } return None

四、Wrap-style Hooks

Wrap-style Hooks赋予了开发者完全的控制下发权,不仅能观测前后,更能决定原始调用是否执行、执行几次,以及如何应对异常


1. Node-style 与 Wrap-style

从思维方式来看,两者的拦截视角有着本质区别:

在 Wrap-style 中,中间件函数不会被系统自动按顺位推着走,而是拿到一个指向原始操作的句柄——handler

  • handler 是什么?handler(request)就是 "通知系统:按照原本的计划,去真正发起 LLM 调用或执行 Tool"

  • 为什么要包裹?因为有了 handler 的控制权,你可以在它外层套上 try-except(捕获异常)、while 循环(失败重试)、耗时计算计时器,甚至在调用前直接修改 request 里的参数


2. wrap_model_call:掌控大模型调用

wrap_model_call 专门用来环绕包裹 Agent 对 LLM 的底层请求

@wrap_model_call def custom_model_call(request: Any, handler: Any) -> Any: # 1. Calling LLM 前:记录耗时、打印/修改请求 start_time = time.time() print(f"[Model Wrap] 准备调用 LLM,请求模型: {getattr(request, 'model', '未知')}") # 2. 核心:由我们决定何时交出控制权,发起真正调用 try: response = handler(request) except Exception as e: print(f"[Model Wrap] LLM 调用抛出异常: {e}") raise e # 3. Calling LLM 后:审计返回结果、计算耗时 cost_time = time.time() - start_time print(f"[Model Wrap] LLM 调用完成,耗时: {cost_time:.2f}s") return response agent = create_agent( model=model, middleware=[custom_model_call], ) agent.invoke({ "messages": [ {"role": "user", "content": "你好,请介绍一下你自己"} ] })

输出结果片段:

Wrap-style 能在模型调用层实现的能力

  1. 修改请求:在调用 handler(request) 之前,可以修改 request 中的 Prompt、Temperature 或调整传入的 tools 工具列表

  2. 错误重试与降级:当 handler(request) 抛出网络超时或限流异常时,可以使用 for 循环重新调用;或者在重试多次依然失败后,切换备用模型请求

  3. 响应改写与过滤:在 handler(request) 返回 response 后,可以在将其交还给 Agent 前进行格式修饰或敏感词二次过滤


3. wrap_tool_call:掌控工具执行

与 wrap_model_call 对应,wrap_tool_call 专门用于拦截和环绕 Agent 对 Tool 工具 的实际调用

代码示例:带重试与异常保护的 Tool 拦截器

@wrap_tool_call def safe_tool_executor(request: Any, handler: Any) -> Any: tool_name = getattr(request, "name", "unknown_tool") print(f"[Tool Wrap] 准备执行工具: {tool_name}") # 设置最多重试 2 次 max_retries = 2 for attempt in range(max_retries + 1): try: # 移交控制权,真正执行业务 Tool result = handler(request) print(f"[Tool Wrap] 工具 {tool_name} 执行成功") return result except Exception as e: print(f"[Tool Wrap] 工具 {tool_name} 第 {attempt + 1} 次执行报错: {e}") if attempt == max_retries: # 达到重试上限后兜底,避免系统直接 Crash return f"工具 {tool_name} 执行失败,错误信息: {str(e)}" agent = create_agent( model=model, middleware=[safe_tool_executor], ) agent.invoke({ "messages": [ {"role": "user", "content": "你好,请介绍一下你自己"} ] })

上一篇博客中提到的 ToolRetryMiddleware(工具重试中间件)与 ToolErrorMiddleware(工具异常捕捉中间件),它们在底层的实现原理正是基于 wrap_tool_call;而 ModelFallbackMiddleware(模型降级中间件)则是基于 wrap_model_call

五、装饰器与类实现的统一

在前面的章节中,我们分别展示了装饰器与类继承两种编写方式。它们在底层完全统一。装饰器并不是另一种机制,而是 LangChain 提供的语法糖——它在运行时动态地将你的函数封装为一个 AgentMiddleware 实例


1. 装饰器写法的本质

当你写以下装饰器代码时:

@before_model(can_jump_to=["end"]) def my_hook(state, runtime): ...

LangChain 会在后台自动创建一个 AgentMiddleware 的匿名子类,把 my_hook 函数绑定为该子类的 before_model 方法,并根据装饰器参数生成对应的配置项。最终传入 create_agent(middleware=[...]) 的,依然是一个标准的 AgentMiddleware 对象

类写法则是直接将这种结构显式表达出来:

class MyMiddleware(AgentMiddleware): @hook_config(can_jump_to=["end"]) def before_model(self, state, runtime): ...

无论选择哪种方式,Agent 引擎在编译执行图时,处理 Hook 的逻辑与执行顺序是完全一致的


2. 为什么提供两种方式

这种 "单函数装饰器 + 完整类" 的设计,是为了平衡开发敏捷完备性

  • 装饰器模式(降低门槛):适合轻量化、单一功能的 Hook(例如临时打日志、单步参数改写)。无需手写类模板代码,几行函数加个装饰器就能即插即用

  • 类继承模式(工程首选):适合复杂场景。当 Middleware 需要接收初始化参数(如重试次数、阈值)、维持内部状态同时定义同步与异步实现,或者需要组合多个 Hook 方法时,类结构能提供极佳的封装性与重用性

六、装饰器还是 Middleware 类

理解了底层机制后,最直接的问题就是:面对具体的业务需求,到底该选装饰器还是 Middleware 类?

这取决于功能的复杂程度是否需要跨 Hook 共享状态/配置

选型逻辑

  • 优先选择装饰器
    当拦截逻辑足够单一、无状态时,装饰器是最高效的选择。例如:单步日志上报、单次 Prompt 追加、简单的输入参数合法性校验或快速代码实验。它无需编写类继承结构,随手写个函数加上装饰器即可生效

  • 优先选择 Middleware 类
    当中间件涉及状态维护、配置注入或多钩子协同时,继承 AgentMiddleware 是唯一优雅的方式。例如:需要在 before_model 记录开始时间并在 after_model 计算耗时(跨 Hook 传递变量)、需要通过 __init__ 接收用户配置参数(如重试次数、过滤规则)、或者需要将拦截能力封装为独立的 Python 包供其他项目复用

七、Hook 的执行顺序

当我们在 Agent 中配置了多个 Middleware 时,拦截逻辑并不是乱序执行的,而是遵循着严格的生命周期顺序与嵌套结构


1. 单个 Middleware 的生命周期

在一个标准的 Agent 推导循环中,各个 Hook 触发的先后顺序如下:

before_agent (全局初始化,仅一次) │ ▼ ◄─────────────────────────────────────────────┐ before_model │ │ │ wrap_model_call (进入包裹) │ │ │ ├──► [ Model 实际调用 ] │ ( Agent 循环) │ │ after_model │ │ │ wrap_tool_call (进入包裹) │ │ │ └──► [ Tool 实际执行 ] ─────────────────────────┘ │ ▼ after_agent (全局收尾,仅一次)

2. 多个 Middleware 的拦截顺序

当传入 create_agent(middleware=[M1, M2, M3]) 注册多个中间件时,执行顺序遵循洋葱模型

请求进入 ──► [ M1 ] ──► [ M2 ] ──► [ M3 ] ──► [ 核心 Model / Tool ] │ 响应返回 ◄── [ M1 ] ◄── [ M2 ] ◄── [ M3 ] ◄────────────┘

各类型的具体排序规则如下:

  • before_* 钩子(正序):按照列表传入顺序从头到尾依次执行(M1.before_model -> M2.before_model -> M3.before_model)

  • wrap_*​​​​​​​ 钩子(嵌套):最前端的中间件处于最外层包裹。M1 的 handler 指向 M2,M2 指向 M3,最后才到达真正的底座机制

  • after_*​​​​​​​ 钩子(逆序):按照倒序(先进后出)触发(M3.after_model -> M2.after_model -> M1.after_model)

总结

回顾自定义 Middleware 的全部内核原理,我们可以将所有知识归纳为三个维度的设计抉择:

1. 拦截时机

  • Agent 级(before_agent / after_agent):全局一次性逻辑(初始化、全流程审计)

  • Model 级(before_model / after_model / wrap_model_call):大模型推导前后或请求拦截(Prompt 注入、限流、重试)

  • Tool 级(wrap_tool_call):工具调用环绕拦截(入参校验、异常兜底)

2. 拦截方式

  • Node-style:基于状态节点(接收 state / runtime,返回更新字典或跳转指令)

  • Wrap-style:基于闭包句柄(接收 request / handler,控制原始调用的执行次数与异常处理)

3. 实现形式

  • 轻量装饰器(如 @before_model):单函数无状态拦截,代码极简

  • 继承类(AgentMiddleware):维护内部配置与状态,支持跨 Hook 协同,适合工程化复用

至此,我们已经能够根据业务需求主动介入 Agent 的执行过程。下一篇将进入上下文与记忆,学习 Agent 如何保存、管理和利用运行过程中的信息,让智能体不仅能够执行任务,还能够在多轮交互中持续利用上下文

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询