Prhub

#42904 [feature] [xgrammar] support `patternProperties`/`propertyNames`/`unevaluatedProperties` kw for object types

原始 PR 作者 cjackal 合并时间 2026-09-15 12:58 文件变更 3 提交数 11 评论 40 代码增减 +172 / -16

执行摘要

xgrammar 后端放行简单 patternProperties/propertyNames,并守卫会静默错编译的组合

PR body 明确目标是让 OpenAI 兼容端点在结构化输出引擎下覆盖更多 JSON Schema:「Support patternProperties and propertyNames keywords for object types in JSON schema for xgrammar structured output backend. Makes openai-compatible endpoint more feature-complete by covering more JSON schema under a structured output engine. This is supported since xgrammar>=0.2.1 by mlc-ai/xgrammar#594.」。base 版本对这两类关键字是一刀切拒绝,用户带 MCP 风格 schema 的请求只能拿到 400(显式 xgrammar)或回退到 outlines(auto)。review 过程中 arpera 与 vadiklyutiy 引用上游缺陷(xgrammar#749、#826、#839、#841)指出存在「检测通过但约束不生效」的过度放行,动机因此从「打开开关」演化为「只打开安全的开关」。

值得精读,尤其是 vllm/v1/structured_output/backend_xgrammar.py 中守卫逻辑的分支条件,和 tests/v1/structured_output/test_utils.py 的测试范式升级(grammar_accepts + 独立 fixture)。建议重点追问四件事:一是布尔形式的 propertyNames / additionalProperties 是否也应纳入守卫;二是运行环境的 xgrammar 版本下限是否已在镜像或依赖中锁定到 0.2.1;三是 patternProperties 与 properties 共存时的拒绝是否应配合更友好的错误提示(当前用户只能看到 VLLMValidationError);四是上游 #749/#826 修复后由谁负责回访放宽守卫。若线上开启了结构化输出,建议用真实的 MCP schema 集合回归一遍,而不是只依赖单测。

讨论亮点
  1. 测试冗余与覆盖缺口:gemini-code-assist[bot] 指出 patternProperties 用例命中已在 properties 中声明的键、属无效覆盖,并缺 propertyNames 用例;作者据此在 supported_schema 里加了 student 子对象,但这个子对象随后被推翻。

  2. 「frankenstein schema」范式争议(PR 中最有价值的一条):arpera 用 repro 脚本证明「propertyNames in this particular scheme does not affect on resulting grammar,so we do not test here behavior of propertyNames」,并主张「it is better to create multiple schemas that check only one thing at a time」,因为拥挤 schema 会让新关键字被遮蔽而测试仍绿;coderabbitai[bot] 也从一致性角度指出 propertyNames + patternProperties 共存会让检测器返回 True、使 test_supported_json_features 失败。最终作者删除 student(commit 64d0e6e),拆出独立 fixture + 具名行为测试。

  3. 三处 over-acceptance 的守卫:vadiklyutiy 转述 AI 评审给出三个具体反例(propertyNames 对具名 properties 不生效、patternProperties 合取被编译成析取、propertyNames 丢弃兄弟 additionalProperties 值 schema),作者确认全部成立并落地守卫,同时指出 AI 建议过窄:「unevaluatedProperties also admits boolean values by JSON Schema rulebook, so the only acceptable value for unevaluatedProperties is true for now」以及「item 1 applies not only to pattern but also enum... so we do not inspect what propertyNames contains」。

  4. 测试该放哪一侧:作者提问是否该把新测试放到 xgrammar 仓库,arpera 主张留在 vLLM:「Xgrammar do not react swift enough for bug fixes that people try to submit them」,并列出被搁置的 #796/#839/#841;作者回应「Got it, I have no objection but just wanted a double check.」

  5. 真实 E2E 佐证:yzong-rh 用 Qwen3.5 做了冒烟测试,main 上稳定失败(VLLMValidationError + Regex compilation timed out after 5s),本 PR 后 patternProperties 与 propertyNames 各 50/50 全部有效。

  6. 未闭合分歧:coderabbitai[bot] 认为 pattern_properties_schema 需要补 additionalProperties: false 才能让 assert not grammar_accepts(..., '{"other": 5}') 成立,但最终 fixture 未修改,讨论中也没有明确结论。

