OpenAI Agents SDK RunState 将 Tool Approval 状态跨进程持久化并恢复执行的生产架构 - XBSTACK

OpenAI Agents SDK RunState 实战:Tool Approval 如何跨进程恢复?

Release Date
2026-07-24
Reading Time
25分钟
Content Size
13,132 chars
OpenAI Agents SDK
RunState
Human-in-the-loop
Tool Approval
Python
AI Agent
Idempotency
State Persistence
Production Engineering
Xiaobai's Note / 实验室笔记

实验使用 Python 3.10.2、openai-agents 0.18.3、确定性自定义 Model 和 SQLite;未调用 OpenAI 或其他模型 Provider API。本文验证 SDK 运行语义、跨进程状态恢复、重复投递和 Context 序列化边界,不代表真实模型选择工具的准确率、网络延迟、Token 成本或托管队列表现。

OpenAI Agents SDK RunState 实战:Tool Approval 如何跨进程恢复?

先说结论:OpenAI Agents SDK 的 RunState 已经能把一次等待人工审批的 Agent 运行保存下来,让原进程退出,并在另一个进程里批准、拒绝和继续执行;但它只解决“Agent 从哪里继续”,不会替你解决“这项业务动作是否仍然合法、是否会执行两次、状态里有没有秘密”。

我用 openai-agents==0.18.3 做了一套完全不调用模型 Provider 的确定性实验。Agent 在调用 deploy_release 前产生一个 interruption,暂停时数据库副作用为 0;状态写入 JSON 后,由另一个进程批准并交给 Worker 恢复,工具执行 1 次。拒绝路径保持 0 次执行。真正危险的结果出现在状态重放:同一份已批准状态交给两个 Worker,工具执行了 2 次;只有加入业务幂等账本后,副作用才重新回到 1 次。

这篇文章不是讲如何做一个审批按钮,而是回答生产环境里更难的几个问题:审批可能等待数小时,原进程早已退出;版本可能已经升级;队列可能重复投递;审批人可能没有当前租户权限;RunContextWrapper.context 里还可能放着 Token。RunState 是重要基础设施,但它不是完整的审批系统。

OpenAI Agents SDK RunState 实验范围:暂停、跨进程批准、拒绝、重复投递和 Context 脱敏

一、这个问题为什么值得单独写,而不是继续补 LangGraph 审批流

XBSTACK 已经有一篇 LangGraph Human-in-the-loop 审批流,里面讨论的是 interrupt、Checkpointer、thread_id 和图节点恢复。OpenAI Agents SDK 的搜索任务不同:开发者使用的是 function_tool(needs_approval=True)RunResult.interruptionsRunState.to_json()Runner.run(agent, state),恢复时没有 LangGraph 拓扑和 Checkpointer 帮你托管业务状态。

OpenAI Agents SDK 的 Python HITL 能力也是一个经历过真实需求推动的功能。早期 GitHub Issue #636 要求 Python SDK 原生支持工具执行前暂停、结构化 interruption、可序列化状态和批准/拒绝后恢复;今天这些能力已经进入正式文档和 openai-agents 0.18.3。但“能暂停恢复”只是第一层。官方文档同时提醒:序列化状态会包含应用 Context、审批、Usage、Tool Input、嵌套 Agent 恢复信息、Trace 元数据和服务端会话设置;长时间审批还应记录 Agent 定义或 SDK 版本。

这正是本文的核心矛盾:

RunState 让 Agent 运行可恢复,但恢复能力越强,业务授权、幂等、秘密和版本边界越不能交给 SDK 默认值。

如果你只是进程内 input("approve?"),官方示例已经足够。如果你要把审批放到 Web 后台、OA、Slack、邮件或移动端,并允许 Worker 在几小时后恢复,就需要完整的数据模型和失败实验。

二、实验环境、任务和没有测试的内容

本次实验代码保存在:

experiments/openai-agents-runstate-approval-resume/

运行环境如下:

项目实验值
Python3.10.2
OpenAI Agents SDKopenai-agents==0.18.3
RunState Schema$schemaVersion = 1.12
Model自定义确定性 Model
Provider API未调用
持久化JSON 文件 + SQLite
高风险工具deploy_release
幂等键tenant-a:release:2026.07.24

我没有用真实模型决定要不要部署,因为那会把模型随机性、网络、费用和 Provider 行为混入实验。确定性 Model 第一次固定生成一个 deploy_release Tool Call,收到工具结果后固定输出最终消息。这样可以把测试范围收窄到 SDK 的暂停、状态序列化、恢复和工具执行语义。

