跳转至

第 30 节 · Harness:agent 跑一半崩了怎么办

agent 跑一半崩了,是重跑还是接着跑。这一节讲 Harness 的价值不在「能跑」,在「断了能续」。

一、一个跑一半崩掉的 agent,有多贵

先说结论:一个工作流跑崩,最贵的不是那一次崩溃,是崩溃之前已经干完的那部分活。

我这边的工作流引擎是这样的口径:一次只跑一个节点,顺序执行,不并发。 一个工作流被拆成若干节点,上一个节点的产出,就是下一个节点的输入。

这带来一个很具体的后果:它跑到一半崩了,而状态没存下来,你唯一的办法是从第一个节点重来。

重来一遍,损失的不只是那几个节点的生成时间。真正贵的是上下文——每个节点都要把前面积累的东西重新读进去;而上下文是这套系统里最贵的那块地(第 26 节)。前面干得越多,重来的代价越大。

没有 Harness 的 agent 编排,像一条不能存档、不能续关的游戏:玩到第 8 关崩了,你只能从头把第 1 关到第 7 关再打一遍——哪怕那 7 关早就打过了。

那能不能干脆别拆节点、一步到位?也不能。任务越复杂,越需要拆成多个节点互相质检;而拆得越细,中断的风险就越大。拆分解耦是好事,但它同时把"中断"从"偶尔发生"变成了"一定会发生"。

Harness 就是为了这一件事存在的。

图 A-30-1(待出):同一条工作流崩在中途——没有状态,前面的节点全部重跑;有状态,从崩掉的那个节点接上

二、引擎把状态写在哪、怎么写得不怕崩

这个引擎的第一条设计原则:状态存在文件系统里,不用数据库。 一个工作流实例对应一个文件,一个实例一个文件。

为什么不上数据库?因为数据库解决的是"很多人同时改一份数据"的问题,而我这台机器上的问题是另一个——一个人,怎么保证自己被打断之后还能接上。 这里没有并发、没有多个写入者,只有一个进程、一次一个节点。为这种规模上数据库,是给独木舟装船舵液压系统。

顺着"存在文件里",还有三条硬约束:

第一,原子写。 状态不是直接覆盖原文件,而是先写一个临时文件、写完整了再改名成正式文件。改名在同一个文件系统上是原子操作——要么是旧文件,要么是新文件,不存在"写了一半"的中间态。 这防的是最恶心的一种崩法:没崩干净,留下一个半真半假的状态文件。它没丢,但它说的不是真的——这比丢了更难查。

第二,单文件上限 100KB。 一个状态文件超过 100KB,说明这个状态本身的设计出了问题:混进了不该混的东西。状态文件应该只记"走到哪了、产出在哪",不记"干了什么"。 这条上限不是省空间,是逼你把状态和流水账分开。

第三,终态可清理。 完成、中止、失败这三种"事情已经结束"的状态文件,30 天后自动清掉。不然状态目录会变成一个只进不出的仓库,一年后谁都不敢删。

图 A-30-2(待出):状态文件的两种写崩方式——直接覆盖(崩了留下半截)/ 原子写(要么旧、要么新)

三、什么状态能被续、怎么续

工作流级的状态一共五个,但不是每个都能续:

状态 含义 能不能续跑
运行中 正在跑 否
已暂停 暂停 能
已失败 失败 能
已完成 正常收尾 否(终态)
已中止 明确放弃 否(终态)

注意这个不对称:只有"已暂停"和"已失败"能被续跑。 已完成和已中止都是"这事已经完了"——一个正常收尾、一个明确放弃,都不必再动它。能续的,只有这两种"停在半路、但还活着"的状态。

续跑协议只有三步:

  1. 读状态文件;
  2. 打印两行字——"上次完成到哪一步"、"下一步是什么";
  3. 从"就绪节点"继续跑。

"就绪节点"的定义也很朴素:它的上游节点全部完成了,它就是就绪的。 不需要记住"上次卡在哪",只要算一遍"现在还有哪个节点可以跑"。

还有一条顺手的设计:会话启动时,自动探测有没有没跑完的工作流,有就提示一句"上次那个还停在第 N 步,要不要接着跑"。但每个会话只许探一次——不然它会每次开口都念叨一遍,变成一个没人想听的提醒。

所以真正的难点不在"存不存状态",在这里:能续的前提,是状态得存成一个不会说谎的样子。 存错了的状态比没状态更糟——它会让你以为接着跑是对的。

四、跑不完怎么办:重试一次,然后交给人

这个引擎的默认错误处理只有一条:

重试 1 次;用尽了,交给人工。

为什么只有 1 次?因为这里的判断是:一个节点失败,第二次还失败,大概率不是"运气不好",是这件事本身有问题——要么输入不对,要么任务定义不对,要么这个节点就不该自动跑。这时候机器再试第三次、第五次,只是在烧时间,顺便掩盖真正的问题。

重试是给机器第二次机会;交人工,是承认这已经不是机器能决定的事了。

这个引擎目前只有三种节点:

  • agent 节点——派一个 agent 干活;
  • 闸门节点——检查上一步的产出,决定放行还是打回;
  • 人工节点——停下来等人回答。

闸门又分三种:

闸门 干什么 默认轮次
反馈环 审稿打回、返工、再审 最多 2 轮
Go-NoGo 判定通过 / 不通过 失败轮最多 2 次
人工批准 停下来等一句批准 —

