☰
LaTeX模板修复实战:基于Agent Skills的候选确认分离与Monaco diff可视化
2026/10/3 4:12:59 网站建设 项目流程

1. 从一份混乱的修复清单说起

LaTeX 项目写久了,最怕的不是写公式,而是改完一轮之后,编译日志里躺着几十条 warning,辅助文件散落一地,.aux、.log、.out、.bbl混在源码目录里,分不清哪些是真正需要提交的,哪些是编译产物。更麻烦的是,当你想把一次修复过程整理成可复用的经验时,会发现改动散落在多个文件里,靠记忆根本说不清楚。

我最近在整理一个论文模板项目时,就遇到了这个问题。项目里有自定义的页眉字号、上标引用格式、图片路径引用,还有一堆历史遗留的辅助文件。手动改完一轮之后,我意识到:如果能把“候选修复”和“确认修复”这两个阶段分开,用一套结构化的技能(Agent Skills)来组织整个流程,后续维护会轻松很多。TeXada Studio 这个思路正好切中了这个痛点——它不是单纯做一个 LaTeX 编辑器,而是把修复动作拆成“候选生成”和“确认落地”两个阶段,中间用 Monaco diff 做可视化比对,底层用 Python 脚本驱动文件操作。

这篇文章不打算讲空泛的概念,而是把我实际跑通的一套流程拆开来讲。从环境准备、技能定义、候选生成、diff 比对,到确认写入和辅助文件清理,每一步都会给出可复现的操作和参数说明。如果你也在维护 LaTeX 模板,或者想用 Agent Skills 的思路来组织重复性修复工作,这篇内容可以直接抄作业。

2. 整体设计思路:为什么要把修复拆成两个阶段

2.1 候选与确认分离的核心逻辑

传统做法是:发现一个问题,直接改文件,改完编译,编译不过再回滚。这个流程在单人、单次修改时没问题,但一旦涉及批量修复或者多人协作,就会暴露两个问题。第一,改动不可追溯,你不知道这次改的是哪几个文件、哪几行;第二,回滚成本高,改错了只能靠版本控制或者手动撤销。

TeXada Studio 的思路是把修复动作拆成两个独立阶段。第一阶段叫“候选生成”,Python 脚本扫描项目文件,根据预设规则找出需要修改的位置,生成一份候选清单,但不直接写入源文件。第二阶段叫“确认落地”,用户在 Monaco diff 界面里逐条查看候选改动,确认无误后再写入。这两个阶段之间用一份结构化的候选数据做衔接,格式可以是 JSON 或者 YAML。

这样做的好处很明显。候选阶段可以反复跑,不会污染源文件;确认阶段可以逐条比对,避免误改;整个流程的中间产物可以存档,方便回溯。我实测下来,对于一个包含 30 多个.tex文件、200 多条待修复项的模板项目,这套流程把误改率从原来的大概 15% 降到了接近零。

2.2 为什么选 Monaco diff 做比对界面

比对界面有很多选择,比如直接用diff命令输出、用 VS Code 内置的 diff、或者自己写一个简单的文本对比。我最终选 Monaco diff,原因有三个。

第一,Monaco 是 VS Code 的底层编辑器组件,对 LaTeX 这种混合了文本和公式的语法有天然的高亮支持。你不需要额外配置就能看到\section、\cite、$...$这些结构的颜色区分。第二,Monaco diff 支持行内 diff,也就是在同一行里标出具体哪个字符变了,这对于修改页眉字号这种只改一个数字的场景特别有用。第三,它是纯前端组件,可以嵌在任意 Web 界面里,不依赖本地编辑器环境。

当然,Monaco 也有代价。它的包体积不小,首次加载大概需要 2 到 3 秒。如果你的项目只有十几个文件,用简单的文本对比就够了。但如果你要处理的是几十个文件、上百条改动,Monaco 的行内 diff 和折叠功能会省很多时间。

2.3 Python 在流程里的角色定位

Python 在这套流程里不是用来做“智能修复”的,而是做“规则化扫描和文件操作”。具体来说,它负责三件事:扫描项目目录、匹配修复规则、生成候选数据。修复规则可以用正则表达式写,也可以用简单的字符串匹配。比如“把页眉字号从\small改成\footnotesize”这种规则,用正则匹配\small出现的位置就行。

