qinghuan
← 回到知识探索

AI 直觉 / 动手实验

工具参数复用了基础规则,新增字段为什么总被拒绝?

用真实 jsonschema 比较六份组合合同,找出关闭额外字段的位置,以及 allOf 为什么不会替你合并声明。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先明确基础规则已经关上了哪扇门
  4. allOf 是同时满足,不是把对象规则拼成一张表
  5. 只把关闭开关搬到外层,仍可能关错范围
  6. 开放基础规则,在组合完成的外层检查遗漏字段
  7. 外层的新关键字,不能救回已经失败的基础规则
  8. 运行八行六列,再核对四十个有限对象
  9. 把合同组合与真实工具执行分开验收
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
规则合取
同一份输入必须把每组规则都满足,任何一组拒绝都不能通过。
未评估属性
在适用规则完成检查后,尚未被相关属性注解覆盖的字段。
打开完整术语小抄 ↗

资料工具原来只接受 document_id。现在想复用基础规则,再增加必填 language,于是把旧规则放入 allOf,在外面声明语言。结果:带语言会报多余字段,不带语言又报缺字段。参数没有写错,两份规则却合在一起互相冲突了。

本篇使用真实 jsonschema 4.26.0,逐格执行六份合同与八个固定输入。实验不查询真实资料,也不调用模型;它只观察应用侧验证器怎样解释这些 Schema。

把例子带到自己的电脑上

下载完整练习包

需要 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 的六份本地组合合同、8输入×6合同及40个对象的有限枚举;未执行工具、远程引用或模型API。

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

allOf 扩展字段,为什么仍然被基础规则拒绝?

正在准备实验控件…

先保持“两个字段都正确”,比较前三种规则与“外层汇总后关闭”;再选择多一个 debug 字段,对比关闭与开放的结果。

{
  "document_id": "D7",
  "language": "zh"
}
实际校验结果
不通过
错误数量
1

基础规则只声明 document_id,却已禁止额外字段。输入带有 language 时,它在基础规则这一层仍会被拒绝;allOf 不会合并两层的 properties。

  1. additionalProperties:当前这一层禁止未声明的额外字段。

    输入位置:根节点(空路径)
    规则位置:/allOf/0/additionalProperties

展开当前规则

关闭基础规则,再用 allOf 扩展

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "language": {
      "enum": [
        "zh",
        "en"
      ]
    }
  },
  "required": [
    "language"
  ],
  "allOf": [
    {
      "type": "object",
      "properties": {
        "document_id": {
          "type": "string",
          "minLength": 1
        }
      },
      "required": [
        "document_id"
      ],
      "additionalProperties": false
    }
  ]
}

固定回放 CPython 3.14.0、jsonschema 4.26.0 在 Draft 2020-12 下实际执行的 48 组结果。网页只切换这 6 份规则与 8 个输入,没有校验任意 JSON,也没有执行资料查询;下方 Python 下载版可在本地复验。

先明确基础规则已经关上了哪扇门

基础合同要求输入是对象,document_id 必须存在、是字符串且非空。如果同时写 additionalProperties=false,这份合同只允许它自己声明的 document_id 出现。language 在基础规则看来就是未声明字段。

扩展合同在外层声明 language 只能为 zh 或 en,并要求它存在;allOf 中继续放入那份已关闭的基础合同。对于 document_id=D7、language=zh 的输入,语言约束通过,基础合同却拒绝 language。

删掉 language 也不能解决:外层 required 又会失败。固定案例表把新形状和旧形状都保留下来,避免只看某一种失败就要求模型来回增删同一个字段。

allOf 是同时满足,不是把对象规则拼成一张表

allOf 把同一份输入分别交给所有子规则,全部成立才通过。它不会把外层的 properties 名单写回内层,也不会撤销内层已经规定的 additionalProperties=false。子规则接收到的还是完整原始对象,不是先被其他规则过滤后的剩余字段。

因此,扩展者不能靠“外层已经声明了 language”说服关闭的基础规则。这个问题与上一篇 oneOf 的匹配数量不同:oneOf 检查恰好一支成立;这里要求全部成立,而每支对字段的认识有自己的作用范围。

