qinghuan
← 回到知识探索

AI 直觉 / 可下载小项目

助手只想改电话,为什么把营业时间清空了?

用一份虚构店铺资料对照未填写、null 和空文字,做出只包含本次修改意图的更新单,并逐项预览变化。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 没提到、删掉、留空,先分成三种意思
  4. 更新单写本次要改的内容
  5. 对象可以往里改,数组在这里整体替换
  6. 预览要同时展示位置、存在性和数值
  7. 留下原稿,才能比较变化
  8. 运行九张更新单,再回看每一项变化
  9. 真实接口可能另附一张“要改哪些栏”的清单
  10. 验收修改范围,也说明预览的时点
  11. 记住这三点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了
  • 能读懂 Python 字典、递归函数与 assert 断言
  • 理解 JSON 的对象、数组、null 和布尔值
  • 先读参数格式与业务规则,本篇处理通过检查后究竟改哪些字段

用户说“把电话改为 010-2000”,助手却交给工具一张所有栏位都齐全的表:电话填了新号码,没提到的营业时间填 null。工具按自己的更新规则执行,结果电话变了,营业时间也被删了。每个字段的格式都可能合法,问题发生在“没提到”被改写成了“请删除”。

这次做一个离线修改预览器。给它同一份虚构店铺资料和九张更新单,观察究竟改了什么。目标是把人的修改范围保留到执行入口;程序不识别自然语言,没有真实模型或商户接口,也不会修改你的店铺。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • sparse_update.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

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

python --version
python -B sparse_update.py

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。虚构店铺数据与本地 JSON Merge Patch 规则;不调用模型,不解释自然语言,也不修改真实商户。

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

只想改电话,实际会改变哪些字段?

这份更新单按 JSON Merge Patch 解释。先只改电话,再试着把没填写的营业时间补成 null,观察结果。

本次改变 1 个字段。phone 仍存在,值变为新号码。 hours 没有出现在更新单中,原营业时间保留。

修改前

{
  "name": "虚构茶铺",
  "phone": "010-1000",
  "hours": "09:00-18:00"
}

发送的更新单

{
  "phone": "010-2000"
}

按约定应用以后

{
  "name": "虚构茶铺",
  "phone": "010-2000",
  "hours": "09:00-18:00"
}
字段是否存在,与字段的值分开记录
字段修改前修改后有变化吗
phone"010-1000""010-2000"是
hours"09:00-18:00""09:00-18:00"否

这里仅在当前页面计算固定的虚构资料,没有发送请求或修改商户。null 的含义属于 JSON Merge Patch;不能据此猜测其他工具的更新规则。数组和根值替换等情况见正文与完整脚本。

没提到、删掉、留空,先分成三种意思

设想纸面表格有电话和营业时间两栏。用户只圈了电话,并不意味着要把另一栏擦掉。本题先约定三种意图:更新单里没有某个键,保留原值;键对应 null,删除这个成员;键对应空字符串,把这个成员的值改成空文字。后两种结果看起来都像“没号码”,但数据结构不同。

这套约定来自本例采用的 JSON Merge Patch。它不是所有更新接口的统一行为。接入一个真实工具前,应读清楚该接口如何表达删除与空值,再做映射;仅看到 HTTP 方法叫 PATCH,还不足以确定正文采用哪种格式。

更新单写本次要改的内容

phone_only 的更新单只有 phone。执行后,营业时间、联系邮箱、标签都保留。dangerous_defaults 只是额外塞进 hours: null,就多出一项删除。预览器不会知道这个 null 是用户明确要求,还是某层代码自动补齐;它只按收到的单子计算结果。

因此从对话整理字段时,要保留“用户没有指定”这一信息。不能先建立所有值为 null 的完整字典,再填入识别到的字段。完整资源用于描述现在有什么,部分更新用于描述这次改变什么,两者长得相似,承担的任务却不同。这里用手工构造的字典代替理解步骤,只验证后续变化。