为什么不把修复逻辑放在前端?因为文件读写、目录遍历这些操作在前端做不安全,也不方便做批量处理。Python 脚本跑在本地或者服务端,生成候选 JSON 之后传给前端展示,确认后再由 Python 执行写入。这样前后端职责清晰,也方便后续把规则做成可配置的。

3. 环境准备与基础配置

3.1 LaTeX 环境的最小可用配置

这套流程不依赖特定的 LaTeX 发行版,TeX Live 或者 MiKTeX 都可以。我本地用的是 TeX Live 2024,安装的时候选的是scheme-full,因为模板里用到了ctex、geometry、fancyhdr这些包,完整安装省得后面缺包再补。如果你磁盘空间紧张,可以先装scheme-basic,然后按需用tlmgr install补包。

安装完之后,验证一下pdflatex和latexmk是否可用。latexmk不是必须的,但它能自动处理多次编译和 bib 引用,后面清理辅助文件的时候会方便很多。在终端里跑:

pdflatex --version latexmk --version

如果这两个命令都能输出版本号,说明基础环境没问题。接下来配置 VS Code 的 LaTeX 插件。我用的组合是 LaTeX Workshop 加latexmk作为编译工具。在settings.json里加一段配置:

{ "latex-workshop.latex.tools": [ { "name": "latexmk", "command": "latexmk", "args": [ "-synctex=1", "-interaction=nonstopmode", "-file-line-error", "-pdf", "%DOC%" ] } ], "latex-workshop.latex.recipes": [ { "name": "latexmk", "tools": ["latexmk"] } ] }

这段配置的作用是让 LaTeX Workshop 用latexmk编译,并且开启-synctex=1,这样在 PDF 和源码之间可以双向跳转。-interaction=nonstopmode是让编译遇到错误不中断,方便一次性看到所有问题。

3.2 Python 环境与依赖安装

Python 版本建议 3.10 以上,因为后面用到的pathlib和dataclasses在 3.10 里更稳定。安装 Python 的时候记得勾选“Add Python to PATH”,不然后面在终端里调python会找不到。装完之后验证:

python --version pip --version

依赖方面,核心只需要两个库:pyyaml用来读写候选数据,watchdog用来监听文件变化(可选)。安装命令:

pip install pyyaml watchdog

如果你打算把候选数据存成 JSON,pyyaml也可以不装,用标准库的json就行。我选 YAML 是因为它支持注释,候选文件里可以写清楚每条规则的来源和意图,后面回溯的时候方便。

3.3 项目目录结构约定

为了让扫描脚本能准确识别哪些是源文件、哪些是辅助文件,目录结构需要有个约定。我用的结构是这样的:

project/ src/ main.tex chapters/ intro.tex method.tex styles/ header.sty build/ main.pdf main.aux main.log candidates/ fix-2024-01-15.yaml scripts/ scan.py apply.py

src/放所有源文件,build/放编译产物,candidates/放候选数据,scripts/放 Python 脚本。这个结构不是强制的,但有了它之后,扫描脚本只需要遍历src/目录,清理脚本只需要删build/目录里的内容,不会误伤源文件。

注意:如果你的项目已经在版本控制里,记得把build/和candidates/加到.gitignore。候选文件虽然有用,但它是中间产物,不需要提交到仓库。

4. Agent Skills 的定义与候选生成

4.1 什么是 Agent Skills 以及为什么用它

Agent Skills 这个概念最近在自动化工具圈里讨论得比较多,简单说就是把一个可复用的操作封装成“技能”,每个技能有明确的输入、输出和执行逻辑。在 LaTeX 修复这个场景里,一个技能可以是一条修复规则,比如“把页眉字号从\small改成\footnotesize”,也可以是一组相关规则的集合,比如“统一所有章节文件的引用格式”。

用技能的方式来组织修复逻辑,好处是规则和代码分离。你不需要为了加一条新规则去改 Python 脚本,只需要在技能配置文件里加一段 YAML。这样即使不懂 Python 的人,也能通过改配置来调整修复行为。我实测下来,一个包含 20 多条规则的技能配置,从零开始写大概需要半小时,但后续维护成本几乎为零。

4.2 技能配置文件的字段设计

技能配置文件我用 YAML 写,每个技能包含以下字段:

skills: - id: header-font-size description: "统一页眉字号为 footnotesize" target: "src/**/*.tex" match: "\\\\small" replace: "\\\\footnotesize" context: "fancyhdr" severity: "warning" enabled: true

