BLOG · #Engineering

让模型输出可用:清洗与自动修复

模型返回的 LaTeX“看起来完全正确”,xelatex 却报 no legal \end found。本文以 LLM 生成 LaTeX 的真实排错链为例,拆解模型输出与可用产物之间必需的确定性清洗层:片段清洗、编译前坑目录改写、缺图占位、注入剥除与报错人话化,给出实测收益(Extended mathchar 13→0 等)与这层该做与不该做的边界。

English version: Making model output usable: sanitizing and automatic repair。“LLM 应用工程”系列第 3 篇。本文的实证基座是一个生成 LaTeX 实验报告的生产级 AI 写作 agent,文中所有报错与数字来自其源码与真实排错记录。

问题:“看起来对”与“能用”之间

你让模型生成一篇 LaTeX 实验报告。返回的源码结构完整、命令拼写无误,肉眼看不出毛病;扔给 xelatex,报错是 no legal \end found——可文件末尾明明有 \end{document}。真因藏在文件头:模型写了 \def\partnerID{% TODO...}\def 行内的 % 把闭合 } 注释掉了,runaway definition 一路吞掉 \begin{document} 之后的全部内容。报错位置与病因位置相距整篇。

这是刚开始做 LLM 应用时最典型的一类落差:模型输出与可用产物不是同一个东西。以“生成 LaTeX → 编出 PDF”为例,中间至少隔着三层失败:

  1. 不可编译:语法看似正确,但踩中引擎与宏包组合的边角行为(上面的 %,下文的 \mathbf);
  2. 可编译但不可用:编译“通过”,产物却是 0 页,或缺图、引用全是 [?];
  3. 可用但不安全-shell-escape 下,正文里一条 \write18 就是任意命令执行。

常见的第一反应是改 prompt 或让模型重试。我们的实测结论:prompt 约束能降低发生率但压不住(源码注释里的原话是“模型常漏、prompt 压不住”);重试要再付一次生成成本,且不保证收敛。另一个反直觉的结论来自一次对三类生成质量问题的归因:其中 2/3 的根因在我们自己的后端代码与模板,而不在模型。正确的位置是在模型输出与产物之间放一层确定性的清洗与修复——零 token、可单测、行为可预期。这也是本系列第 11 篇确定性边界原则在输出侧的具体化:片段必经确定性关卡才能进入系统状态。

入口清洗:strip_fragment 与 check_fragment

第一道关卡在片段进入文档之前,处理与领域无关的形状问题:

def strip_fragment(text: str) -> str:
    """清洗模型返回的节片段:去 Markdown 代码围栏;若误带整篇文档,切出从首个标题起、
    到 \end{document} 前的部分(防止把 preamble / 顶层 end 灌进正文)。"""

模型爱把代码包进 Markdown 围栏;被要求“只返回这一节”时也可能违规返回整篇。前者剥壳;后者从首个 \section 系命令切到 \end{document} 之前。配套的 check_fragment 做体检:花括号配平、\begin/\end 配平、无 \documentclass、非空——全部是软信号,提示而不硬拦,因为体检规则自身也可能误报。

坑目录:sanitize_tex 的确定性改写

第二道在编译之前,是领域专属的坑目录。每一条都来自真实排错链——修掉一个才露出下一个:

