skip to content
tlj 的工程笔记
Table of Contents

这篇和上一次的上下文污染事故是同一类东西:AI 协作过程中真实发生、有原始记录可查的事故。区别是上次坏的是模型的判断,这次坏的是磁盘上的数据。 文中的条数、token 数都是从原始 JSONL 里数出来的,不是估的。涉及的会话一律用「主会话」「缝合版」代称。

一句话先说结论

磁盘写满的直接后果不是「跑不动」,是状态被静默损坏。append-only 的会话记录在 ENOSPC 下写入失败,没有任何报错,链在那一刻断掉;而进程还活着,内存里握着完整历史,所以接下来两天一切看着都正常。直到两天后为了升级版本重启,磁盘变回唯一的真相源,六天的历史在一瞬间消失。

故障时间和症状时间隔了约两天,中间的触发条件是「重启」这个看似完全无关的动作。

时间线

07-31 上午约 10:55 ,盘满。 97G 的根分区被写到只剩约 1.3MB。表现极其直接:连普通 shell 命令都写不出输出。当场止血:清包管理器缓存、日志、各类缓存,腾出约 1.3GB;随后删掉一份已确认冗余、原件另有 durable 位置且校验过的临时副本(约 1.5GB),恢复到约 2.8GB 可用。

此刻没有任何人知道会话记录已经损坏。 进程还活着,一切照常。

07-31 到 08-02,潜伏期约两天。 期间装工具链、新建多个项目会话、从归档里恢复一个旧会话,全程无异常征兆。

08-02 中午 11:43 ,触发点。 为了升级版本,把主会话 kill 后 --resume 拉起。重启后立刻看 /context,显示 603.3k tokens,看着完美,判定「无损成功」。这个判断是错的。

08-02 中午 11:52 ,症状暴露。 切换模型后跑出第一次真实请求,/context 变成 109.7k。第一反应是「切模型导致截断」,这也是错的

08-02 22:51 到 08-03 01:33 ,机器意外关机 2 小时 42 分。 与本事故无因果,但放大了后果——开机自动恢复脚本里硬编码了旧会话 id,于是把残缺版又拉了回来。

08-03 凌晨,定位真因并修复。 深挖 parentUuid 链,断点正是 07-31 盘满那一刻;在副本上缝合,恢复成功。

链是怎么断的

会话记录是一个 append-only 的 JSONL 文件,每轮对话追加若干条记录。每条记录有两个关键字段:uuid 是本条的唯一标识,parentUuid 指向上一条。这些指针把记录串成一条单向链。resume 时的重建方式是:从最后一条(叶子)出发,沿 parentUuid 一路回溯到根,把这条链上的记录还原成上下文。

盘满时发生的事,按顺序是这样的:

  1. 需要追加的记录写入失败(ENOSPC),这些记录从未落盘。
  2. 磁盘腾出空间后,写入恢复,继续追加新记录。
  3. 但恢复后写下的第一条记录,它的 parentUuid 指向的是那条从未落盘的记录。
  4. 于是回溯到这里就走不下去了——链断在这里。

实测数据把这个断口坐实了:文件里总共有 5905 条带 uuid 的记录,但从叶子回溯只能走到 342 条,链根就是盘满恢复后的第一条;这条链根的 parentUuid 在整个文件里查无此条。断点之前的几千条记录物理上仍然躺在文件里,只是不在活动链上,等于孤儿。

全程没有任何报错。 写失败是静默的,链断是静默的,文件看起来完好,甚至还在正常增长。

为什么两天里一切看着正常

因为上下文活在进程内存里,不依赖重读磁盘。

主会话进程从更早就一直活着,内存里握着完整历史。断链之后的两天,它照常引用早期内容,/context 照常显示 600k+,没有任何异常。磁盘上那份记录已经断了,但没有任何代码路径需要去读它,所以损坏不会显现。

一旦进程被 kill,内存里那份消失,唯一的真相源变回磁盘上的 JSONL;而那条链是断的,resume 只能载入断点之后的部分。六天历史在重启的一瞬间消失。

这是典型的静默数据损坏,可以类比内存里的缓存掩盖了磁盘上的坏块,直到某次冷启动才暴露。反过来说也成立:任何长期运行的进程,都可能正在替你掩盖一个已经存在的故障。

/context 的假象

值得单独记一段的是:定位真因的过程走了两次弯路,两次都栽在同一个东西上——相信了显示层。

重启后 /context 显示 603.3k,判定「无损成功」。切模型后显示 109.7k,判定「切模型导致截断」。第三次是被用户直接质疑推翻的:「以前切模型都没事,别的会话上下文比这还大也正常,技术上肯定可行,为什么偏偏现在不行?」这个质疑逼出了一个动作——去找独立于显示层的客观证据

证据就在 JSONL 里:每条 assistant 记录都带 usage 字段(输入 token、缓存读取、缓存写入),那是真实发出去的请求量,不受任何显示逻辑影响。一查就清楚了:

  • 重启最后一次请求:603,332
  • 重启第一次请求:102,838

断崖发生在重启那一刻,不是切模型那一刻。 切模型只是「第一次跑真实请求」,把早已发生的丢失暴露出来。

结论是:resume 之后立刻跑 /context,它读到的是 transcript 里上一个进程留下的用量记录,并不代表新进程实际载入了多少。这个假象很能骗人——它显示的数字完全正确,只是属于一个已经死掉的进程

