← blog 技术原理与实践 · 2026-10-03

一次 report 草稿失败的完整排障:mini-agent 博客链路与字体门禁

从一次 report 被保存为 draft:true 的表象出发,复盘 mini-agent 的失败链路、字体子集门禁、进程超时与修复方案选型。

15 min read

症状:为什么 report 最后变成了 draft

这次问题从一个很容易误判的结果开始:一次生产环境的 /report 请求完成后,新的文章文件被写入了博客仓库,但 front matter 里的 draft 是 true。随后 BlogSync 发现了文件变化并提交到了远端,文章却没有自动回到公开状态。

表面上看,问题像是“report 生成失败”。但 report 是最短、最确定的一条博客生成路径:它不依赖复杂的多轮主题整合,也没有额外的人工编辑步骤。如果这条路径失败,排查重点就应该放在生成链路本身,而不是先怀疑内容质量。

这次最终定位到的缺失字符是中文的“塔”。它触发了字体覆盖门禁,成为文章不能进入发布状态的直接原因。

graph TD
    A["/report 请求"] --> B["生成候选文章"]
    B --> C["语义检查、质量检查、安全扫描、MDX 校验"]
    C --> D["字体覆盖门禁"]
    D -->|缺少字符:塔| E["重建字体子集"]
    E --> F["生产缓存未命中,尝试下载 Noto Serif SC"]
    F -->|连接被重置| G["npm 包装脚本等待超时"]
    G --> H["父进程结束,Python 子进程仍在运行"]
    H --> I["工作流按失败处理并写入 draft:true"]
    I --> J["残留进程稍后继续修改字体输出"]

第一层判断:不是 LLM、MDX 或质量检查失败

排查时先按固定阶段看 trace,而不是从最终文件倒推。这个请求中,LLM 已经产出了候选正文;语义决策、质量评审、敏感信息扫描和 Fuwari front matter 校验也都通过了。工作流是在字体覆盖门禁阶段失败的。

这一步很关键。若把“生成了 draft”简单归咎于模型,就会去调 prompt 或更换模型,既不能解决缺字,也会掩盖真正的基础设施问题。门禁的职责是保证发布后的页面能显示正文,失败时保存草稿是正确的保护动作。

门禁检查候选正文使用到的字符是否已经出现在当前 WOFF2 子集里。缺少“塔”后,mini-agent 创建了临时候选文件,并执行了约定的命令:

npm run subset:fonts -- --extra-file <temporary-candidate-file>

从日志和进程状态继续向下看,才发现这里同时叠加了三个问题:参数契约没有被官方脚本稳定消费;生产机没有本地字体源,脚本转而访问 GitHub;超时只结束了 npm 外层进程,内层 Python 还活着。

沿着 trace 还原完整失败链路

1. --extra-file 是工作流和脚本之间的必要契约

字体脚本平时可以扫描文章目录,但门禁检查的是“刚生成、尚未落盘的候选文章”。因此工作流需要把临时文件显式传给脚本。--extra-file 的语义不是随手附加一个参数,而是告诉字体脚本:把这个文件纳入字符收集范围,并据此决定是否需要重建子集。

原实现中,调用方已经传递了参数,脚本却没有在所有路径上可靠解析和使用它。这会造成一种危险的假象:命令看起来执行成功,输出却没有覆盖刚发现的字符。修复时把参数解析、文件读取和字符合并放进官方脚本本身,npm 入口和直接 Python 入口共用同一套逻辑。

2. 冷缓存路径把发布请求绑定到了外部网络

生产字体缓存里没有源 OTF 时,脚本会尝试从 GitHub 下载 Noto Serif SC。字体重建本身是本地计算,但冷启动却包含网络 I/O;连接被重置后,重建必然无法在请求预算内完成。

这说明“脚本可重试”不等于“请求链路可靠”。重试只能降低偶发网络错误的概率,不能消除外部依赖,也不能保证每次都在 300 秒的工作流预算内完成。

3. npm 超时没有终止完整进程树

mini-agent 通过 subprocess.run(..., timeout=...) 启动 npm。超时异常能结束等待中的父进程,但它不自动替所有后代进程负责。npm 又启动了 Python 字体脚本,于是出现了两个同时成立的事实:

  • 工作流已经收到超时,按失败路径写入 draft:true;
  • Python 子进程仍然继续下载、子集化并写入字体输出。