字段说明:

  • id:技能唯一标识,用于在候选数据里引用。
  • description:人类可读的描述,方便在 diff 界面里展示。
  • target:匹配的文件路径,支持 glob 模式。
  • match:匹配的正则表达式。注意 YAML 里反斜杠需要转义,所以\small要写成\\\\small。
  • replace:替换后的内容。
  • context:可选的上下文关键词,只有包含这个关键词的文件才会被扫描,避免误改。
  • severity:严重级别,用于在候选列表里排序。
  • enabled:是否启用,方便临时关闭某条规则。

这个设计的核心是context字段。LaTeX 项目里\small可能出现在很多地方,不只是页眉。加上context: "fancyhdr"之后,只有同时包含fancyhdr的文件才会被匹配,误报率大幅降低。

4.3 扫描脚本的核心逻辑

扫描脚本用 Python 写,核心逻辑分四步:遍历目标文件、读取内容、逐条应用技能规则、生成候选数据。下面是简化后的代码:

import re import yaml from pathlib import Path from dataclasses import dataclass, asdict @dataclass class Candidate: skill_id: str file_path: str line_number: int original: str replacement: str description: str def load_skills(config_path): with open(config_path, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) return [s for s in data['skills'] if s.get('enabled', True)] def scan_file(file_path, skills): candidates = [] content = file_path.read_text(encoding='utf-8') lines = content.splitlines() for skill in skills: if skill.get('context') and skill['context'] not in content: continue pattern = re.compile(skill['match']) for i, line in enumerate(lines, start=1): if pattern.search(line): candidates.append(Candidate( skill_id=skill['id'], file_path=str(file_path), line_number=i, original=line, replacement=pattern.sub(skill['replace'], line), description=skill['description'] )) return candidates def scan_project(src_dir, skills): all_candidates = [] for tex_file in Path(src_dir).rglob('*.tex'): all_candidates.extend(scan_file(tex_file, skills)) return all_candidates

这段代码的关键点是context检查放在行遍历之前,先判断整个文件是否包含上下文关键词,不包含就直接跳过。这样对于大项目来说,能省不少时间。另外line_number从 1 开始计数,和 Monaco diff 的行号对齐。

4.4 候选数据的存储格式

扫描完成后,候选数据存成 YAML 文件,结构如下:

generated_at: "2024-01-15T10:30:00" project_root: "/path/to/project" total_candidates: 42 candidates: - skill_id: header-font-size file_path: "src/styles/header.sty" line_number: 15 original: "\\small" replacement: "\\footnotesize" description: "统一页眉字号为 footnotesize" status: "pending"

status字段初始为pending,用户在 diff 界面确认后改成accepted或rejected。这个字段是后续写入操作的依据,只有accepted的候选才会被应用到源文件。

实操心得:候选文件建议按日期命名,比如fix-2024-01-15.yaml。这样每次扫描生成一份新文件,不会覆盖之前的记录。如果某次扫描结果不理想,可以直接删掉对应的 YAML,不影响源文件。

5. Monaco diff 比对界面的实现

5.1 界面布局与交互设计

Monaco diff 界面我做成左右分栏:左边是原始内容,右边是替换后的内容,中间用 diff 装饰器标出改动行。顶部放一个候选列表,每条候选显示文件名、行号、技能描述和状态。点击某条候选,下面的 diff 区域自动滚动到对应位置。

交互上,每条候选有三个按钮:接受、拒绝、跳过。接受和拒绝会更新候选数据里的status字段,跳过则保持pending。全部处理完之后,点“应用已接受项”按钮,触发 Python 脚本执行写入。

这个布局的好处是,用户不需要在多个窗口之间切换,所有操作都在一个页面里完成。我实测下来,处理 40 多条候选大概需要 5 到 8 分钟,比手动逐文件改快很多,而且不容易漏。

5.2 Monaco diff 的初始化配置

Monaco 的初始化代码不复杂,核心是创建两个编辑器实例和一个 diff 编辑器。下面是关键代码:

import * as monaco from 'monaco-editor'; const originalModel = monaco.editor.createModel(originalContent, 'latex'); const modifiedModel = monaco.editor.createModel(modifiedContent, 'latex'); const diffEditor = monaco.editor.createDiffEditor(document.getElementById('diff-container'), { readOnly: true, renderSideBySide: true, ignoreTrimWhitespace: false, renderIndicators: true, originalEditable: false }); diffEditor.setModel({ original: originalModel, modified: modifiedModel });

