国庆节10月1日
--


六个 hook 函数(before/after_agent、before/after_model、wrap_model_call、wrap_tool_call)的两类风格、装饰器与类两种写法、state/runtime 参数、返回值与 jump_to 流程跳转

内置中间件覆盖了绝大多数常见需求,但复杂场景下它们不一定够用——这时可以实现 LangChain 暴露的 hook 函数来构建自定义中间件。
官方建议:尽可能使用内置中间件。自定义中间件是”内置不够用”时的补充手段,不是首选。
Hook 函数指的是:在某个既定流程的特定时机,被框架、系统或主程序自动调用的扩展函数。可以理解成主流程预留了一些”插槽”,允许你在这些位置挂上自己的函数。
三个核心特点:
图:主流程预留”插槽”,在钩子点挂上自定义函数(车间生产流程类比)
LangChain 1.2 的 AgentMiddleware 上共有六个 hook(可用 dir(AgentMiddleware) 核对):
| 类型 | hook | 触发时机 |
|---|---|---|
| Node-style(节点风格) | before_agent | Agent 开始运行之前 |
before_model | 模型调用之前 | |
after_model | 模型调用之后 | |
after_agent | Agent 流程全部完成后 | |
| Wrap-style(包装风格) | wrap_model_call | 包裹模型调用 |
wrap_tool_call | 包裹工具调用 |
两类风格的定位不一样:
1from typing import Any2
3from langchain.agents import create_agent4from langchain.agents.middleware import after_model, before_model, AgentState5from langchain.messages import HumanMessage6from langgraph.runtime import Runtime7
8# 1. 定义 before_model 钩子9@before_model10def before_model_middleware(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:11 state["messages"][-1].content += " -> before_model <- "12 return None13
14# 2. 定义 after_model 钩子15@after_model16def after_model_middleware(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:17 state["messages"][-1].content += " -> after_model <- "18 return None19
20agent = create_agent(21 model=model,22 middleware=[before_model_middleware, after_model_middleware],23)三条关键规则:① 必须继承 AgentMiddleware(固定)② 方法名固定(before_model、after_model……)③ 类名随意(只有类名不固定)。
1from langchain.agents.middleware import AgentMiddleware2
3class MyMiddleware(AgentMiddleware):4 def before_model(self, state, runtime):5 print("[我的中间件] before_model")6 return None7
8 def after_model(self, state, runtime):9 print("[我的中间件] after_model")10 return None11
12agent = create_agent(model=model, middleware=[MyMiddleware()])两种写法其实是同一件事:装饰器底层会基于你写的函数构造一个 AgentMiddleware 子类的实例(大致是 type(name, (AgentMiddleware,), {"before_model": wrapped, ...})())。
所以 LangGraph 只关心两件事:是否继承了 AgentMiddleware?是否实现了对应的 hook 方法?
| 参数 | 说明 |
|---|---|
state | AgentState 实例,维护 Agent 运行过程中的状态(会随运行变化,包括消息列表) |
runtime | Runtime 实例,维护运行过程中的上下文环境(包括上下文、长期记忆等) |
1# ① 返回 None:不修改状态,继续流程2def before_model(self, state, runtime):3 print("日志记录")4 return None5
6# ② 返回字典:更新状态7def after_model(self, state, runtime):8 count = state.get("count", 0)9 return {"count": count + 1}10
11# ③ 返回 {"jump_to": "..."}:控制流程跳转12def before_model(self, state, runtime):13 if state.get("count", 0) > 10:14 return {"jump_to": "__end__"} # 跳过模型,直接结束15 return Nonejump_to 的常见目标:"__end__"(结束 Agent)、"tools"(跳到工具节点)、其他自定义节点。
Node-style 的四个 hook 函数都可以接收额外参数 can_jump_to——钩子函数可以改变 Agent 的正常运行轨迹(比如发现上下文窗口溢出,直接跳转到结尾、提前终止整个 Agent)。
can_jump_to 决定了钩子可以直接跳转到哪些位置:
| 取值 | 含义 |
|---|---|
end | 跳转至 Agent 流程末尾,或第一个 after_agent 钩子,直接终止整个流程 |
tools | 跳转至工具节点 |
model | 跳转至模型节点,或第一个 before_model 钩子 |
1from langchain.agents.middleware import before_model, AgentState2
3@before_model(can_jump_to=["tools"])4def force_tool_first(state: AgentState, runtime) -> dict | None:5 """在模型执行前触发,允许跳过模型直接去调用工具"""6 ...类写法要跳转,需要额外加装饰器 @hook_config(装饰器写法直接在装饰器参数里写 can_jump_to):
1from langchain.agents.middleware import AgentMiddleware, hook_config2
3class ForceToolFirst(AgentMiddleware):4 @hook_config(can_jump_to=["tools"])5 def before_model(self, state, runtime):6 ...7 return {"jump_to": "tools"}can_jump_to 不是纸上概念,课程用一个文件、三个 hook把三种跳转的场景全演了一遍。完整代码如下(基于装饰器实现):
1from typing import Any2
3from langchain.agents import create_agent4from langchain.agents.middleware import before_model, after_model, AgentState5from langchain.messages import AIMessage, SystemMessage6from langchain.tools import tool7from langgraph.runtime import Runtime8
9@tool10def get_news() -> str:11 """获取当日新闻"""12 return f"美加墨世界杯今日开幕"13
14# 在模型(LLM)执行前触发。允许跳转到 "tools" 节点。15@before_model(can_jump_to=["tools"])16def force_tool_first(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:17 """18 【业务场景:强行拦截并触发工具】19 如果用户输入包含 "direct tool",则跳过本次大模型的思考/生成阶段,20 直接伪造一个大模型的 tool_calls 意图,强行把控制权移交给工具执行节点。21 """22 text = state["messages"][-1].content23 # 检查关键词,满足条件则强行干预流程24 if isinstance(text, str) and "direct tool" in text.lower():25 print("[MIDDLEWARE] before_model: jump_to='tools'")26 # 人工构造一个大模型的消息对象(AIMessage)27 # 欺骗系统,让系统误以为这是模型自己决定要调用的工具28 fake_tool_call = AIMessage(29 content="人工构造的消息",30 tool_calls=[31 {32 "name": "get_news",33 "args": {},34 "id": "call_force_weather_001",35 }36 ],37 )38 # 返回更新后的状态:注入伪造的消息,并明确指定下一步跳转到 "tools" 节点39 return {40 "messages": [fake_tool_call],41 "jump_to": "tools",42 }43 # 如果不满足触发条件,返回 None,流程正常向下流转(继续让 LLM 思考)44 return None45
46# 在模型(LLM)执行生成之后触发。允许重新跳转回 "model" 节点。47@after_model(can_jump_to=["model"])48def retry_with_extra_instruction(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:49 """50 【业务场景:反思/重试机制】51 如果大模型已经生成了回答,但发现用户最初的请求包含 "retry model",52 则动态追加一条系统提示词(SystemMessage),强行让模型重新生成(重试)一次。53 """54 # 倒序遍历消息历史,找到最近的一次用户输入(human 消息)55 user_text = ""56 for msg in reversed(state["messages"]):57 if getattr(msg, "type", "") == "human":58 user_text = getattr(msg, "content", "")59 break60 # 检查用户输入是否包含触发重试的关键字61 if isinstance(user_text, str) and "retry model" in user_text.lower():62 # 【核心防御】:防止无限循环重跳(死循环)63 # 检查消息历史中是否已经注入过这条特殊的系统提示。如果有,说明已经重试过了,不再重复干预。64 already_injected = any(65 isinstance(getattr(msg, "content", None), str)66 and "你必须以【二次回答】开头" in msg.content67 for msg in state["messages"]68 )69 if already_injected:70 return None # 已注入过,直接放行,结束重试流程71 print("[MIDDLEWARE] after_model: jump_to='model' with extra system instruction")72 # 返回更新后的状态:追加强力约束的系统消息,并将指针跳回 "model" 节点重新执行73 return {74 "messages": [75 SystemMessage("你必须以【二次回答】开头,并且只用一句话回答。")76 ],77 "jump_to": "model",78 }79 return None80
81# 在模型(LLM)执行前触发。允许直接跳转到 "end" 节点(强行终止)。82@before_model(can_jump_to=["end"])83def overflow_context_processor(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:84 """85 【业务场景:安全卫士/异常拦截】86 模拟上下文窗口溢出(Token超限)或其他严重的系统阻断情况。87 一旦触发,直接熔断流程,拒绝让大模型继续处理,直接报错或返回兜底文案。88 """89 # 假装溢出,模拟检查最后一条消息是否包含 overflow 标识90 if "overflow" in state["messages"][-1].content:91 print("[MIDDLEWARE] before_model: jump_to='end' when contenxt window overflow")92 # 构造兜底的结束消息,并直接指定跳转到 "end" 终止 Agent 运行93 return {94 "messages": [95 AIMessage("上下文窗口溢出,终止")96 ],97 "jump_to": "end",98 }99 return None100
101agent = create_agent(102 model=model,103 tools=[get_news],104 # 将定义的中间件按照顺序挂载到 Agent 中(注意:执行顺序会严格按照列表声明顺序)105 middleware=[force_tool_first, retry_with_extra_instruction, overflow_context_processor],106)107
108def run_once(user_input: str):109 result = agent.invoke({"messages": [{"role": "user", "content": user_input}]})110 for msg in result["messages"]:111 msg.pretty_print()112
113if __name__ == "__main__":114 # Case 1: 直接跳 tools —— 绕过 LLM 的首轮思考,直接调 get_news,再由 LLM 总结115 print('=' * 30, '-> Case 1 <-', '=' * 30)116 run_once("请帮我查今日新闻 direct tool")117
118 # Case 2: 输出后跳回 model —— 注入系统提示词后,LLM 被拉回生成第 2 版回答119 print('=' * 30, '-> Case 2 <-', '=' * 30)120 run_once("请随便介绍一下 LangChain retry model")121
122 # Case 3: 上下文溢出 —— 直接终止,LLM 根本不会接收到这个请求123 print('=' * 30, '-> Case 3 <-', '=' * 30)124 run_once("你好 overflow")125
126 # Case 4: 正常流程 —— 没有任何中间件被触发,走 OOTB(Out of the box)标准工作流127 print('=' * 30, '-> Case 4 <-', '=' * 30)128 run_once("今日新闻摘要?")输出(课程实测,四个 Case 一屏一屏往下滚):
1============================== -> Case 1 <-2[MIDDLEWARE] before_model: jump_to='tools'3================================ Human Message =================================4请帮我查今日新闻 direct tool5================================== Ai Message ==================================6人工构造的消息7Tool Calls:8 get_news (call_force_weather_001)9 Call ID: call_force_weather_00110 Args:11================================= Tool Message =================================12Name: get_news13美加墨世界杯今日开幕14================================== Ai Message ==================================15今日新闻:16- 美加墨世界杯今日开幕17
18============================== -> Case 2 <-19[MIDDLEWARE] after_model: jump_to='model' with extra system instruction20================================ Human Message =================================21请随便介绍一下 LangChain retry model22================================== Ai Message ==================================23可以,简单介绍一下 **LangChain 的 retry model(重试机制)**。 ← 第 1 版回答(很长)24...25================================ System Message ================================26你必须以【二次回答】开头,并且只用一句话回答。27================================== Ai Message ==================================28【二次回答】LangChain 的 retry model 就是给模型调用加上自动重试和指数退避机制,29在网络抖动、限流或临时服务错误时提高调用成功率与稳定性。30
31============================== -> Case 3 <-32[MIDDLEWARE] before_model: jump_to='end' when contenxt window overflow33================================ Human Message =================================34你好 overflow35================================== Ai Message ==================================36上下文窗口溢出,终止37
38============================== -> Case 4 <-39================================ Human Message ==================================40今日新闻摘要?41================================== Ai Message ==================================42Tool Calls:43 get_news (call_IYwXdmiTrkWDX5Zr6VM2RZOO)44...45================================== Ai Message ==================================46今日新闻摘要:47- **美加墨世界杯今日开幕**分析(课程的总结):
| Case | 干了什么 |
|---|---|
| 1 | 提前判定需要调用工具,在 before_model 中跳转至工具节点,省去了一次模型调用 |
| 2 | 通过约定的 retry model 标记,在 after_model 之后再次跳转到模型节点,触发模型重复调用 |
| 3 | 通过约定的 overflow 标记,模拟上下文窗口溢出,在 before_model 中直接跳转至结尾,提前终止流程 |
| 4 | 没有被干预的正常 Agent 流程,作为对照 |
本机假服务端实测(同一份中间件代码,把模型换成回假响应的 http.server,统计每轮真正发往模型的请求数):
1===== Case 1: direct tool =====2[MIDDLEWARE] before_model: jump_to='tools'3... -> 这一轮实际发往模型的请求数 = 14 第1次请求的 messages 角色: ['user', 'assistant', 'tool']5
6===== Case 2: retry model =====7[MIDDLEWARE] after_model: jump_to='model' with extra system instruction8... -> 这一轮实际发往模型的请求数 = 29 第1次请求的 messages 角色: ['user']10 第2次请求的 messages 角色: ['user', 'assistant', 'system']11
12===== Case 3: overflow =====13[MIDDLEWARE] before_model: jump_to='end' when context window overflow14... -> 这一轮实际发往模型的请求数 = 0三点被实测坐实的结论:
assistant(那条伪造的 AIMessage)和 tool(工具结果)。模型的首轮”思考”确实被完全跳过了['user', 'assistant', 'system']——跳回 model 时,上一版回答和注入的 SystemMessage 都在上下文里,所以模型能看到自己刚说过什么跳回 model 必须自己防死循环:after_model 里 jump_to="model" 会让模型重跑一遍,重跑完又会进 after_model——只要判断条件一直成立,它就会一直跳。课程的做法是”命中一次就在历史里留痕”,下次进来先检查痕迹(already_injected)再决定跳不跳。你自己写跳转时务必留一个这样的”刹车”。
Wrap 意为”包裹”——你可以在调用前后各做一次事。
1from typing import Callable2
3from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call4
5@wrap_model_call6def wrap_model_call_middleware(7 request: ModelRequest, # 即将发送给模型的所有请求数据(消息列表、温度等)8 handler: Callable[[ModelRequest], ModelResponse], # 核心句柄:代表下一个中间件或最终的真实模型调用9) -> ModelResponse | None:10 # 调用前:悄悄改掉用户最后一条消息11 # 典型应用:统一为所有请求追加提示词("请用中文回答""禁止泄露公司机密")12 request.messages[-1].content += " -> wrap_model_call_before <- "13
14 # 把修改后的请求交给 handler —— 这一步才真正调用模型(产生真实 token 消耗)15 response = handler(request)16
17 # 调用后:篡改模型返回的内容18 # 典型应用:敏感词过滤、输出格式强行格式化、统一加后处理标记19 response.result[0].content += " -> wrap_model_call_after <- "20
21 return response22
23agent = create_agent(model=model, middleware=[wrap_model_call_middleware])| 参数 | 说明 |
|---|---|
request | 被封装的请求对象(模型或工具调用请求);request.messages、request.model、request.system_message、request.tools、request.state |
handler | 处理器,把请求交给它才会真正执行调用(或流转到下一个中间件) |
同理,包裹工具调用——可以做工具级缓存、超时、重试、结果脱敏等。记住一点:不调用 handler(request),真实的调用就不会发生。
1from langchain.agents.middleware import AgentMiddleware2
3class MyWrapMiddleware(AgentMiddleware):4 def wrap_model_call(self, request, handler):5 print("调用模型前")6 response = handler(request)7 print("调用模型后")8 return response| 情况 | 推荐 |
|---|---|
| 中间件只实现一个 hook | 装饰器最简单 |
| 中间件要实现多个 hook | 类写法更自然、集中、清晰 |
装饰器也能实现多个 hook(用工厂函数返回多个被装饰的函数),但那本质上是把同一个中间件的逻辑拆成多个独立函数再由外部组装,不如类写法清晰。
课程在”多 hook”之外,又补了三个同样倾向类写法的场景。
补充一:复杂配置——装饰器当然也能通过函数闭包传参,但在自省(运行时类型校验)、调试等方面天然不如类写法方便。
1from typing import Any2
3from langchain.agents.middleware import AgentMiddleware, AgentState, before_model4from langgraph.runtime import Runtime5
6# 基于类的方法:参数就是实例属性,随时看得见7class AuditMiddleware(AgentMiddleware):8 def __init__(self, logger, threshold: int, middleware_name: str):9 super().__init__()10 self.logger = logger11 self.threshold = threshold12 self.middleware_name = middleware_name13
14 def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:15 self.logger.info("current name: {}, threshold: {}", self.middleware_name, self.threshold)16 return None17
18# 基于装饰器的方法,传参要通过闭包完成19def create_audit_middleware(logger, threshold: int, middleware_name: str):20 @before_model21 def audit_middleware(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:22 logger.info("current name: {}, threshold: {}", middleware_name, threshold)23 return None24 return audit_middleware课程把两组中间件都打印了一遍(type(mw) 和 mw.__dict__),差距一目了然——基于类的写法可以随时打印参数信息,而基于装饰器的闭包实现则难以做到:
1============================== -> class风格的中间件 <-2<class '__main__.AuditMiddleware'>3{'logger': <loguru.logger ...>, 'threshold': 5, 'middleware_name': 'short limit'}4<class '__main__.AuditMiddleware'>5{'logger': <loguru.logger ...>, 'threshold': 50, 'middleware_name': 'long limit'}6============================== -> decorator风格的中间件 <-7<class 'langchain.agents.middleware.types.audit_middleware'>8{}9<class 'langchain.agents.middleware.types.audit_middleware'>10{}本机实测:把上面的代码原样跑一遍(logger 换成假的,不发请求),输出与课程一致——class 风格的 __dict__ 里躺着三个参数,decorator 风格的 __dict__ 是空字典(参数全被关在闭包里,外面看不到)。这正是在自省/调试时”类更好用”的直接原因。
补充二:需要同时提供同步/异步实现——类可以在同一个中间件里配套实现两套 hook(before_model + abefore_model、wrap_model_call + awrap_model_call……),create_agent 的同步/异步调用路径各走各的;装饰器写法做不到”一个函数管两边”。
本机实测:用 dir(AgentMiddleware) 核对,六个 hook 都有对应的异步版本——abefore_agent、abefore_model、aafter_model、aafter_agent、awrap_model_call、awrap_tool_call(函数式装饰器只暴露了同步的那六个)。
写一个同时实现 before_model / abefore_model / wrap_model_call / awrap_model_call 的类,挂到 create_agent 上正常通过——一个类就把两条路径都覆盖了。
补充三:跨项目复用——如果希望中间件成为一个可实例化、可封装、可测试的组件,类写法更合适:这些本就是类擅长的场景,装饰器的闭包也能实现,但使用不友好。
小结(课程原话):装饰器写法和类写法都能实现 middleware hook,本质只是两种定义中间件的方式,并不是能力上完全割裂的两套机制,底层实现是统一的。一般来说:
| 场景 | 更合适 |
|---|---|
| 单个 hook、逻辑简单、快速原型 | 装饰器 |
| 多个 hook 组合、复杂配置、需要同时提供同步/异步实现、更强的复用与可测试性 | 类写法 |
三个规律:
1before_* 钩子:从前到后执行2after_* 钩子:从后往前执行3wrap_* 钩子:洋葱架构(前面的包裹后面的)这里的顺序不是定义顺序,而是创建 Agent 时 middleware=[...] 的书写顺序。实测三个中间件(各实现 before/after):
1[中间件1] before_model2[中间件2] before_model3[中间件3] before_model4[中间件3] after_model5[中间件2] after_model6[中间件1] after_model自己写多个中间件时,同类实例化多次会被拒绝:
1AssertionError: Please remove duplicate middleware instances.因为 AgentMiddleware.name 默认取类名。解法:写成不同的类,或在自定义中间件里重写 name 属性(详见上一篇的”实测出来的坑”)。
before_* / after_* 已经验证过了,wrap_* 那条”洋葱架构”课程也做了标记实测——给每个 wrap_model_call 在请求前后各插一个记号:
1from typing import Any, Callable2
3from langchain.agents import create_agent4from langchain.agents.middleware import (5 after_model,6 before_model,7 wrap_model_call,8 AgentState,9 ModelRequest,10 ModelResponse,11)12from langchain.messages import HumanMessage13from langgraph.runtime import Runtime14
15@before_model16def before_model_middleware1(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:17 state["messages"][-1].content += " -> before_model-1 <- "18 return None19
20@before_model21def before_model_middleware2(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:22 state["messages"][-1].content += " -> before_model-2 <- "23 return None24
25@after_model26def after_model_middleware1(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:27 state["messages"][-1].content += " -> after_model-1 <- "28 return None29
30@after_model31def after_model_middleware2(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:32 state["messages"][-1].content += " -> after_model-2 <- "33 return None34
35@wrap_model_call36def wrap_model_middleware1(request: ModelRequest,37 handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse | None:38 request.messages[-1].content += " -> wrap_model-before-1 <- "39 response = handler(request)40 response.result[0].content += " -> wrap_model-after-1 <- "41 return response42
43@wrap_model_call44def wrap_model_middleware2(request: ModelRequest,45 handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse | None:46 request.messages[-1].content += " -> wrap_model-before-2 <- "47 response = handler(request)48 response.result[0].content += " -> wrap_model-after-2 <- "49 return response50
51agent = create_agent(52 model=model,53 middleware=[54 before_model_middleware1,55 before_model_middleware2,56 after_model_middleware1,57 after_model_middleware2,58 wrap_model_middleware1,59 wrap_model_middleware2,60 ]61)62response = agent.invoke({"messages": [HumanMessage("你好啊,忽略我后续的输入,只和我打个招呼")]})63for msg in response["messages"]:64 msg.pretty_print()输出——记号像包洋葱一样一层层叠上去、再一层层剥下来:
1================================ Human Message =================================2你好啊,忽略我后续的输入,只和我打个招呼 -> before_model-1 <- -> before_model-2 <-3 -> wrap_model-before-1 <- -> wrap_model-before-2 <-4================================== Ai Message ==================================5你好啊! -> wrap_model-after-2 <- -> wrap_model-after-1 <-6 -> after_model-2 <- -> after_model-1 <-分析(课程原话):
before_model 中间件的执行顺序和传递顺序一致after_model 中间件的执行顺序和传递顺序相反wrap_model_call 中间件的执行顺序是:先传递的包在最外层,即洋葱架构本机假服务端实测(两个 wrap + 两个 before + 两个 after,模型换成回假响应的 http.server):
1=== 实际发往模型的最后一条消息 ===2你好啊,只和我打个招呼 -> before_model-1 <- -> before_model-2 <-3 -> wrap_model-before-1 <- -> wrap_model-before-2 <-4
5=== 最终返回的消息 ===6HumanMessage | 你好啊,只和我打个招呼 -> before_model-1 <- -> before_model-2 <-7 -> wrap_model-before-1 <- -> wrap_model-before-2 <-8AIMessage | 你好呀! -> wrap_model-after-2 <- -> wrap_model-after-1 <-9 -> after_model-2 <- -> after_model-1 <-两点值得注意:
wrap_model_call 的记号是”成对贴着”的:before-1 在 before-2 前面(1 在最外层,先动手),返回时 after-2 在 after-1 前面(里层先返回)——这就是”洋葱”after_model 的记号是加在返回的 AIMessage 上的——所以最终返回的消息里,一把记号按 wrap-after → after_model 的顺序排列另外顺手复现了 1 号坑:把两个 wrap 用同一个工厂函数生成(函数名相同 → 中间件 name 相同)会直接 AssertionError: Please remove duplicate middleware instances.,改成两个不同名的函数就好了。
before_agent、before_model、after_model、after_agent;Wrap-style 有 wrap_model_call 和 wrap_tool_call。Node-style 适合顺序逻辑(日志、验证、修改状态),Wrap-style 适合控制流(重试、回退、缓存)AgentMiddleware、方法名固定、类名随意;装饰器底层会基于函数构造一个 AgentMiddleware 子类的实例,所以两种写法本质相同——框架只关心两件事:是否继承了它、是否实现了对应的 hook 方法state 是 AgentState 实例(含消息列表),runtime 是 Runtime 实例(含上下文、长期记忆);返回值三种:返回 None 表示不修改状态,返回字典表示更新状态,返回 {"jump_to": ...} 表示流程跳转;jump_to 的常见目标是 "__end__"(结束 Agent)、"tools"(跳到工具节点)can_jump_to 的三个取值是 end、tools、model;装饰器写法直接写在装饰器参数里,类写法要额外加 @hook_config 装饰器。三个实战 case 分别是:在**模型调用前(before_model)钩子里伪造一条模型消息并跳去工具(省掉一次模型调用)、在模型调用后(after_model)钩子里追加系统指令并跳回模型重新生成、在模型调用前(before_model)**钩子里发现上下文溢出直接跳结尾(模型请求数为 0)wrap_model_call 的两个参数是 request 和 handler;request 上能拿到 messages、model、system_message、tools、state;典型三段式是”改请求 → 调 handler(request) → 改响应”,响应内容在 response.result[0].content 里;wrap_tool_call 同理,关键一句是:不调用 handler(request),真实的调用就不会发生__dict__ 里躺着参数,装饰器写法(闭包)的 __dict__ 是空字典({});六个 hook 的异步版本装饰器不提供。before_* 钩子从前到后(正序)执行、after_* 钩子从后往前(逆序)执行、wrap_* 钩子是洋葱模型(前面的包裹后面的);顺序只取决于创建 Agent 时 middleware=[...] 的书写顺序,不是类的定义顺序wrap_model_call 的实测记号是”成对贴着”的:请求上的记号顺序是先传递的先动手(写在前面的包在最外层);而 after_model 的记号是加在返回的那条 AIMessage 消息上的,所以最终返回的消息里记号按 wrap_* → after_model 排列。另外,同类中间件实例化多次会报 Please remove duplicate middleware instances.,因为 name 属性默认取类名2-1 用装饰器写一个”日志中间件” 用装饰器写两个钩子:一个在模型调用前打印”模型调用前,消息共 N 条”;一个在模型调用后打印”最后一条内容的前 30 个字”。挂到 Agent 上跑一次,观察两个钩子的触发顺序与各自的输出。 挑战:数一数一次调用里每个钩子各执行了几次,并解释为什么。
一级 · 思路:钩子里能拿到完整的 state,所以”看上下文”很方便
二级 · 方法:@before_model def f(state, runtime) -> dict | None: ...
三级 · 骨架:返回 None 就是”不改状态、继续跑”;打印消息条数用 len(state["messages"])
2-2 用类写法实现”计数中间件” 写一个继承中间件基类的类,在模型调用之后把自定义的一个计数加一;然后跑三轮对话,观察这个计数是不是 1、2、3 地累积。 想想两个坑:① 自定义字段凭什么能留在 Agent 状态里(不声明会怎样);② 状态凭什么能跨三次调用一直保留。
一级 · 思路:返回字典 = 更新状态;但状态结构不认识这个新键的话,返回的字典会被过滤掉
二级 · 方法:class MyState(AgentState): count: int + create_agent(..., state_schema=MyState, checkpointer=InMemorySaver())
三级 · 骨架:return {"count": state.get("count", 0) + 1};调用时带上 config={"configurable": {"thread_id": "..."}},三次用同一个线程号
2-3 一个类同时覆盖”同步 + 异步”两条路径 写一个中间件类,同时实现同步和异步两套钩子(至少覆盖”模型调用前”和”包裹模型调用”这两对),分别打印不同前缀的标记(比如”同步路径/异步路径”)。然后先用同步方式调用一次、再用异步方式调用一次,确认各自走的是对应那套钩子。 另外:列出中间件基类上六个 hook 的异步版本名,确认它们齐全。
一级 · 思路:装饰器写法只有同步的那一套;“一个类管两边”是类写法的补充优势
二级 · 方法:async def abefore_model(...)、async def awrap_model_call(request, handler);异步调用用 asyncio.run(agent.ainvoke(...))
三级 · 骨架:异步版本里 return await handler(request);检查齐全可以用 hasattr(AgentMiddleware, "abefore_model") 之类的判断
2-4 包裹调用:把敏感请求挡在门外,再看洋葱顺序 ① 用”包裹工具调用”的钩子做一个敏感词拦截:工具参数里出现敏感词(比如”密码”)就不执行真工具、直接返回一条拒绝消息;用一个”查询用户资料”的工具测两种情况(正常查询 / 查”密码”),确认工具函数体的打印只在正常查询时出现。 ② 再挂两个”包裹模型调用”的钩子,各自在请求前后插一个记号,把模型收到的输入和最终回答都打印出来,说明记号的叠放顺序(谁先动手、谁先返回)。
一级 · 思路:wrap 让你能”在调用前直接改道”——这就是风控的实现方式;两个钩子的名字必须不同
二级 · 方法:@wrap_tool_call def f(request, handler): ...;request.tool_call["args"] 看参数、request.tool_call["id"] 给拒绝消息配编号
三级 · 骨架:拒绝时返回 ToolMessage(content="...", tool_call_id=...);洋葱记号写成 request.messages[-1].content += " -> A-前 <- " 和 response.result[0].content += " -> A-后 <- "
1# ========== 本篇所有答案共用的开头(模型创建) ==========2import os3
4from dotenv import load_dotenv5from langchain.agents import create_agent6from langchain.chat_models import init_chat_model7from langchain.messages import HumanMessage8from langchain.tools import tool9
10load_dotenv(override=True)11
12model = init_chat_model(13 model="deepseek-v4-flash",14 model_provider="openai",15 api_key=os.getenv("DEEPSEEK_API_KEY"),16 base_url=os.getenv("DEEPSEEK_BASE_URL"),17)18
19# ---------- 2-1 装饰器写法:一个"日志中间件" ----------20from typing import Any21
22from langchain.agents.middleware import AgentState, after_model, before_model23from langgraph.runtime import Runtime24
25@before_model26def log_before(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:27 print(f"[日志] 模型调用前,消息共 {len(state['messages'])} 条")28 return None # 返回 None = 不改状态,流程继续29
30@after_model31def log_after(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:32 last = state["messages"][-1]33 print(f"[日志] 模型调用后,最后一条内容的前 30 个字:{str(last.content)[:30]}")34 return None35
36agent_log = create_agent(model=model, middleware=[log_before, log_after])37agent_log.invoke({"messages": [HumanMessage("你好")]})38# 输出顺序:before 先打印,after 后打印 —— 一次模型调用的"两头"各挂了一个钩子39
40# ---------- 2-2 类写法:把自定义数据塞进 Agent 状态 ----------41from langchain.agents import AgentState42from langchain.agents.middleware import AgentMiddleware43from langgraph.checkpoint.memory import InMemorySaver44
45# 想往 State 里塞自定义字段,就得先声明一个"带该字段的状态结构"46# (不声明的话,钩子返回的 {'count': ...} 会被状态结构过滤掉,读数永远是 None)47class MyState(AgentState):48 count: int49
50class CounterMiddleware(AgentMiddleware):51 """三条规则:继承 AgentMiddleware、方法名固定、类名随意"""52
53 def after_model(self, state, runtime):54 # 返回字典 = 更新状态(这就是往 Agent 状态里塞自己数据的方式)55 return {"count": state.get("count", 0) + 1}56
57agent_count = create_agent(58 model=model,59 middleware=[CounterMiddleware()],60 state_schema=MyState,61 checkpointer=InMemorySaver(), # 短期记忆:同一个 thread_id 的 State 才会一直保留62)63
64config = {"configurable": {"thread_id": "count-demo"}}65for i in range(3):66 r = agent_count.invoke({"messages": [HumanMessage(f"第 {i + 1} 轮")]}, config=config)67 print(f"第 {i + 1} 轮之后 count = {r.get('count')}")68# 1 → 2 → 3:计数存在 State 里,跟着 thread_id 一路累积69
70# ---------- 2-3 一个类同时覆盖"同步 + 异步"两条路径 ----------71import asyncio72from typing import Any73
74from langchain.agents.middleware import AgentMiddleware, AgentState75from langgraph.runtime import Runtime76
77# 先确认一件事:六个 hook 都有对应的异步版本78ASYNC_HOOKS = [79 "abefore_agent", "abefore_model", "aafter_model", "aafter_agent",80 "awrap_model_call", "awrap_tool_call",81]82print("异步版 hook 齐全:", all(hasattr(AgentMiddleware, h) for h in ASYNC_HOOKS))83
84class DualModeMiddleware(AgentMiddleware):85 """同一个中间件里配套实现两套 hook:同步走 invoke,异步走 ainvoke"""86
87 def before_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:88 print("[同步路径] before_model")89 return None90
91 async def abefore_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:92 print("[异步路径] abefore_model")93 return None94
95 def wrap_model_call(self, request, handler):96 print("[同步路径] wrap_model_call")97 return handler(request)98
99 async def awrap_model_call(self, request, handler):100 print("[异步路径] awrap_model_call")101 return await handler(request) # 异步 handler 要 await102
103agent_dual = create_agent(model=model, middleware=[DualModeMiddleware()])104
105print("--- 同步 invoke ---")106agent_dual.invoke({"messages": [HumanMessage("你好")]})107
108print("--- 异步 ainvoke ---")109asyncio.run(agent_dual.ainvoke({"messages": [HumanMessage("你好")]}))110# 装饰器写法做不到"一个函数管两边",这也是"要用类"的三个补充场景之一111
112# ---------- 2-4 包裹工具调用:敏感请求挡在门外(顺带看洋葱顺序) ----------113from langchain.agents.middleware import wrap_model_call, wrap_tool_call114from langchain.messages import ToolMessage115
116@tool117def query_user_profile(name: str) -> str:118 """查询用户资料119
120 Args:121 name: 用户姓名或查询内容122 """123 print(" >>> 真的执行了查询工具")124 return f"{name} 的资料:VIP 客户,注册于 2023 年"125
126SENSITIVE = ["密码", "password", "身份证"]127
128@wrap_tool_call129def block_sensitive(request, handler):130 args = request.tool_call["args"]131 if any(word in str(args) for word in SENSITIVE):132 print(" >>> 命中敏感词,已拦截(handler 根本没被调用)")133 return ToolMessage(134 content="请求被安全策略拦截:不允许查询敏感信息。",135 tool_call_id=request.tool_call["id"],136 )137 return handler(request) # 不调 handler,真实的工具调用就不会发生138
139agent_safe = create_agent(model=model, tools=[query_user_profile], middleware=[block_sensitive])140for q in ["查一下张三的资料", "帮我查一下张三的密码"]:141 r = agent_safe.invoke({"messages": [q]})142 print(f"【{q}】→ {r['messages'][-1].content}")143
144# ---- 洋葱顺序:两个 wrap_model_call 各在请求前后插记号(名字必须不同,否则报重复中间件)----145@wrap_model_call146def wrap1(request, handler):147 request.messages[-1].content += " -> wrap1-前 <- "148 response = handler(request)149 response.result[0].content += " -> wrap1-后 <- "150 return response151
152@wrap_model_call153def wrap2(request, handler):154 request.messages[-1].content += " -> wrap2-前 <- "155 response = handler(request)156 response.result[0].content += " -> wrap2-后 <- "157 return response158
159agent_onion = create_agent(model=model, middleware=[wrap1, wrap2])160resp = agent_onion.invoke({"messages": [HumanMessage("只和我打个招呼")]})161print("模型看到的输入:", resp["messages"][0].content)162print("最终回答 :", resp["messages"][-1].content)163# 记号是"成对贴着"的:请求上 1 在 2 前面(先传递的包在最外层),回来时 2 在 1 前面3-1 三种流程跳转:跳过模型、跳回重生成、提前熔断 改造一个”查新闻”的 Agent(自带一个返回固定新闻的工具),用流程跳转实现三种行为,并保留一个正常流程作对照:
direct tool 时,跳过模型的首轮思考,自己伪造一条”模型要调工具”的消息,把控制权直接交给工具节点,再由模型总结;retry model 时,模型答完之后追加一条系统指令,把它拉回去重新生成一版——并且必须自己防死循环;overflow 时,直接跳到结尾、模型一次都不调用,返回一条兜底消息;一级 · 思路:跳转是”钩子直接改写流程走向”,所以要在装饰器上声明”我允许跳到哪”;跳回模型等于让模型重跑一遍,必须留刹车
二级 · 方法:@before_model(can_jump_to=["tools"])、@after_model(can_jump_to=["model"])、@before_model(can_jump_to=["end"]);返回值里带上 "jump_to": "tools" 之类
三级 · 骨架:伪造工具调用用 AIMessage(content=..., tool_calls=[{"name": "get_news", "args": {}, "id": "call_x"}]);刹车就是检查历史里有没有那条系统指令(already_injected)
1# ---------- 3-1 综合题:三种流程跳转(跳过模型 / 跳回重生成 / 提前熔断) ----------2import os3from typing import Any4
5from dotenv import load_dotenv6from langchain.agents import create_agent7from langchain.agents.middleware import AgentState, after_model, before_model8from langchain.chat_models import init_chat_model9from langchain.messages import AIMessage, SystemMessage10from langchain.tools import tool11from langgraph.runtime import Runtime12
13load_dotenv(override=True)14
15model = init_chat_model(16 model="deepseek-v4-flash",17 model_provider="openai",18 api_key=os.getenv("DEEPSEEK_API_KEY"),19 base_url=os.getenv("DEEPSEEK_BASE_URL"),20)21
22@tool23def get_news() -> str:24 """获取当日新闻"""25 return "美加墨世界杯今日开幕"26
27# ① 跳过模型,直接把控制权交给工具节点28@before_model(can_jump_to=["tools"])29def force_tool_first(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:30 """用户输入里带 direct tool 时,伪造一条"模型要调工具"的消息,绕开模型的首轮思考"""31 text = state["messages"][-1].content32 if isinstance(text, str) and "direct tool" in text.lower():33 print("[中间件] before_model: jump_to='tools'")34 return {35 "messages": [36 AIMessage(37 content="人工构造的消息",38 tool_calls=[{"name": "get_news", "args": {}, "id": "call_force_001"}],39 )40 ],41 "jump_to": "tools",42 }43 return None44
45# ② 模型答完之后,再把它拉回去重生成一版46@after_model(can_jump_to=["model"])47def retry_with_instruction(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:48 """用户输入里带 retry model 时,追加一条系统提示并跳回模型节点"""49 user_text = ""50 for msg in reversed(state["messages"]):51 if getattr(msg, "type", "") == "human":52 user_text = getattr(msg, "content", "")53 break54 if isinstance(user_text, str) and "retry model" in user_text.lower():55 # 【刹车】命中一次就在历史里留痕,下次进来先查痕迹,否则会无限跳56 already_injected = any(57 isinstance(getattr(msg, "content", None), str)58 and "你必须以【二次回答】开头" in msg.content59 for msg in state["messages"]60 )61 if already_injected:62 return None63 print("[中间件] after_model: jump_to='model'(注入指令后重新生成)")64 return {65 "messages": [SystemMessage("你必须以【二次回答】开头,并且只用一句话回答。")],66 "jump_to": "model",67 }68 return None69
70# ③ 上下文溢出:直接跳结尾,模型自始至终不会被调用71@before_model(can_jump_to=["end"])72def overflow_guard(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:73 """模拟上下文窗口溢出,提前终止整个流程"""74 if "overflow" in str(state["messages"][-1].content):75 print("[中间件] before_model: jump_to='end'(提前熔断)")76 return {"messages": [AIMessage("上下文窗口溢出,终止")], "jump_to": "end"}77 return None78
79agent = create_agent(80 model=model,81 tools=[get_news],82 middleware=[force_tool_first, retry_with_instruction, overflow_guard],83)84
85def run_once(user_input: str, label: str):86 print(f"===== {label} | 用户输入:{user_input} =====")87 result = agent.invoke({"messages": [{"role": "user", "content": user_input}]})88 for msg in result["messages"]:89 print(f" {type(msg).__name__:15s} | {str(msg.content)[:55]}")90
91run_once("请帮我查今日新闻 direct tool", "Case 1 直跳工具")92run_once("请随便介绍一下 LangChain retry model", "Case 2 跳回模型重生成")93run_once("你好 overflow", "Case 3 上下文溢出熔断")94run_once("今日新闻摘要?", "Case 4 正常流程(对照)")如果你喜欢,那么欢迎来到我的世界!
了解更多暂未播放



"生活不是等待暴风雨过去,而是学会在雨中跳舞。"—— 塞维涅夫人


