长任务一定会被中断

网络会断,模型服务会限流,进程会重启,用户会关闭电脑,外部 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 能在任何时候回答:任务走到哪里,哪些动作已经发生,哪些结果可以确认,哪些仍然未知,以及怎样继续才不会重复伤害外部世界。

一句话总结:可恢复不是重新执行,而是在保留事实的前提下安全地继续。