# PR #23950 完整报告

- 仓库：`sgl-project/sglang`
- 标题：fix(function_call): handle Kimi-K2.5 bare numeric tool call IDs
- 合并时间：2026-05-08 05:20
- 原文链接：http://prhub.com.cn/sgl-project/sglang/pull/23950

---

# 执行摘要

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

# 功能与动机

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

# 实现拆解

1. 修改正则表达式：在 `tool_call_regex` 和 `stream_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`（模块 函数调用；类别 source；类型 core-logic；符号 _parse_tool_call_id, _infer_tool_name）: 核心变更文件，新增 bare counter 检测、工具名推断逻辑，重构正则表达式以支持更广泛的 ID 格式。
- `test/registered/function_call/test_kimik2_detector.py`（模块 单元测试；类别 test；类型 test-coverage；符号 TestKimiK2BareCounterParsing, setUp, test_standard_format_with_functions_prefix, test_standard_format_without_functions_prefix）: 新增完整的 `TestKimiK2BareCounterParsing` 测试类，覆盖了 bare counter 的各种场景，包括单工具、多工具推断、无工具、无参数、非法格式等，同时测试了 `_infer_tool_name` 的边界条件。

关键符号：_parse_tool_call_id, _infer_tool_name

## 关键源码片段

### `python/sglang/srt/function_call/kimik2_detector.py`

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

```python
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`

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

```python
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] 的 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 已被合并，可能问题已被后续其他提交解决或作者认为可接受。

- 流式解析中贪婪匹配导致推断失败 (performance): 未在 PR 中得到明确修复或回应，但 PR 已合并，可能作者认为该问题在缓存机制下风险降低。
- detect_and_parse 与 parse_streaming_increment 的 tool_index 不一致 (correctness): 未在 PR 中讨论或修正。PR 合并后两种模式的 index 仍可能不一致。
- 空参数键集导致工具名推断错误 (correctness): 当前代码中，如果 `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 不一致

# 关联脉络

- PR #22254 未知（未在历史中提供）: 本 PR 是原 PR #22254 中分离出的独立部分，专注于 bare counter 修复。