LangChain Agent 中间件实战(下):DeepAgents 文件权限、长期记忆与对话摘要
上篇解决了 Agent 运行过程的扩展问题:生命周期日志、状态统计、请求改写、流程短路和工具包装。继续往下做,一个真正能长期使用的 Agent 还会遇到三个更具体的问题:
- Agent 可以读写哪些文件,怎样限制敏感文件和写入范围?
- 项目约定与用户偏好怎样跨对话保留下来?
- 对话越来越长以后,怎样降低上下文长度,同时不丢掉旧消息原文?
DeepAgents 的三个 Middleware 正好对应这三类能力:
| Middleware | 主要职责 |
|---|---|
createFilesystemMiddleware |
给 Agent 提供文件工具,并执行路径权限规则 |
createMemoryMiddleware |
从指定文件加载长期记忆,注入模型上下文 |
createSummarizationMiddleware |
在达到阈值时生成滚动摘要,并归档被压缩的旧消息 |
这三者都涉及"记住信息",但它们管理的不是同一种数据。本文会先分别实现,再把它们放到同一张状态图中理解。
一、统一的模型和 ESM 路径准备
示例继续使用环境变量创建模型:
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,
});
因为代码运行在 Node.js ESM 中,没有直接使用 CommonJS 的 __dirname,所以通过 import.meta.url 计算当前模块目录:
js
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
这里经过了两步转换:
text
import.meta.url
↓ fileURLToPath
当前模块的本地文件路径
↓ path.dirname
当前模块所在目录
后面三个示例都以这个目录为基准创建各自的工作区,避免把演示文件散落到其他位置。
二、用 FilesystemBackend 建立虚拟工作区
先创建一个文件系统工作区:
js
import {
createFilesystemMiddleware,
FilesystemBackend,
} from "deepagents";
const workspaceDir = path.join(__dirname, "workspace");
const backend = new FilesystemBackend({
rootDir: workspaceDir,
virtualMode: true,
});
这两个配置要放在一起理解:
rootDir指定本地真实工作区;virtualMode: true让 Agent 使用以/开头的虚拟绝对路径。
例如,Agent 操作:
text
/todo.md
底层实际访问的是:
text
workspace/todo.md
因此,提示词可以稳定地告诉 Agent"工作区根路径为 /",而不用把运行机器上的真实绝对路径暴露给模型。
三、用有序权限规则保护文件系统
仅仅把路径映射到工作区还不够。Agent 获得 read_file、write_file、edit_file 等工具后,还需要明确哪些操作能通过。
下面的规则实现三个目标:
- 禁止读取
/secret.txt; - 允许写入
/todo.md; - 拒绝其他所有写入。
js
const permissions = [
{
operations: ["read"],
paths: ["/secret.txt"],
mode: "deny",
},
{
operations: ["write"],
paths: ["/todo.md"],
mode: "allow",
},
{
operations: ["write"],
paths: ["/**"],
mode: "deny",
},
];
1. 规则是"首次匹配生效"
判断时会同时检查操作类型和路径,并使用第一条匹配规则。因此顺序直接影响结果。
以写 /todo.md 为例:
- 第一条只处理
read,跳过; - 第二条匹配
write + /todo.md,得到allow; - 已经得到结果,不再落到后面的全局写入拒绝规则。
如果把 write + /** + deny 放在允许规则前面,/todo.md 也会先被拒绝。
最终权限矩阵如下:
| 操作 | 路径 | 命中的规则 | 结果 |
|---|---|---|---|
read |
/secret.txt |
精确读取拒绝 | 拒绝 |
write |
/todo.md |
精确写入允许 | 允许 |
write |
/hack.txt |
全局写入拒绝 | 拒绝 |
read |
/todo.md |
没有读取拒绝规则 | 允许 |
最后一行很容易被忽略:这组规则不是"默认拒绝一切"。没有匹配到拒绝规则的操作仍然可以通过。因此它表达的是"除敏感文件外可以读取,但只能写指定文件"。
2. /** 表示递归覆盖工作区
js
{
operations: ["write"],
paths: ["/**"],
mode: "deny",
}
这条路径会匹配当前虚拟根目录下各层级的文件,所以不仅 /hack.txt 会被拒绝,其他子目录中的写入也会落入该规则,除非前面已经有更具体的允许规则。
3. Agent 权限不约束宿主程序自己的 fs 调用
为了测试读取拒绝,可以在启动时由 Node.js 代码准备敏感文件:
js
import fs from "node:fs";
fs.writeFileSync(
path.join(workspaceDir, "secret.txt"),
"机密:不得读取",
"utf-8"
);
这段代码可以成功写入,是因为它是宿主程序直接调用 Node.js fs,并没有经过 Agent 的文件工具。permissions 约束的是 createFilesystemMiddleware 暴露出来的文件操作,不会反过来拦截应用程序自身的文件系统 API。
这一区分非常关键:
text
宿主程序 fs.writeFileSync
└─ 直接操作真实文件
Agent 调用 write_file
└─ Filesystem Middleware 检查权限
├─ allow:交给 Backend
└─ deny:返回权限错误
四、把 Backend、权限和 Agent 连接起来
完整的 Agent 配置如下:
js
import {
createAgent,
HumanMessage,
} from "langchain";
const agent = createAgent({
model,
tools: [],
systemPrompt: `
工作区根路径为 /。用 ls、read_file、write_file、edit_file 操作文件,
路径以 / 开头。中文回答。
`,
middleware: [
createFilesystemMiddleware({
permissions,
backend: new FilesystemBackend({
rootDir: workspaceDir,
virtualMode: true,
}),
}),
],
});
这里顶层 tools 仍然为空,文件工具由 Middleware 注入。System Prompt 则告诉模型:
- 工作区的虚拟根路径是
/; - 应该调用哪些文件工具;
- 文件路径要以
/开头。
Backend 负责真正落地文件操作,Middleware 负责提供工具与权限检查,System Prompt 负责让模型正确使用它们。三者职责不同,缺少任何一层都会影响最终行为。
五、为什么权限失败要检查 ToolMessage
可以写一个专门的测试函数验证拒绝行为:
js
async function expectDenied(label, prompt) {
console.log(`\n== ${label}(预期拒绝)===\n`, prompt, "\n");
try {
const { messages } = await agent.invoke(
{
messages: [new HumanMessage(prompt)],
},
{
recursionLimit: 5,
}
);
const denied = messages.some(
(message) =>
message.getType?.() === "tool" &&
message.status === "error" &&
/permission denied/.test(String(message.content))
);
console.log(denied ? "OK:已拒绝" : "未触发拒绝(异常)");
} catch (error) {
const message = error.cause?.message ?? error.message;
console.log("X", message);
}
}
await expectDenied(
"禁止写",
"只调用 write_file,路径 /hack.txt,内容:text"
);
这里同时处理了两条失败通道:
- Agent 正常完成,但消息列表里出现
status === "error"的工具消息; - 调用过程直接抛出异常,由
catch记录错误。
权限拒绝通常是一次可识别的工具失败,不一定会让整个 agent.invoke 以异常结束。因此,只写 try/catch 不能完整验证权限;还要检查工具消息的类型、状态和内容。
recursionLimit: 5 则限制这次测试的执行步数,防止模型在工具失败后反复尝试。
六、从"能读文件"到"把文件当作长期记忆"
文件系统 Middleware 让 Agent 能按需读取文件,但长期记忆还有一个额外要求:在模型回答前,应该主动把指定记忆放进上下文,而不是每次都等模型先决定调用 read_file。
这就是 createMemoryMiddleware 的作用。
先准备两个记忆来源:
js
const workspaceDir = path.join(__dirname, "workspace-memory");
const projectMemoryPath = "/AGENTS.md";
const preferencesMemoryPath = "/memory/preferences.md";
const backend = new FilesystemBackend({
rootDir: workspaceDir,
virtualMode: true,
});
两个路径分别承载不同类型的信息:
| 记忆文件 | 应保存的内容 |
|---|---|
/AGENTS.md |
项目说明、技术栈、架构、仓库约定、入口脚本等项目事实 |
/memory/preferences.md |
用户语言、包管理器、回答风格、工具偏好等个人偏好 |
把它们分开,不是为了目录好看,而是为了避免项目事实和个人偏好相互污染。
七、文件系统 Middleware 与记忆 Middleware 要配合使用
创建 Agent 时,同时挂载两个 Middleware:
js
import {
createFilesystemMiddleware,
createMemoryMiddleware,
} from "deepagents";
const agent = createAgent({
model,
tools: [],
systemPrompt: [
"你是项目助手。工作区根路径为 /,可用 ls、read_file、write_file、edit_file。",
"根据 <agent_memory> 回答;用户要求记住时,必须立刻调用 edit_file,并按照类型写入对应文件。",
`- ${projectMemoryPath}:项目说明、技术栈、架构、仓库约定等。`,
`- ${preferencesMemoryPath}:用户个人偏好(语言、包管理器、回答风格等)。`,
"不要混写:项目事实不要写入 preferences,个人偏好不要写入 AGENTS.md。",
].join("\n"),
middleware: [
createFilesystemMiddleware({ backend }),
createMemoryMiddleware({
backend,
sources: [
projectMemoryPath,
preferencesMemoryPath,
],
}),
],
});
两者的分工是:
text
createMemoryMiddleware
└─ 从 sources 加载记忆,并注入模型上下文
createFilesystemMiddleware
└─ 提供 edit_file 等工具,让 Agent 能把新记忆写回文件
sources 声明的是"从哪里加载记忆",并不会根据语义自动决定新内容该写入哪个文件。分类写入由 System Prompt 明确约束,Agent 再调用 edit_file 实际完成更新。
因此,长期记忆并不是一个隐藏数据库自动猜测并保存所有对话,而是一条可以观察的链路:
text
启动一次 Agent 调用
↓
Memory Middleware 读取指定文件
↓
内容以 <agent_memory> 进入模型上下文
↓
用户提出需要长期保留的信息
↓
Agent 按 System Prompt 选择目标记忆文件
↓
Filesystem Middleware 提供 edit_file
↓
Backend 把更新写入真实工作区
八、一次完整的分类记忆实验
依次发送四个请求:
js
const prompts = [
"根据记忆,这个项目是做什么的?只回答一句。",
"请记住:我常用的包管理器是 pnpm。",
"请记住:本仓库主入口脚本是 src/deepagents/memory-agent.mjs。",
"我常用什么包管理器?本 Demo 主入口脚本路径是什么?各用一行回答。",
];
第一条测试现有项目记忆能否被读取;第二条是用户偏好;第三条是项目事实;第四条要求同时回忆两种信息。
为了让四次 invoke 仍属于同一段对话,需要显式保存并继续传入消息历史:
js
let messages = [];
for (const prompt of prompts) {
console.log("\n用户:", prompt);
const { messages: newMessages } = await agent.invoke(
{
messages: [
...messages,
new HumanMessage({ content: prompt }),
],
},
{
recursionLimit: 30,
}
);
messages = newMessages;
console.log("回复:", messages.at(-1)?.content);
}
循环中的关键不是 for,而是这两步:
js
messages: [...messages, new HumanMessage({ content: prompt })]
把旧消息与本轮用户消息一起交给 Agent;然后:
js
messages = newMessages;
保存 Agent 返回的完整消息列表,供下一轮继续使用。
执行记忆写入后,两类信息应该分别落在不同位置:
md
# Project Documentation
## Important Notes
- 主入口脚本:`src/deepagents/memory-agent.mjs`
以及:
md
# User Preferences
## Tool Preferences
- Preferred package manager: pnpm
最终提问同时需要两种信息,Agent 可以从注入的记忆与当前对话中组织出:
text
pnpm
src/deepagents/memory-agent.mjs
记忆写入与消息传递是两条不同的连续性
这个实验同时保留了两种上下文:
messages保留当前进程中这几轮对话的连续性;- Markdown 文件保留可再次加载的长期记忆。
如果只传 messages,进程结束后这些信息不会自动成为文件记忆;如果只写记忆文件而不传旧消息,同一次多轮对话的具体上下文也会缺失。两者不能互相替代。
九、长对话为什么还需要摘要
长期记忆解决的是"哪些稳定事实应该跨对话保留",但不应该把整段聊天原文都塞进项目说明或用户偏好。另一方面,如果始终把所有 messages 原样发送给模型,上下文会不断增长。
摘要 Middleware 解决的是另一类问题:
text
较早的对话 → 压缩成滚动摘要
最近的消息 → 保留原文,维持局部连贯性
被压缩的旧消息 → 归档到文件,便于回查
先创建独立工作区与归档路径:
js
const workspaceDir = path.join(
__dirname,
"workspace-summarization"
);
const historyPathPrefix = "/conversation_history";
const backend = new FilesystemBackend({
rootDir: workspaceDir,
virtualMode: true,
});
再定义中文摘要提示词:
js
const summaryPrompt = `
你是对话摘要助手,请用中文总结以下对话,包含:
1. 讨论的主要话题
2. 达成的关键结论或决定
3. 继续对话所需的重要上下文
保持简洁,不要罗列无关的细节。
待摘要的对话:{conversation}
摘要:
`;
{conversation} 是待摘要对话的占位位置。提示词关注的是"后续继续对话所需的信息",而不是简单逐句复述。
十、配置触发阈值与保留窗口
摘要 Agent 的核心配置如下:
js
const agent = createAgent({
model,
tools: [],
systemPrompt:
"你是会话助手,记住用户提到的关键事实,中文简短回答。" +
"若看到[此前对话摘要],请据此继续对话。",
middleware: [
createSummarizationMiddleware({
model,
backend,
historyPathPrefix,
summaryPrompt,
trigger: { type: "messages", value: 8 },
keep: { type: "messages", value: 4 },
}),
],
});
其中:
model用于生成摘要;backend用于写入归档;historyPathPrefix指定归档的虚拟目录;summaryPrompt控制摘要内容;trigger表示有效上下文消息数达到阈值时触发摘要;keep表示摘要后保留最近 4 条消息原文。
这里统计的是消息条数,不是"用户提问 + AI 回答"组成的轮数。一次普通问答通常产生两条消息,有工具调用时还可能出现工具消息。
为什么通常不是第 8 条刚产生就立刻摘要
摘要检查发生在 Agent 准备调用模型时。假设前四轮没有工具调用:
text
第 1~8 条:四轮问答完成
第 9 条:第五轮用户消息进入
第 8 条是上一轮模型刚生成的回答;那次模型调用已经结束。等第 9 条用户消息进入、Agent 再次准备调用模型时,中间件才看到当前上下文已达到阈值并执行摘要。
当时共有 9 条消息,keep: 4 会保留最近 4 条,因此更早的 5 条进入本次压缩与归档范围。这个"第 9 条触发"的结论只适用于无工具消息的这组问答,不代表所有 Agent 都固定在第 9 条触发。
十一、摘要场景必须传递完整 Agent 状态
这是摘要实验中最值得注意的代码:
js
let state = {
messages: [],
};
for (const prompt of prompts) {
state = await agent.invoke(
{
...state,
messages: [
...state.messages,
new HumanMessage(prompt),
],
},
{
recursionLimit: 30,
}
);
}
前面的长期记忆示例只取出了 messages,这里却保存整个 state,原因是摘要 Middleware 还会在状态中维护摘要事件等信息。
对比两种写法:
js
// 只保留消息
const { messages } = await agent.invoke(...);
js
// 保留 Middleware 返回的全部状态
state = await agent.invoke(...);
摘要场景需要第二种。下一轮调用时先展开:
js
...state
让上一轮的摘要相关状态继续存在,再单独构造追加了新用户消息的 messages。如果每轮只传回消息列表,运行时的摘要事件就无法沿着这段手动维护的会话继续传递。
这也是为什么"消息历史"和"Agent 完整状态"不能总被视为同一个东西。
十二、如何观察摘要事件与有效上下文
每轮执行完成后,可以读取:
js
const event = state._summarizationEvent;
当摘要已经产生时,事件里用到三个关键字段:
js
event.summaryMessage
event.cutoffIndex
event.filePath
它们分别表示:
| 字段 | 含义 |
|---|---|
summaryMessage |
当前滚动摘要消息 |
cutoffIndex |
原始消息历史被摘要到的位置 |
filePath |
本次会话历史归档文件的虚拟路径 |
根据摘要与截止位置,可以还原主模型当前使用的有效上下文:
js
const effectiveMessages = [
event.summaryMessage,
...state.messages.slice(event.cutoffIndex),
];
这里揭示了一个看似矛盾、其实非常重要的设计:
state.messages仍可以保留完整原始消息;- 主模型不必每次接收全部原始消息;
- 它看到的是最新摘要,加上截止位置之后的近期消息。
可以逐条打印验证:
js
effectiveMessages.forEach((message, index) => {
const type = message.getType?.() ?? "unknown";
console.log(
`${index + 1}. [${type}]`,
message.content
);
});
console.log(
"有效上下文消息数:",
effectiveMessages.length
);
十三、如何判断本轮是否生成了新摘要
摘要事件一旦存在,后续没有触发新摘要的轮次也仍可能读到当前事件。因此,不能只用 if (event) 判断"本轮刚刚摘要"。
可以记住上一条摘要消息:
js
let lastSummaryMessage = null;
每轮比较:
js
if (event) {
const isNewSummary =
event.summaryMessage !== lastSummaryMessage;
if (isNewSummary) {
console.log("\n===== 触发了摘要 =====");
console.log("摘要内容:");
console.log(event.summaryMessage.content);
console.log("摘要截止位置:", event.cutoffIndex);
console.log("历史归档文件:", event.filePath);
lastSummaryMessage = event.summaryMessage;
}
}
逻辑不是"有摘要事件就打印",而是"摘要消息对象和上次记录不同才打印"。这样,观察日志不会把沿用旧摘要误报成一次新摘要。
十四、归档文件检测与摘要检测不是一回事
还可以通过目录内容观察首次创建的历史文件:
js
const historyDir = path.join(
workspaceDir,
historyPathPrefix.replace(/^\//, "")
);
function listHistoryFiles() {
if (!fs.existsSync(historyDir)) return [];
return fs.readdirSync(historyDir);
}
let knownHistory = new Set(listHistoryFiles());
每轮结束后比较文件名:
js
const currentHistory = listHistoryFiles();
for (const file of currentHistory) {
if (!knownHistory.has(file)) {
console.log(
"\n新增历史归档文件:",
path.join(historyDir, file)
);
}
}
knownHistory = new Set(currentHistory);
这段代码检测的是"是否出现了新文件名"。如果后续摘要继续向同一个会话归档文件追加内容,文件名集合没有变化,这个检测不会再次提示。
所以示例用了两套观察手段:
lastSummaryMessage用于判断是否产生了新的摘要事件;knownHistory用于判断目录中是否新增了归档文件。
二者不能互相代替。
十五、归档保存原文,摘要负责压缩
达到阈值后,历史文件中的内容类似:
md
## Summarized at 2026-10-14T14:57:31.811Z
Human: 请记住:我的宠物猫叫小橘。
AI: 好的,已记住你的宠物猫叫小橘。
Human: 请记住:我住在北京。
AI: 好的,已记住你住在北京。
Human: 请记住:我喜欢喝拿铁。
这里保存的是本次被压缩的旧消息原文,不是摘要文本。摘要可能只保留"用户养了一只叫小橘的猫,住在北京并喜欢拿铁",而归档仍保留原始角色和表达。
三类数据由此各司其职:
| 数据 | 保存什么 | 用来做什么 |
|---|---|---|
state.messages |
完整原始消息历史 | 保存运行时原始对话 |
_summarizationEvent.summaryMessage |
截止当前的滚动摘要 | 缩短主模型有效上下文 |
conversation_history |
每次被压缩的旧消息原文 | 留下可回查的历史归档 |
当第二次达到阈值时,摘要不是简单堆成"摘要 A + 摘要 B"。旧摘要会与本次新进入压缩范围的消息一起形成更新后的滚动摘要;归档则继续记录此前尚未归档的原始消息。
十六、演示环境为什么会先清空摘要工作区
摘要实验在启动时执行:
js
fs.rmSync(workspaceDir, {
recursive: true,
force: true,
});
fs.mkdirSync(workspaceDir, {
recursive: true,
});
它的目的很明确:每次实验都从空目录开始,便于判断本轮究竟创建了哪些归档文件。
但这也意味着每次启动都会删除上次的历史归档。因此,这段清理逻辑属于 Demo 初始化,不应和"通过文件实现持久化"的业务目标混在一起。如果希望跨启动保留归档,就不能在每次运行前删除整个目录。
十七、三种能力放在一起怎样理解
现在可以画出完整的数据关系:
text
┌────────────────────┐
│ 用户本轮消息 │
└─────────┬──────────┘
↓
┌────────────────────┐ ┌────────────────────┐
│ 项目/偏好记忆文件 │ → │ Memory Middleware │
│ AGENTS.md 等 │ │ 注入 agent_memory │
└────────────────────┘ └─────────┬──────────┘
↓
┌────────────────────┐
│ Agent 有效上下文 │
└─────────┬──────────┘
↓
达到摘要阈值了吗?
├─ 否:直接调用模型
└─ 是
├─ 生成滚动摘要
├─ 保留最近消息
└─ 旧消息原文归档
模型需要读写文件时
↓
Filesystem Middleware 检查 operation + path
├─ allow:Backend 映射到真实工作区
└─ deny:返回 status=error 的工具消息
再从用途上比较:
| 能力 | 数据来源 | 何时进入模型上下文 | 是否写入磁盘 |
|---|---|---|---|
| 普通文件工具 | 工作区文件 | 模型主动调用读取工具时 | 写工具可以落盘 |
| 长期记忆 | sources 指定的 Markdown |
Memory Middleware 在模型调用时注入 | Agent 可用 edit_file 更新 |
| 对话摘要 | 旧摘要与待压缩消息 | 达到阈值后,以摘要替代较早原文 | 被压缩的旧消息可归档 |
十八、最容易混淆的五个边界
1. 虚拟根目录不等于操作系统根目录
virtualMode: true 时,Agent 看到的 / 映射到 rootDir。/todo.md 是工作区内文件,不是操作系统真正的 /todo.md。
2. 文件系统权限不限制宿主代码
permissions 检查 Agent 文件工具。应用程序直接执行的 fs.writeFileSync 不走这套规则。
3. Memory Middleware 负责加载,不负责语义分类
sources 给出记忆文件列表;"项目事实写入 AGENTS.md、用户偏好写入 preferences.md"由 System Prompt 约束,实际写入由文件工具完成。
4. 消息历史不等于完整 Agent 状态
普通多轮聊天只保存 messages 可以维持对话,但摘要 Middleware 还产生 _summarizationEvent 等状态。手动续接摘要会话时,应把整个返回状态传给下一轮。
5. 归档文件不等于摘要文件
归档保存被压缩的旧消息原文,摘要消息用于主模型的短上下文。二者一个偏向可回查性,一个偏向调用效率。
十九、总结
一个文件化 Agent 的上下文并不是简单的"把聊天记录保存下来",而是由不同层次共同组成:
FilesystemBackend把虚拟路径映射到受控工作区;createFilesystemMiddleware提供文件工具,并用有序规则控制读写;createMemoryMiddleware主动加载项目事实和用户偏好,形成长期记忆;createSummarizationMiddleware在消息达到阈值后压缩旧上下文,同时归档旧消息原文;- 调用方负责在多轮执行之间正确传递
messages或完整state。
最值得记住的是这三句话:
text
权限决定 Agent 能操作什么。
记忆决定哪些稳定信息每次都进入上下文。
摘要决定长对话中哪些内容用短文本继续携带。
把三者边界分清后,文件读写、长期记忆和长对话压缩就不再是三套互不相干的功能,而是一套可观察、可限制、可持续传递的 Agent 上下文工程。