Agent 工程体系 · 第 72/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 重试、超时与取消:责任层、退避、部分输出和资源清理

Agent 的一次执行并不是一次模型请求,而是一段可能反复经历“模型推理—工具调用—观察结果—继续推理”的运行过程。OpenAI Agents SDK 将一次 run 描述为一个应用层回合:运行器调用模型、检查输出、执行工具调用、处理交接,直到得到最终答案;Anthropic 也将 Agent 描述为依据环境反馈循环执行、并通过停止条件控制运行边界的系统。(developers.openai.com)

因此,重试、超时和取消不能只写成 HTTP 客户端的几个参数。它们必须回答四个问题:

  1. 哪一层负责再次尝试?
  2. 一次重试是否仍属于同一个业务操作?
  3. 超时或取消发生后,已经产生的部分结果如何处理?
  4. 正在运行的工具、Worker、连接和临时资源如何清理?

如果这些问题没有明确答案,重试可能造成重复扣款,超时可能留下后台任务,取消可能只取消了等待响应的请求,却没有停止真正执行写入的工具。


一、先建立执行模型:Agent 运行不是一个函数调用

把一次 Agent 任务抽象为:

R=M0T0O0M1T1O1FR = M_0 \rightarrow T_0 \rightarrow O_0 \rightarrow M_1 \rightarrow T_1 \rightarrow O_1 \rightarrow \cdots \rightarrow F

其中:

  • MiM_i:第 ii 次模型调用;
  • TiT_i:模型决定执行的工具调用;
  • OiO_i:工具返回的观察结果;
  • FF:最终输出;
  • 箭头表示下一步依赖上一步结果。

一个简单的问答可能只有:

用户输入
  -> 模型调用
  -> 最终文本

但一个真实的 Agent 任务可能是:

用户输入
  -> 规划模型
  -> 查询订单工具
  -> 读取退款规则
  -> 生成退款方案
  -> 请求人工审批
  -> 执行退款工具
  -> 发送通知
  -> 最终回复

这两类任务的重试边界完全不同。模型调用通常是无副作用或弱副作用的;退款、发邮件、写数据库、创建云资源则可能产生外部状态变化。

1.1 运行状态不等于最终答案

一个 Agent run 至少有以下状态:

CREATED
  -> RUNNING
  -> WAITING_TOOL
  -> WAITING_APPROVAL
  -> COMPLETED
  -> FAILED
  -> CANCEL_REQUESTED
  -> CANCELLED
  -> EXPIRED

WAITING_TOOL 表示当前运行正在等待工具结果;WAITING_APPROVAL 表示运行被暂停,等待人工或策略决定。暂停时,系统可能没有最终答案,但已经拥有可恢复的状态快照。OpenAI 的结果模型也区分最终输出、历史、待处理的中断项和可恢复状态;被中断的运行可能没有 finalOutput,而是返回 interruptionsstate。(developers.openai.com)

这一区分很重要:

没有最终答案
≠
什么都没有发生

在失败前,Agent 可能已经:

  • 成功读取了三个工具结果;
  • 创建了一个临时文件;
  • 向下游服务提交了写请求;
  • 输出了若干已经发送给用户的流式片段;
  • 产生了一个等待审批的操作。

重试前必须先检查这些中间状态,而不能简单地“从头再来”。


二、责任层:不要让多个组件同时决定重试

Agent 系统通常存在多个可能重试的层:

客户端
  -> API 网关
    -> Agent 服务
      -> Agent Runner
        -> 模型适配器
          -> 工具适配器
            -> 外部服务

如果每一层都默认重试,就会出现乘法效应。

假设:

  • 网关最多重试 2 次;
  • Agent Runner 对模型调用最多重试 3 次;
  • HTTP 客户端对连接错误最多重试 2 次;
  • 下游工具服务自身最多重试 3 次。

最坏情况下,一次用户请求可能触发:

(1+2)×3×2×3=54(1+2)\times 3\times 2\times 3 = 54