“幂等”只说明同一输入多次执行最终可以得到同样的内容,不代表并发执行安全。残留进程会和下一次重建竞争输出文件,还会浪费 CPU 和网络连接。

两个真正的根因

这次故障可以归并成两个需要分别修复的缺陷:

  1. 功能契约缺陷:--extra-file 已经是调用方依赖的接口,但官方字体脚本没有把它实现成稳定、可验证的输入。
  2. 进程与依赖缺陷:热路径通过 npm 间接启动 Python,超时没有清理进程组;字体源又依赖外部网络,导致冷缓存时把网络抖动放大成发布失败。

只有修其中一个,问题仍会复现。参数实现正确,但下载继续失败,门禁依然失败;网络变稳定,但子进程继续残留,下一次请求仍可能遇到并发写入。

为什么字体门禁必须保留

文章使用 Canela 承担英文字形,Canela 的字符集相对小且可以整体加入;中文正文和标题使用 Noto Serif SC。字体门禁检查的是中文字体是否覆盖文章实际出现的汉字,不是检查英文字体是否齐全。UI 和导航全部是英文,不需要把它们混入中文 fallback 的覆盖判断。

因此,门禁不是可有可无的性能优化,而是发布正确性的最后一道检查:文章可以通过模型质量评审,却不能因为线上字体缺字而发布一个不可读的页面。正确的方向是让门禁更可控、更快、更容易恢复,而不是取消它。

修复候选方案与选型

我们比较了三条路径:

graph LR
    S["字体门禁失败"] --> A["方案 A:最小修复"]
    S --> B["方案 B:本地源 + 直接 Python + 进程组治理"]
    S --> C["方案 C:异步重建后再晋级"]
    A --> A1["改参数、保留 npm、加重试"]
    B --> B1["保留 npm 作为手工/CI 入口"]
    B1 --> B2["请求热路径直接调用官方 Python 脚本"]
    B2 --> B3["本地 OTF、锁、暂存目录、原子替换"]
    C --> C1["先保存 pending,再异步重建"]
    C1 --> C2["成功后再把文章晋级为公开"]
方案优点代价判断
A:最小修复改动小,保留现有调用习惯冷缓存仍依赖 GitHub;多次超时仍可能留下进程不足以解决生产根因
B:本地源与进程治理保留现有 npm 入口;热路径少一层进程;无外部下载;失败可控需要一次性安装字体源并增加锁和原子发布逻辑选用
C:异步重建请求延迟更低,可把重建放到后台需要 pending 状态、重试、晋级、恢复和一致性模型复杂度超出当前问题

选择方案 B 的关键不是“Python 比 npm 快”这么简单,而是把故障边界放回真正拥有算法的脚本:npm 仍是开发者和 CI 的官方入口,生产请求则用生产虚拟环境直接运行同一个 Python 实现。这样既不维护两套字体算法,也能在超时时创建进程组并一次性回收整个子树。

最终方案:保留 npm,热路径直接调用 Python

修复后的运行顺序如下:

sequenceDiagram
    participant R as report 请求
    participant W as BlogUpdateActor
    participant L as 字体门禁
    participant P as 生产 Python venv
    participant F as 本地 OTF 源
    participant O as 字体输出目录
    participant G as BlogSync

    R->>W: 生成候选文章
    W->>L: 检查正文字符覆盖
    L->>W: 发现缺字,进入重建
    W->>W: 获取字体重建锁并再次检查覆盖
    W->>P: start_new_session=True,直接运行官方脚本
    P->>F: 读取 Regular/Bold OTF
    P->>P: 生成 WOFF2 与 CSS 到 staging
    P->>O: 原子替换输出
    P-->>W: 返回成功
    W->>L: 重新检查“塔”等缺失字符
    L-->>W: 门禁通过
    W->>G: 写入文章并触发同步

具体约束有四个:

  • npm 入口保留:npm run subset:fonts 仍用于人工执行和 CI,避免开发体验断裂;它调用的仍是同一个官方 Python 脚本。
  • 请求路径直接运行 Python:mini-agent 使用生产 venv 的解释器启动脚本,减少一层 npm 子进程。启动时创建独立进程组,超时后对进程组发送终止信号,再做必要的强制清理。
  • 锁与二次检查:获得字体重建锁后重新检查覆盖率,只有仍然缺字才重建。锁解决并发问题,幂等性负责重复输入的结果一致性,两者职责不同。
  • 暂存与原子替换:先在 staging 目录生成 WOFF2 和 CSS,全部成功后再替换正式输出,避免半套字体被页面读取。

