一、前言
Go 系列博文的最近一口气写了十篇(王婆卖瓜下,全是干货)。这一次换个方向,紧跟技术趋势,聊一个几乎所有 AI 应用都绕不开、却经常被一笔带过的概念------Token。
在使用 ChatGPT、DeepSeek 等大模型时,你是否思考过:
- 模型究竟是如何"阅读"我们输入的自然语言的?
- 为什么在 API 计费中,看似长度相近的中文与英文,请求消耗的 Token 数却存在明显差异?
这些问题的答案,都指向同一个核心概念:Token。
如果说算力是 AI 时代的"石油",那么 Token 更像是 AI 时代的"数字货币"。它既是大模型理解文本的最小语义单元,也是模型推理、上下文限制与 API 计费背后的统一度量标准。对开发者而言,Token 不只是一个抽象名词,而是一个需要被精确理解和管理的工程变量。
本文将从底层原理出发,系统拆解 Token 的生成与作用机制,并结合 Go 语言 ,手写一个简化版的 BPE(Byte Pair Encoding,字节对编码) 算法,带你从"知道 Token 是什么",走到"真正理解 Token 为什么是这样"。
二、什么是 Token?并非简单的"单词"
很多人直觉地认为:Token = 单词(Word)。但在 LLM(Large Language Model,大语言模型)的世界里,这个理解并不成立,甚至可以说是一个常见误区。
在模型内部,Token 并不是语言学意义上的"词",而是模型词表(Vocabulary )中的一个离散符号 ,在实现层面被映射为一个整数 ID 。它是一个介于自然语言与底层数学向量之间的桥梁,更偏向工程实现概念。
1. 不同语言下的 Token 表现形式
英文场景:以"子词"为核心
对于英文文本,Token 通常是子词(Subword)而非完整单词。
例如:
smart往往是一个独立的 Tokensmarter可能被拆分为:smarter
这种拆分方式使模型能够复用高频词根,从而降低词表规模。
补充说明:关于"空格"
在切分时,英文单词前的空格 通常会被合并到 Token 中。例如
"apple"和" apple"会被识别为两个不同的编号。这解释了为什么 Prompt 结尾多一个空格有时会影响结果。
中文场景:并非"一字一 Token"
一个常见误解是:中文 = 一个汉字一个 Token。事实并非如此。
在实际模型中:
- 某些高频汉字或词语(如"我们""中国")可能是单 Token。
- 低频字、罕见字、特殊符号,可能被拆成:
- 多个 Token
- 甚至多个字节级 Token
这完全取决于:
- 模型的 Tokenizer 设计
- 词表规模
- 训练语料的分布
进阶视角:字节降级机制
当汉字不在词表时,它会按 UTF-8 编码拆成多个字节 Token。因此,中文在某些模型里比英文更"贵",因为一个汉字可能占用 2-3 个 Token 额度。
2. 为什么一定要做 Token 切分?
Tokenization 并不是为了"把文本拆开",而是一个在效率、语义与工程约束之间的权衡结果。
2.1 字符级(Character-level):过于细碎
如果以单个字符作为最小单位(如 a, b, c 或 你, 我, 他),序列长度极长,计算复杂度急剧上升,且语义被严重割裂("苹"和"果"分开看,很难直接关联到"苹果")。对于 Transformer 这种注意力模型而言,这是不可接受的。
2.2 单词级(Word-level):词表爆炸
如果以完整单词作为单位,英语存在时态、复数、派生词等变化,且新词层出不穷。这会导致词表规模无限膨胀,OOV(词表外词)问题严重,内存与参数规模失控,在工程上几乎不可行。
2.3 子词级(Subword-level):工程上的最优解
于是,大模型选择了折中方案:子词级 Token 。
核心思想是:高频词整体保留,低频词拆成更小的可复用单元。
而这类方法中,最具代表性的算法之一,就是我们将在下一章详细拆解的:BPE(Byte Pair Encoding,字节对编码)。
3. 一个关键认知:Token 是"模型世界"的最小单位
在模型内部:
- 文本 →\rightarrow→ Token 序列
- Token →\rightarrow→ 整数 ID
- 整数 ID →\rightarrow→ 向量(Embedding)
模型从头到尾都"看不到文字本身",它只认识 Token ID。这意味着:
- 上下文长度限制,本质是 Token 数量限制。
- API 计费,本质是 Token 数量计费。
- Prompt 优化,本质是在 Token 级别做工程优化。
理解 Token,不是为了"知道一个名词",而是为了真正理解大模型的运行成本与行为边界。
4. 核心解密:模型是如何"读懂"这些数字的?
这可能是初学者最大的困惑:"把'苹果'变成了数字 5401,把'好吃'变成了 3922,模型只看到一堆数字,怎么就能理解语义并进行推理呢?"
在大模型的眼里,Token ID 只是一个索引。真正让它"活"过来的,是接下来发生的"查表 "与"特征动态聚合"。这个过程可以简化为三步:
A. 第一步:查字典 (Embedding) ------ 赋予身份
当模型接收到 Token ID 5401 时,它不会把这个数字拿去参与数学加减。相反,它会去查一张巨大的"权值表"------Embedding Matrix(嵌入矩阵)。
在这张表里,5401 对应着一个由数千个浮点数组成的高维向量 ,比如 [0.12, -0.59, 0.88, ...]。
- 语义坐标 :你可以把这个向量看作 Token 在一个"万维空间"里的坐标。
- 空间规律 :经过海量数据训练,语义相近的词,坐标距离会非常近。例如
Apple和Banana的坐标挨在一起,而离Car极远。
这一步,数字 ID 变成了具有"初始语义"的数学实体。
B. 第二步:Transformer 与注意力机制 ------ 动态聚合上下文
这是大模型最核心的"大脑"部分。单个 Token 的向量是静态的,但在不同的句子里,同一个词的意思可能天差地别。
- 句子 1:
Appleis a tasty fruit. (苹果) - 句子 2:
Applereleased a new iPhone. (苹果公司)
Transformer 是如何"转弯"的?
对于开发者来说,你可以把它想象成一个"带有动态权重计算的特征聚合系统":
- 并行摄入 (Parallelism) :
模型不像 RNN 那样逐字阅读,它利用矩阵运算一次性读入所有 Token。这就像是启动了并行的单元同时处理整句话,效率极高。 - 自注意力机制 (Self-Attention) :
这是 Transformer 的灵魂。每个 Token 都会在"内部总线"上广播自己的需求,并根据相关性从其他 Token 那里拉取特征:
- 打分 (Scoring) :在句子 2 中,处理
Apple时,模型计算发现后面的iPhone与它关联度极高。 - 加权融合 (Weighted Sum) :模型会将
iPhone的特征信息按高权重"融合"到Apple的向量中。 - 结果 :
Apple的向量发生了空间偏移------从原本偏向"水果"的区域,移动到了"科技公司"的区域。
总结:Transformer 让每个 Token 都能根据上下文,实时演化出最准确的语义表达。
C. 第三步:概率预测 (Next Token Prediction) ------ 生成回答
经过多层 Transformer 的"逻辑加工"后,模型对当前语境有了深刻理解。最后一步,它本质上是一个概率预测机。
- 投影与归一化 :模型将处理后的复杂向量投影回词表大小的维度,并通过 Softmax 函数,给词表里的每一个 Token 算出一个"胜出概率"。
- 采样预测 :
例如输入:"我喜欢吃"
模型计算后输出概率:
- Token
苹果(ID 5401): 概率 15% - Token
西瓜(ID 6621): 概率 12% - Token
石头(ID 9912): 概率 0.0001%
最终模型根据概率(结合 Temperature 参数)选中 苹果,这就是我们看到的回答。
写在最后:理解 Token 的工程意义
到这里,你已经构建了一个完整的认知链路:
原始文本 → BPE 切分 → Token ID → Embedding 向量 → Attention 语义聚合 → 概率预测 。理解了这一点,你就明白了为什么 Prompt 里的每一个字、每一个空格都会消耗 Token,并且会通过注意力机制影响最终的生成概率。
三、 核心算法:BPE (Byte-Pair Encoding)
目前 GPT-3.5, GPT-4, Llama 等主流模型均主要基于 BPE 算法。它是一种介于"字符级"和"单词级"之间的分词方案。
1. BPE 的直观逻辑
BPE 的核心思想非常简单:统计语料中相邻字符对出现的频率,不断将最频繁出现的"字符对"合并成一个新的"Token",直到达到预设的词表大小。
想象我们要处理语料序列:"hug pug pun bun"。
- 拆分 :起初全是单个字符(如
h,u,g)。 - 统计 :发现
u和g经常挨在一起(hug,pug),u和n也经常挨在一起(pun,bun)。 - 合并 :假设先合并
u+g。从此ug变成了一个不可分割的新单元(Token)。 - 循环:在新的基础上继续找下一个最热组合,周而复始。
2. 用 Go 手搓一个 BPE 训练器
作为 Go 开发者,没有什么比看代码更能理解原理了。下面我们用 Go 模拟上述 "hug pug pun bun" 的合并过程。
注:为了演示核心逻辑,我们简化了预分词步骤,直接将输入视为一串连续的 ASCII 整数序列。
go
package main
import (
"fmt"
)
// 定义一些基础 ASCII 码,方便阅读
const (
h = 104; u = 117; g = 103
p = 112; n = 110; b = 98
space = 32
)
// getStats: 核心步骤 1 ------ 统计相邻 pair 的频率
func getStats(ids []int) map[[2]int]int {
counts := make(map[[2]int]int)
// 滑动窗口遍历:每次看 ids[i] 和 ids[i+1]
for i := 0; i < len(ids)-1; i++ {
pair := [2]int{ids[i], ids[i+1]}
counts[pair]++
}
return counts
}
// merge: 核心步骤 2 ------ 执行合并操作
func merge(ids []int, targetPair [2]int, newTokenID int) []int {
newIds := make([]int, 0, len(ids))
i := 0
for i < len(ids) {
// 贪婪匹配:如果当前位置能组成目标 pair,就合并!
if i < len(ids)-1 && ids[i] == targetPair[0] && ids[i+1] == targetPair[1] {
newIds = append(newIds, newTokenID) // 写入新 ID
i += 2 // 关键:跳过被合并的两个旧 Token
} else {
newIds = append(newIds, ids[i]) // 保持原样
i++
}
}
return newIds
}
func main() {
// 模拟语料: "hug pug pun bun"
tokens := []int{
h, u, g, space,
p, u, g, space,
p, u, n, space,
b, u, n,
}
fmt.Printf("初始序列: %v\n长度: %d\n", tokens, len(tokens))
fmt.Println("------------------------------------------------")
// 词表大小设为 256 (ASCII 0-255 已被占用)
vocabSize := 256
numMerges := 2 // 模拟训练 2 轮
for i := 0; i < numMerges; i++ {
stats := getStats(tokens)
// 1. 找到出现频率最高的 Pair
var bestPair [2]int
maxCount := 0
for pair, count := range stats {
// 注意:Go map 遍历是随机的,如果频率并列,这里选谁全看运气
if count > maxCount {
maxCount = count
bestPair = pair
}
}
if maxCount < 2 {
fmt.Println("没有频繁出现的组合可合并了")
break
}
// 2. 生成新 Token ID (从 256 开始累加)
newTokenID := vocabSize + i
fmt.Printf("Step %d: 发现最热组合 %v (出现 %d 次) -> 合并为新 Token [%d]\n",
i+1, bestPair, maxCount, newTokenID)
// 3. 执行全量合并
tokens = merge(tokens, bestPair, newTokenID)
fmt.Printf("合并后序列: %v\n", tokens)
fmt.Println("------------------------------------------------")
}
fmt.Printf("最终结果: %v (长度: %d)\n", tokens, len(tokens))
}
3. 代码深度剖析:Go 实现的 3 个亮点
💡 亮点一:为什么 vocabSize 从 256 开始?
你可能注意到了 vocabSize := 256 以及新 Token ID 是 256 + i。
- 基础层 :计算机的底层语言是字节 (Byte),取值范围是
0-255。BPE 算法初始时,将所有文本看作字节流。 - 扩展层 :BPE 生成的新 Token(如
ug或ing)是概念上的组合,不能和基础字节冲突。因此,新身份证必须从 256 开始发放。 - 演进:随着训练进行,ID 会变成 257, 258... 甚至达到 100,000+(如 GPT-4 的词表大小约 10 万)。
💡 亮点二:Map Key 的玄机 [2]int ------ 从"棋盘坐标"到"字符对"
在 getStats 函数中,我们使用了 map[[2]int]int。这不仅仅是为了配合 BPE,更是 Go 语言中处理"二维固定关系"的标准范式(Idiom)。
为了理解这一点,我们可以借用游戏开发的场景:
1. 场景类比:棋盘与坐标
想象你在开发一个中国象棋或战棋游戏。棋盘是一个二维网格,你需要存储每个棋子在棋盘上的位置。
- 错误直觉 :很多新手会想用切片(Slice)
[]int{x, y}来表示坐标。 - Go 的限制 :Go 语言中,切片包含指针,不可比较,严禁作为 Map 的 Key。
2. 正确姿势:使用数组作为坐标
在 Go 中,[2]int(数组)是值类型 ,它就像一个实实在在的"坐标点"。只要 x 和 y 一样,两个数组就相等。这使得它完美适合做 Map 的 Key。
我们可以这样定义棋盘状态:
go
// 游戏开发经典场景:稀疏矩阵 / 棋盘状态
// Key: [2]int 代表坐标 {x, y}
// Value: string 代表该位置的棋子
chessboard := make(map[[2]int]string)
// 放置棋子:
// 在坐标 (4, 0) 放一个 "帅"
chessboard[[2]int{4, 0}] = "帅"
// 在坐标 (4, 3) 放一个 "兵"
chessboard[[2]int{4, 3}] = "兵"
// 查找棋子:
pos := [2]int{4, 3}
if piece, ok := chessboard[pos]; ok {
fmt.Println("在位置", pos, "发现棋子:", piece) // 输出: 发现棋子: 兵
}
3. 回到 BPE 算法
BPE 的逻辑与棋盘完全一致,只是把"空间坐标 "换成了"相邻字符":
- 棋盘逻辑 :
x和y确定一个位置 -> 对应一个棋子。 - BPE 逻辑 :
CharA和CharB确定一个组合 -> 对应一个频率。
go
// BPE 统计逻辑
// Key: [2]int 代表相邻的一对字符 {CharA, CharB}
// Value: int 代表这对字符出现的次数
stats := make(map[[2]int]int)
// 记录 "u" (117) 和 "g" (103) 这一对
pair := [2]int{117, 103}
stats[pair]++
总结这个 Idiom:
当我们在 Go 中需要把固定数量 (比如 2个或 3个)的简单数据组合起来,作为一个唯一的哈希键(Hash Key )时,数组(Array)是唯一的选择,也是最高效的选择。它避免了定义复杂结构体的麻烦,又绕过了切片不可比较的限制。
💡 亮点三:Merge 函数的"贪婪与跳跃"
merge 函数不仅仅是简单的替换,它体现了 BPE 的两个核心策略:
- 全量处理 (Global Scope) :
tokens是一个一维大数组。merge操作不区分单词边界,它对整个语料库进行全局扫描和替换 。无论ug出现在句首还是句尾,一视同仁。 - 贪婪跳跃 (Greedy Skip) :
注意代码中的i += 2。当检测到ids[i]和ids[i+1]匹配目标时,我们合成一个新 Token,然后直接跳过这两个旧位置。这保证了已被合并的字符不会再次参与后续的合并(即每个字符在每一轮只能被"吃"一次)。
4. 潜在的挑战:随机性与确定性
在运行上述代码时,你可能会发现一个有趣的现象:ug 和 un 谁先被合并?
- 现状 :在我们的语料中,
ug和un都出现了 2 次(频率并列)。 - Go 的行为 :Go 语言的
map遍历顺序是随机的。这意味着在频率相同的情况下(Tie-breaking),程序每次运行可能会选择不同的合并顺序。 - 工业级做法 :在实际的大模型训练(如 GPT)中,为了保证结果的可复现性,必须消除这种随机性。通常的做法是:将所有 Pair 提取出来,先按频率排序,频率相同时再按 Token ID 大小或字典序排序,确保每次训练得到的词表一模一样。
通过这个简单的 Go 模型,我们不仅跑通了 BPE 的流程,更理解了其背后的数据结构选择与算法权衡。
四、 工程实战:精准计算 Token 与成本控制
理解了 BPE 的原理后,我们在实际工程(如开发 AI 客服、RAG 系统、Agent)中,主要面临两个必须要解决的挑战:
- 省钱(Cost Estimation):API 是按 Token 收费的,且计费规则比想象中复杂,不能等几千美元的账单来了才后知后觉。
- 不报错(Context Management) :每个模型都有上下文窗口限制(如 4k, 8k, 128k)。如果提示词太长,API 会直接返回
400 Bad Request。
本章将提供全套的 Go 语言解决方案。
1. 为什么不能用 len(string)?
很多初学者习惯用字符串长度或空格分割来估算 Token,这是极其危险的。Token 数量与字符长度没有简单的线性关系:
- 英文 :
Hello world(11字符) -> 2 Tokens。 - 中文 :
你好世界(12字节) -> 可能会被编码为 2~4 个 Token,取决于模型词表。 - 代码:大量的缩进空格、换行符都会消耗 Token。
结论 :你必须使用与模型完全一致的 Tokenizer 进行计算。
2. 实战:使用 Go 计算 OpenAI Token
针对 OpenAI 的模型(GPT-3.5, GPT-4, Embedding 等),官方推荐使用 tiktoken。在 Go 语言中,我们使用社区维护的高性能移植版 tiktoken-go。
安装依赖:
bash
go get github.com/pkoukk/tiktoken-go
代码示例:计算 Token 数量
go
package main
import (
"fmt"
"log"
"github.com/pkoukk/tiktoken-go"
)
func main() {
text := "Go语言是构建AI应用的最佳选择!"
// 1. 获取特定模型的编码器 (Encoding)
// GPT-4, GPT-3.5-Turbo, Text-Embedding-3 均使用 "cl100k_base"
tkm, err := tiktoken.EncodingForModel("gpt-4")
if err != nil {
log.Fatalf("getEncoding: %v", err)
}
// 2. 编码 (Encode): 将文本转换为 Token ID 列表
tokenIDs := tkm.Encode(text, nil, nil)
// 3. 统计数量
fmt.Printf("原文: %s\n", text)
fmt.Printf("Token 数: %d\n", len(tokenIDs))
fmt.Printf("Token IDs: %v\n", tokenIDs)
}
如果你想直观看到一段话会被切成什么样,推荐去 OpenAI 官网的 Tokenizer 游览器点点看。你会发现,同样的词,大写首字母和全小写,Token ID 往往是完全不同的。
3. 高频场景:如何安全地截断文本?
在 RAG(Retrieval-Augmented Generation,检索增强生成)应用中,检索出的文档经常会撑爆模型的上下文窗口。此时,不能直接用字符串切片 text[:1000] 进行截断,否则可能导致中文乱码(切断了多字节字符)或破坏 BPE 语义(切断了单词)。
标准做法:Encode -> Slice IDs -> Decode
go
// SafeTruncate 按照 Token 数量限制安全截断文本
func SafeTruncate(text string, maxTokens int, model string) (string, error) {
tkm, err := tiktoken.EncodingForModel(model)
if err != nil {
return "", err
}
// 1. 转成 Token IDs
tokens := tkm.Encode(text, nil, nil)
// 2. 检查是否需要截断
if len(tokens) <= maxTokens {
return text, nil
}
// 3. 截取前 maxTokens 个 ID
truncatedTokens := tokens[:maxTokens]
// 4. 解码回字符串 (Decode)
// tiktoken 会自动处理好边界,保证不会出现乱码字符
return tkm.Decode(truncatedTokens), nil
}
4. 开源模型(Llama, Mistral 等)的处理
非 OpenAI 模型的词表与 GPT 不同。对于 Llama 3、Mistral 等开源模型,通常需要加载其特定的 tokenizer.json 文件。
在 Go 中,推荐使用 HuggingFace Tokenizers 的绑定库(如 github.com/daulet/tokenizers)进行处理。
5. 避坑指南:大模型的计费机制与"上下文爆炸"
这是开发者最容易踩坑的地方。大模型的计费公式通常为:
Total Cost=(Input Tokens×Pin)+(Output Tokens×Pout) \text{Total Cost} = (\text{Input Tokens} \times P_{in}) + (\text{Output Tokens} \times P_{out}) Total Cost=(Input Tokens×Pin)+(Output Tokens×Pout)
这里有三个必须注意的隐形成本:
A. 输出比输入贵
通常 Output Token 的单价是 Input 的 3~10 倍。
- 这意味着:让模型"读"一本书很便宜,但让模型"写"一本书非常贵。
B. 多轮对话的"滚雪球"效应 (The Rolling Snowball)
大模型 API 是无状态的。为了保持对话连贯,你必须在第 N 轮请求时,把前 N-1 轮的历史记录全部发回去。
推演场景:
- 第1轮:你发 "Hi" (2 tokens)。付费:2 input。
- 第2轮 :模型回复了 50 tokens。你又问了 5 tokens。
- 你实际发送的 Input 是:
"Hi"+模型回复+新问题。 - 付费:(2 + 50 + 5) = 57 tokens input。
- 你实际发送的 Input 是:
- 第3轮:...
- 第10轮:你可能只发了一个词,但你需要为前 9 轮所有的历史记录再次付费。
这就是为什么长对话聊久了,费用会呈线性甚至指数级上升。
C. System Prompt 的"入场费"
你设置的 System Prompt(如"你是一个专业的代码助手...")在每一轮对话中都会被发送。如果 System Prompt 很长(比如 2k tokens),那么用户每说一句话,你都要先付这 2k tokens 的基础费用。
D. 视觉盲区
很多模型对控制字符(如换行符 \n、制表符 \t)计费非常狠。在处理代码或格式化文档时,1个缩进可能就消耗了 1 个 Token。这在写大型 Prompt 时是需要优化的细节。
6. 成本预估代码实现
基于上述机制,后端应该实现一个成本预估器:
go
type CostEstimator struct {
InputPricePer1k float64 // 例如 0.0025
OutputPricePer1k float64 // 例如 0.0100 (通常更贵)
}
// Estimate 计算单次请求的预估成本
// history: 历史对话内容
// newPrompt: 用户当前问题
// estimatedOutputLen: 预估模型回答的长度(可基于经验设定,如 500)
func (c *CostEstimator) Estimate(history []string, newPrompt string, estimatedOutputLen int) float64 {
tkm, _ := tiktoken.EncodingForModel("gpt-4")
// 1. 计算 Input Tokens (包含历史记录 + 新问题 + System Prompt)
totalInputText := ""
for _, msg := range history {
totalInputText += msg
}
totalInputText += newPrompt
inputTokens := len(tkm.Encode(totalInputText, nil, nil))
// 2. 计算费用
inputCost := (float64(inputTokens) / 1000.0) * c.InputPricePer1k
outputCost := (float64(estimatedOutputLen) / 1000.0) * c.OutputPricePer1k
return inputCost + outputCost
}
通过这套组合拳------精准计算、安全截断、透视成本------你就能构建出一个既健壮又经济的 AI 应用。
五、 结语:从"调包侠"进阶为"AI 架构师"
至此,我们完成了对 Token 的全方位拆解。
从最基础的"Token ≠\neq= 单词"概念纠偏,到亲手用 Go 语言实现 BPE 算法的贪婪合并逻辑,再到工程实战中对 API 成本与上下文窗口的精准控制。你应该能感受到,AI 开发不仅仅是写好 Prompt 那么简单,背后隐藏着大量的工程细节与算法权衡。
在 Python 主导模型训练的当下,Go 语言 凭借其高并发、强类型和极致的性能,正在 AI Infra(AI 基础设施) 领域发挥着越来越重要的作用。无论是构建高性能的 API 网关、实时的计费系统,还是高吞吐的 RAG 检索服务,理解 Token 这一最基本的"原子单位",都是我们构建健壮系统的基石。
当别人还在因"上下文溢出"而报错,或者因看不懂账单而焦虑时,掌握了底层逻辑的你,已经懂得如何用 SafeTruncate 优雅降级,用 CostEstimator 精打细算。
这就是"调包"与"工程"的区别。
思考题:既然中文 Token 普遍比英文贵,如果你在做一个面向全球的 RAG 系统,你会为了省钱而强制要求模型内部只用英文思考,最后再翻译回中文吗?这种做法会有什么'工程代价'?