长任务状态管理:跑两小时的任务怎么不丢状态
一句话结论(30s)
长任务难,难在「状态」这个东西没有一个天然的安放之处。Agent 跑一次几十步的任务,每一步产出的中间结论、已通过的检查点、还没做完的步骤,如果只存在对话上下文里,那么中途断掉、token 用完、/clear 一按,就全没了。工程上的解法和分布式系统、数据库崩溃恢复是同一套:显式状态机(任务走到哪一步要有字段记录,而不是靠「记得」)、checkpoint / 断点续跑(定期把中间状态落盘,断了从最近一个点续跑)、任务队列(把大任务拆成小步骤,每步独立可重跑)、幂等(重跑不会重复产生副作用)。核心一句话:状态要落在外部存储,而不是落在模型脑子里。 模型说「我做到第 5 步了」不可信,可信的是磁盘上那个字段。
这不是理论空谈。Claude Code 用一个全局 state 单例管会话身份和会话目录,Codex 用 thread-store 把线程历史落成 .jsonl、用 message-history 落全局消息历史——它们都是「把状态搬出脑子」这句话的工程落地。
核心原理(2min)
普通 LLM 调用是无状态的:输入 → 输出,一次往返,模型不记得上一轮说过什么。Agent 的长任务本质是「跨了很多次模型调用、很多个工具执行、甚至很多天的一次分布式计算」。这种计算里,状态可能存在三个地方,只有第三个可靠:
- 对话上下文——自回归模型靠「重读历史」来维持记忆,这是最脆弱的地方:token 用完、
/compact、/clear、进程崩溃,全没了。 - 运行时内存——Agent 框架里可能有
self.step = 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 文件里的 status 和 step 字段。Agent 恢复时读的是字段,不是让模型回忆。
Claude Code 的全局 state 单例就是这个思路的活样本:state.ts 里一个模块级的 const STATE 装着 sessionId、projectRoot、sessionProjectDir、parentSessionId 等字段,源码注释直接写着「DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE」——全局状态是刻意收敛的,不是随便塞的。而且会话切走时 switchSession() 把 sessionId 和 sessionProjectDir 一起原子切换(没有两个独立的 setter),从根上杜绝了两个字段漂移不一致。这正对应「状态机配套迁移副作用」——迁移不能只改一个 enum,关联字段要一起动。
Codex 更直白:thread-store 里 StoredTurnStatus 枚举就四个值 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 的 LiveThread 有 persist(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 的单元:出队一项,就多一项可恢复的进度。
拆分的工程收益:
- 粒度变细,checkpoint 变密——每步完成就是一次天然的状态落盘点;
- 失败影响面变小——第 5 步失败不需要重跑第 1-4 步;
- 可并行——无依赖的步骤同时跑,缩短总时长;
- 可暂停——「做到第 X 步停」有了明确的停点。
但要警惕一个反模式:拆得太碎,编排成本、数据传递成本会吃掉收益。拆分的粒度 = 一个步骤的产出能作为一个清晰的状态单元。
2.4 幂等:重跑不能重复产生副作用
这是四种手法里最容易被忽略、后果最严重的一个。前三个手法都假设「重跑是安全的」,但现实里很多步骤有副作用:
- 发邮件、@ 值班人、创建工单 → 重跑就重复执行;
- 往数据库插数据、调支付接口 → 重跑就重复扣款;
- git commit、发 MR → 重跑就产生重复提交。
幂等的目标:同一个步骤,用同样的输入重跑一次,效果和跑一次完全一样。三种常见实现:
① 用步骤 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一过,你能恢复的只剩一句「做过什么」的轮廓,而不是「做到什么程度」的坐标——两小时的任务就此断头。
总结
长任务状态管理,本质是把「存在模型脑子里的状态」搬成「存在磁盘上的数据」。四条手法构成一个完整的闭环:
- 状态机——回答「状态长什么样」:落在外部存储的字段里,而不是模型的记忆里(Claude Code 的全局
state单例、Codex 的StoredTurnStatus枚举都是这个落地); - checkpoint / 断点续跑——回答「多久存一次、断了怎么恢复」:每步落盘 + 原子写 + 恢复时「跳过已完成」(Codex 用 append-only
.jsonl+ 单次write的原子追加、LiveThread::persist的显式落盘时机、resume读回历史续写); - 任务队列——回答「任务怎么才能被 checkpoint」:拆成有明确边界的步骤 DAG,每步是独立的状态单元(Codex 的
QueueStore持久化有序的待处理消息,出队一项即多一项进度); - 幂等——回答「重跑安不安全」:步骤去重 + 原子提交 + 不可逆副作用加锁(Codex 的 rollout
ordinal单调序号、ThreadMetadataPatch字段级 patch 都是可重放的去重键)。
上下文压缩是这些手法的压力测试点:压缩会销毁上下文里的状态,所以压缩前必须先落盘——把它做成 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 没有幂等,断点续跑能力反而变成「重复扣款、重复发邮件」的事故放大器。