一句话价值:Agent 跑起来之后,怎么在不改核心逻辑 的前提下往运行过程里插自己的代码?答案就是 middleware。但 middleware 不是"运行时挂上去的回调"------它是在编译期就被织进执行图的节点和洋葱。理解这个区别,你才能知道什么东西能在哪里改。
先看一个现实的坑
你的 agent 上线了。现在产品经理提了三个需求:
- "统计一下模型被调用了几次、花了多少 token。"
- "所有回答统一加一条规则:不许编造数据,不确定就说不确定。"
- "拦截敏感词,命中就直接返回,别浪费模型调用。"
这三个需求有个共同点:它们不属于任何一个具体工具,而是贯穿每一次运行。
最笨的做法是在业务代码里到处塞代码:
js
// 需求 1:在每一个调用模型的地方塞
console.log("模型调用次数 +1");
// 需求 2:在每一个 systemPrompt 里重复
systemPrompt += "\n不许编造数据";
// 需求 3:在每一个入口加 if
if (containsBadWord(msg)) return "已拦截";
这叫横切关注点------它们横着切过整个系统,不属于任何单一模块。
你在 Express / NestJS 里肯定见过解法:
Express: 请求 → 日志中间件 → 鉴权中间件 → 业务 handler → 响应
Agent : 消息 → 日志中间件 → 审查中间件 → 模型 → 回复
中间件就是把这些横切逻辑独立封装成插件,按需挂上去,互不干扰、随插随拔。 模型和业务代码完全不需要知道它们存在。
那么问题来了:这些插件到底是在什么时候、以什么方式生效的?
第一步:看清 agent loop 长什么样
要理解 middleware 在哪生效,得先知道它挂在什么东西上面。
erlang
agent.invoke(消息)
│
├─ beforeAgent ← 循环外,整轮只触发 1 次
│
┌─── 循环开始 ────────────────────────┐
│ beforeModel │
│ 【调用模型】 │
│ afterModel │
│ 【执行工具】(如果模型要求) │
│ ↺ 回到 beforeModel │
└─────────────────────────────────────┘
│
├─ afterAgent ← 循环外,整轮只触发 1 次
│
└─ 返回 state
这个循环有个很容易搞混的地方:beforeAgent 和 beforeModel 不是一回事。
很多人在单轮对话里会觉得它们"重复了"------都打印 messages.length,值还一样。因为在没有工具的场景下,一轮 invoke 只会调 1 次模型:
beforeAgent → 1 次,消息数 = 1
beforeModel → 1 次,消息数 = 1 ← 看起来一模一样
但一旦 agent 被迫多轮(有工具、需要反复调模型),差异立刻暴露:
erlang
beforeAgent → 1 次
beforeModel → 第 1 次调模型
afterModel → (模型说"我要调用工具 X")
beforeModel → 第 2 次调模型 ← beforeModel 又触发了,beforeAgent 不再触发
afterModel → (模型给最终答案)
afterAgent → 1 次
记住这两句话就行:
*Agenthook = "这一整次任务开始/结束了"(宏观)*Modelhook = "要发起一次 LLM 调用了/调用完了"(微观,是循环里的一个步骤)
第二步:middleware 会被编译成什么
这是最反直觉的一点。middleware 不是在运行时动态挂上去的。
我建了个带 middleware 的 agent,把 LangGraph 编译出来的图打印出来:
markdown
=== 图里的节点 ===
__start__
MwA.before_agent ← beforeAgent hook 变成了独立节点
MwA.before_model
MwA.after_model
MwA.after_agent
model_request ← 模型真正被调用的地方
tools ← 工具真正被执行的地方
__end__
=== 边(执行路径)===
__start__ → MwA.before_model
MwA.before_model → model_request
model_request → MwA.after_model
MwA.after_model → tools (条件)
MwA.after_model → model_request (条件)
MwA.after_model → __end__ (条件)
tools → MwA.before_model ←★
那个 ★ 是重点:工具执行完不是往下走,而是回到 before_model 再来一轮 。这就是 agent loop 的本质。直到某次 after_model 判断"模型没再要求调工具了",才走 __end__。
所以:你写 middleware: [...] 的那一刻,是在改图的结构,不是在注册运行时回调。
一个决定性的细节
注意上面那堆节点里------before_agent、before_model、after_model、after_agent 都是节点 ,但 wrapModelCall 和 wrapToolCall 一个节点都没生成。
它们跑在 model_request 和 tools 节点内部。
这一个观察,直接把两种 hook 的差异说清楚了。
第三步:六种 hook 与两种范式
官方把 hook 明确分成两类:
节点式(node-style)------ "Run sequentially at specific execution points"
在 agent 执行的特定时间点按顺序运行,用于日志、校验、状态更新。
| hook | 触发时机 | 频次 |
|---|---|---|
beforeAgent |
agent 开始前 | 每次 invoke 1 次 |
beforeModel |
每次模型调用前 | 每轮 N 次 |
afterModel |
每次模型响应后 | 每轮 N 次 |
afterAgent |
agent 完成后 | 每次 invoke 1 次 |
包裹式(wrap-style)------ "Run around each model or tool call"
环绕每一次模型/工具调用运行,你能控制 handler 何时被调。
| hook | 触发时机 |
|---|---|
wrapModelCall |
环绕每次模型调用 |
wrapToolCall |
环绕每次工具调用 |
📌 术语澄清:"包裹式"是官方术语(wrap-style),但"节点式"才是另一半的官方叫法(node-style)------不是"观察式"。
能力对比(这张表值得存下来)
| 能力 | 节点式 | 包裹式 |
|---|---|---|
| 改 state | ✅ 直接 return { 字段: 值 } |
✅ 须用 Command |
| 看到的东西 | agent 的 state |
即将发出去的 request |
| 改当次调用的入参 | ❌ | ✅ 改 request |
| 拿到下一步的结果 | ❌ | ✅ handler() 的返回值 |
| 短路 / 替换结果 | ⚠️ 靠 jumpTo |
✅ 不调 handler,或返回自己的值 |
| 改变控制流 | ✅ jumpTo |
❌ |
| 独立成图节点、可被 interrupt | ✅ | ❌ |
| state 获取方式 | 直接作为参数 | request.state |
一句话:
节点式管"在哪一步、往哪走",包裹式管"这一步内部怎么执行"。
第四步:包裹式的灵魂是 handler
先看一个完整的 wrapModelCall:
js
const addContextMiddleware = createMiddleware({
name: "AddContextMiddleware",
wrapModelCall: async (request, handler) => {
console.log("[Add Context] 注入额外 system 上下文");
return handler({
...request,
systemMessage: request.systemMessage.concat("\n\n 请用一句话简洁回答")
});
}
});
两个入参:
request ------ 即将发给模型的消息请求。关键字段:
| 字段 | 含义 |
|---|---|
systemMessage |
系统提示词(createAgent 里传的 systemPrompt 就在这) |
messages |
对话历史消息列表 |
tools |
这次调用会让模型看到的工具 |
handler ------ "继续往下走"的函数。
这是包裹式和节点式的本质区别:节点式只是"路过看一眼",流程自动继续;包裹式是接管控制权 ------你必须自己显式调用 handler(request) 才会发起真正的调用。
于是有了一个非常漂亮的设计:
| handler 调用次数 | 语义 |
|---|---|
| 0 次 | 短路(拦截,真实模型/工具根本不执行) |
| 1 次 | 正常放行 |
| 多次 | 重试(retry) |
这一个函数,同时表达了三件事。 想拦截敏感词?检测到就不调 handler,直接返回自己的结果。想重试?失败了在循环里多调几次。
上面那段代码净效果是:模型收到的系统提示词从 "你是一个助手。" 变成 "你是一个助手。\n\n 请用一句话精简回答"------在不动业务代码的情况下,偷偷给模型多塞了一条行为约束。
节点式怎么实现"短路"?
包裹式靠"不调 handler"短路,节点式没有 handler,怎么办?
用 jumpTo。但要写成对象形式:
js
const blockedContentMiddleware = createMiddleware({
name: "BlockedContentMiddleware",
beforeModel: {
canJumpTo: ["end"], // ① 静态声明:登记"我能跳到 end"
hook: (state) => { // ② 真正干活的回调
const last = state.messages.at(-1);
const text = typeof last?.content === "string"
? last.content
: String(last?.content ?? "");
if (text.includes("BLOCKED")) {
return {
messages: [new AIMessage("该请求已被 middleware 拦截,无法处理")],
jumpTo: "end" // ③ 运行时指令:现在真的跳
};
}
}
}
});
canJumpTo 和 jumpTo 是两件事:
canJumpTo: ["end"]是编译期登记------告诉框架"我这个 hook 可能跳到 end 节点"。框架需要预先知道这一点,才能把图编排正确。jumpTo: "end"是运行时指令------现在真的要跳了。
两者缺一不可。只塞消息不跳转,模型还是会被调;只跳转不塞消息,最终回复是空的。
同一个 hook,两种写法
对照上面两个中间件就明白了:
js
// 写法 A:裸函数(不需要跳转时)
beforeModel: (state) => {
console.log(state.messages.length);
}
// 写法 B:对象形式(需要 jumpTo 短路能力时)
beforeModel: {
canJumpTo: ["end"],
hook: (state) => { ... }
}
所以 hooks 本质就是函数(回调函数),只是需要跳转能力时,得套一层对象把能力"登记"出去。
第五步:createMiddleware 的配置字段
一个中间件能配这些东西:
| 字段 | 作用 | 类型 |
|---|---|---|
name |
中间件标识 | string |
stateSchema |
往 agent state 里加字段 | Zod schema |
contextSchema |
只读上下文(不跨次持久化) | Zod schema |
tools |
中间件携带的工具 | 工具数组 |
<hook> |
六种 hook 之一 | 函数 / 对象 |
name 是纯标识,不参与业务逻辑------用于日志、LangSmith 追踪、报错时定位"是哪个中间件出的事"。相当于中间件的身份证。
stateSchema:让状态在 hook 之间流动
js
const loggingMiddleware = createMiddleware({
name: "LoggingMiddleware",
stateSchema: z.object({
modelCallCount: z.number().default(0) // 声明"我要往 state 里加这个字段"
}),
afterModel: (state) => {
return { modelCallCount: state.modelCallCount + 1 }; // 返回 = state patch
},
afterAgent: (state) => {
console.log(`共调用 ${state.modelCallCount} 次模型`);
}
});
两个关键点:
- hook 的返回值是对 state 的局部更新(patch) ,会合并回 state。这是它跟普通
console.log装饰器的本质区别------middleware 既能观察,也能改状态。 stateSchema声明的字段会一路带到最后,invoke()的返回值里能直接解构到:
js
const { messages, modelCallCount } = await agent.invoke({ messages: [...] });
tools:能力由中间件携带
这是理解 DeepAgents"内置能力"的关键:
js
const getCurrentTime = tool(() => new Date().toISOString(), {
name: "get_current_time",
description: "返回当前 UTC 时间的 ISO 8601 字符串",
schema: z.object({})
});
const extendedToolsMiddleware = createMiddleware({
name: "ExtendedToolsMiddleware",
tools: [getCurrentTime], // ← 工具挂在中间件里
stateSchema: z.object({ toolInvocationCount: z.number().default(0) }),
wrapToolCall: async (request, handler) => { ... }
});
// 顶层 tools 是空的!
const agent = createAgent({ model, tools: [], middleware: [extendedToolsMiddleware] });
最终工具列表 = 你自己的 tools + 所有 middleware 的 tools 摊平合并。
源码就一行(createAgent 内部):
js
const middlewareTools = this.options.middleware?.filter((m) => m.tools).flatMap((m) => m.tools) ?? [];
const toolClasses = [...options.tools ?? [], ...middlewareTools];
这解释了 DeepAgents 的"内置能力"是怎么来的 ------每个内置中间件都自带工具。挂上 TodoListMiddleware,你就自动获得了 write_todos;挂上 FilesystemMiddleware,就获得了 ls/read_file/write_file......你挂中间件,工具就跟着来了。
包裹式改 state 要用 Command
这里有个很容易踩的坑。对比两种 hook 的返回值语义:
js
// 节点式:直接 return 对象 = state patch
afterModel: (state) => ({ modelCallCount: state.modelCallCount + 1 })
js
// 包裹式:返回值默认被当作"工具/模型的执行结果"!
// 想顺便改 state,必须用 Command 明确声明
wrapToolCall: async (request, handler) => {
const result = await handler(request);
if (!ToolMessage.isInstance(result)) return result;
const wrapped = new ToolMessage({
content: `${result.content}\n[wrapToolCall] 已由 ExtendedToolsMiddleware 包裹`,
tool_call_id: result.tool_call_id, // ⚠️ 必须原样复制
name: result.name,
});
return new Command({
update: {
toolInvocationCount: request.state.toolInvocationCount + 1, // 更新自定义状态
messages: [wrapped] // 同时塞回包装后的结果
}
});
}
三个易踩坑点,记住能省很多调试时间:
tool_call_id必须原样复制 ------它是模型把"工具结果"和它当初发出的"tool call 请求"对应起来的唯一凭证。丢了或改了,模型就找不到这个结果对应哪次调用,直接报错。- 包裹式要改 state,必须用
Command------不能像节点式那样直接return { 字段: 值 }。 - 包裹式里 state 通过
request.state访问,不是直接作为第一个参数。
顺带一提,messages 是叠加式 的(append),所以新消息会被追加而不是覆盖。而 afterAgent 读到的 toolInvocationCount,正是 wrapToolCall 里通过 Command 写进去的------状态在中间件内部跨 hook 共享。
最后一块拼图:内置的也是同一套
到这里,最关键的验证来了:DeepAgents 那些内置中间件,是不是也用 createMiddleware 写的?
翻一下 node_modules 里的源码,证据链很完整。
证据 1:实现文件第一行就 import 了 createMiddleware
js
import { AIMessage, ..., createAgent, createMiddleware,
humanInTheLoopMiddleware, todoListMiddleware, tool } from "langchain";
和你手写 demo 里 import 的,是同一个函数。
证据 2:内置中间件的写法一模一样
js
return createMiddleware({
name: "FilesystemMiddleware",
stateSchema: FilesystemStateSchema,
beforeAgent(...) { ... },
wrapModelCall(request, handler) { ... },
wrapToolCall(request, handler) { ... },
});
name、stateSchema、beforeAgent、wrapModelCall......这些字段就是你手写 demo 里用的那套,一字不差。
证据 3:DeepAgents 甚至直接复用 LangChain 的现成中间件
import 里的 todoListMiddleware、humanInTheLoopMiddleware、anthropicPromptCachingMiddleware 都是 LangChain 已经写好的,DeepAgents 拿来即用。
所以整条链路是这样:
php
你的 demo(手写) DeepAgents(官方)
───────────────── ─────────────────
createMiddleware({ createMiddleware({ ← 同一个函数!
name, stateSchema, name: "FilesystemMiddleware",
wrapModelCall, wrapToolCall, ← 一样的 hook
tools, tools: [ls, read_file, ...],
...
}) })
└── LangChain 的 createMiddleware ──┘
你手写 demo 不是"玩具练习",而是把 DeepAgents 内部真实的实现机制亲手复刻了一遍。
小结
| 要点 | 结论 |
|---|---|
| middleware 什么时候生效 | 编译期被织进图,不是运行时挂载 |
| 节点式 4 个 | beforeAgent / beforeModel / afterModel / afterAgent |
| 包裹式 2 个 | wrapModelCall / wrapToolCall |
| 区别 | 节点式管"在哪一步、往哪走";包裹式管"这一步内部怎么执行" |
| 包裹式的灵魂 | handler 调 0/1/N 次 = 短路/放行/重试 |
| 节点式的短路 | beforeModel: { canJumpTo: ["end"], hook } |
| 工具从哪来 | middleware 的 tools 字段,最终摊平合并 |
| 谁定义了这套机制 | LangChain,DeepAgents 只是复用 |
现在机制搞清楚了。但还有一个更实际的问题:这些内置中间件各自负责什么?它们分别用了哪些 hook?
下一篇我们把 DeepAgents 的七个内置中间件一个个拆开看。
系列导航
| 序号 | 标题 | 主题 |
|---|---|---|
| 01 | 当 Agent 框架开始「交钥匙」 | 定位、痛点、createAgent vs createDeepAgent |
| 02 | 六种 Hook 与两种范式 | middleware 机制:节点式 vs 包裹式 |
| 03 | 一个中间件就是一项能力 | 内置中间件拆解:文件系统、记忆、技能、子代理 |
| 04 | 参数拼错不报错 | 生产避坑:静默失效、流式观测与调试 |