实现拆解

  1. 判定策略反转(vllm/v1/structured_output/backend_xgrammar.py):删除 any(key in obj for key in ("patternProperties", "propertyNames")) 的一刀切拒绝,改为删除后按组合重新判定。放行的是「无 properties 兄弟的单一 pattern patternProperties」与「不与属性类关键字冲突的独立 propertyNames」,这也是本 PR 的功能本体;validate_xgrammar_grammar 仍负责把检测结果转成 VLLMValidationError,从而驱动 auto 后端链式回退。

  2. 新增三组守卫(correctness 主线):propertyNames 与 properties / patternProperties / dict 形式 additionalProperties / 非字面量 true 的 unevaluatedProperties 共存时判为不支持(对应 xgrammar#826);patternProperties 与 properties 共存或 pattern 数 >1 时判为不支持(xgrammar 把本应「同时满足」的合取编译成「满足其一即可」的析取);propertyNames 内部出现 pattern/format + minLength/maxLength 时判为不支持(#749)。为此把字符串分支的判定抽成 _has_pattern_and_length_bounds,并显式套用到 propertyNames 上,因为该关键字按惯例省略 "type": "string",会绕过原有字符串检查。

  3. 健壮性与递归:patternProperties 的判长条件加了 isinstance(obj.get("patternProperties"), dict),避免 patternProperties: null 在守卫里抛 TypeError,保证畸形 schema 能走到 Grammar.from_json_schema() 并被包装成 VLLMValidationError;check_object 的递归下钻保持不变,嵌套对象里的同类组合同样被检查。unevaluatedProperties 只放行字面量 True(缺省也视为 True),因为 JSON Schema 允许布尔值,False 与 schema 形式都被拒绝。

  4. 测试配套(tests/v1/structured_output/test_utils.py):新增 grammar_accepts,用 Grammar.from_json_schema + xgrammar.testing._is_grammar_accept_string 把断言从「检测器说支持」升级为「编译出的语法真的约束住了」,并为 property_names_schema / pattern_properties_schema 各配一个具名行为测试。原来共享的 unsupported_object_schemas 拆成 unsupported_property_names_combinations 与 unsupported_pattern_properties_combinations,supported_schema 更名 supported_frankenstein_schema 并加上「禁止继续叠加关键字」的显式注释。

  5. 回退路径与 CI 配套(tests/v1/structured_output/test_validation.py):auto 回退用例原先用「纯 patternProperties」,该 schema 现在被 xgrammar 接受、不再触发回退,于是换成 properties + patternProperties(xgrammar 与 guidance 都拒绝,落到 outlines),修掉 computer-cpu-v1-others 的失败。依赖侧方面,功能依赖 xgrammar>=0.2.1(mlc-ai/xgrammar#594),但最终 diff 只有 3 个文件、未见 requirements 变更,版本下限需由环境/其他 PR 保证。

文件 模块 状态 重要度
vllm/v1/structured_output/backend_xgrammar.py 结构化输出 modified 7.18
tests/v1/structured_output/test_utils.py 输出校验 modified 7.32
tests/v1/structured_output/test_validation.py 后端回退 modified 4.24

关键符号

has_xgrammar_unsupported_json_features _has_pattern_and_length_bounds check_object validate_xgrammar_grammar grammar_accepts test_unsupported_json_features_by_type test_supported_json_features test_property_names_constrains_keys test_pattern_properties_constrains_keys_and_values test_auto_backend_falls_back_on_unsupported_schema

关键源码片段

vllm/v1/structured_output/backend_xgrammar.py core-logic

唯一的生产代码改动:把对象关键字的「一刀切拒绝」改写为按组合放行 + 守卫,并新增 `_has_pattern_and_length_bounds` 复用字符串长度边界判定,直接决定哪些 JSON Schema 能进入 xgrammar 后端。

# vllm/v1/structured_output/backend_xgrammar.py
​
def _has_pattern_and_length_bounds(schema: dict[str, Any]) -> bool:
    # 抽出的公共判定:字符串上同时出现「生成式约束」(pattern/format) 与长度边界时,
    # xgrammar 只编译前者并静默丢弃 minLength/maxLength,语法与长度关键字无关,
    # 所以这种组合必须整体判为不支持,而不是悄悄放宽。
    return ("pattern" in schema or "format" in schema) and (
        "minLength" in schema or "maxLength" in schema
    )
​
​
def has_xgrammar_unsupported_json_features(schema: dict[str, Any]) -> bool:
    """Check if JSON schema contains features unsupported by xgrammar."""
​
    def check_object(obj: dict[str, Any]) -> bool:
        if not isinstance(obj, dict):
            return False
​
        # 数值 / 数组 / 未知 format 的判定保持不变,此处略
        if obj.get("type") in ("integer", "number") and ("multipleOf" in obj):
            return True
        if obj.get("type") == "array" and any(
            key in obj
            for key in ("uniqueItems", "contains", "minContains", "maxContains")
        ):
            return True
        if (
            obj.get("type") == "string"
            and "format" in obj
            and obj["format"] not in STRING_SUPPORTED_FORMATS
        ):
            return True
​
        # 字符串分支改为复用 helper,行为不变
        if obj.get("type") == "string" and _has_pattern_and_length_bounds(obj):
            return True
​
        # propertyNames 本质上是一个 string schema,按惯例常常省略 "type",
        # 会绕过上面的字符串检查,所以这里显式再查一遍同样的组合。
        if (
            obj.get("type") == "object"
            and isinstance(obj.get("propertyNames"), dict)
            and _has_pattern_and_length_bounds(obj["propertyNames"])
        ):
            return True
​
        # FIXME: propertyNames 与 properties/patternProperties/additionalProperties/
        # unevaluatedProperties 共存时 xgrammar 行为不正确。
        # https://github.com/mlc-ai/xgrammar/issues/826
        # 注意 unevaluatedProperties 允许布尔值,因此只放行字面量 True(缺省也视为
        # True),schema 形式与 False 一律拒绝;这里只看是否存在 propertyNames,
        # 不检查其内部关键字,因为无论写 pattern 还是 enum,都不会被正确执行。
        if (
            obj.get("type") == "object"
            and "propertyNames" in obj
            and (
                "properties" in obj
                or "patternProperties" in obj
                or isinstance(obj.get("additionalProperties"), dict)
                or obj.get("unevaluatedProperties", True) is not True
            )
        ):
            return True
​
        # FIXME: 多个 patternProperties、或 patternProperties 与 properties 共存时,
        # xgrammar 把本应「同时满足」(conjunction) 的约束编译成「满足其一即可」的
        # 分支,所以只放行「单一 pattern 且没有 properties 兄弟」的组合。
        # isinstance 守卫确保 patternProperties: null 这类畸形 schema 能落到
        # Grammar.from_json_schema() 并被转换成 VLLMValidationError,而不是在这里
        # 抛 TypeError。
        if (
            obj.get("type") == "object"
            and isinstance(obj.get("patternProperties"), dict)
            and ("properties" in obj or len(obj["patternProperties"]) > 1)
        ):
            return True
​
        # 递归下钻,保证嵌套对象 / 数组里的同类 schema 同样被检查
        for value in obj.values():
            if isinstance(value, dict):
                if check_object(value):
                    return True
            elif isinstance(value, list):
                for item in value:
                    if isinstance(item, dict) and check_object(item):
                        return True
​
        return False
​
    return check_object(schema)
tests/v1/structured_output/test_utils.py test-coverage

测试范式升级的主战场:引入 `grammar_accepts` 做行为断言,把共享 schema 拆成独立 fixture,并把新增的拒绝组合逐个落到 `unsupported_property_names_combinations` / `unsupported_pattern_properties_combinations`。

# tests/v1/structured_output/test_utils.py
import pytest
from xgrammar import Grammar
from xgrammar.testing import _is_grammar_accept_string
​
from vllm.v1.structured_output.backend_xgrammar import (
    has_xgrammar_unsupported_json_features,
)
​
pytestmark = pytest.mark.cpu_test
​
​
def grammar_accepts(schema: dict, text: str) -> bool:
    # 行为级断言:把 JSON Schema 编译成 grammar,再判断文本能否被接受。
    # 相比只调用 has_xgrammar_unsupported_json_features,它能暴露「检测器说支持、
    # 但编译出的语法其实没约束住」的假阳性。
    return _is_grammar_accept_string(Grammar.from_json_schema(schema), text)
​
​
@pytest.fixture
def unsupported_pattern_properties_combinations():
    return [
        # properties 与 patternProperties 命中同一个键时必须同时满足 (conjunction),
        # 但 xgrammar 编译成互斥分支,满足任一即可,所以这种 schema 必须判为不支持。
        {
            "type": "object",
            "properties": {"x": {"type": "string"}},
            "patternProperties": {"^x$": {"type": "integer"}},
        },
        # 同源问题:多个 patternProperties 之间也会退化成「或」语义。
        {
            "type": "object",
            "patternProperties": {
                "^a[a-z]*$": {"type": "string"},
                "^[a-z]*z$": {"type": "integer"},
            },
        },
    ]
​
​
@pytest.fixture
def pattern_properties_schema():
    # 单一 pattern、且不与 properties 共存 —— 这是本 PR 新放行的组合。
    return {
        "type": "object",
        "patternProperties": {"^grade_[0-9]+$": {"type": "integer"}},
    }
​
​
@pytest.fixture
def property_names_schema():
    return {"type": "object", "propertyNames": {"pattern": "^[a-z_]+$"}}
​
​
def test_property_names_constrains_keys(property_names_schema):
    # 值类型不受 propertyNames 约束,键名必须落在 pattern 内。
    assert grammar_accepts(property_names_schema, '{"score": 5}')
    assert grammar_accepts(property_names_schema, '{"score": "seven"}')
    assert not grammar_accepts(property_names_schema, '{"Score": 5}') # 大写键不匹配
    assert not grammar_accepts(property_names_schema, '{"score_1": 5}') # 后缀数字不匹配
​
​
def test_pattern_properties_constrains_keys_and_values(pattern_properties_schema):
    assert grammar_accepts(pattern_properties_schema, '{"grade_1": 5}')
    assert not grammar_accepts(pattern_properties_schema, '{"other": 5}') # 键不匹配 pattern
    assert not grammar_accepts(pattern_properties_schema, '{"grade_1": "five"}') # 值类型不符

同一文件里,原先共享的 supported_schema 被重命名为 supported_frankenstein_schema,并加了一条面向后续 PR 的约束(这正是 review 中争议最大的一条):

@pytest.fixture
def supported_frankenstein_schema():
    # IMPORTANT(arpera):
    # 不要在这里继续叠加关键字!这个 schema 已经足够拥挤,新关键字可能对编译出的
    # grammar 完全无影响而测试依旧通过。新关键字请使用独立 fixture 加具名测试。
    return {
        # name / age / email / scores / car_type / address 等原有字段保持不变
        "type": "object",
        "minProperties": 1,
        "maxProperties": 100,
    }
tests/v1/structured_output/test_validation.py test-coverage

体现 `auto` 后端回退矩阵的语义变化:纯 patternProperties 现在被 xgrammar 接受、不再触发回退,因此回退用例必须换成 properties + patternProperties。

# tests/v1/structured_output/test_validation.py
​
@pytest.mark.parametrize(
    "schema, expected_backend",
    [
        # multipleOf 仍被 xgrammar 拒绝,auto 回退到 guidance。
        (
            {
                "type": "object",
                "properties": {"n": {"type": "integer", "multipleOf": 2}},
            },
            "guidance",
        ),
        # properties + patternProperties 对 xgrammar(本 PR 新增的守卫)和 guidance
        # 都不支持,因此只能落到 outlines。
        # 原先这里用的是「纯 patternProperties」schema,本 PR 放行该组合后它不再触发
        # 回退,断言语义失效,故替换为当前的冲突组合。
        (
            {
                "type": "object",
                "properties": {"a": {"type": "string"}},
                "patternProperties": {"^a$": {"type": "string"}},
            },
            "outlines",
        ),
    ],
)
def test_auto_backend_falls_back_on_unsupported_schema(schema, expected_backend):
    """`auto` falls back on rejection, so it must catch what the validators raise."""
    params = SamplingParams(structured_outputs=StructuredOutputsParams(json=schema))
    params._validate_structured_outputs(
        _StubModelConfig(is_diffusion=False),
        StructuredOutputsConfig(backend="auto"),
        tokenizer=object(),
    )
    assert params.structured_outputs._backend == expected_backend

评论区精华

共享「frankenstein」schema 上叠加关键字会掩盖失效 测试

gemini-code-assist[bot] 先指出 patternProperties 用例只命中已在 properties 中声明的键、未覆盖 propertyNames,作者在 supported_schema 中加入 student 子对象;arpera 随后用 repro 脚本证明该 schema 里 propertyNames 对编译出的 grammar 无影响,并强调「it is better to create multiple schemas that check only one thing at a time」;coderabbitai[bot] 也指出 propertyNames + patternProperties 共存会让检测器返回 True、导致 test_supported_json_features 失败。

结论:删除 student 字段(commit 64d0e6e),把共享 schema 更名为 supported_frankenstein_schema 并加 IMPORTANT 注释禁止继续叠加,新增 property_names_schema / pattern_properties_schema 独立 fixture 与具名行为测试。 · 已解决

三处 over-acceptance 必须补守卫 正确性

vadiklyutiy 转述 AI 评审给出三个具体反例:propertyNames 对具名 properties 不生效;patternProperties 本应合取却被编译成析取;propertyNames 会丢弃兄弟 additionalProperties 的值 schema。作者确认「Indeed all the 3 cases are valid overacceptions」,并补充 AI 建议过窄——unevaluatedProperties 允许布尔值所以只放行 true,且 propertyNames 内部写 pattern 还是 enum 都不影响判定。

结论:在 has_xgrammar_unsupported_json_features 中新增 propertyNames 冲突守卫与 patternProperties 组合守卫,并把对应反例写成 unsupported_* fixture(commit fbb1bfc)。 · 已解决

patternProperties 为 null 时守卫自身抛 TypeError 正确性

coderabbitai[bot] 指出 {"type":"object","patternProperties":null} 会在 len() 处抛 TypeError,发生在 Grammar.from_json_schema() 把非法 schema 转成 VLLMValidationError 之前,破坏统一的错误契约。

结论:判长条件改为 isinstance(obj.get("patternProperties"), dict) and len(...) > 1,畸形 schema 重新交回 xgrammar 处理(commit fe5417f)。 · 已解决

pattern_properties_schema 是否要加 additionalProperties: false 测试

coderabbitai[bot] 认为 patternProperties 不拒绝未匹配键,因此 test_pattern_properties_constrains_keys_and_values 中 assert not grammar_accepts(schema, '{"other": 5}') 与 fixture 不一致,建议补 additionalProperties: false。

结论:最终 fixture 未加该字段,讨论中也没有明确结论;新引入的 grammar_accepts 行为断言实际表明 xgrammar 的 patternProperties-only 分支会拒绝未匹配键,与 CodeRabbit 的判断相反,属于未闭合分歧。 · unresolved

回归测试放 vLLM 还是上游 xgrammar 设计

作者提问「do we want to keep a vllm's own unit test set for xgrammar?」;arpera 主张留在 vLLM:「Xgrammar do not react swift enough for bug fixes that people try to submit them」,并列出被搁置的 #796 / #839 / #841。

结论:确认在 vLLM 侧保留针对 xgrammar 的行为级回归测试,作者无异议。 · 已解决

auto 后端回退用例因功能放行而失效 测试

CI 的 computer-cpu-v1-others 失败,arpera 提醒作者修 CI;根因是纯 patternProperties schema 现在被 xgrammar 接受,不再回退到 outlines。

结论:把回退用例换成 properties + patternProperties(xgrammar 与 guidance 都拒绝,落到 outlines),并在注释中说明原因(commit 1a3174fa)。 · 已解决

风险与影响

  1. 黑名单式守卫存在漏检面(核心风险):新逻辑是「枚举已知的坏组合」,而非「枚举已知的好组合」。例如 {"type": "object", "propertyNames": false} 这类布尔 propertyNames、propertyNames + 布尔 additionalProperties: false(守卫只拒 dict 形式)、以及 unevaluatedProperties 在没有 propertyNames 时与 properties 共存,都不会被判为不支持,但 xgrammar 是否真正执行这些约束没有行为测试覆盖。一旦上游静默丢约束,vLLM 会在没有报错的情况下产出违反用户 schema 的 JSON,这是结构化输出中最难排查的失败模式。

  2. 依赖版本下限未在 diff 内固定:功能依赖 xgrammar>=0.2.1(mlc-ai/xgrammar#594),但变更文件只有 3 个、未见 requirements/镜像改动。若实际运行环境的 xgrammar 更旧,vLLM 侧守卫已经放行 patternProperties/propertyNames,上游却仍会丢约束,等价于静默放行。

  3. 测试依赖私有 API:tests/v1/structured_output/test_utils.py 使用 xgrammar.testing._is_grammar_accept_string(下划线私有函数)与 Grammar.from_json_schema。xgrammar 升级改签名或改名会直接让 CI 崩,属于测试侧耦合。

  4. auto 后端的归属变化:同一个 schema 从「回退到 outlines」变成「由 xgrammar 服务」,grammar 实现不同会带来输出风格、编译耗时与生成长度的差异;test_auto_backend_falls_back_on_unsupported_schema 的改写正是这条路径仍在演进的证据。

  5. 守卫与上游修复的同步成本:所有拒绝分支都依赖注释里的 FIXME 链接(#826/#749),但代码里没有版本条件判断,xgrammar 修复后不会自动放宽,需要人工回访清理,否则会长期误拒合法 schema。

  6. 行为断言与上游语义绑定:assert not grammar_accepts(pattern_properties_schema, '{"other": 5}') 依赖 xgrammar 的 patternProperties-only 分支会拒绝未匹配键;这与 CodeRabbit 的判断相反且未在讨论中收口,语义漂移时测试会成为噪声源。

用户侧:OpenAI 兼容端点(含工具调用/MCP 场景)中带 patternProperties 或 propertyNames 的 JSON Schema 请求,从「显式 xgrammar 直接 400、auto 回退 outlines」变为「由 xgrammar 用语法原生约束」。yzong-rh 的 E2E 显示 main 上必然失败、本 PR 后 50/50 全部通过,属于可见的功能补齐。

系统侧:改动只落在 xgrammar 后端的校验层(has_xgrammar_unsupported_json_features 及其调用方 validate_xgrammar_grammar),不触及调度、KV cache、模型执行路径;但 auto 后端的回退矩阵发生变化,会间接改变同一请求的 grammar 实现与性能特征。outlines/guidance/lm-format-enforcer 行为不受影响。

团队侧:确立了三条可复用的工程约定——「vLLM 侧保留 xgrammar 回归测试(上游响应慢)」「一个关键字一个 fixture + 具名行为测试,禁止堆叠共享 schema」「对上游缺陷用 FIXME 链接 + 守卫,而不是静默放行」。这些约定会影响后续所有结构化输出相关的 PR。

黑名单式守卫易漏检 上游静默丢约束 依赖私有测试 API auto 回退行为变更 版本下限未在 diff 内固定

关联 Issue

#594 fix: support patternProperties alongside properties in JSON schema

完整报告

参与讨论