← Blog

Agent Engineering:重试、状态与执行控制

一个 Agent 的基本循环很简单:接收输入,调用模型,执行工具,再把结果交回模型。复杂性往往出现在两个步骤之间。参数还没收全,工具已经开始;请求失败了,文件却已经改过;用户点了停止,旧任务仍在提交结果。

这些情况都要求运行器区分已经成立的事实、尚未结束的工作,以及下一步使用的状态。请求能否重发,取决于上次尝试已经推进到哪里;历史能否裁剪,取决于消息之间的协议关系;任务能否结束,取决于产物是否获得了对应的验收证据。

目录

按故障查阅

遇到的现象 优先查阅
一次失败后等待很久,或多个客户端同时重试 重试资格与预算、退避与截止点
输出到一半断流,恢复后重复操作 流式恢复、幂等操作、日志与投影
JSON 校验通过,工具却执行了半截意图 截断参数
并行工具的显示、记录和执行顺序混乱 三种顺序
工具调用成功,却漏读结果或无法继续取数 工具返回值、可恢复引用
点了停止还在写,切换会话后出现旧结果 取消与收尾、迟到唤醒
回滚后设置没回滚,或读到了另一条尝试路线 恢复路径
压缩后 API 报协议错误,或反复压缩仍超限 合法切口、上下文预算、压缩失败
token 已减少,调用仍慢,或压缩后找不回原文 缓存复用、可恢复引用
新输入被忽略,或权限回调突然报 Stream closed 输入边界、迟到唤醒、双向协议
用户拒绝操作后,Agent 换个工具继续做同一件事 拒绝与纠正
记录显示完成,应用却不能用 交接检查、完成证据、Harness 退役

一、请求恢复

重试资格与请求预算

指数退避回答的是“多久再试”,此前还要回答“这次失败能不能再试”。暂时过载、账户额度耗尽和上下文溢出,即使都表现为 API 错误,恢复动作也不同。后两种情况原样重发,通常不会因为多等几秒而变好。

Pi 的重试路径先排除 context overflow,再由错误分类器排除额度、账单和订阅限制,最后识别暂时过载、限流及网络错误。这里仍有文本模式匹配,不能把分类结果当作永远准确的服务端契约;接入新 Provider 时,错误样本也是兼容性测试的一部分。源码:Pi 错误分类、分类谓词、重试入口

重试次数还需要明确是否包含首轮请求。Codex 的 run_with_retry 使用 0..=max_attempts,总请求数上限为配置值加一。Pi 的测试也明确断言:maxRetries=2 对应最多三次请求。源码:Codex 请求循环、Pi 次数断言

OpenCode 又区分了 timeout:默认超时最多重试三次,普通暂时性错误最多十次,插件可以改写决策。一次超时已经消耗了整段等待,不能和立即返回的 503 只按异常个数等价计费。源码:OpenCode 重试策略

重试配置应分别定义可重试错误、最大重试次数、单次请求超时和整项操作的截止点。若两层各允许两次重试,最坏可能形成九次底层请求;必须指定谁拥有哪一段预算,或在外层保留共享上限。次数限制约束请求量,整体 deadline 约束用户到底要等多久。 这两种约束应同时存在,等待本身也应响应取消。

单项任务最多重试两次,仍挡不住一千个任务同时失败。退避改变请求发生的时间,整体恢复额度则约束同一依赖承受的额外流量。令牌桶可以允许有限突发,再按补充速率放行;额度不足时延后或结束恢复,等待仍受任务截止点约束。工程实践:共享重试额度

用于 Agent 服务时,共享范围可以按 Provider、账户或依赖划定。每个会话各自创建一个满桶,会再次放大总额度;进程内桶也无法约束其他进程。共享重试额度只控制额外尝试,首轮请求仍需自己的准入与并发控制。

指数退避、随机抖动与 Retry-After

本地退避可以写成 base × 2^(k−1),再设置上限。随机抖动使不同客户端的下一次请求不落在同一个时刻,减少同时失败、同时恢复的同步现象。Codex 的 HTTP 重试 helper 使用约 ±10% 的乘性抖动,本身没有等待时长封顶;等待上限和整项操作的截止点仍需由外层约束。源码:Codex backoff

服务端的 Retry-After 是另一种约束。假设收到“10 秒后再试”,随后通知 UI、等待锁花了 8 秒。上层若重新 sleep(10s),一次建议就被重复等待;每多经过一层恢复逻辑,延迟都可能增加。

Codex 在接收建议时就把它转换为单调时钟上的 Instant。常规 HTTP/Responses 重试和传输 fallback 分支沿用同一个 deadline,只等待剩余部分。HTTP-date 先借助墙上时钟换算,之后的等待使用单调时钟。它还保留一个重要区分:有效但已到期的建议,和没有有效建议,是两种状态。 前者剩余时间为零,后者才走本地退避。源码:RetryAfter、跨层等待

同一个重试建议跨层流转时,只等待距离原截止点的剩余时间

