Skip to content
Go back

长任务状态管理:跑两小时的任务怎么不丢状态

长任务状态管理:跑两小时的任务怎么不丢状态

一句话结论(30s)

长任务难,难在「状态」这个东西没有一个天然的安放之处。Agent 跑一次几十步的任务,每一步产出的中间结论、已通过的检查点、还没做完的步骤,如果只存在对话上下文里,那么中途断掉、token 用完、/clear 一按,就全没了。工程上的解法和分布式系统、数据库崩溃恢复是同一套:显式状态机(任务走到哪一步要有字段记录,而不是靠「记得」)、checkpoint / 断点续跑(定期把中间状态落盘,断了从最近一个点续跑)、任务队列(把大任务拆成小步骤,每步独立可重跑)、幂等(重跑不会重复产生副作用)。核心一句话:状态要落在外部存储,而不是落在模型脑子里。 模型说「我做到第 5 步了」不可信,可信的是磁盘上那个字段。

这不是理论空谈。Claude Code 用一个全局 state 单例管会话身份和会话目录,Codex 用 thread-store 把线程历史落成 .jsonl、用 message-history 落全局消息历史——它们都是「把状态搬出脑子」这句话的工程落地。

核心原理(2min)

普通 LLM 调用是无状态的:输入 → 输出,一次往返,模型不记得上一轮说过什么。Agent 的长任务本质是「跨了很多次模型调用、很多个工具执行、甚至很多天的一次分布式计算」。这种计算里,状态可能存在三个地方,只有第三个可靠:

  1. 对话上下文——自回归模型靠「重读历史」来维持记忆,这是最脆弱的地方:token 用完、/compact/clear、进程崩溃,全没了。
  2. 运行时内存——Agent 框架里可能有 self.step = 3 这样的变量,但进程一重启就清零。
  3. 外部存储——文件、数据库、消息队列。这是唯一能跨进程、跨会话、跨机器存活的地方。Claude Code 的会话 transcript 是 <sessionId>.jsonl,Codex 的线程历史是 codex-rollout 写的 .jsonl,全局消息历史是 ~/.codex/history.jsonl——它们都选了第 3 种。

长任务状态管理的核心命题一句话:凡是不能丢的东西,必须从「上下文 / 内存」这种易失介质,尽早、频繁地搬到「磁盘」这种持久介质上。剩下的所有手法——状态机、checkpoint、队列、幂等——都是这个命题的四个推论。

💭 思考:为什么说「模型说做到第 5 步了不可信,可信的是磁盘上的字段」?——从「状态到底放哪才靠得住」反推:状态放在上下文里,token 用完、/compact/clear 一按就蒸发;放在运行时内存里,进程一崩就归零;只有外部存储能跨进程、跨会话、跨机器存活。更关键的是,「模型自述进度」这个行为本身不可信——它是预测下一个 token 的机器,「我做到第 5 步了」可能只是一句顺口的话,而不是事实。所以工程上要反过来:不是「问模型做到哪了」,而是「读字段」——恢复时读 step,而不是让它回忆 step。如果不这样,你就把一个必须精确的状态,交给了一个天然不精确的载体。

一、长任务到底难在哪

先量化一下「长任务」有多长。一个完整的多步研发任务——理解需求、写代码、跑测试、修 bug、再回归——一步可能涉及几十次工具调用,一次完整跑下来是几百次 Tool Loop 迭代、几十万 token、横跨数小时甚至数天。这中间任何一个点都可能断:

断点后果
网络超时 / 模型 API 限流这一轮循环中断,前面的产出还在上下文里
token 用完(窗口满)被迫 /compact/clear,上下文被压缩或清空
进程崩溃 / 机器重启内存里的 self.step 归零
用户说「先做到这,明天继续」会话结束,下次是全新上下文

问题不在于「断了会怎样」——断了总能重跑。问题在于重跑的代价不对称:前面已经花了 2 小时、通过了 5 个检查点,如果状态丢了,要么从头再来(浪费 2 小时),要么凭记忆续跑(大概率续错,因为模型对「我做到了第几步」的记忆本身就不精确)。

