多智能体的失败很少发生在"模型不够强",而是发生在拓扑没定清:谁有权决策、上下文在哪一层被裁掉、任务交接时丢了哪几个字段。这三件事没写进代码,就一定在凌晨的日志里出现。这篇把四种常见拓扑摆出来对照,然后往下钻到交接协议与长任务检查点。
1. 四种拓扑的定位
| 拓扑 | 结构 | 解决什么 | 主要代价 |
|---|---|---|---|
| Single-Agent | 一个循环,一组工具 | 线性任务、上下文可控 | 工具一多就选错 |
| Orchestrator-Workers | 主管分派 + 工人执行 | 可并行、子任务异构 | 汇聚与冲突裁决 |
| Evaluator-Optimizer | 生成者 + 评审者循环 | 有明确质量标准 | 循环不收敛 |
| Parent-Child(Sub-Agent) | 主代理派生隔离上下文子代理 | 上下文预算隔离 | 交接信息丢失 |
判断顺序是:任务能不能并行 → 质量有没有可判定标准 → 上下文会不会超窗。三个都否,就该老实写 Single-Agent。
2. Single-Agent:工具超过八个就开始选错
def run_single(task, tools, max_steps=12):
ctx = [{"role": "user", "content": task}]
for step in range(max_steps):
msg = llm(ctx, tools=tools, tool_choice="auto")
ctx.append(msg)
if not msg.tool_calls:
return msg.content
for call in msg.tool_calls:
ctx.append(exec_tool(call, tools)) # 结果以 tool 角色回填
return "step_budget_exhausted"
| 信号 | 含义 | 处理 |
|---|---|---|
| 反复调用同一个工具 | 参数不对或返回没读懂 | 精简返回体,给出示例参数 |
| 工具选择在两个相似工具间摇摆 | 工具描述重叠 | 合并工具或明确"何时用哪个" |
| 步数打满无结论 | 任务本身需要分解 | 升级到 Orchestrator-Workers |
| 上下文越来越长、答非所问 | 历史堆积挤掉任务说明 | 摘要或外置状态 |
工具描述的质量比工具数量更决定成败。两个名字相近、描述都写"查询数据"的工具,模型必然误选。
3. Orchestrator-Workers:扇出容易,汇聚难
import asyncio
async def orchestrate(goal, plan, workers):
subs = plan(goal) # [{id, task, worker}]
results = await asyncio.gather(*[
workers[s["worker"]].run(s["task"]) for s in subs
], return_exceptions=True)
ok = [r for r in results if not isinstance(r, Exception)]
if len(ok) < len(subs) * 0.6: # 汇聚前先判完成率
return {"status": "partial", "results": ok}
return {"status": "ok", "merged": merge(goal, ok)}
| 汇聚策略 | 做法 | 适用 | 风险 |
|---|---|---|---|
| 拼接 | 按顺序合并文本 | 各子任务互不依赖 | 重复内容膨胀 |
| 裁决 | 主管按判据选一份 | 结论互斥 | 裁决依据必须显式 |
| 投票 | 多工人同任务取多数 | 有唯一答案 | 成本翻倍 |
| 结构化合并 | 按字段合并 JSON | 抽取类任务 | 字段冲突要定义优先级 |
扇出数要有上限。子任务从 5 涨到 20,延迟不一定涨,但 token 成本线性涨,而质量在第 8 个之后基本不动。
4. Evaluator-Optimizer:两个角色、一个终止条件
| 要素 | 生成者(Optimizer) | 评审者(Evaluator) |
|---|---|---|
| 输入 | 任务 + 上轮问题清单 | 产物 + 判据清单 |
| 输出 | 新版本产物 | pass/fail + 具体问题 |
| 禁止 | 顺便改没被指出的部分 | 提"更好一点"这类不可判定意见 |
| 终止 | 评审 pass 或轮次上限 | 连续两轮问题集合相同则判死锁 |
评审意见必须能被验证。写"表达可以更清晰"等于没写;写"第 3 条缺少失败时的返回值说明"才是可执行反馈。
def optimize_loop(task, gen, review, max_rounds=3):
draft, prev_issues = gen(task), None
for r in range(max_rounds):
ok, issues = review(draft)
if ok:
return draft, r
if issues == prev_issues: # 同一批问题反复出现 → 死锁
return draft, r
prev_issues = issues
draft = gen(task, fix=issues) # 只修被指出的问题
return draft, max_rounds
5. Sub-Agent:上下文隔离是买来的,不是免费的
Parent-Child 的核心动作是派生一个带独立上下文的子代理,只回传结论。它买到的是预算隔离,付出的是信息丢失。
| 维度 | 同上下文内分工 | Sub-Agent 隔离 |
|---|---|---|
| 上下文预算 | 共享,互相挤占 | 各自独立,互不污染 |
| 信息保真 | 全部可见 | 只保留显式回传的部分 |
| 成本 | 一份上下文反复计费 | 各自计费,总量更高 |
| 可恢复性 | 主循环崩溃即全丢 | 子任务可单独重跑 |
| 调试难度 | 单一轨迹 | 多轨迹需关联 id |
典型误用:把"需要主代理已知的约束"放在子代理内部,结果子代理产出不满足约束的方案,主代理还得再审一遍——这就等于付了隔离成本却没得到隔离收益。
6. Parent-Child 交接协议:字段比措辞重要
{
"handoff_id": "h-2026-0017",
"parent_id": "root",
"child_id": "worker-extract",
"objective": "从附件中抽取全部付款条款,输出 JSON 数组",
"constraints": ["金额保留两位小数", "缺失字段填 null,不要猜"],
"inputs": {"file": "contract.pdf", "pages": "12-18"},
"output_schema": {"type": "array", "items": {"type": "object"}},
"budget": {"max_steps": 8, "max_tokens": 6000, "deadline_s": 90},
"return": {"must_include": ["result", "steps_used", "unresolved"]}
}
| 字段 | 缺了会怎样 | 谁负责填 |
|---|---|---|
objective |
子代理自由发挥 | 父代理(一句话、可判定) |
constraints |
输出不可用 | 父代理 |
output_schema |
回传格式随机,合并失败 | 父代理 |
budget |
子代理长跑拖垮总时延 | 父代理 |
return.must_include |
不知道子代理卡在哪 | 双方约定 |
unresolved 这个字段价值极高:它把"子代理遇到的问题"变成结构化数据,父代理才能决定重试、换工具还是上报人。
7. Long-Running 任务:检查点与外置草稿
长任务的失败模式不是"算错",而是"跑到第 40 步时进程没了,全部重来"。解法是把状态落到文件系统,而不是留在上下文里。
import json, os, pathlib
STATE = pathlib.Path("/work/run-state.json")
def checkpoint(step, plan, done, notes):
tmp = STATE.with_suffix(".tmp")
tmp.write_text(json.dumps(
{"step": step, "plan": plan, "done": done, "notes": notes},
ensure_ascii=False), encoding="utf-8")
os.replace(tmp, STATE) # 原子替换,避免半写文件
def resume():
if STATE.exists():
return json.loads(STATE.read_text(encoding="utf-8"))
return {"step": 0, "plan": [], "done": [], "notes": []}
| 做法 | 抗崩溃 | 上下文开销 | 适合 |
|---|---|---|---|
| 只靠对话历史 | 否 | 高(越跑越贵) | 短任务 |
| Planning-with-Files | 是 | 低(按需读片段) | 多步长任务 |
| 状态存数据库 | 是 | 低 | 需要并发与查询 |
| 全量重跑 | 是(但贵) | — | 幂等且极短的任务 |
Planning-with-Files 的要点是计划本身也是一个文件:每步完成后回写状态与下一步,恢复时只读计划,不读全部历史。这让"第 41 步重启"的成本从"重跑 40 步"降到"读一个文件"。
8. Code-as-Action 与工具调用的分工
| 维度 | Tool Call | Code-as-Action |
|---|---|---|
| 表达力 | 受工具签名限制 | 任意组合、循环、条件 |
| 安全边界 | 每个工具自带权限 | 需要沙箱(容器/解释器) |
| 可审计 | 调用参数即日志 | 需要记录代码与输出 |
| 失败形态 | 参数错、超时 | 语法错、运行时异常、死循环 |
| 适合 | 高频、语义明确的动作 | 数据整形、批量计算、流程粘合 |
判据:动作是"一件事"就用工具,是"一串事"就写代码。让模型连续调用六个工具做数据清洗,既慢又容易在中间步骤丢字段;给它一个能执行代码的沙箱反而更省。
9. Meta-Agent:什么时候才值得多一层
Meta-Agent 指"负责设计/调度其他代理的代理"。它只在两种情况下有价值:拓扑本身要随任务变化,或者代理数量已经多到人工配置不动。
| 条件 | 是否值得引入 Meta-Agent |
|---|---|
| 固定两类请求、固定拓扑 | 否,硬编码更快更稳 |
| 任务数 > 20 且结构各异 | 是,需要动态生成拓扑 |
| 已在用 Orchestrator 且规则可枚举 | 否,先枚举规则 |
| 需要按成本/时延自动换策略 | 是,但要带预算上限 |
引入后必须同时引入拓扑审计:记录每次生成的拓扑与实际执行路径,否则出错时无法归因。
10. 排错:六种协作失效
| 现象 | 原因 | 处理 |
|---|---|---|
| 工人产出不满足约束 | 约束没进 handoff | 把 constraints 写进交接体 |
| 汇聚结果重复度高 | 子任务划分有重叠 | 按内容边界而非按长度切 |
| 评审循环不终止 | 判据不可判定 | 改成清单式可验证项 |
| 子代理超时拖垮整体 | budget 未设 |
每个交接体强制 deadline |
| 长任务重跑后行为不一致 | 状态未落盘 | 引入检查点 + 原子替换 |
| 成本翻倍但质量不变 | 扇出数过大 | 给扇出上限,按边际收益调 |
11. 边界:这些情况不该拆多代理
| 场景 | 更该做的 | 理由 |
|---|---|---|
| 单轮问答、格式转换 | 直接单次调用 | 拆开只增加失败点 |
| 上下文远未超窗 | 单代理 + 结构化提示 | 隔离收益不存在 |
| 质量无客观判据 | 人工抽检 + 评测集 | Evaluator 无据可依 |
| 任务强顺序依赖 | 单代理流水线 | 并行无意义 |
| 团队还没有轨迹日志 | 先补可观测 | 拆完无法排错 |
12. 自测
- 什么时候该从 Single-Agent 升级到 Orchestrator-Workers?判断依据是什么?
- Evaluator-Optimizer 里,"连续两轮问题集合相同"为什么可以判为死锁?
- Sub-Agent 隔离上下文的代价具体是哪一项?什么情况下等于白付这个成本?
- 交接体里
unresolved字段解决的是什么问题?缺了会怎样? - Planning-with-Files 让"第 41 步重启"变便宜,靠的是哪个设计取舍?
- Code-as-Action 相比 Tool Call,多出来的安全与审计成本分别落在哪?