实验覆盖六条路径:

  1. 工具需要审批时,是否在产生副作用前暂停;
  2. 原进程退出后,另一个进程能否加载状态、批准并恢复;
  3. 拒绝后是否仍然执行工具;
  4. 同一份已批准状态被重复投递时,工具会执行几次;
  5. 幂等账本是否能阻止重复副作用;
  6. Context 中的 Demo Secret 会不会进入状态 JSON。

没有测试真实 OpenAI Responses API、流式 interruption、嵌套 Agent.as_tool()、Hosted MCP Approval、真实 Redis/Temporal/Celery 队列、多个 Worker 的同时竞争以及真实 Trace 导出。因此本文不能推导模型选择工具的准确率,也不能给出生产吞吐和延迟数据。

三、Tool Approval 到底如何暂停和恢复

官方流程可以压缩成七步:模型提出 Tool Call;Runner 检查 needs_approval;尚无决策时产生 ToolApprovalItemRunResult.interruptions 暴露待审批项;应用把结果转换为 RunState 并持久化;人工对状态执行 approve()reject();最后再次调用 Runner.run(agent, state)

OpenAI Agents SDK Tool Approval 从模型调用、interruption、RunState 序列化到恢复执行的完整生命周期

最小工具定义并不复杂:

from agents import function_tool

@function_tool(needs_approval=True)
def deploy_release(release: str, idempotency_key: str) -> str:
    return f"deployed:{release}"

第一次运行:

result = await Runner.run(
    agent,
    "Deploy release 2026.07.24",
    context=AppContext(
        tenant_id="tenant-a",
        api_token=runtime_token,
    ),
)

assert len(result.interruptions) == 1
assert effect_count() == 0
state = result.to_state()

needs_approval=True 不是提示词约定,而是 Runner 在工具执行层检查的硬边界。模型已经提出了调用,参数也已经形成,但工具函数还没有运行。本次实验暂停时:

{
  "interruption_count": 1,
  "tool_name": "deploy_release",
  "arguments": "{\"release\":\"2026.07.24\",\"idempotency_key\":\"tenant-a:release:2026.07.24\"}",
  "effect_count_before_approval": 0
}

needs_approval 也不只适用于普通 function_tool。官方文档还覆盖 Agent.as_tool()ShellToolApplyPatchTool、本地 MCP Server 的 require_approval 和 Hosted MCP Tool Approval。嵌套 Agent 内部产生的审批会冒泡到外层 Run,这意味着审批中心应围绕外层 Run 建模,而不是给每个子 Agent 临时设计一套 UI。

四、RunState 保存的是 SDK 运行,不是完整业务工单

RunState 的价值在于把 Runner 继续执行所需的信息封装成可序列化快照。官方参考文档列出的范围包括当前 Agent、原始输入、模型响应、Generated Items、审批状态、Usage、Tool Input、Guardrail 结果、Trace 元数据,以及可选的 conversation_idprevious_response_id 和自动响应链设置。

本次暂停状态经过自定义 Context Serializer 后,JSON 大小为 6,279 bytes,顶层包含:

$schemaVersion
current_turn
current_agent
original_input
model_responses
context
tool_use_tracker
generated_items
current_step
last_model_response
last_processed_response
conversation_id
previous_response_id
trace

但是下面这些内容不应只依赖 RunState:

  • 哪个租户创建了审批;
  • 哪个用户有权批准;
  • 审批是否已经过期或撤销;
  • Tool 参数是否在审批后被替换;
  • 队列消息是否重复;
  • Worker 是否取得执行租约;
  • 外部部署、付款、邮件是否已经产生副作用;
  • State Blob 存在哪里、谁能读取、何时删除。

RunState 负责的 SDK 运行状态与应用必须独立管理的审批、授权、幂等和存储边界

生产系统至少应把数据拆成三类:

run_state_blob       # SDK 快照,可加密存 Blob Store
approval_request     # 业务审批工单、权限与生命周期
idempotency_ledger   # 外部副作用去重、租约与结果复用记录

把三者塞进一张 agent_runs 表虽然省事,但很快会出现权限、TTL 和查询模式冲突。RunState Blob 可能较大,适合对象存储;审批工单需要频繁按用户、状态和过期时间查询;幂等记录需要唯一约束和强一致事务。它们的生命周期也不同。

