1. 问题与 Parse-Render Roundtrip
1.1 语义等价不等于 token-exact roundtrip
Agent 场景里的多轮调用通常长这样:
模型生成 token ids (t_r)
→ detokenize 得到文本 s₁
→ tool parser / reasoning parser 解析为结构化数据
→ 工具执行,客户端拼出新的 messages
→ apply_chat_template 得到下一轮 prompt 文本 s₂
→ tokenize 得到 prompt token ids (t_p)
Prefix cache(vLLM 的 automatic prefix caching [1]、SGLang 的 RadixAttention [2])想复用上一轮 KV cache,至少要满足两个条件:
- 字符串层:
s₁是s₂的严格前缀。 - Token 层:
t_r是t_p的严格前缀。
只满足第一个还不够——BPE 会受相邻字符影响,同一段文本在不同右侧上下文里可能重新分词。只满足”解析后的结构化消息等价”也不够,因为 parser 可能把 false 变成 Python False、把 JSON 重新 dump、把 thinking block 从历史里清掉。对推理系统来说,这些都是历史 token 被改写。
这篇文章关心的不是”模型能不能正确调用工具”,而是更窄但更硬的性质:模型已经采样出来的 token,在下一轮请求里是否仍然逐 token 原样出现?
1.2 定义 Parse-Render Roundtrip
我把这条规范称为 Parse-Render Roundtrip(PRR)。
Parse-Render Roundtrip 要求:模型生成的 token 序列 ,经 detokenize → parser 解析为结构化数据 → 构造新请求 → chat template render 为文本 → tokenize 得到 后, 必须是 的严格前缀。
形式化地:
- 字符串层:(detokenize() 的相关部分)是 (apply_chat_template 输出)的严格前缀
- Token 层: 是 (tokenize() 的对应部分)的严格前缀
这里的 roundtrip 不是普通的语义等价,而是 token-exact 的:模型输出先被 tool parser / reasoning parser 解析成结构化消息,再由 chat template render 回下一轮 prompt,最终重新 tokenize 后,原始生成 token 必须仍然是新 prompt token 的严格前缀。
Parser 的定位。Parser 解决的是”看懂输出”——从模型生成的文本中提取 content、reasoning_content、tool_calls。这是必要的,但它不解决”保住前缀”。PRR 要求 parse + render 是对称操作:parser 可以产出结构化视图,但 render 不能因此改写模型已经生成过的 token。
容错与 PRR 的张力。一些 tool parser 做了容错——partial_json_parser(SGLang)、自动补齐引号、容忍尾随逗号。这对工具执行正确且必要,但容错后的输出和模型原文不同。如果用容错结果 re-render 历史,PRR 就断了。解决方案是分层:parser 输出两个东西——parsed_tool_calls(容错后的结构化数据,用于工具执行)和 raw_completion_ids(原始 token ids,用于 prefix 延续)。当前推理引擎只保存前者然后 re-render;PRR 要求也保存后者用于 bridge。
和 TITO 的关系。Agentic RL 里的 token-in-token-out(TITO)试图绕过有损的 parse-render roundtrip,直接在 token 层延续。PRR 则是更广泛推理系统里的最低设计规范——即使系统仍然使用 tool parser、reasoning parser 和 chat template,也不能改写模型已经生成过的 token。
1.3 闭源 API 如何实现高 cache 命中率
闭源 API 面临同样的 PRR 挑战,但通过不同路径来保证。
OpenAI:服务端控制 re-render + reasoning 持久化
OpenAI Responses API 的 previous_response_id [3] 不是 KV cache 指针,而是服务端会话状态存储。服务器从存储中恢复完整的 response 对象,重建完整 token 序列——所有 prior input tokens 仍然计费。它的优势不是跳过 re-serialization,而是由服务端而非客户端来 re-serialize,通过 Harmony renderer [4] 保证 byte-identical prefix,避免客户端 re-serialization 引入的差异(JSON key 顺序、空白、schema 变化等)。
OpenAI 报告的 40-80% cache 提升只针对 reasoning 模型(o1/o3/o4-mini)[3]。原因很直接:Chat Completions API 中 reasoning tokens 对客户端不可见,每轮丢失,前缀更短;Responses API 在服务端持久化 reasoning tokens,形成更长的匹配前缀。值得注意的是,reasoning tokens 在下一轮被剥离(模型不重用先前 CoT),但对话其余部分形成稳定的可缓存前缀。prompt_cache_key 是独立的路由亲和性提示,通过 hash 前缀的前 ~256 tokens 将请求路由到同一 GPU [3]。
Anthropic:re-render 但严格控制 serialization
Anthropic 的 prompt caching [5] 采用不同路径:每次请求完整 re-serialize(tools + system + messages),cache 匹配最长 common prefix。它通过 cache_control 断点显式标记缓存边界,最小缓存长度 1024 tokens。
关键在于 thinking blocks 的处理 [6]:thinking blocks 在 tool-use continuations 中隐式保留。更新的模型(Opus 4.5+、Sonnet 4.6+)改进了策略——即使添加 non-tool-result user content 也保留 thinking blocks。JSON key 顺序、timestamp、thinking 设置变更都会导致 cache miss,因为匹配是严格的 token-exact prefix。
共同点:两条路径的核心需求一致——reasoning tokens 在多轮中必须保留,serialization 必须 token-exact 一致。开源模型面临同样的问题,需要走同样的路径。
2. 五个开源模型的 PRR 分析
我们下载了 Qwen3.6、GLM-5.1、Kimi K2.6、DeepSeek V4 Pro、GPT-OSS 五个模型的 tokenizer 和 chat template(不含权重),对每个模型模拟了完整的 parse-render roundtrip。点击下方矩阵的单元格查看具体漂移原因和代码证据:
| Qwen3.6 | GLM-5.1 | Kimi K2.6 | DS V4 Pro | GPT-OSS |
|---|
下面按漂移根因分类讨论,每类对应 DriftMatrix 中的一到两个场景行。
2.1 历史 thinking 保留
有开关可修复。 所有模型的默认 chat template 都会删除或缩减历史 assistant 的 thinking 内容。这个默认值是为了兼容无 tool-use 的对话场景——用户不需要看到上一轮的 CoT。但在 Agent 多轮场景下,删除 thinking 直接导致 prefix 断裂。
各模型的开关和代码位置:
- Qwen3.6:
preserve_thinking=True。默认只保留last_query之后的 thinking(chat_template.jinja:L100-103)。 - GLM-5.1:
clear_thinking=False。默认把旧 thinking 替换为孤立的</think>(chat_template.jinja:L75-79)。 - Kimi K2.6:
preserve_thinking=True。基于 history/suffix 分区,最后一个非 tool-call assistant 之前的所有 thinking 变为空<think></think>(chat_template.jinja:L42-54,L67-68)。 - DeepSeek V4 Pro:自动。检测到 tools 存在时自动禁用
drop_thinking,无需手动设置(encoding_dsv4.py:L549-551)。 - GPT-OSS:
auto_drop_analysis。任何 tool-call assistant 的 analysis channel,只要后续存在 non-tool-call assistant 就被删除(chat_template.jinja:L277-295)。
DeepSeek V4 Pro 的设计最好——有 tools 时自动保留,用户无感知。其余模型需要显式开启。
2.2 空白规范化(reasoning 中换行 · </think> 后换行)
Chat template 端。提取 reasoning 和 content 时的 strip()/trim()/lstrip(),以及 re-render 时的固定换行格式,会改变模型原始空白。
Qwen3.6 最典型——三重去除 + 固定格式(chat_template.jinja:L95-101):
{%- set reasoning_content = content
.split('</think>')[0].rstrip('\n')
.split('<think>')[-1].lstrip('\n') %}
{%- set reasoning_content = reasoning_content|trim %}
{# re-render 固定 \n<think>\n{rc}\n</think>\n\n #}
不论模型生成了几个换行,都被统一为首尾各 1 个。GLM-5.1 更严重——content.strip() 把 </think> 后所有换行都吞掉(chat_template.jinja:L80-81)。
DeepSeek V4 Pro 最好——thinking_template = "{reasoning_content}" 直接拼接,不加额外空白(encoding_dsv4.py:L47)。Kimi K2.6 也不做 strip(chat_template.jinja:L89),但依赖 content 字段保留前导 \n。
Parser 端。vLLM 和 SGLang 的 reasoning parser 行为不同:
- vLLM:所有模型的 reasoning parser 用
partition()纯字符串切片——零漂移(basic_parsers.py:L168-178)。 - SGLang:所有模型做
.strip()/.rstrip()——有漂移(reasoning_parser.py:L75,L89,L102;streaming 路径L153)。
这意味着同一模型、同一 chat template,在 vLLM 上可能命中 prefix cache,在 SGLang 上可能不命中——仅因为 reasoning parser 的实现差异。
2.3 截断补闭合(reasoning 截断)
当模型因 max_tokens 截断时,re-render 会补上模型未生成的闭合标签。
Qwen3.6、GLM-5.1、Kimi K2.6 的 chat template 总是输出完整的 <think>...</think> 闭合(chat_template.jinja:L101,L76,L89),即使模型没有生成 </think>,re-render 也会补上。DeepSeek V4 Pro 采取更安全的策略:直接 assert 失败,拒绝处理不完整的 thinking(encoding_dsv4.py:L718)。
截断不可避免,但补闭合的 token 破坏 prefix。Renderers [8] 的 bridge 在拼接点合成 close token——只追加不重写——是正确路径。注意 content 截断(无 stop token)不影响前缀匹配:stop token 追加在模型已生成 token 的末尾,不改写已有 token,prefix cache 可匹配到模型最后一个生成 token 为止。
2.4 tool_call 容错 · tool_call args 序列化
这是最难修复的漂移源。完整 roundtrip:模型输出 → parser 提取 tool_calls(格式转换 + JSON 规范化)→ 工具执行 → chat template 渲染回模型格式(可能二次编码)。两端都可能有损。
tool_call 容错。 截断发生在 JSON/XML 参数中间时,parser 需要容错处理。Kimi K2.6 依赖 partial_json_parser,补齐后的 JSON 与原始不同。Qwen3.6 和 GLM-5.1 的 XML 格式更有弹性——已完成的参数块可以独立解析,但仍需补闭合标签。DeepSeek V4 Pro 的 DSML parser 不容错,正则匹配失败直接报错。
tool_call args 序列化。 即使参数完整,parser 的 json.loads → json.dumps 也会改变格式。Qwen3.6 和 GLM-5.1 的模型输出是 XML,parser 转为 JSON——不可逆:
- vLLM
Qwen3XMLToolParser(qwen3xml_tool_parser.py:L731-732,L1039):XML → JSON + 前导\n去除 +ast.literal_eval→json.dumps类型转换。 - vLLM
Glm4MoeModelToolParser(glm4_moe_tool_parser.py:L224):XML → JSON +.strip()+json.loads/ast.literal_eval→json.dumps。 - SGLang
Qwen25Detector(qwen25_detector.py:L66):格式不匹配——用 Qwen2.5 JSON parser 解析 Qwen3 XML 输出,json.loads(match_result.strip())。 - SGLang
Glm4MoeDetector:XML → JSON + 类型强制转换,最终json.dumps(base_format_detector.py:L84-86)。
DeepSeek V4 Pro 的 DSML 也被转为 JSON:vLLM DeepSeekV4ToolParser(deepseekv32_tool_parser.py:L228)、SGLang DeepSeekV4Detector(deepseekv32_detector.py:L66,L96,L124)都做 .strip() + json.loads + json.dumps。GPT-OSS 更直接——vLLM OpenAIToolParser 显式 json.dumps(json.loads(raw_args))(openai_tool_parser.py:L63)。
Kimi K2.6 是唯一例外——vLLM KimiK2ToolParser(kimi_k2_tool_parser.py:L96-104)和 SGLang KimiK2Detector(kimik2_detector.py:L148)都保留原始 JSON 字符串,不做 json.loads → json.dumps 规范化。
Chat template 端:二次编码。即使 parser 保留了原始字符串,chat template 渲染时也可能引入漂移。如果 arguments 字段是 string(原始保留),Qwen3.6(chat_template.jinja:L122)和 Kimi K2.6(chat_template.jinja:L34)直接输出;如果是 dict(parser 已解析),tojson 重新序列化的空格、key 顺序可能与模型原文不同。GPT-OSS 的 arguments|tojson(chat_template.jinja:L299)在 arguments 是 JSON 字符串时会双重编码。
Parser 的 json.loads → json.dumps 是最常见的不可逆变换,且没有开关可关闭。保留 raw completion token ids 并通过 bridge 路径拼接是唯一可靠方案。
3. 满足 PRR 的设计规范
3.1 Renderers:bridge 优于 re-render
以上分析的核心问题是:apply_chat_template 从 messages(字符串)出发重新渲染整个对话,无法接受”这些是已有的 token,只追加新内容”。PrimeIntellect 的 renderers 项目 [8] 直接解决了这个限制。
Renderers 定义了 Renderer 协议,核心方法:
class Renderer(Protocol):
def render(self, messages, tools, add_generation_prompt) -> RenderedTokens: ...
def parse_response(self, token_ids) -> ParsedResponse: ...
def bridge_to_next_turn(self, prev_prompt_ids, prev_completion_ids,
new_messages, tools) -> RenderedTokens | None: ...
render() 等价于 apply_chat_template,但输出包含逐 token 的 message 归属索引。parse_response() 是 render 的逆——接受 token ids,返回结构化 response。bridge_to_next_turn() 是核心——接受上一轮的 prompt + completion token ids + 新消息,不重新 tokenize 已有内容,只追加新轮次的 token。
Bridge 的契约:返回值 B 满足 B[:len(prev_prompt) + len(prev_completion)] == prev_prompt + prev_completion。已有 token 零重分词,纯追加。 具体步骤:
- Trim to turn close:向后扫描 completion 寻找 stop token。如果因 max_tokens 截断而缺失,合成一个 close token 追加——这是安全的,因为 renderer 知道哪个 token 关闭一个 turn。
- 补尾部换行:vLLM 在 stop token 处截断,不返回其后的
\n。Bridge 显式补上。 - Render 新消息:只渲染新增的 tool response / user message。Bridge 拒绝接受 assistant 消息——assistant 的 token 是模型采样的,不应被重新 tokenize。
- 追加 generation prompt:拼接与
render()相同的 assistant 开头。
为什么 BPE 边界不是问题:bridge 的拼接点始终是 special token(如 <|im_end|>),它们在词表中是原子 token,不参与 BPE 合并。Renderers README 报告在 Qwen3.5-35B-A3B 上,标准 re-render 路径 64 次 rollout 中有 32 次前缀断裂,bridge 路径为 0 次。
Per-token 归属:RenderedTokens 为每个 token 标记来源 message 索引(-1 = 模板结构 token)。这对 RL 训练直接可用——基于 message_indices + role 产生精确的 per-token loss mask,无需事后对齐。
3.2 设计建议
如果你在做 self-host agent 服务或 RL 训练,按以下优先级保证 PRR:
-
保存 raw token ids。每轮保存
prompt_ids和completion_ids,下一轮优先通过 token bridge 扩展,而不是重渲染全部历史。 -
结构化字段只作索引。
reasoning_content、tool_calls可以用于 API 返回和工具执行,但不要把它们当作唯一真相——re-render 历史时不要用 re-serialize 的结果替换原始 token。 -
工具循环中保留 reasoning。各模型的开关:
- Qwen3.6:
preserve_thinking=True - GLM-5.1:
clear_thinking=False - Kimi K2.6:
preserve_thinking=True - DeepSeek V4 Pro:默认(有 tools 时自动禁用 drop)
- GPT-OSS:关注 Harmony 的
auto_drop_analysis
- Qwen3.6:
-
Tool arguments 保留原始字节。JSON 解析后可以得到 dict 用于工具执行,但重渲染历史时不要用
json.dumps的结果替换模型原文。 -
Parser 选择必须匹配模板格式。SGLang 的
qwenparser 是 Qwen2.5 JSON tool-call 格式,不等于 Qwen3.6 XML 格式。配置不匹配会导致解析失败或静默的格式漂移。 -
截断与窗口裁剪按边界裁剪。不在 special token 序列、
<think>...</think>block、JSON string 中间裁剪。如果必须丢历史,优先丢完整 message 或完整 tool cycle。裁剪后重新计算 cache key。 -
Prefix 检查做进 CI。每个模型模板升级后,至少跑一组工具调用 + 多工具调用 + 无工具最终回答 + 下一轮 user + 截断 stop token 的 text-prefix 和 token-prefix 测试。
3.3 最终判断标准
判断标准很简单:
assert previous_text == next_prompt_text[:len(previous_text)]
assert previous_token_ids == next_prompt_ids[:len(previous_token_ids)]
只要这两个断言不能稳定通过,就不要假设 prefix cache 会稳定命中。
参考文献
[1] vLLM. Automatic Prefix Caching. 链接
[2] Zheng, L., et al. SGLang: Efficient Execution of Structured Language Model Programs. arXiv:2312.07104. 链接
[3] OpenAI. Prompt Caching Guide & Conversation State. Prompt Caching | Conversation State | Prompt Caching 201
[4] OpenAI. Harmony Response Format. 链接
[5] Anthropic. Prompt Caching. 链接
[6] Anthropic. Extended Thinking. 链接
[7] DeepSeek. DeepSeek-V4-Pro Encoding Module. 链接
[8] PrimeIntellect. Renderers: Prefix-Preserving Chat Template Rendering. 链接