程序保留失败关键字的位置。合法新请求在 closed_base 中,拒绝来自 allOf 的第 0 支里的 additionalProperties。路径指向的是这份基础合同,不是模型给了非法语言。

只把关闭开关搬到外层,仍可能关错范围

第二份合同先去掉基础规则的 additionalProperties,再在外层设 additionalProperties=false。但外层 properties 只列 language,于是 document_id 又变成外层眼中的额外字段。单纯移动一个开关还不够。

第三份合同在外层同时声明 language 与 document_id,再关闭额外字段。document_id 在外层的约束写成空规则 {},只是让这个名字进入外层名单;类型、非空与必填要求仍由 allOf 中的基础规则执行,没有因为空规则就被撤销。

这份 redeclared 合同接受合法新请求,同时拒绝数字编号、空编号、未知语言、缺字段和 debug 等额外字段。重声明字段名称是一种明确的结构安排;字段较多时要注意维护同步,但本例没有测量维护成本。

开放基础规则,在组合完成的外层检查遗漏字段

第四份合同也使用开放的基础规则,但外层不重复 document_id 名称,而是写 unevaluatedProperties=false。这个关键字会依据适用规则产生的属性注解,识别前面成功评估过的字段,再处理尚未覆盖的字段。

这里 document_id 由 allOf 中的基础规则检查,language 由外层 properties 检查;合法输入的两栏都得到覆盖。额外的 debug 没有得到允许,于是被拒绝。它与上一种重声明方案在本篇声明的有限输入域中给出相同结果。

unevaluatedProperties 从较新的草案开始提供。本篇明确使用 Draft202012Validator;不能把关键字复制到任意旧草案或模型服务后,就假定那里也支持同样行为。校验仍不负责删除字段或修改输入。

外层的新关键字,不能救回已经失败的基础规则

第五份合同保留关闭的基础规则,再添加外层 unevaluatedProperties=false。合法新请求仍然失败:内部 additionalProperties 的拒绝没有消失。外层规则能够增加条件,不会把内层的 False 改写成 True。

第六份 open_everywhere 则让基础规则与外层都不关闭额外字段。它接受合法新请求,也接受多了 debug 的请求。这说明“终于通过了”可能只是把原本想保留的禁止条件取消了,验收还必须包含应该拒绝的输入。

失败子规则还可能影响哪些字段注解可供外层使用,所以一个错误输入有时会出现不止一种错误关键词。程序记录路径与关键词,不把英文错误文案或错误条数当成跨版本稳定的业务接口。

运行八行六列,再核对四十个有限对象

先在独立虚拟环境安装 requirements.txt 中的 jsonschema==4.26.0,再运行 demo.py。2026-10-23 已用 Windows、Python 3.14.0 与该版本真实执行,181 项检查通过;直接复用了本轮已安装的隔离环境,没有修改全局依赖。

主表是八个输入、六份合同,共 48 个接受或拒绝结果。除此之外,代码枚举编号缺失、空串、合法串或数字,语言缺失、zh、en、fr 或数字,再组合是否有 debug,共 40 个对象;独立字段表预期分别核对两份修正版与开放版。

report.json 保存完整 Schema、每格布尔结果、错误关键词和路径。代码还检查输入与规则未被修改。有限枚举只能证明这些声明的组合,不能证明任意嵌套 Schema、引用或全部输入空间都已覆盖。

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

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

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

from jsonschema import Draft202012Validator

EXPECTED_VERSION = '4.26.0'
DIALECT = 'https://json-schema.org/draft/2020-12/schema'
checks = []


def check(name, condition):
    if not condition:
        raise AssertionError(name)
    checks.append(name)