这就是「多步任务需要记忆走到哪一步」的根因:Agent 的每一步是独立的、无状态的模型调用,但任务整体是有状态的、有顺序依赖的 DAG。这两个事实之间的落差,必须由工程手段来填。

二、四种工程手法

2.1 状态机:把「走到哪一步」变成字段

最直接的手法是不让 Agent「记得」状态,而是让状态成为可检查、可写回的数据。一个任务最少要有这几个状态:

pending → in_progress → completed

              ├──→ blocked(等外部输入,挂起)
              └──→ abandoned(放弃)

关键点有两个:

第一,状态要落在外部存储,而不是模型脑子里。 模型说「我做到第 5 步了」这句话不可信——它可能是幻觉。可信的是一个 JSON 文件里的 statusstep 字段。Agent 恢复时读的是字段,不是让模型回忆。

Claude Code 的全局 state 单例就是这个思路的活样本:state.ts 里一个模块级的 const STATE 装着 sessionIdprojectRootsessionProjectDirparentSessionId 等字段,源码注释直接写着「DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE」——全局状态是刻意收敛的,不是随便塞的。而且会话切走时 switchSession()sessionIdsessionProjectDir 一起原子切换(没有两个独立的 setter),从根上杜绝了两个字段漂移不一致。这正对应「状态机配套迁移副作用」——迁移不能只改一个 enum,关联字段要一起动。

Codex 更直白:thread-storeStoredTurnStatus 枚举就四个值 Completed / Interrupted / Failed / InProgress。一个 turn 是正常走完、被中断、还是失败、还是仍在进行中,是落盘字段,不是模型自述。

第二,状态机要配套状态迁移的副作用。 状态不是孤立的 enum,每次迁移都要有动作。一个可恢复的任务状态,最少要回答三个问题——「现在到哪了」「下一步干什么」「断过几次了」:

{
  "status": "in_progress",
  "step": 5,
  "next_step": "跑测试",
  "last_completed_at": "2025-11-29T09:30:00Z",
  "restart_count": 1
}

这几个字段一起构成一个可恢复的「任务坐标系」。

一个容易被忽视的设计:状态机必须能表达「失败」和「回退」。比如「代码审查发现阻断问题 → 回退到写代码 → 重新审查」,这种回退环如果只靠模型自律,模型会在同一个问题上反复硬撑。所以要把回退上限写进状态机:回退计数到某个阈值就强制转成 blocked,挂起等人工介入。用状态机把工程纪律硬编码,而不是寄希望于模型自觉。

2.2 Checkpoint / 断点续跑:定期落盘

状态机回答了「状态长什么样」,checkpoint 回答了「状态多久存一次、断了怎么恢复」。

核心算法就三步:

1. 每步完成后 → 更新状态字段(step、done_steps、风险)
2. 序列化成 JSON
3. 原子写入:先写 .tmp → 再 rename(防止写一半崩溃留下脏文件)

先写 .tmp → rename 这个细节很关键:如果直接覆写原文件,写到一半进程崩了,磁盘上就剩半截 JSON,恢复时解析失败,状态文件变成「损坏」——比没有还糟。原子写保证磁盘上任何时刻都是一个完整的旧版本或完整的新版本。

同一个目标,Codex 用的是另一种实现:codex-rollout 把整个线程历史写成 append-only 的 .jsonl(注释原话:so sessions can be replayed or inspected later),每一行一条 RolloutItem。为什么 append-only JSONL 而不是反复覆写一个大 JSON?因为追加天然原子友好——message-history 更进一步:写入时把整行准备好,用单次 write(2) + O_APPEND + 文件锁落盘,保证多进程并发追加不交错。两种手法殊途同归:磁盘上任何时刻要么是完整旧行、要么是完整新行,永远不会出现「半截记录」。

💭 思考:为什么 checkpoint 落盘要用「先写 .tmp → rename」,而不是直接覆写原文件?——从「最坏情况会怎样」反推:直接覆写,写到一半进程崩了,磁盘上剩半截 JSON,恢复时解析失败——状态文件从「完整的旧版」变成了「残缺的新版」,比没有还糟。原子写保证磁盘上任何时刻要么是完整旧版、要么是完整新版,永远不存在中间态。append-only JSONL 是同一目标的另一种实现:追加天然原子友好,配合单次 write + O_APPEND + 文件锁,多进程并发追加也不会交错。如果不这样,你就是在用「省一行代码」换「崩溃时把状态永久打烂」的风险——而崩溃恰恰是长任务里最不可能避免的事。

