流式中止后遗留的计费副作用
用户正在查看你的智能体(Agent)流式传输回复。在 200 毫秒时,他们点击了停止。UI 清除了气泡,加载图标消失,产品的表现就像请求从未发生过一样。
但它确实发生了。Agent 已经调用了 send_invoice_email。供应商的邮件转发服务器返回了 250 OK。客户收到了一份用户从未批准的草案发票。你的计费系统向用户收取了中止前流式传输的 token 费用。但它无法撤回发送邮件产生的费用。
这是每个使用流式工具(streaming tool use)的团队至少都会遇到一次的失败模式,而且大多数团队甚至从未察觉。流层(stream layer)报告 cancelled。工具层(tool layer)报告 succeeded。你的面向客户的日志会根据最后刷新的子系统从中挑选一个,于是同一个请求的两个部分现在对于该请求是否发生产生了分歧。
人们很容易将客户端中止视为一个 UI 问题——清除气泡、断开连接、继续下一步。但是,一个调用了任何有副作用工具的流,不再是一个可以被静默丢弃的请求。它是一个部分提交(partially committed)的事务,而没有针对部分提交进行设计的团队,其交付的 UX 在每次取消时都 会导致可见状态与实际状态发生漂移。
中止是“部分提交”事件,而非取消
大多数工程师习惯使用“取消”(cancel)这个词,因为浏览器 API 叫作 AbortController,而且 HTTP 层将连接报告为已关闭。但这个词语具有误导性。“取消”意味着动作没有发生。而在工具调用已经改变了第三方系统之后到达的“中止”(abort),更像是数据库客户端在 COMMIT 之后但在读取确认之前断开了连接——写入是真实的,只是客户端从未看到确认。
Agent 循环使得这种情况比数据库类比所暗示的还要糟糕。一个流式轮次可以:
- 调用一个只读工具,返回模型用于起草下一步的上下文。
- 调用一个产生副作用的工具,在请求发出的瞬间改变外部系统。
- 向用户流式传输描述其行为的 token。
如果用户在第二步和第三步之间中止,副作用是真实的,而用户对所发生事情的心智模型则取决于渲染完成的那一小部分描述。这两者是独立的。token 流不知道工具层做了什么,工具层也不知道用户实际看到了哪些 token。现在,请求的“结果”是两个时间线的笛卡尔积。
这是大多数框架掩盖的部分。Vercel AI SDK 的 consumeStream 和 onAbort 回调承认中止的流仍需要清理,但清理被界定为 UI 关注点——持久化部分结果、释放资源、释放模型。这些都无法调和一封已经发出的邮件。这些都无法告诉用户“我们发送了一张发 票,你需要撤回它吗”。框架的心智模型终结于连接边界;用户的心智模型终结于副作用。
流层与工具层各自掌握着不同的真相
让我们梳理一下,当用户在已经调用了工具的轮次进行到 200ms 时点击停止,每个子系统实际观察到了什么:
HTTP 层看到连接关闭。它将 client_disconnect 写入访问日志,并增加中止计数器。
流层看到 AbortSignal 触发。它调用 onAbort,发出配置好的任何遥测数据,并停止向客户端转发 token。
Agent 运行时看到 AbortSignal 被转发到其执行循环中。根据框架的不同,它要么 (a) 在循环下一次产生结果时在调用栈中抛出取消异常,(b) 等待当前工具的 execute 函数返回后再进行传播,或者 (c) 静默吞掉 AbortError 且不发出任何事件——这是 Claude Code 团队提交过 Bug 的失败模式。
工具层看到它对第三方的 HTTP 请求完成。它返回 200 OK 以及描述副作用的负载。从工具层的角度来看,没有任何错误发生——它运行了函数,函数成功了,它忠实地返回了结果。取消信号在工具已经返回后到达,或者在工具等待一个不支持取消的远程系统时到达。
最后,第三方系统根本不知道发生了这一切。它接收到了请求,执行了副作用,并给出了确认。它的日志是唯一反映实地真相的日志。
五个子系统,五份不同的日志,每一份从各自的角度看都是准确的,但集合起来却是不一致的。选择任何一个作为“ 真相”,你都会得到:要么是客户为一封未发送的邮件被计费,要么是客户为一封已发送的邮件未被计费,或者是面向客户的转录文本声称执行了一个底层系统从未执行的操作。
框架的乐观执行(Optimistic Execution)本身就是 Bug
大多数 Agent 框架都采用激进方式调度工具调用。模型发出工具使用块(tool-use block),运行时提取参数,工具的 execute 函数在块被解析的瞬间运行。结果反馈到下一次模型调用中。这很快,具有组合性,而且对于只读工具来说是正确的。
对于有副作用的工具,激进执行(eager execution)是一份你并未签署的契约。它意味着:模型决定调用此工具的时刻就是提交点(commit point)。 在运行时看到工具使用块的那一刻,从用户的角度来看,该操作就是不可撤销的,因为运行时在用户甚至有机会阅读模型决定做什么之前就将其发出了。
如果模型很快且用户正在阅读,用户会在副作用已经落地后才看到描述工具调用的助理消息。用户唯一的中止机会是在不可撤销的操作发生之后。在那一点上,停止按钮只是一个安慰剂(placebo)。
修复方法是结构性的:有副作用的工具不应在调度时提交。它们应该在后续两个时间点之一提交——要么是用户有机会确认时,要么是流完整结束时。无论哪种方式,运行时都必须区分 意图(intent) 与 提交(commit),并将工具使用块视 为前者,而不是同时视为两者。
二阶段工具协议的雏形
借用分布式事务中的词汇,因为这本质上就是分布式事务。一个带有副作用的工具生命周期包含三个状态:
- 已准备 (Prepared)。 参数已验证,请求载荷已构建,前置条件检查已运行。外部状态尚未发生变化。流中会发出一个
tool_prepared事件。 - 已提交 (Committed)。 实际的变更已应用到第三方系统。发出
tool_committed事件,并附带一个幂等键 (idempotency key),客户端可以使用该键查询规范记录。 - 已补偿 (Compensated)。 变更已撤销(已发起退款、已撤回邮件、已恢复记录行)。之所以存在这个状态,是因为中止处理程序 (abort handler) 并不总能观察到“准备 → 提交”之间的间隙,某些提交会在中止信号到达工具层之前就已经生效。
中止处理程序的职责是遍历每个流的副作用账本 (side-effect ledger),并决定如何处理每个条目。已准备但未提交?取消已准备的操作。在中止信号到达前已提交?运行补偿操作 —— 或者,如果无法补偿(邮件已发送、短信已送达、帖子已发布),至少在客户账户中记录下这个不可逆的副作用,以便在下一次会话中显现出来。
账本的作用是弥补流式传输层 cancelled 日志行与工具层 succeeded 日志行之间的缝隙。每个副作用对应一个账本条目,在调用前写入,随调用进度更新,并由中止处理程序进行对账。如果没有 它,请求的两端将永远维持各自独立的“事实”。
这并非理论探讨。Atomix 和类似的运行时将工具调用视为事务性副作用,为每次调用标记 Epoch,并跟踪每个资源的边界 (frontier) 以确定何时提交,并在取消时执行 Saga 风格的补偿。这种模式是行之有效的;大多数团队漏掉的一步是在发生第一次事故之前,意识到他们确实需要它。
幂等性是让恢复成为可能的契约
Agent 可以调用的每个带有副作用的工具都必须携带一个幂等键,该键由运行时在“准备”阶段而非“提交”阶段生成。该键由工具名称、解析后的参数、用户身份和每轮对话的 Nonce 组成 —— 它足够稳定,使得在同一个逻辑轮次内的重试可以实现去重;同时它又足够唯一,使得刻意的重新发布会启动一个新事务。
该契约包含三项义务:
- 第三方系统(或你对其封装的包装器)必须尊重该键 —— 使用相同键的第二次调用应返回原始结果,而不是再次执行。
- 运行时必须在发起调用前将该键与账本条目一同持久化,这样在调度与确认之间的崩溃就不会导致丢失正在进行的副作用踪迹。
- 如果存在补偿操作,也必须带有键,这样重复触发的中止处理程序就不会针对一笔费用产生两次退款。
如果没有键,你无法安全地向前滚动(重试调用以确认其是否生效),也无法安全地回滚(在不面临双重补偿风险的情况下执行补偿)。有了键,中止处理程序就可以选择其策略:乐观重试、乐观补偿或交由人工处理。如果没有键,唯一的安全策略就是“什么都不做并祈祷”,而“什么都不做并祈祷”正是每个运行在激进调度 (eager dispatch) 模式下的团队默认交付的东西。
从面向用户的日志开始,而非运行时
务实的步骤不是重写 Agent 运行时。而是先构建每个流的副作用账本,将每个现有的副作用工具指向它,并使面向用户的对话记录成为账本的视图,而不是令牌 (token) 流的视图。
一旦对话记录来源于账本,“用户认为发生了什么”与“系统实际做了什么”之间的高度不对称就变成了可见的差异 (diff),而不是不可见的漂移 (drift)。中止操作并看到对话在句中结束的用户,也可以看到“我们向 [email protected] 发送了一张发票 —— 撤销”。这个单一的功能点,在账本和幂等补偿路径的支持下,能够挽回大部分因静默提交而损失的信任。
更深层次的架构步骤是将带有副作用的工具重新设计为具有显式“确认或提交”语义的二阶段参与者 (two-phase actors),并将中止信号视为运行时对账的一等公民事件,而不是运行时简单转发的连接层细节。这项工作规模更大,但账本是使其变得可行的切入点 —— 一旦你能看到分歧,你就能选择在哪里弥合它。
架构层面的启示
流是用户可以单方面中止的事务。用户不需要你的许可,不需要与你的后端协作,也不需要等待任何状态刷新。他们关闭标签页,从他们的视角来看,事务就结束了。你的系统必须回答的问题是:在中止的那一刻,已经发生的每一个副作用会怎样?唯一诚实的答案只有:我们补偿了它、我们将它展示给了用户,或者我们交付了一个其可见状态与实际状态在每次取消时都会产生偏差的用户体验。
大多数团队交付的是第三种方案,在事故审查中发现问题(当客户指出他们收到了一封未经批准的邮件时),然后添加一个功能开关,为具有变更性质的工具禁用流式传输。这在下一个带有副作用的工具上线且未设置该开关前是有效的。结构性的修复方案 —— 账本、二阶段工具、带键的补偿 —— 工作量更大,但它也是唯一能在团队增加新工具且忘记前车之鉴时依然存续的方案。
将中止视为提交事件。构建账本。写入键。然后为那些能够承受流式传输的工具开启它,为那些不能承受的工具保持关闭。UI 上的取消按钮是你与用户之间关于可逆性的契约。请审慎地履行它,否则请接受你正在交付一个“安慰剂”的事实。
- https://ai-sdk.dev/docs/advanced/stopping-streams
- https://github.com/openai/openai-python/issues/2643
- https://github.com/BerriAI/litellm/issues/17364
- https://github.com/anthropics/claude-code/issues/38905
- https://openai.github.io/openai-agents-python/streaming/
- https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling
- https://www.buildmvpfast.com/blog/idempotent-ai-agent-retry-safe-patterns-production-workflow-2026
