Tokenizer 与上下文窗口原理
这一章的目标不是让你记几个名词,而是让你能把"为什么这次上线突然更贵、更慢、更容易截断、更容易 JSON 失败"解释清楚。很多团队把 token 当计费单位,但在测试里,token 更像输入分辨率、上下文货币和稳定性上限的集合体。
教学导读
**定位:**这一章解决"为什么 token 会同时影响成本、延迟、截断、结构化输出和多轮记忆"的底层问题。 **前置依赖:**建议至少已经理解 Prompt、RAG、结构化输出和多轮会话的基本测试场景。 **适用场景:**Prompt 评测、JSON 抽取、Tool Calling、RAG 拼接、长上下文问答、多轮客服 Bot。 **学完产出:**你应该能手工拆一个请求的 token 预算,并把"为什么这次上线更贵更慢更容易截断"解释成可执行的测试方案。
先立一个判断。
对传统测试来说,输入长度更多是"边界值"的问题;对大模型测试来说,输入长度会直接改变模型看到世界的方式。你给模型的每一段文字、每一个工具 schema、每一轮历史消息,都在和其他内容竞争上下文窗口。竞争的结果不是"有或没有",而是"谁被压缩、谁被截断、谁更容易被忽略"。
01. Token 到底是什么
Token 不是字,也不是词,更不是字符。它更像模型词表里的"最小可识别碎片"。英文常见词可能一个 token 就够,中文一个字有时一个 token,有时多个字组合成一个 token,代码、URL、emoji、特殊符号常常会把 token 数迅速抬高。
字符视角
你看到的是"这个输入只有 300 个字"。
Tokenizer 视角
模型看到的是"这里有 700 个 token,且 schema 和历史消息已经占掉 500 个"。
计费视角
供应商按 token 计费,所以改一版 prompt 不只影响结果,也影响成本。
测试视角
token 数变化会影响延迟、截断、结构化输出成功率、函数调用稳定性和 RAG 命中质量。
1.1 为什么"字数差不多"却 token 差很多
| 输入类型 | 表面长度 | token 风险 | 原因 |
|---|---|---|---|
| 普通中文句子 | 短 | 中 | 常见字可能较稳定,但专有名词、混合符号会抬升 token |
| 中英混合 + 代码 | 中 | 高 | 标点、路径、下划线、驼峰命名会被切得更碎 |
| JSON Schema | 长 | 很高 | 字段名、嵌套层级、引号、括号共同拉高 token 数 |
| 多轮历史消息 | 长 | 很高 | 不仅有内容,还有 role、tool 调用记录、系统说明 |
1.2 测试岗位最常见的误区
误区一:token 只是计费指标。
实际它同时还是延迟指标、截断风险指标、上下文污染指标和回归不稳定放大器。一个提示词改动如果把输入 token 从 1400 拉到 2600,你看到的通常不只是"更贵",而是首 token 慢、历史更容易被挤掉、JSON 更容易不闭合、RAG 上下文更容易被稀释。
1.3 从信息论看 Token 的本质
从信息论的角度,tokenizer 的目标可以用最小描述长度(Minimum Description Length, MDL)来理解。给定语料 C 和词表 V,总编码长度为: L(C, V) = |V| × log|V| + Σ_{t∈C} -log P(t|V) 第一项是词表本身的存储代价——词表越大,每个条目需要更多 bit 来编码;第二项是语料用这个词表编码后的总 bit 数。BPE 的 merge 操作本质上是在这两项之间做平衡:每一次 merge 增加 |V| 一个条目(增大第一项),但把高频 pair 变成单个 token,减小第二项。当边际收益(第二项下降)不再超过边际成本(第一项上升)时,merge 停止。 这解释了为什么现代大模型的词表通常在 32k-150k 之间——太小则编码效率低(同样的文本需要更多 token),太大则 embedding 矩阵膨胀且低频 token 的 embedding 学不充分。
02. 手撕 BPE:为什么一个词会被切碎
BPE(Byte Pair Encoding)可以粗略理解成"从字符开始,不断把训练语料里高频共同出现的片段合并成更大的单元"。它不是在追求语言学上的"词",而是在追求统计上高频、压缩效果好、对模型训练友好的切分方式。 一个极简 BPE merge 例子 c u s t o m e r
合并高频对:c + u → cu → st → st → omer → omer
cu st omer
2.1 真正值得测试人员关注的不是算法细节,而是结果特性
- 高频片段更可能被合并成大 token,所以常见表述更省 token。
- 冷门术语、随机字符串、日志片段、base64、长 URL 更容易被切碎。
- 同样的意思,写法不同,token 数可能明显不同。
- 你在系统里加入一段特别长的 schema 或工具说明,会吞掉本该给业务输入的上下文空间。
2.2 手算一个简单的 merge 过程
假设语料里高频对依次是 ("退","货")、("系统","提示")、("json","schema")。那么:
原始输入:退 / 货 / 流 / 程 / json / schema / 很 / 长
第一轮 merge:退货 / 流 / 程 / json / schema / 很 / 长
第二轮 merge:退货 / 流 / 程 / jsonschema / 很 / 长
结论:
1. 常见业务词"退货"会更紧凑。
2. 技术词"json schema"是否能被紧凑表示,要看词表是否收录高频合并。
3. 自定义字段名、随机订单号通常没有这种好处,token 更碎。2.3 BPE 训练算法的完整伪代码
下面是 BPE 训练阶段的精确过程。理解它能帮助你预判哪些输入容易"token 膨胀":
def train_bpe(corpus: list[str], vocab_size: int):
# 第一步:初始化词表为所有单字节(UTF-8 byte)
vocab = {bytes([i]) for i in range(256)}
# 将语料拆成 byte 序列
splits = [list(word.encode("utf-8")) for word in corpus]
while len(vocab) < vocab_size:
# 第二步:统计所有相邻 pair 的出现频次
pair_freq = Counter()
for seq in splits:
for i in range(len(seq) - 1):
pair_freq[(seq[i], seq[i+1])] += 1
if not pair_freq:
break
# 第三步:找到频次最高的 pair
best_pair = max(pair_freq, key=pair_freq.get)
# 第四步:合并这个 pair,生成新 token
new_token = best_pair[0] + best_pair[1]
vocab.add(new_token)
# 第五步:在所有序列中执行替换
for seq in splits:
i = 0
while i < len(seq) - 1:
if (seq[i], seq[i+1]) == best_pair:
seq[i] = new_token
del seq[i+1]
else:
i += 1
return vocab, merge_rules测试启发。
当你在评测集中设计边界场景时,不能只看"文本字数"。应该专门准备几类 token 不友好的输入:超长链接、带表情的用户提问、日志片段、路径、SQL 片段、base64、口语化错别字、中英混排。它们更容易暴露上下文预算和结构化输出的脆弱性。
02.3 四种 Tokenizer 算法完整推导与对比
现代 LLM 使用的分词算法可以归纳为四大家族。它们的核心差异在于:从哪里开始(字符 vs 词)、怎么选 token(频率 vs 概率)、怎么处理歧义(贪心 vs 全局最优)。理解这些差异对测试至关重要——不同算法对同一段文本会切出不同数量和粒度的 token,直接影响成本和截断行为。
自底向上
BPE 和 WordPiece 都从最小单元(字节或字符)出发,不断向上合并。
自顶向下
Unigram 从一个超大词表开始,逐步裁剪低价值 token。
预处理统一
SentencePiece 把整个输入当 raw byte stream,不依赖语言特定的空格分词。
BPE(Byte Pair Encoding)
前面已经手撕过基本流程。这里补充关键的数学性质:每一步 merge 选择的 pair (a, b) 满足: (a*, b*) = argmax_{(a,b)} freq(a, b) BPE 是纯粹的频率贪心算法。它的优势是实现简单、可复现、训练快;劣势是每一步的贪心选择不保证全局最优。GPT 系列、LLaMA、Mistral 均使用 BPE 变体。
WordPiece
WordPiece 由 Google 在 BERT 中使用,和 BPE 的核心区别在于合并准则。BPE 选频次最高的 pair;WordPiece 选的是使语言模型似然增益最大的 pair: score(a, b) = freq(ab) / (freq(a) × freq(b)) 这个公式本质上是互信息(Pointwise Mutual Information)的变体。当两个片段 a 和 b 总是一起出现而很少单独出现时,score 最高。这意味着 WordPiece 更倾向于合并语义上紧密绑定的片段,而不仅仅是统计高频的片段。 对测试的影响:同一段文本在 BERT tokenizer(WordPiece)和 GPT tokenizer(BPE)下的 token 数可能相差 10-30%。如果你的系统同时使用 BERT 做 embedding 和 GPT 做生成,token 计数必须分别统计。
Unigram Language Model
Unigram 采用完全不同的策略——自顶向下。它从一个大词表开始(通常包含所有出现过的子串,初始大小可能是百万级),然后逐步移除对语料编码代价影响最小的 token。 给定词表 V 和语料 C,Unigram 定义每个 token t 的概率为 P(t),一个词 w 的所有可能分词方式 S(w) 中,最优分词为: w* = argmax_{s∈S(w)} Π_{t∈s} P(t) 取 log: w* = argmax_{s∈S(w)} Σ_{t∈s} log P(t) 训练时使用 EM 算法迭代:E 步用 Viterbi 找每个词的最优分词,M 步重新估计 P(t),然后裁剪掉移除后 loss 增加最小的 token,直到词表缩小到目标大小。 Unigram 的独特优势在于它天然支持多种分词候选的概率采样,这在某些生成任务中可以引入有益的随机性(subword regularization)。T5、mBART 使用 Unigram。
SentencePiece:语言无关的统一框架
SentencePiece 不是一种新的分词算法,而是一个工程框架,可以在内部使用 BPE 或 Unigram。它的核心贡献是:
- **无预分词:**不依赖空格来划分词边界,将输入视为 raw Unicode 流。这对中文、日文等无空格语言至关重要。
- **可逆性:**空格被编码为特殊字符 ▁(U+2581),保证 detokenize 后能完美还原原始文本。
- **字节回退(byte fallback):**任何 Unicode 字符即使不在词表中,也能通过 UTF-8 字节序列表示,永远不会出现 UNK。
四种算法的工程对比
| 维度 | BPE | WordPiece | Unigram | SentencePiece |
|---|---|---|---|---|
| 构建方向 | 自底向上 | 自底向上 | 自顶向下 | 框架(包装 BPE/Unigram) |
| 合并准则 | 频次最高 | 似然增益最大 | EM + 裁剪 | 取决于内部算法 |
| UNK 处理 | byte fallback | ##前缀拆分 | 概率回退 | byte fallback |
| 代表模型 | GPT/LLaMA/Mistral | BERT/DistilBERT | T5/mBART/ALBERT | LLaMA/Gemma |
| 中文友好度 | 中(需要好的预分词) | 中 | 高 | 高(原生支持) |
| 训练速度 | 快 | 中 | 慢 | 中 |
| 分词确定性 | 确定 | 确定 | 可随机采样 | 取决于内部算法 |
测试落地建议。
在做跨模型对比测试时,务必注意不同模型使用的 tokenizer 家族不同。同一段 prompt 在 GPT-4(BPE, cl100k_base)和 Gemini(SentencePiece)下的 token 数可能差异 15-40%。你的 token budget 不能跨模型复用,必须分别测量。
02.5 为什么代码、URL、emoji 特别容易把 token 数打爆
对测试人员来说,这一节很实用,因为线上最容易低估的就是"看起来没多长"的技术型输入。日志、JSON、URL、文件路径、SQL、base64、emoji、Markdown 表格,都会让 token 数快速膨胀。
| 输入类型 | 为什么容易膨胀 | 测试风险 |
|---|---|---|
| URL / 文件路径 | 斜杠、点、问号、参数键值会被切成很多碎片 | history 或 tool 参数里很容易隐藏高 token 成本 |
| JSON / 日志 | 引号、括号、字段名重复、时间戳都增加分词碎片 | 结构化抽取与日志分析场景成本和截断风险更高 |
| 代码片段 | 驼峰、下划线、符号、缩进、泛型符号都不友好 | 代码助手、SQL 分析容易首包变慢 |
| emoji / 稀有符号 | Unicode 表达复杂,经常不是一个字符一个 token | 社交产品、UGC 评论场景要特别测 |
一个直观例子:
用户输入 A:请帮我总结这段退款规则
用户输入 B:请帮我总结这段退款规则,并分析这个 JSON:
{"bizCode":"refund_2026","trace_id":"8abf-9911-xx","payload":{"items":[...]}}
两者字数差距看起来不夸张,
但 B 的 token 往往会明显更高,
因为字段名、引号、括号、下划线、trace id 都会被切得更碎。02.6 tiktoken 与 HuggingFace tokenizers 源码剖析
理解 tokenizer 的实现细节能帮助你定位"为什么线上和离线计数不一致"等诡异问题。这里剖析两个最常用的库。
tiktoken 架构:Rust 核心 + Python 绑定
tiktoken 是 OpenAI 开源的 BPE tokenizer 实现。它的性能秘密在于核心编解码逻辑用 Rust 编写,通过 PyO3 提供 Python 绑定。关键代码路径:
# Python 入口层 (tiktoken/core.py)
class Encoding:
def encode(self, text: str) -> list[int]:
# 调用 Rust 实现的 _tiktoken.CoreBPE
return self._core_bpe.encode(text)
# Rust 核心层 (src/lib.rs) 简化逻辑
fn _encode_native(&self, text: &str) -> Vec<u32> {
let mut tokens = Vec::new();
// 第一步:用正则按预分词模式切分
// GPT-4 的 cl100k_base 使用的正则:
// (?i:'s|'t|'re|'ve|'m|'ll|'d)
// |[^\r\n\p{L}\p{N}]?\p{L}+
// |\p{N}{1,3}
// | ?[^\s\p{L}\p{N}]+[\r\n]*
// |\s*[\r\n] |\s+(?!\S) |\s+
for piece in regex.find_iter(text) {
// 第二步:在 merge 表中查找最优切分
// 使用 BPE merge ranks 做贪心匹配
match self.encoder.get(piece.as_bytes()) {
Some(&token) => tokens.push(token), // 完整匹配
None => {
// 第三步:逐步应用 merge 规则
tokens.extend(byte_pair_merge(
piece.as_bytes(),
&self.encoder
));
}
}
}
tokens
}关键设计决策的测试影响。
**预分词正则:**tiktoken 在执行 BPE 之前先用正则把文本切成"片段"。这意味着 BPE merge 永远不会跨越正则边界。例如 's 会被单独切出来而不会和前面的词合并。这解释了为什么 it's(2 tokens)和 its(1 token)的 token 数不同。 特殊 token 处理:<|endoftext|> 等特殊 token 在 encode 时有单独路径,默认的 encode() 不允许特殊 token 出现在普通文本中——这是一个安全边界,防止 prompt injection 通过特殊 token 操纵模型行为。
HuggingFace tokenizers 库关键路径
HuggingFace 的 tokenizers 库同样用 Rust 编写,设计为一个通用的 tokenizer pipeline,核心流程是五个可插拔阶段:
Normalizer → PreTokenizer → Model (BPE/WP/Unigram) → PostProcessor → Decoder
| 阶段 | 职责 | 测试关注点 |
|---|---|---|
| Normalizer | Unicode 归一化(NFC/NFD/NFKC)、大小写转换、去重音 | 不同归一化策略下同一文本的 token 数可能不同;中文全角/半角转换 |
| PreTokenizer | 按空白、标点等规则做初步切分 | 中文无空格语言是否正确处理;代码中的特殊符号切分粒度 |
| Model | 核心分词算法(BPE/WordPiece/Unigram) | merge 规则的确定性、vocab 大小对 OOV 率的影响 |
| PostProcessor | 添加 [CLS]、[SEP] 等特殊 token | 特殊 token 占用的 budget 是否被正确计入 |
| Decoder | 将 token ID 序列还原为文本 | 编解码往返一致性:encode(decode(ids)) == ids |
# 验证 tokenizer 编解码一致性的测试脚本
from tokenizers import Tokenizer
tokenizer = Tokenizer.from_pretrained("bert-base-chinese")
test_cases = [
"正常中文句子",
"Hello World 混合",
"emoji: 🎉🔥👨👩👧👦",
"特殊符号: ≠ ≤ ∞ ® ™",
"代码: def foo(bar: dict[str, Any]) -> None:",
"", # 空字符串
" " * 100, # 纯空格
]
for text in test_cases:
encoded = tokenizer.encode(text)
decoded = tokenizer.decode(encoded.ids)
roundtrip = tokenizer.encode(decoded)
assert encoded.ids == roundtrip.ids, \
f"往返不一致: '{text}' → {encoded.ids} → '{decoded}' → {roundtrip.ids}"
print(f"✓ '{text[:30]}...' → {len(encoded.ids)} tokens")02.7 多语言 Tokenization 的 Fertility 问题
Fertility(繁殖率)是衡量一种语言在特定 tokenizer 下的编码效率的核心指标。定义为: fertility(lang) = token_count(text) / word_count(text) 英文文本的 fertility 通常在 1.2-1.5 之间(即平均每个英文单词需要 1.2-1.5 个 token),而中文可能高达 1.5-2.5(每个汉字需要 1.5-2.5 个 token),阿拉伯语甚至更高。这种差异直接导致:
成本不公平
同样 1000 个"词"的文本,中文用户可能支付英文用户 1.5-2 倍的费用,因为中文被拆成更多 token。
上下文容量不对等
128k token 的窗口,英文约能放 10 万个单词,中文只能放 5-7 万个汉字。同样的业务逻辑,中文 prompt 需要的 token 更多。
延迟差异
同一段话中文生成需要输出更多 token,TTFT 和总延迟都会高于英文。
截断位置不可预测
一个 UTF-8 编码的汉字可能跨越多个 byte token,如果在中间截断会产生乱码或不完整字符。
实测数据:同一段话在不同语言下的 token 差异
# 用 tiktoken 实测对比
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
texts = {
"英文": "The customer requested a full refund for order number 12345.",
"中文": "客户要求对订单号12345进行全额退款。",
"日文": "お客様は注文番号12345の全額返金を要求しました。",
"阿拉伯语": "طلب العميل استرداد كامل للطلب رقم 12345.",
"韩文": "고객이 주문 번호 12345에 대한 전액 환불을 요청했습니다.",
}
for lang, text in texts.items():
tokens = enc.encode(text)
ratio = len(tokens) / len(text)
print(f"{lang}: {len(text)}字符 → {len(tokens)} tokens (ratio={ratio:.2f})")
# 典型输出:
# 英文: 59字符 → 13 tokens (ratio=0.22)
# 中文: 18字符 → 15 tokens (ratio=0.83)
# 日文: 23字符 → 22 tokens (ratio=0.96)
# 阿拉伯语: 42字符 → 24 tokens (ratio=0.57)
# 韩文: 28字符 → 26 tokens (ratio=0.93)测试实践:多语言场景必须分语种做 budget 测试。
如果你的产品同时服务中英日韩用户,绝对不能只用英文 prompt 去验证 token budget。建议为每种主要语言分别建立 token 基线,并在回归测试中覆盖"最不友好"的语言(通常是日文和韩文)。特别注意:中日韩混合输入(如用户消息包含日文商品名 + 中文描述 + 英文型号)的 token 数往往比任何单一语言都高。
03. 上下文窗口为什么永远不够用
很多人以为"模型支持 128k 上下文"就等于问题解决了。企业真实场景里,窗口是被不同组件持续蚕食的:
system prompt → 工具 schema → 历史消息 → RAG 上下文 → 当前问题 → 预留输出
| 组成部分 | 常见占用 | 为什么危险 |
|---|---|---|
| System Prompt | 300-1500 token | 改版本最频繁,却最容易被忽视成本影响 |
| Tool / MCP Schema | 500-5000 token | 字段一多,业务输入可用空间立刻缩水 |
| History | 随轮数飙升 | 多轮一长,模型对早期信息的利用率下降 |
| RAG Context | 1000-12000 token | 召回越多不一定越好,噪声也在占预算 |
| Output Reserve | 200-2000 token | 不给输出预留空间,模型就更可能截断或格式不完整 |
3.1 一个企业请求的上下文预算示例
模型最大窗口:32,768 token
system prompt:1,200
tool schema:4,800
对话历史:6,500
RAG context:8,000
当前用户问题:600
输出预留:2,000
当前总预算:23,100
剩余安全空间:9,668
如果再追加 10 条检索片段,每条 700 token:
新增成本 = 7,000 token
剩余安全空间 = 2,668 token
这时风险:
1. 历史消息可能被截断
2. 输出空间不够,JSON 容易坏
3. 长上下文导致延迟显著升高3.2 为什么"塞更多上下文"常常让结果更差
- 更多片段意味着更高噪声,模型要在更多候选信息里分配注意力。
- 如果检索片段之间互相矛盾,模型可能混合拼接出看似合理的答案。
- 当工具 schema 很长时,真正的用户问题在总上下文中的权重会被相对稀释。
- 长窗口并不等于长距离依赖始终稳定。越长的上下文,越需要测试"真正用上了没有"。
- 研究表明模型在上下文中间位置的信息利用率最低("Lost in the Middle" 效应),关键信息应该放在开头或结尾。
03.3 RoPE 位置编码的完整数学推导
理解位置编码对测试的意义在于:它决定了模型"能看多远"的物理极限。当你测试长上下文场景时,截断和遗忘问题的根源往往在位置编码的外推能力上。
为什么需要位置编码
Transformer 的自注意力机制本身是置换不变的(permutation invariant)——打乱输入顺序,注意力分数不变。位置编码的作用是把"第几个 token"的信息注入模型,让模型知道谁在前谁在后。
RoPE 的核心思想:用旋转矩阵编码位置
RoPE(Rotary Position Embedding)的核心想法是:把位置信息编码为向量空间中的旋转角度。对于位置 m 处的 query 向量 q 和位置 n 处的 key 向量 k,RoPE 希望注意力分数只依赖于相对距离 m-n。 具体做法是:将 d 维的 q/k 向量两两分组,每组 2 维,对第 i 组应用一个旋转矩阵: R(θ_i, m) = [cos(mθ_i) -sin(mθ_i)] [sin(mθ_i) cos(mθ_i)]
其中 θ_i = 1 / 10000^(2i/d), i = 0, 1, ..., d/2 - 1 对完整的 d 维向量,RoPE 变换为: f(x, m) = R_Θ,m · x
其中 R_Θ,m = diag(R(θ_0, m), R(θ_1, m), ..., R(θ_{d/2-1}, m))
为什么旋转能编码相对位置
两个经过 RoPE 变换的向量的内积具有一个关键性质: ⟨f(q, m), f(k, n)⟩ = ⟨R_Θ,m · q, R_Θ,n · k⟩ = q^T · R_Θ,m^T · R_Θ,n · k = q^T · R_Θ,(n-m) · k
由于旋转矩阵的正交性:R^T_m · R_n = R_{n-m} 注意力分数只取决于 (n-m)——即相对距离,与绝对位置无关。这正是我们想要的性质。
基频 θ 与上下文长度的关系
θ_i 的取值控制了每一组维度的"旋转频率"。低频维度(大的 i)旋转慢,能感知远距离位置差异;高频维度(小的 i)旋转快,擅长区分近距离 token。base 值(默认 10000)决定了频率的分布范围。 当位置 m 超出训练时见过的最大值时,旋转角度 mθ_i 可能超出训练分布,导致注意力分数异常——这就是外推失败的根源。
对测试的直接影响。
如果你的业务场景需要处理超出模型训练长度的输入(比如用一个训练长度 4k 的模型处理 8k 输入),即使模型架构允许接受更长输入,位置编码的外推失败会导致"远处的信息看不到"——表现为模型对前面的指令失忆、回答只基于最近的几百个 token。测试时应该在不同位置放置关键信息,验证模型是否真的能利用远距离上下文。
03.4 长上下文方案对比:ALiBi / YaRN / NTK-aware
为了让模型支持更长的上下文,业界提出了多种位置编码扩展方案。它们的核心差异在于如何解决"训练时没见过这么远的位置"这个问题。
ALiBi(Attention with Linear Biases)
ALiBi 完全不使用位置编码,而是在注意力分数上加一个线性偏置: attention(q_i, k_j) = q_i · k_j^T - m · |i - j|
其中 m 是每个头特有的斜率,按 2^(-8/n_heads) 的等比数列递减 ALiBi 的思路简洁:距离越远,惩罚越大,自然衰减注意力。它天然支持外推到任意长度,但缺点是注意力严格随距离递减,对"远处有关键信息"的场景不友好。
位置内插(Position Interpolation, PI)
最直觉的方案:训练长度是 4k,想推理 16k,就把位置缩小 4 倍。原来的 position 0-16383 被映射到 0-4095.75: m' = m × (L_train / L_target)
缩放因子 s = L_train / L_target 优点是简单、只需少量微调;缺点是高频信息被压缩,近距离分辨率下降。
NTK-aware Interpolation
NTK-aware 方案不均匀缩放——低频维度(负责远距离)缩放多,高频维度(负责近距离)保持不变: θ'_i = θ_i / (α^(2i/(d-2)))
其中 α = (L_target / L_train)^(d/(d-2))
效果:高频维度 θ'≈θ(近距离精度保留),低频维度被大幅缩放(远距离容量扩展)
YaRN(Yet another RoPE extensioN)
YaRN 是目前效果最好的方案之一,它结合了 NTK 缩放、注意力缩放因子和温度修正:
- **NTK 频率缩放:**和 NTK-aware 类似的非均匀缩放
- **注意力缩放:**乘以
sqrt(1/s)补偿因长度扩展导致的 softmax 分布变化 - **分组处理:**将维度分为"不缩放区"、"线性内插区"和"NTK 缩放区"三组
方案对比表
| 方案 | 是否需要微调 | 近距离精度 | 远距离能力 | 推理开销 | 代表模型 |
|---|---|---|---|---|---|
| ALiBi | 否 | 好 | 理论无限 | 极低 | BLOOM/MPT |
| PI | 少量 | 有损 | s 倍扩展 | 无 | LLaMA-PI |
| NTK-aware | 否/少量 | 好 | 好 | 无 | Code LLaMA |
| YaRN | 少量 | 很好 | 很好 | 低 | Mistral/Qwen |
| 原生长训练 | 大量 | 最好 | 最好 | 无 | GPT-4/Claude |
测试关注点。
当供应商宣称"支持 200k 上下文"时,你需要追问:是原生训练支持还是通过 PI/YaRN 扩展的?如果是扩展方案,在接近上限长度时应该专门做信息检索测试(如"大海捞针" needle-in-a-haystack),验证远距离信息是否真的能被利用。不同扩展方案在不同距离上的衰减曲线差异很大。
03.5 KV Cache 与上下文窗口的显存关系
KV Cache 是 Transformer 推理的核心优化:在自回归生成时,已经计算过的 Key 和 Value 矩阵被缓存,每个新 token 只需计算自己的 Q 并与所有缓存的 K/V 做注意力。但 KV Cache 的显存消耗与上下文长度线性增长,是限制实际可用上下文长度的物理瓶颈。
KV Cache 显存计算公式
KV Cache 大小 = 2 × n_layers × n_kv_heads × d_head × seq_len × bytes_per_param
对 LLaMA-2-7B (FP16): n_layers = 32, n_kv_heads = 32, d_head = 128, bytes = 2
4k 上下文: 2 × 32 × 32 × 128 × 4096 × 2 = 2 GB 32k 上下文: 2 × 32 × 32 × 128 × 32768 × 2 = 16 GB 128k 上下文: 2 × 32 × 32 × 128 × 131072 × 2 = 64 GB
对 LLaMA-3-70B (FP16, GQA 8 kv_heads): n_layers = 80, n_kv_heads = 8, d_head = 128, bytes = 2
8k 上下文: 2 × 80 × 8 × 128 × 8192 × 2 = 2.6 GB 128k 上下文: 2 × 80 × 8 × 128 × 131072 × 2 = 41 GB
GQA/MQA 如何压缩 KV Cache
传统 Multi-Head Attention(MHA)中每个注意力头都有自己的 K/V,KV Cache 与 n_heads 成正比。Grouped Query Attention(GQA)让多个 query head 共享同一组 K/V head,从而成倍降低 KV Cache。这是 LLaMA-3、Mistral 等模型能支持长上下文的关键工程决策。
| 注意力类型 | KV Head 数 | 压缩比 | 代表模型 |
|---|---|---|---|
| MHA | = query heads | 1× | GPT-3, LLaMA-1 |
| GQA | query heads / group_size | 4-8× | LLaMA-2/3, Mistral |
| MQA | 1 | = n_heads × | PaLM, Falcon |
为什么测试人员需要关心 KV Cache。
KV Cache 大小直接决定了:(1) 同一张 GPU 能并发多少请求——每个请求都独占自己的 KV Cache;(2) 当服务器 GPU 显存不足时,后到的请求可能被排队甚至 OOM,表现为延迟飙升或 5xx 错误;(3) 在压测场景下,你需要区分"模型计算慢"和"KV Cache 打满导致排队"两种延迟来源。监控指标:关注推理服务的 kv_cache_utilization 和 batch_size 指标。
04. Token Budget 怎么设计才像企业方案
成熟团队不会只看"超不超窗口",而是会给每一类内容设预算上限。预算的意义不是节省几个 token,而是防止某个模块无限膨胀,把整个系统拖慢拖坏。
提示词预算
给 system prompt 设上限,例如不超过 1200 token,每次 prompt 变更都跑成本回归。
检索预算
限制 RAG top-k、chunk 长度和总拼接上限,防止"召回越多越安全"的错觉。
输出预算
结构化输出、长摘要、多轮 Agent 都要预留足够输出空间,否则容易尾部截断。
4.1 一个推荐的预算表
| 模块 | 预算策略 | 测试动作 |
|---|---|---|
| System Prompt | 固定上限 + 版本对比 | 每次改动记录 token 增量和通过率变化 |
| History | 保留最近 N 轮 + 摘要压缩 | 测试总结质量和记忆丢失风险 |
| RAG Context | 总 token 上限 + 分段优先级 | 测试 top-k 从 3 到 10 的效果差异 |
| Tools | 按场景按需加载 schema | 测试"全量工具"与"按需工具"性能差异 |
| Output | 基于任务类型预留 | JSON、长文生成、多轮任务分别跑截断测试 |
04.5 一个企业可直接落地的 Budget Worksheet
如果你要把 token budget 变成团队通用规则,最好的方式不是口头约定,而是给每个请求模板做一张"预算 worksheet"。下面是一个你可以直接照搬的表。 请求预算拆解图
最大窗口 32k → system 1.2k → tools 4.8k → history 6.5k → RAG 8k → 输出预留 2k
| 字段 | 预算上限 | 实测值 | 是否超标 | 备注 |
|---|---|---|---|---|
| system prompt | 1200 | 980 | 否 | 正常 |
| tool schema | 3000 | 4650 | 是 | 需要按需裁剪工具 |
| history | 5000 | 4200 | 否 | 可接受 |
| RAG context | 6000 | 7200 | 是 | top-k 和 chunk 需要下调 |
| 输出预留 | 1500 | 1000 | 风险 | JSON 场景容易截断 |
04.6 Token Budget 优化的工程实践:Streaming 与 Chunking
当单次请求无法在一个上下文窗口内完成时,工程上有两大类解法:流式处理(Streaming)和分块处理(Chunking)。这两种策略直接影响测试用例的设计。
Streaming(流式输出)
Streaming 不减少总 token 消耗,但改善用户感知延迟。Server-Sent Events(SSE)是最常见的协议:
# Streaming 模式下的 token 监控脚本
from openai import OpenAI
import time
client = OpenAI()
start = time.time()
first_token_time = None
total_tokens = 0
stream = client.chat.completions.create(
model="gpt-4.1",
messages=[{"role": "user", "content": "请详细分析..."}],
stream=True,
max_tokens=2000
)
for chunk in stream:
if chunk.choices[0].delta.content:
if first_token_time is None:
first_token_time = time.time() - start
total_tokens += 1
elapsed = time.time() - start
print(f"TTFT: {first_token_time:.2f}s")
print(f"总 token: {total_tokens}")
print(f"吞吐: {total_tokens/elapsed:.1f} tokens/s")
print(f"总耗时: {elapsed:.2f}s")Streaming 测试要点。
- **TTFT(Time to First Token):**流式下最核心的延迟指标。输入 token 越多,TTFT 越高,因为模型需要先处理完所有输入才能开始生成。
- **流中断:**网络抖动或服务超时可能导致流中断,客户端收到的是不完整的 JSON 或 Markdown。测试应专门模拟流中断场景。
- **token 粒度闪烁:**中文 token 可能对应不完整的 UTF-8 字节,某些实现会出现短暂乱码。
Chunking(分块处理)
当输入文本超过上下文窗口时,需要将文本切块后分次处理,再合并结果。关键决策点:
| 策略 | 做法 | 优势 | 风险 |
|---|---|---|---|
| 固定大小切块 | 按 token 数等分 | 简单可控 | 可能在句子中间切断,破坏语义 |
| 语义切块 | 按段落/句子边界切分 | 保持语义完整 | 块大小不均匀,可能超窗口 |
| 滑动窗口 | 块间有 overlap | 避免边界信息丢失 | 重叠部分的 token 被重复消耗 |
| Map-Reduce | 先分块摘要,再汇总 | 适合长文档总结 | 中间摘要可能丢信息 |
# 带 overlap 的智能 chunking 实现
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
def smart_chunk(text: str, max_tokens: int = 3000, overlap: int = 200):
sentences = text.replace("。", "。\n").replace(".", ".\n").split("\n")
chunks = []
current_chunk = []
current_tokens = 0
for sent in sentences:
sent = sent.strip()
if not sent:
continue
sent_tokens = len(enc.encode(sent))
if current_tokens + sent_tokens > max_tokens and current_chunk:
chunks.append("".join(current_chunk))
# 保留最后几句作为 overlap
overlap_tokens = 0
overlap_sents = []
for s in reversed(current_chunk):
t = len(enc.encode(s))
if overlap_tokens + t > overlap:
break
overlap_sents.insert(0, s)
overlap_tokens += t
current_chunk = overlap_sents
current_tokens = overlap_tokens
current_chunk.append(sent)
current_tokens += sent_tokens
if current_chunk:
chunks.append("".join(current_chunk))
return chunks05. 结构化输出为什么在长输入下更容易坏
结构化输出失败,很多时候不是模型"不会 JSON",而是上下文预算、schema 复杂度、输出保留空间共同叠加造成的。
一个典型故障链。
工具 schema 很长 → 当前请求还带了多段上下文 → 模型可用输出空间缩水 → 生成到一半被截断 → 最终 JSON 缺右括号或字段缺失。表面看像格式错误,本质上是 token budget 管控失败。
5.1 测试时要专门覆盖的 4 类场景
- 长 schema + 长上下文 + 长输出三者同时出现。
- 多工具注册,但当前只会用到一个工具。
- 同一个任务,在"短指令版 schema"和"详细版 schema"下的成功率差异。
- 输出接近上限时,是否出现字段缺失、数组被截断、转义错误。
5.2 最小测试脚本骨架
from openai import OpenAI
client = OpenAI()
def run_case(system_prompt, user_input, schema, max_output_tokens):
return client.responses.create(
model="gpt-4.1",
input=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input}
],
text={
"format": {
"type": "json_schema",
"name": "answer_schema",
"schema": schema
}
},
max_output_tokens=max_output_tokens
)
# 真实测试里你要对比:
# 1. 不同 schema 长度
# 2. 不同 max_output_tokens
# 3. 不同 history 长度
# 4. 是否出现 JSON 不完整或字段空缺05.3 Constrained Decoding 原理与 JSON Mode 约束
当 API 设置 response_format: {type: "json_schema"} 时,背后运行的不只是"希望模型输出 JSON",而是一套受约束的解码机制(Constrained Decoding)。理解它的原理能帮你诊断"为什么 JSON mode 有时反而更慢"。
基于有限状态自动机的 token 过滤
Constrained Decoding 的核心思想是:在每一步 token 生成时,根据当前已生成的内容和目标 schema,构建一个合法 token 掩码(mask),只允许模型从合法的 token 中采样。
JSON Schema → 构建 CFG/正则 → 转化为 FSA → 每步计算合法 token mask → mask 应用到 logits
具体过程:
- 将 JSON Schema 转换为上下文无关文法(Context-Free Grammar, CFG)或正则表达式
- 将 CFG/正则编译为有限状态自动机(Finite State Automaton, FSA)
- 在每一步生成时,FSA 根据当前状态计算所有合法的下一个 token(可能是几十到几千个),将不合法 token 的 logit 设为 -∞
- 模型只能从合法候选中采样,保证输出严格符合 schema
Constrained Decoding 的代价
| 代价类型 | 原因 | 测试影响 |
|---|---|---|
| 延迟增加 | 每步需要额外计算 FSA 状态和 token mask | TTFT 和每 token 延迟都可能升高 5-15% |
| 质量可能下降 | 强制约束可能让模型选择了概率次优但语法合法的 token | 字段值的语义准确率可能略低于无约束模式 |
| schema 越复杂越慢 | FSA 状态空间与 schema 复杂度正相关 | 嵌套 5 层的 schema 比扁平 schema 慢很多 |
| 枚举型字段加速 | enum 约束大幅缩小候选空间,FSA 状态少 | 多用 enum 少用 freeform string 能提速 |
优化建议与测试策略。
(1) 尽量扁平化 schema,减少嵌套层级;(2) 用 enum 替代自由文本字段;(3) 将大 schema 拆分为多个小的结构化调用;(4) 对比测试:同一任务在 json_schema mode 和 text mode + 后处理解析两种方案下的延迟和准确率差异。在某些场景下,text mode + robust parser 可能是更好的选择。
05.5 最小 Token 计数脚本:别再靠感觉估
如果团队里还在用"这段提示词看起来不长"来判断成本和风险,建议直接把计数脚本放进开发流程里。下面给一个最小示例,重点不是库名,而是方法:把 system、history、schema、context 分别计数。
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
def count_tokens(text: str) -> int:
return len(enc.encode(text))
system_prompt = "你是一名企业客服助理..."
tool_schema = '{"type":"object","properties":{"order_id":{"type":"string"}}}'
history = "用户:我昨天问过退款...\n助手:..."
context = "退款规则:七天无理由..."
parts = {
"system": system_prompt,
"tool_schema": tool_schema,
"history": history,
"context": context,
}
total = 0
for name, text in parts.items():
n = count_tokens(text)
total += n
print(f"{name}: {n}")
print("total:", total)进阶:集成到 CI/CD 的 token 回归检测。
把 token 计数做成自动化检查,在 PR 合并前自动对比 system prompt 和 tool schema 的 token 增量。当增量超过阈值(如 10%)时自动标记为需要 review。
# CI 集成示例:prompt 变更的 token 回归检测
import tiktoken
import json
import sys
enc = tiktoken.get_encoding("cl100k_base")
def load_prompt(path: str) -> str:
with open(path) as f:
return f.read()
def check_token_regression(old_path: str, new_path: str, threshold: float = 0.1):
old_text = load_prompt(old_path)
new_text = load_prompt(new_path)
old_tokens = len(enc.encode(old_text))
new_tokens = len(enc.encode(new_text))
delta = (new_tokens - old_tokens) / old_tokens
report = {
"old_tokens": old_tokens,
"new_tokens": new_tokens,
"delta_pct": f"{delta*100:.1f}%",
"status": "PASS" if abs(delta) <= threshold else "FAIL"
}
print(json.dumps(report, indent=2))
if abs(delta) > threshold:
print(f"Token 增量 {delta*100:.1f}% 超过阈值 {threshold*100}%", file=sys.stderr)
sys.exit(1)
# 用法: python token_check.py prompts/v1.txt prompts/v2.txt
if __name__ == "__main__":
check_token_regression(sys.argv[1], sys.argv[2])06. 企业里最常见的 5 类 token 故障
| 故障类型 | 表面现象 | 底层原因 | 修复方向 |
|---|---|---|---|
| 提示词升级后费用暴涨 | 单次请求成本翻倍 | system prompt 和 tool schema 同时膨胀 | 拆分公共指令、按需加载工具 |
| 多轮对话失忆 | 早期约束失效 | 历史被截断或注意力利用率下降 | 滚动摘要 + 关键记忆提炼 |
| JSON 偶发失败 | 同 case 有时过有时不过 | 输出预算边缘化,复杂场景下截断 | 缩 schema、加输出预留、缩上下文 |
| RAG 召回一多就答非所问 | 明明给了更多上下文却更差 | 噪声片段挤占 token 与注意力 | 削减 top-k、做 rerank、控总 token |
| Agent 工具调用慢 | TTFT 变差 | 全量工具 schema 被塞入上下文 | 场景化注册工具 |
6.1 故障定位的三步法
当线上出现 token 相关故障时,推荐以下定位流程:
- **拆解请求:**把实际发送给 API 的完整 payload 拿出来,分别计算 system / tools / history / context / output 各部分的 token 数。
- **对比基线:**和正常工作时的 token 分布做对比。通常某个部分会有显著膨胀(最常见的是 history 或 RAG context)。
- **复现边界:**找到导致故障的 token 阈值,固定其他变量,只改变可疑部分的长度,二分查找临界点。
# 故障定位脚本:解析实际请求的 token 分布
import tiktoken
import json
enc = tiktoken.get_encoding("cl100k_base")
def analyze_request(payload: dict):
breakdown = {}
total = 0
for msg in payload.get("messages", []):
role = msg["role"]
content = msg.get("content", "")
if isinstance(content, list):
content = json.dumps(content)
tokens = len(enc.encode(str(content)))
breakdown.setdefault(role, 0)
breakdown[role] += tokens
total += tokens
if "tools" in payload:
tool_tokens = len(enc.encode(json.dumps(payload["tools"])))
breakdown["tools"] = tool_tokens
total += tool_tokens
breakdown["total"] = total
breakdown["max_tokens"] = payload.get("max_tokens", "unset")
for k, v in breakdown.items():
print(f" {k}: {v}")
return breakdown07. 测试设计清单:这一章学完后应该怎么测
建议把 token 测试从"附加项"升级成"固定回归项"。
- 为核心提示词建立 token 基线,每次变更记录增减量。
- 对长上下文任务建立预算表,明确 system/history/RAG/output 各自上限。
- 准备一组 token 不友好的真实输入:URL、日志、乱码、emoji、SQL、base64、长 JSON。
- 对结构化输出单独做"接近上限"的截断测试。
- 对多轮会话做记忆衰减测试,确认关键指令能保留多少轮。
- 把 token、费用、TTFT、输出完整性放到同一张趋势图里观察。
- 对不同语言做 fertility 基线测试,确保多语言场景的 budget 覆盖。
- 对 KV Cache 做压力测试,观察高并发下的延迟退化曲线。
07.5 Tokenizer 完整测试用例设计
一个成熟的 tokenizer 测试套件应该覆盖以下维度。这些测试用例不仅验证 tokenizer 本身的正确性,更重要的是发现业务场景中的 token 风险。
边界 Case 测试矩阵
| 类别 | 测试输入 | 验证目标 | 预期行为 |
|---|---|---|---|
| 空值 | "" , " " , "\n\n" | 空输入不崩溃 | 返回空列表或仅含特殊 token |
| 超长单行 | 100k 字符无换行 | 不 OOM、不超时 | 正常分词,token 数与字符数合理比例 |
| 纯符号 | !@#$%^&*(){}[] | 特殊符号不产生 UNK | 每个符号至少对应一个 token |
| Unicode 边界 | U+0000, U+FFFF, U+10FFFF, 代理对 | 不崩溃、不产生非法序列 | byte fallback 正确处理 |
| 混合编码 | 中文+日文+韩文+阿拉伯文+emoji | 多脚本共存不互相干扰 | 各语言 fertility 在合理范围 |
Emoji 专项测试
Emoji 是 tokenizer 的重灾区。单个"可见字符"在 Unicode 层面可能由多个 code point 组成:
# Emoji token 测试用例
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
emoji_cases = {
"简单 emoji": "😀", # 1 code point
"肤色修饰": "👍🏽", # 2 code points (base + modifier)
"ZWJ 家庭": "👨👩👧👦", # 7 code points (4 人 + 3 个 ZWJ)
"国旗": "🇯🇵", # 2 regional indicator
"keycap": "1️⃣", # 3 code points
"连续 10 个 emoji": "🎉🔥💯🚀✨🎯🏆💡🎮🎨",
}
for name, emoji in emoji_cases.items():
tokens = enc.encode(emoji)
codepoints = len(emoji.encode("utf-32-le")) // 4
print(f"{name}: {repr(emoji)}")
print(f" code points: {codepoints}, tokens: {len(tokens)}")
print(f" token IDs: {tokens}")
print()代码分词测试
# 代码片段的 token 效率测试
code_cases = {
"Python 函数": "def calculate_total_price(items: list[dict]) -> float:",
"Java 泛型": "Map<String, List<Optional<Integer>>> result = new HashMap<>();",
"SQL 查询": "SELECT u.name, COUNT(*) FROM users u JOIN orders o ON u.id=o.user_id GROUP BY u.name HAVING COUNT(*)>5;",
"正则表达式": r"^(?:(?:\+|00)86)?1[3-9]\d{9}$",
"JSON 嵌套": '{"a":{"b":{"c":{"d":{"e":"deep"}}}}}',
"Base64": "SGVsbG8gV29ybGQhIFRoaXMgaXMgYSB0ZXN0Lg==",
"文件路径": "/usr/local/lib/python3.11/site-packages/tokenizers/__init__.py",
"URL 参数": "https://api.example.com/v2/search?q=hello+world&lang=zh&page=1&size=20&sort=relevance",
}
for name, code in code_cases.items():
tokens = enc.encode(code)
ratio = len(tokens) / len(code)
print(f"{name}: {len(code)}字符 → {len(tokens)} tokens (ratio={ratio:.2f})")往返一致性测试(Round-trip Consistency)
任何 tokenizer 都应该满足 decode(encode(text)) ≈ text。注意某些 normalizer 可能改变空白字符,所以不一定是严格相等。
# 往返一致性批量测试
import random
import string
def generate_adversarial_inputs(n=100):
inputs = []
for _ in range(n):
# 随机混合中英文、符号、空白
chars = []
for __ in range(random.randint(1, 500)):
r = random.random()
if r < 0.3:
chars.append(random.choice(string.ascii_letters + string.digits))
elif r < 0.5:
chars.append(chr(random.randint(0x4e00, 0x9fff))) # CJK
elif r < 0.7:
chars.append(random.choice("!@#$%^&*(){}[]<>:;'\",./\\|`~"))
elif r < 0.85:
chars.append(random.choice([" ", "\t", "\n", "\r"]))
else:
chars.append(chr(random.randint(0x1f600, 0x1f64f))) # emoji
inputs.append("".join(chars))
return inputs
test_inputs = generate_adversarial_inputs(1000)
failures = []
for text in test_inputs:
try:
encoded = enc.encode(text, allowed_special="all")
decoded = enc.decode(encoded)
re_encoded = enc.encode(decoded, allowed_special="all")
if encoded != re_encoded:
failures.append({"text": text[:50], "reason": "re-encode mismatch"})
except Exception as e:
failures.append({"text": text[:50], "reason": str(e)})
print(f"测试 {len(test_inputs)} 条, 失败 {len(failures)} 条")
for f in failures[:5]:
print(f" {f}")性能基准测试
Tokenizer 性能直接影响推理链路延迟。
在高 QPS 场景下,tokenizer 的编码速度可能成为瓶颈。建议用以下维度建立性能基线:(1) 短文本(<100 字符)的单次编码耗时应 <0.1ms;(2) 长文本(>10k 字符)的编码吞吐应 >1MB/s;(3) 批量编码 1000 条应 <1s。如果使用 Python 实现的 tokenizer(而非 Rust binding),性能可能差 10-50 倍。
08. 练习题
8.1 思考题
- 为什么"把 schema 写详细一点"有可能提高正确率,也有可能降低稳定性?
- 为什么同样 500 字的两段文本,在 token 上可能差别很大?
- 为什么长 history 不一定让多轮体验更好?
- BPE 和 Unigram 在处理"从未见过的新词"时有什么行为差异?哪种更适合代码场景?
- 为什么 GQA 能大幅降低 KV Cache 而对模型质量影响有限?
- Constrained Decoding 在什么情况下反而会降低输出质量?
8.2 手算题
假设一个请求窗口上限是 16,384 token,system 占 900,history 占 4,200,RAG context 占 6,000,当前问题占 300,输出至少要留 1,200,请问:
- 当前剩余安全空间是多少?
- 如果工具 schema 再增加 2,500 token,会先影响哪个部分?
- 如果必须保留输出空间,你会优先压缩 schema、history 还是 RAG context?为什么?
8.3 KV Cache 计算题
一个模型有 40 层、32 个注意力头(其中 KV head 用 GQA 缩减为 8 个)、每个头维度 128、使用 FP16。请计算:
- 单个请求在 4096 token 上下文下的 KV Cache 大小是多少 MB?
- 如果 GPU 有 24GB 显存,模型权重占 14GB,最多能同时服务多少个 4096 token 的请求?
- 如果将上下文扩展到 32768 token,最多能同时服务多少个请求?
8.4 参考答案要点
- **8.2.1:**总已占用是 900 + 4200 + 6000 + 300 + 1200 = 12600,因此剩余安全空间是 3784 token。
- **8.2.2:**如果 schema 再增加 2500 token,最先被挤压的往往是输出保留空间和优先级较低的 history / RAG 片段。
- **8.2.3:**企业方案通常先压缩可改写的 schema,再压缩低价值 history,最后才动高价值 RAG 证据,因为证据最直接影响正确率。
- **8.3.1:**KV Cache = 2 × 40 × 8 × 128 × 4096 × 2 bytes = 2 × 40 × 8 × 128 × 4096 × 2 = 671,088,640 bytes ≈ 640 MB。
- **8.3.2:**可用显存 = 24 - 14 = 10 GB = 10,240 MB。每请求 640 MB,最多 10240/640 = 16 个并发。
- **8.3.3:**32768 token 的 KV Cache = 640 × 8 = 5120 MB ≈ 5 GB,最多 10240/5120 = 2 个并发。这就是为什么长上下文场景吞吐量骤降。
8.5 编码实战题
用 tiktoken 或 HuggingFace tokenizers 完成以下任务:
- 写一个函数,输入任意文本,输出该文本在 cl100k_base 和 o200k_base 两种编码下的 token 数差异。
- 构造一段中英混合文本,使得 cl100k_base 下的 token 数恰好比 o200k_base 多 50% 以上。
- 写一个 token budget 检查器:接收完整的 API 请求 payload,检测各组件的 token 占比并生成预警报告。
# 实战题参考框架
import tiktoken
def compare_encodings(text: str):
enc_100k = tiktoken.get_encoding("cl100k_base")
enc_200k = tiktoken.get_encoding("o200k_base")
tokens_100k = len(enc_100k.encode(text))
tokens_200k = len(enc_200k.encode(text))
diff_pct = (tokens_100k - tokens_200k) / tokens_200k * 100
return {
"text_preview": text[:80],
"cl100k_tokens": tokens_100k,
"o200k_tokens": tokens_200k,
"diff_pct": f"{diff_pct:+.1f}%"
}
test_texts = [
"Hello, how are you today?",
"你好,今天天气怎么样?",
"def foo(bar: dict[str, list[int]]) -> Optional[bool]:",
"👨👩👧👦 Family emoji test 🎉",
"SELECT * FROM users WHERE created_at > '2026-01-01' ORDER BY id DESC LIMIT 100;",
]
for t in test_texts:
result = compare_encodings(t)
print(result)8.6 自测标准
完成这一页后,你应该能做到:
- 用自己的话解释 token、BPE、上下文预算三者关系。
- 说清楚 BPE、WordPiece、Unigram、SentencePiece 四种算法的核心差异及其对 token 数的影响。
- 看到一个长 prompt 时,能主动拆成 system / history / tools / RAG / output 预算。
- 把 JSON 输出失败、延迟上升、历史失忆和 token budget 联系起来。
- 解释 RoPE 位置编码的基本思想,以及长上下文方案(PI/NTK/YaRN)的取舍。
- 计算给定模型配置下的 KV Cache 大小,评估并发容量。
- 设计一套包含边界 case、多语言、emoji、代码、往返一致性的完整 tokenizer 测试套件。
- 设计一套最小可落地的 token 回归测试,并集成到 CI/CD 流程。