适用读者:跑 agent 跑到账单心疼的人;以为成本等于「单价 × token」的人;想在不换模型的前提下把费用砍下来的人
结论先行:agent 的成本不是「单价 × token 用量」,而是「上下文策略 × 请求次数」。同一任务十倍差价,主要来自三个杠杆——前缀是否稳定、上下文是否被压缩、一次任务到底发了几次请求。NanoHermes 的五个预算常量里,有三个量的其实是字节而不是名字说的东西,而记忆注入每轮重写 system 消息,恰好把最该稳定的那一段变短了
阅读时间:约 55 分钟 | 难度:进阶 | 收获:一个可套用的成本公式 + 40 轮任务的算例 + 七个可验证的成本缺陷 + 一张按收益排序的调优清单
目录
- 前言:同一个任务,两份账单
- 结论先行:三个杠杆,一条反直觉
- 先把成本公式写出来
- 四种单位:字节、字符、token、美元
- 一个 40 轮的算例:二次曲线是怎么来的
- 一轮请求的完整字节账单
- 压缩的真正意义是截断二次曲线
- NanoHermes 的五个预算常量
CONTEXT_CHAR_BUDGET 的名字骗了你
- 十二万字节在中文里到底是多少
- 压缩由三个条件触发
- 保底十条:压缩收益的下限
- 上下文的三层天花板
- 压缩请求的输入没有逐条截断
- 压缩是投资还是亏损
- 压缩的三种替代方案
- 稳定前缀的第一段:system prompt
- 工具清单也在前缀里
- 项目文档两万字符:怎么拼、怎么截
- 记忆块每轮重写:缓存被打断的地方
set_block 把可变段放在 system 末尾
- 工具结果的上限,量的还是字节
- 两层截断:
max_output 与 max_chars
- 工具输出的分级策略
- 图片:base64 放大三分之一,而且没有上限
- 图片会被后续每一轮重发
max_tokens 与「截断重写」的隐性双倍成本
- 重试:整个 prompt 再发一次
- 轮次上限 40 与并发 8:最坏情况有多坏
- 子代理的成本模型:一次并行调研贵在哪
- 缓存命中率怎么测
- 缓存的五个前提
- 输出为什么比输入贵,而且会被收两次
- 三个反直觉结论
- 五个常见估算错误
- 一次任务的成本画像
- 一次优化实录:14 轮怎么降到 9 轮
- 五种「看起来省钱其实更贵」的做法
- 一张按收益排序的调优清单
- 三个一天内能做完的实验
- 预算常量该怎么定:五个数从哪来
- 什么该省,什么不该省
- 怎么把成本做成可回归的指标
- 小结
- 参考
前言:同一个任务,两份账单
先看一个真实的对比。同一个仓库、同一个需求(把一个 Go 服务里的三处错误处理改掉)、同一个模型,两次跑下来账单差了一个数量级。差别不在模型,也不在 prompt 写得好不好,而在三件事:
- 第一次跑在一个「每次请求都重建 system prompt」的客户端上,第二次跑在「system 前缀全程不变」的客户端上
- 第一次跑了 37 轮,第二次跑了 14 轮
- 第一次把
go.mod 和两个 800 行的文件全文塞进了上下文,第二次只塞了被改的那三段
如果把成本拆成「单价 × token」,你只能解释价格差;把成本拆成「前缀稳定性 × 上下文体积 × 请求次数」,你能解释十倍。
08 讲的是「账本」,这篇讲「有了账本之后怎么花钱」。两者其实是同一个问题的两面:不知道怎么计量,就谈不上优化;不做优化,计量出来的只是一张让人难受的报表。
结论先行:三个杠杆,一条反直觉
反直觉的那一条是:
前缀稳定性比模型选择更影响账单。同一个任务,用贵三倍的模型但保持前缀稳定,往往比用便宜模型但每轮重写 system 更省。因为模型单价是线性因子,前缀缓存是乘在「全部历史」上的折扣因子——而历史才是复利。
这条结论成立的前提是服务端真的支持前缀缓存。主流几家都支持,但触发条件各不相同(最小前缀长度、缓存存活时间、是否要求完全字节一致)。本文只讲「你能控制的那一侧」:别在请求前缀里放会变的东西。
先把成本公式写出来
抛开缓存折扣,一次任务的总成本是:
cost ≈ Σ_t [ P_in × (S + H_t) + P_out × O_t ]
S:稳定前缀,包含 system prompt、工具 schema、项目文档
H_t:第 t 轮开始时的历史消息(用户、助手、工具结果)
O_t:第 t 轮的输出
P_in / P_out:输入、输出单价
这个公式有三个值得盯的地方:
第一,
S 在每一轮都被重发。40 轮就是 40 次
S。如果
S 是三万 token,光前缀就烧掉一百二十万。
第二,
H_t 是累积的。第 1 轮只有一条用户消息,第 40 轮有几十条。这就是二次项的来源。
第三,
O_t 只算一次,但它会变成下一轮的
H 的一部分——输出既计费一次,又永久留在上下文里持续计费。
把公式改写一下,能更清楚地看到结构:
total_in ≈ N × S + Σ_{t=1..N} H_t
而
H_t ≈ d × (t - 1)(d 是每轮新增的平均 token),所以:
total_in ≈ N × S + d × N × (N - 1) / 2
第二项是
N² 的。这就是为什么长任务不是「贵一点」,而是「贵得离谱」。
四种单位:字节、字符、token、美元
聊成本最容易吵起来的原因,是四个人在用四种单位说话。它们的换算关系不固定,但在 agent 里有一条经验链:
从字节到 token 没有线性公式,但有稳定的区间:
英文约 4 字符 1 token,中文约 1 字 1 token,代码约 2 字符 1 token。这三条够你做量级估算,不够你做精确预算。
从字节到字符的换算更简单,但坑更深:JSON 序列化会把
\n 变成两个字节、
" 变成两个字节,
serde_json 对中文默认不转义(写 UTF-8),所以「消息序列化后的字节数」比「原文字节数」略大,比例取决于内容里有多少换行和引号。一份代码文件转成消息后,字节数通常涨 5% 到 15%。
这张表解释了一件很重要的事:
同一个数字,在不同层含义不同。
120_000 在
CONTEXT_CHAR_BUDGET 里是字节,在
MAX_PROJECT_DOCS 里也是字节,在
max_chars 里是字符,在
max_tokens 里是 token。四个「上限」,四种单位,其中三个的名字都在暗示自己是另一种。
看代码时养成一个习惯:看到
len() 就问「这是谁的 len」,看到
chars 就问「这里真的按字符算了吗」。本文后面七个缺陷里,有四个源于这一个疏忽。
一个 40 轮的算例:二次曲线是怎么来的
取一组保守的假设(只作算例,不是实测):
三种情形:
第一行和第三行差约 20 倍,这就是「同一任务十倍差价」的来源。第二行单独看:压缩没有动前缀那 120,000(40 × 3,000 一分没少),它改的是
d 的斜率——压缩后
H_t 从第 1 轮重新累积,原本 780 的三角和变成两段各 190 的三角和,二次项从 936,000 降到 456,000,总输入几乎减半。
压缩省下的全是历史,不是前缀。
如果缓存价是正常输入价的十分之一(各家不同,这里只作量级估计),第三行的计费等效值约为
3000 × 0.1 + 48000 ≈ 48,300 个「全价 token」,与第一行的 1,056,000 相比约 22 倍。这个数字大到不像真的,所以更要强调:
它是算例,不是账单。真实倍数会被缓存最小长度、缓存失效、输出占比等因素拉低,但方向不会变。
一轮请求的完整字节账单
把「二次曲线」落到具体的一轮上,看看请求体里到底装了什么。取第 20 轮为例:
合计约 272,000 字节。其中真正「新」的只有最后两行,不到 3,000 字节;
其余 269,000 字节都是重复内容。
这组数字说明三件事:
第一,
S(稳定前缀)在这里约 29,200 字节,比
H_20 小一个数量级——但在第 1 到第 5 轮,
S 是主体。所以「前缀优化」和「历史优化」的收益曲线交叉在第 5 到第 10 轮之间。
第二,工具 schema 的 7,000 字节是纯固定开销,它不随任务变化,却每轮都付。工具越多,这个固定成本越高——一个装了 30 个 MCP 工具的 agent,光 schema 就可能比系统提示词大。
第三,历史占了 88%。这就是为什么「每轮让历史失效」是最贵的错误:它把 88% 的内容从「缓存价」推回「全价」。
一个健康的请求应该让「重复部分」尽量落在缓存里,只让「新内容」按全价计。做到这一点,第 20 轮的计费等效值大约是
29,200 × 0.1 + 3,000,而不是
272,000。差两个数量级。
压缩的真正意义是截断二次曲线
02 讲压缩时,落点是「上下文是工作集,装不下就要丢」。从成本角度看,压缩的意义更硬:
它把 N² 的三角和切成若干段更小的三角和。
假设不压缩,40 轮的
Σ H_t 是 936,000。假设每 20 轮压缩一次,
Σ H_t 变成两段 20 轮的三角和:2 × (1200 × 190) = 456,000,直接减半。压得越频繁,二次项越小,但每次压缩本身要花钱(下一节算),所以存在一个最优点。
这就是「压缩不是免费午餐」的量化版本:压缩一次的成本 ≈ 一次大请求,收益 ≈ 后续每轮少发的历史。设压缩成本为
C,压缩后每轮省
h,还剩
k 轮,那么只有
k × h > C 时才划算。
对 40 轮的任务,这个不等式几乎总是成立;对 3 轮的任务,压缩纯属浪费。所以「什么时候压缩」不该只看上下文大小,还要看「还剩多少轮」——而这一点,NanoHermes 的触发条件里没有。
NanoHermes 的五个预算常量
整个项目的成本旋钮就这五个数字:
再加上
config.rs 里的三个默认值:
max_tokens: 4096、
temperature: 0.0、
max_iterations: 40。
这八个数字决定了「一次任务最贵能贵到哪」。下面逐个看它们的实际语义。
CONTEXT_CHAR_BUDGET 的名字骗了你
触发压缩的那一行:
let size: usize = self.messages.iter().map(|m| m.to_string().len()).sum();
if !force && (size <= CONTEXT_CHAR_BUDGET || self.messages.len() <= KEEP_RECENT + 1) {
return 0;
}
m.to_string() 是 serde_json 序列化,
String::len() 返回的是
UTF-8 字节数。常量名却叫
CONTEXT_CHAR_BUDGET。
在纯 ASCII 的英文对话里,字节数约等于字符数,这个名字不会害人。在中文对话里,一个汉字三字节;序列化还会把换行转义成
\n(两字节)、把引号转义(两字节)。于是「12 万字符的预算」实际只放得下大约 4 万个汉字。
这不是一个命名瑕疵,而是一个
可观测的成本偏差:你以为上下文装到 12 万字符才压缩,实际上 4 万字就触发了;反过来,如果你按「12 万字符」估算 token,会高估三倍,做出来的成本预测全错。
更麻烦的是它跟
max_output 一唱一和:工具结果的上限
20_000 也是用
s.len() 判断的,同样量的是字节。两个「字符」上限一起骗人三次(
clamp、预算、文档注入里
joined.len() 也是字节)。
十二万字节在中文里到底是多少
把字节换算成 token 需要知道分词器,但可以给一个保守区间:
注意代码那一行:代码的 token 密度比自然语言高(
fn main() { 这种符号密集的片段经常一个字符一个 token)。所以同一个 12 万字节的预算,在「大量贴代码」的场景里反而更早耗尽 token 配额。
结论很实用:
不要把「字符预算」当成「token 预算」来推算成本,两者能差两到三倍。真要估算,就按「中文 1 字 ≈ 1 token、英文 4 字符 ≈ 1 token、代码 2 字符 ≈ 1 token」这种粗口径分开算。
压缩由三个条件触发
compact_with 的触发条件写得很清楚:
if !force && (size <= CONTEXT_CHAR_BUDGET || self.messages.len() <= KEEP_RECENT + 1) {
return 0;
}
三个条件同时满足才动手:
- 总字节数超过 120,000
- 消息条数超过 11
- 不是
/compact 强制触发
第三个条件解释了为什么「手动压缩」在真实使用里很重要:自动压缩只在跨过阈值那一刻发生一次,之后历史继续增长,直到再次跨过阈值。如果一个任务在 100,000 字节附近来回震荡(每轮加一点、压缩一点),它可能永远不触发,也可能反复触发。
压缩的落点是:
let mut start = self.messages.len().saturating_sub(KEEP_RECENT);
while start < self.messages.len() && self.messages[start]["role"] == "tool" {
start += 1;
}
if start <= 1 {
return 0;
}
let dropped: Vec<Value> = self.messages.drain(1..start).collect();
start 从「倒数第 10 条」开始,然后跳过所有
tool 消息。这个跳过很关键:如果第 10 条正好是一个工具结果,直接从这里切会把「助手发起调用」和「工具返回结果」拆开,下一次请求就会因为消息不成对而报错。
代价是——
跳过会把压缩点往后推,实际丢弃的可能远少于预期。一次并发 8 个工具的轮次会产生 9 条消息(1 条助手 + 8 条工具结果),全都得留着。所以「保留 10 条」在工具密集的场景里,等于「保留一整轮还可能不够」。
保底十条:压缩收益的下限
压缩之后,上下文结构变成:
[0] system(含记忆块)
[1] system(摘要)
[2..] 最近 10 条消息原文
KEEP_RECENT = 10 是个保底:无论怎么压,这 10 条原文都在。如果这 10 条里有几条是几万字符的工具结果,压缩对总量的影响就很小——你压掉的是旧历史,留下的是最贵的那部分。
这就产生了一个反直觉的结论:
压缩对「工具输出很肥」的任务几乎无效。因为肥的部分在最近 10 条里。要让压缩真的有效,得先让工具输出不肥(下一节),或者让
KEEP_RECENT 按字节而不是按条数来算。
按条数保底还有另一个问题:10 条短消息和 10 条长消息的差别是几十倍。一个「读文件 + 改文件 + 跑测试」的轮次,三条消息可能就 30,000 字节;一个纯闲聊的轮次,十条消息可能只有 2,000 字节。同一个
KEEP_RECENT,两种含义。
上下文的三层天花板
「上下文满了」这句话在 agent 里有三个完全不同的意思,混在一起就会做错优化:
三层的关系是:窗口最大,预算次之,钱包最小。设计得好的系统应该让「预算」明显小于「窗口」,留出余量给一次超大的工具输出;同时让「钱包」有一个软上限,在超支前给出警告。
NanoHermes 有前两层,没有第三层。
/cost 是事后读数,不是预算控制。这意味着一个跑飞的任务可以毫无提示地烧掉一天的额度——它不会撞墙,只会让你的信用卡撞墙。
补第三层不需要复杂机制:给
UsageTotals 加一个
max_session_tokens,在每轮开始前检查,超了就中断并提示。实现大约二十行,价值是「可控的失败」优于「不可控的账单」。
压缩请求的输入没有逐条截断
summarize_messages 把被丢弃的消息拼成一段文本:
excerpt.push_str(&format!("{role}: {}\n", content));
然后直接交给
llm::summarize:
let turn = chat(&summarizer_cfg, &messages, &Value::Array(vec![]), |_| {})?;
没有逐条截断,没有总长上限(
dropped 的总量本身受 120,000 字节预算约束,但那已经是「接近满」的意思)。也就是说,一次压缩请求的输入,可能和一次普通请求一样大,甚至更大——因为它把被丢弃的历史全塞进去了。
而它的输出被 system 提示词限制在 300 词:
Use at most 300 words. Reply with the summary only.
300 词大约 400 到 500 token。于是这笔交易是:
用几万 token 的输入,换四五百 token 的输出,换来后续每轮少发几万字节的历史。
这笔交易在长任务里划算,在短任务里纯亏。NanoHermes 判断「长/短」的唯一依据是上下文大小,不看剩余轮次——所以一个「最后一轮刚好超预算」的任务,会花一次大请求去做一次再也用不上的压缩。
压缩是投资还是亏损
把上一节写成不等式。设:
C:压缩请求的成本(≈ 被丢弃历史的 token 数 × P_in)
h:压缩后每轮省下的 token(≈ 被丢弃历史 − 摘要长度)
k:压缩后还会发生的轮数
则压缩划算的条件是:
代入算例:一次压缩丢掉了 80,000 token 的历史,摘要 500 token,所以
h ≈ 79,500;
C ≈ 80,000(输入)+ 500(输出)。那么
k × 79,500 > 80,500,只要
k ≥ 2 就划算。
反过来说,如果一次压缩只丢掉 8,000 token 的历史(比如刚过阈值就压),
h ≈ 7,500,
k 需要大于 11 才划算。而「刚过阈值就压」恰恰是最常见的情况——因为触发条件就是「刚超过 120,000 字节」。
这个不等式还揭示了一个设计上的机会:
压缩阈值不该是一个固定值,而该是「剩余轮次」的函数。快结束的任务不必压,刚开始的任务压得越早越赚。
压缩的三种替代方案
压缩(摘要)只是「让上下文变小」的一种手段,而且是最贵的一种——它要发一次大请求。另外两种在特定场景下更划算:
第三种最被低估。NanoHermes 已经有
todo_write 和任务板:如果每轮都把「已完成 / 待办 / 当前卡点」写进结构化清单,那么旧消息就可以硬截断——清单本身就是压缩后的状态,而且比模型写的摘要更准。
从成本看,
todo_write 一次调用几百 token,摘要压缩一次几万 token,差两个数量级。代价是你得在设计上让「状态」和「历史」分离——这正是 07 那条「共享必须有归属」的另一个版本:
状态要有一个比对话历史更可靠的载体。
实践中的组合是:短任务硬截断,长任务摘要,多步骤任务结构化。NanoHermes 只有摘要一种,而且不看任务类型。
稳定前缀的第一段:system prompt
system_prompt 的拼接顺序是固定的:
You are NanoHermes, ... <- 固定文本
Operating rules: ... <- 固定文本
Workspace root: {root} <- 每个工作区不同,但进程内不变
Mode guidance: ... <- 随权限模式变,进程内通常不变
Additional instructions ... <- 用户人设,进程内不变
Project guidance (AGENTS.md) ... <- 启动时读,进程内不变
这个顺序是「从最稳定到最不稳定」,对前缀缓存是友好的:同一进程内,这一段完全不变。
再看
system_content,在它后面又拼了两段:
system.push_str("\n\n=== Project context (NANOHERMES.md) ===\n");
system.push_str(text.trim());
...
system.push_str(&crate::skills::listing(skills));
NANOHERMES.md 是项目上下文,技能列表是可用技能的清单。两段都是启动时读一次,进程内不变。
所以到这一步为止,前缀是稳的。真正的问题在下一节:有一个每轮都在变的东西,被塞进了同一个 system 消息。
工具清单也在前缀里
build_tools() 每次请求都重新拼一遍工具列表:
let mut list = tools::specs().as_array().cloned().unwrap_or_default();
...
if let Some(m) = &self.mcp {
list.extend(m.all_tools());
list.extend(mcp_aux_tools());
}
工具 schema 是跟着每一个请求走的,它在服务端的计费口径里通常算输入 token(有些服务商单独计,但都在请求体里)。一个内置工具集大约 1,500 到 2,000 token,加上 MCP 工具可能翻倍。
关键在于
顺序稳定性。
list.retain(...) 会按权限模式过滤掉一些工具,MCP 工具按连接顺序追加。只要这个顺序在一个进程内不变,schema 部分就是稳定前缀的一部分;一旦顺序抖动(比如 MCP server 重连后换了顺序),缓存就会从抖动点失效。
这是「零成本优化」里最容易被忽略的一条:
工具列表要排序后再发。按名字排序不影响模型能力,但能让不同会话之间的前缀一致——跨进程的缓存共享就靠这个。
项目文档两万字符:怎么拼、怎么截
project_docs 从文件系统根一路读到工作区:
let mut chain: Vec<&Path> = workspace.ancestors().collect();
chain.reverse();
for dir in chain {
for name in DOC_NAMES {
candidates.push(dir.join(name));
}
}
先读
~/.nanohermes/AGENTS.md,再从根目录往下读每一级的
AGENTS.md,最后拼接。顺序设计是对的——越具体的规则越靠后,越有「覆盖」的语义。
截断也很克制:
let mut joined = parts.join("\n\n");
if joined.len() > MAX_PROJECT_DOCS {
let mut end = MAX_PROJECT_DOCS;
while end > 0 && !joined.is_char_boundary(end) {
end -= 1;
}
joined.truncate(end);
joined.push_str("\n… project guidance truncated");
}
注意
is_char_boundary 那个循环:按字节截断时必须回退到字符边界,否则会在 UTF-8 中间切断,产生非法字符串。这个细节是对的。
但
MAX_PROJECT_DOCS = 20_000 同样是按字节算的(
joined.len()),所以一个中文的
AGENTS.md 实际只能注入约 6,600 字。更值得注意的是成本:这两万字节
出现在每一次请求里。40 轮任务就是 80 万字节的项目文档输入。
如果
AGENTS.md 写了两万字,它对账单的贡献和「多跑 20 轮」差不多。这是「文档越详细越好」这个直觉的成本反例。
记忆块每轮重写:缓存被打断的地方
run() 的第一件事不是发请求,是重写 system 消息:
let user_text = memory::content_text(&content);
if let Some(dir) = self.ctx.memory_dir.clone() {
let block = memory::retrieve_block(&dir, &user_text);
self.set_memory_block(&block);
}
retrieve_block 按当前用户输入检索记忆(03 讲过:BM25 + 中文二元切分,取 top 4,上限 2,400 字符)。检索结果取决于这一轮的输入,所以
每一轮的 block 都可能不同。
set_memory_block 把它写进
messages[0]:
if let Some(system) = self.messages.first_mut() {
if system.get("role").and_then(|r| r.as_str()) == Some("system") {
if let Some(text) = system.get("content").and_then(|c| c.as_str()) {
let updated = memory::set_block(text, block);
system["content"] = json!(updated);
}
}
}
于是在每一轮请求发出之前,第一条消息的内容都会变。
set_block 把可变段放在 system 末尾
set_block 的做法是:找到旧 block 的标记区间,删掉,再把新 block 追加到末尾:
base.replace_range(start..end, "");
let base = base.trim_end().to_string();
...
format!("{base}\n\n{block}")
对这个函数本身,实现是对的:删除再追加,不会越积越多。
但对
前缀缓存来说,这是最坏的一种布局。前缀缓存按 token 从头匹配,只要某一位置变了,从那个位置往后全部失效。可变段放在 system 的末尾,意味着:
- 稳定部分(内置提示词、模式、项目文档、技能列表)仍然命中
- 从记忆块开始往后的一切——包括摘要、之前所有轮次的对话——全部变成「新 token」
换句话说,
每轮重检索记忆,等于每轮把整个对话历史重新计费一遍。而历史正是二次项的主体。这个设计用 2,400 字符的记忆换来「历史全部失效」,在长任务里是净亏。
正确的做法是把可变段移到
最后一条用户消息之前,作为一条独立的 user 消息(或者 system 消息追加在末尾),让
messages[0] 保持字节不变。代价是模型看到的「指令位置」变差一点,收益是历史能命中缓存。
这是一个典型的「局部正确、整体有害」的设计:
set_block 没错,
retrieve_block 没错,「每轮重检索」也没错(03 里我们夸过它不积累),三个正确的决定叠在一起,把最贵的那个优化点弄没了。
工具结果的上限,量的还是字节
dispatch 的最后一行:
r.map(|s| clamp(s, ctx.max_output))
clamp 的实现:
if s.len() <= max {
return s;
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}\n… output truncated ({} chars omitted)", &s[..end], s.len() - end)
三个观察:
第一,
s.len() 是字节,
max_output = 20_000 也是字节。改一个中文文件,
read_file 返回的内容 6,600 字就触顶了。
第二,提示文案写的是
chars omitted,但算的是字节。对中文用户来说,这个数字会偏大三倍——用户看到「省略了 40,000 字符」,实际可能只省略了 13,000 字。
第三,截断发生在 dispatch 层,也就是
所有工具共享一个上限。
read_file、
grep、
shell、
web_fetch 都用同一个 20,000 字节。对
grep 来说这个上限合理,对
read_file 来说太狠——读一个 800 行的文件,很容易在中途被切断,模型只好再读一次。
而「模型只好再读一次」就是成本:一次截断,换来一次额外请求,而这次请求还要重发全部上下文。
两层截断:max_output 与 max_chars
web_fetch 有自己的上限:
let max_chars = args
.get("max_chars")
.and_then(|v| v.as_u64())
.unwrap_or(20_000)
.clamp(500, 200_000) as usize;
也就是说,
web_fetch 可以返回最多 200,000
字符的结果,然后被
dispatch 的
clamp 截到 20,000
字节。
两层上限,两套单位,两个默认值恰好都是 20,000。这不是巧合,而是同一套「按字符想问题、按字节写代码」的习惯在两个模块里各自长出来的结果。
后果是:
web_fetch 里那个
max_chars 参数对超过 20,000 字节的部分毫无作用。用户传
max_chars: 50_000 想多要一点内容,实际拿到的还是 20,000 字节。参数存在,但被外层覆盖了——这是「有配置无效果」的典型。
同时
MAX_RESPONSE_BYTES = 2 * 1024 * 1024 决定了下载上限:先下载最多 2 MB,再转文本,再按
max_chars 截断,再按 20,000 字节截断。四层漏斗,每一层单位都不一样。
工具输出的分级策略
既然「一个工具结果会活 k 轮、成本是
b × k」,那么工具设计的核心问题就变成:
该让模型看见多少,才既够用又不复利。
按这个标准,工具分成三档:
第三档最需要设计。
read_file 其实已经有
offset /
limit(默认
limit = 2000 行),所以「分页」在参数层是成立的。问题出在默认值与截断的配合:
limit 按行计,
clamp 按字节截,读一个中文文件时常常是 2,000 行还没到、20,000 字节先到,返回的就是「半页 + 一行 chars omitted」。更糟的是
read_file 自己的尾部提示(
… N more lines)在
clamp 之前生成,一旦被截掉,连「还剩多少行」都看不到,模型只能猜一个 offset 再读一次——一次截断又变成一次请求。
合理的修法是让
read_file 按字节预算反算行数,并在尾部直接给出
next_offset:模型不需要猜,也就不需要重读。
apply_patch 的回显也值得注意。一次成功的改动只需要返回「改了哪几行」,不需要把整个文件回显一遍。如果回显整个文件,那 20,000 字节里 95% 是模型已经知道的内容——纯浪费,而且会占掉
KEEP_RECENT 的 10 个名额之一。
这条给出一句可操作的判据:
工具结果里,重复模型已知信息的比例越低越好。读文件是必要的信息,回显整个文件不是;grep 的行号是必要的,匹配上下文超过三行就不是。
图片:base64 放大三分之一,而且没有上限
image_part 的体量问题比文本更严重:
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(json!({
"type": "image_url",
"image_url": { "url": format!("data:{mime};base64,{b64}") }
}))
没有大小检查。一张 5 MB 的截图,base64 之后约 6.7 MB。这个字符串会进入
messages,然后:
- 在第 1 轮请求里发一次
- 在第 2 轮请求里再发一次(因为它在历史里)
- 一直到压缩把它丢掉为止
一个 20 轮的视觉任务,一张 5 MB 截图要发 20 次,约 134 MB 的上传量。即使服务端按图片 token 计价(而不是按 base64 字符),请求体大小、网络时间、失败重试的代价也都是实打实的。
MAX_ATTACHED_IMAGES = 4 只限制了张数,没有限制字节。四张 5 MB 的图,单次请求 27 MB——很多网关的请求体上限在 10 MB,这时会得到一个 413,然后按 08 的分析,这次失败还不进账。
一个合理的实现应该在
image_part 里做三件事:检查文件大小、超限就缩放(而不是拒绝)、记录缩放的字节数。三件事都不难,难的是第一件——你得先意识到「张数上限不是成本上限」。
图片会被后续每一轮重发
这条单独说,因为它是「一次性成本」和「持续成本」的分界。
文本工具结果也一样:一次
read_file 的 20,000 字节,会留在历史里,直到被压缩掉。所以工具结果的真实成本不是「一次」,而是「一次 × 它还能活多少轮」。
设一个工具结果有
b 字节、还能活
k 轮,它的成本是:
一个 20,000 字节的工具结果活了 20 轮,等于 400,000 字节的输入——比它原始大小的 20 倍。这就是为什么 04 强调「输出都要有上限」:上限不只是为了让模型不被淹没,更是为了不让它复利。
推论很实用:
越早出现的大结果越贵。第 1 轮读进来的大文件要背 40 轮,第 39 轮读进来的只背 1 轮。所以「先概览、后精读」不只是认知策略,也是成本策略。
max_tokens 与「截断重写」的隐性双倍成本
max_tokens: 4096 是输出上限。它省的是输出钱(
P_out 通常是
P_in 的几倍),但会带来一种隐形成本:
输出被截断后,任务往往要重来一次。
截断在 agent 里的显形方式有好几种:
三种情况的共同点是:失败发生在
输出侧,而重试要重发
输入侧。于是「省下的输出 token」被「重发的输入 token」吃掉,通常还倒贴——因为输入比输出大得多。
这条给了一个反直觉的调参建议:
如果任务以工具调用为主,max_tokens 应该调大而不是调小。因为工具调用的参数可能很长(一个 patch 就是几百行),截断一次的成本远高于多留 2,000 token 的预算。
重试:整个 prompt 再发一次
llm::chat 的重试是「重建 body、再发一次」:
let mut attempt: u32 = 0;
loop {
match chat_with_model(cfg, build_body(&cfg.model), &mut on_text) {
Ok(turn) => return Ok(turn),
Err(e) => {
if is_retryable_error(&e) && attempt < cfg.max_retries {
attempt += 1;
let delay = backoff_ms(cfg.retry_base_ms, attempt);
...
continue;
}
build_body 每次都从头构造请求体,包含完整的
messages。所以一次 503 触发的重试,成本约等于「再跑一次这一轮」。
如果
max_retries = 2,最坏情况下这一轮的成本是 3 倍输入。而 429(限流)通常不计费,503 是否计费取决于服务端——客户端不该假设「失败就免费」。
这也是 08 里那句「一次成功掩盖 N 次请求」的成本版本:账本上只记一次成功,账单上可能是三次。
更隐蔽的是 fallback:
if let Some(fallback) = &cfg.fallback_model {
if is_retryable_error(&e) {
return chat_with_model(cfg, build_body(fallback), &mut on_text);
}
}
降级模型通常更便宜,所以「降级」在钱上是好事。但如果降级模型的能力不够,任务会多跑很多轮——
便宜的单价 × 更多的轮数,最后可能更贵。这就是下一节要说的反直觉结论之一。
轮次上限 40 与并发 8:最坏情况有多坏
三个与成本直接相关的上限:
最坏情况:40 轮 × 每轮 8 个工具结果 × 每个 20,000 字节。理论上的上下文是 6.4 MB——当然到不了,因为 120,000 字节就触发压缩了。但压缩前的那一轮,上下文可能是「120,000 字节 + 8 × 20,000 字节」= 280,000 字节,因为压缩发生在轮次之间,不在工具结果之间。
这是一个真实的成本尖峰:
一个并发工具轮次可以瞬间把上下文顶到预算的两倍。而这一轮发出去之后,下一轮就要为这 280,000 字节付钱。
子代理的成本更隐蔽:4 个子代理 × 40 轮,每个都发自己的完整上下文。上限是 160 次请求,全部不计入主账(08 讲过)。从成本角度,这意味着一次「并行调研」可能是主任务的 10 倍开销,而你在
/cost 里只看到一个零头。
子代理的成本模型:一次并行调研贵在哪
把子代理拆开算。每个子代理自带一份 system prompt(继承父配置的模型、工具、系统提示),所以它的
S 和主代理接近;但它没有父代理的历史,它的
H_t 从零开始。
设一次委派跑
m 轮,每轮增量
d,则子代理的输入是:
m × S + d × m × (m - 1) / 2
四个子代理并行,总输入是四倍。取
m = 15、
S = 3,000、
d = 1,000:
而父代理自己的
/cost 只会显示最后那 150,000,除非把子代理的账归集上来。这就是 07 说「归集到发起它的人」、08 说「
&self 记不上账」的成本后果:
你看到的数字只有真实开销的五分之一。
更要紧的是这个模型的形状:子代理的成本随「委派次数 × 每个子代理轮数²」增长。所以「让四个子代理各跑 40 轮」不是一个并行优化,是一个四十倍的账单。
一个实用的上限策略:给每个子代理单独的
max_iterations(比如 12),而不是继承父代理的 40。这个改动在 07 的加固清单里出现过,从成本角度它比任何缓存优化都值钱——因为它砍的是二次项。
缓存命中率怎么测
如果服务端返回
prompt_tokens_details.cached_tokens,你就能算命中率:
hit_rate = cached_tokens / prompt_tokens
NanoHermes 现在不读这个字段(08 讲过),所以命中率是 0——不是「真的没命中」,而是「不知道」。这两者的区别在优化时是致命的:你会以为缓存没用,然后放弃它。
在没有
cached_tokens 的情况下,还有一个替代指标:
同一前缀的重复请求占比。做法很土但有效——把每次请求的
messages 序列化成字符串取哈希,统计「前缀相同(前 N 条消息完全一致)」的请求比例。这个比例是命中率的上界。
三个数字能说明问题:
第一个指标如果很低,问题一定在「有个可变段被放进了前缀」——按本文的排查顺序,先看 system 消息的尾部。
缓存的五个前提
前缀缓存不是「发一样的 prompt 就一定命中」。它有五个前提,任何一个不满足都会静默失效:
这五条解释了为什么「本地测试命中率很高,线上很低」:本地连续跑、prompt 一样、模型一样;线上并发、间隔长、有时切了模型。
第三条和第五条在 agent 里特别容易踩:
/model 换一次模型,整段前缀的缓存就没了;fallback 到另一个模型时同理。所以「换模型」这个操作的真实成本不止是新模型的单价,还包括「丢掉已有缓存」。
一个可操作的检查顺序:先确认前缀长度够(把 system 加长到阈值以上),再确认字节一致(对两次请求做 diff),再看时间间隔,最后才怀疑服务商。绝大多数「缓存没用」是第二条。
输出为什么比输入贵,而且会被收两次
主流定价里,输出单价通常是输入的 3 到 5 倍。原因不神秘:输出是逐 token 生成的,每个 token 都要一次前向计算;输入是可以并行的。
对 agent 来说,这个差价被放大了一次:
输出会变成下一轮的输入。一个 400 token 的回答,先按输出价收一次,然后作为历史的一部分,在后续每一轮按输入价再收一次。
所以一次任务的输出成本是:
Σ_t [ P_out × O_t + P_in × O_t × (N - t) ]
第二项才是大头。一个第 5 轮产生的 400 token 输出,会在第 6 到第 40 轮被重发 35 次。按
P_out = 4 × P_in 算,这笔输出的总成本约等于
4 × 400 + 400 × 35 = 15,600 个输入 token 的等效值——是它「标价」的 39 倍。
这给了一个很实用的推论:
啰嗦的中间输出比啰嗦的最终输出贵得多。模型每轮说一句「我现在要读文件了,因为……」,这句话会在剩下所有轮次里重复计费。system prompt 里那句「Do not over-narrate」不只是体验优化,它是一条成本指令。
三个反直觉结论
第一,便宜模型可能更贵。 便宜模型的指令遵循和工具调用准确率通常更差,表现为「多跑几轮」「重复读同一个文件」「参数格式错」。单价降 5 倍、轮数涨 3 倍,总价只降一点;如果涨到 6 倍,反而更贵。判断标准不是单价,是「完成同一任务的 token 总量」。
第二,更长的上下文不一定更贵(有缓存时)。 前缀命中之后,历史里重复的部分按缓存价计,通常只有原价的十分之一。所以「每次把完整历史发过去」在有缓存时并不像看上去那么可怕——可怕的是「每轮改一个字节」。
第三,重试的代价可能高于失败本身。 一次 503 触发的重试,成本约等于再跑一轮;而
max_retries = 2 让最坏情况变成 3 倍。所以「重试次数」是一个成本参数,不只是可靠性参数。
三条合起来是一句话:
成本优化的对象是 token 流,不是单价表。
五个常见估算错误
在实测之前,大多数人会先用「感觉」估成本。五种感觉最容易错:
第一,按「每次对话」估,而不是按「每轮」估。 一次对话可能只有 1 轮,也可能 40 轮。按对话估会严重低估长任务——而长任务才是成本的主体。
第二,把「上下文大小」直接当「输入 token」。 120,000 字节的中文上下文约 4 万字,比 12 万字符的直觉小不了多少,但 token 数取决于分词器,代码密集时能到 6 万。差两倍起步。
第三,忘了输出会重复计费。 很多人只算
P_out × O,不算它作为历史被重发的部分。对长任务,后者是前者的几十倍。
第四,以为重试是免费的。 429 通常不计费,但 5xx、超时、流中断都可能计费。更关键的是,即使不计费,重试也会拉长任务、增加轮数。
第五,忽略子代理。 一次委派的账不进主账,这既是观测缺陷,也是估算缺陷。四个子代理跑 15 轮的成本,可以是主代理的四倍。
把这五条反过来,就是一张估算清单:按轮算、按字节换算 token、算输出的二次计费、给重试留 3 倍余量、把子代理乘进去。
一次任务的成本画像
如果 08 的账本做出来了,你可以在任务结束后打出一张这样的画像:
{
"task": "修掉三处错误处理",
"turns": 14,
"by_who": {
"main": { "requests": 12, "input": 186_000, "output": 9_400 },
"agent-3": { "requests": 18, "input": 271_000, "output": 7_100 },
"compact": { "requests": 1, "input": 78_000, "output": 480 }
},
"by_kind": {
"model": { "span": 31, "ms": 214_000 },
"tool": { "span": 46, "ms": 61_000 },
"hook": { "span": 92, "ms": 3_400 }
},
"cache": { "reported": true, "hit_rate": 0.71 },
"top_tools": [
{ "name": "read_file", "calls": 19, "avg_input": 11_200 },
{ "name": "grep", "calls": 12, "avg_input": 1_300 }
]
}
这张画像能回答四个问题,而
/cost 一个都答不了:
- 钱花在谁身上(主代理 186k,子代理 271k——子代理才是大头)
- 钱花在哪种动作上(模型 214 秒,工具 61 秒,钩子 3.4 秒)
- 缓存到底有没有用(命中率 0.71,说明前缀基本稳定)
- 哪个工具最贵(
read_file 平均每次 11,200,是 grep 的 8 倍)
最后一行最有行动价值:如果
read_file 平均每次 11,200 字节、被调 19 次,那它就是「默认分页」这个优化的第一目标。优化的顺序应该由画像决定,而不是由直觉决定。
一次优化实录:14 轮怎么降到 9 轮
把前面的方法串起来走一遍。这是一个修 bug 的任务,原始画像:14 轮、主代理输入 186,000、
read_file 19 次、
grep 12 次。
第一步,找出「最贵的一类动作」。 read_file 19 次、平均 11,200 字节,合计 212,800 字节,比主代理的全部输入还多。原因是模型在读大文件时被
clamp 截断,截断后的结果里又没有「下一段从第几行开始」,它只好换个 offset 盲试一次。
第二步,只改一处。 让
read_file 按字节预算反算安全行数,并在返回值末尾写明
next_offset。没有改模型,没有改系统提示词。
第三步,重跑同一个任务。 结果:
三件事同时改善,而且它们互为因果:轮数少了,历史短了,缓存命中率也高了。
第四步,确认能力没掉。 用同一批任务复跑,看最终 diff 是否一致。这一步很重要——如果轮数降了但改动质量掉了,省下来的钱是假的。
这个实录说明一件很朴素的事:
大多数成本问题不是「用贵了」,而是「用多了」。19 次读同一个文件不是模型笨,是工具逼它这么做的。工具的粒度决定了模型的轮数,模型的轮数决定了账单。
所以成本优化的第一现场不是计费面板,是工具描述。
五种「看起来省钱其实更贵」的做法
第五行值得展开。记忆注入的 2,400 字符确实占地方,但它省下的是「模型重新问你叫什么、你的项目用什么框架、上次那个 bug 修到哪了」。一次重复提问就是一轮请求,而一轮请求的输入是几万字节。
2,400 换几万,怎么算都划算——前提是它别放在会打断缓存的位置。
一张按收益排序的调优清单
按「改一行代码能省多少」排序:
前两条几乎零风险,可以先做。第三到第七条需要重新定阈值,要配测试。第八条依赖账本。
三个一天内能做完的实验
如果你不想改代码,也想验证本文的判断,三个实验就够。它们都不需要看服务商的后台,只需要把请求体记下来。
实验一:把 system 消息固定,看请求字节数的变化。
做法:临时把
set_memory_block 调用注释掉,跑同一个 10 轮任务两次(一次带记忆、一次不带),记录每轮请求体的字节数。预期结果是请求字节数几乎不变(记忆只有 2,400 字节),但如果你能拿到
cached_tokens,会发现命中的字节数暴涨。这个实验证明「成本变化不体现在请求大小上,体现在缓存命中上」。
实验二:把 read_file 的默认分页改成按字节预算。
做法:让模型读一个 1,500 行的中文文件。现在默认
limit = 2000 行会先撞上 20,000 字节的
clamp,而截断后的结果里没有「下一段从哪开始」,模型很可能再盲读一次。改成按平均行长反算行数、并在尾部给出
next_offset 之后,两次 8,000 字节的定向读取取代了一次截断加一次盲读。预期总字节下降 20% 到 30%,轮数下降 1 轮。这个实验证明「截断不是省,是延期支付」。
实验三:把 max_tokens 从 4096 调到 8192,看失败率。
做法:跑十个包含大 patch 的任务,统计「
invalid JSON arguments 错误」出现的次数。预期 4096 时会有一两次截断,8192 时为零,而总输出 token 几乎不变(因为没截断时输出长度本来就不变)。这个实验证明「输出上限调小」是个伪优化。
三个实验的共同点是:
不需要真实账单,只需要请求体的字节数和轮数。这两个数字在本地就能拿到,比等月度账单快得多,也比凭感觉可靠得多。
预算常量该怎么定:五个数从哪来
NanoHermes 的五个常量看起来像拍的,其实都能从「一条历史消息的典型大小」推出来。推导顺序是:先量,再定。
先量三个数:
有了这三个数,五个常量就有依据了:
max_output:取工具结果的 P95 再留一点余量。P95 是 18,000 时定 20,000,这个取值与观测吻合,是合理的
KEEP_RECENT:至少保留「一整轮」的消息。一轮最多 1 条助手 + 8 条工具结果 = 9 条,所以 10 是合理的
CONTEXT_CHAR_BUDGET:要让压缩后的上下文仍然容纳得下 S + 若干轮。若模型窗口是 200k token,用 120k 字节(约 40k token)作预算,留了五倍余量
MAX_PROJECT_DOCS:项目文档属于 S,它的大小应该明显小于 S 的总量。20,000 与 29,000 的比例是 0.7,其实偏高
MAX_ATTACHED_IMAGES:由请求体上限和单图体积决定。四张 5 MB 图已经 27 MB,超过多数网关的 10 MB,所以这个数需要配合单图上限才有意义
最后一个数字暴露了推导链的缺口:
MAX_ATTACHED_IMAGES 是按「张数」定的,缺了「单图字节上限」这个前置约束。
只定张数不定体积,等于没定。
这套推导方法可以套到任何 agent 上。核心思想是:每个上限都应该能回答「它对应的是哪个观测量」,答不出来的上限就是拍的,而拍的上限迟早会在某个真实任务上暴露。
什么该省,什么不该省
成本优化有一条隐形的边界:省到某一步,能力会掉。掉在哪里,比省了多少更重要。
读这张表的方式是看第三列:
如果砍掉它会直接消灭一项能力,就不该砍;如果只是让模型多跑一轮,就该砍。工具 schema 和系统提示词属于前者,历史与叙述属于后者。
还有一个反直觉的:记忆注入看着像「锦上添花」,其实是最划算的一项。2,400 字节换掉「模型重复问你一遍项目背景」,而重复问一次的代价是一整轮请求。按前面的公式,一轮请求的输入是几万字节。所以这项的投入产出比大概是 1:20。
省钱省到记忆头上,是最常见的错误优化。
怎么把成本做成可回归的指标
成本最容易退化的地方不是「某天突然翻倍」,而是「每次加一个功能,涨 5%」。这种退化只有回归测试能抓住。
做法:用
e2e_mock_llm 那套 mock 端点,跑一个固定的任务脚本,断言「请求次数」和「总字节数」不超过基线:
#[test]
fn a_fixed_task_costs_no_more_than_the_baseline() {
// 同一个脚本化任务:读一个文件、改两处、跑一次测试。
let (agent, requests) = run_scripted_task();
assert!(agent.usage().requests <= 6, "轮数膨胀");
assert!(requests.iter().map(|r| r.len()).sum::<usize>() <= 180_000, "上下文膨胀");
}
注意这里断言的是「请求次数」和「请求体总字节」,不是 token——因为没有真实服务端。字节数与 token 强相关,作为回归指标足够灵敏。
再加一条前缀断言:
#[test]
fn the_system_prefix_does_not_change_between_turns() {
// 同一个 agent 连跑两轮,第一轮之后的 messages[0] 必须逐字节一致。
assert_eq!(snapshot_after_turn(1), snapshot_after_turn(2));
}
这条测试会立刻失败——因为记忆块每轮重写。它失败得越早越好:这正是我们想让它暴露的成本缺陷。
小结
把这篇压成四句话。
第一,成本是「前缀 × 历史 × 轮数」,不是「单价 × token」。 单价是线性因子,历史是二次项。一个 40 轮任务的输入 token 大约是
N × S + d × N² / 2,第二项才是大头。
第二,压缩的价值是把二次曲线切成几段小曲线。 它不是为了「装得下」,而是为了把
N² 变成若干个更小的
N²。所以压缩频率有个最优点,由「压缩成本 vs 剩余轮次的节省」决定,而不是由「阈值多少」决定。
第三,前缀缓存是最大的单点优化,而它最怕「每轮改一个字节」。 NanoHermes 把每轮重检索的记忆块放在
messages[0] 的末尾,等于每轮让整段历史失效。这是本文最值得改的一处,也是「三个正确决定叠成一个错误」的典型案例。
第四,三个上限都叫「字符」,量的都是字节。 CONTEXT_CHAR_BUDGET、
MAX_PROJECT_DOCS、
max_output,在中文场景里全都比名字许诺的小三倍。这不会让程序出错,但会让所有成本估算和截断行为偏离直觉——而观测的第一要求是「数字的含义和你以为的一致」。
下一篇写评测:有了账本、懂了成本,下一个问题就是「你怎么知道改动让它变好了」。答案不是跑分,是一批可复现的失效样本。
参考
agent/src/agent.rs:CONTEXT_CHAR_BUDGET、KEEP_RECENT、MAX_ATTACHED_IMAGES、MAX_PARALLEL_TOOL_CALLS、max_output、compact_with、summarize_messages、set_memory_block、build_tools、execute
agent/src/prompt.rs:system_prompt、project_docs、read_head、MAX_PROJECT_DOCS
agent/src/llm.rs:chat、build_body、summarize、backoff_ms、max_tokens
agent/src/tools/mod.rs:clamp、dispatch
agent/src/tools/web.rs:max_chars、MAX_RESPONSE_BYTES、FETCH_TIMEOUT_SECS
agent/src/memory.rs:set_block、retrieve_block、BLOCK_START
agent/src/main.rs:build_content、image_part
agent/src/config.rs:max_tokens、temperature、max_iterations、max_retries、retry_base_ms