Prhub

#23950 fix(function_call): handle Kimi-K2.5 bare numeric tool call IDs

原始 PR 作者 JustinTong0323 合并时间 2026-05-08 05:20 文件变更 2 提交数 1 评论 14 代码增减 +244 / -22

执行摘要

处理 Kimi-K2.5 裸数字调用 ID,推断函数名

根据PR body,Kimi-K2.5模型有时会发出裸数字计数作为调用ID(例如3代替functions.ReadFile:0),导致解析器拒绝。需要检测这种格式并从工具声明中推断函数名。

建议合并(PR 已合并)。该 PR 解决了 Kimi-K2.5 工具调用中的关键兼容问题,且测试充分。
值得注意的设计:利用参数模式匹配推断缺失的函数名是一种轻量级解决方法,但在参数重叠时可能不够精确;可考虑在后续迭代中加入更稳健的神经网络或逻辑回归。
后续关注:code review 中提到的三个问题虽未解决,但影响可能有限,建议在真实负载运行后评估是否需进一步修复。

讨论亮点

在 gemini-code-assist[bot] 的 code review 中,提出了三个关键问题:

  • 流式贪婪匹配(高优先级):stream_tool_call_portion_regex 使用贪婪匹配 {.*,可能包含后续工具调用的内容,导致 JSON 解析失败,建议拆分后再传递给 _infer_tool_name
  • tool_index 不一致(高优先级):detect_and_parse 使用 ID 中解析的 index,而 parse_streaming_increment 使用内部自增计数器 self.current_tool_id,对于裸数字 ID 两者不一致。
  • 空参数键误匹配(中优先级):当模型只输出 {} 或空参数时,arg_keys 为空集,第一个有 properties 的工具会被选中,可能导致错误推断。

这些评论未在 PR 内获得作者回应或明确修改,但 PR 已被合并,可能问题已被后续其他提交解决或作者认为可接受。

实现拆解

  1. 修改正则表达式:在 tool_call_regexstream_tool_call_portion_regex 中,将匹配 [\w.\-]+:\d+ 改为 [^\s<|]+,允许捕获任意非空白字符的ID(包括纯数字)。
  2. 新增 bare counter 正则:tool_call_id_counter_regex = r"^\d+$" 用于匹配纯数字ID。
  3. 添加 _parse_tool_call_id 方法:尝试匹配标准ID(如 functions.ReadFile:0);若失败则检查是否纯数字,若是则解析为 call_index 并调用 _infer_tool_name 推断函数名;否则返回 (None, 0)
  4. 实现 _infer_tool_name 方法:当只有一个工具时直接返回;当有多个工具时,解析传入的函数参数字符串为JSON,提取参数键集合,与每个工具的参数 properties 键集合对比,选择交集最大的工具返回;若无法解析JSON或没有匹配,返回 None。
  5. 流式解析增强:在 parse_streaming_increment 中,新增 _current_stream_function_name 变量缓存最近一次推断的函数名,避免在每次增量时重复解析不完整的JSON。当遇到 tool_call_end_token 时,才将缓存的函数名用于生成最终的 ToolCallItem
  6. 测试覆盖:新增 TestKimiK2BareCounterParsing 测试类,包含标准格式、bare counter 单工具(直接返回唯一工具名)、bare counter 通过参数键推断(在两个工具中选择匹配的)、无工具列表返回 None、无参数时返回 None、非法格式等场景。同时测试 _infer_tool_name 的各种边界条件。
文件 模块 状态 重要度
python/sglang/srt/function_call/kimik2_detector.py 函数调用 modified 7.96
test/registered/function_call/test_kimik2_detector.py 单元测试 modified 7.33

关键符号

_parse_tool_call_id _infer_tool_name

关键源码片段

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

核心变更文件,新增 bare counter 检测、工具名推断逻辑,重构正则表达式以支持更广泛的 ID 格式。

def _parse_tool_call_id(
    self, function_id: str, tools: List[Tool], function_args: str = None
):
    """Parse a tool call ID into (function_name, call_index).    Standard format: "functions.ReadFile:0" → ("ReadFile", 0)
    Bare counter:    "3" → call_index=3, infer name from arguments.
    """
    # Try standard form: "functions.ReadFile:0" or "ReadFile:0"
    m = self.tool_call_id_regex.match(function_id)
    if m:
        return m.group("name"), int(m.group("index"))
​
    # Check if it's a bare counter (e.g., "3")
    if self.tool_call_id_counter_regex.match(function_id):
        call_index = int(function_id)
        # Try to infer function name from argument keys
        name = self._infer_tool_name(tools, function_args)
        if name:
            return name, call_index
        # If inference fails, return None for name but keep index
        return None, call_index
​
    # Not a recognized format
    logger.warning("Unexpected tool_call_id format: %s", function_id)
    return None, 0
​
​
def _infer_tool_name(self, tools: List[Tool], function_args: str = None):
    """Infer function name when the model omits it (bare counter ID).
    Matches argument keys against tool parameter schemas, preferring the
    tool whose declared properties best match the actual arguments.
    """
    if not tools:
        return None
    # Single tool shortcut
    if len(tools) == 1:
        return tools[0].function.name
​
    # If no arguments provided, can't infer
    if not function_args:
        logger.debug("No function_args, cannot infer tool name")
        return None
​
    try:
        arg_keys = set(json.loads(function_args).keys())
    except (json.JSONDecodeError, TypeError):
        logger.debug("Could not parse function_args for tool name inference")
        return None
​
    # Select tool with highest overlap between argument keys and declared properties
    best_name = None
    best_score = -1
    for tool in tools:
        props = (tool.function.parameters or {}).get("properties", {})
        score = len(arg_keys & set(props.keys()))
        if score > best_score:
            best_score = score
            best_name = tool.function.name
    return best_name
test/registered/function_call/test_kimik2_detector.py test-coverage

新增完整的 `TestKimiK2BareCounterParsing` 测试类,覆盖了 bare counter 的各种场景,包括单工具、多工具推断、无工具、无参数、非法格式等,同时测试了 `_infer_tool_name` 的边界条件。

class TestKimiK2BareCounterParsing(unittest.TestCase):
    """Tests for bare numeric tool_call_id format (e.g., '3' instead of 'functions.ReadFile:0')."""
​
    def setUp(self):
        self.detector = KimiK2FuncDetector()
        # Set up two tools: ReadFile (default) and get_weather with city/unit
        self.tools = [
            _make_tool("ReadFile"),
            _make_tool(
                "get_weather",
                {
                    "type": "object",
                    "properties": {
                        "city": {"type": "string"},
                        "unit": {"type": "string"},
                    },
                    "required": ["city"],
                },
            ),
        ]
​
    def test_bare_counter_infers_by_args(self):
        # 'city' matches get_weather, not ReadFile (since ReadFile has no custom properties)
        name, idx = self.detector._parse_tool_call_id(
            "0", self.tools, '{"city": "Tokyo"}'
        )
        self.assertEqual(name, "get_weather")
        self.assertEqual(idx, 0)
​
    def test_bare_counter_single_tool(self):
        single_tool = [_make_tool("search")]
        name, idx = self.detector._parse_tool_call_id(
            "3", single_tool, '{"query": "test"}'
        )
        self.assertEqual(name, "search")
        self.assertEqual(idx, 3)
​
    def test_bare_counter_no_tools_returns_none(self):
        name, idx = self.detector._parse_tool_call_id("5", [], '{"x": 1}')
        self.assertIsNone(name)
        self.assertEqual(idx, 5)

评论区精华

流式解析中贪婪匹配导致推断失败 性能

gemini-code-assist[bot] 指出 `parse_streaming_increment` 中 `function_args` 被 `stream_tool_call_portion_regex` 使用贪婪匹配 `{.*` 捕获,可能包含后续工具调用的内容,导致 JSON 解析失败,从而无法推断函数名。建议先按 `tool_call_end_token` 分割,再传给 `_parse_tool_call_id`。

结论:未在 PR 中得到明确修复或回应,但 PR 已合并,可能作者认为该问题在缓存机制下风险降低。 · 未解决

detect_and_parse 与 parse_streaming_increment 的 tool_index 不一致 正确性

gemini-code-assist[bot] 观察到 `detect_and_parse` 使用从 ID 中解析的 `function_idx` 作为 `tool_index`,而 `parse_streaming_increment` 使用内部自增计数器 `self.current_tool_id`。对于 bare counter,其数值是对话级别的自增(如 3),而非工具索引,导致两种模式下的 `tool_index` 含义不同,可能引起下游工具执行混乱。建议统一为使用 `call_index` 或 `self.current_tool_id`。

结论:未在 PR 中讨论或修正。PR 合并后两种模式的 index 仍可能不一致。 · 未解决

空参数键集导致工具名推断错误 正确性

gemini-code-assist[bot] 指出 `_infer_tool_name` 中,当参数键集为空(如模型输出 `{}` 或 `{` 时),`arg_keys` 为空集,导致 `score` 为 0,而第一个有 properties 的工具会被选中作为最佳匹配,可能错误推断函数名。建议在 `arg_keys` 为空时返回 `None`。

结论:当前代码中,如果 `arg_keys` 为空,循环后 `best_score` 仍是 -1,最终返回 `None`?但仔细看逻辑:初始化 `best_score = -1`,循环中如果 `score` 为 0,由于 `0 > -1`,会更新 `best_score` 和 `best_name`。因此第一个工具会被选中。这确实可能导致错误。问题未被回应或修复。 · 未解决

风险与影响

  1. 正则过宽[^\s<|]+ 会匹配任何非空白字符串,可能将未来模型或边缘情况下的错误ID也纳入解析,导致误判。
  2. 推断误匹配_infer_tool_name 基于参数键的简单匹配,当多个工具共用相似参数时可能选错;且当参数稀疏或部分JSON时推断不可靠。
  3. 流式贪婪问题stream_tool_call_portion_regex{.* 未隔离工具调用边界,多工具同时出现时可能将后续参数混入当前调用,导致推断失败。
  4. tool_index 不一致:流式与一次性解析对 tool_index 的赋值逻辑不同,可能引起下游工具执行结果与预期不符。
  5. 性能影响:每次 _parse_tool_call_id 调用都可能执行 JSON 解析和键匹配,在高频流式场景下可能引入微小延迟。

用户影响:使用 Kimi-K2.5 工具调用的用户将获得更高的解析成功率(从 60% 提升到 100%),无需手动处理裸数字 ID 错误。
系统影响:改动集中在 kimik2_detector.py,仅影响 KimiK2 Detector,其他模型检测器不受影响。
团队影响:需要更新代码并重新验证工具调用功能;建议关注 code review 中提出的未解决问题。

正则可能过宽 推断误匹配可能性 流式贪婪匹配问题 tool_index 不一致

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论