LangChain.js Agent 中间件实战:从生命周期拦截、动态工具到文件系统权限
当 Agent 只负责"收到问题,然后调用一次模型"时,代码通常很简单。但只要进入真实业务,问题很快就会出现:
- 怎样统计模型到底调用了几次?
- 怎样在不改业务 Prompt 的前提下动态补充系统指令?
- 怎样在请求触达模型之前拦截敏感内容?
- 工具能否由中间件提供,而不是全部堆在 Agent 的顶层配置里?
- 工具执行后,怎样同时修改消息历史和自定义状态?
- 给 Agent 文件读写能力时,怎样限制它只能在指定目录活动,并且只能修改指定文件?
这些问题都指向同一个概念:中间件不是 Agent 外面的一层普通回调,而是 Agent 执行图的一部分。
本文用一组可以串起来运行的 Node.js 示例,依次讲清生命周期钩子、模型与工具包装器、自定义状态、流程短路,以及 DeepAgents 的文件系统中间件。
一、准备运行环境
示例使用 ESM,因此代码文件使用 .mjs 后缀。依赖版本如下:
json
{
"dependencies": {
"@langchain/langgraph": "^1.4.21",
"@langchain/openai": "^1.6.2",
"deepagents": "^1.14.2",
"dotenv": "^18.0.6",
"langchain": "^1.5.15",
"zod": "^4.6.5"
}
}
这一组依赖中,@langchain/openai 对 Node.js 版本的要求较高,直接使用 Node.js 22 更省心。安装依赖:
bash
pnpm install
环境变量可以按下面的形式准备:
dotenv
# 可选:需要 LangSmith 链路追踪时再配置
LANGCHAIN_API_KEY=your_langsmith_api_key
LANGCHAIN_PROJECT=your_project_name
LANGSMITH_TRACING=true
# 模型配置
MODEL_NAME=your_model_name
OPENAI_API_KEY=your_api_key
OPENAI_BASE_URL=your_openai_compatible_base_url
所有示例都通过同一种方式创建模型:
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,
});
import "dotenv/config" 是一个只有副作用的导入:它会在程序启动时读取 .env,后面的代码就可以直接从 process.env 取值。
baseURL 让 ChatOpenAI 可以连接兼容 OpenAI 协议的服务。temperature: 0 用来降低回答的随机性,适合观察中间件是否按预期执行,但它不等于数学意义上的绝对确定。
二、先建立 Agent 的执行模型
理解中间件之前,先不要把 Agent 想成一次模型请求。带工具的 Agent 更接近下面这个循环:
text
beforeAgent(一次)
↓
beforeModel
↓
wrapModelCall → 模型调用
↓
afterModel
↓
模型是否请求工具?
├─ 否 → afterAgent(一次)→ 结束
└─ 是 → wrapToolCall → 执行工具
↓
回到 beforeModel
这张流程图能解释两个经常混淆的问题。
第一,beforeAgent 和 afterAgent 面向一次完整的 Agent 调用;beforeModel 和 afterModel 面向一次模型调用。只要 Agent 调用工具后再次询问模型,模型级钩子就会再次执行。
第二,wrapModelCall 与 wrapToolCall 是包装器。它们拿到一个 handler,只有调用 handler(request),后续中间件或真正的模型、工具才会继续执行。因此它们天然适合做参数改写、日志、重试和结果包装。
三、用生命周期钩子统计模型调用次数
先实现一个日志中间件。它负责打印 Agent 的开始和结束、每次模型调用前后的信息,并在状态中维护模型调用次数。
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 不只是参数校验
这里的 Zod Schema 定义了中间件拥有的状态字段:
js
stateSchema: z.object({
modelCallCount: z.number().default(0),
})
default(0) 很重要,因为调用 Agent 时不需要手动传入 modelCallCount。框架会初始化这个字段,生命周期钩子随后可以通过 state.modelCallCount 读取它。
钩子不必直接修改 state,返回一个局部状态更新即可:
js
return {
modelCallCount: state.modelCallCount + 1,
};
这样做更符合状态图的工作方式:节点产出"这一步要更新什么",再由图把更新合并进总状态。
2. 为什么消息内容需要分类型处理
下面这段代码没有直接对 content 调用 slice:
js
const preview =
typeof last?.content === "string"
? last.content.slice(0, 200)
: JSON.stringify(last?.content).slice(0, 200);
原因是消息内容不一定永远是纯字符串,它也可能是结构化内容块。先判断类型,再生成日志摘要,可以避免日志代码反过来破坏 Agent 主流程。
3. "一次 Agent 调用"不等于"一次模型调用"
如果模型直接回答,modelCallCount 通常增加一次。如果模型先发出工具调用,工具返回结果后模型还要组织最终答案,那么模型通常会被调用两次:
text
用户消息 → 模型决定调用工具 → 工具结果 → 模型生成最终回答
第 1 次 第 2 次
这也是把统计逻辑放在 afterModel 而不是 afterAgent 中的原因。
四、用 wrapModelCall 动态补充系统消息
有些约束并不适合硬编码进 Agent 的基础系统提示词。例如,不同请求可能需要不同的回答风格、租户上下文或临时规则。此时可以在模型真正收到请求前改写系统消息。
js
const addContextMiddleware = createMiddleware({
name: "AddContextMiddleware",
wrapModelCall: async (request, handler) => {
console.log("[Add Context] 注入额外的 System 上下文");
return handler({
...request,
systemMessage: request.systemMessage.concat(
"\n\n请用一句话间接回答"
),
});
},
});
这里要抓住三个细节。
1. 展开原请求,只覆盖需要修改的字段
request 中不只有系统消息,还包含模型、普通消息、工具和运行时状态等信息。使用 ...request 保留原请求,再覆盖 systemMessage,可以避免遗漏其他字段。
2. 当前版本中优先操作 systemMessage
request.systemMessage 是一条 SystemMessage,concat 会保留原系统消息,再追加文本。这样基础指令"你是一个助手"仍然存在,只是在本次模型调用时多了一条回答约束。
3. 一定要调用并返回 handler
js
return handler(modifiedRequest);
这一步才会把改写后的请求交给后续链路。忘记调用 handler,模型不会正常执行;调用了却不返回,外层也拿不到模型响应。
五、用 jumpTo 在模型调用前短路
如果某类请求根本不应该发给模型,最有效的处理方式不是在模型返回后再过滤,而是在 beforeModel 阶段结束流程。
js
import { AIMessage } 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",
};
}
},
},
});
这里同时完成了两件事:
- 向消息状态追加一条
AIMessage,让调用方仍然能从最后一条消息中取得明确回复; - 返回
jumpTo: "end",告诉执行图不要进入模型节点。
canJumpTo: ["end"] 则是在声明这个钩子允许跳到哪里。它和 jumpTo 是配套的:前者声明可用目标,后者在运行时真正选择目标。
需要特别注意:这个短路发生在 beforeModel。在当前执行图中,它会直接跳到结束节点,因此模型不会调用,afterModel 不会执行,afterAgent 也不会执行。不要把必须执行的资源清理仅仅寄托在这个 afterAgent 钩子上。
将三个中间件组装起来:
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);
这次调用的核心输出是:
text
[Logging] Agent 开始,消息数:1
[Logging] 即将调用模型,当前消息数:1,已调用 0 次模型
[Blocked] 检测到 BLOCKED,短路结束
回复:该请求已被 middleware 拦截,无法处理
modelCallCount:0
为什么 AddContextMiddleware 没有打印日志?因为它使用的是 wrapModelCall,只有执行图真的走到模型节点时才会运行。请求已经在 beforeModel 被截断,自然不会进入模型包装器。
六、让中间件自己提供工具
中间件除了观察和拦截,还能携带工具。下面定义一个返回当前 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({}),
}
);
即使工具没有参数,仍然使用 z.object({}) 明确声明一个空对象 Schema。工具名和描述会提供给模型,模型据此判断什么时候调用它。
接下来创建中间件,把工具注册进去,同时统计它被调用的次数:
js
import { Command } from "@langchain/langgraph";
import {
createMiddleware,
ToolMessage,
} from "langchain";
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 结束,中间件统计工具调用:` +
`${state.toolInvocationCount} 次`
);
},
});
1. 顶层 tools 为空,模型为什么仍能看到工具
创建 Agent 时可以这样写:
js
const agent = createAgent({
model,
tools: [],
systemPrompt: "你是一个助手",
middleware: [extendedToolMiddleware],
});
顶层 tools: [] 只表示 Agent 自身没有直接注册工具。extendedToolMiddleware 的 tools: [getCurrentTime] 会把工具加入 Agent 可用工具集合,所以模型仍能选择 get_current_time。
这种组织方式的价值在于内聚:工具、工具调用日志、结果包装和调用次数统计都放在同一个中间件中,Agent 顶层只负责组合能力。
2. handler(request) 才是真正执行工具
wrapToolCall 在工具外面包了一层:
js
const result = await handler(request);
调用前可以检查名称和参数,调用后可以检查或改写结果。这里的 request 主要用到了:
request.toolCall.name:模型发出的工具调用名称;request.toolCall.args:模型生成的工具参数;request.tool:已经解析出的工具实例,动态场景下可能不存在,所以名称读取提供了回退;request.state:当前 Agent 状态,包括中间件声明的计数器。
3. 为什么先判断 ToolMessage
工具包装链允许返回 ToolMessage,也允许返回用于高级流程控制的 Command。只有确认结果是 ToolMessage,才能安全读取 content、tool_call_id 和 name 并重新包装:
js
if (!ToolMessage.isInstance(result)) return result;
如果已经是 Command,这里保持原样返回,避免误把控制指令当成普通工具结果。
4. 为什么返回 Command,而不是只返回新的 ToolMessage
这个中间件想一次完成两项更新:
- 把包装后的工具结果加入消息历史;
- 将
toolInvocationCount加一。
Command 正适合表达"更新状态,并继续由状态图处理后续流程":
js
return new Command({
update: {
toolInvocationCount: request.state.toolInvocationCount + 1,
messages: [wrapped],
},
});
messages 是 Agent 的内置消息通道,这里的数组会通过对应的累加逻辑并入历史,而不是把整段会话简单替换掉。
调用示例:
js
const { messages, toolInvocationCount } = await agent.invoke({
messages: [new HumanMessage("给我当前时间")],
});
console.log(messages);
console.log("toolInvocationCount:", toolInvocationCount);
一次典型流程是:模型先请求 get_current_time,工具返回 ISO 时间,中间件包装 ToolMessage 并把计数加一,模型再根据工具结果生成自然语言回答。因此,工具调用次数通常是 1,而模型调用次数通常是 2,两者统计的不是同一件事。
七、用 DeepAgents 给 Agent 一个受控工作区
前面的工具由我们逐个定义。文件操作包含列目录、读取、写入、编辑、搜索等一组能力,如果全部手写会比较繁琐。createFilesystemMiddleware 可以把这些能力作为中间件整体接入 Agent。
先构造本地工作区路径:
js
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const workspaceDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"workspace"
);
fs.mkdirSync(workspaceDir, { recursive: true });
fs.writeFileSync(
path.join(workspaceDir, "secret.txt"),
"机密:不得读取",
"utf-8"
);
ESM 中没有 CommonJS 的 __dirname,import.meta.url 又是 file:// URL,因此先用 fileURLToPath 转成本地路径,再取当前目录。
这里使用 Node.js 自己的 fs 初始化测试数据。它发生在 Agent 执行之前,不经过文件系统中间件,因此不受后面权限规则约束。权限限制的是 Agent 通过中间件工具发起的操作,不是宿主程序自身的所有文件调用。
1. 设计最小写权限
目标是:
- 禁止 Agent 读取
/secret.txt; - 只允许写入和编辑
/todo.md; - 其他文件一律禁止写入;
- 其他读取操作保持允许,以便 Agent 能列目录和检查任务文件。
权限规则如下:
js
const permissions = [
{
operations: ["read"],
paths: ["/secret.txt"],
mode: "deny",
},
{
operations: ["write"],
paths: ["/todo.md"],
mode: "allow",
},
{
operations: ["write"],
paths: ["/**"],
mode: "deny",
},
];
权限判断有两个非常关键的规则:
- 按声明顺序检查,第一条匹配的规则生效。
- 没有任何规则匹配时,默认允许。
因此,/todo.md 的允许规则必须放在全局写入拒绝规则之前。否则 /todo.md 会先命中 /**,后面的精确允许规则没有机会生效。
把常见操作展开后会更直观:
| 操作 | 路径 | 命中的规则 | 结果 |
|---|---|---|---|
| 读 | /secret.txt |
第一条 | 拒绝 |
| 读 | /todo.md |
无 | 默认允许 |
| 写 | /todo.md |
第二条 | 允许 |
| 写 | /secret.txt |
第三条 | 拒绝 |
| 写 | /notes.md |
第三条 | 拒绝 |
当 Agent 执行 ls / 时,列目录本身可以进行,但结果还会按照读取权限过滤,因此被拒绝读取的 /secret.txt 不应暴露在列表结果中。
2. 权限必须配置在中间件这一层
这是最容易写错、也最隐蔽的地方。FilesystemBackend 负责"文件操作具体落到哪里",createFilesystemMiddleware 负责"给 Agent 暴露什么工具,以及如何做权限判断"。所以正确层级是:
js
createFilesystemMiddleware({
backend: new FilesystemBackend({
rootDir: workspaceDir,
virtualMode: true,
}),
permissions,
});
不要把 permissions 塞进 new FilesystemBackend(...)。JavaScript 对多余对象字段不会自动报错,但 Backend 并不读取这个字段,最终表现就是规则看起来已经配置,实际上没有生效。
3. virtualMode 把真实目录映射成虚拟根目录
js
new FilesystemBackend({
rootDir: workspaceDir,
virtualMode: true,
})
开启 virtualMode 后,模型看到的是一套虚拟绝对路径:
text
/todo.md
/secret.txt
它们在底层会映射到:
text
<workspaceDir>/todo.md
<workspaceDir>/secret.txt
因此,系统提示词中的"工作区根路径为 /"指的是虚拟根目录,不是操作系统真正的 /。这种映射既让模型更容易使用绝对路径,也把它的文件操作限制在 rootDir 内。
4. 完整组装文件系统 Agent
js
import "dotenv/config";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ChatOpenAI } from "@langchain/openai";
import {
createFilesystemMiddleware,
FilesystemBackend,
} from "deepagents";
import {
createAgent,
HumanMessage,
} from "langchain";
const workspaceDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"workspace"
);
fs.mkdirSync(workspaceDir, { recursive: true });
fs.writeFileSync(
path.join(workspaceDir, "secret.txt"),
"机密:不得读取",
"utf-8"
);
const permissions = [
{
operations: ["read"],
paths: ["/secret.txt"],
mode: "deny",
},
{
operations: ["write"],
paths: ["/todo.md"],
mode: "allow",
},
{
operations: ["write"],
paths: ["/**"],
mode: "deny",
},
];
const model = new ChatOpenAI({
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
temperature: 0,
});
const agent = createAgent({
model,
tools: [],
systemPrompt: `
工作区根路径为 /。用 ls、read_file、write_file、edit_file 操作文件,
路径以 / 开头。使用中文回答。
`,
middleware: [
createFilesystemMiddleware({
backend: new FilesystemBackend({
rootDir: workspaceDir,
virtualMode: true,
}),
permissions,
}),
],
});
async function run(label, prompt) {
console.log(`\n===== ${label} =====`);
const { messages } = await agent.invoke(
{
messages: [new HumanMessage(prompt)],
},
{
recursionLimit: 20,
}
);
console.log(messages);
}
await run(
"允许的操作",
"write_file 创建 /todo.md(三条代办)," +
"edit_file 把其中一条标为完成,ls /,一句话总结"
);
虽然 Agent 顶层仍是 tools: [],文件系统中间件会提供 ls、read_file、write_file、edit_file 等工具。FilesystemBackend 不具备执行 shell 命令的能力,因此这里的重点是结构化文件工具,而不是任意命令执行。
recursionLimit: 20 限制的是状态图允许推进的最大步数。文件任务通常包含"模型决策 → 写文件 → 再次决策 → 编辑文件 → 再次决策 → 列目录 → 总结"等多个节点,设置上限可以避免异常情况下无限循环。
如果 /todo.md 最终内容类似:
md
- [x] 买菜
- [ ] 洗衣服
- [ ] 写周报
说明 write_file 和 edit_file 都成功工作。具体哪一项被标记为完成由模型决定,示例只要求完成其中一项。
八、三种消息对象分别解决什么问题
几个示例里出现了三类消息,它们的角色不同:
| 类型 | 发送者角色 | 本文中的用途 |
|---|---|---|
HumanMessage |
用户 | 将自然语言任务放入 Agent 输入 |
AIMessage |
模型或 Agent | 短路时构造一条可直接返回给用户的回复 |
ToolMessage |
工具 | 将工具结果与对应的 tool_call_id 关联起来 |
其中 tool_call_id 不能随意丢失。模型可能一次发出多个工具调用,框架需要靠它判断每个结果对应哪个调用。重新包装 ToolMessage 时至少要保留 tool_call_id,同时保留工具名。
九、把几个关键边界再梳理一遍
1. Hook 和 Wrapper 不要混用
beforeAgent、beforeModel、afterModel、afterAgent更适合观察状态、返回局部状态更新或控制跳转;wrapModelCall、wrapToolCall更适合包裹真正的调用过程,改请求、执行handler、再处理结果。
如果只是给模型补一段系统消息,wrapModelCall 比在全局到处拼 Prompt 更集中。如果要在模型调用前直接拒绝请求,beforeModel + jumpTo 更直接。
2. 状态计数要放在真正对应的事件上
- 模型调用计数放在
afterModel; - 工具调用计数放在
wrapToolCall成功执行handler之后; - 不要用
afterAgent同时猜测模型和工具分别执行了多少次。
3. 短路会改变后续钩子的执行机会
jumpTo: "end" 不只是返回一个标记,它会改变执行图的路径。在 beforeModel 中直接结束后,模型包装器、模型节点、afterModel 以及这里的 afterAgent 都不会继续执行。因此,日志中看不到这些阶段不是异常,而是短路本身的结果。
4. 虚拟路径隔离与权限规则是两层保护
virtualMode: true 解决"Agent 能活动到哪里",permissions 解决"在允许活动的范围内,哪些路径能读或写"。两者互相补充,但不能互相替代:
- 只有虚拟目录,没有权限规则:Agent 仍可以修改工作区内的所有文件;
- 只有路径规则,没有虚拟目录:路径映射和根目录边界不够直观;
- 两者同时使用:先限制根目录,再对目录内文件做细粒度控制。
5. 规则顺序和默认放行决定最终权限
下面两组规则看起来相似,结果却完全不同:
js
// 正确:精确允许在前,兜底拒绝在后
[
{ operations: ["write"], paths: ["/todo.md"], mode: "allow" },
{ operations: ["write"], paths: ["/**"], mode: "deny" },
]
js
// 错误:/todo.md 会先命中全局拒绝
[
{ operations: ["write"], paths: ["/**"], mode: "deny" },
{ operations: ["write"], paths: ["/todo.md"], mode: "allow" },
]
另外,只配置"禁止读取 secret"并不代表其他读取也被禁止,因为未匹配规则时默认允许。设计白名单时,要显式加入相应的兜底拒绝规则。
十、总结
中间件真正有价值的地方,不是少写几个 console.log,而是把 Agent 的横切能力变成可组合的执行单元:
stateSchema为中间件增加有默认值、可参与状态图更新的字段;- 生命周期钩子让我们在 Agent 或模型调用的关键节点观察、更新和截断流程;
wrapModelCall可以在模型调用前动态修改系统消息;beforeModel配合canJumpTo、jumpTo和AIMessage,可以在零模型调用的情况下返回拦截结果;- 中间件的
tools可以独立向 Agent 注入工具; wrapToolCall配合ToolMessage和Command,可以同时包装结果、维护消息关联并更新自定义状态;FilesystemBackend负责真实存储与虚拟路径映射,createFilesystemMiddleware负责工具暴露和权限执行;- 文件权限采用"按顺序首条匹配、未匹配默认允许",规则顺序必须经过仔细设计。
把这些边界理顺之后,日志、审计、内容拦截、动态上下文、工具扩展和文件权限就不再需要散落在 Agent 主流程中。Agent 负责推理和选择动作,中间件负责约束、观测和扩展,两者的职责会清晰很多。