长任务一定会被中断
网络会断,模型服务会限流,进程会重启,用户会关闭电脑,外部 API 也可能在返回结果前超时。
如果 Agent 只能在一段内存里的消息数组中工作,那么任何中断都可能让它忘记已经做了什么。更麻烦的是,外部世界不会跟着它一起回滚:邮件可能已经发出,工单可能已经创建,只是成功回执没有来得及保存。
负责让任务经得起这些情况的,是 Harness 第六个核心部分:状态与持久化。
它不只是“保存聊天记录”,而是记录任务执行中的事实、阶段和副作用,让系统可以回答:发生了什么、现在进行到哪里、接下来怎样安全继续。
本文是《把 Harness 讲清楚:Agent 背后的运行系统》八个核心部分的第六篇。
Harness 需要保存哪些状态
至少有五类:
| 状态 | 示例 |
|---|---|
| 会话状态 | 用户消息、模型回复、工具调用与结果 |
| 任务状态 | 目标、当前阶段、计划、待办、完成标准 |
| 执行状态 | 当前 Turn、Step、预算、重试次数 |
| 外部副作用 | 已创建的订单、已发送消息、已发布文章 |
| 控制状态 | 审批、拒绝、取消、暂停和恢复决定 |
这些状态的持久化要求不同。流式输出片段可以批量写入,付款结果和审批决定则必须尽快可靠保存。
为什么只保存最终消息不够
假设日志里只有:
用户:发布这篇文章。
Agent:文章已发布。
这无法回答:发布工具是否真的被调用?目标分类是什么?接口返回了哪个文章 ID?网络超时后是否重试过?最终结论来自真实回执还是模型猜测?
更可靠的记录应包括过程事实:
turn/start
user/message
assistant/message,包含 publish_article 调用
tool/call,携带调用 ID 与参数摘要
tool/result,返回文章 ID 23 和公开地址
assistant/message,向用户报告结果
turn/end,状态 completed
模型可见历史可以从这些事实投影出来,审计和恢复也使用同一份记录。
事件日志比可变快照更容易追踪
只保存一份不断覆盖的 current_state.json 很简单,却会失去变化过程。事件日志则记录每次变化:
{"seq": 101, "type": "step/start", "turn": 4, "step": 2}
{"seq": 102, "type": "tool/call", "call_id": "c17", "name": "run_tests"}
{"seq": 103, "type": "tool/result", "call_id": "c17", "exit_code": 0}
{"seq": 104, "type": "step/end", "turn": 4, "step": 2}
事件是仅追加的事实,当前状态则由事件折叠得到:
def reduce(state, event):
if event.type == "step/start":
state.current_step = event.step
elif event.type == "tool/result":
state.results[event.call_id] = event.result
elif event.type == "turn/end":
state.turn_status = event.reason
return state
为了提高读取速度,可以定期保存快照,但快照应能由事件重新构建,而不是成为另一份互相冲突的真相。
一次安全恢复的流程
进程重新启动
↓
读取最后一个持久检查点
↓
重放检查点之后的事件
↓
检查是否存在未闭合的 Turn、Step 或 Tool Call
↓
为中断状态补充明确结果
├─ 尚未开始 → 可以重新调度
├─ 已完成并有回执 → 直接复用
└─ 可能执行但无回执 → 标记 outcome_unknown
↓
恢复上下文,但默认不自动继续高风险工作
↓
按策略或用户决定恢复执行
恢复不是简单地把最后一条消息重新发给模型。系统必须先修复到一个协议合法、语义清楚的状态。
最难处理的是“结果未知”
调用发布接口时,服务端已经创建文章,但连接在响应到达前断开。Harness 只知道请求发出过,不知道外部结果。
此时有三种错误做法:
- 当作失败并直接重试,可能重复发布;
- 当作成功继续,可能实际没有发布;
- 丢掉这段状态,让模型自行猜测。
正确做法是显式记录 outcome_unknown,再根据工具语义处理:
if tool.is_read_only:
retry()
elif tool.is_idempotent and request_key_exists:
retry_with_same_key()
elif tool.can_query_status:
verify_external_state()
else:
ask_user_before_retry()
“不知道”是一种必须被保存的状态,而不是异常文本里的临时描述。
幂等让重试变得安全
幂等意味着同一个逻辑请求重复执行,不会产生多个不同副作用。
创建文章时可以生成稳定请求键:
request_key = hash(
task_id,
operation="publish_article",
source_revision=document.revision,
)
result = publish_article(
content=document.content,
request_key=request_key,
)
如果客户端超时,再次使用同一个键,服务端应返回第一次创建的文章,而不是新建第二篇。
幂等键不能每次重试都随机生成,否则失去意义。内容发生实质变化时,又应该生成新版本键,避免错误复用旧结果。
检查点应该放在哪里
所有 token 都同步落盘最安全,却可能严重影响性能;只在任务结束保存最快,却经不起崩溃。
常见检查点包括:
- 接收任务契约后;
- 每次模型消息组装完成后;
- 有副作用工具执行前和结果返回后;
- 每个 Step 或 Turn 结束时;
- 用户审批、暂停和取消后;
- 上下文压缩或状态迁移后。
高风险事件需要更强的持久保证。纯 UI 流式片段可以采用较宽松的批量策略。
状态版本与并发控制
多个进程、标签页或子 Agent 可能同时更新同一任务。如果只做最后写入覆盖,后完成的旧操作会抹掉新状态。
可以使用 revision 做比较并设置:
def update_task(task_id, expected_revision, change):
task = database.lock(task_id)
if task.revision != expected_revision:
raise Conflict("STALE_REVISION")
event = append_event(task_id, change)
task.revision += 1
return event, task.revision
调用方遇到陈旧 revision 时,应重新读取状态并决定是否仍然适用,而不是强行覆盖。
会话恢复不等于自动续跑
重新加载后可以恢复任务目标、历史和未完成状态,但不一定应该立刻继续执行。
例如用户昨天批准过一次生产部署,今天进程恢复时不能默认把这份临时许可继续使用。运行中的取消信号、一次性审批和临时凭据通常不应持久化为永久授权。
更安全的做法是:状态可以恢复,执行能力重新启用则由策略决定。对高风险任务,恢复后先展示当前状态并要求用户确认。
数据迁移与兼容性
Harness 升级后,事件结构和状态 schema 可能变化。持久化设计应从开始就考虑:
- 事件带版本号;
- 新代码能读取旧事件;
- 迁移过程可重复且有校验;
- 未知字段尽量保留;
- 无法安全迁移时明确阻止恢复;
- 迁移前保留备份和审计记录。
会话能保存几个月,就意味着状态格式是一项长期公共接口。
常见的失败方式
只保存聊天文本
工具调用、审批、预算和外部回执丢失,无法可靠恢复或审计。
失败后从头重跑
只读分析可能只是浪费成本,外部写入则可能重复产生副作用。
随机生成重试键
每次重试都换键,服务端无法识别为同一个逻辑操作。
把内存状态当作持久事实
进程内“工具已完成”尚未落盘时发生崩溃,恢复后就会出现认知分叉。
恢复后自动继承所有临时授权
暂停、重启和跨设备恢复可能跨越原审批语境。一次性授权应过期。
状态与持久化检查清单
- 是否保存任务、会话、执行、副作用和控制状态?
- 关键事实是否使用仅追加事件记录?
- 当前状态能否由日志确定性重建?
- 是否区分未开始、失败、成功和结果未知?
- 外部写操作是否支持稳定幂等键或状态查询?
- 检查点是否覆盖高风险副作用前后?
- 并发更新是否有 revision 或事务保护?
- 恢复时是否修复未闭合的调用和步骤?
- 临时审批与执行能力是否会在恢复后重新判断?
- 持久格式是否有版本和迁移策略?
最后理解状态与持久化
持久化的价值不是让聊天记录一直存在,而是让系统对已经发生的现实负责。
好的 Harness 能在任何时候回答:任务走到哪里,哪些动作已经发生,哪些结果可以确认,哪些仍然未知,以及怎样继续才不会重复伤害外部世界。
一句话总结:可恢复不是重新执行,而是在保留事实的前提下安全地继续。