LangChain Agent 中间件实战(上):生命周期、状态、短路与工具包装
当 Agent 只有一次模型调用时,直接在调用前后写业务逻辑并不困难。真正麻烦的是 Agent 开始循环以后:模型可能决定调用工具,工具执行完又回到模型;一次用户请求可能产生多次模型调用;日志、统计、内容拦截和提示词增强也可能同时存在。
如果把这些逻辑全部塞进主流程,Agent 很快就会变得难以维护。Middleware 的价值,正是把这些横切能力放到 Agent 生命周期的固定位置中。
本文用两个完整示例讲清楚以下问题:
- 如何监听 Agent 和模型调用的生命周期;
- 如何给 Agent 增加自定义状态;
- 如何在模型调用前动态补充 System 上下文;
- 如何在命中规则时跳过模型并提前结束;
- 如何通过 Middleware 向 Agent 注册工具;
- 如何包装工具结果,并用
Command同时更新消息与统计状态。
一、先准备一个可复用的模型实例
两个示例都使用同一种模型初始化方式:
js
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
temperature: 0,
});
这里把模型名称、API Key 和接口地址都交给环境变量,代码本身不绑定具体模型服务。temperature: 0 则让实验结果更稳定,方便观察 Middleware 是否按预期执行。
接下来先从一个不带工具的 Agent 开始。
二、Middleware 处在 Agent 的什么位置
可以先把一次典型执行抽象成下面这条链路:
text
用户消息
↓
beforeAgent
↓
beforeModel
↓
wrapModelCall → 模型调用 → afterModel
↓
模型是否请求工具?
├─ 否:afterAgent → 返回结果
└─ 是:wrapToolCall → 执行工具 → 再次进入模型阶段
这张图里有两类扩展点:
beforeAgent、beforeModel、afterModel、afterAgent是生命周期钩子,适合记录日志、校验输入和更新状态;wrapModelCall、wrapToolCall是包装器,能够拿到本次请求以及继续执行的handler,适合改写请求、包装返回值。
二者的差别很重要:钩子关注"某个阶段已经到来",包装器则真正处在调用链中间,可以决定给下一层传什么。
三、用自定义状态统计模型调用次数
先实现一个日志 Middleware:
js
import { z } from "zod";
import { createMiddleware } from "langchain";
const loggingMiddleware = createMiddleware({
name: "LoggingMiddleware",
stateSchema: z.object({
modelCallCount: z.number().default(0),
}),
beforeAgent: (state) => {
console.log(
`\n[Logging] Agent 开始,消息数:${state.messages.length}\n`
);
},
beforeModel: (state) => {
console.log(
`[Logging] 即将调用模型,当前消息数:${state.messages.length},` +
`已调用 ${state.modelCallCount} 次模型\n`
);
},
afterModel: (state) => {
const last = state.messages.at(-1);
const preview =
typeof last?.content === "string"
? last.content.slice(0, 200)
: JSON.stringify(last?.content).slice(0, 200);
console.log(`[Logging] 模型返回:${preview}...\n`);
return {
modelCallCount: state.modelCallCount + 1,
};
},
afterAgent: (state) => {
console.log(
`[Logging] Agent 结束,累计模型调用:${state.modelCallCount} 次\n`
);
},
});
1. stateSchema 不只是校验参数
js
stateSchema: z.object({
modelCallCount: z.number().default(0),
})
这段定义给 Agent 状态增加了 modelCallCount 字段,初始值为 0。它和内置的 messages 一起贯穿执行过程,因此不同钩子都能读取它。
需要更新状态时,钩子返回一个局部更新对象即可:
js
return {
modelCallCount: state.modelCallCount + 1,
};
这里不是在钩子中直接修改 state,而是明确返回本阶段产生的状态更新。这样,状态变化仍然由 Agent 的执行图统一处理。
2. 一次 Agent 调用不一定只调用一次模型
当前这个 Agent 没有工具,正常情况下会直接生成答案,因此 modelCallCount 通常增加一次。但加入工具后,链路可能变成:
text
第一次模型调用:决定使用哪个工具
工具调用:执行具体操作
第二次模型调用:阅读工具结果并组织最终答案
因此统计"Agent 被调用几次"和统计"模型被调用几次"是两件事。把计数放在 afterModel,统计的正是模型实际返回的次数。
3. 消息内容不一定永远是字符串
日志代码没有直接调用 last.content.slice(...),而是先判断类型:
js
const preview =
typeof last?.content === "string"
? last.content.slice(0, 200)
: JSON.stringify(last?.content).slice(0, 200);
这让预览逻辑既能处理普通文本,也能处理数组或其他结构化内容。last?.content 和 last?. 还避免了消息数组为空时直接报错。
四、用 wrapModelCall 动态增强 System 上下文
下一步是在每次模型调用前追加一条约束:要求模型用一句话间接回答。
js
const addContextMiddleware = createMiddleware({
name: "AddContextMiddleware",
wrapModelCall: async (request, handler) => {
console.log("[Add Context] 注入额外的 System 上下文");
return handler({
...request,
systemMessage: request.systemMessage.concat(
"\n\n请用一句话间接回答"
),
});
},
});
wrapModelCall 接收两个关键参数:
request:当前模型调用的请求,其中包含消息、System Message、模型配置等信息;handler:调用链中的下一步。把处理后的请求交给它,模型调用才会继续。
这里使用了一个很稳妥的改写方式:
js
return handler({
...request,
systemMessage: request.systemMessage.concat("\n\n请用一句话间接回答"),
});
...request 保留原请求的其余字段,只替换 systemMessage;concat 返回追加内容后的新消息。于是 Agent 原本的系统提示词仍在,只是本次模型调用多了一条要求。
这与在创建 Agent 时写死 System Prompt 的用途不同:创建时的 systemPrompt 是基础角色设定,wrapModelCall 更适合在运行阶段根据请求动态调整。
五、在 beforeModel 中短路整个流程
有些请求根本不应该发送给模型。例如,演示中约定只要最后一条消息包含大写的 BLOCKED,就立即返回固定响应。
js
import { AIMessage, createMiddleware } from "langchain";
const blockContentMiddleware = 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",
};
}
},
},
});
1. 为什么这里的 beforeModel 是对象
普通日志钩子只需要一个函数:
js
beforeModel: (state) => {
// 只观察或更新状态
}
而拦截逻辑需要改变执行图走向,因此使用对象形式:
js
beforeModel: {
canJumpTo: ["end"],
hook: (state) => { /* ... */ },
}
canJumpTo 先声明这个钩子允许跳转到哪些节点,hook 再在命中条件时返回 jumpTo: "end"。这让"允许跳到哪里"和"什么时候跳"分开表达。
2. 短路时仍要补上一条 Agent 回复
命中规则后返回的不只有跳转指令:
js
return {
messages: [new AIMessage("该请求已被 Middleware 拦截,无法处理")],
jumpTo: "end",
};
AIMessage 会进入消息状态,调用方仍能从最后一条消息中读取到清晰结果;jumpTo: "end" 则阻止流程继续进入模型节点。
这时模型没有被调用,所以前面的 afterModel 不会为本次拦截增加计数。它恰好说明:Middleware 的短路不是"让模型拒绝回答",而是根本不把请求发给模型。
当然,这里的字符串包含判断只是为了展示控制流程。它只匹配大写的精确关键词,不能被当作完整的内容安全策略。
六、把三个 Middleware 组合到 Agent 中
现在将日志、上下文增强和拦截器按顺序交给 createAgent:
js
import {
createAgent,
HumanMessage,
} from "langchain";
const agent = createAgent({
model,
tools: [],
systemPrompt: "你是一个助手。",
middleware: [
loggingMiddleware,
addContextMiddleware,
blockContentMiddleware,
],
});
const { messages, modelCallCount } = await agent.invoke({
messages: [new HumanMessage("这句话包含 BLOCKED 关键词")],
});
console.log("回复:", messages.at(-1)?.content);
console.log("modelCallCount:", modelCallCount);
这次输入会在 beforeModel 阶段被拦截。调用方不需要捕获异常,而是照常读取最终消息和状态;区别在于模型调用次数仍是 0。
如果换成普通问题,执行链则会继续进入 wrapModelCall,在 System Message 后追加回答要求,再调用模型。
由此可以看到三个 Middleware 的职责很清晰:
| Middleware | 关注点 | 是否改变流程 |
|---|---|---|
LoggingMiddleware |
生命周期日志和模型调用计数 | 更新状态,不主动短路 |
AddContextMiddleware |
修改本次模型请求的 System Message | 请求继续向下传递 |
BlockedContentMiddleware |
检查最后一条消息 | 命中后跳到 end |
七、通过 Middleware 给 Agent 增加工具
前面的 Agent 把 tools 设为空数组。接下来继续保持顶层 tools: [],但通过 Middleware 注册一个获取当前 UTC 时间的工具。
js
import { z } from "zod";
import { tool } from "langchain";
const getCurrentTime = tool(
() => new Date().toISOString(),
{
name: "get_current_time",
description: "返回当前 UTC 时间的 ISO 8601 字符串",
schema: z.object({}),
}
);
这个工具不需要参数,所以 Schema 是空对象。执行结果来自:
js
new Date().toISOString()
返回值是 UTC 时间的 ISO 8601 字符串,与工具描述保持一致。
然后定义工具 Middleware:
js
import {
createMiddleware,
ToolMessage,
} from "langchain";
import { Command } from "@langchain/langgraph";
const extendedToolMiddleware = createMiddleware({
name: "ExtendedToolsMiddleware",
stateSchema: z.object({
toolInvocationCount: z.number().default(0),
}),
tools: [getCurrentTime],
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],
},
});
},
afterAgent: (state) => {
console.log(
`[Tool] Agent 结束,Middleware 统计工具调用:` +
`${state.toolInvocationCount}`
);
},
});
1. tools 可以由 Middleware 提供
创建 Agent 时依旧可以这样写:
js
const agent = createAgent({
model,
tools: [],
systemPrompt: "你是一个助手",
middleware: [extendedToolMiddleware],
});
顶层没有注册工具,但 ExtendedToolsMiddleware 的 tools: [getCurrentTime] 会把工具纳入 Agent。这样可以把"一组工具 + 这组工具的调用策略 + 统计逻辑"封装成一个整体。
2. handler(request) 才是真正执行工具的位置
wrapToolCall 首先读取工具名称与参数并打印日志:
js
const toolName = request.tool?.name ?? request.toolCall.name;
console.log(toolName, request.toolCall.args ?? {});
随后执行:
js
const result = await handler(request);
这一步把请求交给实际工具。因为它位于包装器内部,所以可以同时实现"调用前记录参数"和"调用后处理结果"。
3. 为什么先判断 ToolMessage
js
if (!ToolMessage.isInstance(result)) {
return result;
}
只有标准 ToolMessage 才进入后续包装逻辑。若下游返回其他有效结果,就原样透传,避免贸然读取 tool_call_id 等专属字段。
当结果确实是 ToolMessage 时,代码创建一条新工具消息:
js
const wrapped = new ToolMessage({
content: `${result.content}\n[wrapToolCall] 已由 Middleware 包裹`,
tool_call_id: result.tool_call_id,
name: result.name,
});
这里不仅追加了可观察标记,还保留了两个关键关联字段:
tool_call_id:把工具结果和模型发起的那次工具调用对应起来;name:保留工具名称。
如果只改内容却丢失调用 ID,模型侧的工具调用与结果就无法正确对应。
4. 用 Command 一次完成两种状态更新
包装工具结果后,代码没有直接返回 wrapped,而是返回:
js
return new Command({
update: {
toolInvocationCount:
request.state.toolInvocationCount + 1,
messages: [wrapped],
},
});
这里要同时完成两件事:
- 把工具调用计数加一;
- 把包装后的
ToolMessage写回消息状态。
Command 正适合表达这种"本次工具调用完成后,状态应该如何更新"的结果。messages 字段走消息状态自身的累加逻辑,所以 [wrapped] 表示追加这条工具结果,而不是用一条消息替换全部历史。
八、完整执行一次工具调用
调用代码非常简单:
js
const { messages, toolInvocationCount } = await agent.invoke({
messages: [new HumanMessage("给我当前时间")],
});
console.log(messages);
console.log(toolInvocationCount);
从执行链看,发生的是:
text
用户询问当前时间
↓
模型看到 get_current_time 工具并发起调用
↓
wrapToolCall 打印工具名和参数
↓
handler 执行 get_current_time
↓
原始结果被包装成新的 ToolMessage
↓
Command 追加消息,并把 toolInvocationCount 加一
↓
模型读取工具结果,生成最终回复
↓
afterAgent 输出累计工具调用次数
要注意,"一次工具调用"与"一次模型调用"依然不是同一个计数。这个请求通常需要模型先决定调用工具,再根据工具结果生成答案,而 toolInvocationCount 只在工具真正执行并返回 ToolMessage 后增加。
九、两个示例串起来后,应该怎样选择扩展点
可以把选择规则压缩成下面这张表:
| 需求 | 合适的扩展点 | 核心写法 |
|---|---|---|
| Agent 开始或结束时打印日志 | beforeAgent / afterAgent |
读取 state |
| 每次模型调用前后统计 | beforeModel / afterModel |
返回局部状态更新 |
| 动态修改模型请求 | wrapModelCall |
修改 request 后调用 handler |
| 命中规则后不再调用模型 | 对象形式的 beforeModel |
canJumpTo + jumpTo |
| 给 Agent 增加一组工具 | Middleware 的 tools |
工具与策略一起封装 |
| 观察或包装工具执行 | wrapToolCall |
在 handler 前后处理 |
| 工具完成后同时更新多个状态 | Command |
update: { ... } |
十、总结
Middleware 并不是简单的"模型调用前后各执行一个函数"。在 Agent 场景中,它至少承担了三层职责:
- 观察执行过程:通过生命周期钩子记录消息数、模型调用次数和工具调用次数;
- 改变请求与结果 :通过
wrapModelCall增强 System Message,通过wrapToolCall包装工具结果; - 控制 Agent 图的走向 :在
beforeModel中返回jumpTo: "end",让请求不经过模型就结束。
更关键的是,状态不需要藏在模块级全局变量里。stateSchema、钩子的局部更新以及 Command 共同构成了一条清晰的数据流:每一项统计都属于本次 Agent 状态,每一次修改也都显式地回到执行图中。
上篇解决的是"怎样扩展 Agent 的运行过程"。下篇将继续解决另一个更实际的问题:怎样让 Agent 在受控目录中读写文件,怎样把项目事实和用户偏好变成长期记忆,以及长对话超过阈值后,怎样同时维护原始消息、滚动摘要和历史归档。