几个关键参数说明:renderSideBySide: true是左右分栏模式,如果屏幕窄可以改成false变成上下模式。ignoreTrimWhitespace: false表示不忽略空白差异,因为 LaTeX 里空格有时候是有意义的。renderIndicators: true会在行号旁边显示改动标记,方便快速定位。

5.3 行内 diff 与 LaTeX 语法高亮的配合

Monaco 默认的 diff 是行级 diff,也就是整行标红或标绿。但对于“只改一个数字”这种场景,行级 diff 不够精确。Monaco 支持行内 diff,需要在创建 diff 编辑器时加一个配置:

const diffEditor = monaco.editor.createDiffEditor(container, { renderSideBySide: true, experimental: { useTrueInlineDiff: true } });

开启之后,同一行里变化的字符会被单独标出来。比如\small改成\footnotesize,只有small和footnotesize这部分会被高亮,前面的反斜杠不变。

LaTeX 语法高亮需要注册一个语言定义。Monaco 内置了latex语言支持,但如果你用的是自定义命令,可能需要扩展。最简单的做法是直接用latex,然后通过monaco.languages.setMonarchTokensProvider加自定义规则。我试过加\cite上标格式的高亮,大概十几行配置就能搞定。

5.4 候选状态同步与写入触发

候选状态的同步逻辑放在前端,每次用户点击接受或拒绝,就更新内存里的候选数据,然后通过fetch把更新后的数据发回后端。后端收到之后,更新 YAML 文件里的status字段。

写入触发是一个单独的接口。前端点“应用已接受项”之后,后端读取 YAML 文件,筛选出status: accepted的候选,按文件分组,然后逐文件执行替换。替换的时候要注意行号偏移问题:如果同一个文件里有多个候选,先改后面的行,再改前面的行,这样行号不会错乱。

def apply_candidates(candidates_path): with open(candidates_path, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) accepted = [c for c in data['candidates'] if c['status'] == 'accepted'] by_file = {} for c in accepted: by_file.setdefault(c['file_path'], []).append(c) for file_path, items in by_file.items(): items.sort(key=lambda x: x['line_number'], reverse=True) path = Path(file_path) lines = path.read_text(encoding='utf-8').splitlines() for item in items: idx = item['line_number'] - 1 lines[idx] = item['replacement'] path.write_text('\n'.join(lines), encoding='utf-8')

这段代码的核心是reverse=True排序,确保从后往前改,避免行号偏移。另外写入的时候用'\n'.join(lines),保持 Unix 换行符,避免在 Windows 上出现换行符混乱。

6. 辅助文件清理与项目收尾

6.1 哪些辅助文件该删、哪些该留

LaTeX 编译会产生一堆辅助文件,常见的有:

扩展名用途是否可删
.aux交叉引用信息可删,下次编译会重建
.log编译日志可删,但排查问题时有用
.outhyperref 书签可删
.toc目录数据可删,但删了目录会空
.bbl参考文献数据谨慎,如果没有.bib源文件就不要删
.synctex.gz源码跳转数据可删,但删了不能双向跳转
.fls文件依赖记录可删
.fdb_latexmklatexmk 数据库可删

我的做法是:日常编译保留.aux、.toc、.bbl,删掉.log、.out、.synctex.gz。提交到版本控制之前,全部辅助文件都删掉,只留源文件和 PDF。

6.2 用 Python 脚本做安全清理

清理脚本的关键是“安全”,不能误删源文件。我的做法是只删build/目录下的内容,并且只删白名单里的扩展名。代码:

import shutil from pathlib import Path AUX_EXTENSIONS = {'.aux', '.log', '.out', '.toc', '.synctex.gz', '.fls', '.fdb_latexmk'} def clean_build(build_dir): build_path = Path(build_dir) if not build_path.exists(): return for item in build_path.iterdir(): if item.is_file() and item.suffix in AUX_EXTENSIONS: item.unlink() elif item.is_dir(): shutil.rmtree(item)

注意.synctex.gz的suffix是.gz,不是.synctex.gz。所以判断的时候要用item.name.endswith('.synctex.gz')或者把扩展名集合改成用endswith匹配。我踩过这个坑,第一次跑的时候.synctex.gz没被删掉,后来改成:

def should_delete(file_name): return any(file_name.endswith(ext) for ext in AUX_EXTENSIONS)

6.3 清理前后的编译验证

清理之后一定要重新编译一次,确认没有删掉必要的文件。我用的验证命令是:

latexmk -pdf -interaction=nonstopmode -file-line-error src/main.tex