反馈环和 Go-NoGo 两种带轮次上限,这跟第 31 节那套审稿分级是同一个思路:闸门是为了拦住错的东西,不是为了把人卡在循环里。 轮次用尽还没过,答案不是"再来一次",而是"升级给人工"——机器负责跑到它自己的边界,人负责决定边界之外的事。

人工节点被叫起来的时候,只有三个选项:重试 / 跳过 / 中止。没有第四个。

五、状态机在真实使用里长什么样

上面那台引擎,是我自己写的工作流引擎。而"状态机"这个东西,我更早在一处真实场景里用过——内容生产线的五阶段状态机。

先看它现在的规模,一共 50 个任务:

阶段 数量
待处理 23
就绪 15
已发布 6
已跳过 5
写作中 1

这 50 条里,有 5 条任务五个阶段全部真实走过一遍:从待处理、写作、审稿、就绪一直到已发布。其中 4 条的四段中间态,进入时间和离开时间都成对记着;剩下那条缺了 ready 那一段的记录——能拿出来看的"全程闭环",也就是这 4 条。

但状态机也翻过车,两处都很难看。

第一处,时间倒挂。 有两条记录的"离开时间"早于"进入时间"——它写着自己在一个"还没进入的阶段"里就已经离开了。源文件里能看到的就是这两处倒挂,至于成因,看不到;更像是回写时序的问题:先改了状态、后补了时间,补的时候用的可能是两个对不齐的时点。

第二处,一份派生快照落后了 2 小时 43 分。 状态文件是真相源,另有一份由它程序化生成的摘要给人看。我把两份摆在一起对过:摘要的生成时间,比源文件的最新更新时间早了 2 小时 43 分。结果就是,摘要里"待处理"还写着 7 个,源文件里其实已经是 23 个。

状态机怕的不是"没有状态",是"两个地方各记一份账"。 只要有一份是派生的,它就会落后;只要会落后,就会有人在拿旧数字做新决定。所以后来定的规矩是:数字只有一处能手改,其余全是生成出来的,而且必须标上生成时间。

图 A-30-3(待出):一条内容线状态机的 50 个任务分布(含五阶段全走完的任务,以及两处时间异常)

六、然后,诚实地收个尾

前面讲的那台工作流引擎——它从来没有真正跑过一次。

它的状态目录里只躺着一个占位文件,历史上没有任何一个实例的状态落过盘。引擎写完了,测试也过了,然后就没有然后了:没有一条真实的、跑到一半崩掉、再被续上的工作流。

所以关于这台引擎,"崩了能续"这句话,目前只能保证到两个地方:

  • 协议上是这么写的——状态怎么存、哪些状态能续、从哪个节点接着跑,都定义清楚了;
  • 单元测试覆盖到了——这台引擎共 68 条单元测试(其中状态机读写 16 条):创建、更新、完成、失败、跳过、续跑判定,这些都测过;终态清理有实现,没进单测。

但"测过状态机"不等于"崩过一次又续上了"。我们自己这台,也只在纸面上模拟过崩溃。

顺便说个对照。我另有一个真实落地的 agent——做视频粗剪的,输入是一整个素材目录。它处理失败的方式是:重试 6 次,指数退避,而且错误可见。但它没有任何续跑、没有任何检查点。也就是说,它崩在第三步,前两步的活照样白打——它记得重试,却不记得自己跑到过哪。

这两个东西摆在一起,正好说明同一件事的两半:有重试,不等于有续跑。 重试解决的是"这一次没成",续跑解决的是"这一整件事能不能接上"。绝大多数 agent 系统都有前者,很少真有后者。

七、为什么一个 AI System 需要 Harness

回到那条链:

Model → Small Model → Model Pool → Router → Context → Knowledge/Memory → Agent → Harness/State → Evaluation/Gate → 闭环/反馈 → AI System

Agent 那一节解决的,是"活能不能干成"。Harness 这一节解决的是另一件事:当一个 agent 跑了很久、很多步,中途一定会断的时候,活能不能接上。

这两件事看着像,其实是两种能力。能跑,是能力;断了能接上,才是基建。 一个只会跑、不会续的 agent,你只敢让它干短的活;一个崩了能接上的 agent,你才敢让它干长的活——而真正值钱的活,都是长的。

而这一节的诚实之处也在这里:我这台能续的引擎从没真跑过,那台真跑过的引擎又不会续。这两个半成品拼在一起,恰好是 Harness 这个零件完整的形状——它要的从来不是"跑得对",是"断得起"。

数据与出处

  • 一个工作流引擎的技能定义文件与五个模块(行数、三种节点、三种闸门、五个状态、默认错误处理、原子写与单文件上限、终态清理)
  • 该引擎的单元测试套件(全引擎 68 条,其中状态机读写 16 条,覆盖创建 / 更新 / 完成 / 失败 / 跳过 / 续跑判定;终态清理有实现、未进单测)
  • 该引擎的状态目录(只有一个占位文件,从未落盘过实例状态)
  • 一条内容线状态机的状态文件(50 个任务的阶段分布、5 条五阶段全走完的任务、两处时间倒挂)
  • 由该状态文件派生的一份快照(生成时间比源文件早 2 小时 43 分)
  • 一个视频粗剪 agent 的剪辑决策模块(重试上限 6 次,无检查点 / 续跑)