qinghuan
← 回到知识探索

AI 直觉 / 动手实验

两种工具参数都填对了,为什么 oneOf 反而拒绝?

数清每个分支是否成立,再区分“恰好一个分支”与“恰好填一栏”,用明确模式修正输入合同。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先把两个分支各自负责什么写出来
  4. 至少一条与恰好一条,是不同的合同
  5. 漏掉 required,空对象也能匹配两个分支
  6. 恰好一个合法分支,不等于只出现一个字段
  7. 加上明确模式,让每种输入的边界可见
  8. 运行完整代码,再检查错误路径
  9. 把验证、选择与执行分别说清楚
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 知道 properties 与 required 的作用
  • 能读懂列表、字典和布尔条件
  • Python 3.10+;需要安装 requirements.txt 中的 jsonschema 4.26.0
  • 先读 把工具定义做成可测试的接口契约:先理解工具输入的必填和额外字段,再组合多种输入模式。
遇到不熟悉的词,先看这 2 个解释
分支匹配
同一份输入满足某一组完整规则,而不是只出现了一个相似字段。
互斥分支
规则设计成同一份输入不可能同时满足两种模式。
打开完整术语小抄 ↗

一个资料查询工具接受两种输入:按 document_id 找资料,或按 query 搜索。只填合法编号能通过,只填合法关键词也能通过;把两项都填好,却被 oneOf 拒绝。为什么信息更多,反而不合格?

oneOf 的 one 是“恰好一个规则分支成立”,不是“按顺序选第一个能用的办法”。本篇用真实 jsonschema 4.26.0 验证这个差别,再修正一份容易误读的工具合同;全部资料编号与搜索词都是虚构数据。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本;实测 Windows / Python 3.14.0,依赖 jsonschema==4.26.0;Python 3.10 仅做语法兼容检查。首次创建隔离环境并安装固定版本依赖需要联网;之后运行示例不访问网络。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 6 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要
  • requirements.txt:固定版本依赖

在这个文件夹打开 PowerShell,逐行运行

python --version
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。真实 jsonschema 4.26.0 验证固定只读资料工具的 oneOf/anyOf 分支;未执行查询、授权或模型工具调用;不验证任意 Schema。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

oneOf 数的是成功分支,不是按顺序选第一条

正在准备实验控件…

{
  "document_id": "D7",
  "query": "cache"
}
  1. document_id 是必填非空字符串:通过
  2. query 是必填非空字符串:通过
成功分支数量
2
anyOf:至少一支通过
通过
oneOf:恰好一支通过
不通过

两个分支都通过,因此 anyOf 通过、oneOf 失败;它不会直接采用第一支。

查看这组案例使用的 Schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "document_id": {},
    "query": {}
  },
  "additionalProperties": false,
  "oneOf": [
    {
      "properties": {
        "document_id": {
          "type": "string",
          "minLength": 1
        }
      },
      "required": [
        "document_id"
      ]
    },
    {
      "properties": {
        "query": {
          "type": "string",
          "minLength": 1
        }
      },
      "required": [
        "query"
      ]
    }
  ]
}

这里回放 CPython 3.14.0、jsonschema 4.26.0 对固定案例的实际结果。可切换案例与规则观察差别;网页没有运行通用 JSON Schema 校验器,也没有执行查询或预约。下载完整 Python 程序可在本地复验。

先把两个分支各自负责什么写出来

编号分支要求 document_id 存在,并且是长度至少一的字符串;搜索分支对 query 做同样要求。两分支都在检查同一份完整输入,没有把对象拆成两份各查一半。

第一版外层只允许出现 document_id 和 query。为看清分支逻辑,外层没有再给这两项加类型限制:每个分支只负责自己声明的字段,另一字段是否存在、是什么类型,会在后面成为关键。

程序先逐个调用分支验证器,得到 matches,再对同一输入运行 anyOf 和 oneOf。这样表里不仅有最后的通过或拒绝,还能看到究竟有几条规则满足。

至少一条与恰好一条,是不同的合同

只填合法编号时,matches=1,anyOf 与 oneOf 都通过;只填合法关键词时也一样。两项都合法时,matches=2,anyOf 继续通过,oneOf 拒绝。空对象的 matches=0,两者都拒绝。

这不是 oneOf 随机选错了分支。它根本没有承诺替你挑一项执行,而是在检查“恰好一项成立”这个条件。若产品允许同时提供编号和关键词,应该先决定组合含义,再写允许它的规则。

无论有多少分支,oneOf 都只允许一个成立。代码另测三个恒为真的分支:三个一起满足仍被拒绝,不能把它理解成“只要通过的数量是奇数就行”。

