开场:一个会写代码、却数不清字母的天才
有段时间网上流行一个梗:问大模型"strawberry 这个单词里有几个字母 r?",很多模型会信心满满地回答"2 个"。
我当时也试了一下,确实答错了。然后我又让它把 agentfromzero 倒过来写,它写出来的东西少了一个字母,顺序也乱了一截。
这就很奇怪了。同一个模型,十秒钟前刚帮我写完一个带并发控制的 Go 函数,现在连一个十个字母的单词都数不清?
后来我才明白:它不是笨,是它根本"看不见"字母。 在模型眼里,strawberry 不是 s-t-r-a-w-b-e-r-r-y 十个字母,而是两三个叫做 token 的"块"。就像你看一个汉字"莓",你不会下意识地数它有几笔,你看到的就是一个整体。
这一篇开始进入原理篇。原理篇的任务,是把模型的"怪行为"一个个还原成机制。第一站就是 token:模型眼里的世界,是由 token 组成的。读完本篇,你应该能回答这几个问题:
- token 到底是什么?文字是怎么被切成 token 的?
- 为什么同样的意思,中文和英文消耗的 token 不一样?
- 为什么模型数不清 strawberry 里的 r,也不擅长把字符串倒过来写?
- 为什么计费和上下文上限都按 token 算?
- 怎么知道一段 prompt 到底有多少 token?
一、模型不认识字,只认识编号
1.1 从文字到数字
第 01 篇给过大模型的"函数签名":
func LLM(context []Token) Distribution输入是一串 Token,不是一串字符。那 Token 是什么?最直接的回答是:一个整数编号。
模型有一张很大的"词表",里面有十几万到几十万个条目。每个条目是一小段文本,可能是一个完整的词(the、function),可能是一个词的一部分(straw、ing),可能是一个汉字或几个汉字(模型、的),也可能是一个标点、一个空格、甚至半个汉字的字节。每个条目有一个编号。
文字进入模型之前,要先经过一个叫分词器(tokenizer)的程序,切成词表里的条目,再换成编号:
(编号是我随手写的示意。每个模型的词表不同,切法和编号都不一样。)
模型输出的时候反过来:它预测出下一个 token 的编号,分词器再把编号翻译回文字。模型从头到尾只和编号打交道,它从来没见过"字"。
1.2 为什么不直接按字符切?
一个自然的问题:为什么要搞这么复杂?直接把每个字母、每个汉字当成一个 token 不就行了?
可以,但很亏:
- 序列太长:一个英文单词平均五六个字母,按字母切,同样一段话的长度就是按词切的五六倍。第 09 篇会讲,模型的计算量随序列长度增长得很快,序列越长越慢、越贵;
- 每个 token 携带的信息太少:模型要花更多的"力气"把字母拼成有意义的东西。
那按单词切呢?也不行:
- 词表会爆炸:英文有几十万个词,加上各种变形、拼写错误、新造的词、代码里的变量名,根本列不完;
- 遇到没见过的词就傻了:
agentfromzero不在任何词典里,怎么办?
所以现在的做法是取中间:常见的片段合成一个 token,罕见的拆成小块,再罕见的拆到字节。 这就是 BPE。
二、BPE:从字节开始,一步步合并
2.1 算法本身简单得出奇
BPE(Byte Pair Encoding,字节对编码)是目前最主流的分词算法。它的训练过程可以用一句话说完:统计哪两个相邻的块最常一起出现,就把它们合并成一个新块,重复几十万次。
看一个小例子。假设训练语料只有这么一串:
aaabdaaabac第一步,统计相邻两个字符出现的次数,aa 出现得最多。把它合并成一个新符号,记为 Z:
ZabdZabac (新增:Z = aa)第二步,再统计,Za 和 ab 都出现了两次,挑一个,比如 ab,合并成 Y:
ZYdZYac (新增:Y = ab)第三步,ZY 出现了两次,合并成 X:
XdXac (新增:X = ZY = aaab)就这样一直合并下去,直到词表达到预定的大小。最后得到的词表里,既有单个的字符,也有 aa、aaab 这种频繁出现的片段。
单个字节"] --> B["统计最常见的
相邻对"] B --> C["合并成一个
新 token"] C --> D{"词表够大了?"} D -->|"没有"| B D -->|"够了"| E["得到词表
十几万条"] style E fill:#e8f5e9,stroke:#2e7d32
在真实的语料上跑这个过程,结果就很直观了:the、ing、tion、func、return 这种高频片段很快就会被合成一个 token;中文里 的、是、我们、模型 这种常用字词,在中文语料足够多的情况下也会成为独立的 token;而一个罕见的生僻字,可能被拆成两三个字节级的 token。
2.2 从字节开始:永远不会遇到"不认识的字"
现代分词器大多是"字节级"的:起点不是字母或汉字,而是 UTF-8 的字节。一个汉字在 UTF-8 里是 3 个字节,一个 emoji 是 4 个字节。
好处是:任何文本都能被切开,不存在"词表里没有这个字"的情况。最坏的情况,就是把它拆成一个个字节,每个字节一个 token。代价是罕见字符很费 token:一个冷门的 emoji,可能就要占好几个 token。
2.3 切出来长什么样
拿 strawberry 来说,它在某个分词器里可能被切成这样(具体怎么切,每家都不一样):
str | aw | berry而在模型眼里,这只是三个编号。它知道这三个编号连在一起是"草莓"的意思,知道它常和 jam、cake 一起出现,但"第三个块里有两个 r"这件事,它从来没有被直接告诉过。
2.4 还有一类特殊的 token
词表里除了普通的文本片段,还有一小批特殊 token。它们不对应任何你能打出来的文字,专门用来标记结构:
- 角色的开始和结束:一条 system 消息从哪里开始,一条 user 消息到哪里结束,assistant 从哪里开始说话;
- 一段回答的结束:模型生成出这个 token,就意味着"我说完了",API 据此停止生成,返回
end_turn(DeepSeek 叫stop); - 工具调用的边界:模型想调用工具时,会先生成一个特殊的标记,后面跟着工具名和参数,再用另一个标记收尾。
你在 API 里看到的是整齐的 JSON:role、content、tool_calls。但在送进模型之前,服务端会用一个对话模板把这些 JSON 展开成一长串 token,大概长这样(示意,每家的标记都不一样):
<|system|>你是一个编码助手。<|end|>
<|user|>帮我修复失败的测试<|end|>
<|assistant|>先跑一下测试。<|tool_call|>run_bash{"command":"go test ./..."}<|end|>
<|tool|>--- FAIL: TestDiscount<|end|>
<|assistant|>最后那个 <|assistant|> 后面是空的:这就是在请模型"接着往下写"。
这解释了几件事:
- 下面 6.1 节里"每条消息的固定开销",就是这些角色标记占掉的 token;
- 第 02 篇说的"user 和 assistant 严格交替""每个调用都要有结果",本质上是因为模板展开后的格式,要和模型训练时见过的格式一致;
- 第 03 篇里 2022 年的 ReAct 要用 stop sequence 截住
Observation:,而今天的模型生成完工具调用就会自己停下来:因为"工具调用结束"已经是一个训练进模型里的特殊 token 了。
三、为什么数不清字母
3.1 字母藏在 token 里面
现在可以回答开场的问题了。
问"strawberry 里有几个 r",相当于问模型:这三个编号对应的文本里,一共有几个 r? 模型要回答这个问题,得先知道 str 里有一个 r、aw 里没有、berry 里有两个,然后再加起来。
[编号A] [编号B] [编号C]"] T --> K1["编号A = str
里面有 1 个 r?"] T --> K2["编号B = aw
里面有 0 个 r?"] T --> K3["编号C = berry
里面有 2 个 r?"] K1 & K2 & K3 --> S["加起来:3"] K3 -.->|"记不清,凭感觉"| W["答:2"] style W fill:#ffcdd2,stroke:#b71c1c
"某个 token 由哪些字母组成"这种知识,模型只能从训练数据里间接地学到,而且学得并不牢。人类很少在文本里写"berry 这个词有两个 r"。所以它常常凭感觉答一个,而感觉就是"strawberry 里有个双 r,大概是 2 个"。
把字符串倒过来写也是同样的道理:倒写需要把每个 token 拆成字母,再按相反的顺序拼回去。拆这一步就容易出错。
类似的"怪行为"还有很多,根源都在 token 粒度比字母粗:
- 让它写一段"正好 100 个字"的文字,它总是写不准;
- 让它把每个单词的首字母大写、或者按字母顺序排序,偶尔出错;
- 简单的数字运算,比如很长的两个数相加,偶尔出错(数字也会被切成块,不同的数字切法还不一样)。
3.2 那为什么新模型又能答对了?
现在很多新模型能答对 strawberry 问题了。原因主要有两个:
- 它们会先"想"一下:推理模型在回答前会先写一段思考过程(第 12 篇)。思考里它会把单词拆开:"s-t-r-a-w-b-e-r-r-y"。一旦拆开写,每个字母就变成了一个单独的 token,数起来就不难了;
- 训练里专门补过这类题:这个梗太有名了,后来的训练数据里自然少不了它。
第一个原因更有意思:让模型把隐藏在 token 里的东西"写出来",它就能处理了。 这和第 07 篇要讲的"模型没有草稿纸,它写出来的就是它的思考过程"是同一个道理。
3.3 token 边界还会带来几件怪事
除了数字母,token 的切法还会在一些意想不到的地方露出马脚:
- 空格是 token 的一部分:在很多分词器里,
hello和hello(前面带一个空格)是两个不同的 token。英文单词通常带着前导空格出现在句子中间,所以一段 prompt 如果以一个多余的空格结尾,模型接下来要生成的那个词就"少了"它习惯的前导空格,偶尔会让输出变得别扭; - 数字的切法不统一:
2026可能是一个 token,20261018可能被切成202、610、18这样毫无数学意义的几块。模型做长数字的加法时,要先在这些块之间"对齐位数",这是它算长数字容易错的原因之一; - 同一个词,大小写不同就是不同的 token:
Error、error、ERROR可能是三个完全不相关的编号,模型要靠训练才知道它们是一个意思。
这些都不需要你记住。需要记住的只有一句:模型对文字的感知是"一块一块"的,凡是需要"一个字一个字"处理的事,都要多留个心眼。
四、中文和英文,谁更费 token
4.1 取决于词表是怎么训练的
同样一句话,中文和英文各要多少 token?答案是:看分词器的词表是用什么语料训练的。
BPE 合并的是"最常见的片段"。如果训练语料以英文为主,英文的常用词大多会被合成一个 token,而中文的常用词可能还没来得及合并,一个汉字就得拆成一两个甚至更多 token。反过来,中文语料多的分词器,常用的中文词组会被合成一个 token,中文就省了。
大致的规律是:
| 分词器的词表 | 英文 | 中文 |
|---|---|---|
| 以英文语料为主 | 一个常见单词约 1 个 token | 一个汉字可能要 1 到 2 个 token |
| 中文语料也很充分 | 一个常见单词约 1 个 token | 常用词组往往 1 个 token 就能装下一两个字 |
所以"中文比英文贵"这句话,对某些模型成立,对另一些模型不成立。国内的模型,比如 DeepSeek,词表里的中文覆盖一般更充分;这件事要实测,不要凭印象。
4.2 代码和 JSON 也挺费
除了自然语言,Agent 里大量的内容是代码、JSON、日志。它们有几个特点:
- 空格和缩进:一段缩进很深的代码,前面一串空格也要算 token(虽然分词器通常会把连续空格合并成一个 token);
- 符号很碎:
{"path":"a.go"}这样的 JSON,引号、冒号、括号各占一个 token 是常事; - 标识符千奇百怪:
ContextBudget、handleUserLoginRequest,会被拆成好几块。
这就是为什么第 20 篇讲工具设计时会强调"返回值要精简":工具返回一大坨格式化得很漂亮的 JSON,在 token 账单上一点都不漂亮。
五、一切都按 token 算
5.1 计费:输入一个价,输出一个价
所有模型 API 都按 token 计费,而且输入和输出分开算,输出通常更贵。以 DeepSeek 为例(截至写作时,以官网为准),deepseek-flash 高峰时段每百万 token 的价格是:
| 价格(美元 / 百万 token) | |
|---|---|
| 输入(缓存未命中) | 0.3 |
| 输入(缓存命中) | 0.006 |
| 输出 | 1.2 |
(DeepSeek 还有分时计价,非高峰时段半价。)
几个值得注意的点:
- 输出比输入贵好几倍。原因第 07 篇会讲:输入可以并行地一次算完,输出只能一个 token 一个 token 地往外"吐";
- 缓存命中的输入非常便宜。第 10 篇会讲 prompt caching 是怎么回事,第 37 篇会讲 Agent 怎么用好它;
- 思考也算输出。推理模型的思考过程同样按输出 token 计费,第 12 篇会算这笔账。
配套仓库把这张价目表写在了 internal/llm/pricing.go 里,后面的实验会用它估算每次调用花了多少钱。
5.2 上下文上限:也是按 token 算
每个模型能一次"看"多少内容,也是按 token 算的,这叫上下文窗口(context window)。DeepSeek 的两个模型都是 100 万 token。听起来很大,但别忘了第 02 篇说的:Agent 每一圈都要把全部历史再发一遍,而读一个大文件、跑一次测试,可能就是几万 token。第 10 篇会详细讲上下文窗口。
输入、输出、缓存分开算"] T --> W["上下文上限
一次能看多少"] T --> S["速度
输出一个一个往外吐"] style T fill:#fff3e0,stroke:#e65100
token 是模型世界里的通用货币:花钱按它算,内存按它算,时间也按它算。理解了 token,你才能理解后面所有关于成本和上下文的讨论。
5.3 一笔 Agent 的账
把这几条放进 Agent 的场景里,算一笔粗账(数字只求量级)。
假设一个编码任务里,Agent 第 3 圈读了一个三四百行的 Go 文件,大约 5000 token。之后它又转了 47 圈才做完。第 02 篇说过,这 5000 token 会在之后的每一圈被重新发送一次,一共发了 48 次,合计约 24 万 token。
- 如果每一次都是缓存未命中,按
deepseek-flash高峰价每百万 0.3 美元,这一个文件就是 7 美分左右; - 如果除了第一次之外都命中缓存,按每百万 0.006 美元,后面 47 次加起来不到 0.2 美分。
一个文件差了几十倍。一个任务里通常要读十几个文件、跑好几次测试,这个差距会被放大很多倍。这就是为什么第 10 篇和第 37 篇要花那么多篇幅讲缓存:对 Agent 来说,"内容在前缀里保持不变"本身就是一种省钱的设计。
另一个结论是:输出虽然单价贵,但 Agent 的大头往往是输入。 模型每一圈只写几十到几百个 token 的工具调用,却要读进几万甚至几十万 token 的上下文。所以优化 Agent 的成本,先看输入:工具输出有没有截断(第 28 篇)、有没有读了不需要的文件、缓存有没有命中。
六、动手:数一数 token
6.1 怎么数
最准的办法,是用模型自己的分词器来数。有的厂商提供专门的"数 token"接口;DeepSeek 没有,但它每次调用都会在响应的 usage 里告诉你这次输入用了多少 token。配套仓库就利用了这一点,internal/llm/deepseek.go 里的 CountTokens 是这么写的:
// CountTokens:DeepSeek 没有单独的"数 token"接口。我们发一次关掉思考、只生成 1 个 token 的请求,
// 读 usage.prompt_tokens——数得准,代价是花一点点钱(deepseek-flash 每百万输入 token 几毛钱)。
func (d *DeepSeek) CountTokens(ctx context.Context, req *CountRequest) (int, error) {
r := toDeepSeek(&Request{Model: req.Model, System: req.System, Messages: req.Messages, Tools: req.Tools,
MaxTokens: 1, Thinking: &Thinking{Type: "disabled"}})
// ……发请求,返回 out.Usage.PromptTokens
}不设 Key 时,用的是演示模型的估算:汉字按一字一个 token,英文按四个字符一个 token。这个估算只求量级对,见 internal/llm/count.go。
还有一个细节:一条消息除了内容本身,还有一点固定开销(角色标记之类的)。配套代码先数一下只有一个字母 a 的消息,把这部分开销量出来,后面再减掉:
// 一条消息本身带有少量固定开销(角色标记等),先量出来,后面减掉。
overhead := count(ctx, c, "a") - 1实验 1:中英文对比
go run ./examples/ch06-tokens compare用演示模型跑(也可以在 Playground 的第 06 篇里跑),输出是这样的:
模型:demo-1(每条消息的固定开销约 7 token,已扣除)
中文 16 字 → 16 token(1.00 字/token) 今天天气很好,我们去公园散步吧。
英文 12 词 → 15 token(0.80 词/token) The weather is nice today, let's take a walk in the park.
……这是演示模型按规则估出来的数字,"1.00 字/token"是规则本身,不代表任何真实的分词器。
要看真实的计数,在线 Playground 演示不了:先 export DEEPSEEK_API_KEY=sk-... 再跑。每数一次就是一次真实请求,三组对比加上开销测量一共 7 次,花不到一分钱。接上 DeepSeek 后,重点看两列:"字/token"和"词/token"。看看在 DeepSeek 的分词器里,中文和英文到底谁更省,和你的印象一样吗?
实验 2:数你常用的 prompt
go run ./examples/ch06-tokens count README.md把 README.md 换成你平时最常用的那段 prompt,或者你项目里的 AGENTS.md。程序会告诉你它有多少 token,以及作为输入每调用一次大约多少钱。
一段 prompt 单看很便宜,但别忘了 Agent 每一圈都要带上它。一个转 50 圈的任务,它就被发了 50 遍。对照 5.3 节那笔账,算一算如果缓存没命中,这 50 遍要花多少钱;命中了又是多少。
实验 3:strawberry
go run ./examples/ch06-tokens letters问两个经典问题:strawberry 里有几个 r,把 agentfromzero 倒过来写。演示模型只会复述问题;接上 DeepSeek 再跑。DeepSeek 默认开着思考模式,它很可能会先在思考里把单词拆成字母,然后答对。想看看不思考时会不会翻车,可以把 examples/ch06-tokens/main.go 里那次请求加上 Thinking: &llm.Thinking{Type: "disabled"} 再跑几次。
实验 4:Tiktokenizer
打开 Tiktokenizer 网站(搜这个名字就能找到),把同一段中文和英文贴进去,它会用不同颜色标出每个 token 的边界。你能直观地看到:哪些词被合成了一整块,哪些被拆碎了,空格和标点是怎么处理的。
注意:这个网站展示的是 OpenAI 系列模型的分词器,和 DeepSeek 的分词器不是同一个。用它来理解"token 是怎么切的"很好,用它来估算 DeepSeek 的 token 数就不准了。
七、踩坑记录:和 token 有关的六个坑
坑 1:用字数估算 token。
"一个汉字大约一个 token"是一个很粗的经验值,换个模型可能差一倍。要算钱、要控制上下文长度,就用模型自己的分词器去数,或者看 API 返回的 usage。
坑 2:拿别家的分词器估算 token。
网上很多 token 计算工具默认用的是某一家的分词器。拿它去估另一家的模型,中文和代码尤其容易偏得厉害。
坑 3:只算了自己写的 prompt。
一次请求的输入不只是你写的那句话,还有 system prompt、工具定义、对话历史、检索内容、工具输出。Agent 里,最大头的往往不是你写的话。第 14 篇会拆开一次真实请求,看看每一部分各占多少。
坑 4:忘了输出更贵,思考也算输出。
很多人只盯着输入 token。其实输出单价贵好几倍,而推理模型的思考过程全部按输出计费。让模型"详细解释一下",账单可能比你想的高。
坑 5:让模型精确地控制字数。
"总结成不超过 100 字""正好写 10 个单词",模型只能大概做到。因为它看到的是 token,不是字。真的需要精确控制,就让程序来截断或检查,别指望模型自己数。
坑 6:不信任模型对字符级操作的结果。
拼写检查、字符串反转、数字符、按字母排序,这些操作交给代码去做,一行就搞定,而且绝对不会错。Agent 的正确姿势是:让模型写一段代码去数,而不是让它自己数。 编码 Agent 在这类问题上反而很靠谱,因为它会顺手跑一个 len()。
一句话总结
模型看不见字,只看得见 token:BPE 把常见的片段合成一块、罕见的拆成小块;模型数不清字母,是因为字母藏在 token 里;计费、上下文上限、生成速度,全都按 token 算。
评论
还没有评论,来说点什么吧。