为什么把完整字体缓存到生产机

生产机现在预装 Noto Serif SC 的 Regular 和 Bold OTF,并通过 BLOG_FONT_SOURCE_DIR 指向一个被部署环境管理的字体缓存目录。Regular/Bold 正好对应站点使用的 400/700 字重,不需要把完整字体库复制进博客仓库,也不会让 Git 历史膨胀。

字体源和文章内容的生命周期不同:文章通过 Git 同步,字体源属于机器运行时依赖。把字体源放在固定缓存目录,既能让重建完全离线,也能在更新字体时独立替换和校验。请求路径不再下载字体;如果缓存缺失,门禁会明确失败并保留草稿,而不是临时访问外部站点。

当前生产状态

修复已经在生产环境完成验证:

  • 字体缓存已安装 Regular/Bold OTF,生成过程不再依赖请求时的外部下载。
  • mini-agent 已改为在热路径直接调用官方 Python 字体脚本,同时保留 npm 手工/CI 入口。
  • 字体重建增加进程组回收、并发锁、staging 输出和原子替换。
  • 用临时缺失字符执行了直接 Python 重建,随后恢复了正式字体输出;两个 WOFF2 输出都验证包含“塔”。
  • 通过生产 venv 的 PATH 验证了 npm run subset:fonts 入口仍然可用。
  • 服务重启后保持 active,博客工作流的失败仍然 fail-closed:门禁或安全检查失败时继续保存草稿,不会把未验证的内容误标为公开。

这次修复没有把旧的 draft 自动改成公开文章。发布状态仍由内容审核和后续发布动作决定,这样可以把“修复生成链路”和“决定是否发布内容”分开。

新增案例:门禁失败时不能留下孤立字体产物

这次链路还有一个容易被忽略的一致性问题:字体子集脚本会直接改写博客仓库里的两个 WOFF2 文件和 CSS。假设候选文章已经通过前面的检查,字体门禁开始重建;随后文章因为发布门阀、原子写入或目标状态变化没有进入 src/content/entries,字体文件却已经留在仓库里。下一次 BlogSync 只看到字体改动,就可能把一组没有对应文章的字符集提交出去。

这会让 Git 提交看起来是成功的,但提交内容不再代表一次完整的发布。字体是为某篇候选文章生成的,文章却没有落盘;之后即使有人删除孤立字体,仍然很难从提交历史判断它是否应该保留。因此字体生成必须和文章发布共享同一个失败边界:文章没有成功进入博客目录时,本次生成的字体输出也不能留下。

修复采用了“尝试前快照、失败时恢复”的事务边界。生成候选文章前,工作流记录两个 WOFF2 和 CSS 的原始字节内容及文件权限;字体重建失败、重建后覆盖检查仍失败,或文章原子写入失败时,恢复这三个文件。恢复使用同目录临时文件和替换操作,避免回滚过程中留下半写入文件。只有文章写入成功,新的字体输出才会继续交给 BlogSync 一起提交。

font_snapshot = FontOutputsSnapshot.capture(blog_repo)
try:
    font_ok, reason = await ensure_font_coverage(candidate, blog_repo)
    if not font_ok:
        return review(reason)
    write_atomically(article_path, candidate)
except OSError:
    font_snapshot.restore()
    raise

字体门禁内部也保留自己的快照:官方脚本即使返回失败,或者返回成功但最终 cmap 仍然缺字,已经改写的输出也会立即恢复。这样可以覆盖脚本部分写入、覆盖率复检失败和文章写入失败三种情况。对应测试验证了新增文件会被删除、旧文件会恢复、文件权限会保留,以及文章写入异常时博客目录不会出现孤立字体变更。

这个案例补充了前面的进程治理结论:进程组回收解决“失败后还有进程继续写”的问题,输出快照解决“失败前已经写出结果”的问题;两者缺一不可。前者保证不会有迟到的写入,后者保证已经发生的写入可以回到发布前状态。

新增案例:字体输出与失败发布的仓库冲突

在保留前面排障历史的基础上,又发现了一个 medium issue:字体子集脚本原本会直接修改博客仓库里的两个 WOFF2 和 CSS。一次文章发布在字体重建之后仍可能因为质量门阀、原子写入或目标状态判断失败;如果正式字体已经被改写,BlogSync 仍会把这些没有对应文章的字体变化纳入提交。两台实例同时发布时,这类孤立改动还会增加 pull —rebase 的冲突。

