**## 引子:这个项目到底在做什么
deep-research-assistant 是一个基于 Deep Agents 框架的深度调研助手。你给它一个问题,比如"2026 年主流的 AI Agent 框架有哪些,各自适合什么场景",它最终会还给你一份 Markdown 报告:有结构、有来源链接、有数据支撑。
它和普通聊天机器人最大的区别在于------它不是一个模型在硬扛,而是一支分工明确的团队 在协作。这个团队里有四个角色:一个负责统筹的主 Agent,以及三个负责干活的子 Agent(调研员、分析师、编辑)。整个项目的源码只有两个文件:src/agent.mjs(约 180 行,定义角色和流程)和 src/tools/search.mjs(约 90 行,实现联网搜索工具)。代码量不大,但架构思路非常典型,值得拆开细看。
一、为什么不能只用一个 Agent
用一个 Agent 直接干活,会遇到三堵墙。
第一堵墙是上下文爆炸。 一次深度调研可能要搜十几轮网页,每轮返回几千字。这些内容全部堆在同一个对话历史里,很快就会撑爆模型的上下文窗口,后面的推理质量急剧下降。
第二堵墙是任务混杂。 让同一个模型既去搜资料、又去算数字、又去审自己的稿子,它会变得"什么都做一点,什么都做不精"。而且它审视自己写的稿子时,天然带有偏见。
第三堵墙是无法并行。 如果调研三个子主题必须串行执行,总耗时就是三次之和,效率很低。
子 Agent 架构正好解决这三点:每个子 Agent 有自己的独立上下文(做完只把结论写成文件交出去);每个子 Agent 的提示词只专注一件事;而多个没有依赖关系的子 Agent 可以同时启动。
二、主 Agent:只做编排,不做全部工作
主 Agent 的行为完全由 orchestratorPrompt 这段系统提示词约束。它的职责被明确写成一句话:"协调调研员、分析师和编辑完成报告。不要亲自完成所有调研------将专业工作委派给子 Agent。"
它规定了六个标准步骤:
- 规划 ------用
write_todos把任务拆成中文待办,并把用户原问题存到/workspace/sources/question.txt。 - 调研 ------写一份
research_plan.md,然后委派调研员子 Agent,可以并行。 - 分析------如果涉及数字对比,委派分析师子 Agent。
- 起草 ------注意,这一步是主 Agent 亲自做 ,用
write_file写入/workspace/reports/draft_[主题].md。 - 审阅------委派编辑子 Agent 审稿,然后按反馈修订一次。
- 定稿 ------保存到
/workspace/reports/report_[主题]_[日期].md。
这里有个值得琢磨的设计:为什么起草必须主 Agent 亲自上,不能也委派出去?因为报告是全局产物,需要综合所有调研员的碎片发现,还要保证叙事连贯、口吻统一。主 Agent 手里握着完整的任务上下文,只有它具备这个全局视角。子 Agent 只管局部正确,主 Agent 管整体成立。
提示词里还有几处很"硬"的约束,用来防止 Agent 失控:每份报告最多 3 个调研员、最多并行 3 个、已有 3 份 findings 文件后不再新增、每份报告只调用编辑一次。这些数字上限不是随便写的------它们是成本闸门,防止模型陷入"再来一轮就更好"的无限循环。
另外有一处容易混淆的地方被专门澄清:task 工具合法的 subagent_type 只有四个------researcher、analyst、editor、general-purpose。而 web-research、report-writer 这些名字听起来很像角色,实际上是技能 (写作指南文档),不是可调用的子智能体,禁止当作 subagent_type 传进去。技能是"怎么做"的知识,子智能体是"谁来做"的执行者,两者不是一回事。
三、三个子 Agent 的分工
每个子 Agent 用三个字段定义:name、description、systemPrompt。其中 description 最关键------主 Agent 就是靠读这段描述来决定"这个任务该派给谁"的。
| 子 Agent | 职责 | 携带能力 | 产出物 |
|---|---|---|---|
| researcher | 联网调研单个子主题 | web_search 工具 |
/workspace/sources/findings_*.md |
| analyst | 数值计算与数据分析 | QuickJS 代码解释器 | /workspace/sources/analysis_*.md |
| editor | 审阅报告草稿 | 无(只读只评) | 只返回意见,不写文件 |
调研员 的提示词里塞了一堆防呆规则:最多调用 3 次 web_search(硬性上限)、write_file 只写一次、必须写在 /workspace/sources/findings_*.md 下、写完立刻停止。注释里甚至直白地写着"严格遵守,禁止空转循环"。这些约束针对的是同一个毛病:模型在完成目标后倾向于"再做点什么",最后把时间烧在无意义的重复搜索上。那句"其他人只能看到你写入的文件,内存必须完整、自洽"更是点出了子 Agent 架构的核心约束------子 Agent 的上下文是隔离的,它脑子里的东西别人看不到,只有落盘的文件的才算数。
分析师 的提示词只有三行,但要求极狠:"所有计算必须通过 eval REPL 完成,禁止猜测数字。"这条规则背后是一个朴素的事实:大语言模型算术很不可靠,尤其是多步计算、百分比、排名这类任务,它经常给出"看起来很对"的错误答案。让模型写代码、让代码算数,才是可靠的路径。
编辑 的提示词明确划清边界:"负责审阅 报告草稿,不要亲自改写报告。"它的输出是一份审阅意见,而不是一份新报告。这个设计很聪明------如果让编辑直接改稿,就成了第二作者,审阅的独立性也就没了。它还被要求从五个角度挑毛病:有没有直接回答原问题、结构是否清晰且段落充实(而不是只有一堆 bullet)、是否引用了来源并列出参考资料章节、有没有无依据的断言或缺失的视角、语言是否为中文且表述专业。
四、工具:Bocha 联网搜索
webSearch 是项目里唯一手写的工具,用 LangChain 的 tool() 工厂创建。一个工具由三部分组成:实现函数 (真正干活的异步函数)、元数据 (name 和 description,模型靠这两项判断什么时候该用这个工具)、参数模式(用 zod 定义的 schema,模型必须按这个格式传参)。
真正的网络请求在 bochaWebSearch() 里:向 https://api.bochaai.com/v1/web-search 发 POST 请求,带上 Authorization: Bearer ${apiKey},请求体里写着 freshness: "nolimit"(不限时效)和 summary: true(要求直接返回摘要,省去自己去读网页的成本)。
返回结果由 formatWebPages() 格式化,每个网页输出"引用编号 / 标题 / URL / 摘要 / 网站名称 / 网站图标 / 发布时间"七项。为什么一定要保留 URL? 因为报告的可信度全靠它。编辑的审阅要点里专门有一条"是否引用了来源",而调研员的提示词也要求"包含关键事实与来源 URL"------整条链路都在为"每个断言都能溯源"服务。
五、代码解释器:给分析师装上 REPL
analyst 子 Agent 没有工具,取而代之的是 middleware: [createCodeInterpreterMiddleware()]。这个中间件来自 @langchain/quickjs 包,它会给 Agent 增加一个 js_eval 工具,让模型可以直接执行 JavaScript 代码。
它的运行环境是 QuickJS------一个用 C 写的轻量 JavaScript 引擎,编译成 WebAssembly 后在沙箱里跑。这意味着几件事:变量跨调用持久 (第一次算出的中间结果,第二次调用还能用,像一个真正的 REPL);没有 require、没有 import、没有 fetch ,模型无法借此访问文件系统或网络;只有 readFile 和 writeFile 两个函数被桥接出来,用来读写 Agent 自己的文件系统后端。
为什么值得为它单独配一个子 Agent?因为它把"算"这件事从"猜"变成了"执行"。当模型需要计算一组数据的均值或者同比增速时,正确做法是写三行代码跑一遍、把输出贴进报告,而不是让它在脑子里估算。前者可复现、可验证,后者只能靠运气。
六、文件系统:子 Agent 之间唯一的通信媒介
主 Agent 的 backend 是一个 FilesystemBackend,配置为:
js
new FilesystemBackend({ rootDir: projectDir, virtualMode: true })
rootDir 指定了文件系统的根目录(这里是项目根),virtualMode: true 开启路径沙箱------Agent 只能用 /workspace/... 这样的虚拟路径,框架负责把它映射到真实磁盘,同时挡掉 ../ 之类的路径穿越攻击。这是安全边界,不是可选项。
所有产物都按约定分两处存放:/workspace/sources/ 放计划与原始资料(问题、调研计划、findings、分析结果),/workspace/reports/ 放草稿与终稿。这套目录约定是整套协作的骨架。
理解了文件系统,才能理解多智能体协作的本质:子 Agent 之间的上下文是隔离的,文件系统就是它们唯一的通信媒介。 主 Agent 派 researcher 去查资料,researcher 查完把结果写进 findings_xxx.md,主 Agent 再读这个文件。整个过程就像公司里不同部门通过共享文档区协作------没人能直接看到同事脑子里想什么,写进文件的东西才算数。
提示词最后那句"同一时间只编辑一个文件,避免冲突"也是个务实提醒:并行执行时,多个 Agent 同时改同一个文件会互相覆盖。
七、工程上容易踩的几个坑
读代码时能发现几处实打实的问题,值得记下来当经验。
坑一:工具函数体是空操作。 webSearch 的实现函数写成这样:
js
async (input) => {
async (input) => {
const count = input.count ?? 10;
return bochaWebSearch(input.query, count);
};
}
内层那个 async 箭头函数只是被声明 了,从来没有被调用。外层函数执行完就返回 undefined。结果是 web_search 工具永远返回空,调研员辛苦调的搜索全部白费。这类 bug 最阴险------代码能跑、不报错,只是功能静默失效。正确的写法是把外层函数体改成直接执行逻辑,或者显式 return 内层函数的调用结果。
坑二:配置项名称对不上。 工厂函数里读的是 process.env.OPENAI_MODEL,但 .env 文件里定义的却是 MODEL_NAME。两边名字不一致,导致传进去的 model 是 undefined。这类问题的通病是:不报错,只是行为异常,排查时很容易先怀疑模型、怀疑网络,最后才想到去看变量名。稳妥做法是启动时对关键配置做一次显式校验,缺了就立刻抛错。
坑三:变量定义了却没用。 文件顶部有一个 const model = new ChatOpenAI({...}),但工厂函数内部又重新定义了一个同名的 model 变量,顶上那个从头到尾没被用过。同样地,baseURL 被读取出来后再没出现过。这类"僵尸代码"会误导后来的读者,让人以为某处配置生效了,实际上并没有。
坑四:死代码骗人。 搜索工具里有这么两行:
js
const webPages = json.data.webPages?.value ?? [];
if (!webPages) return `未找到与 ${query} 相关的网页`;
?? [] 保证了 webPages 永远是数组,而数组永远是 truthy,所以 if (!webPages) 这个判断永远为假,"未找到"这句提示永远不会出现。真想让空结果有个友好提示,应该判断 webPages.length === 0。
坑五:声明了不存在的资源。 工厂函数里配置了 memory: [path.join(projectDir, "AGENTS.md")] 和 skills: ["/skills/"],但项目目录下既没有 AGENTS.md 文件,也没有 skills/ 目录。这类"引用悬空"的问题通常不会立刻崩,但会让依赖它的功能悄悄失效。
这几个坑有个共同特征:都不抛异常。它们不会让你在控制台看到红色报错,只会让程序安静地做错事。这也是为什么 Agent 类项目尤其需要日志和可观测性------行为正确与否,往往只能从输出反推。
八、总结
这个项目用不到三百行代码,演示了一套完整的多智能体协作范式,可以提炼成三句话:
第一,编排与执行分离。 主 Agent 不亲自搜资料,它只负责拆解、派活、汇总和把控质量;具体活交给专职子 Agent。让每个角色只为一件事负责,整体质量反而更高。
第二,隔离与通信的权衡。 子 Agent 拥有独立上下文,这避免了上下文爆炸,代价是它们无法直接"心领神会"------必须通过文件系统交换信息。所以路径约定(sources/ 与 reports/)和文件格式要求,就成了架构里不可省略的一部分。
第三,可靠性的关键在边界。 反过来读那些工程坑会发现,它们几乎都出在"边界没有显式写出来"的地方:工具函数没有真正返回、配置名字没有对上、引用的文件不存在。让模型少做不可靠的事(别心算,去跑代码)、让框架多做可靠的校验(写清楚权限和沙箱),才是这类系统真正能上生产的前提。 **