Prhub

#39136 [Fix] Preserve GLM tool argument types across JSON Schema unions

原始 PR 作者 willhu-jpg 合并时间 2026-09-13 13:47 文件变更 2 提交数 2 评论 3 代码增减 +619 / -155

执行摘要

修复 GLM 工具参数类型在 Schema 联合中被过早定型

PR body 明确指出:GLM-4.7/GLM-5 的工具调用解析器「在完整参数值可用之前就确定了一个推断出的 JSON Schema 类型」,于是同一段生成在完整响应与流式响应下产出不同甚至错误的参数;典型例子是声明为 {"type": ["string", "null"]} 的字段,模型输出的 null 被序列化成字符串 "None" 或 "null" 而不是 JSON null。同一早定型的取舍还波及本地引用、enum,以及判别字段出现在值之后的根 anyOf/oneOf;流式场景下数字前缀可能在解析器尚不知道完整值是否为合法 JSON 时就被发出。作者说明本改动建立在已合并的 #36626(在顶层 schema 组合子内发现属性)之上,但避免「选中第一个重复属性类型」;并指出 #35564 是流式/非流式一致性的跟踪 issue,#35625 是统一修复、#36688 是较窄的 GLM 版本,三者覆盖了 schema-less 缓冲与非法数字 token,却不覆盖本 PR 处理的 nullable 强制转换、本地引用、enum/const 收窄与同级判别的根 union。

值得精读,尤其是负责 function calling / 结构化输出与流式一致性的同学。建议重点关注三处设计决策:(a) _argument_types 用「约束集求交 + union 求并」替代单向推断,以及 const/enum 只在与已完成取值相容时参与收窄的条件;(b) _needs_argument_context 用「是否需要缓冲」把延迟定型的影响限制在真正含 union 的工具上,而非全局缓冲;(c) _coerce_numeric_string 刻意保留 number/integer 的非对称("1.5" 对 integer 保持字符串)。阅读顺序建议先看 glm47_moe_detector.py 的类型解析函数,再看 test_glm47_schema_types.py 的 check_arguments 断言方式(值 + 原生类型双重校验、多分块粒度)。后续行动建议:推动 glm4_moe_detector/ling3_detector 同步修复,并在 #35625 合并前确认两者的方案不会冲突。

讨论亮点

本 PR 无行内 review 评论,讨论集中在 Issue 线程中的一次「维护者代改」:JustinTong0323 以 APPROVED 并在评论里说明 GitHub 不允许维护者向 org 拥有的 fork 推送,因此把修复提交推到上游分支 xinyuan/glm47-39136-review-fixes(25c26ddd5f)请作者 cherry-pick。该提交做了四件事:删除 ("auto", "number", "integer") 守卫之后已经不可达的两个 number 分支;修正 integer 强制转换——带引号的 "1.5" 落到 {"type": "integer"} 字段时不再变成 float 1.5 而保持字符串,并新增用例固定这种 number/integer 的非对称;清理 test_field_types 与 test_native_strings_reject_disallowed_json_types_and_enum_values 之间重复的用例(test_root_unions_preserve_each_value_type 的 anyOf/oneOf 循环已被 test_completed_siblings_disambiguate_root_union 完全覆盖,只保留根 allOf 用例)并删掉复述性注释;本地跑过 11 个 schema-types 测试与既有 TestGlm47MoeDetector,ruff 干净。作者回复「done!」后维护者执行 /tag-and-rerun-ci。评论末尾还留了一条本 PR 范围外的提醒:glm4_moe_detector/ling3_detector 存在同类问题,留作后续。

