Administrator
发布于 2025-06-13 / 4143 阅读
61

Agent 落地的成本控制:一次真实的账单分析

财务拿着一张 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,2003.3%
用户原始任务描述1800.1%
历史步骤累积77,40061.4%
工具返回结果累积39,10031.0%
模型输出(各步合计)5,3004.2%
合计126,180100%

单个任务 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-maxqwen-max需要强推理,不能省
工具选择qwen-maxqwen-plus准确率 91% vs 93%,可接受
结果判断(成功/失败)qwen-maxqwen-turbo简单二分类
历史摘要qwen-maxqwen-turbo纯压缩任务
最终总结qwen-maxqwen-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 步那个任务最后返回的是一堆互相矛盾的操作记录,人工还得重做。

优化结果

指标优化前优化后变化
单任务平均 token126,18037,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
平均耗时94s61s-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 预算写进架构,光是测量埋点就能省下两天。

参考