前八篇里,token 这个词反复出现:
max_tokens会预扣额度、重试会放大成本、上下文太长会导致回答退化。这一篇专门把这个数讲清楚——怎么算准它,以及预算不够的时候该砍哪一段。
一、为什么必须自己数
大多数人是等到账单超出预期,才开始关心 token。但有几件事,不数就一定会踩:
输入成本会随对话轮次递增。 第五篇讲过,多轮对话的本质是每次把历史重发一遍。也就是说第一轮的输入是一句话,第十轮的输入是九轮对话加一句话。输出长度基本稳定,输入却在不断膨胀。 长对话里,输入成本反超输出成本是常态,而不是例外。
max_tokens 的预扣会造成误解。 第三篇提过:很多厂商按你声明的 max_tokens 做额度预扣,而不是按实际用量。你写 max_tokens = 8000,即使这次只生成 50 个 token,也可能先占 8000 的额度。高并发下就会出现"明明没发几个请求,却报额度不足"——原因是你自己把额度占住了。
上下文超限的表现不一致。 有的实现直接报错,有的悄悄从中间截掉一段。后者最麻烦:请求成功了,但模型看到的历史缺了一块,回答莫名其妙。这类问题只能靠主动控制长度来避免,不能等着报错。
所以数 token 不是为了省钱,它是"让行为可预测"的前提。
二、估算还是精确:两条路各有代价
估算:按字符数乘一个系数。中文大致按字符数估,英文按 4 字符约 1 token,代码介于两者之间。
优点是快——纯本地计算,零开销,可以放在请求入口做粗筛。
缺点是对中文、代码、混合文本的偏差很大,而且是系统性偏移,不是随机误差。这意味着它不会"平均下来差不多",而会稳定地偏某个方向,导致你按估算留的余量在某些场景下根本不够。
精确:用厂商提供的计数接口或 SDK 来算。
优点是准。缺点是多一次调用开销(有网络往返),高频场景下这个开销不能忽略。
务实做法是分场景用:
- 入口处粗筛用估算——判断"这个请求大概是不是太大了",快速拒绝明显超限的输入;
- 真正决定截断位置时用精确值——这里估错一点,就可能导致请求被上游拒绝,或者截多了丢信息;
- 记录成本时用接口返回的
usage(真实值),不要用估算。
一句话:估算用来快速判断,精确值用来做决定,usage 用来记账。
三、中文和代码为什么特别难估
这是本文最需要动手验证的一段,因为"偏差系数"这件事没有通用答案。
几类差异的来源:
- 中文:一个汉字常常不是 1 个 token。常用字可能 1 个 token,生僻字、专业术语、专有名词可能被切成 2~3 个。所以"字数乘 1"会低估。
- 代码:缩进、括号、运算符、下划线命名,切分方式极其不规律。一个长变量名可能被切成好几段,一段 JSON 可能比同样字符数的自然语言多出不少 token。
- 标点与空白:换行、缩进、连续空格都可能单独占 token。
- Emoji 与特殊符号:往往占多个 token,用字符数估会严重低估。
这些差异加起来,会让同一个"字符数"在不同内容上对应完全不同的 token 数。 所以下面这个方法值得真跑一次。
四、测出你自己的偏差系数
拿三段内容各测一次,对比估算值与精确值:
- 一段纯中文(比如 500 字的产品说明)
- 一段代码(比如 200 行的 Java 文件)
- 一段混合文本(中文里夹杂英文术语和代码片段)
精确值用厂商的计数接口拿。以通义千问的 OpenAI 兼容接口为例:
curl -s https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "qwen3.8-max",
"max_tokens": 1,
"messages": [{"role": "user", "content": "<把你的一段测试文本放这里>"}]
}'
把返回体里的 usage.prompt_tokens 记下来,就是这段文本的精确输入 token 数。
(说明:官方也提供专门的 token 计数接口或 SDK 方法,比上面这种"发一次请求只为看 usage"的做法更省。具体接口名以你所用平台文档为准。)
然后估算用一段本地代码:
public final class TokenEstimator {
/**
* 粗糙的 token 估算,仅用于入口粗筛。
* 中文按 0.7 个字符/token 估,英文与符号按 4 字符/token 估。
* 真实系数请用第四节的测量方法自己标定。
*/
public static int estimate(String text) {
if (text == null || text.isEmpty()) {
return 0;
}
int cjk = 0;
int other = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
// CJK 统一表意文字基本区与扩展区
if ((c >= 0x4E00 && c <= 0x9FFF)
|| (c >= 0x3400 && c <= 0x4DBF)
|| (c >= 0xF900 && c <= 0xFAFF)) {
cjk++;
} else {
other++;
}
}
return (int) Math.ceil(cjk / 0.7) + (int) Math.ceil(other / 4.0);
}
}
跑完你得到的是一张对照关系:同一段文本,估算是多少、精确是多少、偏差百分比是多少。
这张表就是你后续所有预算计算的依据。 别用别人给的系数——它取决于你的内容构成,别人的系数放在你的语料上可能就是错的。
一个必须接受的结论:任何估算都会有偏差,所以预算里必须留余量。 这也是下一节的内容。
五、预算怎么分配
关键认知:上下文窗口是输入和输出共享的。 窗口不是"输入上限",而是"输入加输出的总额度"。你多带一段历史,就少一截输出空间。
所以预算要从窗口里倒着算:
可用输入额度 = 窗口大小 − max_tokens − 安全余量
安全余量必须留,建议 10%~15%。 三个理由:
- token 估算本身有偏差(上一节刚测过);
- 部分实现在内部会加结构性的 token(角色标记、分隔符),你的估算里没有;
- 你偶尔会漏算某个字段。留余量比事后排查"为什么偶尔超长"要省事。
举例说明这个计算为什么重要。假设窗口是 32K,你设 max_tokens = 4000,那么:
可用输入 ≈ 32000 − 4000 − 4000(余量) = 24000
这 24000 才是你带历史的上限,而不是 32K。如果按"窗口 32K"去塞输入,一旦输入接近 32K,输出就没有空间了,回答会被立刻截断——表现是"它突然只说了半句话就停了"。
六、超长了砍哪一段
历史必然有一天会超出预算。砍在哪,直接决定回答质量。第五篇讲过策略(滑窗、摘要压缩、按重要性挑),这里补上按 token 计的落地要点。
第一,用 token 数判断,不要用消息条数。 三条短消息和三条长消息,长度可能差十倍。按条数截必然出现时好时坏。
第二,别把成对结构截断。 这是最容易踩的坑:
user和assistant要成对删除,否则会出现连续两条user,或者历史以assistant开头;- 用了工具调用时,
assistant消息里的tool_calls和紧随其后的tool结果消息必须一起保留或一起删除。
从中间硬切会让结构不合法,部分接口直接拒绝请求。
第三,从最老的一轮开始删,system 永远保留。 system 是全部规则的来源,删掉之后模型行为会突然不受约束。
第四,超长输入先摘要,不要硬截。 如果用户粘贴了一份很长的文档,硬截会切掉后半部分的信息,模型基于残缺内容给出自信的错误答案。更稳的做法是先让模型对文档分段摘要,再用摘要去做真正的任务。 多花一次调用,但避免了"看起来在答,其实在编"。
第五,截断位置尽量落在语义边界。 按 token 数算出的位置可能正好切在一句话中间。往前退到最近的句子或段落结束处,读起来更完整,模型理解也更好。
七、流式下的 usage 要显式开启
第二篇提过这一点,这里给完整处理方式。
流式响应默认不返回 usage。如果你在做成本统计,这个数丢了就等于记账断了。开启方式是在请求里加上:
String body = """
{
"model": "qwen3.8-max",
"stream": true,
"stream_options": {
"include_usage": true },
"messages": [
{
"role": "user", "content": "用三句话介绍杭州西湖"}
]
}
""";
开启后,usage 会在最后一个数据块里给出。所以读取循环里要单独处理一下:
Usage usage = null;
// 在读取每个数据块时
JsonNode node = mapper.readTree(data);
if (node.has("usage") && !node.get("usage").isNull()) {
usage = mapper.treeToValue(node.get("usage"), Usage.class);
}
// 流结束后落库
if (finished && usage != null) {
recordCost(usage.getPromptTokens(), usage.getCompletionTokens());
}
有两点要注意:
- 不是所有兼容实现都支持这个参数。 接之前先用一个真实请求试一次,确认
usage真的回来了,别默认它有。 - 流中断的情况下拿不到
usage。 因为它在最后一块里。这时候要么按估算记账,要么就不计——但要明确选一个,别让成本统计里出现大量静默缺失的记录。
八、成本预估该怎么做
有了准确的 token 数,成本预估就变成了简单的乘法。但它有几个容易被忽略的系数:
- 输入和输出分开计价,而且输出通常更贵。所以"减少输出"和"减少输入"的收益不一样,要分别看。
- 重试会放大成本。 第四篇算过:重试最多 2 次的情况下,最坏消耗是请求数的 3 倍。而且重试的是失败请求,用户什么都没拿到。做预估时要把这个系数算进去。
- 多轮对话的输入是累积的。 不能按单轮输入乘轮数,那样会低估。
- 是否开启了思考/推理模式会影响输出 token,同一句话可能生成数倍的 token。如果你的模型有这个开关,预估时要单独看待。
一个够用的估算模板:
单次成本 ≈ (输入 token × 输入单价) + (输出 token × 输出单价)
月度成本 ≈ 单次成本 × 日请求量 × 30 × 重试系数 × (1 + 余量)
余量建议留 20%~30%——因为用户的实际用法一定比你预估的更费。
九、小结
把这一篇收成几句可操作的:
- 必须自己数:输入随轮次递增、
max_tokens会预扣、超限可能被静默截断 - 估算与精确分工:粗筛用估算、决定截断用精确值、记账用
usage - 偏差系数自己测:中文、代码、混合文本各测一次,别用别人的系数
- 预算倒着算:可用输入 = 窗口 −
max_tokens− 10%~15% 余量 - 截断守规则:按 token 不按条数、成对删除、保留
system、超长先摘要 - 流式记 usage:显式开启,中断时要有明确处理
最后一句判断标准:如果你说不出自己当前请求的输入大概占了多少 token,那这个系统的成本和行为就都是不可控的。
下一篇
token 能算准之后,下一个话题是并发。单请求跑通了,把它放进一个每秒几十次调用的服务里,会冒出三类新问题:连接池、线程池、上游配额。还有一个很隐蔽的坑——线程池复用时 ThreadLocal 没清理,导致数据串到别的请求上。下一篇讲并发与限流。
本文是「用 Java 接入大模型」系列第九篇。上一篇讲了提示词的模板化与版本管理,建议按顺序看。文中 token 估算系数与预算比例为实践起点,真实数值请按第四节的方法自行标定;具体接口与参数以你使用的平台文档为准。