次实际操作。这里的 1+n1+n 表示初始尝试加上 nn 次重试。若其中某一层执行的是非幂等写入,这不仅增加延迟和成本,还可能产生 54 次业务副作用。

2.1 推荐的责任划分

应当为每种错误指定唯一的主要责任层:

错误类型 主要责任层 典型处理
TCP 连接失败、连接重置 HTTP/模型客户端 短暂退避后重试
模型服务限流、暂时不可用 模型适配器或统一调用层 按服务端提示退避
工具参数校验失败 Agent Runner 不重试原调用,返回工具错误给模型修正
工具业务拒绝 工具层/业务层 通常不重试
工具执行结果未知 工具幂等层或操作协调器 查询状态、对账或人工介入
Agent 总体超时 Agent Runner/任务协调器 取消子任务并标记运行状态
用户主动取消 API 层发出取消信号,Runner 执行取消 停止可取消工作并清理
Worker 崩溃 队列与任务协调器 按租约恢复,但必须依赖幂等键

这里的“唯一主要责任层”并不表示其他层完全禁止重试,而是表示默认策略不能叠加。低层可以暴露“本次调用已消耗预算、是否可重试”的结果,由上层作最终决定。

2.2 两种错误不能混为一谈

必须区分:

确定失败

请求明确没有被接受,或服务明确返回可重试错误。

HTTP 503
连接建立失败
模型服务返回 rate limit
工具返回明确的 transient_error

结果未知

客户端没有拿到结果,但服务端可能已经执行成功。

请求发出后连接断开
响应头收到前发生超时
服务端执行完成,但回包丢失
Worker 在提交写入后崩溃

对“确定失败”可以直接重试;对“结果未知”必须先查询或依赖幂等键,否则重试实际上是再次提交。


三、重试的前提:操作必须可安全重复

重试安全性不是由“网络库支持 retry”决定的,而是由操作语义决定的。

将工具调用表示为:

T(k,x)T(k, x)

其中:

  • kk:请求键或操作幂等键;
  • xx:业务参数;
  • TT:工具操作。

理想情况下,同一个 kk 的多次调用满足:

T(k,x)=T(k,x)T(k, x) = T(k, x)

它们最终只产生一个业务效果,并返回同一个已记录结果。

例如,创建退款操作可以使用:

{
  "operation": "refund_order",
  "request_key": "refund:order_123:turn_456",
  "order_id": "order_123",
  "amount": 1000,
  "currency": "CNY"
}

服务端应在数据库中建立唯一约束:

CREATE TABLE tool_operations (
    request_key TEXT PRIMARY KEY,
    tool_name   TEXT NOT NULL,
    input_hash  TEXT NOT NULL,
    status      TEXT NOT NULL,
    result_json TEXT,
    created_at  TIMESTAMP NOT NULL,
    updated_at  TIMESTAMP NOT NULL
);

处理流程不是:

执行退款
写入操作记录

而应尽量是:

1. 以 request_key 抢占或读取操作记录;
2. 若已有成功结果,直接返回历史结果;
3. 若正在执行,查询或等待其状态;
4. 若尚未执行,记录 RUNNING;
5. 执行外部操作;
6. 持久化成功或失败结果;
7. 后续相同 request_key 只读取该结果。

但是,数据库记录和外部支付系统之间仍可能发生双写不一致:

数据库已写 RUNNING
  -> 调用支付系统成功
  -> Agent Worker 崩溃

恢复后不能因为本地没有 SUCCESS 就再次退款。正确做法是:

根据 request_key 向支付系统查询
  -> 已成功:补写 SUCCESS
  -> 未找到:根据服务契约决定是否重试
  -> 查询失败:保持 UNKNOWN,进入对账或人工处理

UNKNOWN 是一种真实状态,不应被强行压缩成 FAILED。把未知结果当成失败,是重复写入的主要来源之一。


四、退避:重试不是立即循环