接口可以同时传递“何时允许再试”和“整项操作何时必须结束”两个截止点。若前者已经达到后者,就直接返回预算耗尽。服务端建议还需要合理性校验;OpenCode 的默认策略会把它截到十五分钟,插件可以覆写。

下面以“有效服务端建议优先”为例,把有上限的本地退避与整体预算放在同一个控制路径中:

delay = min(cap, base * 2**(k - 1) * uniform(0.9, 1.1))
ready_at = server_deadline if server_deadline is not None \
           else monotonic_now() + delay
if ready_at >= operation_deadline:
    return BUDGET_EXHAUSTED
await sleep_until(ready_at, cancel_token)
return await request(deadline=operation_deadline)

server_deadline is not None 保留了“已经到期”与“没有建议”的区别。等待和请求都要实际支持取消与截止点,才能约束整体耗时。这里的 cap 与 operation_deadline 是设计示意中的外层限制,并非 Codex HTTP helper 已提供的全部行为。不同策略也可以取服务端建议与本地退避的较大值;关键是明确选择规则,只执行一次等待。

流式失败与接续

流式调用的危险在于:请求还没有结束,执行现场可能已经改变。模型发出了一个完整工具调用,工具写入文件,随后连接在最终 completion 之前断开。此时重发最初 prompt,会让模型依据旧现场再次作决定。

Codex 收到 OutputItemDone 后即可启动对应工具,不必等整条响应完成。流循环报错返回之前,它仍会收集已经启动的工具结果,写入历史;下一次采样从更新后的会话历史构造输入。这种顺序保留了恢复所需的事实,也意味着慢工具可能推迟重连。源码:提前启动工具、错误后收集结果、重试重建输入

OpenCode 把可恢复失败分成 Retry 和 Continue:尚无输出时透明重试;text、reasoning 或工具输入已经开始以后,保留部分尝试,再让下一轮接续。判断依据是输出是否开始,不只看工具是否产生外部写操作。两条路径共享预算,切换恢复方式不会让次数归零。源码:Retry 与 Continue、已有输出的接续、输出开始边界、共享预算测试

恢复器可以先收齐已启动工具的结果,再根据输出边界选择重发或接续。下面是结合上述分工的设计示意:

await settle_started_tools()
decision = shared_retry_budget.decide(error)
if not decision.retry:
    return terminal_failure(error)
if not output_started:
    return Retry(decision)
await close_failed_attempt(error)
return Continue(decision)

terminal_failure 记录失败并结束恢复循环;close_failed_attempt 保留已有输出与失败事实。Continue 随后依据更新后的历史重新采样。

流式恢复依据已经改变的现场和新历史继续

这仍然不提供外部操作的 exactly-once 保证。远端可能执行成功,却在返回结果前丢失连接;模型也可能提出一个新的、语义相同的调用。尝试 ID 追踪物理请求,逻辑操作 ID 识别同一业务动作;有代价的远程写操作需要另做幂等键、状态查询或补偿。接续提示可以减少重复表达,不能承担支付、发信等操作的去重责任。

幂等键、原子提交与迟到请求

参数相同不能证明是重复请求:用户可能确实需要两个相同对象。同一业务操作跨重试复用同一幂等键,新操作使用新键;服务端按调用者与键绑定原始参数,同键异参数应拒绝,同键同参数则恢复已有结果。AWS:幂等操作契约、Stripe:重试与服务端状态

模型重新采样后产生的 tool-call ID,不天然等于跨尝试稳定的业务操作 ID。后者需要由业务层保存,不能在每次网络重试时重新生成。

“先查重,再执行”还会留下竞争窗口。对于能放进同一数据库事务的修改,可以把去重记录、业务变更和结果放在一个提交单元中:

sig = canonical_fingerprint(payload)
with db.transaction():
    row = lock_or_insert(caller, key, sig)
    require(row.sig == sig, PARAMETER_MISMATCH)
    if row.done:
        return row.result
    result = mutate_in_same_transaction(payload)
    row.complete(result)
return result

这里的 lock_or_insert 依赖唯一约束和锁,使同一调用者、同一键的竞争串行化。事务确定回滚时,三者均不生效;提交结果未知时,应沿用原键查询或重试,不能直接判定回滚。事务已经提交而响应丢失,重试则读取已有结果。原子提交要求

远程发信并不在这个数据库事务中。对外部副作用,需要下游幂等契约或可查询、可恢复的操作状态;只在本地记下“已经调用”不能证明远端已经完成。远程调用的不确定性

资源删除后仍可能收到旧请求,去重记录的保留窗口应独立规定,避免迟到重试重新创建资源。迟到请求与保留窗口 由此还需要区分去重与取消:若服务端从未收到过该键,取消后的迟到首发仍可能执行。需要阻止这种执行时,还要有服务端认可的撤销或执行期限,检查点应接近实际副作用。

二、工具执行

工具参数的完成边界

