LLM 的底层语言:从分词到向量化,搞懂 AI 是怎么"读"文字的
v029 搞懂了 Agent 的四块基石------LLM 是大脑,Tools 是手脚,Reasoning 是思考过程,Context 是记忆。
v030 搞懂了 Loop------不是写 Prompt,而是设计循环。Completion → Check → 退出或重试。
v031 搞懂了工程化------用 LangGraph 写 Agent,用 Supervisor 模式让多个 Agent 协作。
但这四天有一个共同的盲区:我们一直在"用" LLM,却从没问过它到底是怎么"读"文字的。
你发一段中文给 DeepSeek,它回复一段流畅的中文。但 DeepSeek 是一个神经网络------神经网络只能处理数字,看不懂中文、英文、任何文字。那它是怎么做到的?
arduino
v031 ──→ v032 今天
工程化 回到底层
怎么写代码 AI 到底怎么"读"文字
今天的内容分两块,一块是算法思维的启蒙,一块是 AI 基础的认知:
- 递归与分治------一种把大问题拆成小问题的思维方式,快排是它的经典案例
- Token 与 Embedding------LLM 处理文字的两个核心步骤:先切词,再向量化
这两块看似无关,但底层逻辑是一样的:都是在拆解问题。 算法把排序问题拆成子数组,LLM 把一段文字拆成 token,再把 token 转成向量。拆得越细,处理得越好。
一、先聊算法:递归与分治
在进入 AI 之前,先建立一种思维方式------递归。
递归是什么
递归的本质很简单:函数调用自己。
javascript
// 求 1+2+3+...+n 的和
// 迭代的方式
function sum(n) {
let total = 0
for (let i = 0; i <= n; i++) {
total += i
}
return total
}
迭代是"一步一步往前走"------从 1 加到 n,循环累加。这是大多数人最直觉的写法。
但换一种思路:
scss
sum(5) = 5 + sum(4)
sum(4) = 4 + sum(3)
sum(3) = 3 + sum(2)
sum(2) = 2 + sum(1)
sum(1) = 1
要算 sum(5),先算 sum(4);要算 sum(4),先算 sum(3)......直到 sum(1),我们知道答案是 1。 然后一层一层返回去。
这就是递归------把一个大问题,拆成一个更小的同类问题,直到小到可以直接解决。
scss
递归的两个要素:
1. 递归公式:sum(n) = n + sum(n-1)
2. 终止条件:sum(1) = 1
缺任何一个,递归要么无限循环,要么无法启动
递归的实际应用:数组扁平化
递归不只是用来算数学题的。看一个实际场景------把嵌套数组展平:
javascript
const arr = [1, [2, [3, 4, 5]]]
// 目标:[1, 2, 3, 4, 5]
这个数组有多层嵌套,你不知道它嵌套了多少层。用迭代处理会非常麻烦------你需要循环、判断、再循环、再判断......
用递归,思路清晰得多:
javascript
function flatten(arr) {
let res = []
for (let i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i])) {
// 如果是数组,递归展平它
res = res.concat(flatten(arr[i]))
} else {
// 如果是元素,直接放入结果
res.push(arr[i])
}
}
return res
}
scss
flatten([1, [2, [3, 4, 5]]])
遍历到 1 → 不是数组 → push(1)
遍历到 [2, [3, 4, 5]] → 是数组 → 递归 flatten([2, [3, 4, 5]])
遍历到 2 → push(2)
遍历到 [3, 4, 5] → 是数组 → 递归 flatten([3, 4, 5])
遍历到 3 → push(3)
遍历到 4 → push(4)
遍历到 5 → push(5)
返回 [3, 4, 5]
返回 [2, 3, 4, 5]
返回 [1, 2, 3, 4, 5]
递归的精髓:你不需要想清楚每一层的细节,只需要想清楚两件事------"当前层做什么"和"什么时候停"。 当前层:遍历数组,遇到元素就收集,遇到数组就递归。停止条件:数组遍历完了。
二、快速排序:分治策略的经典
理解了递归,就能理解分治(Divide and Conquer)------递归是代码的实现方式,分治是算法的设计策略。
为什么需要快排
先看三种基础排序:
scss
冒泡排序:两两相邻比较,大的往后冒泡
选择排序:每次选最小的,放到前面
插入排序:从当前位置往前找,插入到正确位置
三种都是 O(n²)------数据量翻倍,耗时翻四倍
O(n²) 在数据量小的时候无所谓,但数据量一大就扛不住了。 100 万条数据,O(n²) 需要 1 万亿次操作------现代计算机也得跑好几秒。
快速排序是 O(n log n)------同样的 100 万条数据,只需要约 2000 万次操作。快了 5 万倍。
快排的核心思想
核心思想:选一个基准值(pivot),把数组分成两部分
比 pivot 小的 → 放左边
比 pivot 大的 → 放右边
然后对左边和右边分别递归执行同样的操作
直到每个子数组只剩一个元素
举个例子:
ini
原始数组: [2, 4, 1, 0, 3, 5]
选第一个元素 2 作为 pivot
第一轮分区:
比 2 小的: [1, 0]
pivot: [2]
比 2 大的: [4, 3, 5]
递归处理左边 [1, 0]:
选 1 作为 pivot
比 1 小的: [0]
pivot: [1]
比 1 大的: []
→ 左边排好了: [0, 1]
递归处理右边 [4, 3, 5]:
选 4 作为 pivot
比 4 小的: [3]
pivot: [4]
比 4 大的: [5]
→ 右边排好了: [3, 4, 5]
合并: [0, 1] + [2] + [3, 4, 5] = [0, 1, 2, 3, 4, 5]
分而治之------这就是分治策略。 分:把大数组拆成小数组。治:每个小数组排序。合:拼回大数组。
partition 函数:快排的核心
快排的关键在 partition 函数------它负责"分区",把数组分成"比 pivot 小"和"比 pivot 大"两部分。
javascript
function partition(nums, left, right) {
let i = left, j = right
while (i < j) {
// 从右往左找,找到第一个比 pivot 小的
while (i < j && nums[j] >= nums[left]) {
j--
}
// 从左往右找,找到第一个比 pivot 大的
while (i < j && nums[i] <= nums[left]) {
i++
}
// 交换这两个元素
[nums[i], nums[j]] = [nums[j], nums[i]]
}
// 把 pivot 放到正确的位置
[nums[left], nums[i]] = [nums[i], nums[left]]
return i
}
css
双指针的工作过程:
[2, 4, 1, 0, 3, 5]
↑pivot j→ ←i
i 从左往右找比 pivot 大的
j 从右往左找比 pivot 小的
找到后交换
最终 i 和 j 相遇的位置,就是 pivot 的正确位置
这个函数的精髓是"原地交换"------不需要开辟新数组,直接在原数组上操作。 这就是为什么快排的空间复杂度是 O(log n)(递归栈),而不是 O(n)(新数组)。
快排的递归实现
javascript
function quickSort(nums, left, right) {
if (left >= right) return // 终止条件:子数组只有 0 或 1 个元素
let pivot = partition(nums, left, right) // 分区
quickSort(nums, left, pivot - 1) // 递归排左边
quickSort(nums, pivot + 1, right) // 递归排右边
}
三行核心代码。 partition 做"治",两个 quickSort 做"分"。递归自动完成了"分 → 治 → 合"的全部过程。
快排为什么不稳定
less
稳定性:相等元素的相对顺序是否会被打乱
原始: [2a, 2b, 1] (2a 和 2b 值相同,但 a 在 b 前面)
快排后: [1, 2b, 2a] (b 跑到 a 前面了)
→ 不稳定
快排在分区过程中,相等元素可能被交换到不同侧,导致相对位置颠倒。 这在大多数场景下无所谓,但在需要稳定排序的场景(比如按多个字段排序)下,需要选择归并排序等稳定算法。
三、Token:LLM 的最小语言单位
聊完算法,进入今天的重头戏------LLM 到底怎么处理文字。
为什么 LLM 不能直接读文字
arduino
计算机的底层:
CPU 执行的是指令 → 指令操作的是二进制数
一切都是 0 和 1
神经网络的底层:
输入层接收的是向量(一串数字)
中间层做的是矩阵乘法(数字运算)
输出层给出的是概率分布(数字)
从头到尾,没有"文字"这个东西
神经网络只能处理数字------这是由计算机的底层运行机制和模型训练的效率决定的。 不是 LLM 的设计选择,而是物理约束。
那 LLM 怎么处理中文、英文?答案是:先把文字转成数字。
Token:文字的数字身份
arduino
文本 → 分词器(Tokenizer)→ Token ID → 模型处理
"Hello" → [15339]
"世界" → [73112, 99842]
"人工智能" → [20668, 100653]
Token 是 LLM 计价和工作的最小单位。 一个英文字符大约 0.3 个 token,一个中文字符大约 0.6 个 token。你每次调用 LLM API,输入的 prompt 和输出的回复,都是按 token 计费的。
arduino
为什么中文比英文"贵"?
英文: "Hello World" → 2 tokens
中文: "你好世界" → 4 tokens(大约)
训练数据以英文为主 → 英文词表更大 → 英文编码更紧凑
中文在词表中占比小 → 需要更多 token 来表达同样的语义
分词的原理:BPE
LLM 用的分词算法叫 BPE(Byte Pair Encoding,字节对编码)。核心思想很简单:
arduino
1. 从单个字符开始
2. 统计哪些字符经常一起出现
3. 把经常出现的字符对合并成一个新 token
4. 重复步骤 2-3,直到词表大小达到上限
例如:
"lower" 最初被拆成 ['l', 'o', 'w', 'e', 'r']
统计发现 'lo' 经常一起出现 → 合并成 'lo'
统计发现 'low' 经常一起出现 → 合并成 'low'
最终 'lower' 可能变成 ['low', 'er'] 两个 token
BPE 的好处:高频词会被合并成一个 token(效率高),低频词会被拆成多个 token(不会丢失信息)。 这是一种自动平衡------常见的表达紧凑,罕见的表达冗长。
动手实操:js-tiktoken
javascript
import { getEncoding } from 'js-tiktoken'
// 使用 GPT 官方的 token 编码器
const enc = getEncoding('cl100k_base')
const text = "Hello tiktoken! 你好, 世界"
// 编码:文本 → Token IDs
const tokens = enc.encode(text)
console.log("Token IDs:", tokens)
// [9906, 261, 16718, 4797, 0, 57668, 11, 99842]
// 解码:Token IDs → 文本
const decodedText = enc.decode(tokens)
console.log("Decoded Text:", decodedText)
// "Hello tiktoken! 你好, 世界"
ini
cl100k_base 是什么?
cl = code language(代码语言)
100k = 100K 词表大小
base = 基础版本
这是 GPT-3.5/4 使用的编码器
它定义了"哪些字符组合算一个 token"的规则
编码和解码是可逆的。 encode 把文字切成 token ID 列表,decode 把 token ID 列表还原成文字。中间的 token ID 列表,就是 LLM 真正"看到"的东西。
token 的本质
arduino
token 不是单词,token 不是字符,token 是------
"模型词表中的一个条目"
"hello" → 1 个 token(常见词,整个词在词表中)
"tokenization" → 可能是 3 个 token(不太常见,被拆成 token + ization + ...)
"你好" → 2 个 token(中文字符在词表中占比小)
一个 token 对应一个 Token ID(一个整数)
Token ID 是词表中的索引号
关键认知:token 和"词"不是一一对应的。 一个英文单词可能是一个 token,也可能是多个 token;一个中文字通常是 1-2 个 token。分词规则由 cl100k_base 等编码器决定,不是你能控制的。
四、Embedding:从符号到语义
Token ID 只是一个离散的符号------9906 和 9907 之间没有任何语义关系。但 LLM 需要理解语义------"开心"和"高兴"应该比"开心"和"汽车"更接近。
这就是 Embedding 的作用:把离散的 token ID 转换成连续的向量,让语义关系可以用数学计算。
什么是 Embedding
arduino
Token ID: 离散符号
9906 → "Hello"
57668 → "你好"
9906 和 57668 之间没有任何数学关系
它们只是词表中的索引号
Embedding 向量: 连续数值
9906 → [0.12, -0.34, 0.56, ..., 0.78] (1024 维)
57668 → [0.15, -0.31, 0.52, ..., 0.81] (1024 维)
两个向量之间的余弦相似度 ≈ 0.95(非常接近)
因为 "Hello" 和 "你好" 语义相似
Embedding 把 token 从一个"编号"变成了一个"坐标"。 在这个 1024 维的向量空间中,语义相近的词会聚在一起,语义不同的词会离得很远。
1024 维向量空间
arduino
为什么是 1024 维?
二维空间只能表达两个维度的关系(x 轴和 y 轴)
三维空间能表达三个维度的关系
1024 维空间能表达 1024 个维度的"语义特征"
这些维度不是人类能理解的"特征"(比如"情感色彩""正式程度")
而是模型在训练过程中自动学到的抽象特征
维度越高,能表达的语义越细腻
但计算成本也越高
动手实操:Embedding API
javascript
import { OpenAI } from 'openai'
import dotenv from 'dotenv'
dotenv.config()
const client = new OpenAI({
apiKey: process.env.DASHSCOPE_API_KEY,
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
})
async function getEmbedding(text) {
const res = await client.embeddings.create({
model: 'text-embedding-v4',
input: text,
dimension: 1024, // 输出 1024 维向量
})
return res.data[0].embedding
}
async function run() {
const text1 = "Andrej Karpathy LLM Tokenization 分词原理"
const text2 = "卡帕西讲解大模型BPE字词分词"
const vec1 = await getEmbedding(text1)
const vec2 = await getEmbedding(text2)
console.log(vec1.length) // 1024
console.log(vec1.slice(0, 5)) // [0.12, -0.34, 0.56, ...]
}
arduino
text1 和 text2 的关系:
text1: "Andrej Karpathy LLM Tokenization 分词原理"
text2: "卡帕西讲解大模型BPE字词分词"
文本匹配?完全不同------一个是英文为主,一个是中文为主
语义匹配?非常接近------都在讲 LLM 分词
这就是 Embedding 的价值:
文本匹配找不到的相似性,Embedding 可以找到
Embedding 的应用场景
markdown
Embedding 能做什么?
1. 语义搜索
用户问: "怎么学习 AI?"
知识库中有 100 篇文章
→ 把问题和每篇文章都转成向量
→ 找余弦相似度最高的几篇
→ 返回给 LLM 作为上下文(RAG)
2. 文本分类
把文本转成向量 → 在向量空间中聚类
语义相近的文本自然聚在一起
3. 推荐系统
用户喜欢的文章 → 转成向量
找向量空间中最近的其他文章 → 推荐给用户
4. 去重
两段文字表面不同但语义相同 → 向量几乎重合 → 去重
Embedding 是 RAG 的基础。 v031 讲的 Context Engineering,核心就是"让 AI 看到它需要看到的信息"。Embedding 让你能在海量知识中,快速找到和当前问题最相关的几条------这就是"检索增强生成"的"检索"部分。
五、完整的文本处理管线
把 Token 和 Embedding 串起来,就是 LLM 处理文字的完整管线:
ini
用户输入: "怎么学习大模型?"
│
▼
┌─────────────┐
│ Tokenizer │ 分词器
│ (cl100k_base)│
└──────┬──────┘
│
▼
Token IDs: [73112, 99842, 20668, 100653, 30]
│
▼
┌─────────────┐
│ Embedding │ 向量化
│ (1024 维) │
└──────┬──────┘
│
▼
Vectors: [[0.12, -0.34, ...], [0.15, -0.31, ...], ...]
│
▼
┌─────────────┐
│ Transformer │ 模型处理
│ (Attention) │
└──────┬──────┘
│
▼
输出 Token IDs: [20668, 100653, 113577, ...]
│
▼
┌─────────────┐
│ Decoder │ 解码器
└──────┬──────┘
│
▼
输出文本: "学习大模型可以从以下几个方面入手..."
csharp
每一步的作用:
Tokenizer(分词器)
文本 → Token IDs
把文字切成模型能处理的最小单位
"怎么学习大模型" → [73112, 99842, 20668, 100653, 30]
Embedding(向量化)
Token IDs → 向量
把离散符号转成连续数值,保留语义关系
[73112, ...] → [[0.12, -0.34, ...], ...]
Transformer(模型处理)
向量 → 向量
通过 Attention 机制理解 token 之间的关系
"学习"和"大模型"之间的语义关联
Decoder(解码器)
输出 Token IDs → 文本
把模型输出的数字还原成可读的文字
输入的是 prompt 文本,输出的是 completion 文本。但模型处理的全程都是数字。 Token 和 Embedding 是文字世界和数字世界之间的桥梁。
一个容易混淆的点
yaml
Token ≠ Embedding
Token: 文本被切割后的离散符号 ID
"Hello" → 9906(一个整数)
它是词表中的索引号,没有语义
Embedding: Token 被向量化后的连续数值
9906 → [0.12, -0.34, 0.56, ...](1024 个浮点数)
它是语义的数学表达,有距离和方向
Token 是"身份证号",Embedding 是"三维坐标"
知道身份证号,你只能查到这个人叫什么
知道坐标,你能算出他离谁近、离谁远
六、算法思维与 AI 基础的底层连接
今天的内容看似是两个独立的主题------算法(递归/快排)和 AI(Token/Embedding)。但它们的底层逻辑是相通的。
都是"拆解"
ini
递归/分治:
大问题 → 拆成小问题 → 解决小问题 → 合并结果
快排: [2,4,1,0,3,5] → [1,0] [2] [4,3,5] → 排序 → 合并
扁平化: [1,[2,[3,4,5]]] → 递归展平每一层 → 合并
Tokenization:
长文本 → 拆成 token → 模型逐个处理 → 生成新 token → 拼回文本
"怎么学习大模型" → [73112, 99842, 20668, 100653, 30]
→ 模型处理 → [20668, 100653, 113577, ...] → "学习大模型可以从..."
Embedding:
一个高维语义 → 拆成 1024 个维度 → 每个维度一个数值
"开心" → [0.8, 0.2, -0.1, ..., 0.5](1024 个特征)
递归把大问题拆成小问题,Tokenization 把长文本拆成短 token,Embedding 把复杂语义拆成多维数值。 拆解是处理复杂性的通用策略。
都是"抽象"
arduino
递归的抽象:
不需要想清楚每一层的细节
只需要定义"当前层做什么"和"什么时候停"
剩下的交给递归自动处理
Embedding 的抽象:
不需要人工定义"什么是语义相似"
只需要给模型足够多的数据
模型自动学到"哪些词意思接近"
两者的共同点:
把复杂问题抽象成简单的规则/数学表达
让机器自动处理细节
为什么程序员需要理解这些
markdown
你可能不会自己写分词器
你可能不会自己训练 Embedding 模型
但你需要理解它们的原理,因为:
1. 计费
Token 是 LLM 的计价单位
不懂 token,你就不知道一次对话要花多少钱
中文比英文贵(0.6 vs 0.3 token/字),长对话越来越贵
2. 性能优化
上下文窗口是有限的(128K tokens)
懂 token,你才能控制 prompt 的长度
懂 Embedding,你才能设计高效的 RAG 系统
3. 调试
LLM 输出了奇怪的内容?
可能是分词把你的关键词切碎了
懂 token,你能快速定位问题
4. 架构设计
RAG、语义搜索、文本分类------都依赖 Embedding
不懂 Embedding,你就不知道该选什么模型、多少维度
结语
今天从两个看似无关的领域------算法和 AI------学到了同一个道理:复杂性的解法是拆解。
yaml
递归拆解的是问题:
大问题 → 小问题 → 更小的问题 → 直到可以直接解决
快排拆解的是数组:
大数组 → 小数组 → 更小的数组 → 直到只剩一个元素
Tokenization 拆解的是文本:
长文本 → 短 token → 数字 ID
Embedding 拆解的是语义:
复杂语义 → 1024 个维度 → 每个维度一个数值
vbnet
v029 ──→ v030 ──→ v031 ──→ v032
是什么 怎么跑 怎么写 底层原理
Agent Loop 工程化 Token/Embedding
概念 自动化 代码实现 AI 怎么读文字
知道"Agent = LLM+Tools" → 知道"Loop = gen+check+stop" → 知道"用 LangGraph 写出来" → 知道"LLM 底层怎么处理文字"
从工程层下沉到基础层,不是开倒车------而是补地基。你不理解 Token,就无法优化 prompt 成本;你不理解 Embedding,就无法设计高效的 RAG 系统;你不理解递归和分治,就无法看懂 Agent 的递归调用链。
三十二天下来,从"AI 是什么"到"多智能体协作"再到底层的分词和向量化。认知不是一条直线------它更像是螺旋上升,每转一圈,底下的地基就更扎实一点。
Token 是 LLM 的字母表,Embedding 是 LLM 的语义地图。 搞懂了它们,你就真正理解了 AI 是怎么"读"文字的------不是用眼睛,而是用数学。
下篇见。