审批页面展示的内容也不能直接等同于 RunState 原始 Tool Input。生产系统应在 interruption 产生时生成一份不可变的 review_snapshot,只包含审批人真正需要看的字段,并同时计算摘要:

canonical_args = json.dumps(
    parsed_arguments,
    ensure_ascii=False,
    sort_keys=True,
    separators=(",", ":"),
)
arguments_digest = hashlib.sha256(canonical_args.encode()).hexdigest()

人工批准的是这份快照和摘要,而不是一个随时可以被后端重新拼装的 Python Dict。Worker 恢复前再次从 RunState 取出 Tool 名称与参数,重新计算摘要;只有与工单中的 arguments_digest 一致才允许执行。否则应将工单转为 invalidated,要求重新审批。

这个步骤能防止两类隐蔽问题:第一,审批页面看到的是“部署测试环境”,但恢复前参数被替换成生产环境;第二,Tool Schema 在等待期间升级,旧 JSON 被新代码以不同默认值解释。签名或摘要不是为了证明模型诚实,而是为了证明批准时看到的动作与 Worker 最终执行的动作完全相同

此外,审批工单必须保存最小权限上下文,而不是只保存操作者 ID。至少要记录批准时的租户、角色、Scope、目标环境和资源边界。Worker 不能因为工单已经标记 approved 就跳过当前授权检查;长时间等待期间,用户可能离职、角色被撤销、生产变更窗口已经关闭。安全做法是同时满足“批准时有权”和“执行时仍有权”,任何一项失败都停止恢复。

这不是抽象的安全洁癖。OpenAI Agents SDK 社区在 2026 年提出的 per-tool authorization 需求,明确把 Guardrail 与 Authorization 区分开:前者检查输入输出内容,后者决定某个身份在当前租户、Scope、速率限制和会话条件下能否执行这次 Tool Call。needs_approval=True 解决“必须有人确认”,但它不会自动证明这个审批人有权批准目标资源,也不会替 Worker 完成执行时的二次授权。因此,生产系统应把 Tool Approval 看作授权流程中的一个决策点,而不是完整的权限系统。

更多 Agent 权限、状态与生产治理内容可继续进入 XBSTACK AI Agent 专题

五、跨进程批准与恢复:真正需要重建的是 Agent 定义

实验把一次完整审批拆成三个独立进程:

  • 进程 A 运行 Agent,得到 interruption,把 RunState 写入文件后退出;
  • 进程 B 模拟 Approval API,加载状态并记录批准决策,再把状态写回;
  • 进程 C 模拟 Worker,重新构建 Agent,通过 RunState.from_json() 加载状态并恢复。

Agent Runner、Approval API 和 Worker 三个进程完成一次 Tool Approval 持久化恢复

状态序列化代码:

def context_serializer(context: AppContext) -> Mapping[str, Any]:
    return {"tenant_id": context.tenant_id}

payload = state.to_json(
    context_serializer=context_serializer,
    strict_context=True,
)

wrapper = {
    "app_state_version": "runstate-approval-lab/v1",
    "sdk_state": payload,
}

另一个进程加载:

state = await RunState.from_json(
    initial_agent,
    wrapper["sdk_state"],
    context_deserializer=context_deserializer,
    strict_context=True,
)

这里最容易产生误解:from_json() 的第一个参数仍然是 initial_agent。JSON 不会把 Python 函数、异步回调、Tool 实现、数据库连接、Secret Manager Client 和完整 Agent Graph 变成可执行对象。恢复进程必须使用兼容代码重新构建 Agent,并让 SDK 根据序列化身份把状态映射回这些定义。

批准过程:

interruption = state.get_interruptions()[0]
state.approve(interruption)
await persist(state)

Worker 恢复:

state = await load_state(agent)
result = await Runner.run(agent, state)

最终结果:

{
  "final_output": "Release workflow finished: deployed:2026.07.24",
  "effect_count": 1
}

一个容易踩坑的实现细节是:在 0.18.3 实验中,调用 state.approve(interruption) 后,get_interruptions() 仍然能枚举到原 interruption,直到 Runner 真正恢复并消费它。业务代码不要用“interruption 数量是否为 0”来判断工单是否已批准。审批状态应该由独立 approval_request.status 管理,并通过 compare-and-set 从 pending 单向迁移到 approvedrejected

跨进程恢复还需要一套 Agent Registry。不要在 Worker 中永远调用当前最新版 build_agent(),而应按状态版本路由:

AGENT_FACTORIES = {
    "runstate-approval-lab/v1": build_release_agent_v1,
    "runstate-approval-lab/v2": build_release_agent_v2,
}

factory = AGENT_FACTORIES.get(record.app_state_version)
if factory is None:
    raise StateVersionUnsupported(record.app_state_version)
agent = factory(runtime_dependencies)

每个 Factory 应固定 Agent 名称、Tool 名称、参数 Schema、关键 Prompt Revision 和 Handoff 拓扑。部署新版本时,旧 Factory 至少保留到所有未决审批过期;如果无法长期维护,则在发布迁移时批量撤销旧审批。否则“状态 JSON 还能读”并不意味着 SDK 能找到同名 Tool,更不意味着新 Tool 仍认可旧参数。

恢复接口也不应该直接接收任意 State JSON。合理入口是 POST /approvals/{approval_id}/resume,服务端根据登录用户和租户读取私有记录,再由 Worker 获取 Blob。这样可以避免攻击者提交自行修改的 RunState、替换审批结果或猜测对象存储地址。外部请求只携带不可枚举的审批 ID 和正常身份凭据,不携带可执行状态本身。

六、拒绝不是抛异常,而是把拒绝结果送回 Agent

拒绝路径同样从暂停状态恢复:

state.reject(
    interruption,
    rejection_message="Deployment was denied by the release manager.",
)
result = await Runner.run(agent, state)

实验结果:

{
  "final_output": "Release workflow finished: Deployment was denied by the release manager.",
  "effect_count": 0
}

也就是说,拒绝不会调用 deploy_release,但 Agent 仍可收到一个模型可见的拒绝结果,继续解释、重新规划或结束。官方还支持通过 RunConfig.tool_error_formatter 设置运行级默认拒绝信息,或者在单次 state.reject() 中传 rejection_message 覆盖。

生产系统需要区分四种状态,而不是只有布尔值:

状态含义后续动作
pending等待人工不投递 Worker
approved参数与权限已确认投递恢复任务
rejected明确拒绝恢复 Agent 生成拒绝说明,工具不执行
expired/revoked超时或撤销禁止旧状态继续执行

如果一次 Run 同时产生多个 interruptions,官方允许只处理一部分后先恢复;已决策调用可继续,未决策调用会再次暂停。这意味着审批工单应至少以 run_id + tool_call_id 为粒度,而不是简单给整个 Run 一个 approved=true

七、最危险的反例:同一份已批准状态会把工具执行两次

真正改变本文结论的是状态重放实验。

我先把同一份已批准状态交给 Worker A。工具执行,数据库写入一条副作用记录。然后不使用 Worker A 的最新结果,而是再次把原始已批准状态交给 Worker B。Runner 并不知道这是队列重投、人工重复点击还是 Worker A 完成后 ACK 丢失,它只知道这个 Tool Call 已获批准、尚未在当前快照中完成,因此再次执行工具。

同一份已批准 RunState 重放时,没有幂等账本会执行两次,有唯一幂等键时只执行一次

没有幂等账本:

{
  "retry-a": "deployed:2026.07.24",
  "retry-b": "deployed:2026.07.24",
  "effect_count": 2
}

加入 SQLite 唯一幂等键:

{
  "retry-a": "deployed:2026.07.24",
  "retry-b": "reused:deployed:2026.07.24",
  "effect_count": 1
}

工具中的关键事务:

connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
    "SELECT result FROM idempotency_ledger WHERE idempotency_key = ?",
    (idempotency_key,),
).fetchone()

if existing:
    return f"reused:{existing[0]}"

result = perform_external_effect()
connection.execute(
    "INSERT INTO idempotency_ledger(idempotency_key, result) VALUES (?, ?)",
    (idempotency_key, result),
)
return result

真实系统中的幂等键不应由模型自由生成。可以使用:

tenant_id + approval_id + logical_operation + target_resource

或者由应用在创建审批工单时生成不可变 operation_key,再通过 Tool Context 注入。对付款、部署、发邮件、创建工单等副作用,最好让下游 API 本身也接受幂等键。只在 Agent 数据库做去重,无法覆盖“外部系统已成功、本地事务却在写结果前崩溃”的窗口。

这与 AI SDK 7 流式中断与 Tool Call 恢复 的结论一致:恢复状态和业务幂等属于两个层级。Agent 框架可以告诉你应该从哪个调用继续,但只有业务系统知道这个动作是否已经发生。