流式 JSON 解析器有时会补上缺失的引号、括号,使半截输出仍能解析。若参数要求一个字符串,原本打算生成 hello,截断后得到 hel,它仍可能满足 schema。语法正确、类型正确,都不能证明模型已经把动作说完。

Pi 在 stopReason=length 时跳过工具执行,为该条 assistant 消息中的所有工具调用生成错误结果,再允许后续轮次完整重发。即使其中一个调用看起来已经完整,也会随整批一起被拒绝。对应测试特意构造了能通过 schema 的 {value:"hel"},确认工具没有执行。源码:length 分支、整批拒绝、参数合法仍不执行的测试

这是一个保守取舍:牺牲一部分本来可以执行的调用,换取清晰的完整性边界,也会增加一次模型请求。它适用于该 Pi 路径;不能据此反推所有系统都必须等完整 response 才能执行。Codex 的工具 item 完成边界与这里的整批策略不同,见 流式失败与接续。

工具入口还需要分别检查生成完整性、参数、授权和业务前提。解析器修复数据形状,“修复后可解析”不足以放行动作。

并行执行与有序提交

两个工具 A、B 依次被调用,B 先完成。为了及时反馈,UI 应能先显示 B;为了稳定重放,模型历史又可以保持 A、B 的调用顺序。这两种需求并不冲突,前提是完成事件与结果提交使用不同规则。

Pi 的并行路径在各工具实际完成时发出 tool_execution_end,随后通过 Promise.all 按输入顺序收集结果,并按原始调用顺序写出 toolResult。测试明确断言:完成事件顺序可以是 B、A,而结果记录仍是 A、B。若同批任一工具声明 executionMode=sequential,整个批次转为串行。源码:并行执行与结果收集、结果保序、顺序测试

Codex 使用共享/独占 gate 控制标记为可并行或不可并行的调用,并用 FuturesOrdered 有序收集结果。这里的共享锁不能解释为“只读工具”,源码测试允许 shell 并行;gate 也不会自动分析两个 shell 是否写同一个文件。源码:并发准入、shell 并行测试、有序收集

Pi 的批次控制可简写为:

serial = config.tool_execution == sequential \
         or any(call.requires_sequential)
mode = sequential if serial else parallel
jobs = launch(calls, mode, on_complete=emit_completion)
results = await collect_in_input_order(jobs)
for call, result in zip(calls, results):
    append_tool_result(call.id, result)

完成通知由各任务发出,历史结果由批次统一提交,二者通过调用 ID 对应。运行器能否并发、资源之间是否有读写依赖、结果按什么顺序进入上下文,是三个独立问题。支持并发只回答第一个。更细的资源键、读写集或依赖图可以提高并发度,但也提高声明和校验成本;信息不足时应保守串行。按调用顺序提交还会带来队首等待,不能同时承诺“确定顺序”和“所有结果立即进入模型”。

取消与终态结算

点击停止只表达了新的控制意图。它不自动撤销文件修改,也不能把一个已经成功的工具改写为未执行。尤其当工具已经返回、观察器还在处理完成事件时,迟到的取消很容易制造错误历史。

Codex 的工具封装会检查是否已经达到 terminal outcome;若已经完成,就保留真实结果,避免再补一个 aborted 结局。仓库测试故意阻塞完成观察器,再发取消,确认最终生命周期仍只有 Completed。源码:完成与取消竞争、竞争测试

会话层也需要收尾顺序。Codex 先发取消信号,给任务一个短暂的合作退出窗口,再执行必要清理;启用中断历史标记时,先写标记并 flush,再发 TurnAborted。客户端收到通知后可能立即读取日志,这个次序影响恢复时能否看到中断事实。flush 失败时实现会告警并继续,因此通知到达仍不等于标记已经持久化。源码:取消与清理

OpenCode 把实际工作放在可中断区域,把终态事件与状态结算放在受保护的收尾区域。Claude Agent SDK 关闭子进程时,也会在有界等待后逐级 terminate、kill。工作可以取消,终态记录仍需要完成;清理的等待也需要单独约束。源码:OpenCode 收尾区域、OpenCode 终态结算、Claude SDK 进程关闭

取消路径需要把停止工作和提交终态分开。下面的设计示意在进入第一个等待之前保护收尾路径,停止等待与整个收尾也分别有截止点:

request_cancel(work)
with bounded_finalizer(finalize_deadline, shield_cancellation=True):
    await stop_or_escalate(work, stop_deadline)
    for call in calls:
        call.settle_if_pending(observed_outcome_or_unknown(call))
    await persist_history()
    emit_turn_terminal()

settle_if_pending 原子地保留已有终态,已完成的结果不能被迟到取消覆盖。收尾超时或写入失败要明确上报,取消保护本身不提供持久化事务。

cancel acknowledged、execution stopped 和 settlement completed 分别表示取消已接收、执行已停止、收尾已完成。复用同一执行现场或替换会话状态前,需要等待最后一个边界。若远端调用结果无法确认,应保留“结果未知”和查询线索;把未知统一标成失败,会诱导下一轮重做已经成功的动作。