退避(backoff)是两次尝试之间等待一段时间,以避免瞬时故障期间形成重试风暴。最常见的是指数退避:

dn=min(dmax,d02n)d_n = \min(d_{\max}, d_0 \cdot 2^n)

其中:

  • dnd_n:第 nn 次重试前的等待时间;
  • d0d_0:初始等待时间;
  • dmaxd_{\max}:最大等待时间;
  • n=0n=0 表示第一次重试。

例如 d0=0.5d_0=0.5 秒、dmax=8d_{\max}=8 秒:

第 1 次重试前:0.5 秒
第 2 次重试前:1 秒
第 3 次重试前:2 秒
第 4 次重试前:4 秒
第 5 次重试前:8 秒

但如果所有 Worker 在同一时刻失败,它们仍可能以相同节奏重试,形成“同步重试”。因此需要抖动(jitter)。一种简单的 full jitter 是:

dn=U(0,min(dmax,d02n))d_n = U(0,\min(d_{\max}, d_0 \cdot 2^n))

其中 U(0,x)U(0,x) 表示在 0 到 xx 之间均匀随机取值。

from __future__ import annotations

import asyncio
import random
from collections.abc import Awaitable, Callable
from typing import TypeVar

T = TypeVar("T")


class RetryableError(Exception):
    pass


async def retry(
    operation: Callable[[], Awaitable[T]],
    *,
    max_attempts: int = 4,
    base_delay: float = 0.5,
    max_delay: float = 8.0,
    should_retry: Callable[[Exception], bool] = lambda exc: isinstance(
        exc, RetryableError
    ),
) -> T:
    if max_attempts < 1:
        raise ValueError("max_attempts must be >= 1")

    for attempt in range(max_attempts):
        try:
            return await operation()
        except Exception as exc:
            last_attempt = attempt == max_attempts - 1

            if last_attempt or not should_retry(exc):
                raise

            ceiling = min(max_delay, base_delay * (2**attempt))
            delay = random.uniform(0, ceiling)

            # 取消发生时,sleep 本身也必须可被取消。
            await asyncio.sleep(delay)

    raise AssertionError("unreachable")

这段代码只解决了“如何等待并再次调用”,没有解决“是否应该再次调用”。真正决定安全性的仍然是 should_retryoperation 是否具备幂等语义。

4.1 退避必须受总预算约束

单次调用的重试时间不能脱离 Agent 总体截止时间。设:

  • DD:Agent 总截止时间;
  • tt:已经消耗的时间;
  • ee:预计本次调用及清理所需的时间;
  • dnd_n:下一次退避时间。

只有在:

t+dn+e<Dt + d_n + e < D

时,才允许安排下一次重试。

例如 Agent 总预算为 10 秒,当前已经耗时 8.8 秒,下一次退避为 1 秒,即使错误是可重试的,也不应重试。否则即使请求成功,外层也无法在截止时间内完成发送、持久化和资源清理。


五、超时:不是一个数字,而是预算树

超时(timeout)是系统对等待时间的上限。Agent 中至少有四种不同超时:

  1. 连接超时:建立网络连接允许等待多久;
  2. 单次调用超时:一次模型或工具调用允许执行多久;
  3. 步骤超时:一个 Agent 步骤,包括重试,允许占用多久;
  4. 运行总超时:整次 Agent run 从开始到结束的硬截止时间。

不能只配置一个 timeout=30,然后期待它自动覆盖所有层。更准确的模型是一个预算树:

Agent 总预算:60s
├── 规划模型:15s
│   ├── 第一次调用:8s
│   └── 重试:剩余预算
├── 搜索工具:20s
├── 并行分析:15s
└── 持久化与发送:10s

如果并行执行 NN 个子任务,步骤耗时通常取最长路径,而不是所有任务耗时之和:

Tparallelmax(T1,T2,,TN)+TjoinT_{\text{parallel}} \approx \max(T_1,T_2,\ldots,T_N)+T_{\text{join}}

