六种 Hook 与两种范式——DeepAgents 系列之二

一句话价值:Agent 跑起来之后,怎么在不改核心逻辑 的前提下往运行过程里插自己的代码?答案就是 middleware。但 middleware 不是"运行时挂上去的回调"------它是在编译期就被织进执行图的节点和洋葱。理解这个区别,你才能知道什么东西能在哪里改。


先看一个现实的坑

你的 agent 上线了。现在产品经理提了三个需求:

  1. "统计一下模型被调用了几次、花了多少 token。"
  2. "所有回答统一加一条规则:不许编造数据,不确定就说不确定。"
  3. "拦截敏感词,命中就直接返回,别浪费模型调用。"

这三个需求有个共同点:它们不属于任何一个具体工具,而是贯穿每一次运行。

最笨的做法是在业务代码里到处塞代码:

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 次

记住这两句话就行:

  • *Agent hook = "这一整次任务开始/结束了"(宏观)
  • *Model hook = "要发起一次 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} 次模型`);
  }
});

两个关键点:

  1. hook 的返回值是对 state 的局部更新(patch) ,会合并回 state。这是它跟普通 console.log 装饰器的本质区别------middleware 既能观察,也能改状态。
  2. 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]                                          // 同时塞回包装后的结果
    }
  });
}

三个易踩坑点,记住能省很多调试时间:

  1. tool_call_id 必须原样复制 ------它是模型把"工具结果"和它当初发出的"tool call 请求"对应起来的唯一凭证。丢了或改了,模型就找不到这个结果对应哪次调用,直接报错。
  2. 包裹式要改 state,必须用 Command ------不能像节点式那样直接 return { 字段: 值 }。
  3. 包裹式里 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 参数拼错不报错 生产避坑:静默失效、流式观测与调试
相关推荐
光依旧19 小时前
PageIndex没翻车,翻车的是我的解析器
docker·langchain·大模型·向量数据库·pdf解析·rag·pageindex
打工仔折腾 AI21 小时前
从BPE到SentencePiece:Transformer分词原理与Python实战对比
android·人工智能·python·深度学习·langchain·transformer·ai agent 实战
东方芷兰21 小时前
Agent 技术摘要 06 —— Harness、原生视觉、Jev、mmproj、dsh
人工智能·笔记·python·ai·langchain·ai编程
桃西西呀1 天前
LangChain 之三:模型与消息抽象
人工智能·langchain·llm
用户3134672143541 天前
Agent实践5-无 Function Call 的结构化通用 Agent
langchain·agent
minji...1 天前
LangGraph-AI智能体开发框架 - LangGraph 入门案例1 : 智能快递配送系统
人工智能·python·ai·langchain·大语言模型·agent·langgraph
用户3134672143542 天前
Agent相关-FAISS 与 LangChain 在文档检索中的分工
langchain
用户3134672143542 天前
Agent相关-文档加载器 Document Loader
langchain·llm
染指11102 天前
127.Agent-LangChain核心组件-模型输出后json修复
人工智能·langchain·agent·agents