如果编译通过,并且 PDF 正常生成,说明清理没问题。如果编译报错说找不到某个文件,那说明删多了,需要把对应的扩展名从白名单里去掉。

注意:如果你的项目用了bibtex或者biber,.bbl文件不要随便删。有些期刊模板要求提交.bbl,删了之后读者编译不出来参考文献。

7. 常见问题与排查技巧实录

7.1 候选生成阶段的典型问题

问题一:正则匹配误报太多。比如\small在正文里也出现,不只是页眉。解决办法是加context字段,或者把匹配范围缩小到特定文件。我试过用target: "src/styles/*.sty"把范围限定在样式文件里,误报率从 30% 降到 5% 以下。

问题二:YAML 里的反斜杠转义。LaTeX 命令里全是反斜杠,YAML 里写正则的时候需要双重转义。比如匹配\cite要写成"\\\\cite"。这个很容易写错,建议写完先用 Python 的yaml.safe_load读一遍,确认解析出来的字符串是对的。

问题三:文件编码不一致。有些老模板用 GBK 编码,Python 默认用 UTF-8 读会报错。解决办法是在read_text里加errors='ignore',或者先用chardet检测编码。我一般直接统一转成 UTF-8,避免后续麻烦。

7.2 diff 比对阶段的常见故障

故障一:Monaco 加载慢。首次加载 2 到 3 秒是正常的,但如果超过 5 秒,可能是 CDN 或者打包配置有问题。建议把 Monaco 的静态资源放在本地,不要依赖外部 CDN。

故障二:行内 diff 不生效。检查useTrueInlineDiff是否开启,另外 Monaco 版本要在 0.30 以上才支持这个特性。如果版本太低,升级一下。

故障三:大文件 diff 卡顿。如果单个文件超过 5000 行,Monaco diff 会明显卡顿。解决办法是分页加载,或者只 diff 改动行附近的内容。我一般把 diff 范围限制在改动行上下 20 行,这样既能看到上下文,又不会卡。

7.3 写入阶段的避坑指南

坑一:行号偏移。前面提过,同一个文件多个候选要从后往前改。如果从前往后改,改完第一行之后,第二行的行号就变了,会改错位置。

坑二:换行符混乱。Windows 上默认是\r\n,Linux 上是\n。Python 的read_text默认会做换行符转换,但write_text不会。建议统一用newline='\n'参数,或者在写入前把\r\n替换成\n。

坑三:权限问题。如果项目文件是只读的,写入会失败。建议在写入前检查文件权限,或者用os.chmod临时加写权限。

7.4 常见问题速查表

问题现象可能原因排查方法解决方案
候选数量为零技能未启用或 target 路径不对检查 YAML 里 enabled 和 target修正路径或启用技能
diff 界面空白Monaco 资源加载失败打开浏览器控制台看报错本地化 Monaco 资源
写入后编译报错替换内容语法错误对比替换前后的行回滚候选,修正 replace 字段
辅助文件删多了白名单扩展名不全重新编译看缺什么把缺失扩展名加回白名单
行内 diff 不显示Monaco 版本过低查看 monaco-editor 版本号升级到 0.30 以上

8. 一些实操心得与后续扩展方向

这套流程我跑了大概两个月,处理了四个 LaTeX 模板项目,最大的感受是:候选和确认分离这个设计,比想象中更有价值。它不只是为了安全,更是为了让修复过程变得可讨论。以前改模板,改完只能自己知道改了啥;现在候选文件一生成,可以直接发给合作者看,对方在 diff 界面里逐条确认,沟通成本低了很多。

另一个心得是,技能配置不要一次写太多。我一开始写了 30 多条规则,结果候选列表太长,处理起来反而慢。后来精简到 10 条左右,只保留高频、明确的修复项,效率反而更高。剩下的边缘情况,手动改就行,不值得为它写一条规则。

后续如果继续扩展,我会考虑两个方向。一是把技能配置做成可导入导出的,这样不同项目之间可以复用规则集。二是加一个“批量接受同类候选”的功能,比如所有header-font-size的候选一键接受,省去逐条点击的时间。这两个功能都不复杂,但能进一步提升效率。

如果你也在维护 LaTeX 模板,或者手头有大量重复性的文本修复工作,这套思路可以直接搬过去用。核心就三点:规则配置化、候选数据化、确认可视化。把这三点做到位,修复工作就从“手工活”变成了“流水线”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询