但资源消耗和下游压力仍可能按 NN 增长,所以并行不能只看延迟,还必须受并发上限和队列背压控制。Anthropic 将并行化描述为把独立子任务同时运行后聚合结果;这类结构降低了关键路径延迟,但也扩大了取消和清理范围。(anthropic.com)

5.1 正确的超时传播

超时应沿调用树向下传播:

请求 deadline = 12:00:10.000
  -> Agent Runner deadline = 12:00:10.000
    -> 工具 deadline = 12:00:08.500
      -> 数据库查询 deadline = 12:00:08.000

子调用的截止时间必须早于父调用,留出结果整理、状态写入和连接关闭时间。若子工具一直使用自己的 30 秒超时,父 Agent 在 10 秒时退出,并不能保证工具在后台停止。

在 Python 中可以使用相对简单的总超时包装:

import asyncio


async def run_agent_with_deadline(agent_run, timeout_seconds: float):
    try:
        return await asyncio.wait_for(agent_run(), timeout_seconds)
    except asyncio.TimeoutError:
        # 这里表示本地等待超时,不代表下游一定没有执行成功。
        raise

这段代码的边界是:wait_for 只能取消当前 Python 协程。它是否能停止 HTTP 请求、子进程、远程工具或模型服务,取决于这些下层组件是否正确响应取消信号。因此,应用层仍需处理“远程结果未知”。


六、取消:请求取消、计算取消和业务撤销不是一回事

取消(cancellation)至少有三种含义:

6.1 请求取消

客户端关闭连接,或用户点击“停止生成”。

它首先只说明:

用户不再等待这个响应

它不自动说明:

数据库写入必须回滚
支付操作必须撤销
后台 Worker 必须停止

6.2 计算取消

运行时收到取消信号,停止尚未完成的模型调用、工具等待、并行任务或本地计算。

6.3 业务撤销

已经执行成功的业务操作被反向补偿,例如:

已创建订单 -> 取消订单
已扣款 -> 发起退款
已发送邮件 -> 无法真正撤回,只能补发更正通知

业务撤销不是取消的自动结果,它必须由业务协议单独定义。

6.4 取消信号必须覆盖整棵任务树

考虑如下任务:

Agent Run
├── 模型调用
├── 搜索工具
│   ├── 搜索 A
│   └── 搜索 B
└── 写入工具

用户取消时,系统至少要向所有仍在运行的子任务传播取消信号:

sequenceDiagram
    participant U as 用户
    participant A as API
    participant R as Agent Runner
    participant M as 模型
    participant T as 工具
    participant Q as 队列

    U->>A: 取消请求
    A->>R: cancel(run_id)
    R->>M: 取消等待/关闭流
    R->>T: 取消可取消调用
    R->>Q: 标记任务 CANCELLED
    T-->>R: 已停止/结果未知
    R->>R: 清理资源并持久化状态
    R-->>A: 返回 CANCELLED 或 UNKNOWN
    A-->>U: 任务已取消,可能存在未决外部操作

取消路径中最容易遗漏的是队列消息。API 请求取消后,如果队列中的任务仍然保持 READY,Worker 稍后仍会执行它。队列任务至少需要携带:

{
  "run_id": "run_456",
  "request_key": "agent:session_123:turn_7",
  "expires_at": "2026-09-01T10:00:30Z"
}

Worker 取到任务后必须再次检查:

任务是否已 CANCELLED?
任务是否已过期?
是否已有成功结果?
是否已有其他 Worker 持有租约?

取消检查不能只发生在任务开始时。长时间运行的工具、循环规划和批量处理都应在安全边界定期检查取消信号。


七、部分输出:已发送的数据无法撤回

流式输出会改变失败语义。

假设 Agent 已经向客户端发送:

正在查询订单……
订单金额为 1000 元,

随后退款工具超时。此时系统不能再把这次执行当成“完全没有输出”。客户端已经观察到部分内容,且这些内容可能影响用户判断。

7.1 将输出分为草稿、事件和最终提交