症状(真实报错)真因确定性修复
no legal \end found(文末有 \end{document}\def 行内 {% 注释掉闭合 },runaway definition 吞掉后文\def{%{}%,不动 \abstract{% 跨行写法
\textfont 11/12 undefined\mathbf);Extended mathchar used as mathchar\boldsymbol二者与 xelatex+unicode-math 组合都冲突统一改写为 unicode-math 的 \symbf
Extended mathchar / A number should have been here重音内粗体(\hat{\boldsymbol{x}}去粗体保留重音,两种嵌套顺序都处理
成串 Undefined\makeEngPage 等模板命令却漏设前置变量\begin{document} 后注入空默认
missing-\item / \noalign 级联把两列表格环境 instrmlist 当 itemize 写 \item区块内 \item 名称(型号) 改写为 名称 & 型号 \\,已正确的行不动

两条值得展开。

**\mathbf 这条我们修反过一次。**第一版修复是 \mathbf\boldsymbol(模板载了 bm 宏包,看似合理),端到端重跑才发现方向错了——\boldsymbol 在 xelatex+unicode-math 下同样触发 “Extended mathchar used as mathchar”。正解是 unicode-math 自带的 \symbf。改对后重编一篇含 13 处 \mathbf 的历史版本:Extended mathchar 13→0,错误总数 48→35。教训有两层:清洗规则自身要有实证验证,否则修复也会引入回归;改写目标要选“与当前引擎组合确定兼容”的形式,而不是“通常等价”的形式。

回填什么值是产品判断,不只是技术判断。模板变量回填有一张例外表:英文页作者名缺失时,复制对应中文变量的值——作者名中英文页通用,比留空后作者行只剩一个 “and” 合理;但英文摘要 abstractEng 坚决留空——摘要需要翻译,把中文塞进英文页是引入错误信息。自动修复的红线是只补确定无害的缺省,不猜语义

产物与安全:缺图占位、字体替换、注入剥除

有些问题不在语法层,而在产物是否可用、可安全分发。

**缺图连累整篇。**模型常 \includegraphics 素材里并不存在的图。缺图不只报 File not found,还会连累 .aux 截断——整篇 0 页、PDF 打不开。修复是确定性替换:引用了但图目录里不存在的图,换成 \fbox 占位框,写明“素材未提供此图,请补图或删除此引用”。零 LLM,一次消掉两类错,编出可读的多页 PDF。

硬编码字体的平台替换。模板 cls 写死 \setmainfont{Times New Roman}(Windows 自带字体);Linux 容器没有它,每份报告 100+ 字体错误。编译时只改工作目录里的副本:非 Windows 平台替换为必有的 Latin Modern Roman,模板本体不动。开发机(WSL2)上还有一个教训:Liberation 的字体别名不满足 XeTeX/fontspec 的精确名匹配,装真 MS 字体后字体错误 6→0。

**注入剥除。**模板用的 minted 2.x 强制全权 -shell-escapeminted.sty:1233 处检查 \pdf@shellescape=1,拒绝受限模式),而 -shell-escape 允许 LaTeX 执行任意 shell 命令——LLM 生成的正文因此成为真实攻击面。清洗层从正文剥除命令执行原语:\write18\ShellEscape\directlua、管道形式的 \input{|cmd};minted 自己调用 pygmentize 的代码在宏包层、不经正文,高亮不受影响。端到端验证:注入 \immediate\write18{touch ...} 后编译,目标文件未生成。单机分发版则干脆去掉 -shell-escape——代码块仍由 listings 正常排版,只失去语法着色。

编译之后:diagnose 与 humanize

“编译通过”会撒谎:nonstopmode 下,缺图、未定义引用都不阻止出 PDF。diagnose 从 log 确定性统计四项——错误行数(^!)、缺图清单、未定义文献引用、未定义交叉引用——让“通过”不再掩盖劣化产物。

统计结果分两个受众。给模型的是 extract_errors:抽取 ! 开头的错误行及上下文,作为编译自修复循环(第 6 篇)的输入。给用户的是 humanize:把诊断与常见报错映射成“一句话解释 + 一个可点动作”——形如“缺 2 张图:a.png, b.png”,配按钮“补充图片,或删除对应的 \includegraphics”;“Undefined control sequence”翻译成“用了未定义的命令(可能拼错或缺宏包)”。原始 log 是给工程师的,产品界面里没有它的位置。

流水线全景与实测

flowchart TD A[模型输出] --> B[strip_fragment:去围栏 / 误带整篇时切片段] B --> C[check_fragment:配平体检(软信号)] C --> D[sanitize_tex:坑目录改写 + 执行原语剥除] D --> E[组装工作目录:缺图换占位框;非 Windows 替换字体] E --> F[xelatex + bibtex 编译] F --> G[diagnose:错误数 / 缺图 / 未定义引用] G -->|给用户| H[humanize:一句话解释 + 一个可点动作] G -->|给模型| I[extract_errors → 编译自修复循环]
实测项修复前修复后
\mathbf\symbf(重编含 \mathbf×13 的历史版本)Extended mathchar 13 处,errors 480 处,errors 35
光子晶体自动报告(\def%、数学粗体、重音内粗体三修上线后)编译失败(runaway)131KB 有效 PDF
WSL2 安装真 MS 字体(别名不满足精确名匹配)字体错误 60
注入 \immediate\write18{touch ...}文件未生成(RCE 被挡)

清洗层全部是纯函数;\symbf 修复合入时全套 pytest 37 个通过——每条改写规则、每个回填例外、注入剥除,都有断言钉住。

适用边界

  1. **坑目录只修已知坑。**每条规则绑定特定组合(GPE 模板 + xelatex + unicode-math + minted 2.x),换模板、换引擎就要重新踩一遍。它不是通用 LaTeX 修复器,也不该试图变成。
  2. **正则改写天然有误伤面。**每条规则必须收窄到病灶:只动 \def 行、只在 instrmlist 区块内、已正确的行原样保留——并用单测钉死。规则宽一格就会破坏合法输入;收窄不到确定无误伤的,宁可不修,留给编译自修复的模型闭环。
  3. **自动修复的上限是不引入错误信息。**能确定性判定的缺省(空变量、占位框)可以补;语义缺口(英文摘要)必须留给人或模型。越过这条线,修复就变成了污染。
  4. **清洗不替代上游约束与下游闭环。**系统提示里的“LaTeX 可编译性铁律”仍要写(降发生率),编译自修复处理长尾;清洗层的定位是高频已知坑的零 token 拦截。三层是并集,不是替代。
  5. **安全剥除只是纵深防御的一层。**正则层不覆盖 catcode 之类的极端绕过——这在本地单用户场景是可接受的权衡;多租户服务端必须叠加更硬的层(受限 shell-escape 基线、沙箱化执行环境)。