# 把外部信号接进闭环：编译自修复

> 让生成的 LaTeX 过真实编译器，把报错喂回模型修复——在错误难自检的任务上，外部验证信号是自我反思无法替代的。本文拆解这个闭环的五个工程约束：两轮上限、报错指纹判无进展即停、墙钟预算兜底、约 8k token 的最小修复上下文、每会话独立编译目录，并说明什么时候不该用这个闭环。

- Canonical (HTML): https://kaguc.com/blog/compile-self-repair-zh/
- Date: 2026-07-29


*English version: [Wiring external signals into the loop: compile-and-repair](/blog/compile-self-repair/)。“LLM 应用工程”系列第 6 篇。*

## 问题：模型修不好自己看不见的错

场景：自主 agent 生成一篇 LaTeX 实验报告，xelatex 编译失败。谁来修？

第一反应往往是让模型“再检查一遍”。但这条路有明确的负面证据：Self-Refine（arXiv:2303.17651）的结论之一是，**在错误难以自检的任务上，纯自我反思几乎无效，接入外部校验信号后收益才回升**；TACL 2024 的系统复核（*When Can LLMs Actually Correct Their Own Mistakes*）更直接——仅靠提示模型自评，在算术与代码类任务上不提升甚至降分，文献里好看的自纠数字多半偷用了 oracle（已知正确答案才停）。编译错误正是“难自检”的典型：模型重读自己刚写的 `\def` 行，看不出花括号内的 `%` 会把闭合括号注释掉——但 xelatex 看得出，且报错不可伪造。

所以正确的问法不是“怎么让模型自查”，而是“怎么把一个客观的外部验证器接进生成闭环”。本文拆解我们在一个 AI 写作 agent 里落地的编译自修复环：**生成后真编译，失败就抽取报错喂回模型修一轮**——以及让这个看似简单的循环在生产里不失控的几个工程约束。

## 从“打补丁”到“真编译”

在接编译器之前，系统已有一层确定性 sanitizer（[系列第 3 篇](/blog/output-sanitizing-zh/)）：`\mathbf`→`\symbf`、`\def` 行内 `%` 注释、模板变量漏设回填……这些是能用规则写死的已知坑。但补丁枚举不完长尾。当时 devlog 里这一步的目标写得很清楚：“不靠 sanitizer 打补丁，而让 agent 生成报告后真编译，失败就把 xelatex 报错喂回去自己改一轮，把一次成功率拉满。”

两层分工由此定型：**规则治已知坑（零 token），编译器兜长尾（每轮一次 LLM 调用）**。实践中 sanitizer 已治掉常见坑，首轮编译检查通常直接通过，repair 分支只兜剩余情况。闭环本体三步：

1. `compile_tex`：拼出完整工作目录（article.tex + cls + bib + 图），跑 xelatex → bibtex → xelatex ×2，返回 `{ok, pdf_path, log}`；
2. `extract_errors`：从 log 抽取以 `!` 开头的错误行、`l.` 行号行、Runaway / Emergency stop 行，上限 1800 字——不回喂整本 log；
3. 失败则把报错节选喂回模型，要求输出修订后的完整源码，最多 2 轮。

轮数上限取 2 不是拍脑袋：调研（*How Many Tries*，arXiv:2604.10508）跨 7 个模型的结论是前两轮拿走 76–95% 的可达收益，第三轮起近乎零增益；Self-Refine 也在第 3 轮 plateau。多留轮数只会放大墙钟与 token 支出。另一处细节：第 2 轮修复的产物不再回环验证——保留修复稿，最终验证交给用户侧的手动编译/导出出口。

一个反直觉的设计点：修复走的是**整篇重写**而不是第 7 篇那样的作用域 patch。原因在 LaTeX 报错的特性——报错常与根因脱节、行号不可信（*LaTeX Compilation Challenges*，arXiv:2603.02873），按报错行号定点修复容易修错位置。作用域编辑的前提是“能确定性定位”，编译错误恰恰不满足。

## 终止条件：指纹、轮数、墙钟

自修复环最大的生产风险不是修不好，而是**停不下来**。循环治理的共识是“何时停”不能交给模型自觉，要由外层 controller 用确定性判据硬卡。这里有三道闸：

**无进展闸。**每轮失败后对报错取指纹——首条 `!` 错误行的前 120 字（LaTeX 报错的锚点行）；与上一轮相同即判定无进展，立即停止、保留当前稿：

```python
def _error_fingerprint(errs: str) -> str:
    """编译报错指纹：取首条 '! …' 错误行(LaTeX 报错锚点)，无则取首 120 字。"""
    for line in (errs or "").splitlines():
        s = line.strip()
        if s.startswith("!"):
            return s[:120]
    return (errs or "").strip()[:120]
```

同一个错误修了一轮还在，说明该模型对该错没有办法；再喂一遍同样的报错，大概率得到同样的失败，继续烧 token 没有期望收益。

**轮数闸。**`for attempt in range(2)`，硬上限。

**墙钟闸。**单次编译最多 4 趟子进程、每趟 180 秒超时——token 预算拦不住 CPU 时间。`COMPILE_WALL_BUDGET`（默认 600 秒）在每轮开始前检查，到点即停、保留当前稿；配置注释写明动机：防自修复多轮把客户机 CPU 占太久。

三道闸全部是确定性判据、全部由 Python 侧执行，模型对“停不停”没有发言权——与[第 4 篇](/blog/agent-loop-zh/)工具循环熔断是同一立场。

## 最小修复上下文：约 8k，而不是 4–6 万

