大模型的 Token 到底怎么算?一篇讲清
上个月我的 Agent 已经能跑通 demo,上线后账单直接翻了 5 倍。查下来问题很朴素——他每次都把完整对话历史无脑塞回去,平均单次请求带 4 万多 token。我在想:「我也不知道为什么这么贵,代码就是照着教程写的。」
这就是我想写这篇的原因。做 AI Agent 开发,大模型教程能带你跑通 demo,但决定你成本和稳定性的,永远是那 4 个底层概念。我们把它们一次讲透。
一、Token:计费的真实单位
先说最反直觉的一点:你花钱买的不是字数,是 token。
token 是分词器把文本切出来的片段。它不等于一个字,也不等于一个词。我用tiktoken(OpenAI 的公开分词库)实测了一组数据:
| 类型 | 样本 | 字符数 | token 数 | 每字成本 |
|---|---|---|---|---|
| 中文 | 大模型的上下文窗口不是越大越好 | 15 | 18 | 1.20 |
| 英文 | The context window of an LLM is not always better | 49 | 11 | 0.22 |
| 代码 | def cosine_similarity(a, b): return a @ b | 41 | 11 | 0.27 |
看第三行和第一行的差距:中文每字要 1.2 个 token,英文只要 0.22。同样 200 个词,英文 201 token,中文 600 token,差了近 3 倍。
再看更狠的。「上下文窗口」这 5 个字,被切成了 6 个 token:
'上' -> b'\xe4\xb8\x8a' '下' -> b'\xe4\xb8\x8b' '文' -> b'\xe6\x96\x87' '�' -> b'\xe7\xaa' ← 碎片 '�' -> b'\x97' ← 碎片 '口' -> b'\xe5\x8f\xa3'窗这个字被劈成了两个半截 token。为什么?因为分词器用的是字节级 BPE,一个汉字在 UTF-8 里占 3 个字节,词表里没有「窗」这个完整条目,就只能拆。
这件事的实际影响:**你的 Prompt 越「口语化」、越堆大白话,token 越贵。**给文档做摘要、抽取、分类时,写精简的指令词比写长句子更省钱。
二、温度与采样:输出不是模型说了算
很多人以为大模型的输出是确定的——同样的输入,输出必然一样。这是幻觉之外的第二个幻觉。
模型内部给每个候选 token 打一个分数(叫 logits),然后按概率抽样。temperature这个参数,就是把 logits 除以一个系数:
- T < 1:分布变尖锐,更倾向于选最高分,输出更稳
- T = 0:退化成贪心,永远选最高分,完全确定
- T > 1:分布变平,输出更多样,但更容易跑偏
我写了一段不调 API 的演示,直接拿一个手写概率分布看效果:
importmath,randomfromcollectionsimportCounterdefsoftmax(logits):"""数值稳定的 softmax:先减最大值,避免 exp 溢出"""m=max(logits)exps=[math.exp(x-m)forxinlogits]s=sum(exps)return[e/sforeinexps]deftemperature_scale(logits,temperature):"""温度就是给 logits 除一个系数,不改别的"""return[x/temperatureforxinlogits]defsample(probs,rng):"""按累积概率抽一个下标"""r=rng.random()acc=0.0fori,pinenumerate(probs):acc+=pifr<=acc:returnireturnlen(probs)-1tokens=["北京","上海","广州","深圳","杭州","南京"]base_logits=[3.0,2.4,1.2,1.1,0.4,0.3]# 假装是模型内部算出的分数forTin(0.0,0.5,1.0,2.0):ifT==0.0:probs=[1.0ifi==0else0.0foriinrange(len(tokens))]else:probs=softmax(temperature_scale(base_logits,T))# 关键:rng 必须放在循环外创建。# 若写成 sample(probs, random.Random(42)) 嵌在推导式里,# 每轮都会新建一个同种子的 rng,抽出来的永远是同一个数,概率全失真。rng=random.Random(42)counts=Counter(sample(probs,rng)for_inrange(2000))pct=[counts.get(i,0)/20foriinrange(len(tokens))]print(f"T={T}: "+" ".join(f"{t}:{p:.1f}%"fort,pinzip(tokens,pct)))实测 2000 次采样的结果:
T=0.0: 北京:100.0% 上海:0.0% ...(完全确定) T=0.5: 北京:73.3% 上海:22.1% 广州:2.0% ... T=1.0: 北京:48.4% 上海:29.2% 广州:8.3% ... T=2.0: 北京:32.1% 上海:24.1% 广州:13.6% 深圳:13.1% 杭州:8.8% 南京:8.3%规律很清楚:T 越低越集中,T 越高越分散。
top_p(核采样)是另一个正交的旋钮,它不管分布形状,只做截断——按概率从大到小累加,保留刚好到 p 的最小集合,其余直接置零:
deftop_p_filter(probs,p):idx=sorted(range(len(probs)),key=lambdai:-probs[i])kept,cum=[],0.0foriinidx:ifcum>=p:breakkept.append(i)cum+=probs[i]out=[0.0]*len(probs)foriinkept:out[i]=probs[i]s=sum(out)return[v/sforvinout]ifs>0elseout# 截断后必须重新归一化实测:top_p=0.3只留北京;0.6留北京+上海;0.9留四个。上面那句「重新归一化」很关键,漏掉它,总概率不足 1,很多采样器会直接报错。
实践建议:做信息抽取、分类、格式转换这类要可复现的任务,温度给 0;做文案、起名、头脑风暴,温度往上调。评测场景尤其必须锁 0,否则你永远测不出模型改进没有。
三、上下文窗口:别把预算全花在历史
上下文窗口是模型一次能看进去的 token 总量。这个数字现在动辄 128K、200K,但窗口大,不等于塞满就好。
我算过一笔账,假设窗口 128K:
系统提示词 800 token █ 工具定义 3,000 token █ RAG 召回 12,000 token ███ 历史对话 40,000 token ████████████ 输出预留 4,000 token █ 合计 55,800 / 128,000,剩余 68,200 ✓这里暴露了那个读者的典型问题:历史对话吃掉 40K,占了窗口的 31%,而真正需要模型回答的信息可能只有最后两轮。
除了贵,更麻烦的是准。塞满窗口会触发「注意力稀释」——信息越杂,模型对关键指令的遵循度越差。你会看到一个很迷惑的现象:把知识库全塞进去,答案反而不如只塞 5 条准。
知识库 B5 记忆系统那篇给了几个可直接用的经验值:
top_k=3–8 条:超过 8 条,模型会忽略其中大部分- 相似度阈值 =0.30–0.40:低于阈值就不注入,省 token 也减少干扰
- 注入预算≤ context 的 10%:128K 窗口对应约 12.8K token
还有一条我特别想强调:召回后要按「最近更新」再排一次序。否则三条互相冲突的旧记忆会一起挤进 top-3,模型只能随机采信一个——这是幻觉的常见来源之一,而且极难排查。
四、KV Cache:延迟从哪来
前三个概念管成本,这个管延迟。
LLM 是自回归生成的:一次吐一个 token,每吐一个都要把前面的全看一遍。如果每次都从头算,生成 n 个 token 就是 O(n²)。KV Cache 的做法是把已经算过的 Key/Value 缓存下来,用空间换时间,把 O(n²) 压到 O(n)。
这就是为什么同样一个 Prompt:
- 首 token 延迟(TTFT)慢 → 瓶颈在「读进去」,也就是 prefill 阶段
- 后续每个 token 都慢→ 瓶颈在显存带宽或缓存没命中
但 KV Cache 是把双刃剑。知识库 B2_05 那篇讲得很直接:它既是加速的关键,也是显存的主要占用者,直接限制了你能开多大窗口、能并发多少请求。这也是我上面说「窗口大不等于该塞满」的底层原因。
调试时的实用做法:
- 缩短输入 → 看 TTFT 是否下降,能判断是不是 prefill 瓶颈
- 固定相同前缀(如系统提示词)→ 提高缓存命中率
- 用流式输出 → 体感延迟大幅改善,用户不等全部生成完
五、常见坑清单
- 坑 1:用字符数估算成本—— 表现:账单比预期高 2-3 倍,中文场景尤其明显。解法:上线前用
len(enc.encode(text))实测,别用len(text)。 - 坑 2:评测时没锁温度—— 表现:同一模型两次跑分差好几个点,你以为模型有提升,其实是随机波动。解法:temperature=0,且固定测试集版本。
- 坑 3:只调 temperature 不调 top_p—— 表现:调低温度后,长尾内容里出现莫名其妙的话。解法:两个一起调,
top_p管截断长尾。 - 坑 4:无脑全量塞历史—— 表现:Agent 多轮对话后变慢变贵,答案质量还下降。解法:历史做摘要 + 只保留最近 N 轮原文。
- 坑 5:截断后忘重新归一化—— 表现:
top_p截断后采样器抛异常或概率和不足 1。解法:截断后除以剩余概率和。
六、小结
- Token 是计费单位,中文比英文贵 3 倍。学的时候:写 Prompt 尽量精简,中文长文先做切片再送。
- 温度决定输出的随机性。要可复现就锁 0,要创意就往上调。这条在做评测时是及格线。
- 上下文窗口要有预算思维。历史摘要 + RAG 只取 top 3-8 条,别拿窗口当仓库。
- KV Cache 决定延迟。首 token 慢看输入长度,逐 token 慢看显存和缓存命中。
- 各个厂商的Credits计费不固定Credits 没有固定的 Token 换算比例,1 个 Credits 不等于固定数量的 Token。Credits 是按模型分档抵扣系数动态计费的,单次消耗由模型类型、Token 用量、思考模式及工具调用等因素共同决定。有可能是 1 Credits = 1 Token / 倍率(如 x0.09)等