工具返回值与可行动的错误

工具成功返回,不代表模型得到了足够的下一步信息。搜索结果若只有难以理解的 ID,模型缺少选择依据;若删掉所有 ID,后续读取又没有准确目标。返回值应同时照顾判断所需的语义和继续调用所需的标识,大结果则提供过滤、分页或范围读取。工程实践:工具返回值设计

例如,有限条数的搜索回包可以明确区分“没有更多结果”和“只返回了一部分”:

{
  "items": [{"id": "doc_7", "title": "接口约束"}],
  "truncated": true,
  "next_cursor": "c_2",
  "returned_scope": "当前筛选的一页结果"
}

truncated 说明信息不完整,next_cursor 给出继续取得信息的入口。缺少这两项,模型可能把“未展示”误认成“不存在”。简洁回包可以减少噪声,但不能删去下一步必须使用的字段。

错误回包也可以提供可行动信息:指出哪个参数违反了约束、允许的值是什么,或建议缩小哪一段范围。这样才能把“调用失败”变成“下一次调用应如何修正”。工程实践:截断与错误反馈 这里处理的是可修正错误;权限拒绝与取消仍走独立控制路径,不能包装成鼓励模型继续尝试的提示。

三、会话状态

原始日志与模型投影

失败的半截回答不适合反复送给模型,但直接删除它,会失去排查失败和核对费用的依据。会话存储同时承担审计和推理输入,两种用途需要不同视图。例如原始日志保留 e1, e2, edit(e2, null),模型投影则省略 e2;删除效果本身也有记录。

Pi 重试时通过 _omitRecoveryAttempt 追加 context_edit(targetId, null),在模型投影中省略失败尝试,原始条目仍保留。编辑自身也是新 entry;它只作用于活动分支,同一目标的最后一个编辑生效。若目标仍在上下文中却无法找到来源 entry,代码会报错,而非静默丢失来源关系。源码:省略失败尝试、投影规则、追加编辑

追加编辑改变模型视图,失败尝试仍留在原始日志中

这个结构让“曾经发生过什么”和“下一次模型应该看到什么”都能解释清楚。代价是所有读者必须理解投影:token 估算、压缩、重放和调试界面如果各读一套记录,就会出现显示已省略、预算仍按旧文本计算的偏差。原始日志也仍然占磁盘,隐私删除需要另一套策略。

进入上下文的内容应保留 source ID,把省略、替换及压缩记录为可追踪的变换。审计视图展示原始事实,模型视图展示有效输入,并能从后者回到前者。可解释的恢复,依赖可解释的上下文来源。

失败信息是否进入下一轮,还取决于它能否改变决策。断流留下的重复草稿可以省略;测试失败、路径不存在或业务前提不成立,则需要保留观察,避免再次走同一条失败路径。保留失败观察是 Manus 提出的重要经验。工程实践:保留失败观察

用于日志投影时,可以保留短结论、适用范围和原始证据位置,省略重复堆栈。暂时故障不能固化为永久禁令,结果未知也仍需标为未知。选择依据是失败带来的信息,而不是统一按 isError 删除或保留。

分支、回滚与恢复

一个会话文件可能同时包含失败路线、回滚事件和新分支。按文件顺序读出全部消息,不一定得到当前上下文;只找到最近摘要,也不一定得到可用的恢复点。

Pi 的 entry 带 id 和 parentId,活动 leaf 决定当前路径。切换分支只移动 leaf,后续追加产生新分支,旧节点仍然存在。构建上下文时沿 parent 回到根,再还原有效顺序。存储顺序描述写入经过,因果路径描述这次决策继承了什么。源码:沿叶子重建路径、分支切换

Codex 的 rollout 恢复还处理 rollback、上下文基线和模型设置等元数据。它先反向识别仍有效的分段与 checkpoint,再正向重建历史。测试同时检查回滚后的正文、previous settings、reference context 和 attribution,避免出现“消息退回去了,内部设置仍停在未来”的混合状态。源码:反向识别、同步回滚测试

OpenCode 的消息读取使用 session 内显式 seq 分页,测试刻意打乱字符串 ID 和时间戳。时间戳用于展示,显式 seq 用于重建顺序,字符串 ID 无须承担排序职责。测试:打乱 ID 与时间、源码:消息顺序

恢复契约应包含当前分支、有效 checkpoint、配置与投影版本,并用同一批用例验证。还要单独注明:对话回滚不会自动回滚工作目录或远端系统。 恢复后应重新核对现场;若需要完整环境回滚,就要另有 Git、快照或业务补偿机制。

四、上下文管理

压缩边界与工具协议

“保留最后 N 条消息”看起来简单,却可能从 toolResult 中间开始,留下没有前置调用的结果。上下文裁剪改变的不只是长度,也可能破坏模型 API 的消息协议。

