+++ title = 'Token 应译作「符」' slug = 'token-translation' tags = ["语言","Lisp","编程"] categories = '软件杂谈' date = 2026-09-07 23:37:10 draft = false toc = true fedi = 'https://akk.eltr.ac/objects/871e9998-56c8-4222-af8b-33c83d76b174' +++ 整场「要不要把 Token 翻译成词元?」的争论令我感到诡异,诡异的点在于所有参与者都只看到了 Token 作为大语言模型计价单位的意思,而 Token 一词存在于英语语言中已经有很久了(对中文的影响也很大)。若把 Token 先前的释义也译作「词元」,场面会非常可笑: - 这套桌游里的某个词元不见了! - 请求体里必须携带词元才能通过验证。 - 在构造抽象语法树之前,要先把字符串词元化。(这倒还算接近的) - 把安全词元插入电脑的 USB-C 接口。 - 为保证数据安全,可以把敏感信息词元化。 - 用你的词元兑换优惠或者奖品。 正如人们滥用「人工智能」一词指代「大语言模型」,因为他们只知道热度最高的技术,便让 AI 这个上世纪就出现的概念被人工智能的一个小分支占据一样,人们滥用 Token 一词,以至于他们不关心其他的意思。更何况「词元」这个意思用在 LLM 里其实相当不准确。 不过,也需要批评前人,Token 一词的翻译本身就很混乱。 ## 桌游中的 Token 桌游里的 Token 是「指示物」的意思,还有人根据情况译作「代币」「标记」。大富翁里用来标记地产的房子,飞行棋里的飞机,龙与地下城里的角色小人,都叫作 Token。 用来表示人物、角色的指示物还被称作 *Meeple*(My People),音译作「米宝」。能用米宝来指代所有的指示物吗?当然不行!米宝的本意是人物,是 Token 里相当小的一类。同理,能用「词元」对应 Token 吗?不能!词元只指代相当小的范畴,你当然不能说桌游里的指示物是词元。 不仅桌游里的代币是 Token,游戏厅里的游戏机和赌场里的老虎机使用的代币,也叫作 Token coin,不过如今更多地被 *ticket*(票券)取代了。 ## 密码学中的 Token 密码学中的 Token 通常叫作「令牌」。常见的场景是客户端输入账号密码之后,服务端生成一个(多半有期限)的凭证,一般是字符串,之后客户端只需要在访问时提供这个凭证就好,期限时间内不需要再次输入密码。这个字符串一般保存在 Cookies 里,服务端自动读取,或者客户端携带在 HTTP 请求头里: ``` Authorization: Bearer eyJhbGci......yu5CSpyHI ``` `Bearer` 之后的部分就是 Token。最常见的令牌之一是 [JWT](https://en.wikipedia.org/wiki/JSON_Web_Token),其他的应用场景也不少,不限于 HTTP 通信。任何能够在通信过程中,从密码学意义上验证用户身份的凭证字符串,都叫令牌。 顺带一提,令牌可以当作是时效性很强的密钥,如果泄露,攻击者就可以在令牌过期之前使用你的身份访问相关资源。令牌往往存放在 Cookies 里,这也是为什么[第三方 Cookies](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Third-party_cookies#what_is_the_problem_with_third-party_cookies)(或者说跨域 Cookies)常常受人诟病,现代浏览器也采取了很多限制。 还有一种密码学方法叫作令牌化([Tokenization](https://en.wikipedia.org/wiki/Tokenization_(data_security))),指代把敏感信息替换为不敏感的等价物(即 Token)的过程。我对此了解不多,就不闹笑话了。 ## 编译器和解释器中的 Token 这类 Token 在解析代码的过程中产生,解析(parse)或者预处理(preprocess)还没有到编译(compile)或者解释(interpret)的地步,无论编译器还是解释器都有这个步骤。仔细想想,源代码只是储存在文件里的文本而已,可以当作字符串处理,解释器和编译器是怎么把字符串变成有意义、可执行的语句的?我们可以从解释器的部分工作原理来理解其过程。 程序员极有可能接触过简单的解释器,比方说,如何给下面这个算式字符串求值? ``` (1 + 3) - 4 * 7 ``` 答案是构造抽象语法树(AST)。不难发现,上述算式可以表示为树形结构,`(1 + 3)` 是一个整体,`4 * 7` 因为乘法的优先级更高,也作为一个整体,那么就可以把 `-` 当作根,前面两个整体作为 `-` 的左右子树。继续递归,不难发现 `(1 + 3)` 是 `+` 为根,`1` 和 `3` 为叶子结点的子树,而 `4 * 7` 是 `*` 为根,`4` 和 `7` 为叶子结点的子树。 ![(1 + 3) - 4 * 7 的树状表示](https://img.eltr.ac/geedeapro/article/token-translation/math-ast.svg) {.dark:invert} 接下来只要深度优先遍历 AST 就好了,不难发现结点有两类,非叶子结点是操作符,叶子结点是数字。遍历时若是遇到数字,就直接返回值;若是遇到操作符,就根据操作符计算左右子树的值。在一系列奇妙的递归之后,程序就能求出这棵树的值为 `-24`。 回顾一开始的算式,能不能把它写成这个样子? ```lisp (- (+ 1 3) (* 4 7)) ``` 也就是说,把中置操作符换成**前置**操作符,把所有的括号**显式**地写出来。如果把 `()` 看成列表,前置操作符的写法其实是嵌套的列表,而嵌套的列表就是树。这样一来,把字符串解析成 AST 的算法就好写多了。 假设我们现在不仅仅是要表示算式,而是要表示程式…… ```lisp (format t "it's ~a~%" (- (+ 1 3) (* 4 7))) ``` 上面这段代码应该打印 `it's -24`。 同样,手动解析为 AST,变成下图的这个样子: ![上述 Lisp 代码的树形表示](https://img.eltr.ac/geedeapro/article/token-translation/lisp-ast.svg) {.dark:invert} 现在,非叶子结点是函数,叶子结点是函数的参数。读者可能会觉得把不同类型的数据都放在一起有点奇怪,和刚才的算式差得有点远。不过,只要想到深度优先遍历和递归求值的思路,就不会觉得奇怪了。只不过函数不仅仅是计算得出了值,还可能带有副作用。比方说 `format` 的副作用就是在指定的输出流(此处是 `stdout`)中打印文本。 手动遍历这棵树: 1. 求值根节点 `format`,发现是函数,于是求它的子树。 2. 求值 `format` 的左子树,发现是值,返回 `t`。 3. 求值 `format` 的第二棵子树,发现是值,返回 `"it's ~a~%"` 4. 求值 `format` 的右子树 `-`,发现是函数,求它的子树。 5. 求值 `-` 的左子树 `+`,发现是函数,求它的子树 6. 求值 `+` 的左子树,发现是值,返回 `1` 7. 求值 `+` 的右子树,发现是值,返回 `3` 8. 结点 `+` 的子树都求值完毕,对它本身求值得到 `4` 9. 求值 `-` 的右子树 `*`,发现是函数,求它的子树 10. 求值 `*` 的左子树,发现是值,返回 `4` 11. 求值 `*` 的右子树,发现是值,返回 `7` 12. 结点 `*` 的子树都求值完毕,对它本身求值得到 `28` 13. 结点 `-` 的子树都求值完毕,对它本身求值得到 `-24` 14. 结点 `format` 的子树都求值完毕,对它本身求值得到 `nil`,同时发生副作用:`"it's -24"` 被写入 `stdout` 或许用一门非 Lisp 语言表述上面这个程序会更直观: ```go // eval 对 AST 或子树求值 func eval(node Node) any { if node.IsLeaf() { // 如果是叶子结点,直接返回值 return node.Value } fn := node.Value var args []any for _, child := node.GetChildren() { args = append(args, eval(child)) } // applyFn 根据函数名 fn,把某个操作作用在参数 args 上,最后返回值 return applyFn(fn, args) } ``` 不过 `eval()` 函数的前提是 AST 已经被构造好了,而构造 AST 的前提是什么?回顾上面两棵树,你会发现每个结点中包含的都是有意义的语言单元。这个说法可能有点没头没脑的,难道还能包含没意义的语言单位吗?事实是,如果要创造完整的解释器,构造出 AST,就需要先划分出有意义的语言单元。 不能只根据空格划分,不然 `"Hello World"` 就会被分成 `"Hello` 和 `World"` 两个单元。甚者,`(format t "Hello World")` 会变成:`(format`、`t`、`"Hello` 和 `World")`。 假设有一个 `tokenize()` 函数,它的输入是未经处理的字符串,输出是可以交给解析器构造 AST 的 Tokens ,数组里的每个元素都是独立的语言单元。执行 `tokenize("(format t \"Hello World\")")`,在这个函数内部,应该先产生如下的数据: ```go []string{ "(", "format", "t", "\"Hello World\"", ")", } ``` 这些叫作 *lexeme*(语言学的 *lexeme* 译作「词位」,我并不知道编译器里的 *lexeme* 应该怎么翻译),要从 lexeme 生成 token,需要进一步分类: ```go []Token{ Token{name: "left-paren", value: "("}, Token{name: "symbol", value: "format"}, Token{name: "literal", value: "t"}, Token{name: "literal", value: "\"Hello World\""}, Token{name: "right-paren", value: ")"}, } ``` 上面的表示有点繁杂,一方面是我用了 Go 这门静态类型语言,另一方面是我想区分 *Token name* 和 *Token value*,在词法分析(lexical analysis)中,Token 由名字(或者说词法类别)和值组成。 之所以把括号留下,是因为它们需要作为标记传给 `parse()` 函数,而且词法层面还没有产生句法(syntax),也就不考虑要不要括号这种句法问题。一遇到 `(`,就认为下一个是根,之后的一直到 `)` 都是子树。这个函数作为解析器,输入划分好的 Tokens,输出可以递归求值的 AST。`parse()` 具体怎么实现就不赘述了。 除了 Lisp,你还可以参照 C 语言编译器 `gcc` 的文档的 [1.3 Tokenization](https://gcc.gnu.org/onlinedocs/cpp/Tokenization.html) 这一节。Tokenization 发生在预处理(preprocess)阶段。 至此,你应该明白我说的第二类 Token 指什么了。这类 Token 准确来说叫作 [Lexical Token](https://en.wikipedia.org/wiki/Lexical_analysis#Token)(词法记号,或者说…… 词元)。词法的(lexical)和句法的(syntactic,或名词 syntax)指代不同的抽象层次,词法分析就是把字符串划分为词法记号(lexical token)的过程,而句法分析则是判断词法记号的组合有没有犯句法错误(syntax error)。Token 是词法层次的实体,在词法分析过程中产生,最后在解析过程中变成 AST 内部的组成部分,和句法无关。至于语法(grammar),则是词法和句法的组合,还包含其他的语言定义。 *真是为了一盘醋包了一场酣畅淋漓的饺子啊。* ## 自然语言处理中的 Token 接下来是第三类 Token,即应用在 NLP 领域的 Token。前面我们在处理编程语言,而 NLP 是对自然语言的处理。那么 NLP 中的 Token 是指 [jieba](https://github.com/fxsjy/jieba) 这类分词工具吗? 的确,中文分词算法就是 NLP 分词的一种,叫作 *Word tokenization*。把英文句子打散成一个一个的单词,也属于这类分词。[^1] 其他的分词方式还有: 1. 字符分词(Character tokenization) 2. 子词分词(Sub-word tokenization) 3. 句分词(Sentence tokenization) 4. N 元分词(N-gram tokenization) 第三个是不是看起来很不对劲?如果是以句子为单位,那为什么会叫作分词呢?这里的 Token 不是词法意义上的 Token,把 *tokenization* 译作「分词」显然不对,那么把 *token* 译作「词元」就更不对了。若是译作「词元」,难道不会和 *word* 混淆吗? 上述老派的 NLP 方法,一般用在非 LLM 聊天机器人、搜索引擎的 Query 分析和词频统计中,根据用例不同选用不同的 Tokenization 方法。那么在更新的 NLP 技术,大语言模型中,Token 又指代什么呢? 大语言模型使用的 Tokenization 方法与简单 NLP 类似,至少输入都是自然语言词句,但算法过程和输出完全不同。LLM 用到了字节对编码(Byte-pair encoding,BFE),最早在 1994 年提出的 BFE 算法如下。[^2] 假设有形如 `aaabdaaabac` 的输入,其中 `aa` 这个字节对的出现频率很高,于是替换为整个字符串中没有出现过的其他字符,比如 `Z`,于是就变成了: ``` ZabdZabac Z=aa ``` 可见 `ab` 也出现了多次,替换为 `Y` ``` ZYdZYac Y=ab Z=aa ``` 现在又有了多次出现的 `ZY`,替换为 `X` ``` XdXac X=ZY Y=ab Z=aa ``` 通过递归的字节对编码,我们把 `aaabdaaabac` 压缩成了 `XdXac`。 大模型修改了 BFE 算法,不再用来压缩字符串,而是用来把字符转换为自然数,因为只有数字才能在神经网络算法中处理。字符转换成自然数之后,就成了 Token。 比方说,`aaabdaaabac` 表示为: ``` 0, 0, 0, 1, 2, 0, 0, 0, 1, 0, 3 a=0, b=1, d=2, c=3 ``` 再递归得到: ``` 4, 5, 2, 4, 5, 0, 3 a=0, b=1, d=2, c=3, aa=4, ab=5 ``` 大语言模型的 Token 可以很大,GPT-3.5 和 GPT-4 的 Token 长度为 100258,其中有 258 个特殊 Token。这些自然数被 Unicode 编码之后可能会变成奇怪的字符,比如字节 `32` 会变成 `Ġ`(U+0120,对应的十进制数是 288,即 32+256),在某些大模型里表示空格。 通过字节对编码得到的 Token 显然不是词语,我们是在字节(byte)的层面对其进行编码的,没有任何意义的字符组合也可以被编码为 Token,我们根本没有在「分词」。 老派 NLP 中的 Token 可以是句子,如今的 LLM 中的 Token 则是基于字节的编码,无论怎么看,LLM 的 Token 都不应该译作「词元」。 ## Token 应译作「符」 桌游小人是 Token,游戏代币是 Token,通行凭证是 Token,编译器和解释器划分的词法单元是 Token,NLP 中的字符、词语、句子和 N 元是 Token,LLM 中的字节对编码也是 Token。 把上面这些句子里的 Token 换成「词元」这个中文词,说得通吗?哪怕仅仅是在 LLM 的语境下,「词元」也词不达意。字节对编码显然不是词。 Token 不该译作「词元」,我想这没什么争议了。唯一可以译作「词元」的,只有计算机在做词法分析时得到的词法单元,但那也只是小部分而已。 英文里的 Token 本意是什么?它的[词源](https://www.etymonline.com/word/token)是原始印欧语词根 _*deik-_,意思是展示(*to show*),同源的古诺斯语 *teikn* 意味着星座和征兆(omen),古英语对应的单词 *tacen* 意思是指示、符号、证据和征兆。 星座和征兆都是符号或者说对某种符号的解读,符号就是指示含义或另一个物体的东西。*Token* 就是「指示另一个实体的东西」,至于这「另一个实体」是什么,要根据语境来看。 桌游里的 Token 指示游戏人物、生命值、财产等等,通行凭证或者说令牌指示访问某个资源的权限,编译器和解释器中的词法记号指示不能继续分割的词法要素,NLP 中的 Token 根据使用场景指示一层含义(词语表示的含义、句子表示的含义、N 个词语组成的短语或者说 N 元表示的含义),LLM 中的 Token 指示字节对,同时也对字节对的出现频率做统计学表示。 说到这里我想到一个有些偏门的例子,在《星露谷物语》的某些 Mod 里,由于缺乏标识「玩家是否完成某项任务」的机制,所以有的 Mod 会在玩家与某个 NPC 达成关键进度时,给予名为某某 Token 的物品,并提醒玩家要带着这个 Token 进入特定场景才会触发下一部分的剧情。这里的 Token 就指示「玩家已经达成了某项进度」。 说起通行凭证和令牌,不知道读者有没有看过早年间的古装剧。如果有的话,读者可能熟悉「兵符」这种东西: > 「符」一般分為兩半,右半在帝王,左半在將軍,調兵之時,使者帶符上朝驗合,才可行動。「符」形狀多種,以虎符為主;質料有銅、木、玉等,以青銅為多。 {{< right "——[漢語多功能字庫](https://humanum.arts.cuhk.edu.hk/Lexis/lexi-mf/search.php?word=%E7%AC%A6)" >}} 通行凭证和令牌不就是「符」吗?词法记号和 NLP 中的 token 就更不用说了,语言本身就是符号。编程语言中的关键字、括号、字面量也是词法符号。==就算未来出现更多基于 *token* 本意衍生出的用法,它多半也是符号,是用来指示某个东西的,而汉语中同样表示「指示某个东西的东西」的词,就是「符」。== --- ## 番外:语言学中的…… Lemma 只有 Token 是「词元」吗?不,*Lemma* 也叫「词元」。 语言学的分支词法学(令人意外,英文名和 Lexical 以及 Lemma 没有关系,叫作 Morphology,*morph-* 是变化、变形的意思)中有个概念叫作 *Lemma*,一般译作词目或者**词元**。 Lemma 也可以叫作 *canonical form*(规范形式)、*dictionary form*(词典形式)和 *citation form*(引用形式)。许多欧洲语言的词语都有多种词位变化,而在词典中往往写作一种形式,把变位作为衍生形式写在同一个条目中。词典中规范的形式就叫作词目或者词元。 比方说 *go* 也有 *went*、*going*、*goes*、*to go* 和 *gone* 等变位,但是在编写词典条目和查找词目的时候,我们只找 *go* 这个条目,而不找 *went* 或者 *gone*。有趣的是,*gone* 作为形容词,也可以是独立的词元。 可能这种区分看起来很没必要,但我给读者举个法语的例子就能看出词元这个概念的必要性了。法语词目 *avoir* 表示「有」,第一人称单数现在时直陈式为 *ai*,第二人称单数现在时直陈式为 *as*,第三人称单数现在时直陈式为 *a*,第一人称复数现在时直陈式为 *avons*,第二人称复数现在时直陈式为 *avez*,第三人称复数现在时直陈式为 *ont*,第一人称单数简单将来时直陈式为 *aurai*…… [^1]: 参考:[Tokenization in NLP - geeksforgeeks](https://www.geeksforgeeks.org/nlp/nlp-how-tokenizing-text-sentence-words-works/) [^2]: 参考:[Byte-pair encoding](https://en.wikipedia.org/wiki/Byte-pair_encoding)