落盘时机同样要显式化,不能靠「模型想起来就存」。Codex 的 LiveThreadpersist(PersistContext),而 PersistContext 只有两档:Standard(把线程和所有 queued items 落成 durable、可读)和 TurnStart(一个 turn 开始采样前落盘);再配 flush(强制刷盘)和 shutdown(关 writer 前刷干净)。这就是「每步落盘」被编码成了 API 约束,而不是口头约定。

断点续跑的恢复逻辑:

1. 启动时读状态文件,不存在 → 新任务
2. 存在但很久没动 → 提示用户:继续旧任务还是开新任务?
3. 存在且已完成 → 提示:上一任务已完成
4. 存在且进行中 → 恢复 step / next_step / 未关闭的风险,
   从 next_step 继续,跳过已经做完的步骤

这套逻辑在真实实现里都有对应:Codex 的 LiveThread::resume 读回 StoredThreadHistory,从已有历史继续 append;Claude Code 会话恢复时 setCostStateForRestore() 把累计成本、API 时长、增删行数、分模型用量从磁盘灌回内存,让「已花的钱和时间」跨进程继续累加,而不是清零重算。

注意第 4 步的「跳过」:恢复不是「重放」,是「跳过已完成」。一个正确的 checkpoint 系统,重跑时已经做完的步骤(尤其是那些有副作用、花钱、耗时的步骤)要被识别并跳过,而不是无脑重来。Codex 的 StoredModelContext 说的就是这件事——「重建最新 model-visible context 所需的已落盘 rollout items」:恢复时不需要把全量历史重放一遍,只要取出「能重建当前上下文」的那段可续跑后缀。这就是下一节「幂等」要解决的。

2.3 任务队列:把大任务拆成小步骤

「跑两小时的任务」如果是一整个不可分割的怪兽,checkpoint 也救不了——因为中间没有可停的点。所以长任务管理的前提是任务本身就是可拆的、有明确边界的步骤序列

拆分体现在两个层面:

层面一:流水线本身就是队列。 需求理解 → 写代码 → 审查 → 测试 → 交付,每个步骤有明确的输入、工具、输出,步与步之间是 DAG 依赖。执行引擎按拓扑顺序推进,已经完成的步骤就是「队列里已经出队的元素」。

层面二:把自然语言任务解析成 DAG。 用户说「先理解项目架构,然后追踪支付接口调用链,最后检查测试覆盖」,可以解析出 理解架构 → 追踪调用链 → 检查覆盖 的串行 DAG,无依赖的阶段可以并行执行。DAG 的好处是每个阶段是独立的 checkpoint 单元追踪调用链 失败,不影响 理解架构 已经完成的事实。

「队列」在 Codex 里不是比喻,是真实存在的一层:thread-store 里有 QueueStore 接口,提供 enqueue / list_page / reorder / delete,专门持久化有序的、thread 级的待处理用户消息,还带 MAX_QUEUE_ITEMS 上限。PersistContext::Standard 的语义就明确写着「persists all queued items」——落盘 = 把队列里排队的项全部刷成 durable。队列天然就是 checkpoint 的单元:出队一项,就多一项可恢复的进度。

拆分的工程收益:

  1. 粒度变细,checkpoint 变密——每步完成就是一次天然的状态落盘点;
  2. 失败影响面变小——第 5 步失败不需要重跑第 1-4 步;
  3. 可并行——无依赖的步骤同时跑,缩短总时长;
  4. 可暂停——「做到第 X 步停」有了明确的停点。

但要警惕一个反模式:拆得太碎,编排成本、数据传递成本会吃掉收益。拆分的粒度 = 一个步骤的产出能作为一个清晰的状态单元。

2.4 幂等:重跑不能重复产生副作用

这是四种手法里最容易被忽略、后果最严重的一个。前三个手法都假设「重跑是安全的」,但现实里很多步骤有副作用:

幂等的目标:同一个步骤,用同样的输入重跑一次,效果和跑一次完全一样。三种常见实现:

① 用步骤 ID 做去重。 每个步骤带一个 step_id,执行前先查「这个 step_id 是不是已经完成过了」:

def run_step(step_id, fn):
    if state.has_done(step_id):
        return state.outputs[step_id]   # 已做过,直接返回上次结果
    result = fn()
    state.mark_done(step_id, result)
    return result

② 把「执行」和「记录完成」放进同一个原子操作。 这其实就是数据库事务的语义:副作用和状态落盘要么都成功,要么都回滚。如果步骤执行了但状态没写上,重跑时就会重复执行。

③ 对不可逆副作用单独加锁。 像「创建工单」这种真·不可逆的动作,必须在执行前检查是否已经执行过,或者改成可查询的幂等接口(「相同参数创建工单返回同一个工单号」)。

幂等的关键在于把「去重键」落到存储层,而不是靠业务逻辑自觉。Codex 的 rollout 就是这么干的:每条 RolloutLine 在 paginated 模式下带一个单调递增的 ordinal,重放/续写按 ordinal 定位去重,同一条记录重放不会生成新的序号;元数据更新走的是 ThreadMetadataPatch字段级 literal patch——每个字段 None 表示「不变」,Some(None) 表示「显式清空」,语义可精确重放,不会因为「上次更新到一半」而把字段覆盖错。

断点续跑 + 无幂等 = 重复副作用灾难。 这两件事必须成对出现:checkpoint 让你「能重跑」,幂等保证你「重跑不出事」。

💭 思考:为什么「断点续跑」必须和「幂等」成对出现?——从「重跑会发生什么」反推:checkpoint 让你「能重跑」,但如果步骤有副作用(发邮件、扣款、git commit),重跑就会重复执行这些副作用。前三个手法都默认「重跑是安全的」,可现实里很多步骤重跑一次就是灾难。所以幂等不是锦上添花,而是 checkpoint 的安全保险:checkpoint 负责「能停下来」,幂等负责「停下来的地方能安全地再跑一遍」。如果不这样做,你建了一个断点续跑的系统,结果一断就重复扣款、重复发邮件——续跑能力反而变成了事故放大器。

三、上下文压缩与状态管理的关系

长任务跑到一半,上下文满了,怎么办?这是长任务和「上下文管理」交汇的地方,也是大多数人第一次踩坑的地方。

先说清一个事实:上下文压缩(/compact)本质上是「有损压缩」。它把前面的对话总结成一段摘要,替换掉原文。信息必然丢失——尤其是那些「散落在各步工具输出里的细节」,总结会丢掉,而这些细节往往正是后续步骤需要的。

所以压缩和状态管理的关系是:压缩会销毁「存在于上下文里的状态」,所以必须在压缩发生之前,把状态搬出去

这里有一个容易想当然的错误:很多人以为「压缩 = 摘要 = 状态不丢」。错了。摘要保留了「做过什么」的轮廓,但丢了「做到什么程度、每一步的具体产出、未关闭的风险」这些结构化的、可继续执行的状态。摘要能让模型「记得聊过支付接口」,但没法让模型「从第 5 步的下一步精确地继续」——因为第 5 步的具体产出、第 3 步遗留的 bug、第 4 步还没跑的测试,全被摘要抹平了。

工程上的正确顺序是:预判到窗口快满时,主动把状态落盘,然后才允许压缩。 落盘之后,/compact/clear 随便发生——因为状态已经在磁盘上了,新会话读状态文件就能恢复。

真实实现里,「压缩前先把要保命的东西搬出去」是被显式写进代码的:Claude Code 的全局 state 单例里专门有个 invokedSkills map,注释写着「Track invoked skills for preservation across compaction」——压缩发生前,先把调过的技能内容存进内存状态,别让压缩把它们一起卷走。Codex 则把「压缩」本身也当作一类持久事件:codex-rollout 的落盘策略明确持久化 Compacted / ContextCompaction 这类执行标记(注释:Persist Codex executive markers so we can analyze flows (e.g., compaction, API turns)),让「什么时候压缩过、压缩了什么」成为磁盘上可回放的事实,而不是只存在于上下文里的转瞬记忆。