这里还存在一个比“重复投递”更难的崩溃窗口:外部部署已经成功,但 Worker 在写入本地幂等结果前崩溃。下一次重试查询本地账本时仍看不到完成记录,于是再次部署。单库事务无法原子覆盖一个远程系统,因此需要根据 Tool 类型选择策略:

  • 下游支持 Idempotency-Key:把应用生成的 operation_key 原样传给下游,以对方的幂等结果为准;
  • 下游支持查询:先用稳定业务 ID 查询是否已经创建,再决定创建或复用;
  • 下游不支持幂等也无法查询:把操作标记为 uncertain,进入人工核对,而不是自动重试;
  • 本地可控写入:使用同一数据库事务或 Transactional Outbox,把业务变更和待发送事件一次提交。

幂等账本也不应只有“有记录/没记录”两种状态。更完整的状态机是:

reserved → executing → succeeded
                    └→ failed_retryable
                    └→ failed_terminal
                    └→ uncertain

Worker 抢占操作时写入 reserved 并附带 Lease;超时后另一个 Worker 只能在 Lease 过期且状态允许时接管。已经 succeeded 的请求直接复用结果;uncertain 必须人工处理。这样才能区分“工具从未开始”“工具失败可重试”和“外部是否成功已无法确定”。

同时要防止两个 Worker 在毫秒级并发读取“尚无记录”后都执行。实验使用 BEGIN IMMEDIATE + UNIQUE 串行化这一过程;Postgres 可使用唯一键、INSERT ... ON CONFLICT、行锁或 advisory lock。关键不是选择哪种锁,而是让“取得执行权”成为数据库可证明的原子事件。

八、Context 是持久化数据:Token 放进去就可能跟着状态走

官方文档对 RunContextWrapper.context 的描述很直接:如果后续序列化 RunState,这部分应用 Context 会随状态保存。它不是只能存在于内存里的依赖容器。

实验先使用 Mapping Context:

context = {
    "tenant_id": "tenant-a",
    "api_token": DEMO_SECRET,
}

然后执行:

payload = result.to_state().to_json(strict_context=True)

结果是 mapping_context_secret_present = true。因为 Mapping 本身满足可序列化条件,strict_context=True 不会替你识别哪个字段是秘密。

第二组使用自定义对象:

@dataclass
class AppContext:
    tenant_id: str
    api_token: str

不提供 Serializer,严格模式直接失败:

UserError: RunState serialization requires context to be a mapping when strict_context is True.
Provide context_serializer to serialize custom contexts.

提供最小 Serializer:

def context_serializer(context: AppContext) -> Mapping[str, Any]:
    return {"tenant_id": context.tenant_id}

恢复时从运行环境重新注入 Token:

def context_deserializer(payload: Mapping[str, Any]) -> AppContext:
    return AppContext(
        tenant_id=str(payload["tenant_id"]),
        api_token=secret_manager.get("agent-runtime-token"),
    )

Mapping Context 直接保存秘密与自定义 Serializer 只持久化 tenant_id 的安全边界

最终 safe_serializer_secret_present = false。这里还有一个官方文档特别强调的细节:context_override 可以在加载时替换 Context,但它不会从已经序列化的 Blob 中删除秘密。如果 Token 已经写进数据库或对象存储,加载时换一个 Context 并不能补救历史泄漏,仍需轮换凭据和清理 Blob。

生产建议是把 Context 分成两部分:

PersistedContext
  tenant_id
  user_id
  request_id
  locale
  feature_flags_snapshot

RuntimeDependencies
  API clients
  database connection
  secret manager
  access token
  logger / tracer exporter

前者可以进入 RunState;后者只能通过恢复进程重新注入。

即使 Context 已经脱敏,RunState 本身仍可能包含用户原始输入、Tool 参数、模型输出、文件路径、客户名称或待发送内容,因此不能因为“没有 API Key”就按普通缓存处理。建议按敏感业务数据管理:

  • 存储前使用 KMS 管理的数据密钥加密 Blob;
  • Blob Key 带租户隔离,但不要把可读业务信息写进对象名;
  • 数据库只保存 URI、摘要、版本和密钥引用;
  • Approval API 与 Worker 使用不同的最小权限 Service Account;
  • 前端永远只接收脱敏的 Review Snapshot;
  • 为 pending、rejected、completed 设置不同 TTL;
  • 删除工单时同步删除 Blob、临时导出和缓存副本;
  • 审计读取行为,而不只审计最终批准动作。