Pi 的合法切点不允许从 toolResult 开始;预算选择基于当前有效投影,必要时保留同一轮的前缀供摘要处理。OpenCode 则把近端边界退回 user message,让同一 exchange 的调用和结果一起处理。两者切分粒度不同,不能概括成所有实现都只在完整 user 轮次切分。源码:Pi 合法切点、投影中的边界、OpenCode exchange 边界

裁剪要保持工具调用与结果的关系,长度目标服从协议边界

中断还可能留下有调用、无结果的历史。Pi 的消息适配层会为缺失结果补入 isError=true 的占位,说明 No result provided。这修复了结构,却没有证明工具未执行。若远端结果未知,占位表达的只能是“没有观察到结果”。源码:缺失结果修补

裁剪顺序应当是:先确定不可拆分关系,再找满足预算的边界,最后说明哪些内容被摘要、文本化或丢弃。OpenCode 的 recent exchange 会序列化为文本放在摘要旁,并非原始协议消息无损保留。若一组工具结果本身特别大,合法边界也可能超过目标预算;此时应缩减工具输出或调整下一步任务,不能偷偷破坏配对。

上下文预算与度量失效

上次 API 返回的 usage,只测量了上次模型见到的内容。随后本地工具读入一份大文件,下一请求已经变大,但旧 usage 仍可能显示“空间充足”。只盯着最后一次 token 数,会在工具输出之后低估上下文。

OpenCode 的 estimatePrompt 使用最近可信的 Provider 实测 usage,加上此后未测内容的估计量,尤其补上本地工具结果。若切换 Provider,也不能无条件信任另一个 Provider 的计数。窗口计算优先采用输入上限,再使用 context 上限;默认还预留缓冲,显式配置可以覆盖。源码:estimatePrompt 与 ceiling、工具增长与输入窗口测试

这个预算可以理解为:可信实测基线 + 新增内容估计 + 下一步预留。预留要考虑下一次输入增长和模型接口定义;不同 Provider 对输入、输出和总窗口的限制关系并不一致,不能机械地从所有窗口里扣掉同一个最大输出值。

更容易遗漏的是度量失效。压缩后仍沿用压缩前的 usage,会立刻再次触发压缩。Pi 会核对响应模型,避免把旧模型的溢出错误用于新模型恢复;再核对压缩边界,避免把压缩前的 usage 用于新一轮阈值判断。全零 usage 时改用估算,而非把零解释为没有上下文。源码:响应模型核对、度量来源与压缩判定

token 数应与“测量对象、模型、对应日志边界”一起保存。一个没有来源边界的数字很难安全复用。预算仍是估计,协议配对、图像和超长工具结果都可能产生偏差,因此还需要 压缩失败与基线提交 的超限恢复出口。

压缩失败与基线提交

压缩请求本身也要把输入交给模型。若它同样超窗,原样重试压缩不会让输入变短;若半截摘要立即替换旧历史,失败还会毁掉最后一个可用基线。

OpenCode 在压缩超限后逐步缩小输入,以首次进入收缩分支时的请求估计为固定基线,把目标比例依次调低到 0.7、0.5、0.35。无法在保留最新 exchange 和 checkpoint 的条件下继续收缩时,就显式失败。缩小输入是一种可观察的恢复进展;固定次数保证恢复路径本身也有出口。源码:输入收缩、比例与边界、失败出口测试

Codex 的 PostTurn 压缩路径会暂存摘要输出,等 response.completed 后才构造替换历史,避免失败的半截摘要污染当前基线。完成后替换是这一特定路径的提交语义。源码:摘要暂存、完成后替换

摘要“生成成功”还不等于“重要事实完整”。OpenCode 的一个轻量检查只要求输出含有模板中的至少一个标题。通过这个谓词,仍可能缺少其他章节、关键决策或未完成工作。源码:标题存在性检查

候选摘要与当前基线可以分开保存,发布点放在完成标记与约定检查之后:

old = current_baseline
candidate = await summarize(history)
if not candidate.response_completed:
    return FAILED_KEEP(old)
if not validate(candidate):
    return FAILED_KEEP(old)
await save(candidate)
current_baseline = candidate.id

这是基线提交的设计示意,validate 需要具体规定检查哪些内容。保存与切换引用之间的崩溃恢复,还需要持久层提供相应保证。每次超限恢复则应缩小输入或改变与故障有关的量,并设置明确出口;原样重复同一份失败输入不会取得进展。

稳定前缀与缓存复用

支持前缀缓存的后端,可以复用相邻请求的共同输入。靠前的时间戳、工具定义变化或不确定的序列化顺序,都会缩短可复用前缀。稳定指令和工具定义尽量靠前,新增观察随后追加;具体命中仍取决于后端的缓存粒度、保留时间与路由。工程实践:围绕前缀缓存设计

request_1 = stable_prefix + history_1
request_2 = stable_prefix + history_1 + new_observation

日志只追加,不代表模型输入也只追加。一次 context_edit 可以改变较早的模型投影,压缩也可能替换大段历史。因此,审计日志的保留策略与模型请求的缓存策略需要分别检查。比较压缩收益时,还要统计后续请求的累计延迟和费用;必要的事实纠正、权限更新与预算约束仍优先于缓存命中。

