Agent 接口如何支持断线恢复与幂等写入
文章提出将 Agent API 拆为进度流、持久化运行状态和受控副作用三个边界,避免单一聊天接口混杂重连、重试与业务写入。方案通过可重放事件、队列和数据库支持任务恢复,并控制重复执行。
AI 深度解读
Agent 上线后,最麻烦的往往不是回答慢,而是断线、重试和写操作撞在一起:浏览器刷新会不会重新执行任务?工具超时后再试一次,会不会重复创建账单?把这些都塞进 POST /chat,连接故障就可能变成业务事故。
作者给出的办法,是把进度推送、持久任务和业务写入分成三条通道。这套方案不依赖某个 Agent 框架,普通网关、应用服务、队列和数据库就能实现。它值得看的地方,是把「能演示」到「能承受故障」之间缺失的接口边界讲清楚了。
断线后能接着看,靠的是任务记录和事件回放
传统接口默认一次请求完成一件事。Agent 却可能连续调用工具、等待人工审批,再排队执行后续步骤。HTTP 连接已经断了,任务仍然可能在继续。
任务的生命周期,不能绑在一条 HTTP 连接上。
作者建议,先创建持久任务,再开始昂贵的执行。接口尽快返回任务 ID、事件地址和状态地址,由 worker 推进任务,浏览器负责观察。
任务记录 AgentRun 包含 id、tenantId、actorId、status、inputRef、releaseId、budgetCents、createdAt 和 version。其中几个字段不能只当普通元数据:
inputRef保存加密或脱敏后的输入引用,避免把任意输入直接塞进日志。releaseId标识当时使用的提示词、工具 schema、策略和路由规则,方便追查某次决策。version配合比较并交换更新,避免两个 worker 同时领取同一步骤。
任务状态覆盖 queued、running、waiting_for_approval、completed、failed、cancelled。前端因此可以展示明确状态,不必一直显示「生成中」。
进度通过 SSE 或 WebSocket 推送,但连接本身不保存事实。事件先持久化,再分发给客户端,并附上单调递增的序号。原文列出的事件包括 run.created、assistant.delta、tool.requested、tool.started、tool.completed、approval.requested、run.completed 和 run.failed。
下面这段接口契约,解决的是创建任务、断线续看和查询写入结果的问题:
POST /v1/runs
Idempotency-Key: client-generated-key
201 Created
{
"run_id": "run_123",
"status": "queued",
"events_url": "/v1/runs/run_123/events",
"status_url": "/v1/runs/run_123"
}
GET /v1/runs/run_123/events
Last-Event-ID: 42
POST /v1/runs/run_123/approvals/approve
POST /v1/runs/run_123/cancel
GET /v1/operations/op_456
要看的是 Last-Event-ID: 42:客户端告知最后看到的位置,服务端回放之后的事件,再接上实时推送。重新连接不需要重新创建任务。
SSE 适合主要由服务端推送进度的场景;运行中需要频繁双向通信,再考虑 WebSocket。两者都需要事件持久化。事件也不必永久保存,重连窗口和排障需要满足后,就应按保留策略清理,最终产物另行存储。
防止重复写入,不能只给请求加一个幂等键
创建任务不重复,不代表任务里的业务动作不重复。
浏览器重试 POST /v1/runs,属于请求幂等;worker 重试创建账单,属于业务操作幂等。作者明确要求两套机制分开,否则很难判断到底是在阻止重复创建任务,还是阻止重复执行写入。
模型只能提出操作建议。真正修改金额、记录、消息、权限或基础设施之前,应经过一个职责收窄的 operation service。
这段代码为创建账单生成稳定操作键,让同一意图的重试能够命中同一条操作记录:
const operationKey = sha256([
tenantId,
actorId,
"create_invoice",
customerId,
invoiceDraftHash,
].join(":"));
要注意 invoiceDraftHash 这一行:键需要体现业务意图,不能每次尝试都生成随机值。但原文没有展开草稿如何规范化、如何计算哈希,这部分仍要由业务定义。
操作记录维护 pending、approved、executing、succeeded、failed,并在 (tenant_id, operation_key) 上建立唯一约束。
最棘手的情况是:下游已经收到写入请求,worker 却在保存回执前崩溃。此时超时不等于失败,结果只能算未知。恢复后应先用相同幂等键向下游核对,再决定是否重试。
写操作结果未知时,先查回执,再谈重试。
高影响操作可以返回 202 Accepted 和回执地址,表示请求已受理。审批应发生在结构化操作提案通过校验之后、实际写入之前。审核者需要看到动作、目标、字段和策略原因,不需要看到模型的隐藏推理或带密钥的上下文。
网关管入口,业务系统仍要守住自己的权限
这里的 gateway 是 API 边界,不要求它接管整个 Agent 框架。作者把策略分布在能拿到事实的地方:
- 入口负责身份验证、租户解析、请求体大小、内容类型、基础租户并发和请求脱敏。
- run service 负责最大步骤数、成本预算、允许的模型、取消和发布版本身份。
- 工具与操作服务负责 schema 校验、资源归属、权限范围、审批、幂等和审计回执。
- 后端负责最终资源授权,确认被代表的用户确实可以访问目标记录。
下游传递的应该是经过签名的最小身份上下文,包括 subject、tenant、scopes、request ID 和 expiry。不能让 Agent 自己编造租户 ID,也不能把原始供应商凭证放进工具参数。
流式响应还需要处理两类容易遗漏的故障。连接安静时,用心跳避免中间代理悄悄断开;客户端消费过慢时,限制实时缓冲,保存回放位置,再按约定断开连接,避免内存持续增长。每个工具步骤都要有时限,并区分可重试失败、终止失败和完成情况未知。
排障则需要共享 trace ID,把网关、编排器、工具和下游操作串起来。原文示例记录了 run_id: run_123、step: 4、event: tool.completed、tool: crm.create_note、operation_id: op_456 和 latency_ms: 811,以及结果、预算分类和策略原因。
这里的 811 是日志样例,不是性能测试结果。日志也应优先保存引用、哈希、结果码和脱敏摘要,避免记录原始提示词、密钥、完整客户记录或隐藏推理。客户端进度流与审计记录用途不同,保留期限、脱敏规则和访问权限也应分别制定。
我的判断
这套拆分最有价值的地方,是把三件经常混淆的事分清:用户看到了什么、任务执行到了哪里、业务系统实际改了什么。前端可以恢复展示,worker 可以恢复推进,写入结果有独立凭据。
但原文是一份架构建议,没有提供吞吐量、延迟、成本或故障恢复率的对照数据,也没有交代作者团队的生产实测规模。不能据此断言,采用这套模式就一定更快或更便宜。
「写入最多一次」也有前提。本地唯一约束能拦住重复操作记录,却不能独自解决远程系统已经执行、响应却丢失的问题。下游是否支持幂等键、能否查询操作结果,会直接决定核对与恢复能做到什么程度。原文提出了核对要求,没有给出通用实现。
作者对适用范围的限制是合理的:几秒完成的只读补全,用普通鉴权接口更省事;长任务、多步骤、审批、可重连或写操作出现后,持久任务才更有必要。队列可以负责投递推进命令,但不适合作为唯一任务档案。
LLM gateway 通常侧重模型路由、密钥、配额和供应商观测。本文讨论的边界还包括任务恢复、事件回放、审批和写操作,两层可以同时存在,不能因为接入了模型网关就默认后者已经解决。
能拿来做什么
- 给前端 Agent 页面增加独立任务 ID、状态查询和事件序号,测试工具调用过程中刷新页面,确认恢复的是原任务进度。
- 在 Node 服务里先选一个完整流程改造,例如生成客服回复并创建工单备注;先保持模型调用只读,再把一个写工具接入操作表、稳定幂等键和唯一约束。
- 对写操作分别测试派发前、派发后、回执落库前的 worker 崩溃,检查恢复逻辑是否先核对下游结果,并确认最终写入发生零次或一次。
- 按原文顺序先建立可查询任务和可解释时间线,再根据实际运行情况补租户并发与预算限制,避免一开始就扩成通用平台。