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”为例,中间至少隔着三层失败:
- 不可编译:语法看似正确,但踩中引擎与宏包组合的边角行为(上面的
%,下文的\mathbf); - 可编译但不可用:编译“通过”,产物却是 0 页,或缺图、引用全是 [?];
- 可用但不安全:
-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-escape(minted.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 是给工程师的,产品界面里没有它的位置。
流水线全景与实测
| 实测项 | 修复前 | 修复后 |
|---|---|---|
\mathbf→\symbf(重编含 \mathbf×13 的历史版本) | Extended mathchar 13 处,errors 48 | 0 处,errors 35 |
光子晶体自动报告(\def 内 %、数学粗体、重音内粗体三修上线后) | 编译失败(runaway) | 131KB 有效 PDF |
| WSL2 安装真 MS 字体(别名不满足精确名匹配) | 字体错误 6 | 0 |
注入 \immediate\write18{touch ...} | — | 文件未生成(RCE 被挡) |
清洗层全部是纯函数;\symbf 修复合入时全套 pytest 37 个通过——每条改写规则、每个回填例外、注入剥除,都有断言钉住。
适用边界
- **坑目录只修已知坑。**每条规则绑定特定组合(GPE 模板 + xelatex + unicode-math + minted 2.x),换模板、换引擎就要重新踩一遍。它不是通用 LaTeX 修复器,也不该试图变成。
- **正则改写天然有误伤面。**每条规则必须收窄到病灶:只动
\def行、只在 instrmlist 区块内、已正确的行原样保留——并用单测钉死。规则宽一格就会破坏合法输入;收窄不到确定无误伤的,宁可不修,留给编译自修复的模型闭环。 - **自动修复的上限是不引入错误信息。**能确定性判定的缺省(空变量、占位框)可以补;语义缺口(英文摘要)必须留给人或模型。越过这条线,修复就变成了污染。
- **清洗不替代上游约束与下游闭环。**系统提示里的“LaTeX 可编译性铁律”仍要写(降发生率),编译自修复处理长尾;清洗层的定位是高频已知坑的零 token 拦截。三层是并集,不是替代。
- **安全剥除只是纵深防御的一层。**正则层不覆盖 catcode 之类的极端绕过——这在本地单用户场景是可接受的权衡;多租户服务端必须叠加更硬的层(受限 shell-escape 基线、沙箱化执行环境)。