大模型的输入与输出(一):从文本到 Token —— Tokenizer 与多模态输入

大模型并不直接「读」文本,也不直接「看」图片——所有输入都要先被翻译成模型唯一认识的数学对象:Token(词元)。本文从两种最基本的输入形式出发,讲清楚文本和图片是如何一步步变成 Token 序列的。

概述

无论输入是文本还是图片,Transformer 内部处理的始终是同一种东西:一维的 Token 向量序列。因此大模型输入端的核心任务只有一个——把异构的原始输入「翻译」成 Token 序列。这个翻译过程分两条路线:

1
2
文本 → Tokenizer(分词器)→ Token ID 序列 → Embedding 查表 → 文本 Token 向量
图片 → Patch 切分 → ViT 特征提取 → MLP 对齐 → 视觉 Token 向量

两条路线的产物在数学形式上完全一致,最终拼接成一条序列送入 Transformer。本文先讲文本路线,再讲图片路线。

文本输入:Tokenizer

Tokenizer 与大模型是完全独立的两个阶段

一个常见的误解是「分词」属于大模型推理的一部分。实际上它们是完全独立的两个组件:

阶段 工具 功能 算力需求
Tokenizer 分词器(BPE 算法等) 文本 → Token ID 序列 无需 GPU,本地 CPU 即可运行
Transformer 大模型本体 Token ID 序列 → 概率分布 → 采样输出 需要 GPU,消耗大量算力

这一独立性带来三个直接推论:

  1. Token 数量与大模型内部的矩阵乘法毫无关系。API 返回的 prompt_tokenscompletion_tokens 是 Tokenizer 瞬间统计出来的,不是模型「算」出来的。
  2. Tokenizer 可以在本地独立运行。统计 Token 数、预估调用费用、截断上下文长度等操作都不需要调用大模型。
  3. 每个模型有自己的词表。同一段文本,GPT、Qwen、LLaMA 数出的 Token 数可能不同——它们的词表各不相同,因此「多少 Token」永远要问「哪个模型的 Tokenizer」。

为什么需要 Tokenizer

大模型的词表是有限的(通常几万到十几万个 Token),而自然语言理论上可以组合出无限的词。Tokenizer 要解决的核心矛盾是:用有限的词表覆盖无限的输入

如果按「一个单词一个 Token」划分,问题立刻暴露:

  • 词表会爆炸:英语有数十万单词,加上时态、复数等形态变化会更多;
  • 永远有没见过的新词(OOV,Out-of-Vocabulary),遇到只能丢弃或替换;
  • 生僻词占据词表容量,利用率极低。

现代大模型普遍采用 BPE(Byte Pair Encoding,字节对编码) 或类似的子词算法:词表里存的不是完整单词,而是高频的子词片段。例如 tokenization 可以拆成 token + ization——这两个子词还能分别出现在其他单词中,词表利用率大幅提升。

BPE 词表的构建过程大致是:

  1. 统计训练语料中所有相邻字符对的出现频率;
  2. 合并频率最高的字符对,生成一个新子词并加入词表;
  3. 重复合并,直到词表达到预设大小。

最终得到的是一个「从字符级到单词级」的混合词表:最高频的词(如 the)整体保留,低频词被拆成若干子词。

从文本到 Token ID

Tokenizer 的输出并不是 Token 文本本身,而是 Token ID(整数编号)

1
2
输入: "Hello, world!"
输出: [15496, 11, 995, 0]

实际操作中,用 HuggingFace 的 transformers 库一行代码即可完成这个转换,整个过程在本地完成,与 GPU 无关:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from transformers import AutoTokenizer

# 加载 Qwen3.5 的 Tokenizer:仅需词表文件(几 MB),在本地 CPU 上运行,无需 GPU
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3.5-0.8B")

# 文本 → Token ID 序列
ids = tokenizer.encode("Hello, world!")
print(ids)
# OUTPUT
# [9419, 11, 1814, 0]

# Token ID 序列 → 文本(可逆)
print(tokenizer.decode(ids))
# OUTPUT
# Hello, world!

