吃透 AI Agent 开发 · 第 28 篇 · 第六章 · Multi-Agent
多 Agent 不是简单复制线程。任务边界、上下文隔离、结果预览、通知和生命周期才是协作的难点。
“一个 Agent 太慢,那就开十个。”这句话和“一个人写代码慢,那就找十个人同时改同一个文件”差不多。人数增加了,沟通、冲突和重复工作也一起增加。
多 Agent 真正有价值的场景,是任务可以清楚切开,子任务之间依赖少,并且结果能以小体积回到协调者。
先判断值不值得拆
适合委派的任务通常有这些特征:
- 可以独立搜索不同模块。
- 可以并行阅读文档、测试和实现。
- 产物边界明确,例如一份报告或一个独立目录的修改。
- 子任务不需要频繁询问主任务的中间状态。
不适合的情况包括:同一个函数里反复协调的细碎修改、强顺序依赖、共享状态不断变化,以及主 Agent 自己几分钟就能完成的工作。
一个实用判断是:委派说明加结果整合的成本,是否明显低于主 Agent 自己完成的成本。
子 Agent 复用循环,隔离上下文
子 Agent 通常不需要另一套 Agent Loop。它需要的是:
- 独立消息历史,避免主上下文全部复制过去。
- 清晰角色和完成标准。
- 收窄后的工具集。
- 明确的工作目录或资源范围。
- 局部步数、时间和成本预算。
interface ChildTask {
id: string
prompt: string
allowedTools: string[]
cwd: string
maxTurns: number
background: boolean
expectedOutput: 'summary' | 'patch' | 'artifact'
}
主 Agent 的安全和工具纪律应由共享基础规则提供,子 Agent 只追加自己的任务角色。维护两套完全不同的 Prompt,时间久了很容易出现一个有权限规则、另一个没有。
同步和后台是两种体验
同步子 Agent 像当场请同事查一个问题:主流程等待结果,适合短任务。后台子 Agent 像提交一份需要时间的分析:主流程可以继续,完成后再收到通知。
后台任务需要额外状态:queued、running、completed、failed、killed。还要保存开始时间、最近工具、用量和输出位置。只返回一个 Promise,进程重启后就什么都找不到了。
长结果不要塞回主上下文
子 Agent 读完五十个文件,写出两万字报告。如果把全文塞回主 Agent,协调者的上下文会立刻被一个子任务占满。
更好的结果信封是:
interface ChildResult {
status: 'completed' | 'failed' | 'killed'
preview: string
artifactFile?: string
originalChars: number
truncated: boolean
keyFindings: string[]
}
控制面只回传状态、摘要和句柄;数据面保存完整报告。主 Agent 真正需要某一节时再读取 artifact。
通知不是偷偷插入消息
后台任务完成时,可以把结构化通知放入队列,在下一次安全的消息边界注入主会话。不要在模型请求进行到一半时突然改变 messages 数组,也不要把一大段后台日志伪装成用户输入。
通知至少要说明任务 ID、状态、预览、产物位置和下一步建议。失败与 killed 的错误文本也要限制长度,避免异常本身撑爆上下文。
多 Agent 通信需要邮箱
更长期的团队协作中,成员可能互相发消息。共享一个可变数组会带来竞争和丢消息,可以使用每个成员独立的 mailbox:写入是追加操作,读取有已读位置,消息有大小上限和发送者信息。
邮箱解决通信,不解决事实一致性。两个 Agent 同时判断“测试已通过”,仍需要真实测试结果或共享任务状态作为证据。
并行改代码要隔离工作区
两个 Agent 在同一目录修改同一分支,哪怕改不同文件,也可能被格式化器、依赖安装或 Git 操作互相影响。Git worktree 提供一个实用隔离单位:每个任务拥有独立目录和分支,完成后再由主流程检查和整合。
隔离不是自动合并。仍然要处理:
- 分支命名与来源提交。
- 工作区是否干净。
- 冲突由谁解决。
- 失败任务的 worktree 何时清理。
- 子 Agent 是否有权推送或创建 PR。
外部副作用要遵循最小授权。允许子 Agent 写代码,不等于允许它自行发布。
动态并发不能只看空闲槽位
假设有四个并发槽,不代表每次都应该用满。模型调用受速率限制,多个 Shell 任务会争 CPU,多个写任务可能争同一资源。
调度器可以考虑:任务是否只读、预计耗时、依赖关系、模型配额和资源标签。先并行独立探索,再串行整合,通常比全程并发稳得多。
常见失败模式
任务切得太碎
“读这一行代码”也委派一次,协调成本超过执行成本。子任务应有独立产物和足够工作量。
给子 Agent 全量上下文和工具
这既浪费 token,也扩大权限。只传任务必需的背景和工具。
只收结论,不收证据
子 Agent 说“没有发现问题”并不够。结果应包含检查范围、关键路径、测试或引用位置。
循环委派
主 Agent 叫 A,A 又叫 B,B 再叫 A。需要限制委派深度,并在角色说明中明确谁负责最终整合。
用 Notification 做个小练习
把“评估一个新登录功能”拆成三个只读子任务:安全风险、测试覆盖、文档影响。为每个任务写:
- 不超过 100 字的任务说明。
- 允许使用的工具。
- 必须返回的证据。
- 最大运行时间。
最后设计一个合并模板,要求协调者区分共识、冲突和仍需验证的结论。
Parent Agent 到 Notification 的小结
多 Agent 的收益来自并行独立工作,成本来自沟通和共享状态。隔离上下文与工具,区分同步和后台,把长结果句柄化,用邮箱和 worktree 管理通信与代码边界,才能让并发真正提高吞吐。
下一篇讨论扩展机制。增加一种能力时,究竟应该写 Skill、用户命令、Hook、本地工具还是 MCP?选错层往往比实现本身更麻烦。
委派之前,先把口头安排写成合同
假设主任务是“评审登录模块并给出是否可以发布的结论”。直接告诉三个子 Agent “分别看看安全、测试、文档”,看起来已经拆开,实际上仍有四个空洞:什么算看完、可以读哪些目录、要交什么证据、超时以后谁接手。
一个可执行的子任务合同可以长这样:
task_id: auth-security
objective: 找出登录模块中会阻止发布的安全风险
scope:
include: [src/auth, tests/auth]
exclude: [.env, production]
tools: [read_file, glob, grep]
budget:
max_turns: 12
timeout_ms: 180000
deliverable:
required: [finding, severity, evidence_path, verification]
format: artifact
stop_when: 已覆盖入口、凭据处理、会话失效三个检查点
它和 Prompt 的区别在于,合同里有一部分必须由运行时强制执行。exclude 不能只靠模型自觉,超时不能只写在说明里,工具白名单也不能靠“请不要使用 Shell”来保证。自然语言负责解释目标,宿主程序负责兑现边界。
flowchart TD
P["Parent Agent"] --> C["Task Contract"]
C --> S["只读同步检查"]
C --> B["后台深度分析"]
S --> E1["证据摘要"]
B --> O["完整输出文件"]
O --> N["完成通知 + 预览"]
E1 --> G["Parent 交叉核验"]
N --> G
G --> D{"证据是否足够"}
D -->|"是"| R["整合结论"]
D -->|"否"| F["补查具体缺口"]
长结果的重点不是“截断”,而是“还能找回来”
简单截成前 2000 字会制造一种假象:上下文是省了,但最关键的失败栈可能正好在后面。更稳的做法是把原文保存为 artifact,预览只承担路由职责。预览应该告诉协调者这份结果是什么、规模多大、是否截断、从哪里恢复,而不是试图替代全文。
可以用一次失败来检验设计。后台测试 Agent 返回 18,000 字,其中前半段都是通过项,最后 300 字才写“Windows 路径下有一个阻断发布的失败”。如果通知只有固定前缀,主 Agent会误判;如果通知包含 keyFindings 或失败优先摘要,并保留 artifactFile,它就能定点读取结尾证据。
artifact 写入本身也可能失败,比如会话目录只读。这时不能返回一个不存在的路径。结果信封需要退回到受长度保护的 preview,并明确说明完整内容仍位于哪个备用输出,或者直接把本次子任务标为结果不完整。可恢复性必须被验证,不能只是一个字段名。
后台通知要等到安全边界
主 Agent 正在向模型发送第 6 轮请求时,测试任务完成了。此时直接修改正在使用的 messages 会让本地 transcript、审计轨迹和提供给模型的数组互相对不上。更清楚的协议是:通知先进入队列,等当前模型步骤结束或下一轮构建上下文时再 drain。
const pending = drainPendingNotifications()
const transient = pending.map((item) => ({
role: 'user' as const,
content: formatTaskNotification(item.parts),
}))
await runNextModelStep({ history, transient })
这类通知是本轮运行状态,不应伪装成用户亲自输入的话,也不必永久混进会话主线。系统可以在 transcript 里保留任务 ID、状态和 artifact 句柄,而把长预览当作临时上下文。
四个实现点,对应四个责任人
| 合同环节 | 参考文件 | 它应该证明什么 |
|---|---|---|
| 1. 子任务执行 | src/agents/run-agent.ts |
独立上下文、最大轮数、进度回调、Hook 和审计都在同一生命周期内收口 |
| 2. 后台收尾 | src/agents/run-async-agent.ts |
completed、failed、killed 三条路径都能清理状态并产生通知 |
| 3. 结果保存 | src/agents/final-output-artifact.ts |
短结果内联,长结果落盘,写入失败时仍有诚实的恢复说明 |
| 4. 通知投递 | src/agents/notification-store.ts |
后台结果先排队,再在明确边界被读取和格式化,而不是抢写消息数组 |
多 Agent 的发布门槛可以很朴素:任意结论都能追到任务合同,任意长结果都能按句柄恢复,任意后台任务都能被取消并留下终态,任意代码修改都知道来自哪个隔离工作区。少一个环节,并发带来的就可能只是更快地产生不可验证结论。
拆任务前先算信息依赖
“检查安全、测试和文档”看似独立,但三者可能都需要先知道登录功能改了哪些文件。可以让主 Agent先做一次轻量范围发现,产生共享 manifest,再把三个检查分发;不必让每个子 Agent 从仓库根目录重复搜索。
共享的是稳定事实与路径,不是主会话全部聊天。manifest 带生成时间和 hash,子任务发现范围变化时在结果中报告,不自行修改其他任务输入。
若子任务每两分钟都要询问主 Agent 新状态,它并不独立,强行并发只增加 mailbox 往返。把强依赖阶段串行、稳定后再 fan-out,通常更快。
给子 Agent 的 Context Package 要最小但完整
包里可以有任务合同、项目必须遵守的指令、相关文件索引、允许工具、当前 cwd、预算和父 runId。不要复制主 Agent 的所有工具结果、个人记忆和未相关附件。
共同安全规则由共享稳定 prompt pipe 产生,子 Agent 在项目指令后追加角色说明。维护一套基础纪律,避免主 Agent 修了路径规则,子 Agent 仍使用旧副本。
主题人格、活动团队列表和工具数量属于 transient,不应污染子 Agent 稳定 system。子任务只知道完成自己工作所需的动态事实。
同步 SubAgent 适合“下一步必须等它”
主 Agent 需要两处模块定位后才能写方案,可以并行启动两个只读同步 Agent,再等待全部结果。等待期间主模型不必反复输出“仍在等待”,Runtime 心跳与 TUI Monitor负责体验。
动态并行度按可独立任务数、槽位和模型配额决定。四个槽包括主 Agent,自身还需继续工作时不要占满;多个模型请求共享 rate limit,盲目并发可能一起 429。
同步结果短则内联,长则 artifact。任一子任务失败时,父任务合同决定 fail-fast、收集其他结果还是回退主 Agent 自查,不能一律取消全部。
后台 SubAgent 需要可持久的生命周期
后台任务创建后立即返回 taskId,registry 保存 queued/running/completed/failed/killed、开始时间、最近工具、usage、输出文件和取消 controller。进程内 Promise 不是状态存储。
宿主进程重启后,无法继续的任务标 orphaned/failed,并保留已有 output;若使用外部 worker,可按 jobId 查询。列表不能把未完成任务悄悄删掉。
任务完成后 notification 入队,在安全消息边界注入。用户暂时没有新输入时,TUI 仍能显示 completed,但不自动启动主模型消耗一轮,除非产品明确设计自动续跑。
长结果 Preview 应为决策服务
截取全文前 2000 字常被背景占满。Preview 可以由结构化字段组成:状态、三条关键发现、阻断项、证据路径、未检查范围、artifact 句柄。失败优先展示错误与恢复,不需要复述任务说明。
若 LLM 生成摘要失败,使用确定性尾部/标题提取并标注 fallback。完整原文永远是 artifact 的数据面,preview 不冒充全部结果。
错误文本也要限长。堆栈和 provider response 可能巨大或含敏感字段,先脱敏、落受控 artifact,再回传短诊断。
Mailbox 消息必须有幂等 ID 和大小上限
队友通信采用 append-only 消息,包含 messageId、from、to、taskId、kind、createdAt 和 payloadRef。读取维护 cursor,重复投递同 messageId 只消费一次。
大报告不进 mailbox,写 artifact 后发送引用。消息过大直接拒绝并告诉发送者使用句柄,避免通信层变成第二个上下文仓库。
邮箱只传信息,不授予工具。队友收到“请帮我部署”仍受自己的 task contract 和权限;主 Agent 也不能通过消息让只读 Agent 绕过白名单。
Worktree 隔离代码,不隔离所有副作用
每个写任务独立 worktree 和分支能避免文件互相覆盖,但共享 node_modules 缓存、数据库、端口和外部服务仍可能冲突。任务合同还要声明资源标签,例如 port:3000、db:test-auth、external:jira。
创建 worktree 前记录 base commit,确认主工作区状态;子 Agent 不自行 rebase、push 或创建 PR,除非明确授权。完成后返回 commit/diff/test 证据,主流程检查再整合。
清理 worktree 是资源操作。失败任务先保留诊断,确认无未提交用户内容后再清理;不要为了“收尾干净”删除唯一产物。
合并结论按证据强度,不按多数票
三个 Agent 中两个说安全、一个发现明确未授权访问。多数投票会通过,证据评审应以高严重性具体路径阻断。合并器统一 finding schema:severity、claim、evidence、scope、confidence、verify。
重复 finding 按来源合并,冲突保留双方。主 Agent 可以调用额外只读检查验证,不根据语言流畅度选一个。没有证据的“没问题”权重低于一条可复现失败。
取消和超时按任务树传播
用户取消主任务,默认向非 detached 子任务发 abort;已经完成的结果保留,后台明确独立的任务可继续并提示。父任务超时不等于子任务已停止,等待终态或标 unknown。
每个子 Agent有 maxTurns、model request timeout 和总 timeout。达到 maxTurns 返回局部证据与未完成原因,不用一条泛化 error 抹掉所有工作。
循环委派通过 delegationDepth 和角色规则限制。子 Agent 默认不再委派,只有编排角色且有预算时允许;任务链记录 parentId,检测 A→B→A。
用利用率而不是 Agent 数量看收益
统计主 Agent 等待时间、子任务运行时间、重复读取、合并耗时、失败重做和总 token。四个 Agent 把搜索从 8 分钟降到 3 分钟,却多花 10 分钟合并,就没有收益。
适合并发的只读调查可以动态调度,写任务单一所有者,强依赖串行。并发槽是上限,不是目标。某一时刻只有两个高价值独立任务,空两个槽完全正常。
一次团队演练
主任务评审登录发布。先生成范围 manifest,安全、测试、文档三个只读 Agent 并行;测试 Agent 输出 15KB 报告落 artifact,通知只回阻断失败和句柄;安全 Agent发现高严重性问题;文档 Agent completed。
合并器按证据阻断发布,不被 2:1 多数覆盖。用户取消后所有前台任务终止,artifact 仍可读,Monitor 显示终态。若某个写任务后续获批,在独立 worktree 修改,主 Agent 验证 diff/test 后再整合。整条链路都能从 taskId 回到合同和证据。