修复调用的上下文构造是这个闭环里最省钱的决定。直觉做法是沿用 agent 探索全程的 messages——模型“知道得越多修得越好”？实际相反。代码注释里的判断是：**编译错误 95% 是模板级语法问题**（配平、命令误用、模板变量），修它只需要三样东西：

- 当前稿全文；
- 报错节选（≤1800 字）；
- 格式铁律（`\def` 花括号内不用 `%` 注释；矢量加粗用 `\symbf`；重音内不加粗；配平花括号/环境/`$`；结尾有顶层 `\end{document}`）。

合计约 8k token。沿用探索全程 messages 则要背 4–6 万 token 死重——素材、工具调用记录、多版草稿，对修一个花括号毫无贡献；切到全新最小上下文后每轮修复省 3–5 万 token。system prompt 同时把职责收窄成一句话：

> 你是 LaTeX 编译修复助手：只修复编译错误，不改写内容、不增删章节、不动数据。

这不只是省钱：*Revisit Self-Debugging*（arXiv:2501.12793）的实测是 label-only 反馈胜过冗长详细反馈——不准确的长上下文引入歧义反而降分。商业产品同构：Overleaf 商业版的 Error Assist 也刻意最小化修复上下文，只发完整报错、相关代码行与文件名列表（产品页信息，未独立核验）。

## 并发隔离：每会话独立编译目录

一个朴素但踩过才知道的约束：编译工作目录必须按会话隔离。`compile_tex` 会向目录写 article.tex、拷贝 cls 与图片，拼装时还会 rmtree 重建图片子目录——两个并发的生成任务共用一个目录，就会互相覆盖源文件、删掉对方的图、污染对方的日志。修复目录按 `_repair/<session_id>` 划分后各写各的。教训可以一般化：**验证器是有副作用的进程，不是纯函数**——接外部验证器进闭环时，隔离是默认项而非优化项。

## 全景与量化

```mermaid
flowchart TD
    A[定稿] --> W{超出墙钟预算?}
    W -->|是| S[停止，保留当前稿]
    W -->|否| C[compile_tex 真编译]
    C -->|通过| P[出 PDF]
    C -->|失败| E[extract_errors 抽 ! 错误行]
    E --> F{指纹与上轮相同?}
    F -->|相同=无进展| S
    F -->|不同| R[最小上下文修复调用 ≈8k token]
    R --> N{已满 2 轮?}
    N -->|是| S
    N -->|否| W
```

| 约束 | 缺失时的失效模式 | 实现与数值 |
|---|---|---|
| 轮数闸 | 多轮死磕，第 3 轮起近零收益 | K=2；前两轮拿走 76–95% 可达收益（调研值） |
| 无进展闸 | 同一报错反复喂回、原地打转 | 首条 `!` 行前 120 字做指纹，连续相同即停 |
| 墙钟闸 | 单次编译 4 趟×180s，token 上限拦不住 CPU | `COMPILE_WALL_BUDGET` 默认 600s，到点保留当前稿 |
| 最小上下文 | 背探索全程 4–6 万 token 死重 | 当前稿+报错节选+铁律 ≈8k；每轮省 3–5 万 token |
| 会话隔离 | 并发任务互相覆盖源文件/图/日志 | `_repair/<session_id>` 独立工作目录 |

## 适用边界

1. **前提是存在便宜、客观、不可伪造的验证器。**编译器、类型检查、单测是；“写得好不好”不是。停止条件只能锚在“编译成功”这类客观信号上，绝不能用模型自评当停止条件——没有验证器的质量维度属于评测与评委（第 8、9 篇），不属于这个闭环。
2. **确定性修复优先。**能写成规则的坑（已知语法陷阱、缺图占位、变量回填）应先由零 token 的 sanitizer 治掉，LLM 修复环只兜规则枚举不了的长尾。顺序颠倒等于拿概率部件干确定性的活：每次都付调用成本，还引入新的不确定性。
3. **弱模型需要非 LLM 出口。**调研数据显示弱模型对死结类错误的修复率仅约 30%（未独立核验）。闭环必须配一个不依赖模型的兜底出口——这里是 export_zip 导出可编译包，交给 Overleaf 或人工。没有出口的闭环在弱模型上会退化成“烧完预算再放弃”。
4. **验证器贵时，预算闸先于轮数闸。**这里单次编译墙钟上限 4 趟×180 秒（至多 720 秒），K=2 尚可承受；若验证器是十分钟级的集成测试套件，同样的 K=2 也可能不可接受——此时墙钟预算要成为第一道闸，并允许“一轮都不修直接放弃”。

## 参考

- *Self-Refine: Iterative Refinement with Self-Feedback*（arXiv:2303.17651）——自我反思在错误难自检的任务上几乎无效，接入外部信号才回升；收益集中于前 1–2 轮（其“平均 +20%”的量化数字未独立核验）。
- *When Can LLMs Actually Correct Their Own Mistakes*（TACL 2024）——仅靠提示自评在算术/代码上不提升甚至降分；好看的自纠结果多依赖 oracle。
- *How Many Tries*（arXiv:2604.10508）——前两轮拿走 76–95% 可达收益，第 3 轮起近零。
- *LaTeX Compilation Challenges*（arXiv:2603.02873）——LaTeX 报错常与根因脱节、行号不可信。
- *Revisit Self-Debugging*（arXiv:2501.12793）——label-only 反馈胜过冗长反馈。
- Overleaf Error Assist——商业产品的最小修复上下文同构做法（产品页信息，未独立核验）。

