OpenAI Agents SDK RunState 实战:Tool Approval 如何跨进程恢复?
实验使用 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 是重要基础设施,但它不是完整的审批系统。

一、这个问题为什么值得单独写,而不是继续补 LangGraph 审批流
XBSTACK 已经有一篇 LangGraph Human-in-the-loop 审批流,里面讨论的是 interrupt、Checkpointer、thread_id 和图节点恢复。OpenAI Agents SDK 的搜索任务不同:开发者使用的是 function_tool(needs_approval=True)、RunResult.interruptions、RunState.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/
运行环境如下:
| 项目 | 实验值 |
|---|---|
| Python | 3.10.2 |
| OpenAI Agents SDK | openai-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 的暂停、状态序列化、恢复和工具执行语义。
实验覆盖六条路径:
- 工具需要审批时,是否在产生副作用前暂停;
- 原进程退出后,另一个进程能否加载状态、批准并恢复;
- 拒绝后是否仍然执行工具;
- 同一份已批准状态被重复投递时,工具会执行几次;
- 幂等账本是否能阻止重复副作用;
- 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;尚无决策时产生 ToolApprovalItem;RunResult.interruptions 暴露待审批项;应用把结果转换为 RunState 并持久化;人工对状态执行 approve() 或 reject();最后再次调用 Runner.run(agent, state)。

最小工具定义并不复杂:
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()、ShellTool、ApplyPatchTool、本地 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_id、previous_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 存在哪里、谁能读取、何时删除。

生产系统至少应把数据拆成三类:
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()加载状态并恢复。

状态序列化代码:
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 单向迁移到 approved 或 rejected。
跨进程恢复还需要一套 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 已获批准、尚未在当前快照中完成,因此再次执行工具。

没有幂等账本:
{
"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"),
)

最终 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 $schemaVersion 是 1.12。SDK 自己维护序列化 Schema 兼容,但生产系统不能只检查这个字段。假设审批周五创建、周一批准,期间发生了这些变化:
deploy_release参数从release改成artifact_id;- Agent Prompt 增加环境限制;
- Tool 名称被重命名;
- 原审批人已经失去生产权限;
- 目标版本已经被撤销;
- SDK 升级改变了状态结构。
即使 RunState.from_json() 成功,也不代表旧审批仍然可以执行。

我在实验状态外包了一层:
{
"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_id | OpenAI Conversations API | 跨 Worker 的具名服务端会话 | 相同 conversation_id |
previous_response_id | OpenAI Responses API | 轻量响应链 | 上一个 response ID |
RunState | 应用持久化 Blob | 被中断的单次运行恢复 | 恢复后的 RunState |
RunState 会保存当前运行的 conversation_id、previous_response_id 和 auto_previous_response_id 配置,因此审批恢复后可以继续同一服务端会话。但 conversation_id 与 previous_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 Security 和 MCP 安全治理。
十一、生产级架构:审批 API、Blob、队列、Worker 和幂等账本如何协作
一套可上线的 RunState 审批系统,建议采用下面的职责分工:

Agent API
负责运行 Agent,直到完成或出现 interruption。出现 interruption 时,不把完整 State JSON 直接返回浏览器,而是:
- 计算 Tool 参数摘要;
- 将加密 State Blob 写入私有存储;
- 创建
approval_request; - 返回最小化、脱敏后的审批展示数据。
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_bytes | Blob 大小 | Context、历史或 Trace 意外膨胀 |
日志要以 approval_id、run_id、tool_call_id、operation_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 参数上。
建议把每次灰度发布记录成一组可量化门禁:状态反序列化成功率、重复恢复率、幂等复用率、拒绝后副作用数、过期工单执行数、参数摘要不一致数和人工核对量。只要出现拒绝后执行、过期后执行或摘要不一致仍执行,立即停止扩量。这些属于安全正确性问题,不应通过“错误率还不高”接受。
上线前至少检查:
- 高风险 Tool 是否使用
needs_approval或对应审批能力; - 暂停时副作用是否确实为 0;
- State 是否经过严格 Context Serializer;
- Blob 是否加密并绑定租户 ACL;
- 审批参数是否有不可变摘要;
- 工单是否支持 approved、rejected、expired、revoked;
- Worker 是否按应用版本重建 Agent;
- 队列是否按至少一次投递设计;
- Tool 是否有稳定幂等键与唯一约束;
- 重复恢复、拒绝、过期和版本不兼容是否都有自动测试。
本次实验的最终判断是: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
官方资料
- OpenAI Agents SDK:Human-in-the-loop
- OpenAI Agents SDK:RunState API Reference
- OpenAI Agents SDK:Running agents 与状态策略
- OpenAI Agents SDK:Sessions
- OpenAI Agents SDK:Context
- PyPI:openai-agents 0.18.3
从单个 Agent 问题继续进入完整生产体系
AI Agent 专题统一组织架构、记忆、工具调用、评测、安全、部署和多智能体协作,让每篇文章都回到明确的主题主页面。
下一步阅读
返回专题入口 →AutoGen 实战教程:多智能体对话协作、工具调用与生产化边界
系统拆解 AutoGen 在多智能体对话协作中的实战用法与生产化边界,覆盖 AgentChat、GroupChat、Planner / Executor / Critic 模式、工具调用、Human-in-the-loop、对话轮次控制、评估指标、成本监控和 Microsoft Agent Framework 迁移风险。
AI 日志分析智能体实战:异常聚类、根因定位、Runbook 匹配与故障复盘闭环
系统拆解 AI 日志分析智能体的生产级设计方法,覆盖日志采集、异常聚类、Trace / Metrics 对齐、根因定位、Runbook 匹配、告警降噪、人工确认、自动修复边界、事故复盘与评估指标,帮助团队构建可控的运维智能体系统。
AI 合同审查智能体实战:条款抽取、风险标注、版本比对与法务复核闭环
系统拆解 AI 合同审查智能体的生产级设计方法,覆盖 OCR 识别、文档解析、条款抽取、标准模板比对、法律风险标注、版本差异、审批流、法务复核与审计日志,帮助团队构建可追踪的合同审查辅助系统。
AI 研究智能体实战:论文检索、证据抽取、引用审计与研究知识库闭环
系统拆解 AI 研究智能体的生产级设计方法,覆盖 arXiv / Semantic Scholar / Google Scholar 检索、论文筛选、摘要解析、方法与实验抽取、claim 审计、引用验证、研究假设生成、人工复核和知识库沉淀,帮助团队构建可信的研究自动化系统。

小白
Full-Stack AI Engineer
小白,全栈 AI 工程师,持续构建生产级 Agent 系统、产品工具与独立软件资产。
了解小白与 XBSTACK →