🍟 权限、记忆与遗忘:三个"挂上就有"的中间件

15 阅读15分钟

写在前面:上一篇我们手写了中间件——四个钩子、一次短路、自带工具,全靠自己拼。这篇换个姿势:看 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 }
参数值含义
triggermessage / 8消息数达到 8 条就触发总结
keepmessages / 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 调研团队。