# Token 数量 = 序列长度,由 Tokenizer 瞬间统计得出
print(len(ids))
# OUTPUT
# 4

# 中文示例:字节级 BPE 下,常用词组与汉字通常各占 1 个 Token
ids_zh = tokenizer.encode("你好,世界!")
print(ids_zh, len(ids_zh))
# OUTPUT
# [109266, 3709, 96748, 6115] 4

# 每个模型有自己的词表:同一段文本在不同模型下的 Token ID 不同
print(AutoTokenizer.from_pretrained("gpt2").encode("Hello, world!"))
# OUTPUT
# [15496, 11, 995, 0]

注意:上文的 [15496, 11, 995, 0] 正是 gpt2 词表的结果——同一个字符串,不同模型的 Token ID 完全不同,这正是「每个模型有自己的词表」的直接体现。

Token ID 只是词表中的索引,模型不能直接对整数做数学运算——编号之间没有语义关系(9419 和 9420 相邻,不代表两个词意思相近)。真正的输入是每个 ID 在 Embedding 层查表得到的向量:

从 Token ID 到向量:Embedding 查表

Embedding 层是一张巨大的「词义表」:形状为 词表大小 × 嵌入维度 的矩阵(如 Qwen3.5 约 15 万 × 1024),是模型参数的一部分,在预训练中通过梯度更新学出来:

1
2
3
4
5
6
7
8
Embedding 表(词表大小 × 嵌入维度)
─────────────────────────────────────────
行 0 : [0.12, -0.31, ..., 0.45] ← 每个词条 1024 个浮点数
行 11 : [-0.02, 0.77, ..., 0.19]
行 1814 : [0.55, 0.03, ..., -0.28]
行 9419 : [0.31, -0.52, ..., 0.68] ← "Hello" 这个词条的"坐标"
...共 15 万行
─────────────────────────────────────────

「查表」就是把 Token ID 当作行号,取出那一行的向量:

1
2
3
[9419, 11, 1814, 0]       ← 4 个整数
↓ 按行号查表
[vec1, vec2, vec3, vec4] ← 形状 [4, 1024] 的向量矩阵

这串向量是词条的「静态词义坐标」:语义相近的词,坐标天然相近。之后送入 Transformer 各层,经过注意力计算逐步融合上下文——同一个词在不同句子里会得到不同的最终表示(如 bank 在「river bank」和「money bank」中最终的向量不同),这正是 Transformer 各层真正在计算的东西(见本系列第二篇)。

Embedding 表也是模型参数量的组成部分,其规模 = 词表大小 × 嵌入维度,占比随模型规模变化:

模型 Embedding 参数量 总参数量 占比
Qwen3.5-0.8B ~1.56 亿 ~8 亿 ~19%
LLaMA-2-7B ~1.31 亿 70 亿 ~1.9%
Qwen2.5-72B ~12.4 亿 720 亿 ~1.7%

小模型(1B 以下)里 Embedding 是参数大户;模型越大,Transformer 各层的平方级增长(每层 ≈ 维度² × 常数,再乘层数)越占主导,Embedding 占比被稀释到 1~2%。另外现代模型普遍将输入 Embedding 与输出头(lm_head)权重绑定,两处共享同一张表,不重复计费。

类比查字典:Token ID 是页码,Embedding 表是词典正文,查表是按页码取出词条内容——只不过这里的「词条内容」是一串数字坐标。从这一刻起,文本不再是「文字」,模型只跟坐标打交道,与原始字符串再无关系。

输出形态:N×M 矩阵

输入 N 个 Token ID,Embedding 查表输出的是一个 N 行 M 列的矩阵(N 为序列长度,M 为嵌入维度),而不是一行:

1
2
3
4
5
6
7
8
9
[9419, 11, 1814, 0]        N=4 个整数
↓ Embedding 查表
┌─────────────────┐
│ vec1 │ ← "Hello" 的词向量
│ vec2 │ ← "," 的词向量
│ vec3 │ ← " world" 的词向量
│ vec4 │ ← "!" 的词向量
└─────────────────┘
形状 [4, 1024] 的矩阵