上下文卸载与可恢复引用

摘要保留当前判断,外部引用保留重新取得证据的入口。大文件、网页或查询结果可以留在窗口外,通过文件路径、链接和存储的查询条件按需读取。这样,暂时不加载全文与永久丢失信息就成为不同选择。工程实践:按需取得上下文

可恢复引用需要满足比“记住一个地址”更强的条件。路径可能被覆盖,URL 可能变化,跨会话权限也可能失效。需要重现当时证据时,应保存快照或版本,并保留内容哈希和定位信息;哈希能检查取得的内容,却不能让已经丢失的内容重新出现。

按需读取也有往返成本。当前决策立即需要的约束适合预先加载,大体量证据再通过工具取得;若某份资料每一轮都会重读,持续卸载可能比保留摘要和关键片段更慢。卸载策略需要同时设计保存位置、重新读取的方法和读取时机。

五、控制与交互

Steering、Follow-up 与 Abort

用户在运行中补充“先看另一个目录”,可能只是希望改变下一步,并不希望把正在完成的工具结果丢掉。输入注入既要及时,也要遵守工具协议,不能随意插在 toolCall 与 toolResult 之间。

Pi 把 steering 和 follow-up 分成不同队列。当前版本在整批工具结果完成、当前 turn 收尾后读取 steering;内部循环自然结束时再处理 follow-up。steering 不会在每个工具完成后立即打断同批剩余工具。若压缩等 prepareNextTurn 耗时,且准备前没有取到输入,准备后还会补检查一次,接住等待期间到来的消息。源码:准备后补轮询、工具批次后的调度

这里的第二次检查有一个细节:只在此前 pending 为空时补查,避免 one-at-a-time 队列在同一轮多取一条。它既解决新输入的时效,也保持队列消费语义。

输入接口需要区分三种意图:下一安全边界改变方向、当前工作自然结束后追加任务、立即请求取消。前两种进入调度队列,第三种进入取消路径。产品只提供一个含糊的“发送”,运行器就只能猜测用户到底要什么。安全边界会带来等待;对耗时工具,还需要明确展示新指令已排队,以及何时生效。

Lost wakeup 与收尾调度

新输入到来时,若 runner 正忙就不再启动一个;runner 处理完队列后转为空闲。这个看似合理的规则会在退出窗口丢失唤醒:旧 runner 最后一次检查队列为空,已经决定结束;新输入随后入队,看见 runner 仍是 busy,便跳过启动;旧 runner 切换 idle 并退出。此时队列里有工作,却没有执行者。

OpenCode 的 coordinator 用 pendingWake 作为门铃。drain 后发现门铃就再检查工作;若唤醒发生在收尾窗口,还会启动 successor。它同时区分 interrupt 的立即确认、当前 execution 的 awaitSettlement,以及包含后继运行的 awaitIdle。源码:drain 与门铃、接口等待语义、收尾窗口与等待语义

收尾窗口的关键逻辑是:先完成可能等待的 cleanup,再同步检查门铃并移交执行权。

wake(key, scope):
    if e := active.get(key):
        e.pending_wake = merge_scope(e.pending_wake, scope)
    else:
        active[key] = start(key, scope)

finish(e):
    await cleanup(e)
    without_await:
        if e.pending_wake:
            active[e.key] = start(e.key, e.pending_wake)
        else:
            del active[e.key]

这是 late wakeup 路径的简写。正常 drain 也会消耗门铃并重新检查工作;合并时 INPUT 覆盖 STEER。start 只安排异步 drain 并返回执行记录,同步段完成 active 登记,因此新 wake 无法插入“检查完门铃、尚未切换状态”的空隙。

门铃表示“还需要检查一次”,可以合并多次唤醒,并不保存任务内容。这里的 executions 是进程内 Map,without_await 也只是同进程协作调度边界。跨进程互斥、任务持久化与断电恢复需要另外实现。

竞态测试需要覆盖最后一次 drain 前、drain 后而 idle 前,以及取消清理期间。取消前积累的旧唤醒可以被清除,但取消后新提交的用户意图仍要运行。正常路径测试很难覆盖这段窗口,最好用 barrier 精确控制时序;OpenCode 的测试正是通过阻塞收尾来验证迟到唤醒。测试:收尾期间的新输入、取消后的唤醒

双向控制协议与通道生命周期

把 CLI 包成 SDK 时,很容易以为“写完 prompt 就可以关闭 stdin,随后只读 stdout”。但若工具权限、hook 或 SDK 内 MCP server 由父进程处理,CLI 还需要从 stdout 发控制请求,再从 stdin 收到对应回复。输入通道承担的已经不只是用户 prompt。