可以给每次修改留一张小卡:用户明确提到哪个字段,要求设置还是删除,生成的更新单用了哪种表示。用户说“我不知道电话号码”并不自动等于“删除已有号码”;没有足够线索时,更新单不应自行扩大范围。这样最后的预览才有原始意图可对照,而不是只拿执行结果证明自己理解正确。

对象可以往里改,数组在这里整体替换

contact 里面还有 email 和 note。本例只提交 contact.note,新便条会覆盖旧便条,邮箱仍在。程序递归进入对象,是为了把修改范围推进到更小的位置;直接用新 contact 覆盖整个旧 contact,就会得到另一种结果。

tags 是数组。提交只含 tea 的新数组后,quiet 不再保留;这不是逐项删除命令,而是把整份列表换掉。数组的下标会随插入变化,本篇没有实现按元素身份合并。若业务希望追加一个标签,应选择明确支持该操作的工具,不能把“数组也会递归合并”当作默认规则。

手工跟一次递归:先复制整份门店资料,看到 contact 后拿出旧联系对象,再只处理更新单里的 note,最后把新联系对象放回原位置。email 没被列入更新单,所以没有进入删除或替换分支。若旧 contact 原本是文字,而新值是对象,则先从空对象开始,旧文字不会被神奇地转换成邮箱或便条。

预览要同时展示位置、存在性和数值

changes 会生成逐项变化账本。path 是一串键名,例如 contact、note,旁边各存修改前后的 present 与 value。删除电话时 after.present 为 false;改为空文字时则为 true。只输出 value 会让成员缺失与成员存在但为空混到一起,读者便无法检查真正的结果。

路径列表只是本地展示格式,不是 JSON Pointer,也不是可提交给服务的 JSON Patch。若输入 true,输出整数 1,Python 普通相等比较可能把它们看作相等;本例用 JSON 表示比较这组受控输入,确保类型变化进入预览。数值表示差异也可能被记录,它不是通用 JSON 语义等价判定器。

留下原稿,才能比较变化

merge_patch 返回独立结果,原资料与更新单都不原地修改。Python 的 copy 文档解释了赋值、浅复制和深复制的区别。本例的数据只有普通 JSON 值,使用 deepcopy 保留嵌套资料,让之后改动结果里的便条也不会悄悄改坏原稿。

空对象更新不会改变一个已有对象。把根更新值设为 null,则替换整个根结果;这与对象成员内的 null 删除语义不同。脚本故意保留 root_null 对照,提醒接入层决定是否允许整份替换。预览函数能计算一种变化,不代表业务应该允许它。

输出报告保留的是解析后的数据,没有承诺保留输入文本的空格、缩进或键排列。读者应比较字段含义与存在性,而不是把两份格式不同的 JSON 文字直接当成两次业务修改。对于电话这类文本,也不要为了看起来整齐而自行去掉前导零;显示格式与更新内容应分开决定。

运行九张更新单,再回看每一项变化

把完整代码保存为 sparse_update.py,在独立目录运行。只用 Python 标准库,无账号、无网络。脚本写出 sparse-update-report.json,里面保留每组原稿、更新单、结果与变化账本;重复运行会覆盖同名练习报告。

先预测电话删除和空电话各是什么结构,再检查 JSON。除九组主流程,断言还覆盖删除不存在的键、已有 null 的保留、根对象与数组的替换,以及嵌套副本互不影响。输出的 changed 数表示预览条目数,不能当作接口调用次数或真实服务器修改数量。

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

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

小实验 · python
"""Local JSON Merge Patch exercise; no HTTP, model, or real merchant changes."""
from copy import deepcopy
import json
from pathlib import Path


def same_json(left, right):
    return json.dumps(left, sort_keys=True, allow_nan=False) == json.dumps(
        right, sort_keys=True, allow_nan=False)


def merge_patch(target, patch):
    # Operates on already parsed JSON values. Object members with null are removed.
    if not isinstance(patch, dict):
        return deepcopy(patch)
    result = deepcopy(target) if isinstance(target, dict) else {}
    for key, value in patch.items():
        if value is None:
            result.pop(key, None)
        else:
            result[key] = merge_patch(result.get(key), value)
    return result


