上线一个自动处理任务,第二周被问起账单。查下来不是流量涨了:请求量平稳,单次请求的输入长度也平稳,但每个任务内部的模型调用次数从三次变成十几次——有些任务在工具调用之间来回试了七八轮才收敛。同一份流量,成本翻了三倍。
这是 Agent 与普通服务最本质的成本差异:普通服务按请求计费,Agent 按推理步数计费,而步数不可预知——请求数是可预测的输入,步数是运行时的产物。
1. 成本结构与普通服务的差异
| 成本组成 | 由什么驱动 | 可预测性 |
|---|---|---|
| 模型调用 | 步数 × 每步输入输出 token | 差,步数方差最大 |
| 工具调用 | 步数 × 工具单价 × 参数规模 | 中,回合数受任务影响 |
| 检索与重试 | 查询次数 × 重排深度;失败步数 × 重放成本 | 检索可缓存,重试尾部极长 |
关键差异有三条:
- 步数由内容决定,不由流量决定。同一个接口、同样大小的请求,任务难度不同则步数差一个量级。
- 输入 token 会随步数累积。多轮 Agent 的每一轮都可能带着历史上下文,若不做压缩,第 N 步的输入约等于前 N−1 步之和。
- 失败会放大步数。一次失败的尝试通常已经消耗了若干步和若干工具调用,重试是在这个基础上再加一轮。
所以"按请求估算成本"在 Agent 上是失效的。有用的单位是单任务成本分布:p50 与 p99 的差距通常在一到两个数量级,只看平均值会把长尾亏掉的部分藏起来。
2. 步数爆炸的两个原因
步数异常增长的根因只有两类,识别它们的方式不同:
| 模式 | 现象 | 根因 | 处理方向 |
|---|---|---|---|
| 任务分解失败 | 步数阶梯式上升,工具调用重复同一动作 | 目标不可达或描述含糊,模型反复尝试不同路径 | 改任务描述,收窄工具集,加前置可行性判断 |
| 错误恢复循环 | 步数在某一对工具之间来回振荡 | 错误信息没被正确理解,恢复策略固定不变 | 早停判据,失败两次即换策略或上报 |
两种模式在日志里的形状不同:分解失败是"缓慢爬升且工具种类发散",恢复循环是"两三个工具的高频往返"。用步数直方图看不出来,要看同一任务内的工具调用序列——把每个任务的工具名按顺序拼起来,统计重复子串即可区分:
─── 按任务聚合工具序列,找振荡模式:同一工具对在单任务内出现 3 次以上
jq -r 'select(.tool != null) | "\(.task_id)\t\(.tool)"' steps.jsonl \
| awk '{a[$1]=a[$1] " " $2} END {for (t in a) print t a[t]}' \
| awk '{n=split($0,f," "); for(i=2;i<=n;i++) if(f[i]==f[i-2]) {print $1" osc:"f[i]; break}}'
它的价值是把"成本高"归到两种病因之一,而不是笼统加更多上限——加上限只压成本、不修病因,成功率会一起掉。
限步数是唯一能在运行时止损的手段,但它与成功率直接对冲:
| 步数上限 | 成功率的典型影响 | 失效表现 |
|---|---|---|
| 远低于分布 p50 | 大量任务半途而废 | 返回"未完成"但已产生成本 |
| 略高于 p50 | 长尾任务被砍 | 复杂任务失败率陡升 |
| 约等于 p95 | 只砍真异常 | 成本上限可控,成功率损失小 |
| 不设上限 | 尾部成本无界 | 单个坏任务可吃掉当日预算 |
上限应当定在接近 p95 的位置,而不是"尽量小"。低于 p50 的上限不是节约,是把成本花在了没有产出的任务上——这是最坏的一种花法。同时必须区分两种终止:正常完成与达到上限被截断,前者计成功,后者必须单独计数,否则成功率会被虚高。
─── 成本上限写进任务定义,而不是事后从账单里发现
task:
name: invoice-reconcile
max-steps: 24 ── 按历史 p95 定,低于 p50 属浪费
max-tool-calls: 12 ── 工具调用单独设限,两者互不替代
max-tokens-per-task: 180000
on-limit-exceeded: report-partial ── 截断必须显式上报,不得静默返回
retry:
max-retries: 1 ── 重试同时消耗步数与工具配额
backoff-not-retry-same-error: true
3. 成本控制的四个层次
控制手段的副作用差异很大,应当按顺序使用,而不是一上来就压模型:
| 层次 | 手段 | 省下的 | 代价 |
|---|---|---|---|
| 1 | 收窄工具范围 | 减少无效尝试步数 | 任务能力下降,需要更明确的描述 |
| 2 | 限制步数与工具配额 | 砍掉尾部 | 复杂任务半成品 |
| 3 | 缓存中间结果 | 重复子任务的调用 | 缓存命中率依赖任务同质性 |
| 4 | 分支判断换小模型 | 每步单价下降 | 判断错误会把步数推高 |
顺序必须守住。先换小模型往往把成本推向另一方向:分支判断错一次就多走两三步,单价省下的被步数吃掉,总成本反升。正确顺序是先削无效步数(工具范围与描述质量),再设上限兜底,最后才考虑缓存与模型降级。
缓存的判据是任务同质性:大量任务前几步相同(同一份预处理、同一份检索)时价值高;任务高度独立时命中率低到不值得维护。键的选择决定成败——出现时间戳、请求 ID 或不稳定序列化字段,命中率直接归零:
─── 缓存键只由语义稳定字段构成,并显式暴露命中率
cache:
scope: step-result ── 缓存粒度是步骤结果,不是整个任务
key: [task_type, normalized_input_hash, tool_version]
exclude: [request_id, timestamp, trace_id] ── 这三类字段进键必零命中
ttl_seconds: 3600
metrics: [cache_hit_total, cache_lookup_total] ── 输出命中率,不只看调用数
4. 重试与可观测性
重试是最贵的失败模式,它同时消耗三样:步数(重放本身要步)、工具配额(工具调用再算一次)、上下文长度(历史带进下一轮,输入 token 继续累积)。一次失败任务的总成本通常不是单次的两倍,而是三到五倍。
因此重试策略要区分可重试与不可重试:网络超时、限流退避可以重试;同一错误连续两次必须停止,因为第三次大概率得到同样的结果,只是多花一笔钱。
除步数上限外还要一道单任务成本熔断:累计成本超阈值即终止并上报。它与步数上限不重复——步数少但每步都是长上下文的任务,成本可能远高于步数多的小模型任务。阈值按任务价值换算:若人工处理需十分钟,熔断线就该明显低于它,否则任务做成了也是亏的。
可观测性要落到"每步"而不只是"每任务",否则定位不了是模型贵、工具贵,还是步数多:
─── 每步一条结构化记录,任务结束再聚合成任务级
{"task_id":"T-8821","step":7,"model":"small-router","in_tokens":3120,"out_tokens":210,
"tool":"search_orders","tool_ms":840,"cost_units":0.42,"stop_reason":null,
"cache_hit":false,"retry_of":null,"elapsed_ms":1180}
任务级看四个分布就够:
| 指标 | 用途 | 异常信号 |
|---|---|---|
| 单任务成本 p50 / p95 | 成本基线 | p95 与 p50 差距扩大 |
| 步数分布(直方图) | 判断上限是否合理 | 大量任务堆积在上限处 |
| 重试率与重试放大倍数 | 失败代价 | 放大倍数超过 2 说明重放太贵 |
| 工具调用次数 / 任务 | 判断工具设计问题 | 单任务工具调用持续上升 |
| 成本归因覆盖率 | 无归因的成本管不了 | 低于 95% 时预算判断失真 |
"让它多想想"是隐性的成本决定。这类优化(增加反思轮次、扩大检索、提高最大步数)在指标上通常表现为成功率小幅提升,而成本提升是数倍的,且发生在长尾上——平均值看不出来,只有 p95 与 p99 会动。任何这类改动都必须同时给出成本增量的授权,否则等于悄悄改了预算。
─── 归因维度:没有归属的成本判断不了哪类请求在亏钱
attribution:
dimensions: [task_type, tenant_id, entry_point]
required: true ── 缺维度视为埋点缺陷,需告警
bucket: per-task ── 按任务归集,不按请求归集
budget:
monthly_limit_units: 90000
alert_at_ratio: 0.8 ── 到 80% 时告警,而不是到 100%
5. 排错、度量与边界
| 现象 | 最可能的原因 | 处理 |
|---|---|---|
| 成本翻倍但请求量未变 | 单任务步数上升 | 看步数直方图与工具序列,先排除循环 |
| 单个任务吃掉当日大部分预算 | 无步数上限或上限过高 | 设 p95 上限,加单任务成本熔断 |
| 成功率提升但账单涨得更快 | "多想想"类优化未计入成本 | 对改动做成本增量评估 |
| 重试后成本是原来的五倍 | 重放带上了完整历史上下文 | 重放时裁剪上下文,只带必要状态 |
| 回答不了"哪类请求在亏钱" | 缺归因维度或覆盖率低 | 补 task_type 与 tenant 维度 |
归因覆盖率要定量:任务在聚合时缺 task_type 或 tenant_id 就只能进"未归因"桶,这个桶会随埋点遗漏变大,表现为"总账单对得上但看不出谁花的"。未归因占比超过 5% 时先补埋点再谈预算,因为此时任何调整都建立在失真分布上。
边界很清楚:调用量极小(月成本低于一次优化的工时投入,直接看月度总额就够)、任务类型单一且稳定(同质化高,平均成本即可作为预测依据,无需分布)、成本绝对额远低于人工成本(单任务几分钱而人工处理要几分钟,优化的机会成本高于省下的钱)——这三种情况下,正式成本核算可以压缩成一次月度抽查,不需要每步埋点与归因维度。
反过来,一旦出现任一条就该正式化:有按任务或按租户的收费/结算、出现单个任务成本超过该任务价值的情况、调用量月增超过一倍、多个团队共用同一套模型配额。
6. 自测
- 为什么"按请求估算成本"在 Agent 上会失效?真正该看哪个统计量?
- 任务分解失败与错误恢复循环在日志里的形状差别是什么?
- 步数上限定在分布 p50 以下,为什么不算节约?
- 为什么"重试"的代价不是单次成本的两倍?它同时消耗了哪三样东西?
- 把分支判断换成小模型,为什么可能让总成本上升?
- 哪三个指标放在一起,才能判断"成本涨了"是模型贵、工具贵还是步数多?