Prhub

#33025 [Kimi K3] Add reasoning, tool-call, and OpenAI serving support

原始 PR 作者 JustinTong0323 合并时间 2026-08-02 05:57 文件变更 34 提交数 22 评论 9 代码增减 +3439 / -198

执行摘要

Kimi K3 推理与工具调用解析及 OpenAI 服务支持

PR body 明确说明拆分动机:"This extracts the parser and directly related OpenAI serving work from #32541 so the model-support PR has a smaller review surface. The parser, constraint, auto-detection, Chat Completions, and Responses pieces are presented as one pull request because they share the same Kimi K3 wire format and need end-to-end validation together." 需要解决的三个问题:(1) Kimi K3 使用 XTML 结构化通道(think / response / tools),每个 marker 都是多 token 特殊序列,现有 parser 无法处理;(2) xgrammar 结构约束必须贴合 K3 原生编码,否则强制 JSON-only schema 时模型输出无法被解析器读回;(3) 字面 <|kimi_image_placeholder|> 文本会被上游渲染器当图像槽位,text-only 场景直接 400(issue #32960),PR 同时并入 #33003 的占位符中和修复。

值得精读。建议关注 4 个设计点:(1) kimik3_structural_tag.py 的 JSON Schema → XTML 双格式翻译与 OrFormat 变体构造;(2) KimiK3Detector(function_call 与 reasoning_parser 两个)的流式状态机与 buffer / 游标重置契约;(3) TokenSequenceMatcher(KMP 前缀函数)在 grammar 回滚与多 token marker 匹配中的用途;(4) #33192 的约束失败降级策略——“不强制 JSON-only schema” 的取舍。若要接入新的专用 token 格式模型,这是当前最完整的参考实现。

讨论亮点

核心交锋集中在 issue #32960 的占位符 400 排查线程:

  • tancheng33 定位到根因:serving_chat.py 无条件执行 template_kwargs['image_prompts'] = ['<|media_pad|>'] * image_count,当 image_count == 0 时传入 [],使上游 encoding_k3.py 渲染器进入 scan-and-consume 模式并立即抛 ValueError;而 image_prompts=None 会把占位符渲染为惰性纯文本。
  • hnyls2002 确认该修复来自 #33003 并已落地本 PR。
  • tancheng33 进一步指出残留路径:上游渲染器还会对 assistant tool_calls 参数(json_block 与 _xtml_value 分支)以及 reasoning_content 做同样的占位符扫描,这两条路径未经过 neutralize,text-only agentic 对话一旦出现引用字面占位符的工具参数,后续每一轮请求都会 400;建议补 if image_count: 守卫。
  • tancheng33 最终验证 #33183 覆盖了全部残留路径:渲染前统一中和 reasoning / 嵌套工具参数,image_prompts 重建时加 if image_count: 守卫并丢弃过期用户值。

另一项设计决策来自 #33192(作为本 PR 最后一个提交并入):KimiK3Detector 的 parses_required_natively() 返回 False,但最终策略是让模型原生 parser 处理 required tool choice;当结构约束构建失败时,保持 K3 输出不受限(原生格式)而不是退回 JSON-only schema——因为 K3 的解析器读不回 JSON-only 输出。

实现拆解

  1. wire 格式常量层(新增 python/sglang/srt/function_call/kimik3_format.py:定义 THINK_OPEN / THINK_CLOSE / RESPONSE_OPEN / RESPONSE_CLOSE / TOOLS_OPEN / TOOLS_CLOSE / MESSAGE_CLOSE 等 XTML marker 常量,并提供 3 个流式工具函数——partial_suffix_len(计算文本尾部与任一 marker 前缀的最大重叠长度)、strip_partial_marker_suffix(剥离因 max_tokens 截断残留的半截 marker)、strip_response_wrappers(剥掉 response / message 包装)。这是流式解析正确性的地基:任何 marker 都可能跨 chunk 截断。

  2. 结构约束层(新增 kimik3_structural_tag.py,+598 行):把 OpenAI Tool 的 JSON Schema 翻译成 xgrammar StructuralTag。关键路径是 _schema_types(推导 schema 可接受的 JSON 类型集合,处理 $ref / anyOf / oneOf / allOf / const / enum 及关键字启发)、_restrict_schema_type(按单个 JSON 类型收窄 schema,保证各类型变体互斥)、_argument_value_variants_known_argument_format(为每个类型生成 TagFormat,string 用 qwen_xml 风格、其余用 JSONSchemaFormat,多类型时包成 OrFormat)。对外暴露 get_kimik3_structural_tagget_kimik3_auto_tool_call_structural_tag,支持 auto / required / named 三种 tool choice、parallel_tool_callsSGLANG_TOOL_STRICT_LEVEL

  3. 工具调用解析层(新增 kimik3_detector.py,+234 行)KimiK3Detector 用正则提取 tools 通道中的 call / argument 块,_parse_attrs 反转义属性(& / "),string 参数保留原文、其余类型 JSON 解码(失败回退字符串)。提供一次性 detect_and_parse 与流式 parse_streaming_increment 两套接口;流式实现依赖 buffer 与 _sent_normal_idx 游标,异常路径按“重置 buffer 必须同步重置游标”的契约处理,否则后续输出会被静默丢弃。supports_structural_tag() 返回 True,parses_required_natively() 返回 False(配合 #33192 降级策略)。

  4. 推理解析层(修改 reasoning_parser.py,+188 行):新增同名 KimiK3Detector(职责为解析 think 通道)并注册进 ReasoningParser.DetectorMapkimi_k3 项。处理 3 个边界:缺省 <|sep|> 的结束 marker 恢复、response / message 包装剥离、tools 通道原样透传给 tool-call 检测器。配套新增 utils/token_sequence_matcher.py(KMP 前缀函数)并改造 constrained/reasoner_grammar_backend.py:支持预算耗尽时逐步行走完整多 token think-end 序列、回滚后 marker 存活;schedule_batch.py::update_reasoning_tokens 改为等待完整 end 序列出现后才计入 reasoning token。

  5. OpenAI 服务层(修改 serving_chat.py,+272/-42)_prepare_kimi_k3_messages 统一做消息预处理——文本与结构化字段(含 reasoning_content、tool_calls arguments)递归中和 <|kimi_image_placeholder|>、developer 消息改写为 system、system / developer 消息级 tools 透传;_effective_tools 汇总请求级与消息级工具,使校验、约束构建与流式工具处理口径一致;image_prompts 仅在确有图片时设置,避免 [] 触发上游渲染器 scan-and-consume 模式;normalize_assistant_tool_call_arguments 增加 strict 参数,K3 渲染路径允许畸形历史参数原样通过;结构约束构建失败时不强制 JSON-only schema,保留 K3 原生 stop 格式(TOOLS_CLOSE,即 #33192)。serving_responses.py 同步传递 reasoning 状态与 chat encoder 字段,protocol.py 增加 _has_message_level_tools 逻辑。

  6. 测试与验证:新增 test_kimik3_structural_tag.py(810 行,用 xgrammar 的 accept-string 校验 grammar 接受 / 拒绝)、test_kimik3_detector.py(221 行,覆盖单 / 多 call、未闭合 tools、流式分块 1 / 7 / 23)、test_kimik3_reasoning_parser.py(162 行,覆盖缺 sep 恢复、部分 marker 剥离、流式 split、tools 透传);扩展 serving_chat / serving_responses / protocol / reasoner_grammar_backend 测试。PR body 报告 200 + 35 + 436 + 73 用例全绿;message-level tools 校验通过 default / dsv4 / dsv32 / kimi_k3 四种编码的 subtest 覆盖;另含 Python 3.10 兼容修复(f12f3ac)。

文件 模块 状态 重要度
python/sglang/srt/function_call/kimik3_structural_tag.py 结构约束 added 9.08
python/sglang/srt/function_call/kimik3_detector.py 工具解析 added 8.87
python/sglang/srt/entrypoints/openai/serving_chat.py 服务层 modified 8.65
python/sglang/srt/parser/reasoning_parser.py 推理解析 modified 8.58
python/sglang/srt/constrained/reasoner_grammar_backend.py 语法约束 modified 7.19
python/sglang/srt/function_call/kimik3_format.py 格式常量 added 7.88
python/sglang/srt/utils/token_sequence_matcher.py 匹配工具 added 8.07
test/registered/unit/function_call/test_kimik3_structural_tag.py 约束测试 added 7.48
test/registered/unit/entrypoints/openai/test_serving_chat.py 服务层测试 modified 7.24

关键符号

KimiK3Detector(function_call).parse_streaming_increment KimiK3Detector(function_call).detect_and_parse KimiK3Detector(function_call)._decode_call get_kimik3_structural_tag get_kimik3_auto_tool_call_structural_tag _schema_types _restrict_schema_type _argument_value_variants _known_argument_format partial_suffix_len strip_partial_marker_suffix strip_response_wrappers KimiK3Detector(reasoning_parser).parse_streaming_increment KimiK3Detector(reasoning_parser)._drain_content TokenSequenceMatcher.advance _prepare_kimi_k3_messages _effective_tools neutralize_kimi_k3_image_placeholder_value normalize_assistant_tool_call_arguments update_reasoning_tokens

关键源码片段

python/sglang/srt/function_call/kimik3_structural_tag.py core-logic

本 PR 核心新增:把 OpenAI Tool 的 JSON Schema 翻译成 Kimi K3 原生 XTML 的 xgrammar StructuralTag,支撑 auto / required / named 三种 tool choice、per-tool 严格约束与 parallel_tool_calls。

def _argument_value_variants(
    schema: Union[bool, Dict[str, Any]],
    root_schema: Dict[str, Any],
    loose_strings: bool = False,
) -> List[Tuple[str, Format]]:
    # 对 schema 允许的每个 JSON 类型生成一个“类型标签 + 取值格式”变体:
    # K3 的 argument 头里 type 属性是显式的,因此不同 JSON 类型必须拆成
    # 互斥分支,最终由调用方包成 OrFormat。
    return [
        (
            json_type,
            _value_format(restricted, json_type, loose_string=loose_strings),
        )
        for json_type in _schema_types(schema, root_schema)
        if (restricted := _restrict_schema_type(schema, json_type, root_schema))
        is not False
    ]
​
​
def _known_argument_format(
    key: str,
    schema: Union[bool, Dict[str, Any]],
    root_schema: Dict[str, Any],
) -> Optional[Format]:
    # 已知参数:为每个 JSON 类型构造一个 TagFormat。begin 直接是 XTML 的
    # <|open|>argument key="..." type="..."<|sep|>,内容格式取决于类型:
    # string 用 qwen_xml 风格(原生 XTML 文本),其余类型用 JSON 风格。
    escaped_key = _escape_attr(key)
    variants = [
        TagFormat(
            begin=(
                f'<|open|>argument key="{escaped_key}" '
                f'type="{_JSON_TO_XTML_TYPE[json_type]}"<|sep|>'
            ),
            content=value_format,
            end=ARGUMENT_CLOSE,
        )
        for json_type, value_format in _argument_value_variants(schema, root_schema)
    ]
    if not variants:
        return None
    if len(variants) == 1:
        return variants[0]
    return OrFormat(elements=variants)
python/sglang/srt/function_call/kimik3_detector.py core-logic

Kimi K3 XTML 工具调用检测与流式解析,承担 tools 通道提取、属性反转义与非 string 参数 JSON 解码,是 serving 层 tool-call 解析的落点。

def parse_streaming_increment(self, new_text: str, tools: List[Tool]) -> StreamingParseResult:
    # 流式解析入口:先把新 chunk 追加进内部 buffer,再尝试从中提取完整
    # tool call。XTML 的 marker 是多 token 特殊序列,可能跨 chunk 截断,
    # 因此未闭合部分必须留在 buffer 里继续累积。
    self._buffer += new_text
    try:
        open_idx = self._buffer.find(self.bot_token)
        if open_idx == -1:
            # 尚未出现 tools 通道开头,按普通文本输出;_emit_normal_text
            # 内部用 partial_suffix_len 做 holdback,避免把 marker 前缀
            # 拦腰吐给客户端。
            return StreamingParseResult(normal_text=self._emit_normal_text())
​
        normal_text = self._emit_normal_text(limit=open_idx)
        section = self._buffer[open_idx + len(self.bot_token) :]
        calls = []
        parsed = self._parse_calls(section)
        for call in parsed[self.current_tool_id + 1 :]:
            self.current_tool_id += 1
            while len(self.prev_tool_call_arr) <= self.current_tool_id:
                self.prev_tool_call_arr.append({})
            while len(self.streamed_args_for_tool) <= self.current_tool_id:
                self.streamed_args_for_tool.append('')
            self.prev_tool_call_arr[self.current_tool_id] = {
                'name': call['name'],
                'arguments': json.loads(call['arguments']),
            }
            self.streamed_args_for_tool[self.current_tool_id] = call['arguments']
            calls.append(
                ToolCallItem(
                    tool_index=self.current_tool_id,
                    name=call['name'],
                    parameters=call['arguments'],
                )
            )
        return StreamingParseResult(normal_text=normal_text, calls=calls)
    except Exception as e:
        logger.error(
            'Error in Kimi K3 parse_streaming_increment: %s', e, exc_info=True
        )
        # _sent_normal_idx 索引指向 _buffer,因此必须与 buffer 一起重置;
        # 否则后续每次 _emit_normal_text 都会因 limit <= _sent_normal_idx
        # 而静默丢弃响应剩余部分。
        self._buffer = ''
        self._sent_normal_idx = 0
        return StreamingParseResult()
python/sglang/srt/entrypoints/openai/serving_chat.py core-logic

OpenAI Chat 服务层集成:消息预处理占位符中和、message-level tools 合并、image_prompts 守卫、约束失败降级与 wire 字段透传,是全模型核心入口。

def neutralize_kimi_k3_image_placeholder_value(value: Any) -> Any:
    # 递归中和字符串、列表、字典中的字面占位符。上游 encoding_k3.py 渲染器
    # 会对文本执行占位符扫描(scan-and-consume),用户消息里出现
    # "<|kimi_image_placeholder|>" 会被误当成图片槽位(issue #32960);
    # 替换成带空格的变体后,渲染器把它当作惰性纯文本处理。
    if isinstance(value, str):
        return neutralize_kimi_k3_image_placeholder(value)
    if isinstance(value, list):
        return [neutralize_kimi_k3_image_placeholder_value(item) for item in value]
    if isinstance(value, dict):
        return {
            key: neutralize_kimi_k3_image_placeholder_value(item)
            for key, item in value.items()
        }
    return value
​
​
def _effective_tools(self, request: ChatCompletionRequest) -> List[Tool]:
    # 汇总请求级 tools 与 system / developer 消息上的级联 tools,让消息级
    # 工具参与校验、约束构建与流式工具调用处理;这改变了默认编码的校验
    # 口径,测试覆盖 default / dsv4 / dsv32 / kimi_k3 四种编码。
    tools = list(request.tools or [])
    for message in request.messages:
        if (
            isinstance(message, ChatCompletionMessageGenericParam)
            and message.role in ('system', 'developer')
            and message.tools
        ):
            tools.extend(message.tools)
    return tools

评论区精华

image_prompts=[] 触发上游渲染器 scan-and-consume 导致 400(issue #32960) 正确性

tancheng33 定位:serving_chat.py 无条件执行 template_kwargs['image_prompts'] = ['<|media_pad|>'] * image_count,当 image_count == 0 时传 [],使上游 encoding_k3.py 进入 scan-and-consume 模式并立即抛 ValueError;而 image_prompts=None 会把占位符渲染为惰性纯文本。

结论:hnyls2002 确认 #33003 的修复已落地;tancheng33 验证 #33183 以 if image_count: 守卫 + 丢弃过期用户值方式闭合全部路径。 · 已解决

占位符中和的残留路径:assistant tool_calls 参数与 reasoning_content 正确性

tancheng33 指出 encoding_k3.py 的 _append_text 对工具调用参数(json_block 与 _xtml_value 分支)和 reasoning_content 也做占位符扫描,这两处未经过 neutralize_kimi_k3_image_placeholder,text-only agentic 会话一旦出现引用字面占位符的历史轮次,后续每轮请求都会 400。

结论:提交 5f4f76c 先尝试修复后被 revert,最终经 #33183 以“渲染前统一中和 reasoning / 嵌套工具参数”方式落地。 · 已解决

约束失败时的降级策略(#33192) 设计

KimiK3Detector.parses_required_natively() 返回 False,但最终决策是让模型原生 parser 处理 required tool choice;结构约束构建失败时保持 K3 原生格式不受限,而非强制 JSON-only schema——因为 K3 的解析器无法读回 JSON-only 输出。

结论:以提交 af3e2d6(#33192)并入,配套测试 test_kimi_k3_constraint_failure_keeps_native_stop_format 覆盖。 · 已解决

流式解析错误路径的 buffer 与游标重置契约 正确性

提交 d13b4320 “reset streamed-text cursor with the buffer” 修复了 _sent_normal_idx 与 buffer 不同步导致后续输出被静默丢弃的问题;代码注释明确该契约。

结论:以提交与注释形式固化,流式分块测试 test_streaming_split_markers 覆盖 1 / 7 / 23 字节分块。 · 已解决

strict-thinking 下 think_excluded_tokens 的选词约束 设计

提交 92140a9 “explain k3 think-excluded token choice”:strict-thinking 会把 marker 压平成单 token id,完整 "<|open|>response<|sep|>" 无法表达,因此选 response / message 裸词作为排除 token,代价是推理中途无法以非空格方式开始这些词;tools 故意不排除以便 think 通道直接跳转。

结论:以注释形式固化在 KimiK3Detector(reasoning_parser) 的实现中,测试 test_kimi_k3_excluded_tokens_spare_the_xtml_control_tokens 覆盖。 · 已解决

风险与影响

  • 全模型 serving 入口变更serving_chat.py 是 /v1/chat/completions 核心路径,normalize_assistant_tool_call_arguments 新增 strict 参数、_effective_tools 把消息级工具并入校验与流式处理,默认编码路径的校验口径变化会影响所有模型的服务行为(测试覆盖了 4 种编码,但真实模型组合仍有暴露差异的可能)。
  • 语法后端跨模型影响reasoner_grammar_backend.py 的多 token marker 行走与回滚改造服务于所有 reasoner grammar 模型,不只是 Kimi K3;配套测试覆盖了 multi-token end marker 用例,但推理预算耗尽、自重叠 marker 等极端路径仍需生产验证。
  • 流式解析状态机风险KimiK3Detector.parse_streaming_increment 的异常路径重置 buffer 与游标,语义是“丢弃未吐出的内容”,调用方必须依赖该契约;正则 _CALL_RE / _ARG_RE 对参数正文中出现 <|close|>call<|sep|> 原始序列的场景没有显式防护。
  • 复杂 JSON Schema 翻译边界_schema_types / _restrict_schema_type 对 allOf / anyOf / $ref 组合使用集合运算收窄(含 number / integer 互通的特殊处理),逻辑自洽但极端嵌套 schema 仍可能有类型枚举与关键字启发不一致的边界。
  • reasoning token 计数口径schedule_batch.py::update_reasoning_tokens 改为等待完整多 token think-end 序列后计数,影响 usage 统计口径,需与前端计费逻辑核对。
  • 用户 / 模型侧:Kimi K3 用户可开箱使用推理内容(reasoning_content)与原生工具调用,支持 parallel_tool_calls、per-tool 严格 schema、SGLANG_TOOL_STRICT_LEVEL、消息级工具与 named tool choice;字面 <|kimi_image_placeholder|> 不再被误判为图像输入(#32960)。
  • 系统侧:新增 4 个模块(kimik3_format / kimik3_structural_tag / kimik3_detector / token_sequence_matcher),改动 5 个高复用入口(serving_chat、serving_responses、reasoning_parser、reasoner_grammar_backend、schedule_batch);ReasoningParser.DetectorMap 与 chat_encoding_spec 的自动检测机制降低了后续模型接入成本。
  • 团队侧:形成“wire 常量 + schema 翻译 + 检测器 + 推理解析 + serving 接线”的完整接入范式,后续新专用 token 格式模型可复制该结构。
核心服务路径变更 跨模型语法后端影响 流式解析状态机风险 正则解析边界 复杂 schema 翻译边界 reasoning token 计数口径变化

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论