ELL3N HSU

Available in Chinese only.

← 文章

Agent 长任务运行时设计:续跑、状态与完成验收

从暂停后迟到的验收结果入手,用 SQLite 示例解释目标版本与执行代号,再展开续跑、持久化、预算和完成验收。

用户给出“修复登录失败,完成回归并提交 PR”这样的目标后,任务可能跨越多次模型请求、测试等待和进程重启。一次回复结束,不能说明目标已经完成;工具没有及时返回,也不能说明它没有执行。

假设测试还在运行,用户这时点击暂停;几秒后,测试返回“通过”。系统应该保存结果,但不该因此继续提交 PR。要做到这一点,程序必须知道当前目标、暂停状态,以及这个结果属于哪一次执行。下面从这些具体情况出发,设计一套组织模型、工具和会话的 Agent Harness。文中的状态名和伪代码用于讨论自建系统,并不代表 Codex 或其他产品的内部实现。

从目标生成可检查的完成标准

例如,用户输入“修复登录失败,完成回归并提交 PR”。在开始执行前,Harness 应保存原始目标,并将它转成能够检查的条目:

条目完成证据需要保留的边界
原问题得到修复复现路径与修复后的结果只针对已确认的问题范围
受影响回归通过命令、环境、退出码、日志及代码版本旧提交的结果不能证明新改动通过
PR 已提交分支、提交和 PR 地址提交不包含自动合并

模型可以提议计划与标准。Harness 保存经确认的目标版本,后续新增用户要求时更新版本,避免用旧标准判断新任务。要求本来就明确时直接执行;会改变授权范围或交付定义的歧义才需要澄清。

有了这些条目,程序才能在测试结束后判断下一步是补做回归还是提交 PR。提示词里可以写“持续工作直到完成”,但遇到重启、等待或预算耗尽时,仍要靠保存下来的状态决定怎样继续。

Goal、Turn 和工具操作有不同生命周期

Goal 是跨回合的目标。Turn 是一次有边界的 Agent 执行回合,可以包含多个模型请求和工具调用。工具操作可能比当前模型请求持续得更久,也可能已经在外部成功、但结果尚未保存。

因此,一次 Turn 正常结束,只能说明本轮执行结束。目标是否继续、暂停或验收,要由调度器根据持久状态决定。可以将 Goal 状态和阻塞原因分开保存:

字段示例值含义
statusactive、paused、verifying、completed、failed、cancelled目标当前生命周期
wait_reason空值、approval、user_input、tool_result、retry_backoff本轮不能继续的原因
turn_id一个唯一标识关联当前回合的消息与工具结果
goal_version递增整数拒绝应用旧目标版本的异步结果

这里把等待表示成 active + wait_reason,其他实现也可以单列等待状态。需要保持的语义是:等待结果时不能空转,用户主动暂停后,异步结果到达不能自动解除暂停。

下面的图只画 Goal 的生命周期。等待、预算耗尽和调度优先级不全部展开,具体条件由后面的调度规则补充。

活动目标可由用户暂停或取消,提交完成声明后进入验收;未通过则继续,通过则完成。等待原因独立保存,不等于用户暂停。活动目标可由用户暂停或取消,提交完成声明后进入验收;未通过则继续,通过则完成。等待原因独立保存,不等于用户暂停。
Goal 生命周期示意:active 不等于正在执行;有等待原因时,调度器不会开启新回合。

宽图可横向滚动。查看原图查看原图

每次续跑前,重新判断是否可以运行

测试结果返回时,调度器会被唤醒;用户点击恢复、一次回合结束或重试时间到达,也会触发同样的检查。它首先要读当前状态。如果用户已经暂停,收到结果只意味着可以补记证据,还不能开启下一回合。

一次调度检查可以按以下顺序执行:

  1. 读取最新目标版本;已取消、完成或失败的目标不再运行。
  2. 检查会话是否已有执行中的回合,避免两个 worker 同时推进同一会话。
  3. 优先处理用户消息、取消和暂停。用户澄清可能改变计划,也可能改变完成标准。
  4. 检查等待条件是否解除,再检查剩余预算与重试时间。
  5. 在事务中领取回合,记录执行租约和预算预留,随后发起模型请求。

一个数据库表和单个 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。同一目标暂停后再恢复,目标要求可能没有变化,但先前的执行已经失去更新资格。

事件versionattemptstatus
开始验收17verifying
用户暂停,使旧 attempt 失效18paused
用户恢复,之后发起新验收19verifying
attempt 7 的通过结果到达19保持 verifying,拒绝旧结果
attempt 9 的通过结果到达19completed

以下是用 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 评估与回归。