开始前,会这些就够了
- 知道 properties 与 required 的作用
- 能读懂列表、字典和布尔条件
- Python 3.10+;需要安装
requirements.txt中的 jsonschema 4.26.0 - 先读 把工具定义做成可测试的接口契约:先理解工具输入的必填和额外字段,再组合多种输入模式。
一个资料查询工具接受两种输入:按 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 数的是成功分支,不是按顺序选第一条
正在准备实验控件…
这里回放 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 为空解释成没有错误。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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失败。
自己试一小步
为虚构资料工具增加第三种按标签查询的模式,你能证明三个输入分支的允许范围符合自己的设计吗?
- 先写三种模式各自必填、可选和禁止的字段,再写 Schema。
- 构造缺模式、未知模式、额外字段、错类型,以及会同时匹配多支的规则变体,运行前写出预期。
- 枚举一组有限输入,输出每个分支和总规则的结果,并把校验结果与模拟路由记录分开。
做到这些,就算完成
- 分支预期来自自己先写的字段表,没有直接照抄库输出。
- 能解释零匹配与多匹配的不同失败原因。
- 没有把一种输入通过校验表述成真实资料查询成功。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- JSON Schema 2020-12:逻辑组合关键字 (新标签页)↗
anyOf要求至少一个子 Schema 有效;oneOf要求恰好一个,规则不表达顺序路由。 - JSON Schema:组合规则与重叠反例 (新标签页)↗
解释
anyOf、oneOf的组合含义以及同时满足两项时oneOf拒绝。 - JSON Schema:对象字段、必填与额外字段 (新标签页)↗
properties 只约束出现的对应字段,缺省允许缺字段;
additionalProperties约束本层未声明字段。 - 官方测试集:Draft 2020-12 oneOf (新标签页)↗
提供零个、一个、多个有效分支,以及 required、可选字段和三个
true子 Schema 的独立预期。 - jsonschema 4.26.0:结构化错误与子错误 (新标签页)↗
读取错误关键字、绝对路径与 context;context 表示造成失败的子错误,并非所有未通过情况都有子错误。