一种可靠的输出模型是:

事件流:
  RUN_STARTED
  TEXT_DELTA("正在查询订单")
  TOOL_STARTED("get_order")
  TOOL_FINISHED(...)
  TEXT_DELTA("订单金额为 1000 元")
  RUN_FAILED(...)

其中:

  • TEXT_DELTA 是可观察的增量;
  • TOOL_* 是执行事实;
  • RUN_FAILED 表示最终状态;
  • 最终答案是否可展示,由客户端根据状态决定。

不要把每一个文本增量都当成最终事实。对于需要准确性或副作用确认的场景,应采用“草稿输出 + 最终提交”:

模型生成草稿
  -> 工具执行
  -> 校验
  -> 一次性提交最终答案

对于实时聊天,则可以保留流式体验,但必须在结束时发送明确终止事件:

{
  "type": "run_ended",
  "status": "failed",
  "reason": "tool_timeout",
  "partial": true,
  "retryable": true
}

7.2 重试时不要重复发送旧片段

错误实现:

第一次运行发送:
  正在查询订单……
  订单金额为 1000 元,

第一次失败后从头重试,又发送:
  正在查询订单……
  订单金额为 1000 元,

客户端会看到重复内容。更严重的是,第一次运行可能已经成功完成查询,第二次运行又再次调用写工具。

可以使用输出序号或事件 ID:

{
  "run_id": "run_456",
  "event_id": 17,
  "sequence": 3,
  "type": "text_delta",
  "text": "订单金额为 1000 元"
}

发送器以 (run_id, event_id) 去重;重试时从持久化事件位置继续,而不是无条件从头推送。若无法保证客户端去重,则重试应生成新的运行版本,并明确告知:

上一次执行未能确认完成,正在核验结果。

7.3 部分输出能否作为结果?

可以,但必须满足两个条件:

  1. 输出被标记为部分结果;
  2. 输出的语义不要求后续步骤才能成立。

例如:

已完成:
- 已读取订单信息
- 已确认订单金额为 1000 元

未完成:
- 尚未执行退款
- 未发送退款通知

这比返回一个看似完整、实际没有执行退款的“退款成功”更安全。


八、资源清理:finally 不是形式,而是生命周期边界

Agent 的资源包括:

  • HTTP 连接;
  • 流式响应;
  • 数据库事务;
  • 文件句柄;
  • 临时文件;
  • 子进程;
  • 浏览器页面或会话;
  • 沙箱容器;
  • 分布式锁;
  • 队列租约;
  • 并行任务;
  • 计费中的外部运行。

资源清理的基本不变量是:

RUN_ENDED所有可回收资源已释放或已登记待清理\text{RUN\_ENDED} \Rightarrow \text{所有可回收资源已释放或已登记待清理}

注意“已登记待清理”这一部分。远程资源不一定能在当前请求内立即释放,因此需要 cleanup job 或补偿任务。

示例:

import asyncio
from contextlib import asynccontextmanager


@asynccontextmanager
async def managed_tool(tool):
    resource = await tool.open()
    try:
        yield resource
    finally:
        try:
            await asyncio.shield(tool.close(resource))
        except Exception as exc:
            # 清理失败不能覆盖原始业务错误;
            # 应记录并交给异步清理机制。
            print(f"cleanup failed: {exc}")


async def run_step(tool, cancelled: asyncio.Event):
    async with managed_tool(tool) as resource:
        for chunk in tool.iter_chunks(resource):
            if cancelled.is_set():
                raise asyncio.CancelledError
            await process(chunk)

这里的 finally 保证正常返回、异常和取消路径都会尝试关闭资源。asyncio.shield 的意图是避免外层取消立即打断关闭动作,但它不是万能的:如果远程连接已经失效,仍需将资源标记为 CLEANUP_UNKNOWN 并由后台任务继续处理。

8.1 清理顺序

通常应按照“停止新工作—取消子任务—释放本地资源—更新状态”的顺序:

