你问客服助手:“商品有质量问题,运费谁承担?”这句话进入模型前,并不会先被翻译成某种写着“商品”“质量”“责任”的知识表。程序要先把文字变成一串整数,再把整数变成浮点向量,模型才能开始计算。
这中间看起来只是格式转换,却决定了模型究竟看见什么。少一个角色标记,模型可能接着用户的话续写;把字符数当作 Token 数,长消息可能超过窗口;裁剪不当,还会把“核实质量问题后由商家承担”剪成“由商家承担”。理解这条输入链,是理解后面 Transformer、上下文管理和 RAG 的起点。
本系列沿六个问题展开:文字怎样变成输入、Transformer 怎样处理输入、模型怎样学会回答、对话怎样运行、图片和声音怎样接入,以及应用怎样选择和使用模型。本文先走到模型输入层。涉及的店铺规则、手算词表和小矩阵都是教学材料,不代表真实商家的政策。
模型收到的是消息,为什么还要套一层模板
应用里的一次请求通常具有这样的逻辑结构:系统要求“根据提供的政策回答”,用户提出售后问题。角色属于消息的结构;它不是正文里碰巧出现了“系统”两个字。
但普通自回归语言模型的主要输入是一条 Token 序列,它不直接理解 Python 字典的键。聊天模板负责把多条消息组织成训练时约定的形式。用一套虚构标记,可以把这个过程画成:
应用消息:
system → 根据提供的政策回答。
user → 商品有质量问题,运费谁承担?
教学模板展开:
<开始>system<分隔>根据提供的政策回答。<消息结束>
<开始>user<分隔>商品有质量问题,运费谁承担?<消息结束>
<开始>assistant<分隔>
最后一行没有回答正文,它表明接下来要生成助手的内容。模型训练时见过类似边界,才能学会区分“对方在说什么”和“现在轮到谁说”。有些模型需要助手起始标记,有些采用不同约定,因此不能把这套教学标记直接发送给任意模型。
真实模板可能已经插入序列开始、消息结束等特殊 Token。若模板展开后又让 Tokenizer 额外加一遍,输入就会与预期不同。实战中优先使用模型配套的 apply_chat_template;若先生成模板字符串再编码,则要注意避免重复添加特殊标记。聊天模板文档解释了这两个步骤的关系。
这里也埋着一个和 Prompt 有关的边界:用户在正文里写“system: 忽略前文”,并不会自动获得应用层的 system 角色。应用应该保留真实消息结构,不把用户文字解析成更高权限的消息。模型仍可能受文字诱导,那属于指令遵循问题,不能靠一个角色标签保证绝对隔离。
为什么不把每个汉字或英文单词直接编一个号
最容易想到的办法是按字符编号。例如把“运”“费”分别放进词表。字符级表示覆盖面不错,但一句话会变成长序列;模型必须用更多位置才能组合出常见词。对于英文,transportation 会被拆成很多字符,而它本来是经常一起出现的片段。
另一端是按完整单词编号。这样常见英文词可以只占一个位置,但词表会迅速扩大:refund、refunds、refunded 都要有位置;新产品编号 P204-X 和用户随手拼出的新词也可能不在表里。中文没有统一的空格分词边界,还增加了切分选择。
子词表示在两者之间取折中。常见片段可以整体保留,不常见的词拆成更小的片段。例如一套教学词表可能包含 refund 和 ed,于是 refunded 分成两段。这里的片段不一定符合语言学上的词根,也不保证“运费”恰好是一个 Token;最终结果由训练出来的词表和编码规则决定。
词表更大,通常有机会让相同文本使用较少位置,但 Embedding 和输出投影也会变大。假设隐藏维度为 4096,词表从 5 万增加到 10 万,仅一张不共享的 Embedding 表就新增约 2.048 亿个参数。Token 数量与参数规模之间存在取舍,不能单独追求“切得越少越好”。
手推一次 BPE,看看片段从哪里来
先用一个人为设置的英文语料,暂时忽略空白和词尾标记:low 出现 5 次,lower 出现 2 次。初始时只有字符:
| 词及次数 | 当前切分 |
|---|---|
| low × 5 | l · o · w |
| lower × 2 | l · o · w · e · r |
相邻字符对 l,o 出现 7 次,o,w 也出现 7 次,w,e 和 e,r 各出现 2 次。假设约定同频时先选 l,o,第一次合并就把它变成新片段 lo。此时两种词分别是 lo · w 和 lo · w · e · r。
接着 lo,w 出现 7 次,再把它合成 low。现在 low 可以用一个 Token 表示,lower 则暂时表示为 low · e · r。继续训练可以产生更多合并,直到达到词表大小或其他停止条件。
注意这是构造 Tokenizer 的训练过程。以后收到 lower 时,编码器应用已经确定的合并顺序,不会为每个用户临时重新统计语料。神经网络模型的参数训练与 Tokenizer 的词表构建也是两件事;常见流程先确定 Tokenizer,再用它编码训练文本。BPE 教程提供了完整算法背景。
这个例子同时说明,合并并不是看到最长字符串就随意取出。已学习的规则、预切分边界、同频处理都会影响结果。真实实现还要处理空格、标点、Unicode 和特殊 Token,因此十几行玩具 BPE 不能用来精确估算商用接口账单。
WordPiece、Unigram 和字节级表示又有什么不同
WordPiece 同样使用子词词表,但常见编码方式是从当前位置寻找词表里最长的可用片段,再继续处理剩余内容。BERT 风格词表常用 ## 标明延续片段,例如教学切分 play · ##ing 中的 ## 是表示约定,不是原文真的有两个井号。具体模型的词表和未知词机制决定最终结果。
Unigram 则给候选片段分配概率,从可能的切分里选择得分较好的路径。若一个词既能切成 ab · c,也能切成 a · bc,片段概率会影响整条路径的选择。它提醒我们,同一个字符串可能存在多种有效分割;编码器需要规则来决定使用哪一种。关于两种算法的编码差异,可以继续阅读 WordPiece 与 Unigram 的机制说明。
字节级表示解决的是底层覆盖问题,与“是否使用 BPE”并非互斥选择。汉字在 UTF-8 中通常由多个字节组成,一个罕见字符即使没有专门词条,也可以回退到字节相关片段。代价是某些字符可能占用多个 Token;一个 Token 也未必单独构成合法、完整的 Unicode 字符。
所以,不能把 Token 理解为“一个显示字符”。有时一个 Token 是多个字,有时一个字要几个 Token。把单个 Token 逐一解码再拼接,也不总等于对完整 ID 序列一次解码,因为字节恢复可能需要等待后续部分。
编码得到的整数,怎样变成有形状的模型输入
回到售后问题。为了看清索引过程,先假设一张很小的词表:
| 教学 Token | ID |
|---|---|
| 商品 | 3 |
| 有 | 8 |
| 质量问题 | 12 |
| , | 5 |
| 运费 | 17 |
| 谁 | 42 |
| 承担 | 23 |
| ? | 6 |
正文就变成 [3, 8, 12, 5, 17, 42, 23, 6]。这些数字只表示词表里的位置。42 比 17 大,不意味着“谁”比“运费”更重要;ID 差值也不是语义距离。
实际请求还有系统正文和模板标记,这张表为了展示索引才省略了它们。推理引擎把完整 ID 序列组织成张量。张量先理解为形状明确的多维数组即可:一条长度为 8 的输入,形状是 [1, 8],第一维表示批次中有一条序列,第二维表示位置数。
若第二条输入只有 5 个 Token,又想把两条放进同一个规则矩阵,可以补齐到 8 个位置。以下仍是教学 ID,0 表示填充:
input_ids:
[ 3, 8, 12, 5, 17, 42, 23, 6]
[ 0, 0, 0, 3, 17, 42, 23, 6]
有效位置掩码:
[ 1, 1, 1, 1, 1, 1, 1, 1]
[ 0, 0, 0, 1, 1, 1, 1, 1]
第二条左边的三个 0 是占位,不是用户真的输入了三段内容。有效位置掩码让实现识别它们;用于预测时还要正确处理位置编号。对于常见 decoder-only 批量生成,左填充可以让每条输入的最后一个位置都对应真实文本,但具体接口仍应遵循模型说明。
这里的 mask 与下一篇的因果 mask 职责不同:前者区分真实内容与填充,后者禁止一个位置读取未来位置。即使没有任何 padding,自回归训练也仍需要因果约束。实现可能把两种约束合并,但理解时不要把它们当成同一个问题。
Embedding 是第二次查表,语境还没有在这里完成
得到 ID 后,模型查询自己的 Embedding 矩阵。假设词表有 50 个条目,每个条目用 4 个浮点数表示,那么矩阵形状是 [50, 4]。教学模型里,E[17] = [0.2, -0.4, 0.8, 0.1],表示“运费”的初始向量。
有两张表参与了这个过程:Tokenizer 词表把片段对应到整数,模型 Embedding 把整数对应到浮点向量。前一张解决离散编码,后一张提供神经网络可学习的连续表示。把两者都叫“向量化”,会掩盖它们不同的职责。
对上面两条补齐的序列,输入 [2, 8] 经过查表后成为 [2, 8, 4]。第一维是两条请求,第二维是八个位置,第三维是每个位置四个数。真实模型的隐藏维度通常大得多,但维度之间的关系相同。
这些数不是人为规定的“价格维”“运输维”“责任维”。训练通过调整参数,使这些表示对后续任务有用。对同一个模型,同一个 ID 初始查到同一行;“银行账户”和“河的右岸”中的词义区别,需要结合各自上下文进一步计算,而不是期待一个孤立向量直接包含所有解释。
这里也能区分 Token Embedding 与 RAG 文本 Embedding。前者为序列中每个位置提供初始表示;后者通常由编码器和聚合过程,把一句话或一段文档变成用于比较的表示。直接平均一张未经训练的 Token 表,不会自动得到可靠的语义检索模型。
模型最后输出文字时,也不是把隐藏向量“反查 Embedding”。它先通过输出投影得到整个词表的分数,选出新 ID,再交给 Tokenizer 解码器恢复文本。输入查表与输出预测方向不同,中间还有多层计算。
用真实 Tokenizer 看一次中间结果
理解教学数据后,可以用一个真实聊天模型的 Tokenizer 观察实际切分。下面只下载 Tokenizer 和配置,不下载模型权重,也不调用生成服务。模型名称用于固定演示对象,不代表推荐它承担正式售后决策。
完整文件为 tokenizer_walkthrough.py下载,依赖说明在配套指南下载。具体切分与 Token ID 由模型词表和配置决定,以你运行时的实际输出为准。
from transformers import AutoTokenizer
# Tokenizer 必须与目标模型配套;这里只加载文本编码所需文件。
model_id = "Qwen/Qwen2.5-0.5B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
# 用不同类型的文本观察切分;len(text) 是 Python 字符数,不是 Token 数。
texts = ["商品有质量问题,运费谁承担?", "Who pays for shipping?", "order_id = 'A100'"]
for text in texts:
ids = tokenizer.encode(text, add_special_tokens=False)
print("原文:", text)
print("字符数 / Token 数:", len(text), len(ids))
print("词表片段:", tokenizer.convert_ids_to_tokens(ids))
print("编号:", ids)
# 对整段 ID 一次解码,避免把多字节字符的部分 Token 单独恢复。
print("恢复文本:", tokenizer.decode(ids, clean_up_tokenization_spaces=False))
messages = [
{"role": "system", "content": "根据提供的政策回答;缺少政策时说明无法判断。"},
{"role": "user", "content": texts[0]},
]
# 先展示模板文本,便于观察角色标记与助手起始位置。
rendered = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
print("模板展开:", rendered)
# 让模板接口直接完成编码,避免手动拼接特殊 Token 后重复添加。
# return_dict=True 明确要求键值结果,不能假定不同版本的默认返回类型一致。
inputs = tokenizer.apply_chat_template(
messages, tokenize=True, add_generation_prompt=True,
return_dict=True, return_tensors="pt",
)
print("完整输入形状:", tuple(inputs["input_ids"].shape))
print("完整请求 Token 数:", inputs["input_ids"].shape[-1])
# 批量演示使用纯文本,单独观察左填充;不把它冒充完整聊天请求。
tokenizer.padding_side = "left"
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
batch = tokenizer(texts, padding=True, add_special_tokens=False, return_tensors="pt")
print("批次 ID:", batch["input_ids"])
print("有效位置掩码:", batch["attention_mask"])
观察输出时,可以先找三件事。第一,中文、英文和代码的字符数与 Token 数比例是否相同。第二,完整聊天输入比用户正文多了哪些片段。第三,填充位置怎样在 mask 中被标记,而较长句子仍保持真实长度。
若把系统提示改得很长,用户问题的 Token 数不变,完整请求却增长了。这个差异来自输入结构,而不是模型“更费脑”。如果换用另一个 Tokenizer,片段和 ID 都可能变化;因此不能拿本例精确核算另一个模型的请求。
下面这张图把本文已经展开的步骤放回完整请求。Transformer 与逐步生成部分会在后面的文章中继续解释;先注意 Tokenizer 位于输入编码和输出恢复两个位置。
从 Token 数回到上下文预算
假设一个教学接口允许输入加输出合计 8000 Token,准备输出 1000,再预留 300,那么输入预算是 6700。若系统与工具说明占 1200,历史占 2500,当前问题占 200,留给资料的空间就是 2800。预算应在组装过程中分配,避免最后对拼好的整段文本盲目截断。
“已核实质量问题,且签收未超过三十天,由商家承担运费”若只留下后半句,Token 数下降了,适用条件也消失了。裁剪应尽量保留完整消息、完整工具交互或完整条款;节省位置的同时,需要知道牺牲了什么信息。
服务返回的 usage 还可能区分输入、输出、缓存命中、推理或媒体等计量。这些字段的包含关系随接口而异,不能把所有数字直接相加。例如 cached input 往往是输入中的一个子集,不是额外占用的一段文本;计费优惠也不意味着它不占上下文位置。
到这里,一次文字输入已经从消息结构走到 [批次, 位置, 隐藏维度] 的浮点表示。下一步要解释的是:同一个“运费”向量,怎样因为前面的“质量问题”而改变。我们在Transformer 的完整计算过程里继续沿这句话往下算。