def schemas():
    base_open = {'type': 'object', 'properties': {'document_id': {'type': 'string', 'minLength': 1}},
                 'required': ['document_id']}
    base_closed = {**deepcopy(base_open), 'additionalProperties': False}
    extension = {'$schema': DIALECT, 'type': 'object',
                 'properties': {'language': {'enum': ['zh', 'en']}}, 'required': ['language']}
    result = {}
    result['closed_base'] = {**deepcopy(extension), 'allOf': [deepcopy(base_closed)]}
    result['outer_only'] = {**deepcopy(extension), 'allOf': [deepcopy(base_open)],
                            'additionalProperties': False}
    result['redeclared'] = {**deepcopy(extension), 'allOf': [deepcopy(base_open)],
                           'properties': {**deepcopy(extension['properties']), 'document_id': {}},
                           'additionalProperties': False}
    result['unevaluated'] = {**deepcopy(extension), 'allOf': [deepcopy(base_open)],
                            'unevaluatedProperties': False}
    result['closed_plus_unevaluated'] = {**deepcopy(result['closed_base']), 'unevaluatedProperties': False}
    result['open_everywhere'] = {**deepcopy(extension), 'allOf': [deepcopy(base_open)]}
    return result


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


def error_records(validator, payload):
    records = [{'keyword': str(error.validator), 'instancePath': list(error.absolute_path),
                'schemaPath': list(error.absolute_schema_path)} for error in validator.iter_errors(payload)]
    return sorted(records, key=lambda row: json.dumps(row, sort_keys=True))


