🍕 一个主编、三个工种、两本手册:搭一支 AI 调研队

写在前面:前两篇我们学了零件------中间件的四个钩子(上篇)、三个开箱即用的中间件(中篇)。这篇是收官:用这些零件组装一个真正能干活的多 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 你得操心"怎么让它停下来"。能干活不算本事,干完活知道收手,才算。

相关推荐
纸片人1 小时前
Blender 建模 + Three.js 展示:和 AI 一起做一个光储充超充站数字孪生大屏
前端
智能直播1 小时前
网络RTMP拉流不卡顿、声音不同步?一文讲透缓冲区、时间戳与MEDAI V2实战调优
前端
第七页独白1 小时前
汽车零件厂如何通过 QMS 真正落地 IATF 16949——QMS软件系统:品质检验-内审稽核-8d客诉管理:全星质量管理软件系统
java·前端·数据库
纸片人1 小时前
只用 three.js + OpenStreetMap,手搓一个「成都城市 3D」数据大屏
前端
星若生辉1 小时前
Hugo 静态站点搭建|从本地调试到上线部署实操指南
前端
沐浴露z1 小时前
Agent 响应延迟过高?从工具治理角度提供两条思路
前端·网络
CappuccinoRose1 小时前
FormData数据处理
开发语言·前端·javascript·表单数据
飘尘2 小时前
SVG和Canvas,前端里的两支“画笔”,用的时候怎么选择?
前端·javascript·面试
计算机魔术师3 小时前
DeepSeek 据报道接近完成至少 800 亿元融资,腾讯与宁德时代参与
前端