写在前面:上一篇我们手写了中间件——四个钩子、一次短路、自带工具,全靠自己拼。这篇换个姿势:看 DeepAgents 官方给的三个成品。它们分别解决三个问题——Agent 能碰什么(文件系统权限)、Agent 记住什么(记忆)、Agent 该忘什么(总结压缩)。前两个是"能力",第三个是"负担管理"。三个 demo 都是同一个套路:挂上中间件,改几句 systemPrompt,然后跑起来看效果。以下所有代码均来自课堂真实文件。可能会与几期之前的东西有重合。
一、共同的地基:那个"虚拟文件系统"
三个 demo 都有一个相同的开头:
import {createFilesystemMiddleware, FilesystemBackend} from "deepagents";
import {createAgent, HumanMessage} from "langchain";
const backend = new FilesystemBackend({
rootDir: workspaceDir,
virtualMode: true,
});
注意 FilesystemBackend 出现的次数——连"记忆"和"总结"这两个看起来跟文件无关的中间件,也都要它。
为什么?因为它们的实现方式就是把东西存成文件:
| 中间件 | 存什么文件 |
|---|---|
createFilesystemMiddleware | 让 Agent 能读写工作区里的文件 |
createMemoryMiddleware | 把记忆存在 /AGENT.md 等文件里 |
createSummarizationMiddleware | 把被压缩的历史存到 /conversation_history |
所以 FilesystemBackend 是它们共同的地基。 这也解释了为什么 deepagents 把它作为独立模块导出——它是一个可复用的"文件系统抽象层"。
virtualMode:Agent 看到的是"假路径"
new FilesystemBackend({
rootDir: workspaceDir, // 真实目录
virtualMode: true, // 虚拟路径模式
})
virtualMode: true 的含义是——Agent 眼里看到的路径是虚拟的,实际落盘在 rootDir 下。
Agent 看到的 实际文件系统的位置
/ → workspaceDir/
/AGENT.md → workspaceDir/AGENT.md
/secret.txt → workspaceDir/secret.txt
这个设计有三个好处:
| 好处 | 说明 |
|---|---|
| 安全 | Agent 压根不知道真实路径,没法写 /etc/passwd 这类系统路径 |
| 可移植 | 换台机器、换个目录,Agent 的 prompt 一个字都不用改 |
| 好理解 | / 就是"工作区根",比一长串绝对路径清爽 |
这也解释了为什么几个 demo 的 systemPrompt 里都要特意交代一句:
systemPrompt:`
工作区根路径为/ 。用ls、read_file、edit_file 操作文件,
路径以/ 开头。中文回答。
`
"路径以 / 开头" ——这句话是在教模型适应虚拟路径的规则。不交代清楚,模型可能试 ./todo.md 或者绝对路径,都找不到东西。
拿真实路径:一个 Node.js 的小技巧
import { fileURLToPath } from "node:url";
const workspaceDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"workspace"
);
import.meta.url 拿到的是当前文件的 URL(file:///D:/.../filesystem-agent.mjs),不是普通路径。所以要先 fileURLToPath() 转成文件路径,再 path.dirname() 取目录。
为什么要绕这一圈? 因为在 ESM 模块里,没有 __dirname 这种现成的"当前目录"变量(那是 CommonJS 的东西)。ESM 里要拿当前文件目录,这就是标准姿势。
后面 memory-agent.mjs 和 summarization-agent.mjs 都用了这行:
const __dirname = path.dirname(fileURLToPath(import.meta.url));
自己造了个 __dirname。 这是 ESM 迁移中非常常见的一行代码。
二、文件系统中间件:管"能碰什么"
第一个中间件解决的是权限问题。
const permissions = [
{ operations: ["read"], paths: ["/secret.txt"], mode: "deny" },
{ operations: ["write"], paths: ["/todo.md"], mode: "allow" },
{ operations: ["write"], paths: ["/**"], mode: "deny" }
];
const agent = createAgent({
model,
tools: [],
systemPrompt:`...`,
middleware:[
// 业务层 拿来就用
createFilesystemMiddleware({
permissions,
backend: new FilesystemBackend({
rootDir: workspaceDir,
// permissions,
virtualMode: true,
}),
}),
]
});
这三条规则值得逐条拆开看。
规则一:禁止读机密文件
{ operations: ["read"], paths: ["/secret.txt"], mode: "deny" }
operations: ["read"]—— 管的是"读"这个动作paths: ["/secret.txt"]—— 针对这个具体文件mode: "deny"—— 拒绝
这是个精确打击的规则:就这一个文件,就这一种操作。
规则二:允许写待办文件
{ operations: ["write"], paths: ["/todo.md"], mode: "allow" }
这条是"开口子"——在全面禁止的背景下,单独放行一个文件。
规则三:禁止一切写入(兜底)
{ operations: ["write"], paths: ["/**"], mode: "deny" }
paths: ["/**"]—— 通配符,匹配所有路径mode: "deny"—— 拒绝
这条是"兜底规则"——除了上面明确放行的,其他一律不许写。
顺序很关键:这叫"默认拒绝"
把三条规则连起来看,逻辑是:
1. 禁止读 /secret.txt ← 具体的一条例外
2. 允许写 /todo.md ← 明确开的口子
3. 禁止写其他一切 ← 兜底封锁
这是一种"白名单"思路——默认全部拒绝,只放行明确许可的。
反过来写("默认允许,只禁几个")叫黑名单。两者差别很大:
| 策略 | 逻辑 | 风险 |
|---|---|---|
| 白名单(本例) | 除了允许的,全都禁 | 低——新出现的东西默认是禁的 |
| 黑名单 | 除了禁止的,全都放 | 高——总有你想不到的漏网之鱼 |
这就是安全领域讲的"最小权限原则":只给必需的权限,其余默认关掉。
写死一个 /secret.txt 当然是为了演示。但换成真实场景,这套机制就能派上大用:
| 场景 | 规则示例 |
|---|---|
| 保护配置文件 | 禁读 .env / secrets.json |
| 只写不读 | 允许写日志,禁止读日志(防止泄露) |
| 只读仓库 | 读代码允许,写代码拒绝 |
| 限定目录 | 只允许写 /workspace/** |
一个值得注意的注释
backend: new FilesystemBackend({
rootDir: workspaceDir,
// permissions, ← 注释掉的
virtualMode: true,
}),
permissions 在外层和 FilesystemBackend 里各能传一次,但这里注释掉了其中一个。
这说明两处都能配权限,课堂选择传给外层的 createFilesystemMiddleware。这种"两个地方都能配、但只在一处生效"的 API 设计,读文档的时候要特别留意——配错地方会出现"我明明设了权限,怎么没用"的困惑。
怎么验证权限真的生效了?
这是这份文件最讲究的地方——它没有靠"看日志"来判断,而是写了个断言函数。
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((m) => m.getType?.() === "tool" &&
m.status === "error" && /permission denied/.test(String(m.content))
);
console.log(denied ? "OK: 已拒绝" : "未触发拒绝(异常)" );
} catch (e) {
const msg = e.cause?.message ?? e.message;
console.log("X", msg);
}
}
核心是那个 denied 的判断,三个条件同时成立:
| 条件 | 检查什么 |
|---|---|
m.getType?.() === "tool" | 这条是工具返回的消息 |
m.status === "error" | 这次工具执行是失败的 |
/permission denied/.test(...) | 失败原因是权限被拒 |
三个条件缺一不可。 如果只看"有没有错误",可能是网络问题、参数错误等任何原因——必须确认"失败的原因是权限",才能证明权限系统真的生效了。
这跟前面讲评估时"要能定位问题"是同一个思路:不要满足于"出错了",要确认"错在我预期的那个地方"。
注意 m.getType?.() 那个可选调用——不是所有消息对象都有 getType 方法(比如纯对象格式的消息),加个 ?. 避免报错。
recursionLimit: 5 的用意也很清楚——这是个预期失败的测试,不需要跑太多轮。限制轮数,避免测试卡住浪费时间。 对比那个"允许的操作"用的 recursionLimit: 20,宽松得多(因为要写文件、编辑、列目录、总结,步数多)。
catch 里的 e.cause?.message ?? e.message 也是老练写法——有些错误在 cause 里藏着更具体的原因,没有就退回 message。
测试用例的三种类型
// await run(
// "允许的操作",
// "write_file 创建 /todo.md (三条待办), edit_file 把一条标为完成,ls /,一句话总结"
// );
// await expectDenied("禁止读", "只调用read_file,路径/secret.txt");
await expectDenied("禁止写", "只调用write_file,路径/secret.txt");
三种测试,覆盖了权限系统的三个维度:
| 测试 | 预期 | 验证什么 |
|---|---|---|
| 允许的操作 | 成功 | 白名单真的放行了(写 /todo.md 应该能过) |
| 禁止读 | 拒绝 | 第一条规则生效(读 /secret.txt 被拒) |
| 禁止写 | 拒绝 | 兜底规则生效(写 /secret.txt 被拒) |
注意"允许的操作"那条 prompt 写得非常细——"创建 /todo.md(三条待办),edit_file 把一条标为完成,ls /,一句话总结"。它把要验证的每一步都点名了,这样跑完能一次性确认"写、改、列、总结"四个动作全通。
而且它在验证一个微妙的东西:规则二是"允许写 /todo.md",但规则三是"禁止写 /**"。这两条冲突吗? 不冲突——具体的规则优先于通配的规则。这条测试就是在证明这个优先级判断是对的。
准备测试环境的部分也别忘了:
fs.rmSync(workspaceDir, { recursive: true, force: true });
fs.mkdirSync(workspaceDir);
fs.writeFileSync(path.join(workspaceDir, "secret.txt"), "机密:不得读取", "utf-8");
先把工作区清空重建(保证每次运行的起点一致),再造一个"机密文件"出来。
那个 fs.rmSync(..., { recursive: true, force: true }) 很关键:
| 参数 | 作用 |
|---|---|
recursive: true | 目录非空也删 |
force: true | 目录不存在也不报错 |
两个参数一加,这行代码就变成了"幂等清理"——不管之前是什么状态,跑完一定是干净的空目录。测试脚本的第一步永远应该是"把环境恢复成已知状态"。
而那个机密文件的内容——"机密:不得读取"——是写给人看的。如果权限失效、Agent 真读到了,日志里会明晃晃地出现这五个字。测试数据要设计成"一旦泄露就能一眼看见"。
三、记忆中间件:管"记住什么"
第二个 demo 解决跨轮次的记忆问题。
const projectMemoryPath = "/AGENT.md";
const preferencesMemoryPath = "/memory/preferences.md";// 用户偏好记忆
const agent = createAgent({
model,
tools:[],
systemPrompt:[
"你是项目助手。工作区根路径为 /,可用ls、read_file、write_file、edit_file",
"根据<agent_memory>回答:用户要求记住时,必须立刻 edit_file,且按类型写入对应文件",
`- ${projectMemoryPath}:项目说明、技术栈、架构、仓库约定等`,
`- ${preferencesMemoryPath}:用户个人偏好(语言、包管理器、回答风格等)`,
`不要混写:项目事实不要写入 preferences,个人偏好不要写入 AGENT.md。`
].join("\n"),
middleware:[
createFilesystemMiddleware({ backend }),
createMemoryMiddleware({
backend,
sources: [projectMemoryPath, preferencesMemoryPath],
}),
]
})
关键设计:记忆分两类,存两个文件。
| 文件 | 记什么 | 例子 |
|---|---|---|
/AGENT.md | 项目事实 | 项目说明、技术栈、架构、仓库约定 |
/memory/preferences.md | 个人偏好 | 语言、包管理器、回答风格 |
为什么不都写进一个文件?
因为这两类信息的生命周期和归属完全不同:
| 对比 | 项目事实 | 个人偏好 |
|---|---|---|
| 属于谁 | 团队(谁 clone 都该知道) | 个人(换个人就不一样) |
| 变不变 | 跟项目走,相对稳定 | 跟人走,随时可能变 |
| 该不该提交到 Git | 该(团队共享) | 不该(个人配置) |
混在一起写,后果很尴尬——比如把"我习惯用 pnpm"写进 AGENT.md 提交上去,整个团队都被迫"习惯用 pnpm"了。
所以 systemPrompt 里那句最狠:
不要混写:项目事实不要写入 preferences,个人偏好不要写入 AGENT.md。
这是用自然语言给模型立规矩。 而规矩能不能守住,靠的是下面两条 prompt 的明确指引——把"哪类信息该进哪个文件"写清楚。
systemPrompt 的写法:数组 join
systemPrompt: [
"你是项目助手。...",
"根据<agent_memory>回答:...",
`- ${projectMemoryPath}:...`,
`- ${preferencesMemoryPath}:...`,
"不要混写:...",
].join("\n")
这次 systemPrompt 不是一个大字符串,而是字符串数组 join("\n")。
这个写法比写一个长模板字符串好读多了:
- 每行一个独立的规则,改哪条改哪行
- 路径用模板字符串插值,改了常量自动同步
- 不用纠结缩进(模板字符串里的缩进会带进 prompt,多行拼接反而干净)
记忆是怎么被"读"的?
注意这条指令:
根据<agent_memory>回答
以及 createMemoryMiddleware 的参数:
createMemoryMiddleware({
backend,
sources: [projectMemoryPath, preferencesMemoryPath],
})
sources 声明了"这两个文件是记忆来源"——中间件会把它们的内容读出来,注入到上下文里,标记成 <agent_memory>。
于是模型在回答前,就已经"读过"这两个文件了。这就是记忆生效的机制:不是靠模型自己想起来,而是靠中间件每次把记忆文件塞到它眼前。
这也回应了前面 Memory 那节课讲的"LLM 是无状态的" ——它不会自己记住任何东西,所谓"记忆"就是每次调用前把该给的资料准备好。这里的实现方式是"存文件 + 自动注入",比向量检索简单,但对"少量关键事实"这种场景足够用。
四条 prompt 的测试设计
const prompts = [
"根据记忆,这个项目是做什么的?只回答一句", // 读memory
"请记住:我常用的包管理 pnpm。",
"请记住:本仓库主入口脚本是 src/deepagents/memory-agent.mjs。",
"我常用什么包管理器?本demo主入口脚本路径是什么?各用一行回答"
];
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);
}
这四条的顺序,是一个完整的"记忆生命周期"测试:
| 顺序 | Prompt | 测什么 |
|---|---|---|
| 1 | 根据记忆,这个项目是做什么的? | 读——能不能从已有记忆文件里读出来 |
| 2 | 请记住:我常用 pnpm | 写(偏好类)——会不会写进 preferences.md |
| 3 | 请记住:主入口脚本是 … | 写(项目类)——会不会写进 AGENT.md |
| 4 | 我常用什么包管理器?主入口脚本路径是什么? | 验证分类——两条记忆都取得到,且各写在正确的文件里 |
第 4 条是点睛之笔。 它一次问两件事,而且是分别属于两个不同文件的信息。
如果模型回答得出来,说明:
- 两条记忆都被正确写入并成功注入
- 分类没搞错(虽然从"答得出来"看不出分类,但配合去工作区看文件就能确认)
被注释掉的那句 // 读memory 也提醒了:第 1 条是唯一一条"只读不写"的测试。 放在最前面,正好验证"初始状态下的记忆读取"。
手动维护 messages:跨轮次的关键
let messages = [];
for (const prompt of prompts) {
const { messages: newMessages } = await agent.invoke(
{ messages: [...messages, new HumanMessage({content: prompt})] },
{ recursionLimit: 30, }
);
messages = newMessages;
}
这段模式值得记住:把上一轮返回的 messages,拼上新问题,再喂给下一轮。
因为 LangChain 的 Agent 每次调用都是"无状态"的——它不会自动记住上一轮聊了什么。所以调用方必须把历史消息带着走。
第 1 轮:invoke([问题1]) → 返回 [问题1, 回答1]
第 2 轮:invoke([问题1, 回答1, 问题2]) → 返回 [问题1, 回答1, 问题2, 回答2]
第 3 轮:invoke([...(全部历史), 问题3]) → ...
这个 messages 数组就是"短期记忆" ——前面 Memory 那节课手写过这个逻辑(用 InMemoryChatMessageHistory 管理)。这里是裸用数组,更直白。
而 createMemoryMiddleware 管的是"长期记忆"——跨进程、跨会话都还在,因为它存在文件里。
两层记忆配合,才是完整的记忆系统:
| 层 | 载体 | 生命周期 |
|---|---|---|
| 短期 | 调用方手里的 messages 数组 | 单次会话 |
| 长期 | /AGENT.md、/memory/preferences.md | 一直存在 |
四、总结中间件:管"该忘什么"
前两个中间件是"加能力",第三个是"减负担"。
const summaryPrompt = `
你是对话摘要助手。请用中文总结以下对话。包含:
1.讨论的主要话题
2.达成的关键结论或决定
3.继续对话所需的重要上下文
保持简洁,不要罗列无关细节。
待摘要的对话:
{conversation}
摘要:
`;
createSummarizationMiddleware({
model,
backend,
historyPathPrefix,
summaryPrompt,
trigger:{ type: "message", value: 8 },
keep: { type:"messages", value: 4 }
})
五个参数,其中后三个是核心。
两个旋钮:什么时候总结、留多少
trigger:{ type: "message", value: 8 },
keep: { type:"messages", value: 4 }
| 参数 | 值 | 含义 |
|---|---|---|
trigger | message / 8 | 消息数达到 8 条就触发总结 |
keep | messages / 4 | 保留最近 4 条原文 |
两个旋钮,控制"什么时候忘"和"忘到什么程度"。
为什么不干脆都设成"消息很少就总结"?因为总结本身也要花钱——它要调一次模型。设太勤,成本反而更高,而且每次总结都会丢一点细节。
这是个权衡:
| 设置 | 效果 |
|---|---|
| trigger 小 + keep 大 | 保留信息多,但总结频繁、成本高 |
| trigger 大 + keep 小 | 省钱,但可能上下文太长或丢太多 |
课堂用 8 和 4 是为了演示时能快速看到效果——聊 4 轮就触发了。
那两行注释掉的类型
trigger:{ type: "message", value: 8 },
keep: { type:"messages", value: 4 }
注意两个 type 的值不一样:一个是 "message"(单数),一个是 "messages"(复数)。
这种细节很容易写错,而 { type, value } 这种结构说明框架支持多种计量方式——除了按消息条数,很可能还支持按 token 数(毕竟 token 才是真正的成本单位)。用对象而不是单个数字来传参,就是为了给未来留扩展位。
summaryPrompt:总结要看什么
你是对话摘要助手。请用中文总结以下对话。包含:
1.讨论的主要话题
2.达成的关键结论或决定
3.继续对话所需的重要上下文
保持简洁,不要罗列无关细节。
待摘要的对话:
{conversation}
摘要:
这三条要求,正好覆盖了"摘要该保留什么"的三个方面:
| 要求 | 保留的是 |
|---|---|
| 讨论的主要话题 | 做了什么 |
| 达成的关键结论或决定 | 定了什么(通常最重要) |
| 继续对话所需的重要上下文 | 还得知道什么 |
第 3 条最能体现"总结是为继续对话服务的"。 摘要不是为了归档好看,而是为了让对话能接下去——所以判断标准是"缺了这条,后面会不会接不上"。
{conversation} 是个占位符——框架会把要压缩的那段对话填进去。这也说明 prompt 是"模板"而不是"最终文本",写的时候要留好这个位置。
"保持简洁,不要罗列无关细节"这半句是负向约束——不写的话,模型很容易把每句话都摘要一遍,等于没压缩。
数据放哪:historyPathPrefix
const historyPathPrefix = "/conversation_history";
const historyDir = path.join(workspaceDir,
historyPathPrefix.replace(/^\//, ""));
console.log(historyDir);
被压缩掉的历史不是删了,而是存到 /conversation_history 目录下。
这跟前面那个"文件系统记忆"是一个思路——所谓"遗忘",其实是"归档"。 上下文里不带了(省 token),但原始记录还在硬盘上(需要时能查)。
所以"总结"这个能力,严格说是三件事:
原文太长 → 生成摘要 → 摘要替换原文进上下文 → 原文归档到文件
(压缩) (腾出窗口) (留作证据)
代码里那两行路径处理也值得看一眼:
historyPathPrefix.replace(/^\//, "")
把开头的 / 去掉——因为虚拟路径 /conversation_history 要映射到真实文件系统时,不能直接拼(path.join(workspaceDir, "/conversation_history") 在某些平台会出问题)。虚拟路径和真实路径的转换,是这套设计必须处理的细节。
还有个辅助函数:
function listHistoryFiles() {
if(!fs.existsSync(historyDir)) return [];
return fs.readdirSync(historyDir);
}
"目录不存在就返回空数组" ——一个小兜底。因为第一次跑的时候目录还不存在(还没触发过总结),如果直接 readdirSync 会抛异常。
五条 prompt:4 条攒料 + 1 条验货
const prompts = [
"请记住:我的宠物猫叫小橘。",
"请记住:我住在北京。",
"请记住:我喜欢喝拿铁。",
"请记住:我的生日是 5 月 1 日。",
"根据我们聊过的内容,我的猫叫什么、住哪、喜欢喝什么、生日是哪天?每项一行。",
];
这个设计比记忆那个 demo 更精妙——它在精确控制"消息数"。
算一下:每条 prompt 产生一轮对话(用户 + AI 各一条消息),4 条就是 8 条消息——正好撞上 trigger: 8。
第 1 轮结束:2 条消息
第 2 轮结束:4 条消息
第 3 轮结束:6 条消息
第 4 轮结束:8 条消息 → 触发总结!
保留最近 4 条(第 3、4 轮)
前 4 条(第 1、2 轮的"猫叫小橘""住北京")被压缩
然后第 5 条 prompt 来问这四件事——这才是真正的考验。
因为"猫叫小橘"和"住北京"这两个事实,原文已经从上下文里被删掉了。模型要答对,只能靠摘要里保留的信息。
这就是一个"总结是否有效"的有效性测试:
| 结果 | 说明 |
|---|---|
| 四个问题都答对 | 摘要完整保留了关键事实 ✅ |
| 答漏了某一条 | 摘要丢了信息 → 需要调整 prompt 或参数 |
代码还特意打印了消息数,方便你观察这个过程:
console.log("当前消息数:", messages.length);
看着这个数字从 2 → 4 → 6 → 8,然后突然掉回 5(4 条保留 + 1 条摘要),你就直观地看到了"总结"发生的那一瞬间。
listHistoryFiles() 也是为观察设计的——总结一触发,那个目录里就会多出一个文件。 文件出现 = 归档成功。
清理工作区:测试前的仪式
fs.rmSync(workspaceDir, { recursive: true, force: true });
fs.mkdirSync(workspaceDir, { recursive: true });
跟文件系统那个 demo 一样——先把上次的痕迹清干净。
对总结中间件来说这一步尤其重要:如果上次跑留下的历史文件还在,可能会影响这次的判断(比如 Agent 一上来就读到了上次的摘要)。测试要的是"从零开始",那就得真的从零开始。
五、三个中间件放在一起看
| 中间件 | 管什么 | 关键参数 | 存储位置 |
|---|---|---|---|
createFilesystemMiddleware | 能碰什么 | permissions | 工作区文件 |
createMemoryMiddleware | 记住什么 | sources | /AGENT.md、/memory/preferences.md |
createSummarizationMiddleware | 该忘什么 | trigger、keep、summaryPrompt | /conversation_history |
三个中间件,恰好覆盖了 Agent 上下文管理的三个动作:限制、积累、压缩。
而它们的共同点也很明显:
第一,都以 FilesystemBackend 为地基。 说明这套框架的默认存储方案就是"文件系统"——简单、可检查(你能直接打开文件看)、可版本控制。
第二,都要在 systemPrompt 里配合交代规则。 文件系统要告诉它"路径以 / 开头",记忆要告诉它"哪类信息写哪个文件",总结要告诉模型"摘要里要保留什么"。中间件提供机制,prompt 提供规则——两者缺一不可。
第三,测试都在"观察中间产物"。 文件系统看工具消息的 status、记忆看工作区里的文件、总结看历史目录和消息数。
这三个 demo 教会的东西,本质上不是"怎么用这三个 API",而是"怎么验证一个中间件真的在干活"。
六、从"自己写"到"拿来用"
回到上一篇的对比:
| 自己写中间件(上篇) | 用现成的(本篇) | |
|---|---|---|
| 工作量 | 定义 stateSchema、写钩子、处理返回值 | 挂上去就行 |
| 灵活性 | 想干嘛干嘛 | 受限于框架提供的参数 |
| 适合 | 你的业务特有逻辑 | 通用能力 |
权限校验、记忆管理、上下文压缩——这三件事几乎每个 Agent 都要做,而且做法大同小异。
所以它们被封装成了开箱即用的中间件。这就是"半成品框架"的价值:把共性做成零件,你把零件装到自己的业务上。
而上一篇那些手写中间件(日志、短路、统计)——那些是"你的项目独有"的需求,框架不可能替你想好,所以留给你插。
一个框架好不好用,看它有没有把该封装的封装好、该留口的留好。 从这个角度看,deepagents 的分寸拿捏得挺准:通用的三个直接给,专用的留 createMiddleware 让你自己写。
PS:这三个 demo 里,我最喜欢总结那个"4 条攒料 + 1 条验货"的设计——它用消息数精确算好了触发点,然后在信息被"忘掉"之后,专门问那几个被忘掉的细节。 这哪是在演示 API,分明是在写测试用例。下一篇是这个系列的收官:用前面所有零件,搭一支真正的多 Agent 调研团队。