1. 禁止创建新的工具调用;
2. 取消尚未开始或可取消的子任务;
3. 释放连接、文件、锁、容器等本地资源;
4. 对远程资源执行关闭或撤销;
5. 持久化最终运行状态;
6. 发布完成事件。

如果先发布 CANCELLED,再去释放锁,另一个 Worker 可能看到任务已取消并立即接管,导致两个 Worker 同时操作同一外部资源。


九、重试、超时和取消在多 Agent 中的传播

多 Agent 系统还要处理“谁拥有控制权”的问题。OpenAI 的多 Agent 编排区分两种模式:handoff 将控制权交给专家 Agent;agents-as-tools 则由管理 Agent 保持最终回复的所有权。(developers.openai.com)

这会直接影响故障责任:

主管 Agent
├── 专家 Agent A:作为工具调用
└── 专家 Agent B:handoff 后接管

在“作为工具调用”模式中,主管 Agent 通常负责:

  • 设置子 Agent 的截止时间;
  • 决定是否重试;
  • 聚合部分结果;
  • 对用户输出最终状态。

在 handoff 模式中,控制权已经交给专家 Agent,必须定义:

  • 专家失败后是否回到主管;
  • 主管是否可以重新 handoff;
  • 专家的部分输出是否已经对用户可见;
  • 原 Agent 的取消信号如何传递给新 Agent。

一个常见反例是:

主管 Agent 超时
  -> 主管请求被取消
  -> 专家 Agent 在后台继续运行
  -> 专家完成后发送消息

用户已经结束本次请求,但后台专家仍会产生结果。这不一定错误,但必须是有意设计的异步任务;否则就是“取消只取消了外层等待”。


十、完整算例:退款 Agent 的失败路径

设用户请求:

“请把订单 123 的 1000 元退款,并告诉我结果。”

系统流程如下:

run_id = run_456
request_key = refund:order_123:turn_7
总截止时间 = 20 秒

第一步:模型决定调用退款工具

工具调用参数:

{
  "tool": "refund_order",
  "request_key": "refund:order_123:turn_7",
  "order_id": "123",
  "amount": 1000
}

第二步:工具服务记录 RUNNING

request_key = refund:order_123:turn_7
status = RUNNING

第三步:调用支付服务

支付服务实际完成退款,但返回响应前网络断开:

支付系统:退款成功
Agent Worker:收到连接错误

此时结果不是 FAILED,而是:

UNKNOWN

第四步:Agent 是否直接重试?

不能直接重试。先使用相同 request_key 查询:

GET /refunds/status?request_key=refund:order_123:turn_7

如果返回:

{
  "status": "SUCCEEDED",
  "refund_id": "rf_789"
}

则本地补写:

status = SUCCEEDED
result_json = {"refund_id": "rf_789"}

随后模型生成:

订单 123 已完成 1000 元退款,退款单号为 rf_789。

如果查询也超时:

status = UNKNOWN

则不能告诉用户“退款失败”,也不能告诉用户“退款成功”。应返回:

退款操作已提交,但当前无法确认最终结果。系统将继续核验,请勿重复发起退款。

同时将对账任务加入队列:

reconcile(refund:order_123:turn_7)

这个例子说明,重试并不总是再次执行原操作。对于写入类操作,重试往往应该是“查询状态—恢复记录—必要时补偿”,而不是重新提交。


十一、诊断:只记录“重试了几次”是不够的

生产日志至少要能回答以下问题:

run_id
parent_run_id
attempt
request_key
tool_name
agent_name
deadline
remaining_budget
cancel_reason
error_class
remote_operation_id
cleanup_status
partial_output
final_status

例如:

{
  "run_id": "run_456",
  "attempt": 2,
  "request_key": "refund:order_123:turn_7",
  "tool_name": "refund_order",
  "deadline_ms": 20000,
  "elapsed_ms": 18340,
  "error_class": "result_unknown",
  "remote_operation_id": null,
  "cancel_reason": null,
  "cleanup_status": "completed",
  "partial_output": true,
  "final_status": "unknown"
}

