Agent 长任务运行时设计:续跑、状态与完成验收
从暂停后迟到的验收结果入手,用 SQLite 示例解释目标版本与执行代号,再展开续跑、持久化、预算和完成验收。
用户给出“修复登录失败,完成回归并提交 PR”这样的目标后,任务可能跨越多次模型请求、测试等待和进程重启。一次回复结束,不能说明目标已经完成;工具没有及时返回,也不能说明它没有执行。
假设测试还在运行,用户这时点击暂停;几秒后,测试返回“通过”。系统应该保存结果,但不该因此继续提交 PR。要做到这一点,程序必须知道当前目标、暂停状态,以及这个结果属于哪一次执行。下面从这些具体情况出发,设计一套组织模型、工具和会话的 Agent Harness。文中的状态名和伪代码用于讨论自建系统,并不代表 Codex 或其他产品的内部实现。
从目标生成可检查的完成标准
例如,用户输入“修复登录失败,完成回归并提交 PR”。在开始执行前,Harness 应保存原始目标,并将它转成能够检查的条目:
| 条目 | 完成证据 | 需要保留的边界 |
|---|---|---|
| 原问题得到修复 | 复现路径与修复后的结果 | 只针对已确认的问题范围 |
| 受影响回归通过 | 命令、环境、退出码、日志及代码版本 | 旧提交的结果不能证明新改动通过 |
| PR 已提交 | 分支、提交和 PR 地址 | 提交不包含自动合并 |
模型可以提议计划与标准。Harness 保存经确认的目标版本,后续新增用户要求时更新版本,避免用旧标准判断新任务。要求本来就明确时直接执行;会改变授权范围或交付定义的歧义才需要澄清。
有了这些条目,程序才能在测试结束后判断下一步是补做回归还是提交 PR。提示词里可以写“持续工作直到完成”,但遇到重启、等待或预算耗尽时,仍要靠保存下来的状态决定怎样继续。
Goal、Turn 和工具操作有不同生命周期
Goal 是跨回合的目标。Turn 是一次有边界的 Agent 执行回合,可以包含多个模型请求和工具调用。工具操作可能比当前模型请求持续得更久,也可能已经在外部成功、但结果尚未保存。
因此,一次 Turn 正常结束,只能说明本轮执行结束。目标是否继续、暂停或验收,要由调度器根据持久状态决定。可以将 Goal 状态和阻塞原因分开保存:
| 字段 | 示例值 | 含义 |
|---|---|---|
status | active、paused、verifying、completed、failed、cancelled | 目标当前生命周期 |
wait_reason | 空值、approval、user_input、tool_result、retry_backoff | 本轮不能继续的原因 |
turn_id | 一个唯一标识 | 关联当前回合的消息与工具结果 |
goal_version | 递增整数 | 拒绝应用旧目标版本的异步结果 |
这里把等待表示成 active + wait_reason,其他实现也可以单列等待状态。需要保持的语义是:等待结果时不能空转,用户主动暂停后,异步结果到达不能自动解除暂停。
下面的图只画 Goal 的生命周期。等待、预算耗尽和调度优先级不全部展开,具体条件由后面的调度规则补充。
每次续跑前,重新判断是否可以运行
测试结果返回时,调度器会被唤醒;用户点击恢复、一次回合结束或重试时间到达,也会触发同样的检查。它首先要读当前状态。如果用户已经暂停,收到结果只意味着可以补记证据,还不能开启下一回合。
一次调度检查可以按以下顺序执行:
- 读取最新目标版本;已取消、完成或失败的目标不再运行。
- 检查会话是否已有执行中的回合,避免两个 worker 同时推进同一会话。
- 优先处理用户消息、取消和暂停。用户澄清可能改变计划,也可能改变完成标准。
- 检查等待条件是否解除,再检查剩余预算与重试时间。
- 在事务中领取回合,记录执行租约和预算预留,随后发起模型请求。
一个数据库表和单个 worker 就能承载最初版本;不必一开始引入分布式工作流平台。多 worker 时,租约过期后旧 worker 仍可能返回,需用递增的执行代号拒绝旧结果覆盖新状态,并在执行外部操作前再次校验资格。
队列也应区分普通用户消息、当前回合的补充事件和 Goal 自动续跑。普通消息保留自己的处理顺序;补充事件只在适合接纳的边界进入请求;自动续跑不能不断插队,让用户的暂停命令长期得不到处理。
下面是调度与验收的伪代码。存储、租约、预算、幂等和运行中取消由相应函数实现,代码不能直接作为生产运行时使用。
async def on_wakeup(goal_id):
# 事务内检查状态、等待、用户队列、租约及预算,再领取执行权
ticket = store.claim_if_runnable(goal_id)
if ticket is None:
return
try:
result = await runner.run_turn(ticket)
store.record_turn(ticket, result) # 校验执行代号并结算实际用量
# 运行期间可能发生暂停、取消或目标修改,重新读取后再决定
goal = store.current_for(ticket)
if goal is None or goal.status != "active":
return
if result.completion_claim:
review_ticket = store.claim_verification(goal)
if review_ticket is not None:
report = await verifier.evaluate(review_ticket)
store.apply_review_if_current(review_ticket, report)
else:
store.schedule_next_if_runnable(goal)
except Exception as error:
store.record_failure_if_current(ticket, error)
finally:
store.release_if_owner(ticket)
异常处理函数需要区分可重试、不可重试和结果未知。验收异常后也必须持久化重试安排或阻塞原因,不能让目标永久停在无人处理的 verifying。运行中的工具是否能取消取决于工具本身;界面要显示取消请求与实际停止的区别。
一个可运行的切口:拒绝过期验收结果
先考虑单进程调度中也会发生的交错:验收开始后,用户暂停任务;验收结果稍后返回。如果回调只执行 status = completed,用户的暂停就会被旧结果覆盖。
除了目标版本,还需要标识本次执行的 attempt。同一目标暂停后再恢复,目标要求可能没有变化,但先前的执行已经失去更新资格。
| 事件 | version | attempt | status |
|---|---|---|---|
| 开始验收 | 1 | 7 | verifying |
| 用户暂停,使旧 attempt 失效 | 1 | 8 | paused |
| 用户恢复,之后发起新验收 | 1 | 9 | verifying |
| attempt 7 的通过结果到达 | 1 | 9 | 保持 verifying,拒绝旧结果 |
| attempt 9 的通过结果到达 | 1 | 9 | completed |
以下是用 SQLite 实现的两个状态操作。表含 id、version、attempt、status;更新条件与写入在同一条 SQL 中完成,避免先读状态、随后无条件写入的间隙。
def complete_if_current(db, goal_id, version, attempt):
with db:
changed = db.execute(
"""UPDATE goal SET status = 'completed'
WHERE id = ? AND version = ? AND attempt = ?
AND status = 'verifying'""",
(goal_id, version, attempt),
).rowcount
return changed == 1
def pause(db, goal_id):
with db:
db.execute(
"""UPDATE goal SET status = 'paused', attempt = attempt + 1
WHERE id = ? AND status IN ('active', 'verifying')""",
(goal_id,),
)
complete_if_current 只在验收已通过后调用。返回 False 表示状态已不匹配,调用方应读取当前目标,不能去掉条件再写一次。原始验收结果可以另存为审计事件,但不能借保存证据解除暂停或恢复已取消目标。
下载 SQLite 状态示例,运行 python goal_version.py。它用临时磁盘数据库检查当前验收只生效一次、关闭连接后暂停仍然存在、旧 attempt 被拒绝、旧目标版本被拒绝,以及取消状态不会被完成结果覆盖。
这段代码验证的是状态应用规则。它尚未实现多 worker 领取、租约到期、预算预留和外部写操作恢复;重新打开数据库也不等于已经验证了强制终止时的全部恢复路径。先让这个小切口成立,再把同样的版本条件加到调度与验收入口。
完成声明进入验收,证据决定结果
模型可以报告“已完成”,并给出对应条目与证据引用。Harness 随后进入 verifying,检查是否满足目标,而不是搜索回复中有没有“完成”两个字。
验收可以分成两层:程序检查确定条件,例如提交是否存在、日志是否关联当前代码版本、退出码是否为零;模型检查需要语义判断的部分,例如修复是否覆盖原问题、某项需求是否遗漏。涉及实际行为的标准,还需要执行或读取相应证据。
判定器可以返回结构化结果:
{
"verdict": "incomplete",
"criteria": [
{ "id": "login-fix", "result": "pass", "evidence": ["artifact-17"] },
{ "id": "regression", "result": "unknown", "evidence": [] }
],
"missing": ["最新代码版本的回归结果"]
}
这个例子中,修复有证据,但回归缺失。目标可以回到 active 补做回归。若需要用户提供环境或授权,则保存等待原因;判定器超时或输出不合法应记为未判定,不能当作通过。
只读取目标、完成声明与证据摘要的模型判定器,是一次语义检查。它没有亲自打开文件、跑测试,不能声称完成了独立实测。即使换一个模型,也可能被同一份错误摘要误导。对确定条件优先使用原始工具结果,对代码版本、文件哈希和运行环境做关联,再让模型处理语义缺口。
如果产品提供跳过验收的选项,要明确记录采用了哪种完成路径。completed 应能够解释“依据什么完成”,而不是把接受自述与经过验收混成相同证据等级。
持久化解决恢复,幂等处理重复
恢复至少需要保存目标与版本、完成标准、计划与下一步、等待条件、预算用量,以及工具操作和证据引用。模型上下文只是本轮看到的工作视图,数据库或持久文件才是恢复依据。
以 LangGraph 为例,线程状态检查点与跨线程存储承担不同职责;仅用内存检查点不能跨进程重启保留状态。这一区分对应 LangGraph 的持久化文档:checkpointer 保存线程状态,store 保存跨线程数据。可以采用这类框架,也可以自行存储;选用检查点框架不会自动解决外部系统的重复执行。
例如,Agent 创建 PR 成功后,在保存结果前崩溃。重启时只能看到“操作已开始”,无法确认请求失败还是结果遗失。直接再创建一次可能产生重复 PR。
工具操作可以记录 operation_id、目标版本、参数摘要、开始时间和结果状态。恢复时先根据操作标识查询外部系统;能够确认成功就补写结果,确认未执行才重试。无法查询、也不支持幂等键的写操作,应保留“结果未知”,交给可验证的恢复路径或人工处理。
对于支持幂等键的接口,同一个逻辑操作重试时复用原键。幂等键、数据库事务与外部副作用的边界需要单独设计,不能靠本地加锁就承诺跨系统“恰好一次”。
LangGraph 中断文档说明,恢复会重新运行中断所在节点,因此中断之前的副作用应具备幂等性或拆到其他节点。这里框架所说的执行暂停,与用户主动暂停 Goal 是两个层次,应用仍需保存自己的暂停语义。
预算、重试与无进展退出
长任务的预算应跨回合累计,模型请求、工具调用和验收都计入。可以先设置最大回合数、总 token、费用和截止时间,再根据实际需求增加工具时长等维度。
单次请求前预留最大可能消耗,返回后按实际 usage 结算。若要把费用作为硬上限,预留还必须覆盖并发请求、输出上限和工具计费;请求后才检查余额,只能发现超支,不能阻止超支。
重试需要次数上限与退避时间。相同错误反复出现、证据长期没有增加,或模型持续改计划而没有可检查产物,都应触发无进展检测。选择停止、暂停或等待补充条件时,保留原因和恢复入口,不能把预算耗尽标记成任务完成。
界面至少要显示目标状态、当前动作、等待原因、剩余预算、完成条目和证据。用户应能判断 Agent 是正在运行、等结果,还是已经停止,并可以暂停、恢复和取消。
上下文压缩保留任务,稳定前缀保留复用机会
长任务无法无限保留所有对话。压缩时除了已做事项,还需要保留尚未完成的标准、最新用户约束、失败原因、待返回操作、证据索引和下一步动作。摘要不能把“计划运行测试”改成“测试通过”,也不能把等待授权写成获得授权。
恢复下一轮时,Harness 从持久状态构造工作视图,并允许按引用读取原始证据。可以定期拿恢复视图对照目标条目,检查遗漏,而不是依靠模型从一段自由文本中猜测进度。
缓存则涉及请求组织。以 Claude 提示词缓存的层次为例(官方在线文档核对于 2026-10-27),稳定工具和系统内容位于可复用前缀;将实时进度不断重写进前部,会减少后续复用。可将目标版本、进度摘要和新事件放在稳定内容之后,具体位置遵守平台 API。压缩会改变被替换位置后的前缀,通常需要重新建立相应缓存;不能为了命中率无限保留过期上下文。
目标状态、模型上下文与推理缓存各有职责:前者保证知道任务到了哪里,中间一层保证模型获得本轮所需信息,缓存帮助复用计算。工具发现的输入组织见大量工具如何告诉模型。
最小实现要验证哪些故障
先实现单会话、持久存储、有限预算和明确验收,再考虑多 worker 与复杂任务树。下面的场景比“连续运行几个小时”更能检验实现是否符合预期。
| 场景 | 应观察到的行为 |
|---|---|
| 等待工具结果时用户暂停 | 结果可保存,Goal 保持暂停,不自动续跑 |
| 用户取消后旧结果返回 | 不恢复执行,不覆盖终态;记录外部操作的实际结果 |
| 两个 worker 同时被唤醒 | 只有一个领取回合,过期 worker 的结果不能覆盖新状态 |
| 目标修改期间验收返回 | 旧版本验收被拒绝,按最新标准重新判断 |
| 写操作成功后进程崩溃 | 先核对外部结果,不能盲目重复执行 |
| 压缩后重启 | 未完成条目、等待条件和原始证据引用仍然可恢复 |
| 预算耗尽或重复失败 | 停止续跑,显示原因,不报告完成 |
| 模型声称完成但缺证据 | 标为未完成或未判定,补做验证或等待条件 |
最值得先实现的测试,是在外部操作成功、结果尚未落库的间隙主动终止进程,再检查恢复是否重复操作。这个切口把状态恢复、幂等和完成证据连在一起,比只验证正常路径更能检验设计是否自洽。如何把这些场景积累为版本回归,见Agent 评估与回归。