多智能体上线两周,成本涨了 6 倍
十一月份我们把合同审查从单 Agent 改成了多智能体——一个主管 Agent 负责任务分解,下面挂了四个专职 Agent(条款抽取、风险识别、合规比对、历史案例检索)。理由是单 Agent 的表现遇到瓶颈:一份 40 页的合同要塞进一次上下文,模型经常顾此失彼,审查项召回率卡在 82% 上不去。
改造后召回率确实提到了 93%。但上线第二周,财务把账单发过来了:单份合同的审查成本从 ¥0.34 涨到 ¥2.17,涨了 6.4 倍。日均 900 份合同,月成本从 9,180 元变成 58,590 元。
这篇是完整的复盘,包括架构、成本失控的原因,以及我们怎么把成本压回到 ¥0.71。
架构:Supervisor 模式
我们用的是最经典的 Supervisor(主管-执行者)模式。选择它的原因很实际——层级结构最容易被人类理解和干预。出问题的时候能明确说是哪个子 Agent 的责任。
┌──────────────────┐
│ Supervisor │ 强模型,负责分解+汇总
│ qwen-max │
└────────┬─────────┘
│ 委派任务
┌────────────┬───────┴────┬────────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 条款抽取 │ │ 风险识别 │ │ 合规比对 │ │ 历史案例 │
│ qwen-plus│ │ qwen-max │ │ qwen-plus│ │qwen-turbo│
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │
└────────────┴────────────┴────────────┘
▼ 结果汇总回 Supervisor
Supervisor 拿到结果后做汇总,如果发现结果不充分,可以再派发一轮任务(最多 3 轮)。
任务委派:结构化的任务描述
委派这块我们踩过坑。第一版是让 Supervisor 用自然语言描述任务,子 Agent 自己去理解:
// v1:自然语言委派,问题很多
"请审查这份合同的付款条款,看看有没有风险"
// 子 Agent 的返回五花八门:
// - 有时候返回一大段分析文字
// - 有时候返回一个列表
// - 有时候只说"没发现问题"(其实没审)
问题在于 Supervisor 无法可靠地解析子 Agent 的自由文本输出。汇总阶段经常丢失信息,或者重复计算。
改成结构化任务契约之后稳定多了:
public record SubTask(
String taskId,
String agentName,
String instruction, // 仍然是自然语言,给模型看
List<String> inputs, // 明确输入:需要哪些数据
OutputSpec outputSpec, // 明确输出:字段、类型、必填
int maxTokens,
String model
) {}
public record OutputSpec(List<Field> fields, String format) {
public record Field(String name, String type, String desc, boolean required) {}
}
// 实际派发的任务
new SubTask("t-01", "clause-extractor",
"从合同原文中抽取所有涉及付款的条款,包含金额、时间节点、前置条件",
List.of("contract_text#full"),
new OutputSpec(List.of(
new Field("clauses", "array", "条款列表", true),
new Field("clause_no", "string", "条款编号", true),
new Field("amount", "number", "金额,单位元,无则 null", false),
new Field("deadline_days", "integer", "付款期限天数", false)
), "json"),
4000, "qwen-plus");
关键是 OutputSpec——子 Agent 被强制按 schema 输出,Supervisor 能可靠解析。这一项改动把汇总失败率从 12% 降到 1.4%。
另外 inputs 字段明确指定了输入范围。contract_text#full 是全部原文,contract_text#clause_3 是指定条款。这个设计是为了控制 token——不要让每个子 Agent 都拿到全量原文。
成本失控的四个原因
拿到账单后我们逐条分析,成本暴涨不是单一原因。
| 原因 | 成本占比 | 说明 |
|---|---|---|
| 子 Agent 重复读取全量原文 | 41% | 四个 Agent 各自把 40 页合同都读了一遍 |
| Supervisor 汇总时上下文过大 | 27% | 把四个 Agent 的完整输出都塞进汇总 |
| 无效的重试轮次 | 19% | 3 轮里平均有 1.7 轮是没必要的 |
| 子 Agent 用小模型返工 | 13% | 输出不达标,Supervisor 让它重做 |
原因一:全量原文的重复传递
一份 40 页合同约 2.8 万 token。四个子 Agent 都拿到全量原文,光这一项就是 11.2 万输入 token。而且这还没算 Supervisor 自己读的那一次。
改造分成两步。第一步是预处理一次,切片共享:由一个专门的预处理 Agent(用最便宜的模型)先把合同切成结构化片段,后续 Agent 只拿自己需要的片段。
// 预处理:只做一次,结果共享
ContractSections sections = preprocessor.extract(contractText);
// {
// payment: [第3条, 第7条, 第12条], // 约 3,200 token
// liability: [第8条, 第9条], // 约 1,800 token
// termination: [第15条], // 约 900 token
// ...
// }
// 子 Agent 只拿相关片段
new SubTask("t-01", "payment-risk", "...",
List.of("sections#payment"), // 3,200 token 而不是 28,000
...);
第二步是给每个 Agent 的 inputs 做精确匹配。我们统计了各 Agent 实际用到的条款分布,发现「合规比对」Agent 只需要 4 类条款,之前却拿到了全文。
效果:输入 token 从 11.2 万降到 2.9 万,降了 74%。
原因二:汇总阶段的上下文爆炸
Supervisor 汇总时,原来的做法是把四个子 Agent 的完整输出(平均每个 2,100 token)全部放进上下文,再加自己的指令。四轮下来累计 8,400+ token。
问题是这些输出里大量内容是过程描述,比如「我首先检查了第 3 条,发现……然后我又看了第 7 条」。Supervisor 真正需要的只是结论和依据。
我们强制子 Agent 分两部分输出:
public record AgentOutput(
List<Finding> findings, // 结构化结论,进汇总上下文
String reasoning, // 过程描述,不进汇总上下文,只存审计日志
double confidence
) {}
// 汇总时只用 findings,序列化后约 380 token/agent
String buildSummaryContext(List<AgentOutput> outputs) {
return outputs.stream()
.map(o -> Json.toJson(o.findings())) // 不含 reasoning
.collect(joining("\n---\n"));
}
汇总上下文从 8,400 token 降到 1,520 token。reasoning 我们仍然保留——它存在审计日志里,复盘时能看,只是不占 token。
原因三:无意义的重试轮次
Supervisor 的重试逻辑一开始写得太宽松:
// 问题版本:只要觉得"不够充分"就再来一轮
while (round < 3 && !supervisor.isSatisfied(results)) {
results.addAll(dispatchMoreTasks(...));
round++;
}
isSatisfied 是让模型自己判断,结果它几乎永远觉得「还能再查查」。统计下来平均 2.7 轮,其中 1.7 轮是没必要的。
改成基于明确缺口的判断:Supervisor 不说「满不满意」,而是必须列出「还缺什么具体信息」,列不出来就结束。
public record RoundDecision(
boolean needAnotherRound,
List<InformationGap> gaps, // 必须具体:「缺少 2024 年同类合同的实际赔付金额」
String justification
) {}
boolean shouldContinue(RoundDecision d) {
// 硬约束:拿不出具体缺口就必须停
if (!d.needAnotherRound()) return false;
if (d.gaps().isEmpty()) {
log.warn("supervisor requested another round without specific gaps, stopping");
return false;
}
// 缺口必须能映射到某个 Agent
return d.gaps().stream().anyMatch(g -> router.canHandle(g));
}
这一改,平均轮次从 2.7 降到 1.2,成本降了 19%。而且召回率只掉了 0.4pp——说明那些多出来的轮次基本没产生有效信息。
原因四:小模型输出不达标导致返工
我们把「条款抽取」和「合规比对」配了 qwen-plus 省钱,结果它们的输出经常不符合 schema,Supervisor 要求重做。平均 1.4 次返工/任务,等于付了 2.4 倍的钱。
这个账要重新算:
| 方案 | 单次成本 | 返工率 | 期望成本 |
|---|---|---|---|
| qwen-plus | ¥0.09 | 58% | ¥0.09 × 2.4 = ¥0.216 |
| qwen-max | ¥0.31 | 7% | ¥0.31 × 1.07 = ¥0.332 |
看起来 plus 还是便宜。但返工不只是钱的问题——每轮返工增加 3~5 秒延迟,而且返工后质量仍不稳定。
我们最后的方案不是换模型,是加校验和修复:子 Agent 输出后先用代码校验 schema,不合格的走一次廉价的修复调用(只发错误信息让模型改,不发原文):
String fixOutput(String badOutput, List<Violation> violations) {
// 只发错误和输出,不重发原文,成本极低
return cheapModel.call("""
以下 JSON 不符合要求,错误是:
%s
原始输出:
%s
只输出修正后的 JSON,不要解释。
""".formatted(describe(violations), badOutput));
}
修复调用平均 420 个 token,成本 ¥0.001。用这个替代整轮返工后,期望成本降到 ¥0.09 × 1.58 + ¥0.001 × 0.58 ≈ ¥0.143,比换 qwen-max 还便宜。
冲突解决:子 Agent 结论不一致怎么办
这是多智能体特有的、单 Agent 不会遇到的问题。我们统计了 1,200 份合同的处理记录,23% 存在至少一处子 Agent 结论冲突。
典型例子:风险识别 Agent 说「第 8 条违约责任过重,高风险」,合规比对 Agent 说「第 8 条符合行业标准,无异常」。
处理策略我们按冲突类型分了三种:
| 冲突类型 | 检测方式 | 处理 | 占比 |
|---|---|---|---|
| 同一条款风险等级不同 | 按 (条款号, 风险等级) 分组比对 | 交给仲裁 Agent,用强模型重审 | 64% |
| 事实性矛盾(金额、日期) | 规则抽取数值比对 | 回原文重新抽取,取证据充分的一方 | 28% |
| 表述冲突但结论一致 | 语义相似度 > 0.8 但措辞相反 | 忽略,由 Supervisor 统一表述 | 8% |
// 冲突检测
List<Conflict> detect(List<AgentOutput> outputs) {
Map<String, List<Finding>> byClause = outputs.stream()
.flatMap(o -> o.findings().stream())
.collect(groupingBy(Finding::clauseNo));
return byClause.entrySet().stream()
.filter(e -> e.getValue().stream()
.map(Finding::riskLevel).distinct().count() > 1)
.map(e -> new Conflict(e.getKey(), e.getValue()))
.toList();
}
// 仲裁:只把冲突的那几条送给强模型,不重发全文
Finding arbitrate(Conflict c, String clauseText) {
return strongModel.call(ARBITRATION_PROMPT, c.candidates(), clauseText);
}
仲裁 Agent 的调用成本很低(平均 1,100 token),因为只处理冲突的那几条。34% 的仲裁结果推翻了原结论,说明这一步是必要的——不是走过场。
另外我们把冲突率做成了一个监控指标。某个子 Agent 的结论被推翻的比例如果持续偏高(我们设的阈值是 40%),说明它的 prompt 有问题,需要调优。这个功能帮我们发现了「合规比对」Agent 的一个系统性错误(它把「行业标准」理解成了错误的版本)。
成本控制的其他手段
除了上面四项,还加了两个通用的:
public class CostGuard {
private static final BigDecimal TASK_BUDGET = new BigDecimal("1.20");
// 1. 单任务预算:超了就降级(换小模型)或中止
void check(String taskId) {
BigDecimal spent = costTracker.current(taskId);
if (spent.compareTo(TASK_BUDGET.multiply(BigDecimal.valueOf(0.7))) > 0) {
degradeToCheaperModel(taskId); // 70% 预算:降级
}
if (spent.compareTo(TASK_BUDGET) > 0) {
abort(taskId, "超出成本预算,转人工"); // 100% 预算:中止
}
}
// 2. 并发限制:最多 3 个子 Agent 并行
ExecutorService executor = Executors.newFixedThreadPool(3);
}
并发限制这一项是为了避免「同时开 8 个 Agent 各自跑」的情况。我们测过,并发 4 个以上时,单个 Agent 的响应时间会因为 GPU 排队显著变长,总耗时反而增加。
预算触发率:降级 8.3%,中止 1.1%。中止的那部分全部转人工,这是我们必须付出的成本——但 1.1% 的比例在可接受范围。
最终结果
| 指标 | 单 Agent | 多 Agent(初版) | 多 Agent(优化后) |
|---|---|---|---|
| 审查项召回率 | 82.1% | 93.4% | 92.8% |
| 误报率 | 7.2% | 5.1% | 5.4% |
| 单份成本 | ¥0.34 | ¥2.17 | ¥0.71 |
| 平均耗时 | 42s | 187s | 94s |
| 月成本(900 份/天) | ¥9,180 | ¥58,590 | ¥19,170 |
相对单 Agent,多智能体版本的召回率提升了 10.7 个百分点,成本是 2.1 倍。我们认为是划算的——一份合同漏审一个风险条款的代价,远高于多付的 0.37 元。
耗时还是比单 Agent 慢一倍(94s vs 42s)。这个我们接受了,因为合同审查是异步批处理场景,用户提交后等通知,不阻塞。
什么时候不该用多智能体
这轮做下来,我对多智能体的适用边界有了更清楚的认识。以下情况不建议上:
- 单 Agent 上下文装得下。如果一份文档 8,000 token 就能塞进去,拆成四个 Agent 纯属浪费。我们的合同是 2.8 万 token,确实装不下,这是拆分的正当理由。
- 子任务之间高度耦合。我们试过把「风险识别」拆成「财务风险」和「法务风险」两个 Agent,发现两者经常需要互相的信息,来回传递的成本超过了并行收益,后来合并了。
- 对延迟敏感。多轮委派 + 冲突仲裁,耗时大概率翻倍以上。
- 团队还没有单 Agent 的完整监控。多智能体的调试难度是数量级的提升。如果没有 token 追踪、决策日志、效果评测这些基础设施,出了问题根本无从下手。
最后一条是我最想强调的。我们这次能快速定位成本问题,是因为一上线就有按 Agent 维度的 token 统计。如果没有这个,我们可能要花一个月才发现「子 Agent 重复读全文」这个占了 41% 成本的问题。
小结
多智能体不是「更强的 Agent」,它是用成本换效果的一种架构选择。在我们的案例里,2.1 倍成本换 10.7 个百分点的召回率提升,这笔账算得过来。但如果当初没有做那轮成本优化(6.4 倍成本),这个项目大概率会被砍掉。
如果要我给一个最重要的建议,是这一条:多智能体的成本控制,必须在架构设计阶段就做,不能等上线后优化。重复传原文、汇总上下文爆炸、无意义重试——这三个占了 87% 的浪费,全都是可以在设计时避免的,只需要在写第一行代码前问一句「每个 Agent 真正需要看到什么」。
顺带说一句,今年 Java 的 Agent 框架生态变化挺大,Embabel、AgentScope Java 这些都冒出来了,Spring AI 也在往这个方向演进。我们这版是自己手写编排逻辑的(大约 2,300 行),如果有机会重做,我会先认真评估一下现成框架。不过话说回来,自己写的好处是成本控制的每一步都在自己手里,用框架的话,这些优化点未必都能改得到。