说实话,之前写了不少调用大模型 API 的代码,但一直停留在 "传 prompt、等结果" 的黑盒阶段。我知道大模型处理的是数字,也听过 token、embedding 这些词,但它们到底在整条链路里扮演什么角色,为什么缺一不可,脑子里始终是一团浆糊。
直到这周啃完相关资料,自己动手跑了几遍 demo,才算把这条链路串了起来。这篇就顺着我当时踩坑的思路,把 token 分词和 embedding 向量化这俩最基础的概念掰扯清楚。
先从一个最朴素的疑问说起
最开始我特别纳闷:神经网络不就是算数字吗,那直接把每个字符转成 ASCII 或者 Unicode 编码喂进去不行吗?为啥非要搞个 token 出来,还得再做一次 embedding?
我第一反应是 ------"应该是性能问题吧",字符太多的话计算量太大。后来去翻了讲分词的资料,才发现事情没这么简单。
你想啊,如果按单个汉字或者字母来处理,"天" 和 "地" 在计算机里就是两个独立的数字,模型根本没法直观感受到它们之间有啥语义关联。更麻烦的是,像 "苹果" 这种词,拆成 "苹" 和 "果" 两个字,语义就碎了。模型得自己一点点学 "苹 + 果" 合在一起是一种水果,效率低得离谱。
所以分词的本质,就是找一个既不太大也不太小的语义单元。比整句话小,比单个字符大,大概对应我们常说的 "词" 或者 "词根"。模型在这个粒度上学语义,效率最高。
动手跑一下 token 编码解码
光说没用,我直接找了个 js 版本的分词库 js-tiktoken 跑了一下。OpenAI 官方用的就是 tiktoken,这个是 JavaScript 实现版,规则完全一致。
安装很简单:
bash
pnpm i js-tiktoken
然后写了个最小 demo,就测一句话:
javascript
import { getEncoding } from 'js-tiktoken';
// 用 gpt 官方的 cl100k_base 分词规则
const enc = getEncoding('cl100k_base');
const text = "hello world! 你好,世界!";
// 编码:文本 → token ID 数组
const tokens = enc.encode(text);
console.log("Token IDs:", tokens);
console.log("tokens.length:", tokens.length);
// 解码:token ID 数组 → 文本
const decodedText = enc.decode(tokens);
console.log("Decoded Text:", decodedText);
跑出来的结果是这样的:

我当时数了一下:英文部分 "hello"、"world"、"!" 占了 3 个,中文 "你好,世界!" 占了 8 个。算下来差不多一个中文字对应 1.3 个 token 左右,和网上说的 "1 个中文字符 ≈ 0.6 个 token" 刚好对上。
冷知识计价也是按 token 算的,输入输出加在一起计费。所以平时写 prompt 别瞎啰嗦,字字都是钱。国产模型百万 token 大概几块钱人民币,听着便宜,但架不住量大。
这里我踩了第一个坑:一开始我以为 token 就是 "单词",跑了几个例子发现根本不是。比如 "world" 前面带了个空格也算一个 token,有些长单词会被拆成词根 + 后缀,中文更是经常一个字就占一个 token。
它不是按我们语言学上的 "词" 来切的,是按 cl100k_base 这套映射规则来的。这套词表里有大概十万个左右的 token ID,从 0 开始编号。文本进来之后,就按最大匹配的方式切成表里已有的片段,每个片段对应一个数字 ID。
说白了,token 就是大模型的 "识字表" 。模型出生的时候就只认识这十万个符号,再多的它就不认得了。你输入任何文字,最终都会被翻译成这张表里的数字 ID 序列,再送进模型。
光有 token ID 还不够,得变成 "有语义的数字"
到这一步,文本变成了一串数字,比如 [15339, 1917, 0, 220, ...]。
那直接把这串数字丢进神经网络行不行?
我当时觉得也行吧,反正都是数字。后来才明白,差远了。
这些 ID 本质上就是个编号,跟食堂的取餐号一样,只有 "是不是同一个" 的意义,没有 "距离远近" 的概念。比如编号 123 和 124,虽然数字挨在一起,但语义上可能八竿子打不着;而 "猫" 和 "猫咪" 对应的 ID 可能差了好几万,但语义几乎一样。
模型拿这种纯编号算不动。它需要的是 ------每个 token 都对应一组数字,数字和数字之间的 "距离" 能反映出语义的远近。
这就是 embedding 干的事:把每个 token ID 映射成一个高维向量。向量是几百到几千维的浮点数,每一维都代表某种说不清的语义特征。两个向量离得近,就说明语义相似;离得远,就说明语义差得远。
跑个 embedding + 余弦相似度的 demo
我直接调用了通义千问的 embedding 接口,又手写了个余弦相似度计算,测了三句话。
完整代码长这样:
javascript
import OpenAI from 'openai';
import dotenv from 'dotenv';
dotenv.config();
const client = new OpenAI({
apiKey: process.env.DASHSCOPE_API_KEY,
baseURL: process.env.DASHSCOPE_API_BASE_URL,
});
// 文本 → 高维向量
async function getEmbedding(text) {
const res = await client.embeddings.create({
model: 'text-embedding-v4',
input: text,
dimension: 1024, // 向量维度,维度越高语义越精细,计算量也越大
});
return res.data[0].embedding;
}
// 余弦相似度:值越接近 1,语义越相似
function cosineSimilarity(vecA, vecB) {
let dot = 0, magA = 0, magB = 0;
for (let i = 0; i < vecA.length; i++) {
dot += vecA[i] * vecB[i];
magA += vecA[i] ** 2;
magB += vecB[i] ** 2;
}
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
}
async function run() {
const text1 = 'Embedding is a process of converting text into a numerical representation.';
const text2 = "Embeding 是将文本转换为数值表示的过程。";
const text3 = "我是一个学生";
const vec1 = await getEmbedding(text1);
const vec2 = await getEmbedding(text2);
const vec3 = await getEmbedding(text3);
console.log(cosineSimilarity(vec1, vec2)); // 中英文同一个意思,相似度应该很高
console.log(cosineSimilarity(vec1, vec3)); // 完全不相关,相似度应该很低
}
run();
跑出来的结果我截了图:

结果挺有意思的:
- 一句英文一句中文,说的完全是同一件事,相似度达到了 0.88
- 一句讲 embedding 和一句 "我是一个学生",相似度只有 0.19
要知道这两句话字面上来讲没有任何一个字是重合的,但 embedding 就是能把 "语义" 给抓出来。这就是向量化最神奇的地方 ------ 它把 "意思" 翻译成了数学上的 "距离"。
我当时盯着这两个数字愣了几秒。以前总听人说 RAG、语义检索,原理其实就这么简单:把知识库所有段落都转成向量存起来,用户提问的时候也转成向量,去库里找距离最近的几个段落,就是最相关的内容。
整条链路串起来看
到这儿基本就通了。我在代码注释里写了一行,现在回头看还挺准确的:
plaintext
prompt(文本输入)
→ tokens(分词器编码成数字ID)
→ embedding(向量化,带上语义)
→ LLM(Transformer 一顿算)
→ tokens(输出ID)
→ 解码成文本
画个示意图的话大概是这样:

每一步都有它不可替代的作用:
- 分词是为了找到合适的语义单元,把无限的文本映射到有限的词表里
- Embedding 是为了把离散的编号变成连续的向量,让模型能计算语义关系
- 最后 Transformer 在向量空间里做注意力计算,预测下一个 token 是什么
之前我还疑惑过 "为什么 embedding 之后还要经过那么多层 Transformer",现在想明白了 ------embedding 只是给每个 token 准备了一个初始的语义向量,还带着上下文无关的 "字面意思"。经过多层注意力之后,每个位置的向量才会融合整句话的上下文,变成 "在当前语境下的意思"。
比如 "苹果" 这个词,单独的 embedding 可能更偏向水果;但在 "苹果发布了新手机" 这句话里,经过注意力层之后,它的向量就会往 "科技公司" 那边靠。
我踩过的几个坑
坑一:以为 token 数和字数差不多
最开始估算费用的时候,我按 "一个字算一个 token" 粗算,结果实际跑出来差了不少。中文其实是 1 个字约等于 1.3 个 token,英文反而更省,一个单词可能才 1 个 token。长文本算费用的时候,中文要往多了估。
坑二:embedding 向量直接拿来做精确匹配
我一开始想当然地拿向量去做等值比较,后来才反应过来,浮点数哪有完全相等的。语义相似性看的是相对距离,不是绝对相等。一般余弦相似度在 0.8 以上就算高度相关了,0.5 左右就是弱相关。
坑三:维度越高越好
dimension 参数我一开始无脑选最大的,后来发现很多场景 1024 维完全够用。维度越高精度确实会好一点,但存储和计算成本也线性上涨。做召回的话,几百万条向量差几百维,索引体积差很多。
注意不同模型产出的 embedding 向量不能混着算相似度。A 模型的向量和 B 模型的向量,不在同一个语义空间里,算出来的距离没有意义。
最后说几句
其实搞懂这俩概念之后,再去看什么 RAG、Agent、微调,心里就有底了。它们都是在这条基础链路之上玩出来的花样,底层逻辑没变 ------ 大模型自始至终都在跟向量打交道,从来没真的 "读懂" 过文字。
我觉得最关键的收获有三点:第一,token 是计价和处理的最小单位,不是字也不是词,是词表中的一个编号。写 prompt、做上下文窗口估算都得用它来算。第二,embedding 把离散符号变成了连续语义空间,"语义相似" 这件事才能用数学来计算。这是所有语义检索、聚类、分类的基础。第三,这两步只是前置处理,真正的 "理解" 发生在 Transformer 的注意力计算里。但没有这两步,后面的一切都无从谈起。
当然这东西也不是万能的。embedding 擅长抓整体语义,但对精确的数字、专有名词、格式要求经常抓不准,所以 RAG 才需要配合各种召回策略,不能光靠向量相似度一条路走到黑。
如果你之前也对这块一知半解,建议自己动手跑一遍 tiktoken 和 embedding 的 demo,打印出来看看数组长啥样、相似度数值是多少,比看十篇概念文章都管用。跑通了记得回来留个言,我也想看看你是怎么理解这俩概念的。