运行时不变量一次 Run 必须拥有稳定身份、明确 Deadline、可传播取消、受限资源、唯一终态和可关联证据。HTTP 已返回、流已关闭或模型说“完成”,都不能单独代替业务终态。

Runtime 拥有什么,不拥有什么

Runtime 接收任务、创建 Run、注入模型/Tool/Policy/State 依赖,并把执行过程转换为事件和最终结果。它拥有控制面的通用语义:身份、预算、取消、步骤推进、状态提交、观测和恢复入口;业务领域仍拥有退款资格、订单状态、不变量和最终验收。

把业务规则藏在框架 Hook 中,会让规则无法复用、测试或审计;把会话对象放在进程内 Map 中,会在扩容、发布和崩溃时丢失。框架提供生命周期,但系统必须显式决定哪些状态持久、哪些动作允许重放、哪些失败只能人工处置。

Demo 升级的分水岭不是改用某个 Agent SDK,而是把不可见的隐式假设变成可验证契约:谁发起 Run、预算多少、当前状态在哪里、取消是否到达所有依赖、结果如何证明。

从请求进入到 Run 终止

一次 Run 的控制链:准入早于资源消耗,终态晚于结果校验
Admission身份、Schema、配额、幂等与风险
Run ContextID、Deadline、取消、依赖与策略版本
Execute模型、Tool、状态提交与事件
Finalize验证终态、Usage、释放资源与关闭流

接入与准入

Transport Adapter 先认证身份并解析租户,再做输入 Schema、大小、速率、并发和风险门禁。幂等键绑定“同一业务意图”,不能由服务每次随机生成。拒绝应发生在昂贵模型调用前,并返回稳定原因码,而不是让排队和超时替代容量控制。

执行上下文

Run Context 至少携带 Run/Trace/Tenant/User ID、绝对 Deadline、组合后的 AbortSignal、预算余额、策略版本和依赖引用。它是服务端可信对象,不应把用户可修改的消息历史当权限或业务状态。Node.js 当前提供 AbortSignal.timeout() 与 AbortSignal.any(),但只有下游 API 真正消费同一 Signal,取消才算贯穿调用链。

终止处理

模型输出先经过结构、领域和策略验证,再以带版本的条件更新写入终态;只有提交成功才能发送最终完成事件。随后持久化 Usage、结果引用和失败原因,关闭流并释放连接、临时文件和并发令牌。取消、失败、不完整和成功必须是互斥终态。

按责任而非框架类名分层

Transport Adapter

负责 HTTP/SSE/WebSocket 与内部命令/事件的转换、认证、断连和协议错误映射。Transport 断开只说明客户端不再消费,不能擅自把服务端 Run 标记失败;是否继续、取消或转后台执行由任务策略决定。

Runner

Runner 驱动 Loop、扣减步骤/Token/时间/费用预算,选择恢复点并维护唯一终态。它不直接实现业务 Tool,也不以“模型无 Tool Call”作为普遍完成条件;完成必须委托给任务验收器。

Tool Executor

在调用前执行 Schema、权限、审批和幂等裁决;调用中传播 Deadline/取消并限制并发;调用后验证结果、过滤敏感字段并归一化错误。模型只能提出 Tool Call,Executor 才拥有副作用入口。

State、Policy、Telemetry

State Store 保存可恢复事实;Policy 决定动作是否允许及是否需审批;Telemetry 记录 Span、事件、指标和诊断关联。三者应可替换但语义不能相互吞并:Trace 不是状态库,Prompt 不是 Policy,数据库行也不自动成为审计事件。

以下是省略模型 Adapter、持久化实现和错误映射的 TypeScript 骨架,用于展示责任连接,不应直接当完整生产服务:

type RunCommand = { runId: string; tenantId: string; input: unknown; deadlineAt: number }