两个关键点:

  1. Embedding 查表没有「融合」:N 个 ID 各自查各自的表,vec1 的计算完全不看 vec2。融合发生在后面的 Transformer 各层——每层的注意力机制让行与行之间「交流」,层层加工后,每一行才变成「融合了整句上下文」的表示;
  2. 模型输出也是 N×M 矩阵,但预测下一个词只用最后一行:最后一个位置的向量通过 L 层注意力吸收了前面所有 Token 的信息,乘以输出权重得到 logits(词表大小的概率分布),再采样出下一个 Token。
1
2
3
4
5
6
7
8
9
Token ID [N 个整数]
↓ Embedding 查表(无交互,只是"取货")
[N × M] 矩阵 → 这才是真正进入大模型的数据
↓ Transformer 第 1 层(注意力:行间开始融合)
[N × M]
↓ ... 第 L 层
[N × M] 矩阵 → 每一行都是融合了上下文后的表示
↓ 取最后一行
[M 维向量] → 输出权重 → logits → Softmax → 采样下一个 Token

后续「层层融合」的计算细节,见本系列第二篇。

特殊 Token

除了常规词表,Tokenizer 还会插入特殊 Token 承担结构控制功能:

特殊 Token 作用
<BOS> 序列开始标记
<EOS> 序列结束标记——模型采样到它即停止生成
<PAD> 填充标记,批处理时对齐不同长度的输入
<|im_start|> 对话模板中的角色分隔符(system/user/assistant)

其中 <EOS> 最为关键:它出现在词表概率分布中的那一刻,就是模型「决定结束回答」的时刻——它是生成过程结束的信号(详见本系列第三篇)。

Token 数是怎么算出来的

「Token 数」的本质很简单:Token ID 列表的长度。API 返回的 prompt_tokenscompletion_tokens 就是两份 ID 列表的长度,由 Tokenizer 瞬间统计得出,与大模型推理计算无关:

1
"Hello, world!" → [9419, 11, 1814, 0] → 4 tokens

但实际计费中,有三个容易踩坑的细节:

1. 计费的是模板填充后的完整序列

送入模型的不是用户的原始输入,而是对话模板填充后的完整序列——<|im_start|><|im_end|>user/assistant 角色标记等特殊 Token 全部入列计费:

1
2
3
用户原始输入: "你好"(2 tokens)
实际送入模型: <|im_start|>user\n你好<|im_end|>\n<|im_start|>assistant\n
计费的 prompt_tokens: 明显多于 2

所以账单上的 prompt_tokens 通常比用裸文本跑 len(tokenizer.encode(...)) 的结果大几个 Token。

2. 多轮对话的重复计费

大模型是无状态的,每轮请求都要携带全部历史对话

1
2
3
第 1 轮: prompt = System + Q1                     (1000 tokens)
第 2 轮: prompt = System + Q1 + A1 + Q2 (1250 tokens)
第 3 轮: 历史继续变长...prompt_tokens 线性增长

轮次越多、历史越长,prompt_tokens 越大。注意这与第四篇讲的 KV Cache 缓存命中并不矛盾:缓存省的是服务端的算力,不省计费的 token 数

3. 多模态的图片也占 token

图片经过 ViT + MLP 变成视觉 Token 后同样入列计费:

1
2
一张 224×224 的图 → 196 个视觉 Token → 计入 prompt_tokens
高清图动态切片后 → 数千甚至上万个视觉 Token

「一张图 = 多少 token」取决于编码方案和分辨率,通常在几百到上千的量级。

生成侧同理:completion_tokens 就是模型生成的那串 ID 的长度——每采样出一个 Token,列表 +1,直到采出 <EOS>(生成过程详见本系列第三篇)。

一句话总结:Token 数 = 送入模型(或模型生成)的完整 Token ID 序列长度——包括模板标记、图片 Token,而不是用户原始输入的长度。

中文分词的特殊性

