· LLM Agent 工程实践 · 第 4 / 4 讲

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[未命中区域不需要重新生成]
  1. 输出 Token 成本与模型物理上限:大模型生成输出受模型、网关和请求配置共同限制,具体上限并不统一。一份 2 万字的文件,全量重写更容易逼近输出上限并触发 finish_reason: "length",导致内容只生成到一半。
  2. 生成过程中的格式退化与幻觉漂移:大模型在长文本生成中,很难保证未修改部分逐字一致。复杂的表格、对齐的空格、精细的 LaTeX 公式、Office 内部 XML 结构,在全量重写时都可能变形甚至丢失。
  3. 版本控制与协作语义缺失:全量覆写无法产生有意义的 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 文本与代码算子设计

2.2 Office 结构化 OOXML 算子设计

针对 Excel、Word、PPT,传统的文本替换会彻底破坏压缩包内的 XML 命名空间与样式层。

2.3 内存批处理与快照原子性

一个 file_edit 工具调用允许携带包含多个操作的 ops: [...] 数组。后端采用全事务内存应用机制

  1. 先在内存 Buffer 中按顺序依次执行每一个 op;
  2. 如果中间任意一个 op 执行失败(如某处锚点没找到、行号越界、XML 损坏),整批操作全部中断,服务端不向磁盘写入任何字节
  3. 只有全部 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)中: 单轮输出总 Token 消耗=思考过程 (Thought)+可见文本+工具调用 JSON 参数\text{单轮输出总 Token 消耗} = \text{思考过程 (Thought)} + \text{可见文本} + \text{工具调用 JSON 参数}

当模型试图在单次调用中修改多个段落,或把几百字的新内容填入 replace 字段时:

  1. 结构化载荷有额外开销:工具参数必须同时承载字段名、数组结构、引号和转义后的换行/引号。UTF-8 中文不必然会被序列化成 \\uXXXX,但不同 SDK、网关和日志链路可能产生不同的转义与封装开销,不能用固定倍数估算;
  2. 输出预算见底:模型的 reasoning、可见文本和工具参数通常共享本轮输出上限。具体上限由模型和网关决定,不能假设所有模型都是 4k、8k 或 16k;当长参数先消耗完额度时,可能得到 finish_reason: "length"
  3. 半截畸变参数:流式在输出到 {"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% 的预算,哪怕工具参数本身很短,也会在末尾被截断

优化 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 乐观锁)

场景回放

在多轮对话或复杂任务中,模型极易产生并发幻觉:

我们的三层防线:

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 重新发起精确编辑
  1. 同轮写操作强制串行化调度:调度器对针对文件的写操作(file_edit / file_write)按顺序串行执行,杜绝底层并发竞态。
  2. 编排层本地 CAS 记账哨兵(Local CAS Tracker): 在单轮执行队列中维护一个 writtenRevisions 状态机。当第 1 个编辑成功使文件升至版本 46 后,如果同轮内的第 2 个编辑仍拿着旧版本 45编排层在本地直接予以拦截,不再白白浪费一次网络请求打到后端报错,同时直接把最新版本 46 作为结构化回执返回给模型。
  3. 后端强制 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_textfindreplace 以及 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_truncatedinvalid_argumentscontent_rev_conflictzero_matchesunsupported_optimeout 和真正的 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 失败重试不能变成无界循环

错误回执可以帮助模型自愈,但自愈本身也必须有边界。比较稳妥的策略是:

  1. 第一次截断:尝试一次 continuation,前提是网关接受该上下文格式;
  2. continuation 仍不完整,或网关拒绝半截历史消息:立即结束本次工具执行;
  3. 返回结构化错误,保留文件 id、已知 revision 和推荐的下一步;
  4. 下一轮由模型重新读取文件,再选择短锚点或完整 fence,而不是无限拼接残片。

不能把“自动续写”当作自动猜测缺失正文。系统没有生成出来的内容,就不应该替模型补全,更不能把截断字符串修剪成看似合法的 JSON 后直接执行。


五、 Function Calling 与 Markdown Diff:两种协议不是二选一

标准工具调用和 Markdown Diff 解决的是不同问题。前者擅长结构化能力、权限边界和后端事务;后者擅长承载大段自然语言内容,并让用户直接看到补丁。更合理的方向是按载荷类型分流,而不是把一种协议强行替换成另一种

需求Function CallingMarkdown 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
```

解析器必须把它当作一个有边界的协议,而不是“看到几行文本就尝试修改文件”。至少需要:

这里尤其不能直接采用“每闭合一个 block 就立即落盘”的实现。它看起来能绕开 JSON 截断,却会引入新的部分成功语义:后面的 block 可能匹配失败、版本冲突或连接中断。第一版更适合先完整解析整批 block,做 dry-run,再一次性提交;等 UI 和恢复语义稳定后,再考虑显式的逐块提交模式。

5.2 混合路由的建议

可以把路由规则收敛为三类,而不是继续堆 Prompt 个案:

  1. 短文本局部修改file_read 获取最新内容,调用 replace_text 或行级 op;
  2. 大段文本写入:正文走 Markdown/fence,工具或解析器只携带文件、位置和 revision;
  3. Office 与结构化操作:继续使用 xlsx_*pptx_*docx_* 等专用 Function Calling。

这些规则应主要由工具 schema、文件 MIME、操作字段和运行时验证共同完成。Prompt 只需要说明选择原则,不应把每一个历史失败句子都永久写进去。


六、 当前方案的收获与仍然存在的边界

这次实践最重要的收获不是找到一个“永不失败”的提示词,而是把不可靠的生成行为包在可靠的执行边界里:

仍然需要持续观测的指标包括:工具参数截断率、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 方案已经适合承担:

它的边界也同样明确:大段自然语言正文一旦直接放进 ops[].replace,就会让 JSON 结构、转义、reasoning 和模型输出上限共同成为风险源。enable_thinking: false、截断检测、互斥 Schema 和有限 continuation 能降低风险,但不能改变这个物理事实。

Markdown Diff 因而不是“替代一切工具调用”的银弹,而是大正文的候选承载协议。它可能减少结构化参数的开销,并改善可见 diff 体验;同时也必须重新解决文件权限、版本校验、唯一匹配、批次事务、截断恢复和协议注入等问题。第一版应作为独立能力灰度验证,保留现有 file_edit 作为结构化操作和回退路径。

一份可执行的演进路线

  1. 先建立基准:记录不同模型、文件类型和补丁大小下的截断率、匹配失败率、CAS 冲突率、P95 延迟和最终成功率;
  2. 实现离线 Markdown Diff parser:只解析,不写盘,覆盖闭合、不闭合、重复匹配、特殊字符和多文件 block;
  3. 接入 dry-run 与 diff 预览:让用户或编排层在提交前看到将修改的范围;
  4. 接入单次 CAS 提交:整个 block 集合校验成功后再写入,先不做“看到一个闭合块就立即落盘”;
  5. 小流量 A/B:短编辑继续走 Function Calling,大正文在满足条件时走 Diff,比较真实指标;
  6. 根据数据决定是否增加逐块提交:只有明确需要长任务恢复时,才引入带事务 id、幂等键和部分成功回执的复杂状态机。

最终值得复用的不是某个字段名或某条提示词,而是一组工程原则:小载荷优先、能力按类型分流、失败不写入、写入必须带版本、同一文件串行、所有成功都以真实回执为准。这组原则既适用于今天的 Function Calling,也适用于未来的 Markdown Diff 或其他编辑协议。它们不能消除模型和网络的不确定性,但能把不确定性限制在可观察、可拒绝、可恢复的边界内。