执行摘要
- 一句话:修复自定义编码器下 Anthropic 历史思考标记泄漏
- 推荐动作:值得精读。三点设计值得借鉴:一是从渲染产物反推根因——用修复前后经真实 tokenizer 的渲染对照定位频道错位,而不是只看解析器行为;二是契约用
spec is not None 覆盖整个编码器家族并明确安全默认方向(丢弃优于泄漏),避免未来新增编码器踩同样的坑;三是用源码解析守卫测试把“新增编码器必须分类”变成 CI 硬约束并做 mutation-check。若后续增强,建议补 dsv4 / dsv32 / inkling 的端到端多轮回归,并把 reasoning_content 的双向验证收进契约测试。
功能与动机
按 PR body 的描述,用 Kimi-K3 跑 --reasoning-parser kimi_k3 --tool-call-parser kimi_k3 并在 /v1/messages 开启 thinking 时,agent 客户端按 Messages API 要求把 thinking 块回传历史,几轮之后原始 XTML 频道标记(如 <|open|>think<|sep|>)出现在可见文本中且永不恢复。作者通过去掉 thinking 块的对照实验确认根因在“历史推理的回灌方式”而非推理解析器:K3 没有 Jinja chat template,SGLang 走 checkpoint 自带的 XTML 编码器,该编码器自行框架 think / response / tools 频道并期望历史推理放在 assistant 的 reasoning_content 字段;而 Anthropic 适配器从未使用该字段,反而把历史思考用推理检测器标记包裹后拼进 content,等于每轮都在教模型把频道标记当正文输出。
实现拆解
变更入口
整个修复以 chat_encoding.py 新增的 spec_owns_reasoning_history 契约为入口,沿 serving_chat.py 的能力委托,最终在 anthropic/serving.py 的 Anthropic → OpenAI 消息转换中消费。
- 契约定义(
python/sglang/srt/entrypoints/openai/chat_encoding.py):新增 spec_owns_reasoning_history(spec),返回 spec is not None,即所有绕过 apply_chat_template 的自定义编码器(dsv4 / dsv32 / inkling / kimi_k3)都被认定“自行负责推理历史渲染”;同时更新 resolve_chat_encoding_spec 的 docstring,使契约与规格定义放在一起。选择按整个编码器家族作答而非枚举名单,让未来新增的第五种编码器自动获得安全默认:最坏情况是丢弃历史,而不是泄漏标记。
- 能力暴露(
python/sglang/srt/entrypoints/openai/serving_chat.py):OpenAIServingChat 新增 supports_native_reasoning_history(),一行委托给 chat_encoding.spec_owns_reasoning_history(self.chat_encoding_spec),供 Anthropic 适配器查询当前模型的编码器是否原生接收 reasoning_content。
- 消费端改造(
python/sglang/srt/entrypoints/anthropic/serving.py):_convert_assistant_thinking_blocks 返回类型从 Optional[str] 扩展为 (Optional[str], Optional[str]) 二元组,分别表示 reasoning_content 与需 wrap 进 content 的文本,二者至多一个非 None。组装 assistant 消息时,命中原生能力则写入 openai_msg["reasoning_content"],否则走原 wrap_reasoning_history 拼接路径;redacted_thinking 仍在入口抛错,无 detector 模型的尽力丢弃逻辑原样保留。无需 schema 变更:ChatCompletionMessageGenericParam 已声明 reasoning_content,消息序列化未排除 None,字段自然透传。
- 测试配套:
anthropic/test_serving.py 新增 2 个用例——历史思考必须到达 reasoning_content 且绝不经过 wrap 路径(用抛 AssertionError 的桩验证)、纯思考轮在空 content 占位分支下仍保留推理字段;openai/test_serving_chat.py 新增能力枚举用例(四个 spec 加 None)与源码解析守卫测试(正则解析 resolve_chat_encoding_spec 的 return 字面量并与 _ALL_CHAT_ENCODING_SPECS 比对,新增编码器未分类时 CI 失败)。作者声明新用例对修复前代码均红且经过 mutation-check,并非空洞断言;实机验证覆盖流式 / 非流式 / 并发会话 / 禁用 thinking / redacted_thinking 等多种场景。
关键文件:
python/sglang/srt/entrypoints/anthropic/serving.py(模块 协议适配;类别 source;类型 core-logic;符号 _convert_assistant_thinking_blocks): 核心修复点:_convert_assistant_thinking_blocks 改为返回 (reasoning_content, text) 二元组,并依据 supports_native_reasoning_history() 分支决定历史思考走字段还是走 wrap 拼接;所有经 Anthropic /v1/messages 携带 thinking 历史的请求都经过这条路径。
python/sglang/srt/entrypoints/openai/chat_encoding.py(模块 聊天编码;类别 source;类型 core-logic;符号 spec_owns_reasoning_history, resolve_chat_encoding_spec): 新增 spec_owns_reasoning_history 契约,定义“非 None spec 即拥有推理历史渲染权”,是整个修复的判定依据;契约与 resolve_chat_encoding_spec 放在一起,让规格定义与契约绑定,未来新增编码器自动获得安全默认。
python/sglang/srt/entrypoints/openai/serving_chat.py(模块 聊天服务;类别 source;类型 core-logic;符号 supports_native_reasoning_history): 新增 supports_native_reasoning_history() 一行委托,把 chat_encoding 契约暴露给 Anthropic 适配器查询;与 wrap_reasoning_history 并列放置,明确两条历史渲染路径的边界。
test/registered/unit/entrypoints/anthropic/test_serving.py(模块 协议测试;类别 test;类型 test-coverage;符号 supports_native_reasoning_history, test_assistant_thinking_history_uses_native_reasoning_content, _NativeOpenAI, test_thinking_only_turn_keeps_native_reasoning_content): 新增两个单测钉死新行为:历史思考必须到达 reasoning_content 且绝不经过 wrap 路径(wrap 被替换为抛 AssertionError 的桩),纯思考轮在空 content 占位分支下仍保留推理字段;两个用例对修复前代码均红。
test/registered/unit/entrypoints/openai/test_serving_chat.py(模块 聊天测试;类别 test;类型 test-coverage;符号 test_custom_encoders_own_reasoning_history, test_all_chat_encoding_specs_are_enumerated, _ALL_CHAT_ENCODING_SPECS): test_custom_encoders_own_reasoning_history 对全部四个 spec 加 None 断言能力枚举;test_all_chat_encoding_specs_are_enumerated 用正则解析源码 return 字面量并钉住 spec 名单,新增编码器未分类时 CI 立即失败,且经 mutation-check。
关键符号:spec_owns_reasoning_history, supports_native_reasoning_history, _convert_assistant_thinking_blocks, test_assistant_thinking_history_uses_native_reasoning_content, test_thinking_only_turn_keeps_native_reasoning_content, test_custom_encoders_own_reasoning_history, test_all_chat_encoding_specs_are_enumerated
关键源码片段
python/sglang/srt/entrypoints/anthropic/serving.py
核心修复点:_convert_assistant_thinking_blocks 改为返回 (reasoning_content, text) 二元组,并依据 supports_native_reasoning_history() 分支决定历史思考走字段还是走 wrap 拼接;所有经 Anthropic /v1/messages 携带 thinking 历史的请求都经过这条路径。
def _convert_assistant_thinking_blocks(
blocks: list[AnthropicContentBlock],
) -> tuple[Optional[str], Optional[str]]:
"""把上一轮 thinking 块还原为 (reasoning_content, text) 二元组,至多一个非 None。
自带频道框架的编码器(custom encoder,如 dsv4 / dsv32 / kimi_k3 / inkling)
通过 reasoning_content 接收历史思考;其余模型沿用 wrap 拼接进 content。
"""
# redacted_thinking 携带本地解析器无法解读的加密字节,宁可抛错也不静默丢弃
if any(block.type == "redacted_thinking" for block in blocks):
raise ValueError("Anthropic redacted_thinking history is not supported")
thinking_parts = [
block.thinking
for block in blocks
if block.type == "thinking" and block.thinking
]
if not thinking_parts:
return None, None
reasoning_text = "\n".join(thinking_parts)
# 关键分支:编码器自己框架 think 频道,历史思考必须走 reasoning_content,
# 否则 think 块会嵌套进 response 频道而真频道为空,等于每轮教模型输出原始标记
if self.openai_serving_chat.supports_native_reasoning_history():
return reasoning_text, None
# 默认路径(Jinja chat template):用检测器标记包裹后拼进 content,
# 标记本来就活在内容流里,能忠实还原模型此前的输出
try:
return None, self.openai_serving_chat.wrap_reasoning_history(reasoning_text)
except ValueError as e:
# 没有配置 reasoning detector 的模型:尽力而为,丢弃历史思考,
# 避免历史回显把整个请求打成 400
logger.warning(
"Dropping prior-turn thinking history (%d blocks): %s",
len(thinking_parts),
e,
)
return None, None
# 调用侧:组装 assistant 消息时按返回值分流
if msg.role == "assistant":
reasoning_content, reasoning_history = _convert_assistant_thinking_blocks(msg.content)
# 自定义编码器:历史思考直接进 reasoning_content 字段,随消息透传
if reasoning_content is not None:
openai_msg["reasoning_content"] = reasoning_content
# Jinja 路径:wrap 后的文本作为 content 片段,放在正文最前
if reasoning_history is not None:
content_parts.append({"type": "text", "text": reasoning_history})
python/sglang/srt/entrypoints/openai/chat_encoding.py
新增 spec_owns_reasoning_history 契约,定义“非 None spec 即拥有推理历史渲染权”,是整个修复的判定依据;契约与 resolve_chat_encoding_spec 放在一起,让规格定义与契约绑定,未来新增编码器自动获得安全默认。
def resolve_chat_encoding_spec(
*, hf_config: Any, tokenizer: Any, tool_call_parser: Optional[str] = None
) -> Optional[str]:
"""返回模型对应的聊天编码 spec。
None 表示默认路径(HF chat template);非 None 的 spec 同时拥有
推理历史渲染权(见 spec_owns_reasoning_history)。
"""
# 工具解析器优先:显式指定 deepseekv4 / deepseekv32 / kimi_k3 时直接命中
if tool_call_parser == "deepseekv4":
return "dsv4"
if tool_call_parser == "deepseekv32":
return "dsv32"
if tool_call_parser == "kimi_k3":
return "kimi_k3"
architectures = hf_config.architectures
arch = architectures[0] if architectures else ""
# 架构兜底:DeepSeek-V4、Kimi-K3 等模型没有可用 Jinja 模板
if "DeepseekV4" in arch:
return "dsv4"
if "KimiK3" in arch:
return "kimi_k3"
# Inkling 同样没有 Jinja chat_template,只能走专用渲染器
if "InklingForConditionalGeneration" in arch:
return "inkling"
has_chat_template = tokenizer is not None and tokenizer.chat_template is not None
if "DeepseekV3" in arch and not has_chat_template:
return "dsv32"
return None
def spec_owns_reasoning_history(spec: Optional[str]) -> bool:
"""判断 spec 对应的编码器是否自行渲染助手推理历史。
自定义编码器自己框架 think / response 频道并读取 reasoning_content,
历史思考必须走该字段传入;若沿用旧逻辑把检测器标记拼进 content,
会在 response 频道内部嵌套一个 think 块而真正的 think 频道为空,
等于每轮都在教模型把原始标记当可见文本输出。
按整个编码器家族(spec is not None)作答而非枚举名单:
新增编码器自动拿到安全默认 —— 最坏情况是丢弃历史,而不是泄漏标记。
"""
return spec is not None
评论区精华
本 PR 无 review 评论线程(review_comments = 0),唯一审核为 JustinTong0323 的 APPROVED,附言 "LGTM"。核心设计论证全部集中在 PR body:作者给出了修复前后经 K3 tokenizer 实际渲染的对照(修复前 think 频道为空、思考块嵌套在 response 频道内;修复后 think 频道正确承载历史思考),并论证“所有四个绕过 apply_chat_template 的 Python 聊天编码器都自行框架推理频道并读取 reasoning_content,因此旧的拼接逻辑对它们是普遍性错误而非 K3 特例”。同时对两处边界做了明确取舍:Jinja 模板路径故意保留拼接(其标记本来就活在内容流里,拼接能忠实还原模型输出);忽略该字段的编码器最坏丢弃历史而不泄漏。
风险与影响
- 风险:
- 行为面广:
spec is not None 一票覆盖 dsv4 / dsv32 / inkling / kimi_k3 四个编码器,多轮历史渲染路径全部从 wrap 切换为 reasoning_content,虽然符合 dsv4 / dsv32 的官方多轮契约,但存量部署若依赖旧拼接行为需要回归;实机验证仅覆盖 Kimi-K3,其余编码器只有单测保障。
- 静默丢弃:若未来某编码器不读
reasoning_content,历史思考会被静默丢弃而非报错,用户难以察觉——这是设计上有意取舍(丢弃优于泄漏),但在可观测性上留了缺口。
- 守卫测试脆弱:
test_all_chat_encoding_specs_are_enumerated 用正则解析源码 return 字面量,若未来重构改变返回写法(如改为变量间接返回)会漏检或误检。
- 序列化依赖:字段透传依赖
ChatCompletionMessageGenericParam 已声明 reasoning_content 且消息 dump 不排除 None,未来序列化策略变更会导致字段静默丢失。
- CI 状态:PR body 显示 Extra 与 AMD ROCm 7.2 两个 CI 任务失败,材料中未见失败原因说明,合并前未解决;建议关注后续 nightly 是否覆盖 Anthropic 多轮路径。
- 回归面整体可控:
_convert_assistant_thinking_blocks 返回类型变更影响所有经 /v1/messages 携带 thinking 历史的请求,但非自定义编码器分支逻辑等价,OpenAI 端点与 Jinja 模板模型行为不变。
- 影响:对用户:Kimi-K3 经 Anthropic /v1/messages 的多轮工具对话不再出现 XTML 标记泄漏,工具循环能够收敛(12 轮从每轮泄漏变为第 9 轮收敛,并发 6 会话全部转好);DeepSeek-V4 / V3.2 的多轮思考历史也切换到官方 reasoning_content 契约。对系统:无 API 或 schema 破坏性变更,OpenAI /v1/chat/completions 逻辑不变,Jinja 模板模型行为保持原样,回归面集中在自定义编码器 + Anthropic 适配器这一条路径。对团队:确立了“自定义编码器拥有推理历史渲染权”的契约与安全默认,守卫测试把“新增编码器必须分类”变成 CI 硬约束,该测试手法(源码字面量解析 + mutation-check)可复用到同类能力探测。
- 风险标记:核心服务路径变更, 影响全部自定义编码器, 静默丢弃历史思考的取舍, 正则守卫测试较脆弱, Extra / AMD CI 失败未说明
关联脉络
- PR #35508 [NPU] [DOC] Add Ascend NPU (A3) recipe to the Kimi-K3 cookbook: 同类模型线(Kimi-K3):该 PR 完善了 Kimi-K3 的部署 recipe,而本 PR 修复了 Kimi-K3 在 Anthropic 协议下多轮思考历史泄漏,两者共同构成 Kimi-K3 端到端可用性支撑。
- PR #35854 [AMD] Update amd deepseek v4 cookbook 0822: DeepSeek-V4 同样走 dsv4 自定义聊天编码器路径,本 PR 使 dsv4 / dsv32 的多轮 Anthropic 思考历史从 wrap 拼接切换到官方 reasoning_content 契约,与 V4 部署演进相关。
参与讨论