执行摘要
- 一句话:处理Kimi-K2.5裸数字调用ID,推断函数名
- 推荐动作:建议合并(PR 已合并)。该 PR 解决了 Kimi-K2.5 工具调用中的关键兼容问题,且测试充分。
值得注意的设计:利用参数模式匹配推断缺失的函数名是一种轻量级解决方法,但在参数重叠时可能不够精确;可考虑在后续迭代中加入更稳健的神经网络或逻辑回归。
后续关注:code review 中提到的三个问题虽未解决,但影响可能有限,建议在真实负载运行后评估是否需进一步修复。
功能与动机
根据PR body,Kimi-K2.5模型有时会发出裸数字计数作为调用ID(例如3代替functions.ReadFile:0),导致解析器拒绝。需要检测这种格式并从工具声明中推断函数名。
实现拆解
- 修改正则表达式:在
tool_call_regex 和 stream_tool_call_portion_regex 中,将匹配 [\w.\-]+:\d+ 改为 [^\s<|]+,允许捕获任意非空白字符的ID(包括纯数字)。
- 新增 bare counter 正则:
tool_call_id_counter_regex = r"^\d+$" 用于匹配纯数字ID。
- 添加
_parse_tool_call_id 方法:尝试匹配标准ID(如 functions.ReadFile:0);若失败则检查是否纯数字,若是则解析为 call_index 并调用 _infer_tool_name 推断函数名;否则返回 (None, 0)。
- 实现
_infer_tool_name 方法:当只有一个工具时直接返回;当有多个工具时,解析传入的函数参数字符串为JSON,提取参数键集合,与每个工具的参数 properties 键集合对比,选择交集最大的工具返回;若无法解析JSON或没有匹配,返回 None。
- 流式解析增强:在
parse_streaming_increment 中,新增 _current_stream_function_name 变量缓存最近一次推断的函数名,避免在每次增量时重复解析不完整的JSON。当遇到 tool_call_end_token 时,才将缓存的函数名用于生成最终的 ToolCallItem。
- 测试覆盖:新增
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 格式。
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 的边界条件。
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。因此第一个工具会被选中。这确实可能导致错误。问题未被回应或修复。
风险与影响
关联脉络
- PR #22254 未知(未在历史中提供): 本 PR 是原 PR #22254 中分离出的独立部分,专注于 bare counter 修复。
参与讨论