def snapshot(obj, key):
    return {"present": key in obj, "value": deepcopy(obj.get(key))}


def changes(before, after, path=()):
    """Readable leaf changes; path is a list of keys, NOT a JSON Pointer."""
    if isinstance(before, dict) and isinstance(after, dict):
        rows = []
        for key in sorted(before.keys() | after.keys()):
            old, new = snapshot(before, key), snapshot(after, key)
            if old["present"] and new["present"]:
                rows.extend(changes(old["value"], new["value"], path + (key,)))
            elif old != new:
                rows.append({"path": list(path + (key,)), "before": old, "after": new})
        return rows
    if same_json(before, after):
        return []
    return [{"path": list(path), "before": {"present": True, "value": deepcopy(before)},
             "after": {"present": True, "value": deepcopy(after)}}]


def run_case(name, before, patch):
    original, original_patch = deepcopy(before), deepcopy(patch)
    after = merge_patch(before, patch)
    assert before == original and patch == original_patch
    return {"case": name, "before": original, "patch": original_patch,
            "after": after, "changes": changes(before, after)}


def main():
    shop = {"name": "Fictional Tea", "phone": "010-1000", "hours": "09:00-18:00",
            "contact": {"email": "demo@example.invalid", "note": "upstairs"},
            "tags": ["tea", "quiet"], "closed": True}
    cases = [
        run_case("phone_only", shop, {"phone": "010-2000"}),
        run_case("remove_phone", shop, {"phone": None}),
        run_case("empty_phone", shop, {"phone": ""}),
        run_case("nested_note", shop, {"contact": {"note": "ground floor"}}),
        run_case("replace_tags", shop, {"tags": ["tea"]}),
        run_case("false_is_a_value", shop, {"closed": False}),
        run_case("empty_object", shop, {}),
        run_case("dangerous_defaults", shop, {"phone": "010-2000", "hours": None}),
        run_case("root_null", shop, None),
    ]
    by_name = {row["case"]: row for row in cases}
    phone = by_name["phone_only"]
    assert phone["after"]["hours"] == shop["hours"]
    assert [row["path"] for row in phone["changes"]] == [["phone"]]
    assert "phone" not in by_name["remove_phone"]["after"]
    assert by_name["empty_phone"]["after"]["phone"] == ""
    assert by_name["nested_note"]["after"]["contact"]["email"] == shop["contact"]["email"]
    assert by_name["replace_tags"]["after"]["tags"] == ["tea"]
    assert by_name["false_is_a_value"]["after"]["closed"] is False
    assert by_name["empty_object"]["changes"] == []
    assert "hours" not in by_name["dangerous_defaults"]["after"]
    assert by_name["root_null"]["after"] is None
    assert merge_patch({"x": None}, {}) == {"x": None}
    assert merge_patch({}, {"absent": None}) == {}
    assert merge_patch([1, 2], {"a": 1}) == {"a": 1}
    assert merge_patch({"a": 1}, [2, 3]) == [2, 3]
    assert len(changes({"x": True}, {"x": 1})) == 1
    cloned = merge_patch(shop, {})
    cloned["contact"]["note"] = "changed later"
    assert shop["contact"]["note"] == "upstairs"
    report = {"scope": "parsed JSON fixtures; no API adapter or concurrency control",
              "cases": cases, "assertions": "passed"}
    Path("sparse-update-report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    for row in cases:
        print(f"{row['case']}: changed={len(row['changes'])}")
    print("PASS: missing, null, empty, nested, array, false, root replacement, input isolation")


if __name__ == "__main__":
    main()

怎样运行:Python 3.10 或更新版本,在独立练习目录执行:python sparse_update.py。仅标准库。 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。虚构店铺数据与本地 JSON Merge Patch 规则;不调用模型,不解释自然语言,也不修改真实商户。

运行后对照结果
phone_only: changed=1
remove_phone: changed=1
empty_phone: changed=1
nested_note: changed=1
replace_tags: changed=1
false_is_a_value: changed=1
empty_object: changed=0
dangerous_defaults: changed=2
root_null: changed=1
PASS: missing, null, empty, nested, array, false, root replacement, input isolation

真实接口可能另附一张“要改哪些栏”的清单

Google AIP-134给出另一种设计:资源数据旁边附 update_mask,列出要更新的字段,并讨论全量替换的风险。这说明“值是什么”和“哪些值参与修改”可以分开表达;不能把这里的 null 删除规则直接套到字段掩码接口。

为真实工具准备适配器时,可以先把用户意图整理成“保留、设置、删除”三类,再按目标接口生成请求。生成后重新计算或读取预览,检查最终变化是否仍与意图一致。本项目没有实现字段掩码转换,也没有把某家服务的默认规则当成通用约定。

验收修改范围,也说明预览的时点

2026-10-15 已在独立临时目录执行脚本,全部内置断言通过,并生成九组报告;文章中的完整代码来自实际执行文件。读者能从 phone_only 的一条变化追到原始输入,也能看到补默认值为什么造成额外删除。

预览只针对传入的那份旧资料。真实服务在预览之后可能被其他人修改,还需要服务端版本条件、原子提交和结果核对。本篇没有完成这些能力,也没有校验电话号码、权限或任意不可信 JSON 输入;这些边界不能由一个本地 PASS 代替。

记住这三点

  • 未指定、删除与空文字是三种更新意图,不能都装成 null。
  • 按实际接口规则解释对象和数组,并用变化账本检查修改范围。
  • 离线预览证明固定输入的变化,不证明真实服务在提交时仍是同一版。

别把例子用过头

  • 把完整资源对象直接当成更新单,为未指定字段自动补空值。
  • 把所有 PATCH 接口都当成 JSON Merge Patch,或假定数组会逐项合并。
  • 只看输出值,不看字段是否存在;把预览成功当作真实写入完成。

自己试一小步

给虚构门店增加地址与配送区域后,怎样确保只修改用户明确指定的部分?

  1. 自行构造另一份资料及至少六张更新单,覆盖未指定、删除、空文字、嵌套对象与数组替换;运行前写出预期。
  2. 设计一份中间意图记录,区分保留、设置与删除,再把它转换为本题更新格式。
  3. 增加一次预览后资料改变的情境,说明原预览还能证明什么,以及提交端需要补什么检查。
  4. 交付脚本、变化账本和一段供用户确认的自然语言摘要;不要提交真实门店修改。

做到这些,就算完成

  • 摘要中的每一项变化能否回到更新单中的明确意图?
  • 缺失、null 和空文字是否仍可区分,未指定字段是否保留?
  • 是否分别说明数组行为、接口格式与预览时点?

查原始资料

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

  • RFC 7396:JSON Merge Patch (新标签页)↗IETF / RFC Editor · 英文

    核读第 1、2、3 节及附录 A;支持对象成员的删除、递归更新、数组及非对象整体替换规则。

    2026-10-15 已实际打开公开原始正文并核读;无需登录。本地实现操作已解析 JSON,没有 HTTP 服务。

  • AIP-134:部分更新与字段掩码 (新标签页)↗Google · 英文

    核读 Request message 与 PATCH and PUT;支持 update_mask 的字段范围设计及全量替换风险,未把它与 Merge Patch 混为同一格式。

    2026-10-15 已实际打开公开原始正文并核读;无需登录。未接入 Google API 或实现 FieldMask。

  • Python 3.10:浅复制与深复制 (新标签页)↗Python Software Foundation · 英文

    核读赋值绑定、浅复制和 deepcopy 的说明;支持本例保留嵌套输入副本。

    2026-10-15 已实际打开公开原始正文并核读;无需登录。资料为 Python 3.10 文档,项目仅使用兼容该版本的标准库语法。