写在前面:上次我们看了一个 DeepAgents 的中间件骨架——
createMiddleware({ })里面空空的,当时说"这个空壳子标出了'你可以在这里插手'"。这次它填满了。填满之后你会发现,中间件能做的事比"加个日志"多得多:它能记账、能改写请求、能直接叫停整个流程,甚至能自带工具。readme 里那两句注释值得先抄下来——"用户 request,agent 中间,生成 response"、"middleware 是 Agent 的一部分,不影响 Agent 的运行的情况下添加一些额外的功能"。今天就用两个测试文件,把中间件的四个钩子和三种特权全部过一遍。以下所有代码均来自课堂真实文件。
一、先看全局:Agent 的一天里有四个时间点
middleware-test.mjs 里的 loggingMiddleware,一次把四个钩子全用上了:
const loggingMiddleware = createMiddleware({
name: "loggingMiddleware",
stateSchema: z.object({
modelCallCount: z.number().default(0),
}),
// 监听Agent的全生命周期 hooks
beforeAgent: (state) => {
console.log("\n[Logging] agent 开始,消息数:", state.messages.length)
},
beforeModel: (state) => {
console.log(`[Logging] 即将调用模型,当前消息数:${state.messages.length},
已调用${state.modelCallCount}次模型
`)
},
afterModel: (state) => {
const last = state.messages.at(-1);
const preview = typeof last?.content === "string"
? last.content.slice(0, 280)
: JSON.stringify(last.content)?.slice(0, 280);
console.log(`[Logging] 模型返回:${preview}...`)
return {
modelCallCount: state.modelCallCount + 1,
}
},
afterAgent: (state) => {
console.log(`[Logging] agent 结束,
累计模型调用:${state.modelCallCount}次\n`)
}
});
四个钩子,对应 Agent 一次任务的四个时间点:
| 钩子 | 时机 | 相当于 |
|---|---|---|
beforeAgent | 整个 Agent 启动前 | 上班打卡 |
beforeModel | 每次调模型前 | 每次开会前点名 |
afterModel | 每次模型返回后 | 开完会写纪要 |
afterAgent | 整个 Agent 结束后 | 下班总结 |
注意"每次"这两个字。 Agent 的主循环可能调好几次模型(调工具、再推理、再调工具……),所以 beforeModel / afterModel 会被触发多轮,而 beforeAgent / afterAgent 各只触发一次。
这正是上次讲的 Agent 主循环——中间件就是挂在这个循环的各个节点上的钩子。
中间件有自己的"记忆"
stateSchema: z.object({
modelCallCount: z.number().default(0),
}),
这一行是本次最值得学的设计。
中间件不只是"读一下状态",它可以声明自己的状态字段。这里声明了一个 modelCallCount,类型是数字、默认值 0。
然后:
afterModel: (state) => {
// ...
return {
modelCallCount: state.modelCallCount + 1,
}
}
afterModel 返回的这个对象,就是"把新值写回状态"。 每调用一次模型就加一——这个中间件在给模型调用"记账"。
于是调用方就能拿到这个数:
const { messages, modelCallCount } = await agent.invoke({
messages: [new HumanMessage(text)]
});
console.log("回复", messages.at(-1)?.content);
console.log("modelCallCount", modelCallCount);
注意 agent.invoke() 的返回值里直接有 modelCallCount。
这说明中间件声明的状态字段,跟 messages 一样,都是 Agent 状态的一部分——中间件不是"外挂",它真的住在 Agent 里面。 这也正好呼应了开头那句注释:"middleware 是 Agent 的一部分"。
这解决了一个很实际的问题:怎么测量 Agent 干了多少活?
以前只能靠猜或者翻日志。现在中间件自己记着账,跑完直接问它要数字。而且这个数字是结构化的、可编程的——可以直接断言、可以上报监控、可以写进测试。
一个小细节:preview 的类型判断
const last = state.messages.at(-1);
const preview = typeof last?.content === "string"
? last.content.slice(0, 280)
: JSON.stringify(last.content)?.slice(0, 280);
为什么要判断类型?因为消息的 content 不一定是个字符串。
在 LangChain 里,content 可能是一个字符串,也可能是内容块数组(比如同时包含文本和图片的结构化内容)。两种情况处理方式不同:
| 类型 | 处理 |
|---|---|
| 字符串 | 直接 slice(0, 280) |
| 数组/对象 | 先 JSON.stringify 再截断 |
如果不判断就直接 .slice(),遇到数组类型的 content 会得到奇怪的结果(数组也有 slice 方法,但切的是元素不是字符)。
slice(0, 280) 的截断也很实用——日志是为了"看一眼发生了什么",不是"完整存档"。280 个字符足够判断模型在说什么,又不会把日志刷屏。
state.messages.at(-1) 用 at(-1) 而不是 [length - 1]——负索引取最后一个,这是 ES2022 引入的写法,比算长度简洁。
二、第一个特权:改写权
看完四个钩子,接下来是"你还能动手做什么"。第一个特权是改写请求。
const addContextMiddleware = createMiddleware({
name: "AddContextMiddleware",
wrapModelCall: async (request ,handler) => {
console.log("[Add Context] 注入额外的system上下文")
return handler({
...request,
// 覆盖systemPrompt
systemMessage: request.systemMessage.concat(
"\n\n请用一句话简洁回答"
)
})
}
})
这个中间件干了什么?它偷偷往 system prompt 里加了一句话。
流程是这样的:
请求准备发出
↓
wrapModelCall 拦下来 → 拿到 request(包含 systemMessage、messages 等)
↓
改写:systemMessage 后面追加 "\n\n请用一句话简洁回答"
↓
handler(改过的 request) → 把请求交给真正的执行逻辑
↓
模型收到的是"被你改过"的请求
关键在 wrapModelCall 这个名字——"wrap"(包裹)。
它不是"在调用前后各插一段",而是把整个调用过程包起来。签名是 (request, handler):
| 参数 | 是什么 |
|---|---|
request | 这次调用的完整请求(可读可改) |
handler | 真正的执行逻辑——由你决定什么时候调、调几次、传什么 |
注意 return handler({...}) —— 你必须自己把 handler 调起来。 不调,模型就不会被调用。
回忆一下上次讲的知识:包裹式钩子可以决定 handler 被调用零次、一次或多次。 这个中间件是"改完再调一次"——最常见的一种。
那个"零次"的可能性,马上就有一个例子。
三、第二个特权:叫停权
const blockedContentMiddleware = createMiddleware({
name: "BlockedContentMiddleware",
beforeModel: {
canJumpTo:["end"],
hook: (state) => {
const last = state.messages.at(-1);
const text = typeof last?.content === "string"
? last.content: String(last?.content ?? "");
if (text.includes("BLOCKED")) {
console.log("[Blocked] 检测到Blocked,短路结束");
return {
messages:[new AIMessage("该请求已被middleware 拦截,无法处理")],
jumpTo: "end",
}
}
}
}
})
这个中间件是个安检员——发现可疑内容,直接把整个流程掐掉。
钩子的"对象形式"
注意 beforeModel 这次不是函数,而是个对象:
beforeModel: {
canJumpTo: ["end"],
hook: (state) => { ... }
}
为什么要写成对象?因为要声明 canJumpTo。
canJumpTo: ["end"] 的意思是——这个钩子有权跳到 end 节点。
这很像一种"权限声明":你不能随便跳,得先把"我能跳到哪"登记清楚。框架给跳转能力加了个白名单,避免中间件乱改流程走向。
这跟前面学 LangGraph 时的条件边是姊妹概念——那时是"图自己决定往下走哪条边",这里是"中间件中途把流程拽到别处"。但能力被显式声明约束着,不是想跳就跳。
短路:让 handler 被调用零次
if (text.includes("BLOCKED")) {
return {
messages:[new AIMessage("该请求已被middleware 拦截,无法处理")],
jumpTo: "end",
}
}
一旦命中关键词,返回两样东西:
| 返回 | 作用 |
|---|---|
messages | 伪造一条 AI 回复——告诉用户"被拦截了" |
jumpTo: "end" | 直接跳到结束,模型根本没被调用 |
这就是"零次调用"的实战版本。
对比一下那个"注入上下文"的中间件——它调了 handler(模型被调用了一次)。而这个中间件在命中条件时压根不走到模型那一步。流程被拦腰截断,省下了一次模型调用的钱和时间。
而且注意它伪造了一条 AIMessage 塞进 messages。这是个很周到的设计——如果没有这条消息,调用方拿到的 messages 会停在用户提问那里,看起来"Agent 没反应"。补上这条 AI 回复,调用方拿到的对话记录才是完整的:
[HumanMessage] 这句话包含Block 关键词 BLOCKED
[AIMessage] 该请求已被middleware 拦截,无法处理 ← 中间件补的
为什么这个中间件特别有现实意义
把 BLOCKED 换成真实规则,这就是内容安全中间件的雏形:
| 触发词 | 现实中的用途 |
|---|---|
| BLOCKED | 演示用的占位符 |
| 敏感词 | 内容合规 |
| 越权指令 | 防 prompt 注入 |
| 超预算标志 | 成本熔断 |
"在模型被调用之前拦下来"这件事的价值在于——省钱、省时间、还防住了风险。 如果等到模型回答完再去检查,钱已经花了。
测试用例是"故意触发"
for (const text of [
// "用中文说:langchain createAgent 中的middleware 是什么",
"这句话包含Block 关键词 BLOCKED"
]) {
两个测试用例,注释掉一个、启用一个——这个选择很有意思。
- 注释掉的这句:
用中文说:langchain createAgent 中的middleware 是什么—— 正常的提问,走完整流程(日志会完整打印、模型会被调用、modelCallCount变成 1) - 启用的这句:
这句话包含Block 关键词 BLOCKED—— 故意撞线,验证短路逻辑
换成启用后面那句之后,日志的输出会明显变短:beforeAgent → beforeModel(检测到 BLOCKED)→ 直接就结束了,中间没有 afterModel。
这是个很聪明的调试手法:用测试用例的开关,切换"正常路径"和"异常路径"。 想验证哪条就打开哪个。
四、第三个特权:自带装备
middleware-test2.mjs 展示了另一个能力——中间件可以给 Agent 送工具。
import {
createAgent,
createMiddleware,
HumanMessage,
AIMessage,
ToolMessage,
tool
} from "langchain";
const getCurrentTime = tool(() => new Date().toISOString(), {
name: "get_current_time",
description: "返回当前UTC 时间ISO 8601 字符串",
schema: z.object({})
});
这里用 tool() 定义了一个工具。 三个要素跟前面学的完全一致:
| 要素 | 值 |
|---|---|
| 执行函数 | () => new Date().toISOString() |
name | get_current_time |
description | "返回当前UTC 时间ISO 8601 字符串" |
schema | z.object({})——空对象,因为这个工具不需要参数 |
z.object({}) 这个"空 schema"值得留意——无参数工具也要显式声明 schema 为空对象,而不是省掉不写。这样模型明确知道"这个工具不用传参"。
工具挂在中间件上
const extendedToolsMiddleware = createMiddleware({
name: "ExtendedToolsMiddleware",
stateSchema: z.object({
toolInvocationCount: z.number().default(0),
}),
tools: [getCurrentTime],
// ...
})
const agent = createAgent({
model,
tools: [], // 顶层tools
systemPrompt:"你是一个助手",
middleware: [
extendedToolsMiddleware
]
});
看那两个地方:
// 中间件里
tools: [getCurrentTime],
// createAgent 里
tools: [], // 顶层tools
顶层工具是空的,但 Agent 实际能用 get_current_time——因为工具是中间件带进来的。
那个注释 // 顶层tools 就是在提示这个对比:工具有两个来源。
| 工具来源 | 适合放什么 |
|---|---|
createAgent 的顶层 tools | 这个 Agent 的核心能力 |
中间件的 tools | 这个中间件配套的工具 |
为什么把工具放在中间件里?因为它和中间件的逻辑是配套的。
这个中间件除了提供 get_current_time,还要统计工具调用的次数。工具和统计逻辑是一个整体——打包在一起,才能复用:想让另一个 Agent 也具备"带统计的时间工具",只要把这个中间件挂上去就行,不用记得"还要单独加那个工具"。
这就是"封装"的价值:能力 + 配套机制,一起打包,一起搬运。
wrapToolCall:包裹工具执行
wrapToolCall: async (request, handler) => {
const toolName = request.tool?.name ?? request.toolCall.name;
console.log(`[Tools] 即将执行工具:${toolName}`,
"args:",
request.toolCall.args ?? {}
);
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],
}
})
},
这是 wrapToolCall——wrapModelCall 的姊妹版本,只不过包裹的对象从"模型调用"变成了"工具调用"。
逐段看:
第一段:执行前记录
const toolName = request.tool?.name ?? request.toolCall.name;
console.log(`[Tools] 即将执行工具:${toolName}`, "args:", request.toolCall.args ?? {});
request.tool?.name ?? request.toolCall.name 这个双重取法是防御式写法——不同版本/场景下工具名可能挂在 tool 上,也可能挂在 toolCall 上,两个都试一遍。
第二段:真正执行
const result = await handler(request);
跟 wrapModelCall 一样——你得自己把 handler 调起来。
第三段:类型守卫
if(!ToolMessage.isInstance(result)) return result;
这行很重要:工具不一定返回 ToolMessage。
比如有的工具会返回一个 Command(用来更新状态),而不是标准的工具消息。这时候不能按 ToolMessage 处理,直接原样返回。
ToolMessage.isInstance() 是 LangChain 提供的类型守卫方法——比 result instanceof ToolMessage 更可靠(跨包/多实例场景下 instanceof 有时会失灵)。先判断类型再处理,是处理"别人返回的东西"时的基本素养。
第四段:包装结果
const wrapped = new ToolMessage({
content: `${result.content}\n[wrapToolCall]
已由ExtendedToolsMiddleware 包裹`,
tool_call_id: result.tool_call_id,
name: result.name,
});
它把工具返回的内容加工了一道——原文后面追加了一行标记。
这一行标记是教学演示用的:证明"工具的结果真的经过了中间件的包装"。但在真实场景里,这个位置能干的事很实际:
| 加工方式 | 用途 |
|---|---|
| 追加提示 | 告诉模型"这是中间件处理过的" |
| 脱敏替换 | 把工具返回的敏感信息打码 |
| 截断 | 工具返回太长,先压缩再给模型 |
| 格式化 | 把原始数据转成模型更好读的结构 |
注意 tool_call_id 和 name 都原样带上了——这两个字段是工具消息的"身份证",模型靠 tool_call_id 把"工具结果"跟"刚才那次调用"对应起来。改了内容可以,改丢这两个字段,对话就对不上了。
第三种改状态的方式:Command
return new Command({
update: {
toolInvocationCount: request.state.toolInvocationCount + 1,
messages: [wrapped],
}
})
注意这里返回的是 Command,不是普通对象。
而前面 afterModel 里改状态,返回的是普通对象:
// afterModel 的写法
return {
modelCallCount: state.modelCallCount + 1,
}
两种写法都能改状态,区别在于钩子的类型:
| 钩子类型 | 改状态的方式 |
|---|---|
节点式(beforeModel / afterModel) | 直接 return 对象 |
包裹式(wrapModelCall / wrapToolCall) | return new Command({ update: {...} }) |
Command 是从 @langchain/langgraph 引入的——这张"命令"可以同时做两件事:更新状态 + 指定下一步去哪。
这里的 update 里干了两件事:
update: {
toolInvocationCount: request.state.toolInvocationCount + 1, // 记账 +1
messages: [wrapped], // 把包装后的工具消息塞回去
}
因为包裹式钩子"接管了"整个返回过程,所以它得负责把该更新的东西都更新掉——包括把工具结果放回消息列表。
这也解释了为什么上面那个 ToolMessage.isInstance 判断很重要:如果工具返回的不是 ToolMessage,就不能用这套逻辑包装,得原样放行。
request.state.toolInvocationCount 说明包裹式钩子能通过 request.state 读到当前状态——跟节点式钩子从参数里拿 state 是同样的信息,只是包装在不同位置。
跑一次看看
for (const text of [
// "王者荣耀 日本比赛夺冠了,谁是MVP?",
"给我当前时间"
]) {
const {messages, toolInvocationCount} = await agent.invoke({
messages: [new HumanMessage(text)],
});
console.log(messages, toolInvocationCount);
}
测试用例又是个"二选一":
| 用例 | 预期行为 |
|---|---|
王者荣耀 日本比赛夺冠了,谁是MVP?(注释掉) | 一般问题,可能不需要工具 |
给我当前时间(启用) | 明确需要工具——必然触发 get_current_time |
为什么选"给我当前时间"?因为这是个"必然用工具"的问题。
模型自己不知道当前时间(它的知识有截止日期),所以它只能去调工具。这样就能确保 wrapToolCall 一定被触发——不然中间件的代码根本没机会跑,你也不知道自己写对没写对。
这是挑测试用例的一个原则:想让某段代码被执行,就设计一个"绕不开它"的输入。
最后解构出 toolInvocationCount ——又是中间件记的账。 跟前面 modelCallCount 是同一个套路:中间件自己统计数据,调用方跑完直接取。
五、中间件的"权限光谱"
把今天这两个文件的能力汇总,中间件的权限其实是逐级递增的:
| 层级 | 能力 | 实现方式 | 课堂例子 |
|---|---|---|---|
| 1 | 看见 | 只读 state,打日志 | beforeAgent / afterAgent |
| 2 | 记账 | stateSchema + 返回新值 | modelCallCount / toolInvocationCount |
| 3 | 改写 | wrapModelCall 改 request | 追加 system prompt |
| 4 | 中断 | jumpTo + canJumpTo 声明 | BLOCKED 短路 |
| 5 | 供装备 | 中间件的 tools 字段 | get_current_time |
| 6 | 加工结果 | wrapToolCall 包装返回值 | 追加标记 / 脱敏 / 截断 |
从"只能看"到"能拦能改能加装备"——这一条光谱,就是中间件的全部威力。
回到开头那句 readme 注释:
"middleware 是 Agent 的一部分,不影响 Agent 的运行的情况下添加一些额外的功能"
"不影响运行"这四个字,正好对应光谱的前两级——打日志、记账,确实不改变 Agent 的行为。
但今天我们看到的是光谱的全部——改写 prompt 会改变输出、短路会改变流程、包装工具结果会改变模型看到的信息。"不影响运行"是入门用法,"精确地影响运行"才是中间件的完整能力。
而"精确"这两个字,体现在那些限制上:短路要先用 canJumpTo 声明权限、改状态要走 stateSchema 声明的字段、包裹式钩子返回要包成 Command。框架给你能力,但每一处都留着约束——能改,但要按规矩改。
PS:这篇看下来,最有意思的是"记账"这件事——modelCallCount 和 toolInvocationCount 这种字段,看着不起眼,但它把"Agent 干了多少活"变成了一个可以编程的数字。以前你只能翻日志数,现在跑完直接断言"这次调用不超过 3 次"。下一篇我们看 DeepAgents 官方给的三个"开箱即用"中间件——不用自己写,挂上就有。