漏掉 required,空对象也能匹配两个分支

properties 说明某个字段出现时怎样检查,不表示这个字段必须出现。把两个分支中的 required 都删掉,空对象没有任何已有字段违反它们,因此两个分支都会通过。

结果是空对象在 anyOf 下通过,在 oneOf 下因同时满足两支而失败。后一个失败容易让人误以为“必填检查起作用了”,其实拒绝原因完全不同;规则稍作调整后,隐藏的缺字段问题就可能出现。

所以调试时应分别记录每个分支的结果。只看最后的 False,无法区分没有方案符合,还是多个方案同时符合,更不能证明你原本想要的必填条件已经写对。

恰好一个合法分支,不等于只出现一个字段

再交一份 document_id 为 D7、query 为数字 7 的输入。编号分支通过,搜索分支因类型错误失败;因此 matches=1,第一版 oneOf 反而通过。这份输入明明包含两栏。

原因就在第一版的规则:编号分支没有限制 query,外层也只是允许这个名称出现,没有要求其值必须是字符串。oneOf 数的是完整分支的成功数,不会自动统计用户填了几栏,也不会自动替所有出现的字段补检查。

如果意图是两栏只能提供一栏,应明确禁止另一栏或采用独立的模式合同。如果意图是两栏可同时提供且都必须合法,就要把共同字段约束写清楚,并定义二者结合后的查询行为;简单改一个关键字不能替代业务决定。

加上明确模式,让每种输入的边界可见

第二版增加必填 mode。编号分支要求 mode 固定为 id,并要求 document_id;搜索分支要求 mode 固定为 text,并要求 query。const 表达的就是“这个值必须等于指定常量”。

每个分支还把 additionalProperties 设为 false,只接受该分支自己的字段。这样 mode=id 的请求再带 query,就会被拒绝;缺 mode、未知模式和错误类型同样失败。根部也显式声明 type=object,直接写明输入类型,不依靠非对象偶然触发的分支冲突来表达这条要求。

实测六组模式输入只有两组合格,null 也被拒绝。这个验证结果仍不是工具执行器;应用需要按已验证的 mode 调用相应只读能力,并继续处理资源是否存在、是否允许读取和实际查询是否成功。

运行完整代码,再检查错误路径

先在独立虚拟环境安装下载包的 requirements.txt,再执行 demo.py。2026-10-23 已用 Windows、Python 3.14.0 与 jsonschema 4.26.0 实测,79 项检查通过;运行过程不需要网络或模型账号。

程序枚举每栏缺失、空字符串、合法字符串和数字四种状态,共 16 个组合。独立预期只按“这个分支的字段是否为非空字符串”计数,再与真实库的 anyOf、oneOf 结果对照;它不是用手写检查器替代主实验。

零分支与两分支匹配都会产生 oneOf 错误,但本版本的 context 不同:前者保留两个 required 子错误,后者没有失败子分支可保留,context 为空。代码核对失败关键字与路径;不能把 context 为空解释成没有错误。