def main():
    installed = version('jsonschema')
    check('pinned jsonschema version', installed == EXPECTED_VERSION)
    contracts = schemas()
    original_contracts = deepcopy(contracts)
    validators = {name: make_validator(schema) for name, schema in contracts.items()}
    names = list(validators)
    cases = [
        ('valid', {'document_id': 'D7', 'language': 'zh'}, [False, False, True, True, False, True]),
        ('old_id_only', {'document_id': 'D7'}, [False] * 6),
        ('only_language', {'language': 'zh'}, [False] * 6),
        ('bad_id_type', {'document_id': 7, 'language': 'zh'}, [False] * 6),
        ('unknown_language', {'document_id': 'D7', 'language': 'fr'}, [False] * 6),
        ('extra_field', {'document_id': 'D7', 'language': 'zh', 'debug': True}, [False, False, False, False, False, True]),
        ('null', None, [False] * 6),
        ('empty_id', {'document_id': '', 'language': 'zh'}, [False] * 6),
    ]
    print('jsonschema=' + installed)
    print('columns=' + ','.join(names))
    report = []
    for label, payload, expected in cases:
        before = deepcopy(payload)
        results = [validator.is_valid(payload) for validator in validators.values()]
        for name, got, want in zip(names, results, expected):
            check(label + ' / ' + name, got is want)
        check(label + ' input unchanged', payload == before)
        report.append({'case': label, 'payload': payload, 'accepted': dict(zip(names, results)),
                       'errors': {name: error_records(validator, payload) for name, validator in validators.items()}})
        print(label + ': ' + ','.join('T' if valid else 'F' for valid in results))

    valid_payload = {'document_id': 'D7', 'language': 'zh'}
    closed_errors = error_records(validators['closed_base'], valid_payload)
    outer_errors = error_records(validators['outer_only'], valid_payload)
    check('closed base rejects at its own keyword', any(
        e['keyword'] == 'additionalProperties' and e['schemaPath'] == ['allOf', 0, 'additionalProperties']
        for e in closed_errors))
    check('outer-only closure rejects at root', any(
        e['keyword'] == 'additionalProperties' and e['schemaPath'] == ['additionalProperties']
        for e in outer_errors))
    check('outer unevaluated does not override failing inner closure', any(
        e['keyword'] == 'additionalProperties' and e['schemaPath'] == ['allOf', 0, 'additionalProperties']
        for e in error_records(validators['closed_plus_unevaluated'], valid_payload)))

    # Independent truth table over a small, declared input domain.
    missing = object()
    count = 0
    for document_id, language, extra in product([missing, '', 'D7', 7], [missing, 'zh', 'en', 'fr', 7], [False, True]):
        payload = {}
        if document_id is not missing:
            payload['document_id'] = document_id
        if language is not missing:
            payload['language'] = language
        if extra:
            payload['debug'] = True
        fields_valid = type(document_id) is str and bool(document_id) and language in ('zh', 'en')
        expected_closed = bool(fields_valid and not extra)
        check('finite redeclared oracle ' + str(count), validators['redeclared'].is_valid(payload) == expected_closed)
        check('finite unevaluated oracle ' + str(count), validators['unevaluated'].is_valid(payload) == expected_closed)
        check('finite open oracle ' + str(count), validators['open_everywhere'].is_valid(payload) == bool(fields_valid))
        count += 1
    check('schema definitions unchanged', contracts == original_contracts)
    print(f'finite_objects={count}; repaired_contracts_match_oracle=True')
    print('outer_unevaluated_does_not_override_closed_base=True')
    print(f'PASS checks={len(checks)}')
    Path('report.json').write_text(json.dumps({
        'jsonschema': installed, 'scope': 'fixed local schemas for a fictional read-only lookup; no actual lookup',
        'schemas': contracts, 'columns': names, 'cases': report, 'finite_objects': count,
        'checks': len(checks), 'passed': 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 的六份本地组合合同、8输入×6合同及40个对象的有限枚举;未执行工具、远程引用或模型API。

运行后对照结果
jsonschema=4.26.0
columns=closed_base,outer_only,redeclared,unevaluated,closed_plus_unevaluated,open_everywhere
valid: F,F,T,T,F,T
old_id_only: F,F,F,F,F,F
only_language: F,F,F,F,F,F
bad_id_type: F,F,F,F,F,F
unknown_language: F,F,F,F,F,F
extra_field: F,F,F,F,F,T
null: F,F,F,F,F,F
empty_id: F,F,F,F,F,F
finite_objects=40; repaired_contracts_match_oracle=True
outer_unevaluated_does_not_override_closed_base=True
PASS checks=181

把合同组合与真实工具执行分开验收

组合前,明确基础片段是否计划被复用,以及哪一层负责最终限制字段。若基础规则本来就是完整且封闭的合同,应重新设计扩展方式,不能指望调用方的 allOf 自动把它变成可继承的对象类型。

Schema 通过只说明输入满足当前规则。资料存在、用户可读、语言支持与查询成功仍是后续业务条件。本篇不访问远程 Schema,不执行资料查询,也不检验任何模型产品原生工具接口支持哪些关键字。

移植时一起保存草案、依赖版本、完整输入和预期矩阵。增加字段后,既运行应该通过的例子,也运行额外字段、缺字段与错误类型的反例,才能发现“放宽到什么程度”和“拒绝从哪一层发出”。

记住这三点

  • allOf 要求同一输入满足所有子规则,不合并字段声明,也不覆写内部拒绝。
  • additionalProperties 只看同一 Schema 对象中的字段声明;移动它时要重新核对作用范围。
  • 开放基础规则后,可在外层重声明字段再关闭,或用支持草案中的 unevaluatedProperties 统一检查遗漏字段。

别把例子用过头

  • 把 allOf 当作面向对象继承或字段合并器。
  • 只把 additionalProperties 移到外层,却没有让外层认识基础字段。
  • 保留内层关闭,还期待外层 unevaluatedProperties 覆盖内部失败。
  • 为了让合法输入通过而完全开放字段,却没有检查反例。

自己试一小步

给资料工具再增加一种可选输出选项,怎样保持复用同时拒绝意外字段?

  1. 先画出基础片段与完整工具合同的职责,独立列出哪些字段必填、可选或禁止。
  2. 分别实现外层重声明与 unevaluatedProperties 两种方案,运行前写预期表。
  3. 加入缺字段、错误类型、非法枚举和额外字段,逐格保存结果与错误路径。
  4. 再构造一个仍保留关闭基础规则的错误版本,解释拒绝位置为什么改变。

做到这些,就算完成

  • 声明使用的草案与库版本,未把结果推断成所有模型接口都支持。
  • 两份修正版符合自己先写的字段表,允许和拒绝样例都覆盖。
  • 输入未被校验过程修改,结果没有包装成真实工具执行成功。

查原始资料

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