我遇到过一个看起来很简单、实际很容易吵起来的问题:到底有多少个 AI 会话没有做完?一个统计结果说有一批,另一个统计结果却少了很多。两边都不是完全乱数,文件也确实在那里,最后更新时间也确实不同,可一旦要决定哪些任务需要续跑,数字就对不上了。
当时最自然的做法,是把会话文件列出来,再根据文件数量、最后更新时间,或者最后有没有一段像样的回复来猜。这个办法很快,却把“文件存在”和“任务完成”混成了一件事。后来我停下来重新看问题,发现真正应该问的不是“有多少文件看起来没收尾”,而是“每个会话最后留下了什么终态证据”。
数量为什么会把人带偏
第一次看到两个统计结果时,我也下意识想找一个更合理的数量。一个系统把未完成项报得很多,另一个系统报得少一些。表面上看,这是扫描范围或过滤条件的差别;实际上,双方都在用间接信号替代完成状态。
文件多,不代表未完成。一次正常结束的会话也会留下一个文件;文件最近被修改,也不代表任务正好卡在那里;最后有文字,更不代表那段文字已经形成了可交付结果。有些会话会在接近结束时写入事件,有些会话会因为中断只留下半截记录。只看外观,当然会把不同情况混到一起。
判断会话是否完成,第一证据应该是终态事件,而不是文件的外观。
如果不处理这个区别,后果不只是统计数字难看。续跑时可能重复执行已经完成的任务,也可能漏掉真正被额度挡住的工作;更麻烦的是,后续 Agent 拿到一张没有依据的清单,只能再次猜测,错误会沿着交接继续放大。
先定义什么叫完成
我后来把判断顺序倒了过来:先定义真值,再去扫描文件。正常完成的 turn 会写入 task_complete 事件。这个事件不是“最后一句话看起来像总结”,而是系统明确记录的一次收束动作。只要找到它,至少可以证明这一次执行走到了正常终点。
但 task_complete 也不能被简单理解成“所有结果都成功”。额度挡停时同样可能出现 task_complete,只是事件内部带有 error.codex_error_info=usage_limit。这类会话在流程上收束了,在任务上却没有完成。它需要续跑或调整资源,不能被统计器当作普通成功项。
这一步让我意识到,终态判断至少要分两层:先看有没有收束事件,再看收束事件的原因。前者回答“流程有没有走到尾部”,后者回答“这个尾部是不是可交付的尾部”。
有终态不等于有成果,终态的类型才决定下一步动作。
因此,正常结束和额度挡停不能用同一个“已结束”标签覆盖。前者通常可以排除出待续跑清单,后者则应该保留任务上下文,并标记为需要恢复。把这两个状态分开,统计数字也许不如一个总数漂亮,但行动会准确得多。
没有终态时,不要急着补结论
第三种情况更容易引发误判:流水文件到某个位置就停了,尾部没有 task_complete。这并不能直接证明任务失败,也不能证明用户主动中止。它只说明,在现有记录里没有看到正常收束。
如果尾部留下 turn_aborted,通常可以把它归为用户主动打断;如果连这个事件也没有,更保守的判断是中途断掉,原因可能是进程崩溃、环境退出,或者记录没有来得及写完。这里我不愿意凭“最后一句话”补一个确定结论,因为那会把未知伪装成已知。
这也是整个方法里最需要克制的地方。人很容易看到一句“我接下来会……”就认为任务还没做完,也容易看到一句完整答复就认为任务已经做完。但自然语言是内容,终态事件是状态。内容可以停在半路,也可以在没有真正提交结果时显得完整。
错误捷径就是按文件数量、更新时间或有没有最后一句话来猜哪些会话没做完。它的危险不在于偶尔错一个,而在于它没有办法说明自己为什么这样判断。没有理由的清单,无法被另一个 Agent 复核,也无法在下次规则变化时稳定重算。
从总数转向尾部分类
我选择按每个会话流水的尾部事件分类,是因为它既接近系统真实状态,又能留下可审计的依据。具体流程并不复杂:先读取每个会话最后一段结构化事件,确认是否出现 task_complete;出现后,再检查是否带有用量限制错误;没有正常收束时,继续检查是否有 turn_aborted;两者都没有,就归入中途断掉,等待人工或后续流程判断。
这样得到的不是一个“未完成总数”,而是一张有原因的状态表。正常完成、额度挡停、中途断掉、用户中止,分别对应不同的行动。正常完成不再续跑;额度挡停要暴露恢复线索;中途断掉要保留上下文并谨慎恢复;用户中止则不能擅自替用户重新启动。
可靠的清单不是把对象分成完成和未完成,而是让每一种终态都对应明确的下一步。
这里还有一个边界不能省略:从属线程。初次统计的 30 个未完成项里,有 15 个其实带有 parent_thread_id,它们是子代理线程,不应该和主线程一起计入需要续跑的任务。子线程可能正常结束,也可能因为父任务停止而留下记录;如果不先识别从属关系,统计结果会把一个任务放大成多个任务。
我会先按终态分类,再根据 parent_thread_id 排除从属线程,而不是一开始就把所有文件平铺计数。顺序很重要。过早排除可能丢掉有用的诊断证据,过晚又会让交接清单混入不能独立恢复的对象。保留原始上下文和转录位置,同时只把主线程放进待续跑清单,才兼顾了追查和执行。
清单不仅要告诉人,也要告诉下一位 Agent
判断完成状态的价值,不在于这次得出一个更接近真实的数字,而在于结果能不能被别人接着用。最终我会把主线程待续跑项输出到固定位置:机器读取的 JSON 记录状态、任务上下文和转录位置;人读取的 Markdown 交接文档说明为什么要续跑;每一项再附上统一格式的续跑命令。
这3种输出承担的职责不同。JSON 适合筛选和再次统计,Markdown 适合人快速理解,续跑命令适合直接行动。只留一种形式,都会让某一类使用者重新猜测。尤其是只给一个总数时,接手者不知道哪些是额度问题,哪些是中途崩溃,也不知道应该从哪一轮继续。
我没有把真实标题、绝对位置和恢复时间全部暴露出去,是因为交接的目标是恢复工作,不是扩大敏感信息的传播范围。保留事件分类、任务标识和可定位的转录引用,已经足够支撑下一步;把无关的环境细节塞进清单,只会增加误用风险。
表面上我是在整理一批会话,其实是在建立一种“可被接手”的状态表达。一个流程成熟的标志,不是它能在当前机器上跑通一次,而是下一位执行者不需要重新猜测你当时的判断。
这套方法可以复用到哪里
这次的具体事件属于会话流水,但方法并不只适用于 Agent。任何需要判断“是否完成”的批处理,只要有明确的终态记录,就应该优先读终态,而不是从文件数量和时间戳反推。订单、导出任务、备份、异步队列,都有类似问题:结束、失败、取消、超时,外观可能相近,后续动作却完全不同。
复用时我会守住4条规则。第一,先写清楚什么事件代表正常完成;第二,把“流程停止”和“业务成功”分开;第三,对没有终态的对象只做保守分类,不替未知补原因;第四,把主对象和从属对象分开统计,并把恢复所需上下文一并输出。
如果某个系统还没有终态事件,我不会先做更复杂的猜测器,而会先补最小的状态记录。因为猜测器越聪明,越可能把错误包装得更像结论;一个朴素但稳定的终态,反而能让后面的统计、告警和交接共用同一份真值。
这件事让我重新意识到,自动化并不等于把所有判断都交给脚本。真正可靠的自动化,是先把判断依据固定下来,再让脚本忠实执行。数字只是结果,事件才是证据。
下次我会怎么做
下次再遇到“有多少会话没做完”这类问题,我会先问:系统有没有写出终态?如果有,就从尾部事件开始;如果没有,就明确标记证据不足,不用更新时间替它投票。看到 task_complete 后,我会继续检查是否是用量限制;看到 turn_aborted,我会保留用户中止的语义;什么都没有,就先放进中途断掉,而不是编造一个更具体的原因。
然后我会识别父子关系,把带有 parent_thread_id 的从属线程从主清单中剔除,但不删除它们的诊断记录。最后输出固定格式的机器清单、人类交接说明和续跑入口,让统计、复核和行动使用同一套分类。
我想留下的不是“这次找到了多少个未完成项”,而是一条以后还能复用的工作习惯:先找终态,再谈数量;先保留证据,再决定是否续跑。 当一个系统能清楚回答“它停在哪里、为什么停、下一步由谁接手”,未完成就不再是一个需要猜的数字,而是一个可以被管理的状态。