Claude Agent SDK 的 _has_bidirectional_needs 正是检查这些配置,并延后关闭 stdin。一个 result frame 只结束一轮,后台任务仍可能唤醒父 Agent,后续轮次还会发权限或工具控制请求。过早关闭会让这些请求失败为 Stream closed。源码:双向需求与 run 结束

传输层还有独立边界:一次 pipe read 不等于一条 JSON 消息。SDK 按行重组 stdout 的 NDJSON,检查完整行与未完成缓冲的长度;EOF 时可以接受合法的无换行尾帧,但不能把截断 JSON 当作完整消息。源码:消息分帧

这要求分别建模 prompt 输入完成、控制通道可关闭、单轮结果结束和整个 run 结束。控制请求要有 ID、超时与失败唤醒机制;reader 终止时,不能让所有待回复请求各自等到超时。旧 CLI 不报告 session state 且排队多个异步 prompt 时,关闭判定仍有未覆盖的情况,当前 SDK 在相邻注释中保留了这一限制。

权限拒绝与纠正反馈

用户拒绝一个工具,有时意味着“停止这次操作”,有时附带“换到测试目录”。若统一当普通 tool error 返回模型,模型可能换个工具继续执行同一个意图;若统一终止,又会丢掉有价值的纠正反馈。

OpenCode 将无反馈拒绝和带反馈拒绝分别表示为 DeclinedError、CorrectedError。前者沿停止当前轮次的路径传播,避免被工具叶子的通用错误处理吞掉;后者可以转为模型可见反馈,允许下一轮按新约束继续。拒绝还会传递给同 session 的其他 pending 权限请求,收束并行批次。源码:两种控制语义、停止与纠正测试

回复处理位于取消保护区,同 session 的等待请求收到一致的控制信号:

reject(request, feedback):
    signal = CorrectedError(feedback) if feedback else DeclinedError()
    for pending in same_session_requests(request):
        publish_replied(pending, REJECT)
        fail_waiter(pending, signal)
        remove_pending(pending)

“始终允许”也不应直接放行所有旧请求。OpenCode 会重新评估等待请求;已经改变的配置 deny 仍能阻止它。授权是在当前约束下作出的判断,不能只复用几秒前的队列位置。源码:重新评估等待请求

用户停止、方向纠正、配置策略拒绝需要不同的传播路径。可恢复的业务故障可以成为模型反馈;权限拒绝和取消则由运行器维护控制效力,不能由下一轮模型重新解释为执行许可。

六、交接与验收

跨会话交接与回归检查

跨会话交接需要目标、进度和可恢复版本,但这些记录只描述上次观察。依赖可能变化,服务可能没启动,上次测试也可能遗漏回归。读取交接文件以后,还要重新建立对当前环境的信任。

Anthropic 的长程 Harness 把初始化与后续增量工作分开,用功能清单、启动脚本、进度记录和 Git 支撑接手。后续编码提示规定了接手顺序:先启动环境,重测一两个此前已通过的核心功能,发现回归先修复,再开始新功能。官方文章:长程 Harness、官方示例:接手与回归检查

基线检查不需要每轮重跑全部昂贵测试,但要覆盖继续工作所依赖的关键路径。若刚修改认证逻辑,仅检查首页能打开就不足以支持后续工作;重测范围取决于改动影响、成本和风险。

另一个边界是功能清单的保护。提示要求仅更新 passes,不能删改验收内容;这是一条 prompt 约束,示例不能因此自动获得 ACL 或确定性校验。需要强制保护时,可以冻结验收规范或比较不可变字段,由运行器验证允许的状态变更。测试应由外部条件决定,不能在失败后随手改写成容易通过的版本。官方示例:功能清单约束

交接记录还应区分已验证事实、未验证推测和下一步建议,并附上验证所对应的版本或产物。下一位 Agent 既能快速接手,也能判断哪些结论需要重新检查。

运行终态与任务验收

“完成”必须说明完成了哪一层。收到 response.completed 等协议完成标记,才说明对应响应完整结束,连接关闭或 EOF 本身不能证明完成;轮次停止,说明本轮控制流进入终态;任务验收则要求产物满足目标。这三个边界不能通过事件名字互相推导。

Codex 的 TurnComplete.error 可以携带本轮的终态错误,消费方需要读取这个字段。Claude Agent SDK 则会把 CLI 先返回的结构化错误结果保留下来,避免随后非零退出把真实原因压扁成“exit code 1”;同时唤醒所有待处理控制请求,让它们得到同一个终态原因。源码:TurnComplete 的错误字段、Claude SDK 错误保真

协议结束、轮次终态与业务验收分别需要对应证据

Claude Code 官方 Ralph 插件提供另一种观察:stop hook 检查输出中的 <promise> 是否匹配约定字符串,匹配就结束循环。它是停止信号,并非独立测试证明。同样,长程 Harness 示例的外层循环能自动开始新会话,但代码没有把“所有功能通过”作为确定性退出门;打印进度与验证完成是不同职责。源码:Ralph completion promise、官方示例:外层循环