正确的验法有三条,建议全用:先跑一次真实请求再看 /context,让新进程真正发一次 API 调用;直接读 JSONL 的 usage 字段,绕开显示层;以及最硬的一条——行为探针,问一个只有深层历史才答得出的具体问题。数字可能骗人,答不出来骗不了人。

怎么修的

能救回来的根本原因是:文件是 append-only 的,没有覆盖写。 断链之前的记录只是失去了指针关联,数据本身还在。

  1. 建索引:解析整个 JSONL,构造 uuid → 记录 的映射。
  2. 确认断点:从最后一条回溯,量出活动链长度、找到链根;验证链根的 parentUuid 在文件中确实不存在——确认这就是断口,而不是别的原因。
  3. 找缝合锚点:在断层之前的记录里,取时间戳最大的那一条消息记录,作为要接回去的位置。
  4. 预演可恢复量:从锚点回溯,量出上游链有多长、覆盖到哪一天。本次实测上游 658 条,最早回到六天前。
  5. 在副本上缝合,原文件不动:原文件当时有活进程正在往里追加,就地改写会冲突,所以复制成一份新会话 id 的文件;把链根那条记录的 parentUuid 改写为锚点的 uuid;同时把文件内的会话 id 字段统一改成新的 id 保持一致性。原文件一个字节都不动,失败可以无限重来。
  6. 用新会话 id 重新 resume。

验证做了两层,缺一不可。结构验证:缝合后活动链从 342 条涨到 1016 条(随对话继续增长到 1086),覆盖范围从「六天前」到现在,实际载入 token 从约 200k 升到 411.8k。行为验证(更硬):向恢复后的会话提一个只有深层历史才答得出的问题——「那六天里主要在忙哪些项目」。它答出了具体项目、具体数字、具体踩坑细节,而这段在残缺版里是完全空白的。这一步比任何数字都可信。

诚实边界

缝合后是 411.8k,而重启前进程内存里是 603k,少约 190k。差额来自两处:盘满期间从未落盘的那些记录永久丢失,以及部分未挂在主链上的分支记录不会被载入。这是「恢复了绝大部分」,不是 1:1 还原

另外,上游链的根本身是一条更早的压缩摘要记录——说明更早的历史在几天前就已经被正常压缩过了,不是这次事故丢的。「这次丢的」和「本来就压缩过的」得分清楚,损失和功劳都不该记到错的账上。

盘为什么会满

一台约 97G 的云服务器上同时承载着:30 多个长期运行的会话,每个会话的记录文件本身在持续增长(主会话单个已到 20MB 量级);多个比赛项目的产物,单个模型 checkpoint 300MB+、一组就是 1.6GB,训练数据集 10GB 级;备份校验产生的中间文件,为了做 md5 回验需要把 GB 级归档从云端拉回本地;以及各类缓存,浏览器自动化缓存近 2GB、包管理器缓存、模型库缓存等。

直接触发是一次抢救性备份期间同时保留了多份冗余副本,叠加若干子任务的临时下载,把最后的余量吃光。

结构性原因说得更清楚一点:多个独立的自动化主体共享同一块磁盘,各自都「只多用了一点」,没有任何一方感知全局水位。

可迁移的检查清单

磁盘水位告警设在 90%,不是等 100%。 到 100% 时损坏已经发生了,告警只能报丧。

任何 append-only 的状态存储,都要把写入失败显式报出来。 ENOSPC 下的静默失败是这次事故的全部根源——如果那一刻有一行错误日志,就不会有后面两天的潜伏。

重启是唯一的真验证。 内存里的状态会掩盖磁盘上的损坏,「看着正常」只能证明进程还活着。想确认持久化状态没坏,只有冷启动一次。

修复走副本,原件一个字节不动。 尤其是有活进程正在写的文件。副本上失败可以无限重来,原件动坏了就没有第二次。

验证要用独立于显示层的证据。 显示层可能读缓存、读残留值。数字对不上时,先怀疑「这个数字是谁的、什么时候的」,再怀疑数据本身。

行为探针胜过一切指标。 让系统做一件只有状态完好才做得到的事,比读任何仪表盘都可靠。

修好了数据,必须同步更新指向它的自动化配置。 这次开机自动恢复脚本里硬编码了旧的会话 id,导致修复之后的一次意外重启,又把残缺版拉了回来。自动化会忠实地重放错误状态。

做取证前先统一时间基准。 记录里存的是 UTC,机器墙钟是本地时区,差 8 小时。分析时一度拿本地时间去比对 UTC 时间戳,得出「最近半天的记录都不见了」的错误结论,差点基于错觉去做破坏性操作。

冗余 < 错误 < 丢失。 磁盘紧张的正解是加盘或转储到外部存储,不是删备份副本。为了腾几个 G 删掉一份冗余,可能换来不可逆的损失。相关的一条:删「冗余」前必须 diff 完整内容——同一目录下几 KB 的统计/配置文件最容易被「只拷大件」的备份漏掉,而它们往往是致命的。本次另一起事故正是备份只拷了几百 MB 的主文件、漏了同目录几 KB 的归一化统计,导致成果差点无法使用。

被质疑时,先去找客观证据,而不是给出下一个猜测。 这次真因是在用户第三次质疑之后才找到的;前两次给的都是听起来合理、但没有证据支撑的归因——先怪模型版本,再怪切换动作。猜测的成本不是零,它会把排查引向错误方向,浪费掉最宝贵的时间。


最后留一条:这次能救回来,靠的是 append-only。正因为没有覆盖写,断链之前的记录还完整躺在文件里,才有缝合的可能。如果是原地覆盖的存储,这次就是永久丢失,什么方法都没有。