写在前面:前两篇我们学了零件------中间件的四个钩子(上篇)、三个开箱即用的中间件(中篇)。这篇是收官:用这些零件组装一个真正能干活的多 Agent 系统。 它是一个"深度调研助手":给它一个主题,它自己规划、派人联网调研、做数据分析、起草报告、审稿、定稿。readme 在
cli.mjs里写了一行很有意思的注释------"除了开发者,面向其他 Agent 的调用" 。这句话点明了这套东西的定位:它不只是一个给人用的工具,更是一个可以被上层编排调用的能力单元。以下所有代码均来自课堂真实文件。
一、先看编制表:一个主编 + 三个工种
整个系统的骨架在 agent.mjs 里:
javascript
return createDeepAgent({
model,
systemPrompt: orchestratorPrompt,
subagents: [researchSubAgent, analystSubAgent, editorSubAgent],
memory: [path.join(projectDir, "AGENT.md")],
middleware: [todoListMiddleware()],
backend,
skills: ["/skills/"]
})
六个参数,就是一个团队的完整编制:
| 参数 | 作用 | 类比 |
|---|---|---|
systemPrompt |
主 Agent 的职责说明 | 主编的岗位职责 |
subagents |
三个子 Agent | 三个工种的员工 |
memory |
项目记忆文件 | 公司的知识库 |
middleware |
待办清单能力 | 任务看板 |
backend |
文件系统后端 | 共享的稿件盘 |
skills |
技能目录 | 两本岗位手册 |
注意 createDeepAgent 和上次那个 createAgent 的区别 ------多出来的这几个参数(subagents、memory、skills)正是 DeepAgents 相对基础 Agent 的"高阶封装"。
上次说它是"半成品"------现在能看到具体半成品在哪了:它把"团队编制"这件事做成了配置项。
还有一处细节:
javascript
const projectDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
)
".." ------往上一级。 因为 agent.mjs 在 src/deepagents/ 目录里,而工作区的根应该在整个项目的根目录。这样 skills: ["/skills/"] 才能指到项目根的 skills/ 目录。
二、主编:只做协调,但有一条活必须自己干
主 Agent 的 systemPrompt 是整个系统里最长的(五十多行),结构很清晰。
第一段:语言要求
markdown
## 语言要求
- **所有输出必须使用中文**:对话回复、write_todos 任务列表、文件内容、搜索关键词
- write_todos 中每条 todo 的 content 必须用中文描述,例如「撰写调研计划」「委派调研员调研 LangGraph」
- 搜索时优先使用中文关键词;英文专有名词(如 LangGraph、AutoGen)可保留
- 报告、调研笔记、计划文件全部用中文撰写
为什么第一段要花四行讲"用中文"?
因为这件事在系统里会层层放大 :主 Agent 用英文写计划 → 子 Agent 看到英文计划 → 用英文搜索 → 检索到英文资料 → 写出英文报告。源头定错了,整个链条都错。
所以它把"中文"要求写得非常具体------不是笼统的"用中文",而是点名了四个地方:对话回复、任务列表、文件内容、搜索关键词。 还给了例子、还说清了例外(英文专有名词可保留)。
"要求要具体到能被执行" ------这是写 Agent prompt 的一个基本原则。
第二段:职责与边界
shell
## 你的职责
协调调研员、分析师和编辑完成报告。不要亲自完成所有调研------将专业工作委派给子 Agent。
一句话定边界:"你是协调者,不是全干的那个人。"
为什么要特意强调?因为 LLM 有个倾向------能自己干就自己干了。 尤其是它看到"调研"这个任务,很可能直接开始搜索,把子 Agent 晾在一边。
所以这条职责说明其实是在"纠正模型的默认行为"。
第三段:标准流程六步
markdown
1. **规划** --- 用 write_todos 拆解任务(中文)。将用户问题保存到 /workspace/sources/question.txt
2. **调研** --- 按 web-research 技能:写 research_plan.md,委派调研员子 Agent(可并行)
3. **分析** --- 若涉及数字对比或数据表,委派分析师子 Agent
4. **起草** --- **由你亲自**按 report-writer 技能撰写,用 write_file 写入 /workspace/reports/draft_[主题].md
5. **审阅** --- 委派编辑子 Agent 审稿,根据反馈修订一次
6. **定稿** --- 保存最终报告到 /workspace/reports/report_[主题]_[日期].md
六步,每步都写清楚了三件事:做什么、用什么、产出放哪。
注意第 4 步那个加粗的"由你亲自"------这跟第二段的"不要亲自做调研"形成了呼应:
| 环节 | 谁干 | 为什么 |
|---|---|---|
| 调研 | 委派 | 可以并行,分工更专业 |
| 分析 | 委派 | 需要专门的代码执行能力 |
| 审阅 | 委派 | 独立视角,避免自己审自己 |
| 起草 | 主 Agent 亲自 | 这是不可拆分的核心工作 |
"起草由主编亲自写,调研交给记者,审稿交给编辑"------这就是一套人类编辑部的工作流。
为什么起草不能委派? 因为报告是最终交付物,它需要统合所有 findings 和 analysis 的视角。如果派给一个子 Agent,那个子 Agent 只能看到自己那部分上下文,写出来的东西是碎的。
第四段:最容易踩的坑------"技能不是子 Agent"
markdown
## task 工具(子 Agent 委派)
**仅**以下 subagent_type 合法:researcher、analyst、editor、general-purpose。
- web-research、report-writer 是**技能**(写作指南),**不是**子 Agent,禁止作为 subagent_type 调用
- 报告起草、修订、定稿由**主 Agent 自己**用 write_file / edit_file 完成,不要委派 task
这一段是整份 prompt 里最"实战"的部分------它在防一个具体的错误。
系统里同时存在两样东西:
| 类别 | 名字 | 性质 |
|---|---|---|
| 子 Agent | researcher、analyst、editor |
能干活的 ,可以用 task 委派 |
| 技能 | web-research、report-writer |
说明书,只能"看",不能"派" |
名字都是英文小写加连字符,长得还很像------模型很容易搞混,试图去 task 一个叫 web-research 的子 Agent。
所以 prompt 里用加粗的"仅 "和"不是 "强调了两遍。这是被实际运行教出来的经验------不写这段,八成会翻车。
第五段:委派规则------一堆"最多"
markdown
## 委派规则
- 每个调研员只负责一个聚焦的子主题
- **每份报告最多 3 个调研员**------只选最相关的子主题
- 框架对比类任务:优先调研用户明确点名的框架;否则选最重要的 3 个
- 最多并行启动 3 个调研员,已有 3 份 findings 文件后不再新增调研员
- 仅在确实需要数值计算时使用分析师
- 每份报告只调用编辑一次(草稿完成后)
- 调研完成后直接进入起草 → 审阅 → 定稿,不要额外开调研轮次
七条规则,六条在限制。
| 规则 | 限制什么 |
|---|---|
| 每个调研员只负责一个子主题 | 防止范围重叠 |
| 最多 3 个调研员 | 防止发散 |
| 已有 3 份 findings 后不再新增 | 用"已有产出"当停止条件 |
| 仅在确实需要时用分析师 | 防止滥用 |
| 只调用编辑一次 | 防止无限返工 |
| 不要额外开调研轮次 | 防止回到调研阶段打转 |
为什么要限制得这么细?因为 Agent 有个通病------"过度勤劳"。
它会不停搜索、不停派活、反复审稿,看起来很努力,实际上是在空转烧钱。而 token 和时间都是真金白银。 所以这些"最多"其实是在给 Agent 装护栏。
尤其是最后一条"不要额外开调研轮次" ------它防的是"绕回去"。流程本来是线性的六步,但 Agent 可能在审阅后觉得"资料不够,再查一轮吧",然后回到第 2 步,再来一遍。这条规则明确封死了这条路。
第六段:文件约定
markdown
## 文件约定
- 计划与原始资料:/workspace/sources/
- 草稿与终稿:/workspace/reports/
- 同一时间只编辑一个文件,避免冲突
光有流程还不够,还得规定"东西放哪"。
两个目录分工明确:
bash
/workspace/
├── sources/ ← 中间产物:问题、计划、findings、分析
└── reports/ ← 交付物:草稿、终稿
为什么必须约定路径?因为这是子 Agent 之间唯一的通信方式。 后面会看到------子 Agent 各跑各的,靠的就是"你去读 /workspace/sources/ 下的文件"。
"同一时间只编辑一个文件,避免冲突" 这条也很关键------多 Agent 并行时,两个子 Agent 同时写一个文件会互相覆盖。 这条规则本质是个简易的并发控制。
第七段:完成后要交代什么
markdown
## 完成时告知用户
- 最终报告保存路径
- 2-3 句话的核心发现摘要
- 调研中的局限或信息缺口
三条,最后一条最有意思------要求主动交代"哪里没搞清楚"。
这跟前面 RAG 课学的"允许模型说不知道"是同一个思路。一个只报喜的系统,你没法判断它的结论能不能信。主动说明局限,反而提高了整份报告的可信度。
三、三个工种:每个都写清了"只做什么"
三个子 Agent 的定义都不长,但每一个都在用力的地方很讲究。
调研员 researcher:一份"防失控"的岗位说明
javascript
const researchSubAgent = {
name: "researcher",
description: "通过联网搜索调研单一子主题。每次只分配一个子主题;多个独立子主题可并行启动多个调研员。",
systemPrompt:dedent`
你是一名专业的调研员,负责调研**一个**分配给你的子主题,并写入**一份**调研结果文件。
## 工作流程(严格遵守,静止空转循环)
1. **可选**:用 write_todos 列出最多3条中文执行步骤(例如[搜索官方文档]
[搜索社区评价] [整理并写入finds]),然后按照步骤执行。
2.最多调用3次 web_search(硬性上限,绝不超过)
3.将搜索结果整理为结构化摘要,包含关键事实与来源URL。
4.调用 write_file **一次**,保存到任务指定的路径
(必须在/workspace/sources/findings_*.md)
5.用一句话确认已完成,然后**立即停止**,不要再搜索、写入或者更新 todo
...
`,
tools: [webSearch],
}
这是三个子 Agent 里 prompt 最长的一个,因为它承担的风险最大------联网搜索是"停不下来"的重灾区。
看那五步流程里的关键词:
| 步骤 | 约束 |
|---|---|
| 1 | 可选、最多 3 条 |
| 2 | 最多调用 3 次 (硬性上限,绝不超过) |
| 4 | 调用 write_file 一次 |
| 5 | 立即停止,不要再搜索、写入或者更新 todo |
"最多 3 次搜索、一次写入、写完立即停止" ------这三条凑成了同一个目的:防止它陷入"再搜一下""再补充一点"的循环。
这个担心不是多余的。 联网搜索这种工具,模型很容易上瘾------搜到一个结果,觉得不够全,再搜;搜到新关键词,再搜。没有上限的话,一个子主题能烧掉几十次调用。
第 5 步那句"不要再搜索、写入或者更新 todo"尤其细致------它逐个点名了三种可能的行为。写"及时停止"太笼统,模型不知道该停什么;点名到具体动作,它才知道该收手。
description 那条也很关键:
每次只分配一个子主题;多个独立子主题可并行启动多个调研员。
这句是写给主 Agent 看的------它是"调用说明书"。告诉主 Agent:"我是单线程的,想并行就多开几个我。"
注意 description 面向的是"调度者",systemPrompt 面向的是"执行者自己"。 两个 audience 不同,写法也不同:一个讲"我适合干什么",一个讲"你该怎么干"。
还有个细节值得注意:
diff
- 其他人只能看到你写入的文件,内容必须完整、自洽
这句话揭示了多 Agent 协作的一个硬约束------子 Agent 之间没法直接聊天,只能通过文件交流。
所以它必须提醒调研员:你脑子里的东西不算数,写进文件的才算数。 而且写的时候要"完整、自洽"------因为读者(主 Agent 或另一个子 Agent)看不到你的思考过程,只看到这个文件。
这就是"上下文隔离"的代价: 隔离让每个子 Agent 不被无关信息干扰(前面 Harness 那节课讲过),但也意味着它必须把成果"显式地交出来",不能指望别人知道。
分析师 analyst:不许猜数字
javascript
const analystSubAgent = {
name: "analyst",
description:
"使用 eval REPL 进行数值计算与结构化数据分析。适用于计算、排名、同比对比或JSON/CSV 分析。",
systemPrompt:dedent`
你是一名数据分析师,所有计算必须通过 eval REPL 完成 **禁止** 猜测数字。
## 工作流程
1.从/workspace/sources/ 读取数据文件(或从调研结果中提取数字)。
2.在 REPL 中编写并允许 JavaScript ,计算总和、均值、排名、增长率等
3.将分析结果保存到/workspace/sources/analysis_*.md ,包含计算逻辑与结论
必须展示计算过程,结论可以从 REPL 输出复现。所有输出使用中文。
`,
middleware: [createCodeInterpreterMiddleware()]
}
这个 Agent 的组合方式跟前两个不一样------它靠的是"中间件给的能力"。
javascript
middleware: [createCodeInterpreterMiddleware()]
注意:它没有 tools 字段,角色能力全在中间件里。
这正好呼应上一篇讲的------中间件可以给 Agent 送工具。 这里更进一步:createCodeInterpreterMiddleware(来自 @langchain/quickjs)提供的是一个代码执行环境(REPL),比单个工具复杂得多。
为什么分析师需要 REPL,而不是让模型直接算?
因为 LLM 算数是不可靠的。 它擅长语言和推理,但做多位数运算、累加、百分比时经常出错,而且错得很自信。
所以 prompt 里那句才那么硬:
bash
所有计算必须通过 eval REPL 完成 **禁止** 猜测数字
"禁止猜测数字"------它把"算数"这件事从"模型推理"改成了"代码执行"。
| 方式 | 可靠性 |
|---|---|
| 模型心算 | 不可靠,可能悄悄算错 |
| 写代码执行 | 确定性的,可复现 |
而且它还要求"必须展示计算过程,结论可以从 REPL 输出复现 "------这不只是要求答案对,还要求"答案能被验证"。 跟前面文件系统那篇里"用断言验证权限"是同一个工程习惯:别让我信你,让我能查你。
编辑 editor:只审不改
javascript
const editorSubAgent = {
name: "editor",
description:"审阅报告草稿的准确性、结构与完整性。在/workspace/sources/draft_*.md ",
systemPrompt:dedent`
你是一名资深情报编辑,负责**审阅**报告草稿 **不要**亲自改写报告。
## 阅读材料
- 原始问题:/workspace/sources/question.txt
- 待审核草稿:任务中指定的路径
- 支撑材料:/workspace/sources/ 下的调研文件(如需要)
## 审阅要点
- 报告是否直接回答了原始问题?
- 章节结构是否清晰、段落是否充实(而非只有bullet 列表)
- 是否引用了来源,并在【参考资料】章节列出?
- 是否有遗漏、无依据的断言或缺失的视角?
- 语言是否为中文,表述是否专业?
## 输出
返回简洁的审阅意见和具体、可操作的修改建议。
**不要**写入报告文件,只提供反馈。所有输出使用中文。
`,
}
这个 Agent 的关键词是"审"------它被明确禁止改稿。
markdown
负责**审阅**报告草稿 **不要**亲自改写报告
...
**不要**写入报告文件,只提供反馈
为什么要这么严格地禁止它动手?
因为**"审"和"改"是两种不同的立场:**
| 立场 | 倾向 |
|---|---|
| 审稿人 | 挑毛病,找出问题 |
| 改稿人 | 把事情做完,倾向于"差不多就行" |
如果让同一个 Agent 又审又改,它会倾向于"小修一下就过" ------因为自己改自己的稿,心理上会不自觉地放松标准。分工的价值就在于立场独立。
而且"不改"还有个技术上的好处:避免两个 Agent 同时写一个文件(呼应主 prompt 里"同一时间只编辑一个文件"那条规则)。
它的审阅要点也设计得很具体,五条全是"可判断"的问题:
| 要点 | 检查什么 |
|---|---|
| 是否直接回答了原始问题? | 回归原题 (要不要读 question.txt 的原因) |
| 章节结构是否清晰、段落是否充实(而非只有 bullet 列表) | 形式质量 |
| 是否引用了来源,并在【参考资料】章节列出? | 可追溯性 |
| 是否有遗漏、无依据的断言或缺失的视角? | 内容质量 |
| 语言是否为中文,表述是否专业? | 基本规范 |
五条覆盖了"对不对、好不好、有没有据、全不全、专不专业"------这是一份真正的审稿清单。
注意第一条为什么需要读 question.txt------因为审稿人要独立核对"这份稿子有没有回答最初的问题",不能光看草稿自己写得漂不漂亮。
最后,"返回简洁的审阅意见和具体、可操作的 修改建议"------"可操作"三个字很重要。审稿意见最怕"这段不够深入"这种评价,主 Agent 看了也不知道怎么改。要的是"第二节缺少 2024 年后的数据,建议补充 X 来源"。
四、两本手册:技能(Skills)
createDeepAgent 的参数里有一行:
javascript
skills: ["/skills/"]
指向项目根目录下的 skills/ 文件夹。里面放着两份 SKILL.md。
这是技能------前面反复强调"技能不是子 Agent",现在可以看清它到底是什么了。
技能长什么样
markdown
---
name: report-writer
description: 将调研结果整理为结构清晰、专业的中文情报报告
---
# 报告撰写技能
将调研 findings 综合为最终交付物时使用本技能。
> **注意**:本技能是主Agent的写作指南,不是子Agent。请主Agent 亲自用`write_file`撰写报告,**不要**通过`task`工具委派`report-writer`。
## 报告结构
...
结构很简单:YAML frontmatter(名字 + 描述)+ Markdown 正文。
| 部分 | 作用 | 给谁看 |
|---|---|---|
name + description |
元信息,让 Agent 知道"有这个技能、什么时候用" | 主 Agent 的"技能列表" |
| 正文 | 具体的流程、规范、模板 | 主 Agent 决定用的时候读 |
这跟工具的定义方式很像 ------工具有 name + description + schema,技能有 name + description + 正文。都是"先让模型知道有什么,再让它决定用不用"。
两份技能,正好覆盖流程的两段
| 技能 | 描述里写的用途 | 对应流程 |
|---|---|---|
web-research |
"结构化多来源联网调研,支持并行委派调研员子 Agent" | 第 2 步:调研 |
report-writer |
"将调研结果整理为结构清晰、专业的中文情报报告" | 第 4 步:起草 |
主 Agent 的流程里那两个"按 xxx 技能"的引用,就指向这两个文件。
这就是"技能"的作用:把长流程的操作细节从主 prompt 里抽出来,变成"按需加载"的说明书。
想想看------如果把这两份文档的内容全塞进 orchestratorPrompt,那 prompt 会膨胀到一两百行,而且大部分内容在不需要的时候也占着上下文。
技能机制解决的就是这个矛盾:主 prompt 里只留一句"按 web-research 技能做",需要时再去读细节。
那两句警告:技能的定位问题
两份 SKILL.md 的开头,都有一句几乎一样的话:
markdown
> **注意**:本技能是主 Agent 的流程指南,不是子 Agent。
> 联网搜索请委派 `researcher` 子 Agent,**不要**将 `web-research` 作为 subagent_type 调用。
markdown
> **注意**:本技能是主Agent的写作指南,不是子Agent。
> 请主Agent 亲自用`write_file`撰写报告,**不要**通过`task`工具委派`report-writer`。
这是"防御性写作"------同一个坑,在三个地方各堵了一遍:
| 位置 | 怎么堵 |
|---|---|
orchestratorPrompt |
"web-research、report-writer 是技能 ,不是子 Agent" |
web-research/SKILL.md |
"不要将 web-research 作为 subagent_type 调用" |
report-writer/SKILL.md |
"不要通过 task 工具委派 report-writer" |
为什么要在技能自己身上也写一遍? 因为技能被读取的那一刻,往往就是模型"正在考虑要不要用某个名字去派活"的时候------在最接近出错点的位置再提醒一次。
而且两份技能的警告还各自带了个"正面指引":
web-research说"联网搜索请委派researcher子 Agent"(告诉你该派谁)report-writer说"请主 Agent 亲自用write_file撰写"(告诉你该谁干)
光说"别做什么"不够,还得说清"该做什么" ------不然模型只是排除了一个错误选项,接着去试下一个错误选项。
web-research 的流程:三步
markdown
### 1. 规划
1. 将用户问题写入 `/workspace/sources/question.txt`
2. 创建 `/workspace/sources/research_plan.md`,包含(**中文撰写**):
- 主调研问题
- 2-4 个互不重叠的子主题
- 每个子主题的预期产出
- 综合策略
### 2. 委派(可并行)
对每个子主题,用 `task` 工具启动 **researcher(调研员)** 子 Agent:
调研【具体子主题】。可用 write_todos 列出最多 3 条中文步骤(可选)。 使用 web_search 搜索(最多 10 次,关键词用中文)。 将 findings 保存到 /workspace/sources/findings_子主题slug.md,写入后结束。
markdown
子主题相互独立时,最多并行 3 个调研员。**总数不超过 3 个。**
### 3. 综合
1. 读取所有 `/workspace/sources/findings_*.md`
2. 整合为连贯分析
3. 定稿前委派 **editor(编辑)** 子 Agent 审阅
三步:规划 → 委派 → 综合。
第 2 步里那段代码块很讲究------它是"派活时该说的话"的模板。
主 Agent 要把这段话发给子 Agent,里面包含了子 Agent 完成任务所需的所有信息:干什么、怎么干、上限多少、产出存哪、什么时候停。这就是多 Agent 系统里的"工单"。
但这里出现了一个不一致,值得留意:
| 位置 | 说的搜索次数上限 |
|---|---|
web-research/SKILL.md 的派活模板 |
"最多 10 次" |
researcher 的 systemPrompt |
"最多调用 3 次 web_search(硬性上限,绝不超过)" |
两个数字对不上------一个说 10 次,一个说 3 次。
这种情况在真实项目里很常见,也很危险:当两处指令冲突时,模型可能挑对自己"宽松"的那个遵守。 结果就是"以为限制了 3 次,实际跑了 10 次"。
经验:凡是数值型的约束(次数、长度、超时),都应该定义成常量并在所有地方引用同一个来源。 写在自然语言里的数字,迟早会对不上。
report-writer 的规范:报告长什么样
markdown
## 报告结构
1. **标题** - `#[主题]:情报简报`
2. **执行摘要** - 3-5 条核心要点
3. **背景** - 主题背景与当前重要性
4. **核心发现** - 按主题组织,而非按来源堆砌
5. **结论** - 直接回答原始问题
6. **参考资料** - 标号列表,格式 `[标题](URL)`
六个部分,是一个标准的调研报告骨架。
第 4 条那个括号注释最有价值:
markdown
- **核心发现** - 按主题组织,而非按来源堆砌
"按主题组织,而非按来源堆砌" ------这句话点出了新手写调研报告最常见的毛病:
| 写法 | 结构 | 读者体验 |
|---|---|---|
| 按来源堆砌 | "A 网站说......B 网站说......C 网站说......" | 读了一堆,不知道结论是什么 |
| 按主题组织 | "关于性能:......;关于生态:......" | 每个问题都能找到答案 |
"堆砌"是资料搬运,"组织"才是分析。 这一句注释,把"报告撰写"和"资料汇编"区分开了。
写作规范里也有几条很实用的禁令:
markdown
- **全文使用中文** (专有名词可保留英文)
- 第三人称专业表述,禁止 [我调研了] [我发现] 等自述
- 关键论断 inline 引用 `[标题](URL)`
- 每节内容充实(多段落),避免一句带过
- 对比类报告:每项单独一节,再加综合对比节
"禁止「我调研了」「我发现」等自述"------因为这是给客户看的报告,不是 Agent 的工作日志。 报告里出现"我调研了某某网站",读起来就变成了一份个人笔记。
"关键论断 inline 引用" ------要求在正文里直接标出来源,而不是只在末尾列参考资料。这样读者能立刻知道"哪个说法有出处、哪个没有"。
文件命名也规定死了:
markdown
- 草稿:`/workspace/reports/draft_[主题slug].md`
- 终稿:`/workspace/reports/report_[主题slug]_[YYYY-MM-DD].md`
注意终稿的文件名带了日期。 这跟前面评估那篇讲的"数据集要带版本号"是同一个思路------同一个主题可能调研多次,带日期的文件名能区分批次,也能看出"这次比上次新在哪"。
五、那把"联网的钥匙":search.mjs
调研员能联网,靠的是这个工具:
javascript
export const webSearch = tool(
async (input) => {
const count = input.count ?? 10;
console.log(`搜索:${input.query}(${count}条)`);
return bochaWebSearch(input.query, count);
},
{
name: "web-search",
description:`
使用 Bocha 联网搜索 API 检索互联网网页。输入中文或者中英结合的搜索关键词,
可选count指定结果数量。
`,
schema:z.object({
query: z
.string()
.min(1)
.describe(
"搜索关键词,优先使用中文,例如:2026年 AI Agent 框架对比、LangGraph最新动态"
),
count: z
.number()
.int()
.min(1)
.max(20)
.optional()
.describe("返回的搜索结果,默认10条")
})
}
)
用的是博查(Bocha)搜索 API ------跟前面 RAG 那篇的"联网兜底"用的是同一个服务。但这次的封装更完整。
schema 里的三重约束
javascript
count: z
.number() // 必须是数字
.int() // 必须是整数
.min(1) // 至少 1
.max(20) // 最多 20
.optional() // 可以不传
.describe("返回的搜索结果,默认10条")
六个方法链在一起------这就是用 Schema 给模型的参数"立规矩"。
对比一下"只在 description 里写'最多20条'"的做法:
| 做法 | 效果 |
|---|---|
| 只写在描述里 | 模型可能忽略,传个 50 进来 |
.min(1).max(20) |
框架层面直接拦住非法值 |
这是上一篇讲结构化输出时的老套路------能用代码约束的,别指望自然语言。
query 的 .describe() 里还给了一个具体的例子:
arduino
"搜索关键词,优先使用中文,例如:2026年 AI Agent 框架对比、LangGraph最新动态"
给例子比给规则有效。 尤其"优先使用中文"这种偏好,光说规则模型可能理解成"能用中文就行",给两个例子它就懂到底想要什么样的关键词了。
所有失败路径都"返回字符串",而不是"抛异常"
这是这个文件最值得学的设计。看这几种情况:
javascript
async function bochaWebSearch(query, count) {
const apiKey = process.env.BOCHA_API_KEY?.trim();
if (!apiKey) {
return "Bocha 联网搜索的API KEY 未配置" // 没 key → 返回提示
}
// ...
if (!response.ok) {
const errorText = await response.text();
return `搜索API 请求失败,状态码:${response.status},错误信息:${errorText}`
}
let json;
try {
json = await response.json();
} catch (error) {
return `搜索API 请求失败,原因是:搜索结果解析失败:${error.message}`
}
try{
if (json.code !== 200 || !json.data) {
return `搜索API 请求失败,原因是:${json.msg || "未知错误"}`
}
const webpages = json.data.webPages?.value ?? [];
if(!webpages) {
return `未找到[${query}] 相关的结果`
}
return formatWebPages(webpages);
} catch (error) {
return `搜索API 请求失败,原因是:${error.message}`
}
}
五种失败情况,五种都 return 一个字符串,一次 throw 都没有。
为什么?因为工具的返回值是给模型看的。
回想一下前面 Harness 那节课写的 run_bash:
javascript
return "Error: Dangerous command blocked."
是同一个思路。 工具的返回值会作为 ToolMessage 塞进对话,模型会读到它。
| 失败处理方式 | 模型的反应 |
|---|---|
throw 异常 |
整个流程崩了,用户看到报错 |
return "错误说明" |
模型读到"搜索失败了",可以换个关键词重试、或者如实告知用户 |
"API KEY 没配"这个提示尤其典型 ------它不是给程序看的,是给人和模型看的:"我知道现在没法搜,因为钥匙没插。"
把错误当成一种"信息"而不是"事故" ------这是 Agent 工具设计里一条非常重要的原则。因为 Agent 的主循环本来就能处理"工具返回了不理想的结果"这种情况,前提是你得让它知道。
业务错误也要查:json.code
javascript
if (json.code !== 200 || !json.data) {
return `搜索API 请求失败,原因是:${json.msg || "未知错误"}`
}
注意这里是 json.code,不是 response.status。
HTTP 状态码 200 表示"请求成功送达",但不表示"业务成功" 。很多 API 会返回 200 + 一个 { code: 500, msg: "余额不足" } 的响应体。
所以要查两层:
| 层次 | 查什么 | 在哪查 |
|---|---|---|
| HTTP 层 | response.ok(2xx) |
请求本身通没通 |
| 业务层 | json.code !== 200 |
这次搜索成没成功 |
这是调第三方 API 的标准姿势------只查 HTTP 状态码,会把"欠费了""超配额了"这类错误当成成功,然后在后面莫名其妙地崩掉。
json.msg || "未知错误" 又是那套兜底------万一对方没给错误描述,也不能返回 undefined。
六、入口:cli.mjs
最后是命令行入口。
javascript
// 除了开发者,面向其他Agent的调用
import 'dotenv/config';
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"// file://
import readline from "node:readline/promises"
import { stdin as input, stdout as output } from "node:process"
import { HumanMessage } from "@langchain/core/messages"
import {createIntelligenceDeskAgent, projectDir} from "./agent.mjs"
const recursionLimit = 300;
开头那句注释值得琢磨:
arduino
// 除了开发者,面向其他Agent的调用
"面向其他 Agent 的调用" ------意思是这个模块不只是给人用的 CLI,它本身也是一个可以被上层系统调用的能力单元。
这跟前面 "一个主编、三个工种" 的架构是一脉相承的------调研助手可以是别人的子 Agent。 前面 Harness 那节课讲过"Sub Agents 是分层递归的",这里看到了一个具体的落点。
recursionLimit = 300 ------这个数字大得显眼。
对比一下前面那些 demo:recursionLimit: 5(预期失败的权限测试)、20、30。这里直接 300。
为什么?因为这是个多 Agent 系统。
主 Agent 要规划、派活、收结果、起草、审稿、修订、定稿,而每个子 Agent 自己也要跑好几轮(调研员最多搜 3 次 + 写文件,分析师要执行代码)。这些轮次是累加的。
如果 limit 设小了,任务跑到一半会被强行掐断。 300 是个"给足了空间"的值。
读输入:参数优先,交互兜底
javascript
async function readQuery() {
// node a.js a b c
const fromArgs = process.argv.slice(2).join(" ").trim();
if(fromArgs) return fromArgs;
// console.log('--', fromArgs);
const rl = readline.createInterface({
input,
output,
})
try {
return (await rl.question('请输入调研主题:')).trim();
} finally {
rl.close();
}
}
两种输入方式,优先级明确:
| 方式 | 用法 | 场景 |
|---|---|---|
| 命令行参数 | node cli.mjs 对比主流 Agent 框架 |
脚本化、自动化调用 |
| 交互式问答 | 直接跑,等提示输入 | 手动调试 |
finally { rl.close() } 是个好习惯------不管 question 成功还是抛异常,都要把 readline 接口关掉,否则进程可能不退出。
这跟前面 cli.mjs(RAG 那个)的 join(" ") 是同一个手法------把多段参数拼回一句完整的话 ,避免语义被切碎。这说明这个坑在项目里踩过一次了。
跑起来:流式看全过程
javascript
async function run(query) {
console.log(`query: ${query}\n`);
console.log(`recursionLimit: ${recursionLimit}\n`);
console.log(`-`.repeat(50));
const agent = createIntelligenceDeskAgent();
const pending = new Map();
const pendingEval = new Map();
for await(const [namespace, chunk] of await agent.stream(
{ messages: [new HumanMessage(query)]},
{ streamMode: "updates", subgraphs: true, recursionLimit}
)) {
console.log(namespace, chunk);
}
}
重点是那个 stream 的三个参数:
| 参数 | 值 | 作用 |
|---|---|---|
streamMode |
"updates" |
每完成一步就推一个更新 |
subgraphs |
true |
子图也一起流出来 |
recursionLimit |
300 | 最大轮数 |
subgraphs: true 是这里的关键 ------它是"能不能看见子 Agent 在干嘛"的开关。
不开这个,你只能看到主 Agent 的状态变化;开了之后,子 Agent(调研员、分析师、编辑)的每一步也会流出来 ,每个都有自己的 namespace。
所以那个循环写成了:
javascript
for await(const [namespace, chunk] of await agent.stream(...)) {
console.log(namespace, chunk);
}
解构出的 namespace 就是"这段输出是谁产生的" ------是主 Agent 还是某个子 Agent。
这个设计让整个多 Agent 系统变得"可见"了。 想想看:四个 Agent 协作、几百轮调用,如果只输出最终报告,出了问题你根本不知道是哪一环出的。而带上 namespace,你能看到"调研员搜了什么、分析师算了什么、编辑提了什么意见"。
这跟前面 LangSmith 那篇讲的"拆盲盒"是同一个诉求 ------只不过一个是事后在界面上看 trace,一个是实时在终端里看流。
(顺带一提,那两个 pending / pendingEval 是声明了但当前没用上的 Map------从名字看,大概是为后面做"输出美化"预留的:按 namespace 分组、按工具类型区分展示。开发中的代码留着这种"半成品变量"很正常。)
七、这套系统的三个设计心法
回头看整个系统,最值得学的不是代码,是那堆 prompt 里的"克制"。
心法一:分工要写清"只做什么"
三个子 Agent 的描述里,全是限定词:
| Agent | 限定词 |
|---|---|
| researcher | "负责一个 分配给你的子主题,并写入一份调研结果文件" |
| analyst | "所有计算必须通过 eval REPL 完成" |
| editor | "负责审阅 ......不要亲自改写报告" |
每个 Agent 的 prompt 里,"不要做什么"和"要做什么"一样多。
因为没有边界的 Agent 会越界:调研员顺手把报告写了、分析师顺手改了数据、编辑顺手改了稿子。越界的结果是职责混乱,出了问题找不到责任人。
心法二:所有循环都要有刹车
数一数这套系统里有多少个"上限":
| 地方 | 上限 |
|---|---|
| 调研员搜索次数 | 3 次(硬性) |
| 调研员写文件 | 1 次 |
| 每份报告的调研员数量 | 3 个 |
| 并行调研员 | 3 个 |
| 调用编辑次数 | 1 次 |
recursionLimit |
300 |
六个上限,一个都不能少。
这不是"保守",这是"安全"。 Agent 的行为本质上是概率性的------你没法保证它一定会停,所以必须用硬性的数字拦住它。
心法三:Agent 之间只通过"交付物"交流
researcher 的 prompt 里那句话,是整个系统的通信协议:
其他人只能看到你写入的文件,内容必须完整、自洽
没有共享的对话历史,没有口头交接------只有文件。
| 通信方式 | 优点 | 缺点 |
|---|---|---|
| 共享上下文 | 信息全 | 互相干扰,上下文爆炸 |
| 文件交付 | 隔离、可检查、可并行 | 要求交付物自洽 |
代价是每个 Agent 都得把成果"写全",好处是可以并行、可以追溯、出了问题能直接打开文件看。
所以 web-research 技能的最佳实践里才有一条:
diff
- Agent 之间通过文件传递信息,不要依赖对话历史
这不是建议,是这套架构的硬约束。
八、三篇看下来,一条完整的成长线
从第一颗螺丝到一支团队:
| 阶段 | 做了什么 | 对应篇目 |
|---|---|---|
| 看 | 认识中间件的四个钩子和三种特权 | 上篇 |
| 用 | 挂上官方三个开箱即用的中间件 | 中篇 |
| 造 | 用它们搭一个多 Agent 调研系统 | 本篇 |
而这条线的方向,其实是"从写代码到写规则":
- 上篇在写代码------定义 stateSchema、写钩子函数
- 中篇在配参数------permissions、sources、trigger
- 本篇在写文档------五十行的主编职责、三份岗位说明、两本操作手册
到最后,核心竞争力变成了"把要求写清楚"的能力。 那堆 prompt 里的每一个"最多""不要""只能",都是一次对模型行为的精确约束。
这就是 Agent 开发的现状:代码量不大,但表达量很大。
PS:这套系统里最让我印象深刻的,是那些"上限"------最多 3 次搜索、最多 3 个调研员、只调用编辑一次。写 Agent 和写普通程序的思路差别正在这里:普通程序你操心"怎么让它跑起来",Agent 你得操心"怎么让它停下来"。能干活不算本事,干完活知道收手,才算。