feat: add DeepSeek chat-template routing probe

This commit is contained in:
wuyang
2026-07-29 17:23:19 +08:00
parent 405c816d55
commit b0acc8b47b
11 changed files with 1113573 additions and 32 deletions
+438
View File
@@ -0,0 +1,438 @@
# DeepSeek-V2-Lite 路由的官方模板敏感性审计
> 状态:真实官方 BF16 权重、本机 RTX 5090、layer 0–6 连续 forward。
> 固定模型 revision:`604d5664dddd88a0433dbae533b7fe9472482de0`。
> 研究单位:同一批 128 条公开 source prompt 的三种输入协议。
> 结论身份:**协议敏感性探针**,不是能力评测、训练分布、线上流量或专家语义。
## 1. 这轮究竟要排除什么混杂因素
上一轮已经证明:自然长度不同会改变路由集中度,因此我们不能把所有域间差异都
解释成“内容语义”。
但即使内容和长度都固定,输入模型之前还有一层协议:
```text
纯文本
↓ tokenizer
BOS + content
单轮对话
↓ official chat template + tokenizer
BOS + "User: " + content + "\n\n" + optional "Assistant:"
```
角色前缀会:
1. 增加 wrapper token;
2. 改变内容 token 的绝对位置;
3. 为每个内容 token 增加可见的左侧上下文;
4. 可能让第一个内容 token 在 BPE 边界处重新切分;
5. 改变“整段输入统计”的分母。
所以本轮不是问“chat 模型强不强”,而是问:
> 同一段内容送入同一个 base checkpoint,仅改变官方输入包装时,前六个 MoE
> 层的路由统计会怎样变化?
## 2. 官方模板不是自行编写的教学字符串
固定 revision 的 `tokenizer_config.json` 给出:
```jinja
{{ bos_token }}
{% for message in messages %}
user -> "User: " + content + "\n\n"
assistant -> "Assistant: " + content + eos_token
system -> content + "\n\n"
{% endfor %}
{% if add_generation_prompt %}"Assistant:"{% endif %}
```
完整模板字符串 SHA-256:
```text
8aeba567270fa9a8d5372caf4b04affea947c4421d96adc01e3ff94021b0ae8e
```
特殊 token:
| 角色 | 文本 | ID |
| --- | --- | ---: |
| BOS | `<|begin▁of▁sentence|>` | 100000 |
| EOS / PAD | `<|end▁of▁sentence|>` | 100001 |
实验通过 Transformers 4.41.2 的 `apply_chat_template` 生成字符串和 token IDs;
随后再次对渲染字符串编码,并要求两条路径的 token IDs 完全一致。
## 3. 三个条件如何构成“实验 + 负对照”
每条 source prompt 都渲染三遍:
```text
RAW
[BOS] [content]
USER
[BOS] [User:] [content] [\n\n]
GENERATION
[BOS] [User:] [content] [\n\n] [Assistant:]
```
比较分两种:
### 3.1 `RAW → USER`
这是主要敏感性实验。
- 内容字符前缀相同;
- checkpoint、权重、batch、层、dtype 相同;
- 改变的是左侧角色前缀和尾部换行;
- 尾部换行不能反向影响更早的内容 token;
- `User:` 前缀可以通过因果注意力影响后续内容 token。
它能描述“输入协议敏感性”,但不能单独证明训练中的角色语义或能力变化。
### 3.2 `USER → GENERATION`
这是因果负对照。
`GENERATION` 只在 `USER` 末尾追加 `Assistant:`。在严格因果 attention 中,
未来 suffix 不应改变此前共享前缀的 hidden state 或路由。
因此它同时验证:
- causal mask;
- batching;
- token prefix;
- route 捕获;
- 分析管线。
若这个负对照失败,`RAW → USER` 的任何解释都不可信。
## 4. 固定 cohort 与内容长度合同
四个公开域仍为:
| 域 | 来源 | 使用字段 | 数量 |
| --- | --- | --- | ---: |
| 英文百科 | WikiText-2 raw validation | `text` | 32 |
| 中文新闻 | CLUE TNEWS public test | `sentence` | 32 |
| Python 代码 | OpenAI HumanEval | `prompt` | 32 |
| 小学数学 | OpenAI GSM8K test | `question` | 32 |
答案不输入,HumanEval 代码不执行。
### 4.1 为什么固定 23 个 content tokens
上一轮 24-token matched cohort 的总长包括 BOS:
```text
1 BOS + 23 source-content tokens = 24 raw input tokens
```
本轮直接把内容合同写清楚为 **23 个 regular-tokenizer content tokens**。
这样 RAW 条件固定为 24 tokens,同时保留足够多的 TNEWS 候选。
### 4.2 字符前缀如何重建
BPE 对“完整字符串”和“截断后的字符串”可能在最后一个 token 发生不同合并。
因此不能粗暴地取完整编码的前 23 个 IDs 再假设可逆。
脚本采用:
1. 读取 fast tokenizer offset mapping;
2. 在目标 token 边界附近搜索字符终点;
3. 重新编码字符前缀;
4. 只接受恰好得到 23 tokens 的最长前缀。
选样排序固定为:
```text
ascending SHA256(
"llm-atlas-deepseek-routing-template-control-v1"
| domain
| source_id
)
```
候选与选中来源长度:
| 域 | 可用候选 | 选中 | source tokens min / mean / max |
| --- | ---: | ---: | ---: |
| 英文百科 | 1,655 | 32 | 29 / 142.84 / 280 |
| 中文新闻 | 1,609 | 32 | 23 / 24.66 / 30 |
| Python 代码 | 164 | 32 | 42 / 141.78 / 352 |
| 小学数学 | 1,319 | 32 | 31 / 59.88 / 131 |
TNEWS 在完成选中前有 1 条无法构造精确 23-token 字符前缀,脚本显式记录并继续按
哈希顺序选择下一条;其余域为 0。
## 5. 为什么必须分“整段输入”和“对齐内容”
若直接把 wrapper token 与 content token 全部求和,得到的是部署协议问题:
> 这一整段实际输入会把 token 路由到哪里?
若只研究内容,需要保证比较的是同一 token,而不是“都在内容字符范围内”就算相同。
本轮为三种条件中的每个 token 记录:
```text
(相对内容字符起点, 相对内容字符终点, token ID)
```
然后只保留三个条件的精确交集。
### 5.1 Token 账
| 条件 | 输入 tokens | 对齐内容 tokens | 六层真实 top-6 routes |
| --- | ---: | ---: | ---: |
| RAW | 3,072 | 2,874 | 110,592 |
| USER | 3,642 | 2,874 | 131,112 |
| GENERATION | 3,898 | 2,874 | 140,328 |
| 合计 | 10,612 | — | **382,032** |
RAW 原始内容范围共有 2,944 tokens;由于 `User: ` 后的 BPE 边界,70 个首 token
不能以相同 `(span, token ID)` 对齐,因此从三个条件中同时剔除。
按域的对齐覆盖:
| 域 | aligned / RAW content | 覆盖率 |
| --- | ---: | ---: |
| 英文百科 | 706 / 736 | 95.9% |
| 中文新闻 | 735 / 736 | 99.9% |
| Python 代码 | 733 / 736 | 99.6% |
| 小学数学 | 700 / 736 | 95.1% |
这意味着 `content_only` 比较中的每一条 route 都对应相同字符段与相同 token ID。
## 6. 统计合同
与长度实验相同,抽样单位仍是 source prompt,而不是 token。
每个 layer × scope × mode × domain:
1. 32 条 prompt;
2. 有放回重采样 2,000 次;
3. before / after 使用完全相同的 prompt indices;
4. 固定 seed `20260729`;
5. 报告 percentile 95% 区间;
6. 不进行假设检验;
7. 不做多重比较校正。
聚合口径:
- `prompt_balanced`:每条 prompt 先归一,再让 32 条 prompt 等权;
- `token_weighted`:先汇总选中 token routes,再整体归一。
主要正文使用 `prompt_balanced`,避免少量 tokenization 长度差异改变 prompt 权重。
## 7. 负对照:21,852 个共享前缀 token 全部 exact
每个 MoE 层:
| Layer | USER 共享前缀 tokens | ordered top-6 exact | 违规 prompt |
| --- | ---: | ---: | ---: |
| L1 | 3,642 | 3,642 | 0 |
| L2 | 3,642 | 3,642 | 0 |
| L3 | 3,642 | 3,642 | 0 |
| L4 | 3,642 | 3,642 | 0 |
| L5 | 3,642 | 3,642 | 0 |
| L6 | 3,642 | 3,642 | 0 |
| 合计 | **21,852** | **21,852** | **0** |
`content_only` 的 USER→GENERATION 在 6 层 × 4 域共 24 个比较中:
```text
CV Δ = 0
total variation = 0
JSD = 0
```
但若看 `full_input`,因为 GENERATION 确实新增了 `Assistant:` token,prompt-balanced
total variation 为 0.037–0.051,平均 0.0445。
这组结果正好说明:
> suffix 没有改写过去;整段统计改变,是因为统计对象加入了新 token。
## 8. 主结果:同一个 `User:` 前缀在不同深度方向不同
以下使用:
- scope:三个条件精确对齐的 content tokens;
- mode:prompt-balanced;
- Δ:`CV(USER) − CV(RAW)`。
| Layer | English | Chinese | Code | Math |
|---|---:|---:|---:|---:|
| L1 | 0.633→0.593, **−0.040** [−0.063, −0.017] | 0.670→0.621, **−0.049** [−0.068, −0.028] | 0.901→0.882, −0.019 [−0.039, 0.001] | 0.817→0.796, −0.021 [−0.049, 0.006] |
| L2 | 0.404→0.405, 0.001 [−0.014, 0.016] | 0.383→0.353, **−0.030** [−0.041, −0.016] | 0.684→0.617, **−0.067** [−0.084, −0.047] | 0.453→0.455, 0.002 [−0.016, 0.016] |
| L3 | 0.405→0.422, 0.017 [−0.002, 0.033] | 0.415→0.471, **0.057** [0.033, 0.075] | 0.824→0.812, −0.011 [−0.023, 0.001] | 0.541→0.547, 0.006 [−0.011, 0.023] |
| L4 | 0.530→0.555, 0.025 [−0.002, 0.049] | 0.897→0.848, **−0.048** [−0.067, −0.030] | 1.064→1.038, **−0.027** [−0.039, −0.016] | 0.668→0.649, −0.020 [−0.040, 0.000] |
| L5 | 0.446→0.451, 0.005 [−0.015, 0.021] | 0.478→0.504, **0.026** [0.006, 0.042] | 0.808→0.850, **0.043** [0.032, 0.055] | 0.770→0.779, 0.008 [−0.005, 0.021] |
| L6 | 0.502→0.562, **0.060** [0.036, 0.076] | 0.509→0.539, **0.030** [0.009, 0.043] | 0.851→0.909, **0.058** [0.046, 0.068] | 0.738→0.775, **0.037** [0.013, 0.058] |
### 8.1 初学者应该怎样读
CV 越高,64 个 routed experts 的份额越不平。
- L1:英文、中文的对齐内容变得更平;
- L2:中文、代码变得更平;
- L3:中文反而更集中;
- L4:中文、代码又变平;
- L5:中文、代码更集中;
- L6:四域全部更集中。
因此不应写成:
> Chat template 会让路由更均衡。
也不应写成:
> Chat template 会让路由更集中。
更准确的说法是:
> 在这个 23-token 固定探针中,角色前缀对路由集中度的影响随层和域改变方向;
> 低层与高层不能用一个单调结论概括。
## 9. “整段输入”会给出另一幅图
若把 BOS、`User:`、换行与 content 全部计算,Δ CV 为:
| Layer | English | Chinese | Code | Math |
|---|---:|---:|---:|---:|
| L1 | 0.061 [0.041, 0.077] | 0.093 [0.062, 0.112] | 0.014 [−0.009, 0.035] | 0.058 [0.029, 0.083] |
| L2 | 0.093 [0.069, 0.106] | 0.138 [0.106, 0.151] | 0.038 [0.020, 0.052] | 0.098 [0.065, 0.122] |
| L3 | 0.100 [0.072, 0.110] | 0.140 [0.096, 0.159] | 0.078 [0.058, 0.092] | 0.058 [0.031, 0.076] |
| L4 | 0.131 [0.093, 0.152] | −0.012 [−0.033, 0.003] | 0.051 [0.035, 0.063] | 0.066 [0.042, 0.083] |
| L5 | 0.083 [0.050, 0.094] | 0.036 [−0.003, 0.054] | 0.092 [0.070, 0.112] | −0.053 [−0.075, −0.036] |
| L6 | 0.134 [0.090, 0.155] | 0.046 [0.000, 0.069] | 0.103 [0.079, 0.118] | 0.027 [−0.004, 0.047] |
多数格子是正值,与对齐内容的混合方向明显不同。
原因不是统计错误,而是问题不同:
- `full_input` 包含重复出现的 wrapper token;
- `content_only` 只比较相同字符段和 token ID;
- wrapper 自己可能有很强、很稳定的路由偏好;
- wrapper 数量占短输入的比例不小。
这也是为什么评测协议、chat template 和 tokenizer 必须进入实验账本,而不能只写模型名。
## 10. 逐 token top-6 有多稳定
对 RAW 与 USER 中能按 `(relative span, token ID)` 对齐的内容 token,报告:
- `set`:top-6 专家集合完全相同的比例;
- `J`:top-6 专家集合的平均 Jaccard。
| Layer | English | Chinese | Code | Math |
|---|---:|---:|---:|---:|
| L1 | set 0.552 · J 0.854 | set 0.600 · J 0.867 | set 0.625 · J 0.867 | set 0.459 · J 0.822 |
| L2 | set 0.606 · J 0.874 | set 0.604 · J 0.869 | set 0.562 · J 0.846 | set 0.577 · J 0.860 |
| L3 | set 0.625 · J 0.881 | set 0.634 · J 0.875 | set 0.570 · J 0.864 | set 0.644 · J 0.890 |
| L4 | set 0.572 · J 0.865 | set 0.590 · J 0.869 | set 0.562 · J 0.831 | set 0.593 · J 0.873 |
| L5 | set 0.540 · J 0.849 | set 0.550 · J 0.841 | set 0.551 · J 0.824 | set 0.610 · J 0.872 |
| L6 | set 0.581 · J 0.859 | set 0.558 · J 0.839 | set 0.529 · J 0.814 | set 0.544 · J 0.846 |
两个数字可以同时成立:
- 只有约 46%–64% 的 token 保持完全相同的 top-6 集合;
- 但平均 Jaccard 仍为 0.814–0.890。
直觉上,这说明很多变化不是“六个专家全部换掉”,而是 top-6 边缘的一两个专家发生替换。
它仍不能告诉我们专家“负责什么语义”。
## 11. 分布距离不大,不等于逐 token 没变化
RAW→USER 的对齐内容、prompt-balanced:
- total variation 点估计约为 0.024–0.067;
- JSD 点估计约为 0.001–0.008 nats。
聚合分布看起来接近,但上一节仍有大量 token 的 top-6 集合不完全相同。
这是一个重要的测量层级区别:
```text
aggregate distribution close
≠
every token took the same route
```
相反,也不能从 token route 变化推出输出答案或任务分数必然变化;本实验没有执行完整模型生成。
## 12. 独立复跑
正式输出:
```text
src/data/deepseek-v2-lite-routing-template.json
src/data/deepseek-v2-lite-routing-template-repro.json
```
两份完整 JSON:
```text
SHA-256
da1f10333b2fa269e64f9716a0ca6c1a656d23d3e70b8a25d6e59f8a5b3bc1b9
```
`cmp` byte-exact。
复现命令:
```bash
PYTHONPATH=/path/to/transformers-4.41.2-deps:/usr/lib/python3/dist-packages \
python -B experiments/deepseek/v2_lite_routing_template_probe.py \
--artifact-dir /path/to/deepseek-v2-lite \
--human-eval /path/to/HumanEval.jsonl.gz \
--gsm8k /path/to/gsm8k/test.jsonl \
--tnews /path/to/tnews/test.json \
--tnews-archive /path/to/tnews_public.zip \
--wikitext /path/to/wikitext-validation.parquet \
--output /path/to/template-probe.json \
--per-domain 32 \
--content-tokens 23 \
--batch-prompts 8 \
--layers 7 \
--bootstrap 2000 \
--seed 20260729 \
--captured-at 2026-07-29T08:30:00+00:00
```
Jinja2 是 `apply_chat_template` 的运行依赖;正式环境记录为 3.1.6。
## 13. 可以说什么,不能说什么
### 可以说
- 固定官方 revision 的 chat template 会改变输入 token 序列;
- 相同字符段、相同 token ID 的内容路由对角色前缀敏感;
- 敏感性随层和域改变方向;
- 追加在未来的 `Assistant:` 不改变此前共享前缀,六层 21,852 / 21,852 exact;
- wrapper-inclusive 与 aligned-content 统计回答不同问题;
- 正式运行与独立复跑 byte-exact。
### 不能说
- `User:` 专门激活某类“对话专家”;
- top-6 ID 的变化等于专家语义变化;
- 路由更平或更集中等于模型能力更强;
- 这个 base checkpoint 的 route probe 等于 chat model 的线上流量;
- 前六层等于完整 27 层;
- 23-token 前缀代表长对话;
- 不生成答案的实验可以给出任务准确率结论;
- 24 个 layer×domain 区间是已校正的显著性检验。
## 14. 下一道闸门
1. 下载并执行完整 layer 0–26,检查“低层变平、高层变集中”的模式是否延续;
2. 加入 system role、few-shot turn 与换行边界的正交扰动;
3. 把模板敏感性接到完整生成与输出指标,仍保持 route / output 两张账;
4. 在支持环境执行 FlashMLA optimized kernel;
5. 继续 FP8 / pipeline traces 与 R1-like 小模型复现。