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,至少要满足两个条件:

  1. 字符串层s₁s₂ 的严格前缀。
  2. Token 层t_rt_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 序列 trt_r,经 detokenize → parser 解析为结构化数据 → 构造新请求 → chat template render 为文本 → tokenize 得到 tpt_p 后,trt_r 必须是 tpt_p 的严格前缀。

形式化地:

  • 字符串层s1s_1(detokenize(trt_r) 的相关部分)是 s2s_2(apply_chat_template 输出)的严格前缀
  • Token 层trt_rtpt_p(tokenize(s2s_2) 的对应部分)的严格前缀

这里的 roundtrip 不是普通的语义等价,而是 token-exact 的:模型输出先被 tool parser / reasoning parser 解析成结构化消息,再由 chat template render 回下一轮 prompt,最终重新 tokenize 后,原始生成 token 必须仍然是新 prompt token 的严格前缀。

Parser 的定位。Parser 解决的是”看懂输出”——从模型生成的文本中提取 contentreasoning_contenttool_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.6preserve_thinking=True。默认只保留 last_query 之后的 thinking(chat_template.jinja:L100-103)。
  • GLM-5.1clear_thinking=False。默认把旧 thinking 替换为孤立的 </think>chat_template.jinja:L75-79)。
  • Kimi K2.6preserve_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-OSSauto_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.loadsjson.dumps 也会改变格式。Qwen3.6 和 GLM-5.1 的模型输出是 XML,parser 转为 JSON——不可逆

  • vLLM Qwen3XMLToolParserqwen3xml_tool_parser.py:L731-732,L1039):XML → JSON + 前导 \n 去除 + ast.literal_evaljson.dumps 类型转换。
  • vLLM Glm4MoeModelToolParserglm4_moe_tool_parser.py:L224):XML → JSON + .strip() + json.loads/ast.literal_evaljson.dumps
  • SGLang Qwen25Detectorqwen25_detector.py:L66):格式不匹配——用 Qwen2.5 JSON parser 解析 Qwen3 XML 输出,json.loads(match_result.strip())
  • SGLang Glm4MoeDetector:XML → JSON + 类型强制转换,最终 json.dumpsbase_format_detector.py:L84-86)。

DeepSeek V4 Pro 的 DSML 也被转为 JSON:vLLM DeepSeekV4ToolParserdeepseekv32_tool_parser.py:L228)、SGLang DeepSeekV4Detectordeepseekv32_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 KimiK2ToolParserkimi_k2_tool_parser.py:L96-104)和 SGLang KimiK2Detectorkimik2_detector.py:L148)都保留原始 JSON 字符串,不做 json.loadsjson.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|tojsonchat_template.jinja:L299)在 arguments 是 JSON 字符串时会双重编码。

Parser 的 json.loadsjson.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 零重分词,纯追加。 具体步骤:

  1. Trim to turn close:向后扫描 completion 寻找 stop token。如果因 max_tokens 截断而缺失,合成一个 close token 追加——这是安全的,因为 renderer 知道哪个 token 关闭一个 turn。
  2. 补尾部换行:vLLM 在 stop token 处截断,不返回其后的 \n。Bridge 显式补上。
  3. Render 新消息:只渲染新增的 tool response / user message。Bridge 拒绝接受 assistant 消息——assistant 的 token 是模型采样的,不应被重新 tokenize。
  4. 追加 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:

  1. 保存 raw token ids。每轮保存 prompt_idscompletion_ids,下一轮优先通过 token bridge 扩展,而不是重渲染全部历史。

  2. 结构化字段只作索引reasoning_contenttool_calls 可以用于 API 返回和工具执行,但不要把它们当作唯一真相——re-render 历史时不要用 re-serialize 的结果替换原始 token。

  3. 工具循环中保留 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
  4. Tool arguments 保留原始字节。JSON 解析后可以得到 dict 用于工具执行,但重渲染历史时不要用 json.dumps 的结果替换模型原文。

  5. Parser 选择必须匹配模板格式。SGLang 的 qwen parser 是 Qwen2.5 JSON tool-call 格式,不等于 Qwen3.6 XML 格式。配置不匹配会导致解析失败或静默的格式漂移。

  6. 截断与窗口裁剪按边界裁剪。不在 special token 序列、<think>...</think> block、JSON string 中间裁剪。如果必须丢历史,优先丢完整 message 或完整 tool cycle。裁剪后重新计算 cache key。

  7. 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. 链接