状态模型应分别记录运行终态和验收状态:运行可以正常结束但尚未验收,也可以因预算停止而留下部分成果。验收证据绑定具体产物版本、检查项和结果;修改产物后,受影响的证据需要失效或重跑。另一个 LLM 的“看起来不错”可以提供意见,但不能替代可执行检查和真实环境观察。

run_status: completed | failed | cancelled | budget_exhausted
acceptance: pending | passed | failed
evidence:   artifact_version + checks + results

验收主要检查任务结束后的产物与环境状态,并核对任务明确要求的过程约束。两条工具路线都满足要求时,不应因为调用顺序不同就拒绝其中一条。trace 用于诊断失败、核对授权和成本;固定工具顺序只在任务确实要求该顺序时才属于验收条件。工程实践:评分器与合法替代路径

Harness 的评估与简化

一个工程补丁常常针对某个模型的具体弱点:过早收工、临近上下文上限时收缩工作,或一次推进过多功能。模型升级以后,这些机制可能仍有帮助,也可能增加重启、摘要和协调成本。

Anthropic 后续的 Harness 设计文章记录过这种调整:模型能力变化后,部分上下文重置和 sprint 切分被简化,评估方式也随之变化。官方文章:Harness 的迭代

可以给每个机制写一份短契约:针对什么故障,靠什么指标判断有用,增加多少时间与调用成本,什么条件下应移除。模型或工具升级后,在相同任务集与预算下比较保留、关闭两种版本,观察最终验收率、恢复失败率和总成本。总轮数增长不一定意味着更强,也可能是系统在反复补救自身造成的问题。

验收规范最好在实现之前固定;如果需求改变,就显式更新规范版本。对于生成器与评估器分离的流程,也要防止它们共同依赖错误假设:浏览器是否真的操作了页面,测试是否真的覆盖要求,评估器是否读取了最新产物,都需要可核对的证据。Harness 负责持续推进,也应当允许被证据修正。

比较稳定性时,还需要区分两种成功率:pass@k 关注 k 次尝试中至少一次成功,pass^k 关注 k 次全部成功。前者适合可以筛选候选的任务,后者反映重复运行的可靠性。工程实践:重复试验指标

若同一任务每次独立成功的概率为 0.8,三次里至少一次成功的概率是 99.2%,三次全部成功则只有 51.2%。这个独立同分布假设下的算例说明,多给几次机会可以提高找到可用结果的概率,却没有改变单次运行的成功率。

hits, consistent = [], []
for task in held_out_tasks:
    ok = [grade(run(task, fresh_env(task), budget_limit))
          for _ in range(k)]
    hits.append(any(ok))
    consistent.append(all(ok))
report(mean(hits), mean(consistent))

这里的重复试验从相同初始环境分别开始,不是同一执行现场中的 HTTP 重试。固定单次预算,隔离前一次产物,并记录样本数、模型与 Harness 版本,才能区分稳定性改善和单纯增加尝试次数。

不变量与故障测试

接口承诺需要对应的故障测试。下面这些条件可以直接形成回归用例:

不变量 值得专门制造的失败条件
同一个等待建议不会跨层重新开始计时 通知延迟、锁等待、切换传输方式
恢复输入能反映已经发生的动作 工具已执行而模型流随后中断
同一幂等键不能悄悄改变业务意图 并发提交同键请求、同键异参数、资源删除后的迟到重试
调用只有一个真实终态 完成观察器阻塞期间收到取消
投影与回放使用相同的来源和边界 分支切换、回滚、连续压缩
新工作不会落在 busy→idle 的空隙里 drain 后、收尾中、取消后送入新输入
任务完成有独立且对应当前产物的证据 正常终止却未达标、旧测试对应新文件
卸载后的证据仍可重新取得并核对版本 路径覆盖、引用失效、恢复后读到更新内容

对于竞态,使用 barrier 和可控时钟,比反复跑“希望撞上问题”的测试更有解释力。对于恢复,至少让恢复本身失败一次。对于完成,至少测试一次“进程正常退出但验收不通过”。这些负向条件能说明系统究竟承诺到哪一步。

实现版本与范围

实现依据为 Codex、OpenCode、Pi 的公开源码,以及 Claude Code 官方插件、Claude Agent SDK 和 Anthropic 长程 Harness 示例。Claude Code 核心运行时未在官方仓库完整公开,相关结论限于这里列出的公开层次。相邻工程文章提供补充机制与设计依据,不表示这些方案已经存在于所有项目中。伪代码抽取控制关系,省略具体类型和部分分支;标为设计示意的部分是可采用的接口方案。

源码固定版本:Codex 806d9732、OpenCode 7b3d4ce3、Pi 4ac0bd8c、Claude Code 插件 2301018b、Claude Agent SDK b6e9d12f、Harness 示例 9ec32b91。OpenCode 对应 packages/core 结构,Pi 旧仓库已重定向至 earendil-works/pi。

测试链接对应已有场景和断言的静态核对,上游完整测试集未在本机运行。实现行为以相邻链接的固定提交为准。