实现拆解

  1. 类型解析层整体重写(glm47_moe_detector.py):删除基于 get_schema_properties + infer_type_from_json_schema 的旧 get_argument_type,改为新增 _json_type、_json_equal、_matches_discriminators、_argument_types、_get_argument_types、get_argument_type 一组函数。核心 _argument_types 先收集若干 constraints 再 set.intersection:type(字符串或数组,number 自动扩展出 integer)、const/enum(仅当允许值与已完成取值相容时才收窄)、anyOf/oneOf(先按同级判别参数过滤分支再取并集)、allOf(取交集)、本地 $ref(支持 #、#/... 与 ~0/~1 解码)。seen 以 id(schema) 记录,遇到环形 $ref 直接返回 None 终止递归;悬空 $ref 被 except (KeyError, IndexError, TypeError, ValueError) 吞掉并视为无约束。get_argument_type 只在类型唯一时返回类型名,{"number", "integer"} 归一为 number,多类型一律返回 None,从接口层面放弃「提前猜一个类型」。

  2. 新增缓冲决策入口 _needs_argument_context:它用内部 visit 递归遍历 schema 图(跟随 $ref、进入 anyOf/oneOf/allOf),收集所有 properties 键并标记是否存在真正的 union(allOf 不算),对存在 union 的工具逐 key 调 _argument_types 判断类型是否不唯一;返回 True 时流式侧需要对参数做缓冲,等值完整后再定型。材料中没有展示该函数的具体调用点,按 PR 描述它是「缓冲 ambiguous 值与数值前缀」的判定依据。

  3. 流式侧延迟定型:_get_value_type 不再在 self._current_value 仍为空时做自动推断(旧实现因此恒返回 string,把所有未声明 schema 的数字都加了引号),改为缓存声明的类型、把无法安全判断的取值推迟到 </arg_value>;普通 string 字段保持增量输出,由 test_plain_strings_still_stream_before_value_completes 断言首个分块就带出 "hello。同时用「已完成参数 + 同级判别字段」来选择根 union 分支,使 kind 与 value 无论谁先到达都能定对类型。

  4. 数值转换语义收敛:新增 _convert_to_integer 与 _coerce_numeric_string,parse_arguments 的 Strategy 1/2 统一改为 _coerce_numeric_string(json.loads(...), arg_type),把「只有字符串才参与强制转换」和「integer 不接受 1.5 这类小数」两条规则集中到同一处;维护者追加的提交正是落在这里。

  5. 测试与 CI 配套:新增 test/registered/unit/function_call/test_glm47_schema_types.py,register_cpu_ci(est_time=2, suite="base-a-test-cpu") 注册进 CPU CI。check_arguments 对每个用例跑 size=0/1/7/len(text) 四种分块(0 表示非流式基线),既比对解析出的 JSON 值,也用 assertIs(type(actual[key]), type(expected[key])) 卡住原生类型,覆盖 nullable、$defs/definitions 引用与环、根 allOf、enum 反例、123abc 这类非法数字、判别字段顺序调换与解析状态在多次 tool call 间的重置。无文档、kernel 或部署侧改动。

文件 模块 状态 重要度
python/sglang/srt/function_call/glm47_moe_detector.py 工具解析器 modified 8.75
test/registered/unit/function_call/test_glm47_schema_types.py 单元测试 added 7.83

关键符号

_argument_types _matches_discriminators _get_argument_types get_argument_type _needs_argument_context _json_type _json_equal _coerce_numeric_string _convert_to_integer parse_arguments check_arguments

关键源码片段

test/registered/unit/function_call/test_glm47_schema_types.py test-coverage

新增的 CPU 单元测试,用 `check_arguments` 在 1 字符 / 7 字符 / 整段三种分块下同时校验解析值与原生 Python 类型,是这次类型收窄与流式缓冲行为的回归护栏。

class TestGlm47SchemaTypes(unittest.TestCase):
    def check_arguments(self, schema, expected, raw_values=None):
        # 同一份 schema / 期望值,分别走非流式与 1 字符、7 字符、整段三种分块流式
        tools = [
            Tool(type="function", function=Function(name="inspect", parameters=schema))
        ]
        pairs = []
        for key, value in expected.items():
            # raw_values 允许用例单独指定模型侧原始文本(例如 "null" 还是 '"null"')
            raw = (raw_values or {}).get(
                key, value if isinstance(value, str) else json.dumps(value)
            )
            pairs.append(f"<arg_key>{key}</arg_key><arg_value>{raw}</arg_value>")
        text = "<tool_call>inspect" + "".join(pairs) + "</tool_call>"
        for size in (0, 1, 7, len(text)):
            with self.subTest(schema=schema, expected=expected, chunk_size=size):
                detector = Glm47MoeDetector()
                if size == 0:
                    # size == 0 走 detect_and_parse,作为非流式基线
                    calls = detector.detect_and_parse(text, tools).calls
                else:
                    calls = []
                    for offset in range(0, len(text), size):
                        calls.extend(
                            detector.parse_streaming_increment(
                                text[offset : offset + size], tools
                            ).calls
                        )
                # 所有分块的参数片段拼接后必须是一段合法且等值的 JSON
                actual = json.loads("".join(call.parameters for call in calls))
                self.assertEqual(actual, expected)
                # 值相等还不够:null 不能被序列化成 "None"/"null",必须卡住原生类型
                for key in expected:
                    self.assertIs(type(actual[key]), type(expected[key]))
​
    def test_numeric_prefix_does_not_commit_to_invalid_json(self):
        # 数值型参数在值闭合前必须缓冲:"123" 不能先于 "abc" 发出,
        # 否则客户端会先看到 123 再看到非法 JSON
        tools = [
            Tool(
                type="function",
                function=Function(
                    name="inspect",
                    parameters={"properties": {"value": {"type": "number"}}},
                ),
            )
        ]
        detector = Glm47MoeDetector()
        prefix = detector.parse_streaming_increment(
            "<tool_call>inspect<arg_key>value</arg_key><arg_value>123", tools
        ).calls
        self.assertNotIn("123", "".join(call.parameters for call in prefix))
        suffix = detector.parse_streaming_increment(
            "abc</arg_value></tool_call>", tools
        ).calls
        self.assertEqual(
            json.loads("".join(call.parameters for call in prefix + suffix)),
            {"value": "123abc"},
        )

评论区精华

integer 字段的字符串强制转换非对称性 正确性

维护者 JustinTong0323 在上游分支 `xinyuan/glm47-39136-review-fixes`(`25c26ddd5f`)的补充提交中提出:带引号的 `"1.5"` 落到 `{"type": "integer"}` 字段时不应变成 float `1.5`,而 `number` 的强制转换行为应保持不变。实现落在新增的 `_convert_to_integer` 与 `_coerce_numeric_string`,并在测试中固定这种非对称。

结论:已采纳并合并;`number` 允许 `"1.5"` → `1.5`,`integer` 只接受可转成 int 的字符串,否则原样保留为字符串。 · 已解决

删除 `("auto", "number", "integer")` 守卫后的不可达分支 设计

维护者指出守卫已先行命中,后面两个 `number` 分支永远不会执行,属于死代码,随补充提交一并删除。

结论:已删除,逻辑等价但可读性提升。 · 已解决

测试用例重复与复述性注释清理 测试

维护者认为 `test_field_types` 与 `test_native_strings_reject_disallowed_json_types_and_enum_values` 之间存在重复用例,且 `test_root_unions_preserve_each_value_type` 的 anyOf/oneOf 循环已被 `test_completed_siblings_disambiguate_root_union` 完全覆盖,只保留根 `allOf` 用例,同时删掉复述代码的注释。

结论:已按建议裁剪,schema-types 的 11 个测试与既有 `TestGlm47MoeDetector` 测试本地通过。 · 已解决

glm4_moe_detector / ling3_detector 的同类缺陷留作后续 设计

维护者在评论末尾备注:「One note for follow-up (not this PR): `glm4_moe_detector`/`ling3_detector`…」,说明同一段「值未到就定型」的逻辑在其它检测器里仍然存在,本 PR 未同步(`glm`/`glm45` 正是 #36688 覆盖的对象)。

结论:明确为后续工作,本 PR 不做;导致 GLM 各解析器在同类 schema 下行为短期继续分叉。 · unresolved

维护者无法向 org 拥有的 fork 推送的协作流程 other

GitHub 不允许维护者向 org-owned fork 推送,因此 review 修复以独立提交 `25c26ddd5f` 推到上游分支 `xinyuan/glm47-39136-review-fixes`,请作者 `git fetch` + `git cherry-pick` 拉回。作者回复「done!」后维护者执行 `/tag-and-rerun-ci`。

结论:提交已被作者合入,PR 以 APPROVED 状态合并。 · 已解决

风险与影响

1) 同族解析器未同步导致行为分叉:改动只落在 glm47_moe_detector.py,而 glm4_moe_detector.py(glm/glm45)与 ling3_detector 携带几乎相同的早定型逻辑,维护者已在评论中确认留作后续;在修复落地前,同一份工具 schema 在不同 GLM 解析器下仍会给出不同的流式/非流式参数类型,客户端无法自行规避。
2) 流式粒度与时延的显式权衡:类型不确定与数值型参数被缓冲到 </arg_value> 才发出,首包时延变大、参数分片变粗;对大对象/长数字参数的增量体验下降,PR body 承认这是有意为之但未给出量化数据。
3) 递归 Schema 解析的 CPU 开销与边界:_argument_types 与 _needs_argument_context 都会遍历 schema 图,复杂嵌套工具定义(深层 $defs + 多层 anyOf)会带来额外开销;seen 按 id() 去重可防环,但 id 复用与同构 schema 的重复计算没有缓存,也未看到复杂度上限保护。
4) 接口语义变更的潜在外部影响:模块级 get_argument_type 由「尽量返回一个类型字符串」改为「多类型返回 None」,若本仓库其它检测器或下游代码导入该符号,行为会随之变化;材料中未展示其它调用点,无法确认影响面。
5) 测试与 CI 覆盖不完整:新测试只针对 Glm47MoeDetector,未覆盖 glm4_moe_detector;PR body 亦说明结构标签能力测试需要更新的本地 xgrammar,未覆盖本次改动;CI 状态显示 Extra 与 AMD ROCm 10 lane 为 ❌,需要确认这些失败是否与本改动无关。

用户侧:使用 GLM-4.7/GLM-5 做 tool calling 且 schema 含 nullable、enum/const、本地 $ref 或根 union 的场景,流式与非流式会返回一致且类型正确的参数(null 不再变成 "null",123abc 不再变成非法 JSON),减少客户端 json.loads 失败与业务侧类型错乱。系统侧:只影响 function call 解析路径,不触及模型执行、注意力或 kernel,单请求额外开销是 schema 遍历与参数缓冲,量级远小于推理本身;对使用纯 string 参数的工具几乎无感知。工程侧:确立了「类型推断推迟到取值完整、union 用判别字段收窄」的实现范式,是 #35625 统一修复的可复用参考;同时暴露 glm4_moe_detector/ling3_detector 尚未同步的技术债,跨解析器一致性在短期内仍不成立。

仅覆盖 glm47 解析器 流式缓冲改变首包时延 递归 Schema 解析开销 与 #35625/#36688 重叠

关联 Issue

#35625 [function_call] Make streaming tool-call parsing agree with non-streaming
#36688 [Fix] GLM detectors: streamed tool-call arguments disagree with non-streaming

完整报告

参与讨论