它被定为 medium,是因为它通常不会泄露数据,也不会直接破坏已经发布的文章;影响在于失败请求污染共享 Git 工作树,让提交边界变得模糊,并给下一次发布增加冲突和回滚成本。

针对这个 issue,我们比较了四种策略:

策略做法取舍
Approach 1:继续直接写正式输出保持脚本直接生成到 public/fonts/noto-serif-sc/改动最小,但失败和并发时仍会污染正式工作树
Approach 2:快照与失败回滚运行前保存字体文件,异常时恢复字节和权限能覆盖更多失败路径,但正式目录仍有写入窗口,回滚也可能再次失败
Approach 3:独立字体产物把字体放到独立仓库、分支或异步构建产物,再由部署步骤引用能解耦 Git 冲突,但需要版本绑定、发布编排和过期产物清理
Approach 4:暂存后再安装官方脚本支持 --output-dir,先写入 BLOG_FONT_TMP,通过检查后再安装需要调整脚本接口,但改动集中、兼容现有发布流程

选择 Approach 4,是因为它正好隔离了最容易造成仓库冲突的失败阶段。每次尝试使用独立的 staging 子目录;脚本失败、超时、输出不完整或生成后仍然缺字时,正式 WOFF2 和 CSS 都不会被触碰。只有三个输出都存在并通过 cmap 覆盖率检查后,主进程才在字体锁内使用同文件系统的 os.replace 安装它们。BLOG_FONT_TMP 写入 ECS 的 .env,BLOG_REPO_ROOT 则明确博客工作树的位置。

candidate article
      │
      ▼
official subset-fonts.py --output-dir $BLOG_FONT_TMP/<attempt>
      │
      ├── failed / timeout / incomplete / missing glyph
      │       └── clean staging; keep live fonts unchanged
      │
      └── all checks pass
              └── atomic install → BlogSync can see one validated font change

这个选择主要降低失败发布对共享博客仓库的污染,以及本地与 ECS 两个实例之间的无关字体冲突。它没有把文章写入和字体安装变成跨文件系统的完整事务:如果字体安装成功后更高层文章写入才失败,仍需要通过日志和 Git 审阅确认最终状态。因此 Approach 4 是针对本次 medium issue 的冲突隔离方案,而不是对所有发布状态建立新的数据库事务。

落地后,失败的字体计算不会在博客目录直接留下半套正式字体,也不会因为一次失败候选发布单独制造一笔字体提交。

这次排障留下的决策经验

第一,最终状态不是根因。draft:true 只是工作流的保护性结果,必须沿着阶段 trace 找到第一个失败门禁。

第二,接口契约要由被调用方实现和验证。调用方传了 --extra-file 不代表脚本真的使用了这个文件;参数应当进入脚本的测试和覆盖率检查。

第三,幂等性、并发控制和进程生命周期是三件事。幂等性解决重复执行的结果,锁解决同时执行,进程组治理解决超时后的资源回收。把它们混成“脚本可重复运行”会留下盲区。

第四,发布热路径应尽量使用本地、可验证的输入。网络下载可以作为显式的运维准备步骤,却不适合成为一次文章发布请求的隐式依赖。

最后,保留熟悉的 npm 命令和优化生产进程模型并不冲突。入口可以面向开发者保持稳定,运行时则根据生产资源约束选择更短、更可控的调用链。

Sources

  1. FontTools subset 文档 — FontTools
  2. Python subprocess 文档 — Python Software Foundation
  3. CPython subprocess timeout 讨论 — CPython
  4. Node.js process tree termination 讨论 — Node.js
  5. mini-agent 修复提交 — GitHub
  6. 博客字体脚本修复提交 — GitHub

Related

On this page 症状:为什么 report 最后变成了 draft第一层判断:不是 LLM、MDX 或质量检查失败沿着 trace 还原完整失败链路1. --extra-file 是工作流和脚本之间的必要契约2. 冷缓存路径把发布请求绑定到了外部网络3. npm 超时没有终止完整进程树两个真正的根因为什么字体门禁必须保留修复候选方案与选型最终方案:保留 npm,热路径直接调用 Python为什么把完整字体缓存到生产机当前生产状态新增案例:门禁失败时不能留下孤立字体产物新增案例:字体输出与失败发布的仓库冲突这次排障留下的决策经验