我这台机器上,一次本地模型调用最短 79 毫秒。Python 跑一圈循环 23 纳秒。
两个数一除,340 万。
这个数字让我手上好几件「优化」直接变成了笑话。最典型的一件:我一直以为把 system prompt 从 2 万 token 砍到 1.5 万能省不少时间。按实测单价算下来,省的时间还不够一次调用的零头。更丢人的是,那个让我相信「长 prompt 很贵」的数字,本身就是我除错了分母算出来的(第 4 章)。
只想要能拿走的除法,看第 3 章;想知道那种「每 token 多少微秒」的数字怎么骗人,看第 4 章。
1. 那张 2007 年的表,还能用吗
Jeff Dean 有张很出名的表,叫「每个程序员都该知道的延迟数字」:读一次 L1 缓存多少纳秒、读一次内存多少纳秒、分支预测失败多少纳秒。这张表是 2007 年的。
我好奇它过了 19 年还准不准,就在一台 Ryzen 5 5600(Zen 3,6 核,62GB 内存)上用 C++ 一项项重测了:
| 操作 | 2007 表 | 本机实测 | 怎么看 |
|---|---|---|---|
| L1 缓存读 | 0.5 ns | 1.13 ns | 原表偏理想 |
| L2 缓存读 | 3 ns | 3.43 ns | 准 |
| L3 缓存读 | 表里没有 | 15.55 ns | 得自己补上 |
| 主内存读 | 50 ns | 97.56 ns | 原表乐观了一倍 |
| 分支预测失败 | 5 ns | 5.078 ns | 19 年误差 1.6% |
| 无竞争锁 | 15 ns | 5.45 ns | 快了近 3 倍 |
| 顺序读 1MB 内存 | 64,000 ns | 47,286 ns | 单核约 22 GB/s |
分支预测失败那一行我看了好几遍,19 年了,差 0.078 纳秒。
结论挺朴素:这张表拿来估数量级照样能用,只要改两处。主内存乘 2,再自己补一行 L3。现在 L3 动不动 32MB,大数组估算不算这一层,会系统性地偏。
但问题不在准不准。问题在于,我平时干的活根本用不到这张表。
2. 我真正该背的那张表
我每天写的是 agent 流程、Python 脚本、数据管线。这些东西的时间单位是毫秒到秒,跟纳秒差了六个数量级。拿纳秒表去指导毫秒级的活,就像拿游标卡尺量马路。
所以我照着同样的思路,把我自己那一层的单价也测了一遍。模型那几行来自本地跑的一个中等规模模型,前面挂了一层兼容 OpenAI 接口的网关,都是预热过后的数:
操作 时间
─────────────────────────────────────────────
Python 循环一圈 23 ns
Python 字典查找 54 ns
Python 方法调用 69 ns
SQLite 索引点查(10 万行表) 7.4 µs
SQLite 1000 次独立点查 6.75 ms
SQLite 同样 1000 行一条语句查完 168 µs
SQLite 1000 次插入,每次都 commit 2.06 ms
SQLite 1000 次插入,最后 commit 一次 420 µs
起一个 /bin/true 子进程 583 µs
起一个 python3 子进程 24.9 ms
本机 TCP 建连 36.5 µs
─────────────────────────────────────────────
模型最短往返(1 token,已预热) 79 ms
模型解码 41.4 tok/s,约 24 ms/token
流式首字延迟 185 ms
换成同一把尺子,拿 Python 循环一圈当 1:
Python 循环一圈 1
字典查找 2
SQLite 点查 320
起 /bin/true 2.5 万
起 python3 108 万
模型调用一次 340 万
这里有个我自己的笔误,顺手认了:我最早的笔记里把「子进程」那行写成 2.5 万倍,那其实是 /bin/true 的数。起一个 python3 是 108 万倍,差了四十多倍。笔记里还有一句「起一次 python3 等于 1000 次 SQLite 点查」,一算是 24.9 ms ÷ 7.4 µs ≈ 3400 次。两处都是凭印象抄的,没当场除。
3. 第一个除法:次数乘单价,看谁是老大
Jeff Dean 那套方法的核心就三步:数一数每类操作做了几次,乘单价加起来,看谁占大头。
流程耗时 ≈ Σ(某类操作次数 × 它的单价)
就是一个乘法加一个加法,谈不上什么经验公式。
拿一个很普通的 agent 流程代进去:调 5 次模型、查 200 次数据库、起 3 次 python 子进程。
5 × 79 ms = 395 ms
200 × 7.4 µs = 1.5 ms
3 × 24.9 ms = 75 ms
────────────────────
合计 ≈ 471 ms
模型占 84%,子进程占 16%,数据库占 0.3%
这时候你去给数据库加索引、改查询,就算把那 1.5 毫秒优化到 0,整个流程也快不了 0.3%。
真正能动的是那个 5。合并两次调用,一下省 79 毫秒,顶你把数据库优化 50 遍。
这条在我自己身上应验过一次,代价挺疼。我有个模拟世界项目,每年自动让模型写一份年度总结。检查器有条规则:标题不许以「第 X 年」开头。模型偏偏爱这么写。检查器一看违规,退稿重写,重写又带前缀,又退……一口气连撞 8 次,烧掉 165 万 input token、42 分钟。
而每一份被退的稿子,删掉那几个字的前缀,标题完好,内容一个事实不少。删前缀是字符串操作,几微秒。每次重跑是一次满价模型调用。
中间差了多少,用上面那把尺子一眼就看得出来:退稿之前,先问一句「这个毛病能不能不调模型就修好」。能,就地修;修不了(编造事实、缺内容、逻辑错),才退稿重跑。
顺带一个配套的坑:「修」和「验」必须共用同一份规则。否则会出现检查器判违规、修复函数删不掉的死循环,每转一圈都是一次满价调用。
数据库那一层也有类似的形状,只是量级小得多:
- 1000 次独立点查 6.75 ms,一条语句查完 168 µs,40 倍
- 每次插入都 commit 2.06 ms,最后统一 commit 420 µs,4.9 倍
所以看到循环里在查数据库,立刻改;看到循环里在 .append(),别管它。列表推导换掉 append 我也测了,1.15 倍,不值得你花那十分钟。
4. 第二个除法:砍 prompt 到底省多少
这是我翻得最狠的一章。
我测预填充(模型读 prompt 的那个阶段)的时候,拿到的是这样一组数:
| prompt 长度 | 总耗时 | 总耗时 ÷ token 数 |
|---|---|---|
| 643 tok | 138 ms | 215 µs/tok |
| 5,194 tok | 128 ms | 25 µs/tok |
| 20,790 tok | 251 ms | 12 µs/tok |
我当时的读法是:「每 token 单价从 215 跌到 12,降了 18 倍,长 prompt 反而划算。」
听起来挺有道理,其实这个 18 倍几乎全是除法除出来的。
那一列是总耗时除以 token 数。总耗时里有一块跟长度无关的固定开销:发请求、排队、回包。我粗略拿那 79 ms 的最短往返当这块开销(它自己也读了一个很短的 prompt,这里忽略不计)。643 token 那一行,138 ms 里有 79 ms 根本不是在读 prompt,你拿它去除 643,当然除出一个吓人的单价。
先把固定开销减掉再除:
643 tok: (138 − 79) ÷ 643 ≈ 92 µs/tok
5,194 tok: (128 − 79) ÷ 5,194 ≈ 9.4 µs/tok
20,790 tok:(251 − 79) ÷ 20,790 ≈ 8.3 µs/tok
后两行基本持平了。那个「18 倍」大部分是固定开销被摊薄的假象,只有 643 那一行还偏高,短 prompt 下剩下的那点时间本来就小,误差被放大得厉害。
所以问「砍 prompt 能省多少」,该用的是边际单价,也就是两个长度点之间,每多一个 token 多花多少时间:
砍 prompt 省下的时间 ≈ 砍掉的 token 数 × 边际预填充单价
边际单价 = (长 prompt 耗时 − 短 prompt 耗时) ÷ (长度差)
拿我的数代进去:
5,194 → 20,790:(251 − 128) ÷ 15,596 ≈ 7.9 µs/tok
643 → 20,790:(251 − 138) ÷ 20,147 ≈ 5.6 µs/tok
砍 5000 token ≈ 5000 × (5.6 ~ 7.9) µs ≈ 28 ~ 40 ms
一次模型往返 79 ms。你辛辛苦苦把 system prompt 砍掉四分之一,省下的时间顶多半次调用。
我最早用平均单价 12 µs 算,得出的是 60 ms,已经觉得「不划算」。换成边际单价,更不划算。结论方向没变,但原来的数是错的。
这就是拆别人数字时我现在会先问的一句:看到「每 token 预填充多少微秒」,先问它减没减固定开销。 没减的话,短 prompt 那一头的数字会虚高好几倍,而且会让你误以为长度越长越划算。同一个毛病在「每请求多少毫秒」「每张图多少秒」里都有,只要是总耗时除以数量,都值得多看一眼。
5. 我自己测的时候踩的坑
这一章决定你要不要信前面的数。
冷启动差 50 倍。 我第一次测模型最短往返,拿到 3.99 秒。预热 3 次之后是 79 毫秒。第一次里包含了加载、图捕获、建连接这些一次性开销。要是我拿 3.99 秒去算第 3 章,结论会离谱到没法看。
编译器把我要测的东西优化没了。 测分支预测失败,第一版测出 0.01 纳秒。原因是编译器用 -O2 把 if/else 编成了一条无分支的条件移动指令,根本没有分支可以预测失败。修法是在两个分支里各插一个空的内联汇编屏障,再反汇编数条件跳转指令,确认它真的在。
有两个点我解释不了。 643 token 的预填充(138 ms)居然比 5,194 token(128 ms)还慢。另外我顺手测过一次前缀缓存命中,命中 216 ms,没命中 128 ms,命中反而慢。这两个异常我都没追下去,可能是那个后端的缓存和调度行为,也可能只是噪声。所以第 4 章那个边际单价我给的是一个区间,没敢给一个数。
每个数我都只报了一个值,没报方差。 没有多轮重复的分布,也没有置信区间。它们适合估数量级,不适合拿来比 10% 以内的差距。
这张表只属于这台机器、这个模型。 换显卡、换模型、换成云端 API,单价会整体移动。云端的最短往返通常比本地还高,那样第 3 章的结论只会更极端。你要用,最好在自己环境里重跑一遍。探针都是只读的,几分钟的事。
另外,我那个 Jeff Dean 原文里的快排估算也复现过:估 7.41 秒,实测 6.08 秒,差 22%。22% 听着不小,但估算本来就只为了判断哪一项是老大,这个精度够用了。
6. 下次遇到「这个慢,要不要优化」
- 先别打开 profiler。列出这个流程里每类操作各做了几次:几次模型调用、几次数据库查询、几次子进程、几次网络请求。
- 在自己机器上测一遍这几类的单价。模型那一项预热 3 次再测。
- 次数 × 单价,加起来,排序。
- 只优化排第一的那一项。如果排第一的是模型调用,手段是减少次数:合并调用、缓存结果、能不调模型就修的毛病就地修掉。
- 想砍 prompt 之前,用两个长度点算出边际单价,乘上要砍的 token 数,跟一次往返比一比。
- 看到任何「总耗时 ÷ 数量」的单价,先问固定开销减了没有。
- 自己笔记里的倍数关系,写下来那一刻就当场除一遍。我两处笔误都是这么来的。