Token,模型眼里的世界

outlier 15 分钟 5850 字 0 0
系列教程Agent从入门到放弃 第 7 / 19 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02①大模型、Chatbot、Agent,一次把名字理清楚
  3. 03②Agent 的最小定义,一个 while 循环
  4. 04③Agent 简史,从 ReAct 到 Claude Code
  5. 05④Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
查看教程目录

开场:一个会写代码、却数不清字母的天才

有段时间网上流行一个梗:问大模型"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)的程序,切成词表里的条目,再换成编号:

flowchart LR T["今天天气很好"] --> TK["分词器"] TK --> P["今天 | 天气 | 很好"] P --> ID["[ 18947, 22551, 9706 ]"] ID --> M(("模型")) style TK fill:#fff9c4 style M fill:#e3f2fd,stroke:#1565c0

(编号是我随手写的示意。每个模型的词表不同,切法和编号都不一样。)

模型输出的时候反过来:它预测出下一个 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 这种频繁出现的片段。

flowchart LR A["起点
单个字节"] --> 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 里有两个,然后再加起来。

flowchart TB Q["strawberry 里有几个 r?"] --> T["模型看到的:
[编号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 问题了。原因主要有两个:

  1. 它们会先"想"一下:推理模型在回答前会先写一段思考过程(第 12 篇)。思考里它会把单词拆开:"s-t-r-a-w-b-e-r-r-y"。一旦拆开写,每个字母就变成了一个单独的 token,数起来就不难了;
  2. 训练里专门补过这类题:这个梗太有名了,后来的训练数据里自然少不了它。

第一个原因更有意思:让模型把隐藏在 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 篇会详细讲上下文窗口。

flowchart LR T["token"] --> C["计费
输入、输出、缓存分开算"] 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 算。

flowchart LR W["文字"] -->|"分词器(BPE)"| T["token 编号"] T --> M(("模型")) M --> T2["下一个 token 编号"] T2 -->|"分词器"| W2["文字"] style T fill:#fff3e0,stroke:#e65100 style T2 fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