LLM Agent 文件编辑实战(上):基于 Function Calling 的就地修改、CAS 乐观锁与流式截断自愈
让大模型像程序员一样精准就地修改文档并非易事。本文从生产级实践出发,复盘在标准 OpenAI Function Calling 架构下,如何设计多格式微编辑算子、实现多轮 CAS 乐观锁防覆盖、解决流式 JSON 参数截断,以及如何设计互斥安全降级 Schema。
在前面两篇《LLM Agent 的上下文管理实践:文件、图片与指令标记的协调》与《LLM Agent 的系统提示词工程:动态按需加载、自愈回执与反幻觉契约》中,我们系统讨论了如何让模型“高性价比地读取”长文档以及如何“低开销地调度”工具。
但在真实生产环境中,用户对 AI 的要求往往不仅是“看”和“问”,更是**“改”**:
- “把第 6 章里关于平台算法的那句话改掉,换成更新的观点。”
- “在数据表格第 12 行前插入一条 2024 年的新数据,不要动其他行。”
- “给 PPTX 的第 3 页换个标题,把第 5 页删掉。”
面对这类需求,最朴素的方案是调用
file_write全量重写。但在长文或复杂文档中,全量重写会带来灾难性的体验。本文将从生产级实践出发,深度复盘我们基于标准 OpenAI Function Calling 构建**细粒度就地文件编辑(In-place File Edit)**时的全套架构设计、硬核踩坑经历与核心技术突破。
一、 为什么不能“全量重写”?就地编辑的本质诉求
当一个文件达到 30 页 Markdown 报告(约 2 万字)、包含多 Sheet 的 Excel 表格或复杂的 PPTX 幻灯片时,要求模型“把整份文件重新输出一遍”会同时撞上几类工程风险:
flowchart TD
A[用户请求: 修改第6章的某一句话] --> B{处理策略选择}
B -->|策略 1: 全量覆写 file_write| C[生成整篇 2 万字文档]
B -->|策略 2: 就地微编辑 file_edit| D[生成精准补丁 Patch / Op]
C --> E1[Token 消耗巨大: 几万 Output Tokens]
C --> E2[延迟极高: 用户等待 30-60 秒]
C --> E3[格式退化: Markdown表格变形/LaTeX丢失]
C --> E4[幻觉风险: 未修改章节被模型随机篡改]
D --> F1[参数载荷显著缩小]
D --> F2[减少生成与传输延迟]
D --> F3[未命中区域不需要重新生成]
- 输出 Token 成本与模型物理上限:大模型生成输出受模型、网关和请求配置共同限制,具体上限并不统一。一份 2 万字的文件,全量重写更容易逼近输出上限并触发
finish_reason: "length",导致内容只生成到一半。 - 生成过程中的格式退化与幻觉漂移:大模型在长文本生成中,很难保证未修改部分逐字一致。复杂的表格、对齐的空格、精细的 LaTeX 公式、Office 内部 XML 结构,在全量重写时都可能变形甚至丢失。
- 版本控制与协作语义缺失:全量覆写无法产生有意义的
git diff,用户根本无法在 UI 上直观看到 AI 究竟改动了哪几个字。
因此,现代 Agent 必须支持类似 IDE 代码补丁机制的就地微编辑(In-place Patch / Mutate)。
二、 系统架构:多格式微编辑算子体系(Micro-ops Engine)
为了在服务端实现安全且精确的行级与结构级编辑,我们在后端(chat-api)与工具层构建了一套闭集的微操作算子体系:
graph TD
A[file_edit 工具调用请求] --> B{MIME 嗅探与格式分流}
B -->|纯文本 / Markdown / 代码| C[Text Mutate 引擎]
B -->|Excel .xlsx| D[XLSX OOXML 引擎]
B -->|PowerPoint .pptx| E[PPTX 幻灯片引擎]
B -->|Word .docx| F[DOCX 段落引擎]
C --> C1[replace_text / delete_text 短锚点替换]
C --> C2[insert_lines / replace_lines / delete_lines 行级补丁]
C --> C3[replace_lines_containing 条件批量过滤]
D --> D1[xlsx_set_cell / xlsx_set_cells 单元格值修改]
D --> D2[xlsx_append_rows / delete_rows / delete_cols 行列操作]
E --> E1[pptx_replace_on_slide 幻灯片文本替换]
E --> E2[pptx_append_slide 追加幻灯片]
F --> F1[docx_set_paragraph / docx_append_paragraph 段落更新]
2.1 文本与代码算子设计
- 短锚点查找替换(
replace_text):{ "op": "replace_text", "find": "旧文本唯一短锚点(10~40 字符)", "replace": "替换后的新文本" }find应尽量是来自最新读取结果的、短而唯一的原文锚点。长度不是协议层的魔法保证:过短会多匹配,过长会增加参数体积并放大复制误差。生产实现仍应以精确匹配和明确的多匹配拒绝为默认;如果提供空白归一化等容错,也必须返回实际命中的范围,不能把“相似”当成“确定相同”。 - 行号精确定位算子(
insert_lines/replace_lines/delete_lines): 支持基于 1-based 行号的插入与替换。针对replace_lines,引入了可选的expect_prefix校验参数——如果目标起始行的文本不以expect_prefix开头,说明模型数错了行号,立即安全拒绝并返回实际行内容,防止错位篡改。
2.2 Office 结构化 OOXML 算子设计
针对 Excel、Word、PPT,传统的文本替换会彻底破坏压缩包内的 XML 命名空间与样式层。
- XLSX 算子:直接解析
xl/worksheets/sheet*.xml与xl/sharedStrings.xml,支持xlsx_set_cell(指定单元格如B4设值)和xlsx_append_rows(保留公式和样式追加数据); - PPTX 算子:按幻灯片维度(
slide: 2)定位<a:t>文本节点并执行替换,绝不破坏版面与母版样式; - DOCX 算子:按段落索引(
index: 5)精准更新正文段落。
2.3 内存批处理与快照原子性
一个 file_edit 工具调用允许携带包含多个操作的 ops: [...] 数组。后端采用全事务内存应用机制:
- 先在内存 Buffer 中按顺序依次执行每一个 op;
- 如果中间任意一个 op 执行失败(如某处锚点没找到、行号越界、XML 损坏),整批操作全部中断,服务端不向磁盘写入任何字节;
- 只有全部 ops 在内存中全部成功应用后,才原子性写入临时文件、备份旧版本快照(Snapshot),并原子发布至正式文件存储。
三、 核心工程挑战与自愈优化实践
在看似完美的算子体系背后,将大模型(尤其是免费开源模型或弱推理模型)接入标准 OpenAI Function Calling 协议时,我们遇到了极其顽固的工程难题。以下是核心挑战与解决方案复盘。
timeline
title file_edit 核心问题攻坚演进
2026-08-10 : 初始版本上线 : 支持 ops 数组与全量算子
2026-08-15 : 遭遇参数截断 : 发现长 JSON 导致 finish_reason=length
2026-08-20 : 引入 Fence 协议 : 正文移入 Markdown, 参数只传指针
2026-08-23 : 修复并发冲突 : 建立 CAS 乐观锁与本地哨兵记账
2026-08-25 : 预算抢占与隔离 : 工具轮显式关闭 Reasoning (enable_thinking: false)
2026-08-27 : 自动续写与互斥Schema : Auto-Continue 与降级 Schema 纳入受限恢复路径
1. 致命瓶颈:流式 JSON 工具参数的物理截断(Truncation)
根因剖析
在 OpenAI 规范中,工具调用参数必须是一段合法的 JSON 字符串。而在流式响应(SSE)中:
当模型试图在单次调用中修改多个段落,或把几百字的新内容填入 replace 字段时:
- 结构化载荷有额外开销:工具参数必须同时承载字段名、数组结构、引号和转义后的换行/引号。UTF-8 中文不必然会被序列化成
\\uXXXX,但不同 SDK、网关和日志链路可能产生不同的转义与封装开销,不能用固定倍数估算; - 输出预算见底:模型的 reasoning、可见文本和工具参数通常共享本轮输出上限。具体上限由模型和网关决定,不能假设所有模型都是 4k、8k 或 16k;当长参数先消耗完额度时,可能得到
finish_reason: "length"; - 半截畸变参数:流式在输出到
{"file_id": "...", "ops": [{"op": "replace_text", "find": "xxx", "replace": "新章节一半...处戛然而止。此时 JSON 不完整,无法解析。
我们的优化组合拳:
graph TD
A[流式接收 tool_call 参数] --> B{JSON 是否完整闭合?}
B -->|是| C[正常送入执行器执行]
B -->|否| D[检测到参数截断 truncated: true]
D --> E[自愈手段 1: Auto-Continue 自动续写]
E -->|向后无感续写 JSON 片段| F{拼接后是否闭合?}
F -->|是| C
F -->|否| G[安全拦截 拒绝执行脏参数]
G --> H[自愈手段 2: 动态降级互斥 Schema]
H --> I[强制分离: Fence指针模式 vs 短锚点模式]
G --> J[自愈手段 3: 工具轮显式关闭 Reasoning]
J --> K[发送 enable_thinking: false 留足输出预算]
优化 1:截断检测与安全阻断(Fail-safe Gate)
在前端接收流的聚合器中使用 toolArgumentsAreComplete 严格校验 JSON 完整性。一旦判定 JSON 参数末尾残缺,坚决不送入底层执行器,将其标记为 truncated: true 并返回友好的自愈指引,彻底杜绝半截畸变数据破坏文件。
优化 2:工具轮显式关闭 Reasoning(enable_thinking: false)
许多具备推理能力的模型在工具调用前会输出长达数千 Token 的 Thought。我们发现,只要在前置思考阶段消耗了 70% 的预算,哪怕工具参数本身很短,也会在末尾被截断。
- 做法:进入工具调用轮次时,向网关显式发送
enable_thinking: false,彻底压制无意义的冗长推理,将全部输出预算留给工具参数;在最终完成文本回答轮次时再重新开启思考。
优化 3:Fence 协议(大正文从 JSON 中剥离)
对于超过 500 字符的大章节写入,要求模型绝不将长文本塞入工具参数,而是在正常的 Assistant 消息中以标准的 ````md 代码块输出,工具参数仅传递轻量指针:
{
"file_id": "file-02ea40ee",
"op": "append_lines",
"text_from_fence": true
}
工具执行器从约定的 Assistant 消息 fence 中提取正文,并在文件、操作类型、块编号和版本校验通过后注入。参数体积因此显著降低,但并不等于绝对不会失败:正文自身仍可能被截断、fence 可能未闭合,也可能和错误的文件或操作绑定。安全实现必须在 fence 未完整闭合时拒绝执行。
优化 4:流式截断自动续写拼接(Auto-Continue)
当流式输出由于 length 中止且留下半截 JSON 时,系统可以尝试一次受限的 continuation 子轮次(isContinuation: true),再将候选片段拼接并重新解析。这个机制是机会性恢复,不是协议保证:部分 OpenAI 兼容网关不接受非法 JSON 的历史 tool call,部分模型会重发完整参数而不是只输出尾部,多条并行 tool call 还需要按 call id 严格对应。续写失败时必须回到 fail-safe:不执行半截参数,返回可重试错误。
2. 状态冲突:并发编辑与版本覆盖(CAS 乐观锁)
场景回放
在多轮对话或复杂任务中,模型极易产生并发幻觉:
- 模型在同一轮回复中发出了两个独立的
file_edit调用(调用 A 改标题,调用 B 改正文),两者都携带了刚读取到的版本号expected_content_rev: 45; - 调用 A 率先到达后端写入成功,文件版本升至
46; - 调用 B 紧接着到达后端,由于拿着过期的版本号
45,直接触发409 CONTENT_REV_CONFLICT。
我们的三层防线:
sequenceDiagram
autonumber
actor User as 用户
participant Agent as 工具编排调度层 (Frontend)
participant Guard as 本地 CAS 记账哨兵
participant API as 后端存储引擎 (chat-api)
User->>Agent: 请求修改标题与正文
Note over Agent: 模型在同轮发出了 Edit #1 (rev: 45) 与 Edit #2 (rev: 45)
Agent->>Guard: 串行派发 Edit #1
Guard->>API: POST /mutate (expected_content_rev: 45)
API-->>Guard: ok: true, content_rev: 46
Guard->>Guard: 哨兵记账: file-xxx 当前最新 rev = 46
Agent->>Guard: 派发 Edit #2 (参数仍带 rev: 45)
Note over Guard: 检测到同轮中预期版本 45 < 46 (已过期)
Guard-->>Agent: 本地快速拒绝拦截 (不发往后端)<br/>返回最新 content_rev: 46 与自愈提示
Agent->>User: 引导模型在下一轮基于 rev: 46 重新发起精确编辑
- 同轮写操作强制串行化调度:调度器对针对文件的写操作(
file_edit/file_write)按顺序串行执行,杜绝底层并发竞态。 - 编排层本地 CAS 记账哨兵(Local CAS Tracker):
在单轮执行队列中维护一个
writtenRevisions状态机。当第 1 个编辑成功使文件升至版本46后,如果同轮内的第 2 个编辑仍拿着旧版本45,编排层在本地直接予以拦截,不再白白浪费一次网络请求打到后端报错,同时直接把最新版本46作为结构化回执返回给模型。 - 后端强制 CAS 校验契约(HTTP 428 / 409):
后端 API 彻底收口,任何未携带
expected_content_rev的请求坚决返回428 CONTENT_REV_REQUIRED,版本不一致坚决返回409 CONTENT_REV_CONFLICT,彻底封死任何无版本覆盖(Last-Write-Wins)的潜在漏洞。
3. 自愈死循环与互斥安全降级 Schema(Mutually-Exclusive Schema)
在早期版本中,当模型发生截断后,系统下发了截断安全提示,但弱模型在下一轮依然会产生混乱调用,例如:
{
"op": "replace_text",
"text_from_fence": true
}
这是因为原先的降级 Schema 是一个扁平对象,同时展示了 replace_text、find、replace 以及 text_from_fence。弱模型“看见字段就填”,导致短替换和 Fence 模式杂糅在一起。
我们的架构解法:oneOf 物理互斥 Schema
在首次发生截断后,系统动态切换降级 Schema,将其拆解为两个物理互斥的 oneOf 分支:
{
"oneOf": [
{
"description": "长内容 Fence 模式(必须配对 text_from_fence,无 replace 字段)",
"properties": {
"op": { "enum": ["append_lines", "insert_lines", "replace_lines"] },
"text_from_fence": { "enum": [true] }
},
"required": ["file_id", "op", "text_from_fence"]
},
{
"description": "短文本替换模式(必须配对 find/replace,物理移除 text_from_fence 字段)",
"properties": {
"op": { "enum": ["replace_text", "delete_text"] },
"find": { "type": "string" },
"replace": { "type": "string" }
},
"required": ["file_id", "op", "find"]
}
]
}
通过在 Schema 层面减少“短替换 + Fence 指针”共存的可能性,可以降低模型生成非法组合的概率;但 Schema 不是完整的运行时验证器,仍需由执行器检查文件类型、操作字段、fence 完整性、版本号和匹配结果。成功率应通过固定测试集和生产指标测量,不能从设计本身推出“近乎 100%”。
四、 从失败回执反推系统边界:一次真实编辑是怎样失败的
最有价值的结论不是“加了一条提示词之后成功了”,而是把一次失败拆成可定位的阶段。以一篇较长的 Markdown 文章为例,用户要求模型同时修改多个章节,模型生成了多个 file_edit 调用,其中至少一个调用把大段新正文放进了 ops[].replace。在流式传输中,工具参数停在字符串中间,最终形成了不完整的 JSON。
这条链路可以表示为:
用户要求多处大幅改写
→ 模型选择全量/大段 replace
→ reasoning、正文与工具参数竞争输出预算
→ SSE 收到不完整 arguments
→ JSON 解析失败
→ 安全门闸拒绝执行
→ 模型若继续使用旧策略,重复失败
这里有两个容易混淆的事实:
- 拒绝执行不是编辑器坏了。它证明安全门闸阻止了半截内容污染文件;
- 一次成功也不证明协议已经稳定。成功可能来自模型后来改用了短锚点、减少了操作数量,或仅仅是这次输出没有触碰预算边界。
因此,诊断日志和工具回执应至少区分:arguments_truncated、invalid_arguments、content_rev_conflict、zero_matches、unsupported_op、timeout 和真正的 ok: true。只有这样,用户才能知道应该重读文件、缩小补丁、换操作类型,还是等待后端恢复,而不是把所有问题都归因于“模型没听提示词”。
4.1 可复查的实验矩阵
文件编辑系统不应只用一次手工成功案例验收。一个最小回归矩阵可以固定以下维度:
| 场景 | 预期行为 | 需要观察的证据 |
|---|---|---|
| 单处短句替换 | 一次调用成功 | ok、旧/新 revision、diff 摘要 |
| 同一调用多个短 ops | 全部成功或全部失败 | 是否满足批次原子性 |
| 大段正文 inline | 拒绝或在未截断时受大小约束 | 不得写入半截正文 |
| 大段正文 fence | 完整闭合后执行 | fence 编号、文件 id、revision |
| 未闭合 fence | 不执行 | 明确的 incomplete-fence 错误 |
| 两个调用使用同一旧 revision | 后一个被 CAS 拒绝 | CONTENT_REV_CONFLICT |
| 文件已被网页预览修改 | 编辑器拒绝旧版本 | 要求重新 file_read |
| 多格式 Office 操作 | 进入对应结构化引擎 | MIME、op 类型、输出快照 |
SSE [DONE] 后无 TCP EOF | 正常收尾 | 不额外等待、不误报 timeout |
| 用户主动取消大文件读取 | 停止未开始任务 | abort 信号和清理结果 |
这类测试的价值在于把“模型表现”与“系统不变量”分开。模型可能选错锚点,但系统仍应保证:错误不会写入;版本不会倒退;失败原因可以复查;重试有明确前置条件。
4.2 失败重试不能变成无界循环
错误回执可以帮助模型自愈,但自愈本身也必须有边界。比较稳妥的策略是:
- 第一次截断:尝试一次 continuation,前提是网关接受该上下文格式;
- continuation 仍不完整,或网关拒绝半截历史消息:立即结束本次工具执行;
- 返回结构化错误,保留文件 id、已知 revision 和推荐的下一步;
- 下一轮由模型重新读取文件,再选择短锚点或完整 fence,而不是无限拼接残片。
不能把“自动续写”当作自动猜测缺失正文。系统没有生成出来的内容,就不应该替模型补全,更不能把截断字符串修剪成看似合法的 JSON 后直接执行。
五、 Function Calling 与 Markdown Diff:两种协议不是二选一
标准工具调用和 Markdown Diff 解决的是不同问题。前者擅长结构化能力、权限边界和后端事务;后者擅长承载大段自然语言内容,并让用户直接看到补丁。更合理的方向是按载荷类型分流,而不是把一种协议强行替换成另一种。
| 需求 | Function Calling | Markdown Diff / Search-Replace |
|---|---|---|
| 修改一个单元格或一页幻灯片 | 很适合:字段结构清晰 | 没必要引入文本解析 |
| 短句精确替换 | 适合:find / replace 很小 | 可以做,但收益有限 |
| 写入大段 Markdown/代码 | JSON 参数容易变长 | 更适合承载正文 |
| 需要 Office 专用算子 | 更适合:schema 可表达类型 | 需要额外映射层 |
| 权限、文件 id、版本校验 | 天然适合结构化字段 | 必须从块头解析并校验 |
| 流式展示修改内容 | 工具卡片通常较黑盒 | 正文天然可展示 diff |
| 跨网关兼容性 | 依赖 tool calling 支持 | 依赖自定义解析协议 |
5.1 Markdown Diff 的最小协议
未来可以新增独立的文本协议,不立即替换现有 file_edit:
```edit:file-xxx rev:12
<<<<<<< SEARCH
旧文本
=======
新文本
>>>>>>> REPLACE
```
解析器必须把它当作一个有边界的协议,而不是“看到几行文本就尝试修改文件”。至少需要:
- 校验
file_id是否属于当前会话且仍可编辑; - 校验
rev,拒绝缺失或过期版本; - 要求
SEARCH、分隔线和REPLACE完整闭合; - 要求搜索内容唯一,零匹配和多匹配都不自动猜;
- 在真正写入前生成 diff 预览;
- 同一文件的多个 block 串行执行,每个 block 使用最新 revision;
- 流被截断时只展示未完成 block,不执行它;
- 明确选择批次原子性,避免用户误以为“前两个已写入、第三个必然也会写入”。
这里尤其不能直接采用“每闭合一个 block 就立即落盘”的实现。它看起来能绕开 JSON 截断,却会引入新的部分成功语义:后面的 block 可能匹配失败、版本冲突或连接中断。第一版更适合先完整解析整批 block,做 dry-run,再一次性提交;等 UI 和恢复语义稳定后,再考虑显式的逐块提交模式。
5.2 混合路由的建议
可以把路由规则收敛为三类,而不是继续堆 Prompt 个案:
- 短文本局部修改:
file_read获取最新内容,调用replace_text或行级 op; - 大段文本写入:正文走 Markdown/fence,工具或解析器只携带文件、位置和 revision;
- Office 与结构化操作:继续使用
xlsx_*、pptx_*、docx_*等专用 Function Calling。
这些规则应主要由工具 schema、文件 MIME、操作字段和运行时验证共同完成。Prompt 只需要说明选择原则,不应把每一个历史失败句子都永久写进去。
六、 当前方案的收获与仍然存在的边界
这次实践最重要的收获不是找到一个“永不失败”的提示词,而是把不可靠的生成行为包在可靠的执行边界里:
- 模型可以犯错,但截断参数不能执行;
- 模型可以重复使用旧 revision,但后端不能静默覆盖;
- 模型可以选择不合适的 op,但文件类型和执行器必须再次校验;
- 网络可以在
[DONE]后关闭,也不应把已经完成的响应误报成失败; - 大文件可以耗时,但取消、超时和子进程清理必须有明确结果。
仍然需要持续观测的指标包括:工具参数截断率、continuation 成功率、ZERO_MATCHES 比例、CAS 冲突率、单文件读取耗时、PDF 解析超时率、用户取消率,以及不同模型在短编辑和长编辑上的成功率。没有这些数据,“更稳定”只能是主观感受。
七、 总结:把不可靠的生成包在可验证的执行边界里
这次实践没有证明“某个 Prompt 可以让模型永不出错”。更实际的结论是:模型负责提出编辑意图,工具编排层和文件服务负责验证、排序、提交和回执。两者的职责必须分开。
graph LR
subgraph 生成层
A[模型选择编辑意图] --> B[Function Calling 或 Markdown Diff]
end
subgraph 编排层
B --> C[参数完整性与文件权限校验]
C --> D[按文件串行调度]
D --> E[CAS revision 校验]
end
subgraph 存储层
E --> F[内存批处理]
F --> G[原子写入与新 revision]
end
当前 Function Calling 方案已经适合承担:
- 短文本和代码的精确替换、行级编辑;
- XLSX 单元格/行列、PPTX 幻灯片、DOCX 段落等结构化操作;
- 文件 id、操作类型、权限和
expected_content_rev等必须结构化传输的字段; - 需要后端事务、失败回滚和明确工具回执的编辑任务。
它的边界也同样明确:大段自然语言正文一旦直接放进 ops[].replace,就会让 JSON 结构、转义、reasoning 和模型输出上限共同成为风险源。enable_thinking: false、截断检测、互斥 Schema 和有限 continuation 能降低风险,但不能改变这个物理事实。
Markdown Diff 因而不是“替代一切工具调用”的银弹,而是大正文的候选承载协议。它可能减少结构化参数的开销,并改善可见 diff 体验;同时也必须重新解决文件权限、版本校验、唯一匹配、批次事务、截断恢复和协议注入等问题。第一版应作为独立能力灰度验证,保留现有 file_edit 作为结构化操作和回退路径。
一份可执行的演进路线
- 先建立基准:记录不同模型、文件类型和补丁大小下的截断率、匹配失败率、CAS 冲突率、P95 延迟和最终成功率;
- 实现离线 Markdown Diff parser:只解析,不写盘,覆盖闭合、不闭合、重复匹配、特殊字符和多文件 block;
- 接入 dry-run 与 diff 预览:让用户或编排层在提交前看到将修改的范围;
- 接入单次 CAS 提交:整个 block 集合校验成功后再写入,先不做“看到一个闭合块就立即落盘”;
- 小流量 A/B:短编辑继续走 Function Calling,大正文在满足条件时走 Diff,比较真实指标;
- 根据数据决定是否增加逐块提交:只有明确需要长任务恢复时,才引入带事务 id、幂等键和部分成功回执的复杂状态机。
最终值得复用的不是某个字段名或某条提示词,而是一组工程原则:小载荷优先、能力按类型分流、失败不写入、写入必须带版本、同一文件串行、所有成功都以真实回执为准。这组原则既适用于今天的 Function Calling,也适用于未来的 Markdown Diff 或其他编辑协议。它们不能消除模型和网络的不确定性,但能把不确定性限制在可观察、可拒绝、可恢复的边界内。