财务拿着一张 14.7 万的账单来找我
六月初,财务同事拿着模型账单发消息给我:"你们那个智能运维 Agent,上个月花了 14.7 万?预算不是 5 万吗?" 我一看确实超了快三倍,而且这个 Agent 上线才一个月,日均任务量只有 420 个左右。
我花了两天把账单拆开分析,发现问题比想象的有意思——Agent 的 token 消耗模式和普通对话完全不是一回事。这篇是完整的分析过程和优化结果。
先拆解:钱花在哪儿了
我们从审计日志里导出了整月的调用记录,一共 3.2 亿 token,按几个维度拆:
| 维度 | 拆分 |
|---|---|
| 按 token 类型 | 输入 2.91 亿(90.7%)、输出 0.29 亿(9.3%) |
| 按模型 | qwen-max 78%、qwen-plus 15%、embedding 7% |
| 按任务阶段 | 规划 11%、执行循环 84%、总结 5% |
90.7% 是输入 token,这是第一个反直觉的地方。普通对话场景里输入输出大概是 1:1 甚至输出更多,但 Agent 完全不同——它每次调用都要把全部历史(系统提示 + 所有历史步骤 + 所有工具返回结果)重新发给模型。
我算了一下单个任务的消耗分布:
| 项目 | 平均 token | 占比 |
|---|---|---|
| 系统提示词(工具定义 + 规则) | 4,200 | 3.3% |
| 用户原始任务描述 | 180 | 0.1% |
| 历史步骤累积 | 77,400 | 61.4% |
| 工具返回结果累积 | 39,100 | 31.0% |
| 模型输出(各步合计) | 5,300 | 4.2% |
| 合计 | 126,180 | 100% |
单个任务 12.6 万 token,按 qwen-max 的价格算下来是 ¥1.17 一次。日均 420 个任务,一个月就是 14.7 万。数字对上了。
再看历史步骤累积那 61.4%:我们统计了任务的平均步数是 18.7 步。第 18 步时,模型收到的 prompt 里包含了前面 17 步的全部内容,而这些内容里绝大部分对当前步骤毫无用处。这是 Agent 成本失控的核心原因。
还有个更糟的数据:任务步数分布的长尾很重。中位数是 9 步,但有 11% 的任务超过 40 步,最极端的一个跑了 143 步(模型在两个工具之间来回打转)。这 11% 的长尾任务消耗了 47% 的成本。
优化一:上下文压缩
这是收益最大的一项。思路是把"完整历史"换成"摘要 + 近期原文"。
// 压缩策略:保留最近 4 步原文,更早的用摘要替换
List<Step> compress(List<Step> history) {
if (history.size() <= KEEP_RECENT) return history;
List<Step> old = history.subList(0, history.size() - KEEP_RECENT);
String summary = summarize(old); // 用便宜的模型做摘要
List<Step> result = new ArrayList<>();
result.add(Step.summary(summary));
result.addAll(history.subList(history.size() - KEEP_RECENT, history.size()));
return result;
}
String summarize(List<Step> steps) {
// 关键:用 qwen-turbo 而不是 qwen-max,成本差 12 倍
return cheapModel.call("""
把以下运维操作步骤压缩成不超过 200 字的摘要,
保留:已执行的动作、关键结果、未解决的问题。
%s
""".formatted(format(steps)));
}
这里有个细节很重要:摘要用便宜的模型做。压缩任务本身很简单,用 qwen-turbo 完全够,成本是 qwen-max 的 1/12。我们一开始图省事用了主模型,等于省下的钱又花出去了。
另外工具返回结果要单独处理。很多工具返回的完整 JSON 有几百行,但模型真正需要的是其中几个字段。我们在工具层加了结果裁剪:
@Tool(description = "查询指定服务的监控指标")
public MetricSummary queryMetrics(
@ToolParam(description = "服务名") String service) {
List<Metric> raw = monitorClient.query(service);
// 只回传聚合后的摘要,不回传原始时序数据
return MetricSummary.from(raw)
.withLimit(20)
.omitFields("rawDataPoints", "tags", "metadata");
}
这一个改动把工具返回的平均长度从 2100 token 降到 340 token。累计下来,工具返回部分的成本降了 79%。
优化二:Prompt 缓存复用
上下文压缩解决的是"历史太长",缓存解决的是"每次都重发"。
Agent 的 prompt 有个特点:前缀高度稳定。系统提示词、工具定义、任务描述在整轮对话中完全不变,变的只是后面追加的历史。这正好适合用厂商提供的上下文缓存(我们用的通义千问的 context cache,DeepSeek 也有类似机制)。
启用方式很简单,但要保证前缀稳定:
// 反例:每次都把当前时间拼进系统提示词,导致前缀每次都变
String system = "你是运维助手。当前时间:" + LocalDateTime.now(); // 千万别这样
// 正例:静态部分放前面,动态部分放最后
String staticPrefix = SYSTEM_PROMPT + TOOL_DEFINITIONS; // 约 4200 token,可缓存
String dynamicPart = "\n当前时间:" + now + "\n历史:..." + history;
我们专门检查了一遍,发现有三处把动态内容混进了前缀(时间戳、随机 requestId、用户昵称),改掉之后缓存命中率从 31% 涨到 89%。
缓存命中的部分费用大概是原价的 1/10,这一项单独就把成本降了 34%。
优化三:模型分级
之前所有步骤都用 qwen-max,这是最大的浪费。我们重新按步骤类型分级:
| 步骤类型 | 原模型 | 新模型 | 理由 |
|---|---|---|---|
| 任务规划 | qwen-max | qwen-max | 需要强推理,不能省 |
| 工具选择 | qwen-max | qwen-plus | 准确率 91% vs 93%,可接受 |
| 结果判断(成功/失败) | qwen-max | qwen-turbo | 简单二分类 |
| 历史摘要 | qwen-max | qwen-turbo | 纯压缩任务 |
| 最终总结 | qwen-max | qwen-max | 面向用户,质量优先 |
分级前我们很担心准确率下降,所以做了 A/B 测试:200 个任务分别用全 max 和分级方案跑,人工评估最终结果质量。结果分级方案的平均得分 4.3/5,全 max 方案 4.4/5,差异在可接受范围,但成本降了 51%。
这个取舍的关键在于识别出哪些步骤真正需要强模型。我们的判断标准很简单:这一步需不需要多步推理?需要就用强模型,不需要就不用。
优化四:早停机制
针对那 11% 的长尾任务。我们加了三重保险:
public class StepLimiter {
private static final int MAX_STEPS = 25;
private static final int MAX_REPEAT = 3;
public boolean shouldStop(List<Step> history) {
if (history.size() >= MAX_STEPS) return true;
// 重复检测:最近 3 步是否调用了相同工具且参数相同
var recent = history.subList(Math.max(0, history.size() - 3),
history.size());
if (recent.size() == 3 && recent.stream()
.map(Step::signature).distinct().count() == 1) {
log.warn("agent loop detected, aborting");
return true;
}
return false;
}
}
还有一层是成本上限:单任务累计消耗超过 8 万 token 就强制停止,返回"任务过于复杂,请拆分或人工处理"。
早停触发率 4.2%,这些任务全部转人工。看起来是损失,实际上这些任务本来也跑不出结果——143 步那个任务最后返回的是一堆互相矛盾的操作记录,人工还得重做。
优化结果
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 单任务平均 token | 126,180 | 37,400 | -70% |
| 输入 token 占比 | 90.7% | 82.1% | — |
| 缓存命中率 | 31% | 89% | +58pp |
| 单任务成本 | ¥1.17 | ¥0.31 | -74% |
| 月度总成本 | ¥147,000 | ¥39,200 | -73% |
| 任务成功率 | 82.3% | 85.1% | +2.8pp |
| 平均耗时 | 94s | 61s | -35% |
有意思的是成功率还提升了。原因是上下文压缩让模型注意力更集中——原来 12 万 token 的上下文里,模型经常"忘记"任务目标或者重复执行已经做过的事。压缩到 3.7 万之后,这类问题明显减少。
耗时下降则主要来自模型分级:turbo 的响应比 max 快 2~3 倍。
几条经验
- Agent 的成本主要来自输入 token 的历史累积,不是输出。优化重点要放在减少每轮重发的上下文体积上;
- 先做 instrumentation 再优化。我们整个分析过程最花时间的是导出和拆解数据,没有审计日志这一步根本做不了。建议从第一天就把 token 消耗按任务 ID 记录下来;
- 摘要和压缩用便宜模型,别用主模型;
- 保证 prompt 前缀稳定,缓存才能命中。检查有没有时间戳、随机数、动态 ID 混在前缀里;
- 长尾任务的成本占比远超其数量占比,早停机制性价比极高;
- 模型分级要做 A/B 验证,别拍脑袋降配。我们的分级方案测试了三轮才定下来。
小结
Agent 和对话式 AI 的成本模型完全不同。对话的成本大致跟轮次线性相关,Agent 的成本跟步数是平方级关系——因为每一步的上下文都在增长。如果不做控制,20 步的任务成本是 10 步任务的 4 倍而不是 2 倍。
这次优化下来,我最深的体会是:Agent 的成本控制本质上就是一场上下文管理的战斗。谁能把每一步真正需要的信息精准投喂给模型,谁就能把成本压下来。而过程中意外收获是,压上下文不仅省钱,还能提升成功率和速度——因为噪音少了,模型反而更聪明。
最后提一句:成本控制应该在 Agent 设计阶段就考虑,而不是上线后救火。我们这次是账单来了才动手,如果一开始就把 token 预算写进架构,光是测量埋点就能省下两天。