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

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

- Canonical (HTML): https://kaguc.com/blog/output-sanitizing-zh/
- Date: 2026-07-29


*English version: [Making model output usable: sanitizing and automatic repair](/blog/output-sanitizing/)。“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 篇[确定性边界](/blog/deterministic-boundary-zh/)原则在输出侧的具体化：片段必经确定性关卡才能进入系统状态。

## 入口清洗：strip_fragment 与 check_fragment

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

```python
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`：抽取 `!` 开头的错误行及上下文，作为[编译自修复](/blog/compile-self-repair-zh/)循环（第 6 篇）的输入。给用户的是 `humanize`：把诊断与常见报错映射成“一句话解释 + 一个可点动作”——形如“缺 2 张图：a.png, b.png”，配按钮“补充图片，或删除对应的 \includegraphics”；“Undefined control sequence”翻译成“用了未定义的命令（可能拼错或缺宏包）”。原始 log 是给工程师的，产品界面里没有它的位置。

## 流水线全景与实测

```mermaid
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 48 | 0 处，errors 35 |
| 光子晶体自动报告（`\def` 内 `%`、数学粗体、重音内粗体三修上线后） | 编译失败（runaway） | 131KB 有效 PDF |
| WSL2 安装真 MS 字体（别名不满足精确名匹配） | 字体错误 6 | 0 |
| 注入 `\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 基线、沙箱化执行环境）。