展开收起代码 · 小实验python · 131 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Real jsonschema branch validation for a fictional read-only lookup tool."""
from copy import deepcopy
from importlib.metadata import version
from itertools import product
import json
from pathlib import Path

from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError

EXPECTED_VERSION = "4.26.0"
DIALECT = "https://json-schema.org/draft/2020-12/schema"
checks = 0


def check(condition, message):
    global checks
    if not condition:
        raise AssertionError(message)
    checks += 1


def branch(field):
    return {"properties": {field: {"type": "string", "minLength": 1}}, "required": [field]}


def lookup_schema(combinator, branches):
    return {"$schema": DIALECT, "type": "object", "properties": {"document_id": {}, "query": {}},
            "additionalProperties": False, combinator: branches}


def validator(schema):
    Draft202012Validator.check_schema(schema)
    return Draft202012Validator(schema)


def main():
    installed = version("jsonschema")
    check(installed == EXPECTED_VERSION, "install requirements.txt first")
    branches = [branch("document_id"), branch("query")]
    per_branch = [validator(item) for item in branches]
    any_validator = validator(lookup_schema("anyOf", branches))
    one_validator = validator(lookup_schema("oneOf", branches))
    report = []
    cases = [
        ("only_id", {"document_id": "D7"}, 1, True, True),
        ("only_query", {"query": "cache"}, 1, True, True),
        ("both", {"document_id": "D7", "query": "cache"}, 2, True, False),
        ("neither", {}, 0, False, False),
        ("bad_id_type", {"document_id": 7}, 0, False, False),
        ("empty_query", {"query": ""}, 0, False, False),
        ("mixed_types", {"document_id": "D7", "query": 7}, 1, True, True),
    ]
    print("jsonschema=" + installed)
    for name, payload, want_matches, want_any, want_one in cases:
        before = deepcopy(payload)
        matches = sum(item.is_valid(payload) for item in per_branch)
        any_ok = any_validator.is_valid(payload)
        one_ok = one_validator.is_valid(payload)
        check(matches == want_matches, name + " matching branches")
        check(any_ok is want_any, name + " anyOf")
        check(one_ok is want_one, name + " oneOf")
        check(payload == before, name + " unchanged")
        print(f"{name}: matches={matches}; anyOf={any_ok}; oneOf={one_ok}")
        report.append(dict(case=name, payload=payload, matches=matches, anyOf=any_ok, oneOf=one_ok))

    # Independent finite oracle: a branch requires its own nonempty string.
    missing = object()
    values = [missing, "", "ok", 7]
    enumerated = 0
    for left, right in product(values, repeat=2):
        payload = {}
        if left is not missing:
            payload["document_id"] = left
        if right is not missing:
            payload["query"] = right
        count = int(type(left) is str and len(left) > 0) + int(type(right) is str and len(right) > 0)
        check(any_validator.is_valid(payload) == (count >= 1), "finite anyOf oracle")
        check(one_validator.is_valid(payload) == (count == 1), "finite oneOf oracle")
        enumerated += 1

    no_required = [{"properties": deepcopy(item["properties"])} for item in branches]
    check(validator(lookup_schema("anyOf", no_required)).is_valid({}), "empty matches both")
    check(not validator(lookup_schema("oneOf", no_required)).is_valid({}), "two matches rejected")
    print("missing_required_empty: matches=2; anyOf=True; oneOf=False")
    check(not validator({"oneOf": [True, True, True]}).is_valid({}), "three matches also rejected")
    print("three_matching_branches: oneOf=False")

    tagged_branches = [
        {"properties": {"mode": {"const": "id"}, "document_id": {"type": "string", "minLength": 1}},
         "required": ["mode", "document_id"], "additionalProperties": False},
        {"properties": {"mode": {"const": "text"}, "query": {"type": "string", "minLength": 1}},
         "required": ["mode", "query"], "additionalProperties": False},
    ]
    tagged = validator({"$schema": DIALECT, "type": "object", "oneOf": tagged_branches})
    tagged_cases = [
        ({"mode": "id", "document_id": "D7"}, True),
        ({"mode": "text", "query": "cache"}, True),
        ({"document_id": "D7"}, False),
        ({"mode": "id", "document_id": "D7", "query": "cache"}, False),
        ({"mode": "other", "query": "cache"}, False),
        ({"mode": "id", "document_id": 7}, False),
    ]
    for payload, expected in tagged_cases:
        check(tagged.is_valid(payload) is expected, "tagged contract")
    check(not tagged.is_valid(None), "explicit root type rejects null")
    print("tagged_modes: accepted=2; rejected=4; null=rejected")

    error_paths = {}
    for label, payload in [("zero_matches", {}), ("two_matches", {"document_id": "D7", "query": "cache"})]:
        try:
            one_validator.validate(payload)
        except ValidationError as error:
            check(error.validator == "oneOf", label + " keyword")
            check(list(error.absolute_path) == [], label + " root instance path")
            check(list(error.absolute_schema_path) == ["oneOf"], label + " schema path")
            error_paths[label] = {"instance": [], "schema": ["oneOf"], "context_count": len(error.context)}
        else:
            raise AssertionError("invalid oneOf payload accepted")
    check(error_paths["zero_matches"]["context_count"] == 2, "required errors retained")
    check(error_paths["two_matches"]["context_count"] == 0, "no failing subbranch to retain")
    print("error_context: zero_matches=2; two_matches=0")
    print(f"finite_field_combinations={enumerated}; PASS checks={checks}")
    Path("report.json").write_text(json.dumps({
        "jsonschema": installed, "scope": "fixed read-only lookup schemas; no lookup executed",
        "cases": report, "error_paths": error_paths, "finite_combinations": enumerated, "checks": checks,
    }, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 Python 3.10 或更新版本;实测 Windows / Python 3.14.0,依赖 jsonschema==4.26.0;Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。真实 jsonschema 4.26.0 验证固定只读资料工具的 oneOf/anyOf 分支;未执行查询、授权或模型工具调用;不验证任意 Schema。

运行后对照结果
jsonschema=4.26.0
only_id: matches=1; anyOf=True; oneOf=True
only_query: matches=1; anyOf=True; oneOf=True
both: matches=2; anyOf=True; oneOf=False
neither: matches=0; anyOf=False; oneOf=False
bad_id_type: matches=0; anyOf=False; oneOf=False
empty_query: matches=0; anyOf=False; oneOf=False
mixed_types: matches=1; anyOf=True; oneOf=True
missing_required_empty: matches=2; anyOf=True; oneOf=False
three_matching_branches: oneOf=False
tagged_modes: accepted=2; rejected=4; null=rejected
error_context: zero_matches=2; two_matches=0
finite_field_combinations=16; PASS checks=79

把验证、选择与执行分别说清楚

Schema 校验回答输入是否满足当前合同;路由逻辑决定调用哪项能力;工具结果才说明实际查询到了什么。即使 oneOf 只匹配一支,也不证明资料存在,更不自动授予读取权限。

这里使用应用侧完整 JSON Schema 验证器。某个模型服务允许提交哪些结构化输出关键字,是另一层支持范围;本篇没有接入产品 API,也不把本地通过当作它们支持相同 Schema 的证据。

下载包保留完整源码、固定直接依赖、实际输出和版本记录。有限的字段组合能帮助发现这份合同的逻辑错误,不能代表穷尽了任意嵌套 Schema、远程引用或所有工具输入。

记住这三点

  • anyOf 至少一个分支成立;oneOf 必须恰好一个,多个同样会失败。
  • properties 不代替 required;分支成功数量也不等于字段出现数量。
  • 明确 mode、必填与额外字段规则,仍要另行实现授权、路由和真实执行。

别把例子用过头

  • 把 oneOf 当作从左到右的工具选择器。
  • 漏写 required,却把多分支冲突当成正确的必填拒绝。
  • 认为 oneOf 会自动检查所有出现的字段。
  • 看到错误的 context 为空就忽略父级 oneOf 失败。

自己试一小步

为虚构资料工具增加第三种按标签查询的模式,你能证明三个输入分支的允许范围符合自己的设计吗?

  1. 先写三种模式各自必填、可选和禁止的字段,再写 Schema。
  2. 构造缺模式、未知模式、额外字段、错类型,以及会同时匹配多支的规则变体,运行前写出预期。
  3. 枚举一组有限输入,输出每个分支和总规则的结果,并把校验结果与模拟路由记录分开。

做到这些,就算完成

  • 分支预期来自自己先写的字段表,没有直接照抄库输出。
  • 能解释零匹配与多匹配的不同失败原因。
  • 没有把一种输入通过校验表述成真实资料查询成功。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • JSON Schema 2020-12:逻辑组合关键字 (新标签页)↗JSON Schema · 英文

    anyOf 要求至少一个子 Schema 有效;oneOf 要求恰好一个,规则不表达顺序路由。

    2026-10-23 已打开原始正文并核读第 10.2.1.1 至 10.2.1.4 节;未声称通读整个网站。

  • JSON Schema:组合规则与重叠反例 (新标签页)↗JSON Schema · 英文

    解释 anyOf、oneOf 的组合含义以及同时满足两项时 oneOf 拒绝。

    2026-10-23 已打开原始正文并核读Introduction、anyOf、oneOf 与重叠数值示例;未声称通读整个网站。

  • JSON Schema:对象字段、必填与额外字段 (新标签页)↗JSON Schema · 英文

    properties 只约束出现的对应字段,缺省允许缺字段;additionalProperties 约束本层未声明字段。

    2026-10-23 已打开原始正文并核读Properties、Additional Properties 与 Extending Closed Schemas 段落;未声称通读整个网站。

  • 官方测试集:Draft 2020-12 oneOf (新标签页)↗JSON Schema Test Suite · 英文

    提供零个、一个、多个有效分支,以及 required、可选字段和三个 true 子 Schema 的独立预期。

    2026-10-23 已打开原始正文并核读原始 JSON 中 oneOf、boolean schemas、complex types、required 和 missing optional property 测试组;本例另写虚构工具数据;未声称通读整个网站。

  • jsonschema 4.26.0:结构化错误与子错误 (新标签页)↗python-jsonschema 项目 · 英文

    读取错误关键字、绝对路径与 context;context 表示造成失败的子错误,并非所有未通过情况都有子错误。

    2026-10-23 已打开原始正文并核读ValidationError 的 validator、absolute_path、absolute_schema_path、context 及嵌套错误示例;未声称通读整个网站。