如果审批内容涉及个人信息、合同、财务或生产凭据,还要明确数据驻留和备份策略。状态被复制到日志、错误追踪、调试下载或消息队列后,单纯删除主 Blob 并不等于完成清理。最稳妥的做法是从设计上禁止完整 State 进入普通日志,只记录 approval_id、Schema、大小、摘要和错误类别。

九、长时间审批必须同时治理 SDK Schema 和应用版本

本次 RunState JSON 的 SDK $schemaVersion1.12。SDK 自己维护序列化 Schema 兼容,但生产系统不能只检查这个字段。假设审批周五创建、周一批准,期间发生了这些变化:

  • deploy_release 参数从 release 改成 artifact_id
  • Agent Prompt 增加环境限制;
  • Tool 名称被重命名;
  • 原审批人已经失去生产权限;
  • 目标版本已经被撤销;
  • SDK 升级改变了状态结构。

即使 RunState.from_json() 成功,也不代表旧审批仍然可以执行。

长时间审批需要应用状态版本、SDK Schema、Agent 身份、参数摘要和过期策略五层门禁

我在实验状态外包了一层:

{
  "app_state_version": "runstate-approval-lab/v1",
  "sdk_state": {
    "$schemaVersion": "1.12"
  }
}

生产记录建议至少保存:

CREATE TABLE approval_request (
  approval_id           TEXT PRIMARY KEY,
  tenant_id             TEXT NOT NULL,
  operator_scope        TEXT NOT NULL,
  run_id                TEXT NOT NULL,
  tool_call_id          TEXT NOT NULL,
  tool_name             TEXT NOT NULL,
  arguments_digest      TEXT NOT NULL,
  app_state_version     TEXT NOT NULL,
  sdk_schema_version    TEXT NOT NULL,
  agent_revision        TEXT NOT NULL,
  state_blob_uri        TEXT NOT NULL,
  status                TEXT NOT NULL,
  expires_at            TIMESTAMP NOT NULL,
  decided_by            TEXT,
  decided_at            TIMESTAMP,
  revoked_at            TIMESTAMP
);

Worker 恢复前必须重新验证租户、审批人、当前权限、参数摘要、状态版本、过期时间和撤销状态。对于无法兼容的旧版本,正确做法是返回“需要重新生成审批”,而不是强行反序列化并执行。

十、RunState、Session、conversation_id 和 previous_response_id 不要混成一个状态概念

OpenAI Agents SDK 提供了多种状态延续方式,它们解决的问题不同:

机制状态在哪里主要用途恢复时传什么
result.to_input_list()应用内存/数据库完全手动的历史管理历史 Items + 新输入
session应用存储 + SDK多轮会话历史同一 Session 或同一后端实例
conversation_idOpenAI Conversations API跨 Worker 的具名服务端会话相同 conversation_id
previous_response_idOpenAI Responses API轻量响应链上一个 response ID
RunState应用持久化 Blob被中断的单次运行恢复恢复后的 RunState

RunState 会保存当前运行的 conversation_idprevious_response_idauto_previous_response_id 配置,因此审批恢复后可以继续同一服务端会话。但 conversation_idprevious_response_id 互斥;本地 session 也不能在同一次运行中与这些服务端状态机制组合。

如果审批流程同时使用 Session,恢复时要继续传同一个 Session 实例,或者传一个指向同一存储后端的实例。RunState 负责恢复暂停中的 Run,Session 负责把恢复后的这一轮追加到正确会话历史。两者不能相互替代。

更重要的是,这些 ID 都不应该被当作业务授权 ID:

  • conversation_id 不是 tenant_id
  • previous_response_id 不是审批凭据;
  • Session ID 不是 Tool 幂等键;
  • RunState Blob 地址不是可公开访问的恢复 Token。

如果你还在设计 Agent 的用户、会话和运行隔离,可以同时参考 AI Agent Memory Architecture;涉及高风险工具权限时,继续看 AI Agent SecurityMCP 安全治理

十一、生产级架构:审批 API、Blob、队列、Worker 和幂等账本如何协作

一套可上线的 RunState 审批系统,建议采用下面的职责分工:

生产级 OpenAI Agents SDK RunState 审批恢复架构:Agent API、审批库、Blob、队列、Worker、幂等账本和审计

Agent API

负责运行 Agent,直到完成或出现 interruption。出现 interruption 时,不把完整 State JSON 直接返回浏览器,而是:

  1. 计算 Tool 参数摘要;
  2. 将加密 State Blob 写入私有存储;
  3. 创建 approval_request
  4. 返回最小化、脱敏后的审批展示数据。

