三层不要混在一起
- Chat Template 把结构化消息(role、content、tools 等)序列化成模型训练时约定的文本或 token 控制结构。
- Tokenizer 把该序列编码成整数 token ids;BPE、Unigram/SentencePiece、byte-level 等是不同分词算法与实现选择。
- Special Tokens 是词表中具有控制语义的 ids,例如 BOS、EOS、role marker、padding 或 tool delimiter;字符串长得像
<eos>不保证会被编码为那个 special id。
输出是 。下游 embedding 接收 [B,T] 的整数张量(常为 torch.int64);Prefill/Decode 的 prompt 长度、KV Cache 容量、prefix cache key 和 token 计费都基于 ids,而不是 Unicode 字符数或 UTF-8 字节数。
一个具体边界例子
文本 你好, GPU 有 7 个 Unicode code points,却有 11 个 UTF-8 bytes。真实 tokenizer 可能把它切成少于、等于或多于 7 个模型 tokens。若模板再加入 BOS、user role 和 assistant generation prompt,最终 token 数还会增加。
下面的教学 tokenizer 故意使用“每个 UTF-8 byte 一个普通 token”,并把 ids 256–259 留给 special tokens。它不是生产 BPE,但能精确验证模板和 cache key 的边界,而且完全离线。
import hashlib, platform, sys
BOS, USER, ASSISTANT, EOS = 256, 257, 258, 259
SPECIAL = {BOS:"<bos>", USER:"<user>", ASSISTANT:"<assistant>", EOS:"<eos>"}
def encode_text(text):
return list(text.encode("utf-8"))
def apply_chat_template(messages, add_generation_prompt=True):
ids=[BOS]
for message in messages:
role = USER if message["role"]=="user" else ASSISTANT
ids += [role] + encode_text(message["content"])
if add_generation_prompt: ids.append(ASSISTANT)
return ids
def decode(ids, skip_special_tokens=False):
output, pending = [], bytearray()
def flush():
if pending:
output.append(pending.decode("utf-8",errors="strict")); pending.clear()
for token_id in ids:
if token_id < 256: pending.append(token_id)
else:
flush()
if not skip_special_tokens: output.append(SPECIAL[token_id])
flush(); return "".join(output)
def prefix_key(token_ids, model_revision, adapter="none"):
payload=model_revision.encode()+b"\0"+adapter.encode()+b"\0"
payload += b"".join(token.to_bytes(4,"little") for token in token_ids)
return hashlib.sha256(payload).hexdigest()[:16]
def main():
messages=[{"role":"user","content":"你好, GPU"}]
templated=apply_chat_template(messages,True)
without_prompt=apply_chat_template(messages,False)
assert templated[:-1]==without_prompt and templated[-1]==ASSISTANT
assert decode(encode_text("你好, GPU"),True)=="你好, GPU"
assert prefix_key(templated,"model-v1") != prefix_key(without_prompt,"model-v1")
assert prefix_key(templated,"model-v1") != prefix_key(templated,"model-v2")
print(f"python={platform.python_version()} implementation={sys.implementation.name}")
print(f"raw_chars={len(messages[0]['content'])} utf8_bytes={len(encode_text(messages[0]['content']))}")
print(f"with_generation_prompt length={len(templated)} ids={templated}")
print(f"without_generation_prompt length={len(without_prompt)}")
print(f"decoded={decode(templated)}")
print(f"prefix_key_model_v1={prefix_key(templated,'model-v1')}")
if __name__=="__main__": main()
完整文件位于 examples/tokenizer_boundary.py。实际运行得到 11 个 byte tokens,加 BOS、role 和 generation prompt 后总长 14;移除 generation prompt 后长度变 13,prefix key 也改变。
Special token 影响哪些工程边界
- BOS/EOS:是否自动添加必须与 checkpoint 训练约定一致;重复 BOS 或漏 EOS 会改变序列和停止行为。
- Role/tool delimiters:决定 chat turns 的结构,通常直接进入 token ids 和 prefix cache identity。
- PAD:padding side 与 attention mask 共同决定 batch 中哪些位置可见;PAD id 等于 EOS id 时尤其要分清“填充”与“生成停止”。
- Generation prompt:常用 assistant role marker 告诉模型接下来生成 assistant 内容;它不是自然语言后缀,可直接改变首 token 分布。
- Added tokens:在 tokenizer 中注册特殊字符串与仅在文本里出现该字符串不是一回事;added-token normalization 和匹配规则也会影响切分。
Streaming detokenization 不能逐 token 字符串相加
一个 token 可能只包含 UTF-8 多字节字符的一部分,byte fallback token 也可能要等后续 bytes 才能解码。Request Lifecycle 与 Streaming给出可运行 incremental decoder/跨 token stop string 实验。流式系统需要保留未完成 byte buffer,并同时处理 special-token suppression、stop token、stop string 跨 token 边界匹配和 normalization。因而“每到一个 token 就 decode([id]) 再拼字符串”可能产生替换字符或漏掉停止串。
生产差距与失败场景
本页 byte tokenizer 没有 BPE merge、Unigram 概率、normalizer、pre-tokenizer、offset mapping、added-token 优先级或批处理。生产中还要固定 tokenizer 文件哈希与 chat template revision,避免服务重启后同一请求产生不同 ids。
Tokenizer 通常不是 GPU 推理 kernel;它更可能消耗 CPU、线程池和队列时间。极长输入、高并发、复杂正则 normalization 或 Python glue 仍会让 tokenization 进入 TTFT 关键路径,但不能据此把它和模型 prefill 混成同一个 benchmark。
上下游关系
- Prefill/Decode 从本页输出的
[B,T]ids 开始,并把 tokenization/template/queue 与模型 prefill 的时间边界分开。 - KV Cache 的长度按 token positions 增长;Prefix Cache 共享的也是确定 token prefix 对应的模型状态,不是原始 prompt 字符串。
- 后续 Sampling 页面输出新的 token ids;streaming detokenizer 再把它们安全转换为增量 bytes/text。