这一点在 Claude Code 里还可以落到 hook 机制上:它提供了 PreCompact(压缩前)这样的生命周期钩子,可以挂一段脚本,在压缩发生前先把状态 flush 出去。它的价值在于它是强制时机,不是 Agent 的自觉——Agent 可能「忘了」在压缩前保存状态,但 hook 不会忘,因为它是 harness 在固定时机自动触发的,不依赖模型记得。这就是把状态管理从「模型行为」变成「系统保证」的关键一步。

一句话:上下文是工作台,状态文件是仓库。工作台太小放不下时,不是把东西扔了,而是先搬进仓库再清台面。

💭 思考:为什么「压缩前先落盘」这个顺序不能反?——从「压缩的代价」反推:/compact 是有损压缩,它把原文替换成摘要,散落在各步工具输出里的细节(第 5 步的具体产出、第 3 步遗留的 bug、第 4 步没跑的测试)全被抹平。摘要能让模型「记得聊过支付接口」,但没法让它「从第 5 步的下一步精确继续」。所以压缩会销毁「存在于上下文里的状态」,而这些状态正是后续步骤需要的。正确顺序是「预判窗口快满 → 先落盘 → 再允许压缩」,而不是等压缩发生了才想起状态没存。如果不这样,/compact 一过,你能恢复的只剩一句「做过什么」的轮廓,而不是「做到什么程度」的坐标——两小时的任务就此断头。

总结

长任务状态管理,本质是把「存在模型脑子里的状态」搬成「存在磁盘上的数据」。四条手法构成一个完整的闭环:

上下文压缩是这些手法的压力测试点:压缩会销毁上下文里的状态,所以压缩前必须先落盘——把它做成 hook 的强制时机,而不是模型的自律(Claude Code 的 invokedSkills 跨压缩保存、Codex 把压缩标记持久化,都是同一件事的不同侧面)。

一句话收尾:跑两小时的任务不丢状态,靠的不是模型记性好,而是每个关键时刻都有一份落盘的状态文件。模型可以忘,磁盘不会。

章末提问

追问 1:长任务的状态为什么不能放在对话上下文里?

结论先行:上下文是易失介质,只有外部存储能跨进程、跨会话、跨机器存活。 因为:状态放上下文,token 用完、/compact/clear、进程崩溃都会丢;放运行时内存,进程一重启就归零;只有文件、数据库、队列能持久。而且模型「自述做到第 5 步」不可信,可信的是磁盘上的字段——所以核心是「状态要落在外部存储,而不是模型脑子里」。

追问 2:checkpoint 怎么做?「先写 .tmp 再 rename」有什么用?

结论先行:每步完成后序列化落盘,用原子写保证磁盘上任何时刻都是完整版本。 因为:直接覆写原文件,写到一半崩溃会留下半截 JSON,恢复时解析失败,状态文件比没有还糟。原子写保证任何时刻要么完整旧版、要么完整新版;append-only JSONL 是同一目标的另一种实现,追加天然原子友好。

追问 3:恢复时为什么要「跳过已完成」而不是「重放」?

结论先行:重跑有代价——已完成的步骤可能有副作用、花钱、耗时,必须识别并跳过。 因为:checkpoint 的价值在「从最近的断点续跑」,不是「从头重放」。正确做法是读回状态字段(step / next_step),从 next_step 继续,跳过已经做完的步骤;Codex 的 StoredModelContext 就是只取出「能重建当前上下文」的可续跑后缀,而不是全量历史重放。

追问 4:幂等和 checkpoint 是什么关系?不幂等会怎样?

结论先行:它们必须成对出现——checkpoint 让你「能重跑」,幂等保证你「重跑不出事」。 因为:checkpoint 默认「重跑安全」,但发邮件、扣款、git commit 这类副作用重跑就重复执行。幂等用步骤 ID 去重、原子提交、不可逆副作用加锁,把「去重键」落到存储层。只有 checkpoint 没有幂等,断点续跑能力反而变成「重复扣款、重复发邮件」的事故放大器。


Share this post on:

Previous Post
Claude Code vs Codex:两种 Agent 编排哲学
Next Post
Subagent 与 Skill 注入:多 Agent 编排的艺术