Approval API

负责认证审批人,检查租户和权限,以 compare-and-set 更新工单:

UPDATE approval_request
SET status = 'approved', decided_by = ?, decided_at = CURRENT_TIMESTAMP
WHERE approval_id = ?
  AND tenant_id = ?
  AND status = 'pending'
  AND expires_at > CURRENT_TIMESTAMP;

只有更新行数为 1 时,才允许投递恢复任务。这样可以阻止双击批准、重复 Webhook 和过期审批。

Queue

只传 approval_id,不要把完整 RunState Blob 放到普通消息队列。Worker 根据 ID 重新读取工单和 Blob,并再次验证权限、版本、摘要与撤销状态。队列默认按至少一次投递设计,不能假设消息只来一次。

Worker

根据 app_state_version 加载兼容 Agent Factory,重新注入运行时依赖,调用 RunState.from_json(),再次核对 Tool Call 参数摘要,然后执行 Runner.run(agent, state)

Idempotency Ledger

所有有外部副作用的 Tool 在执行前抢占 operation_key。最好使用下游系统原生幂等能力;没有时至少使用数据库唯一约束、状态机和结果复用。不要用“Worker 已经拿到消息”作为完成标志,只有外部副作用和结果记录都进入可恢复状态后才能 ACK。

Audit 与 Trace

审计记录需要回答:谁批准、批准了什么参数、当时使用哪版 Agent、哪个 Worker 执行、外部系统返回什么、是否命中幂等复用。Trace 可以帮助还原 SDK 运行,但长期凭据不应为了恢复 Trace 而默认写入 State。官方的 include_tracing_api_key=True 是显式选项,生产环境应非常谨慎。

生产观测至少要有以下指标:

指标说明异常信号
approval_pending_age工单等待时间P95 持续升高,审批渠道失效
approval_expired_total超时工单数默认 TTL 或通知机制不合理
resume_attempts_total每个工单恢复次数大于 1 说明发生重投或人工重试
idempotency_reuse_total复用已完成结果次数突增说明队列 ACK 或 Worker 稳定性有问题
state_deserialize_failure状态加载失败SDK/应用版本不兼容或 Blob 损坏
argument_digest_mismatch参数摘要不一致数据被修改、Schema 漂移或潜在攻击
effect_uncertain_total外部结果不确定必须停止自动重试并人工核对
state_blob_bytesBlob 大小Context、历史或 Trace 意外膨胀

日志要以 approval_idrun_idtool_call_idoperation_key 关联,但不能打印完整 Tool 参数和 State。Trace ID 可以作为检索入口,不能替代业务审计;Trace 被采样或过期后,审批记录仍需独立存在。

上线前的自动回归不应只测试“批准能成功”,至少覆盖:暂停前 0 副作用、批准 1 次、拒绝 0 次、重复批准只投递一次、同一状态重复恢复、Worker 执行后 ACK 丢失、Context 秘密扫描、参数摘要不一致、工单过期、权限撤销、旧应用版本、Blob 损坏和部分 interruptions 未决。每个测试都应验证数据库状态与外部效果,而不只是最终文本。

这套架构也符合 AI Agent 生产治理 的原则:模型负责提出动作,审批系统负责权限,Worker 负责执行,业务系统负责幂等,审计系统负责可追溯性。

十二、最终决策:什么时候只用官方示例,什么时候必须上完整架构

如果你的场景满足以下条件,官方的进程内示例已经足够:

  • 审批人在同一个 CLI 或请求生命周期里立即决定;
  • Tool 没有不可逆副作用;
  • 进程不会退出;
  • 不经过队列;
  • Context 不包含长期凭据;
  • 状态不需要跨版本存活。

出现任意一项,就应进入持久化架构:

  • 审批可能等待分钟、小时或天;
  • 前端、Approval API 和 Worker 是不同服务;
  • Tool 会部署、付款、发信、写数据库或改云资源;
  • 队列、Webhook 或人工操作可能重复;
  • 多租户权限需要隔离;
  • Agent、Tool 或 Prompt 会持续升级;
  • 状态 Blob 需要加密、TTL、撤销和审计。

正式上线不建议一次性把所有高风险 Tool 接入持久化审批。更稳妥的灰度顺序是:先接入只读或可逆工具,验证状态 Blob、审批通知、版本加载和审计链;再选择一个内部租户开放低频写入,故意制造队列重投、Worker 重启和超时;确认幂等复用、权限撤销和过期工单都符合预期后,才接入部署、付款、外发邮件等不可逆动作。

