你的 token 计数器有没有可能一直在给你报错数,而你看不出来?一位开发者把自己上线的 token 计数器拆开复盘,发现里面藏着两个 bug:第一个让工具彻底死掉,第二个让数字看起来完全合理。真正危险的是第二个。

第一个 bug:页面完美,工具从没跑起来

计数器是一段内联脚本,放在 WordPress 的 wp:html 块里,通过 REST API 保存。本地一切正常。到了线上,输入框渲染了,样式渲染了,FAQ 渲染了,打字却毫无反应。

原因出在 WordPress 会在内容到达浏览器前跑一遍 wptexturize。数据库里存的是字面的&&,渲染出的 HTML 里变成了&&,再经过第二遍处理又变了一次。在内联脚本里,HTML 实体不会被解码,于是if (e && e.message)被当成if (e && e.message)送进浏览器,构成语法错误。整个立即执行函数解析失败,什么都没初始化。

控制台检查、冒烟测试、截图,一个都没抓到,因为截图和正常页面长得一模一样。修复方式很朴素:这个站点的内联 JavaScript 里不出现裸的 &,用嵌套 if 代替 &&,必要时用 String.fromCharCode()。再加一条四秒钟的部署检查:

  • 用 curl 抓取页面并 grep 统计 & 的数量,输出不是 0 就说明部署坏了。
  • 同时 grep 一个只在工具启动后才存在的哨兵字符串,因为“HTML 合法”和“脚本跑起来了”是两回事。

第二个 bug:那个带波浪号的兜底数字

真正的分词器是懒加载的 import()。一旦失败——离线、CDN 被挡、浏览器拒绝加载模块——旧代码会做所有 token 估算器都会做的事:Math.ceil(chars / 4),然后在数字前面加一个波浪号。一个字符的诚实,罩着一个错得足够离谱的数字。

错到什么程度,是量出来的。他取了 10,000 条真实提示词,逐条分词:

  • 整批看没问题:2,715,542 个字符,646,092 个 token,平均 4.203 字符每 token,相对假设的 4.0 只偏了5.08%。如果一万次调用只付一张账单,chars / 4 完全够用。
  • 逐行看就不对了:每条提示词的中位数是 4.469 字符每 token,中位偏差+11.72%40.5%的单条提示词偏差超过 20%,从 p5 的 -23.9% 到 p95 的 +46.8%。
  • 短输入最糟:200 字符以下,43.6%偏差超过 20%;1500 字符以上只有4.8%
  • 内容类型的影响比任何单一变量都大:散文 4.58,代码 4.16,结构化文档(比如 OpenAPI、JSON 配置)3.78,同样一个输入框里,两类内容的差距达到 18%。

同一个估算器,在月度账单上准确到 5%,在四成的单条请求上错掉五分之一。用户付的不是整批,是自己那一行。

三处改动,按重要程度排序

  1. 绝不打印你没算过的数字。兜底逻辑还在,但它现在会说明自己是什么:分词器不可用、离线兜底已激活、仅显示四字符每 token 的估算,恢复联网后刷新可得到精确计数。会自我声明的估算才是工具,披着计数外衣的估算就是 bug。
  2. 把分歧摆出来。真正的分词器加载后,工具同时算出朴素估算并打印差值,让启发式不再藏在答案背后,而是坐在答案旁边。结构化数据上,你能在浏览器里亲眼看到它偏 20%。
  3. 如果非要估算,别再用一个常数。散文 4.58、代码 4.16、结构化文本 3.78,三个数字代替一个,你实际粘贴的内容误差带立刻收窄。这也是检验别人家成本计算器最便宜的办法:粘一段散文、粘一段 JSON 配置,如果两边返回的字符每 token 一样,那它就是在猜。

还有一个坑:如果管线在数据到达模型前做了 base64 编码,这一步会在分词发生之前把字节数抬高约33%,估算器就是在错误之上再错一层。先解码,再测真实的东西。

可以推广的部分

静默失败有两种形状。第一种是死掉:脚本解析失败,工具从未启动,页面看起来却已完成。冒烟测试能抓,一条 curl | grep 能抓,他没做,所以第一个 bug 上线了。

第二种是貌似合理:数字被算出来、被格式化,然后在四成行上错 20%。冒烟测试抓不到,类型检查抓不到,唯一能抓到的是你去拿来的真实基准——他这里是 10,000 条真实提示词——以及按行而不是按批打分的习惯。

此后他上线的每个估算器都要回答两个问题:失败时它打印什么,只是不确定时它打印什么。任何一个答案是“一个数字”,那就是第二个 bug 在等着发生。

如果你生产环境里有成本估算器,可以这样验一遍:取 100 条真实提示词,正确分词,数一数有多少条偏差超过 20%。他的猜测是你会落在 40 附近,而在你去看之前,你不会知道。