不同故障应有不同指标:

  • retry_count:重试次数;
  • retry_exhausted_count:重试耗尽次数;
  • timeout_count:超时次数;
  • cancel_requested_count:收到取消的次数;
  • unknown_result_count:结果未知次数;
  • cleanup_failed_count:清理失败次数;
  • duplicate_suppressed_count:因幂等键抑制的重复调用次数;
  • partial_run_count:产生部分输出的运行次数。

只看成功率会掩盖危险:系统可能“成功率很高”,但同时有大量 UNKNOWN 写操作等待人工对账。


十二、常见误解与边界

误解一:超时就是失败

超时只表示当前等待者放弃等待。远程服务可能已经成功执行。对于只读查询,超时通常可以重试;对于写操作,超时往往意味着进入未知状态。

误解二:取消会自动回滚

取消协程不会自动撤销已经提交的数据库事务、支付请求或外部 API 操作。取消之后需要执行回滚、补偿、查询或对账中的一种。

误解三:流式文本发出后,重试可以从头生成

除非客户端具备事件去重或服务端维护可恢复的事件游标,否则从头发送会造成重复输出。更安全的做法是保存事件序号,并区分草稿、事实和最终状态。

误解四:重试次数越多越可靠

当错误来自参数、权限、配额或业务规则时,重试只会重复失败。可靠性来自正确的错误分类,而不是更大的 max_retries

误解五:finally 能解决所有清理问题

finally 只能保证程序尝试执行清理逻辑,不能保证远程资源已经释放。网络断开、进程崩溃和节点失联时,必须依靠租约、TTL、后台清理和对账机制。

误解六:队列重投就是 Agent 重试

队列重投通常意味着 Worker 没有确认任务完成;Agent 内部重试可能只重试一个模型或工具步骤。二者的幂等边界不同,必须通过 run_idrequest_key 和步骤状态关联起来。


十三、建议的最小状态机

对于一个可恢复的 Agent 任务,可以采用如下状态:

READY
  -> RUNNING
  -> RETRY_WAIT
  -> WAITING_TOOL
  -> WAITING_APPROVAL
  -> COMPLETED

RUNNING
  -> FAILED
  -> CANCEL_REQUESTED
  -> EXPIRED

CANCEL_REQUESTED
  -> CANCELLED
  -> UNKNOWN

WAITING_TOOL
  -> RUNNING
  -> RETRY_WAIT
  -> UNKNOWN

UNKNOWN
  -> COMPLETED
  -> FAILED
  -> MANUAL_REVIEW

关键约束是:

COMPLETED 只能由确定成功的最终状态进入;
FAILED 只能由确定失败的状态进入;
UNKNOWN 不能直接当作 FAILED;
CANCELLED 不代表外部副作用已经撤销;
EXPIRED 不代表后台 Worker 已经停止。

OpenAI Agents 的运行循环、会话状态和中断恢复能力说明了一个更一般的原则:Agent 运行结果不只是最终文本,还包括下一轮继续执行所需的历史、状态和中断信息。(developers.openai.com)


结语:可靠执行的核心是“知道发生了什么”

Agent 的重试、超时和取消,最终都围绕同一个问题:

系统能否区分“没有执行”“执行失败”“执行成功但响应丢失”“已经产生部分输出”和“仍有后台资源未清理”?

可以将责任边界压缩成四条不变量:

  1. 重试只发生在明确可重试、且副作用可控的操作上。
  2. 超时必须传播为截止时间,而不是各层互不相干的数字。
  3. 取消必须覆盖任务树,并明确区分停止计算与撤销业务副作用。
  4. 任何终止路径都必须保存运行状态、部分输出和清理结果。

做到这些,Agent 才能在模型服务抖动、工具超时、Worker 崩溃、用户取消和网络断连时保持可解释、可恢复,而不是依靠“再跑一次”碰运气。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。