LangChain v1 中间件实战:不碰主流程,也能给 Agent 装上"外挂"
面向正在用 LangChain / LangGraph 做 Agent 的后端和 AI 开发同学,读完你能独立写出日志、埋点、限流、敏感词拦截、动态工具这类"横切逻辑"。
前言
一句话:Agent 的业务逻辑不该被日志、计数、敏感词、权限这些杂事污染,中间件就是那个"不侵入主流程的插槽"。
本文用两个可运行的小脚本,把 LangChain v1 的 6 个生命周期钩子、状态注入、短路跳转、动态挂工具全部跑通,并附上我们真实踩到的坑。
知识点清单
createAgent的四个核心配置:model/tools/systemPrompt/middleware- 6 个生命周期钩子的执行时机与执行次数
stateSchema:中间件如何给 Agent 增加自定义状态,并让invoke()把状态返回给你- 钩子返回值 = 状态补丁(不是副作用)
wrapModelCall:改写请求、重试、缓存、短路beforeModel+canJumpTo+jumpTo:在模型调用前"掐断"整个流程- 中间件自带工具:顶层
tools: []也能让 Agent 拥有工具 wrapToolCall返回Command:同时改消息和改状态- 洋葱模型的注册顺序:谁在最外层
业务背景
假设你做了一个客服 Agent,上线后立刻会遇到这些需求:
| 需求 | 直觉做法(错) | 实际问题 |
|---|---|---|
| 记录每次模型调用耗时和次数 | 在业务函数里到处 console.log |
逻辑被日志淹没,删日志还要改业务 |
| 统一给所有回答加"请用一句话回答" | 改 systemPrompt | 不同场景要不同策略,改一次影响全部 |
| 命中敏感词直接拒绝 | 在入口 if 判断 |
只能拦用户输入,拦不住模型自己"越狱" |
| 临时给 Agent 加一个查时间的工具 | 改 createAgent({ tools }) |
工具是动态开关的,每次改代码不现实 |
| 统计工具被调用了多少次 | 每个工具函数里计数 | 工具几十个,重复代码爆炸 |
这些需求有个共同点:它们横跨所有业务,但不属于任何一条业务。业内管这叫"横切关注点",解决方案就是中间件(AOP 切面)。
放到 Agent 场景里,中间件的价值更直接:
- 可观测性:不侵入业务,就能拿到每次模型调用、每次工具调用;
- 成本控制:可以在调模型前直接短路,省掉 token;
- 安全兜底:敏感词、越权文件操作,在钩子里统一拦截;
- 能力扩展:工具可以由中间件动态注入,按租户/按场景开关。
整体业务思路
把一次 Agent 运行看成一条流水线:
erlang
用户输入
→ beforeAgent(整次运行只跑一次:初始化、开 trace)
→ beforeModel(每轮模型调用前:检查、改状态、可短路)
→ wrapModelCall(包住模型调用:改 prompt、重试、缓存)
→ 模型
→ afterModel(每轮模型调用后:记账、改结果)
→ (有工具则 tools → 回到 beforeModel,形成循环)
→ afterAgent(整次运行只跑一次:汇总、上报)
选择钩子的判断标准只有一句:你要在"哪一步"动手?
- 只想观察 →
beforeXxx/afterXxx - 想改请求或改结果 →
wrapXxx - 想跳过后续所有步骤 →
beforeXxx+jumpTo
详细业务逻辑
1. 六个切点,各管一段
| 钩子 | 触发时机 | 次数 | 典型用途 |
|---|---|---|---|
beforeAgent |
Agent 启动,模型还没动 | 1 次 | 初始化上下文、打开始日志、建 trace |
beforeModel |
每轮调模型之前 | 每轮 | 校验输入、注入状态、短路 |
wrapModelCall |
包住模型调用 | 每轮 | 改 systemMessage、加工具、重试、缓存 |
afterModel |
模型返回之后 | 每轮 | 记录 token、清洗输出、计数 |
wrapToolCall |
包住工具执行 | 每次工具调用 | 参数校验、权限、重试、结果包装 |
afterAgent |
Agent 结束 | 1 次 | 汇总统计、上报、落库 |
记忆口诀:before 是事前检查,after 是事后记账,wrap 是连皮带肉包起来。
2. 案例一:日志 + 调用次数统计
需求:不碰业务代码,统计"这次对话调用了几次模型",并把结果返回给调用方。
js
const loggingMiddleware = createMiddleware({
name: "LoggingMiddleware",
// ① 给 Agent 状态加一个字段,默认 0
stateSchema: z.object({
modelCallCount: z.number().default(0)
}),
beforeAgent: (state) => {
console.log("[Logging] agent 开始,消息数:", state.messages.length);
},
beforeModel: (state) => {
console.log(`[Logging] 即将调用模型,已调用 ${state.modelCallCount} 次`);
},
afterModel: (state) => {
const last = state.messages.at(-1); // 刚返回的 AIMessage
const preview = typeof last?.content === "string"
? last.content.slice(0, 280)
: JSON.stringify(last?.content)?.slice(0, 280);
console.log("[Logging] 模型返回:", preview);
// ② 返回对象 = 状态补丁,交给 LangGraph 合并
return { modelCallCount: state.modelCallCount + 1 };
},
afterAgent: (state) => {
console.log(`[Logging] agent 结束,累计模型调用:${state.modelCallCount} 次`);
}
});
三个关键点:
stateSchema是"注册字段" ,不是普通变量声明。注册后所有钩子都能读,invoke()也会把它返回(const { modelCallCount } = await agent.invoke(...))。default(0)必须写 。少了它,初始值是undefined,undefined + 1 = NaN,最终你会拿到modelCallCount: NaN。- 钩子返回值是"状态补丁",不是随便返回。LangGraph 会把它 merge 进 state 通道。
3. 案例二:改写发给模型的 system prompt
需求:所有回答都强制"一句话简洁回答",但不改业务里的 systemPrompt。
js
const addContextMiddleware = createMiddleware({
name: "AddContextMiddleware",
wrapModelCall: async (request, handler) => {
// request: 本次模型调用的完整请求(model / messages / systemMessage / tools)
// handler: 真正去调模型的函数
return handler({
...request, // 其余字段原样保留
systemMessage: request.systemMessage.concat("\n\n 请用一句话简洁回答")
});
}
});
三个容易翻车的细节:
request.systemMessage是SystemMessage对象,不是字符串 。.concat()是BaseMessage的方法,返回一个新的SystemMessage;它不是String.prototype.concat。- 必须
return handler(...)。只改请求不调用 handler,模型根本不会执行。 - 不能同时改
systemPrompt和systemMessage,框架会直接抛错:Cannot change both systemPrompt and systemMessage in the same request。
顺带一提,wrapModelCall 里"不调 handler、直接返回一个 AIMessage"就是另一种短路------常用于缓存命中或兜底回答。
4. 案例三:敏感词短路,模型一次都不调
需求:命中敏感词时,直接拒绝,既省钱又快。
js
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")) {
return {
messages: [new AIMessage("该请求已被中间件拦截,无法处理")],
jumpTo: "end"
};
}
}
}
});
为什么要写成 { hook, canJumpTo } 而不是直接写函数?
因为跳转是特权操作,框架要求你显式声明"允许跳到哪",运行时校验,越权会抛:
yaml
Invalid jump target: xxx, must be one of: end.
可跳目标只有三个:model、tools、end。本例的 end 表示"整张图直接结束"。
另外注意 messages 是追加型通道 :你返回一条 AIMessage,是往历史里补一条,而不是覆盖。
5. 案例四:中间件自带工具,并统计工具调用
需求:不改 createAgent 的 tools,让中间件动态提供一个工具,同时统计被调用了多少次。
js
const getCurrentTime = tool(() => new Date().toISOString(), {
name: "get_current_time",
description: "返回当前 UTC 时间的 ISO 8601 字符串",
schema: z.object({})
});
const extendedToolsMiddleware = createMiddleware({
name: "ExtendedToolsMiddleware",
stateSchema: z.object({
toolInvocationCount: z.number().default(0)
}),
tools: [getCurrentTime], // ① 中间件自带工具
wrapToolCall: async (request, handler) => {
console.log(`[Tools] 即将执行: ${request.tool?.name ?? request.toolCall.name}`);
const result = await handler(request); // ② 真正执行工具
if (!ToolMessage.isInstance(result)) return result;
const wrapped = new ToolMessage({
content: `${result.content}\n[wrapToolCall] 已由中间件包裹`,
tool_call_id: result.tool_call_id,
name: result.name
});
// ③ 返回 Command:既更新状态,又替换消息
return new Command({
update: {
toolInvocationCount: request.state.toolInvocationCount + 1,
messages: [wrapped]
}
});
},
afterAgent: (state) => {
console.log(`[Tools] agent 结束,工具调用 ${state.toolInvocationCount} 次`);
}
});
为什么工具执行完要返回 Command 而不是直接返回 ToolMessage?
- 直接返回
ToolMessage:只能替换这一次的工具结果; - 返回
Command({ update }):能同时"改消息 + 改状态",这是记账、写审计日志、回填业务字段的标准姿势。
实测这一次 "给我当前时间" 的完整链路是 4 条消息:
scss
HumanMessage("给我当前时间")
AIMessage(tool_calls: get_current_time) ← 模型决定调工具
ToolMessage("2026-...Z\n[wrapToolCall] ...")
AIMessage("当前时间是:...")
对应的 token 消耗也印证了循环:第一次 175 输入 token(决定调工具),第二次 246(带着工具结果再问模型)。
6. 执行顺序:洋葱模型
多个中间件同时注册时,顺序不是随机的:
- 同一类钩子按注册顺序执行 :先
logging的beforeModel,再blocked的; wrapModelCall是洋葱 :先注册的在最外层(先拿到请求,最后拿到结果);- 代理型钩子(wrap)与通知型钩子(before/after)不要混着理解:前者能改数据流,后者只能观察/补丁。
核心代码片段
把三个中间件组装进 Agent:
js
const agent = createAgent({
model,
tools: [],
systemPrompt: "你是一个助手。",
middleware: [
loggingMiddleware, // 生命周期日志 + 计数
addContextMiddleware, // 改写 systemMessage
blockedContentMiddleware // 敏感词短路
]
});
const { messages, modelCallCount } = await agent.invoke({
messages: [new HumanMessage("这句话包含Block 关键词")]
});
实测输出(节选):
csharp
[Logging] agent 开始,消息数: 1
[Logging] 即将调用模型,当前消息数:1, 已调用 0 次模型
[Add Context]注入额外system上下文
[Logging]模型返回: 这句话包含"Block"关键词。...
[Logging] agent 结束,累计模型调用:1次
modelCallCount: 1
踩坑点 & 避坑总结
坑 1:敏感词判断大小写不匹配,拦截成了"死代码"
js
if (text.includes("BLOCKED")) // 全大写
// 输入:"这句话包含Block 关键词" → includes("BLOCKED") === false
实测日志里只有 [Logging],没有 [Blocked],模型照常回答------看起来演示了拦截,其实一次都没触发。
修法:
js
if (text.toUpperCase().includes("BLOCK")) { ... }
教训:演示代码里的"命中分支"一定要用能命中的输入跑一遍,否则你验证的只是"没崩",不是"逻辑对"。
坑 2:忘了 default(0),计数变 NaN
stateSchema 里写 z.number() 不给默认值,初始是 undefined,+1 后是 NaN,一路污染到 invoke() 返回值。自定义状态字段,永远给默认值。
坑 3:jumpTo: "end" 会跳过 afterAgent
beforeModel 跳到 end 是直接连到图末尾,不经过 afterAgent 节点。所以一旦拦截生效,你会看到:
csharp
[Blocked] 检测到敏感词,短路结束
而没有 "Logging agent 结束"。如果"结束也要记账",收尾逻辑要放在别处(比如 wrapModelCall 里或者包一层外层函数)。
坑 4:SystemMessage.concat 不是字符串拼接
它是 BaseMessage 的方法,返回一个新消息对象。写成 request.systemMessage + "..." 会得到 "[object Object]..."。
坑 5:只改请求,不调 handler
wrapModelCall 里忘写 return handler(...),模型不会被调用,你会拿到一个"空跑"的结果,且很难排查。wrap 钩子的铁律:改了请求一定要往下传。
坑 6:脚本依赖没装全,第一行就报错
所有脚本都用 import "dotenv/config",但 package.json 里没有 dotenv,直接报:
lua
ERR_MODULE_NOT_FOUND: Cannot find package 'dotenv'
补一句 pnpm add dotenv 即可。
坑 7:不在项目目录运行,读不到 .env
dotenv 读的是当前工作目录 的 .env。在仓库根目录跑 node demo/src/xxx.mjs 会拿不到 API Key,正确姿势是 cd demo 再跑。
总结
- 中间件的本质是 Agent 运行流水线上的切面,解决"横切关注点",让业务代码保持干净。
- 6 个钩子按"事前 / 事后 / 包裹"记忆,选钩子只看一件事:你要在哪一步动手。
stateSchema+ 钩子返回补丁,是中间件与 Agent 交换数据的唯一通道,默认值不能省。- 短路要满足两个条件:对象形式的钩子 +
canJumpTo白名单 +jumpTo,并且要接受"跳过后afterAgent不执行"这个副作用。 - 工具也能由中间件注入;
wrapToolCall返回Command才能同时改消息与状态。 - 写完一定要用能命中分支的输入跑一遍,否则"拦截逻辑"可能从未执行。
下一篇我们换到 DeepAgents,看官方把三个最常用的能力------文件系统、长期记忆、上下文摘要------封装成了什么样,以及它们各自会在哪里咬你一口。