BLOG · #Engineering

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

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

English version: Wiring external signals into the loop: compile-and-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 篇):\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 报错的锚点行);与上一轮相同即判定无进展,立即停止、保留当前稿:

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 篇工具循环熔断是同一立场。

最小修复上下文:约 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> 划分后各写各的。教训可以一般化:验证器是有副作用的进程,不是纯函数——接外部验证器进闭环时,隔离是默认项而非优化项。

全景与量化

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 上限拦不住 CPUCOMPILE_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——商业产品的最小修复上下文同构做法(产品页信息,未独立核验)。