中文没有空格分隔,BPE 的粒度选择对中文影响很大。实践中主流模型(如 Qwen)采用字节级 BPE:最底层单位是 UTF-8 字节,常用汉字通常占 1 个 Token,生僻字可能被拆成多个字节级 Token。这也是「同样的语义内容,中文的 Token 效率普遍低于英文」的原因之一。

图像输入:多模态处理

四步流水线

图片进入大模型,走一条完全不同的处理路线:

1
图片 → 切成 Patch → ViT 提取特征 → MLP 翻译对齐 → 视觉 Tokens → 与文本 Tokens 拼接 → 喂给 LLM

Step 1: 切分(Patch Embedding)

Transformer 只能处理一维向量序列,而图片是二维像素矩阵。第一步就是「拍平」:

  • 将图片(如 224×224)切成 16×16 像素的小方块(Patch),共 196 个;
  • 每个 Patch 通过线性投影变成一个特征向量;
  • 加上位置编码(Positional Encoding),保留 Patch 之间的空间关系。

Step 2: 编码(Vision Encoder / ViT)

切分后的 Patch 序列送入 ViT(Vision Transformer)——一个标准的 Transformer 编码器:

  • 通过自注意力机制,各图块之间互相「交流」,建立全局视觉关联;
  • 输出包含丰富视觉信息的特征矩阵(如 196 个 1024 维向量)。

到这一步,图片已经变成了一串向量,但它还处于「视觉语义空间」,与文本语义空间并不相通。

Step 3: 翻译对齐(MLP Projector)—— 关键步骤

ViT 输出的视觉特征维度(如 1024)与 LLM 的文本嵌入维度(如 4096)不一致,语义空间也不同。MLP Projector 的任务是做一次「翻译」:

1
视觉特征 (196 × 1024) → MLP 投影 → (196 × 4096) → 与文本 Token 处于同一数学空间

这一步是整个多模态管线的关键:经过投影后,图像特征在数学空间中与文本 Token 的分布对齐,LLM 才能用同一套参数对图文进行联合推理。有些实现还会在投影时做池化压缩(如每 2×2 个 Patch 合并为 1 个 Token),将视觉 Token 数量缩减为原来的 1/4,以降低后续计算量。

Step 4: 拼接(Concatenation)

视觉 Tokens 与文本 Tokens 被串联为统一序列:

1
[<BOS>][视觉 Token 1 ... 196][文本 Token 1 ... N] → LLM

送入 LLM 的 Transformer 层进行图文联合推理。对模型而言,它看到的只是一条更长的 Token 序列——视觉 Token 和文本 Token 在计算上没有任何区别。

这正是「多模态」得以实现的原因:把一切输入统一为 Token 序列。模型的注意力机制并不关心 Token 来自文本还是图片,它只关心向量之间的关系。

两类输入的统一视角

维度 文本路线 图片路线
处理工具 Tokenizer ViT + MLP Projector
中间产物 Token ID → Embedding 向量 Patch 特征 → 投影向量
最终形态 与 LLM 嵌入维度一致的向量序列 经投影对齐后的向量序列
进入模型的方式 直接入列 与文本 Tokens 拼接后入列

高清图片的挑战

上述流程隐含一个问题:Patch 数量与图像分辨率成正比,而 Transformer 的计算量与序列长度呈平方关系。

  • 一张 224×224 的图片只有 196 个 Patch;
  • 一张 4K 图片(3840×2160)切出的 Patch 可达数万个;
  • Attention 计算量随序列长度平方增长,高分辨率图片会带来计算量爆炸。

业界的主要应对手段:

  1. 动态切片(Tiling / Dynamic Resolution):不把整张高分辨率图一次性编码,而是切成若干子图分别编码后再组合,避免序列过长,同时保留细节;
  2. KV 压缩:对视觉 Token 做池化/下采样(如每 4 个 Patch 合并为 1 个 Token),减少进入 LLM 的视觉 Token 数量。

小结

本文建立了整个系列最重要的一个心智模型:大模型只处理 Token 序列。文本经由 Tokenizer、图片经由 ViT + MLP Projector,殊途同归地变成向量序列。下一篇把模型本身拆开:Embedding、Transformer 层与输出头的内部解剖。

相关阅读