async function executeRun(command: RunCommand, deps: RuntimeDeps, requestSignal: AbortSignal) {
  await deps.admission.assertAllowed(command)
  const deadlineSignal = AbortSignal.timeout(Math.max(0, command.deadlineAt - Date.now()))
  const signal = AbortSignal.any([requestSignal, deadlineSignal, deps.shutdownSignal])
  const lease = await deps.capacity.acquire(command.tenantId, { signal })

  try {
    const state = await deps.state.createOrLoad(command)
    return await deps.runner.run(state, { ...deps, signal })
  } finally {
    lease.release()
  }
}

并发控制必须贯穿调用链

需要同时限制入口并发、每租户 Run、每 Run Tool 扇出、Provider 并发和存储连接。只限制 HTTP 请求数无效:一个请求可能并行产生十几个 Tool Call;只限制 Tool 数也会让等待中的流耗尽 Socket 与内存。容量令牌应在明确边界获取和释放,并把排队时间计入 Deadline。

用户取消、服务 Deadline、发布关闭和策略撤销是不同原因,应分别记录。取消是协作协议而非强制回滚:已经提交的副作用不能被 abort() 撤销;晚到结果不得继续推进状态,但要保留用于对账。对不可取消调用,应标记“取消待确认”而不是谎报已停止。

配置、密钥与依赖生命周期

进程级复用无状态 SDK Client、连接池和编译后的 Schema;Run 级创建身份、预算和 Trace;Tool 调用级获取最小范围短期凭据。启动时校验必需配置和依赖版本,避免首个真实请求才发现模型名、数据库或策略包无效。

日志默认记录 ID、版本、大小、耗时和结果类别,不记录完整 Prompt、Tool 结果或 Token。OpenTelemetry 的 GenAI 语义约定也明确警告消息内容可能包含敏感数据;采集正文必须显式 Opt-in、脱敏、限长并遵循租户保留策略。

启动、健康检查与优雅关闭

Liveness 与 Readiness

Liveness 只回答进程是否需要重启;Readiness 回答是否还能接新流量。Provider 暂时失败不一定应杀进程,但队列不可写、状态库不可用或策略无法加载通常应摘除流量。依赖健康不能简单串行探测所有远端,否则健康接口本身会变成雪崩放大器。

关闭顺序是:Readiness 置否、停止接新 Run、给在途任务设置 Drain Deadline、可恢复任务保存检查点、不可恢复任务受控取消、刷新 Telemetry,最后关闭连接。滚动发布必须让旧 Run 绑定旧版本或经过显式迁移;不能由新进程拿旧状态后“尽量继续”。

生产骨架的最小证据

运行时验收不是一次成功演示
场景必须观察到失败信号
正常 Run入口、模型、Tool、状态提交共享 Run/Trace 关联只有最终文本,无步骤证据
取消与 DeadlineSignal 到达下游,晚到结果被拒绝推进客户端断开后仍无限执行
限流与高并发有界队列、租户隔离、资源回落排队无界、重试放大
优雅关闭停止接流量,在途完成或形成可恢复检查点发布后 Run 消失或重复副作用
故障推演:浏览器断开 SSE,Runtime 为什么不能立刻把 Run 标记 cancelled?
传输生命周期和任务生命周期不同。后台任务可能仍需完成,Tool 副作用可能已经开始;应由任务策略决定取消、转后台或继续,并记录断连与真实终态。
架构判断:什么时候进程内状态仍然可以接受?
只适用于可从输入完全重建、无跨请求恢复要求、无副作用且实例丢失可接受的短任务。即便如此,也应显式声明这个失败边界,而不是误称为持久状态。

资料与事实边界

以下以官方文档、标准、论文和原始工程资料为主。厂商 API、价格、限额与模型能力会变化,落地时仍需按目标版本做能力探测和回归。

  1. OpenAI Agents SDK:Running agents — Runner 生命周期、Loop、流式结果、状态与错误恢复的当前 SDK 事实
  2. Node.js:AbortController / AbortSignal — 取消组合、超时和事件语义;实际支持取决于项目 Node 版本
  3. OpenTelemetry:Semantic conventions — Trace/Metric/Log 命名与 GenAI 内容敏感性边界
  4. Kubernetes:Liveness, Readiness and Startup Probes — 健康检查三种语义及流量/重启边界