回滚也要提前设计。代码回滚时不能只把线上镜像切回旧版本,因为数据库里可能同时存在 v1 和 v2 的 pending State。应保留版本路由,让旧 Worker 继续消费旧状态,新流量停止创建 v2 工单;无法兼容的工单统一标记 migration_required,由 Agent 重新生成提案并再次审批。绝不能把旧批准记录直接套到新 Tool 参数上。

建议把每次灰度发布记录成一组可量化门禁:状态反序列化成功率、重复恢复率、幂等复用率、拒绝后副作用数、过期工单执行数、参数摘要不一致数和人工核对量。只要出现拒绝后执行、过期后执行或摘要不一致仍执行,立即停止扩量。这些属于安全正确性问题,不应通过“错误率还不高”接受。

上线前至少检查:

  1. 高风险 Tool 是否使用 needs_approval 或对应审批能力;
  2. 暂停时副作用是否确实为 0;
  3. State 是否经过严格 Context Serializer;
  4. Blob 是否加密并绑定租户 ACL;
  5. 审批参数是否有不可变摘要;
  6. 工单是否支持 approved、rejected、expired、revoked;
  7. Worker 是否按应用版本重建 Agent;
  8. 队列是否按至少一次投递设计;
  9. Tool 是否有稳定幂等键与唯一约束;
  10. 重复恢复、拒绝、过期和版本不兼容是否都有自动测试。

本次实验的最终判断是:RunState 已经把 OpenAI Agents SDK 的 HITL 从“进程内暂停”推进到真正可持久化的运行边界,但生产可靠性仍然取决于应用层是否把审批授权、状态版本、秘密和副作用幂等拆开。 只保存一个 JSON 文件可以完成 Demo;要让它安全执行生产动作,必须把它放进完整的软件工程系统。

可复现实验

实验目录:

experiments/openai-agents-runstate-approval-resume/

运行:

python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python lab.py run-all
.venv/bin/python -m unittest discover -s tests -v

核心结果保存在:

artifacts/results.json

官方资料

专题入口 / AI Agent Hub

从单个 Agent 问题继续进入完整生产体系

AI Agent 专题统一组织架构、记忆、工具调用、评测、安全、部署和多智能体协作,让每篇文章都回到明确的主题主页面。

下一步阅读

返回专题入口 →
agent

AutoGen 实战教程:多智能体对话协作、工具调用与生产化边界

系统拆解 AutoGen 在多智能体对话协作中的实战用法与生产化边界,覆盖 AgentChat、GroupChat、Planner / Executor / Critic 模式、工具调用、Human-in-the-loop、对话轮次控制、评估指标、成本监控和 Microsoft Agent Framework 迁移风险。

agent

AI 日志分析智能体实战:异常聚类、根因定位、Runbook 匹配与故障复盘闭环

系统拆解 AI 日志分析智能体的生产级设计方法,覆盖日志采集、异常聚类、Trace / Metrics 对齐、根因定位、Runbook 匹配、告警降噪、人工确认、自动修复边界、事故复盘与评估指标,帮助团队构建可控的运维智能体系统。

agent

AI 合同审查智能体实战:条款抽取、风险标注、版本比对与法务复核闭环

系统拆解 AI 合同审查智能体的生产级设计方法,覆盖 OCR 识别、文档解析、条款抽取、标准模板比对、法律风险标注、版本差异、审批流、法务复核与审计日志,帮助团队构建可追踪的合同审查辅助系统。

agent

AI 研究智能体实战:论文检索、证据抽取、引用审计与研究知识库闭环

系统拆解 AI 研究智能体的生产级设计方法,覆盖 arXiv / Semantic Scholar / Google Scholar 检索、论文筛选、摘要解析、方法与实验抽取、claim 审计、引用验证、研究假设生成、人工复核和知识库沉淀,帮助团队构建可信的研究自动化系统。

小白

小白

Full-Stack AI Engineer

小白,全栈 AI 工程师,持续构建生产级 Agent 系统、产品工具与独立软件资产。

了解小白与 XBSTACK →

喜欢这篇文章?
加入小白实验室的周刊

每期只整理 AI 工程变化、真实故障、可复现实验、值得尝试的工具和 XBSTACK 新资产,不做泛新闻汇总,也不为周更凑数。

Comments