用户正在阅读 Agent 流式输出的推理过程。在 token 1200 附近,模型决定调用 send_email,然后 create_ticket,再 kick_off_deploy。用户看到部分输出并意识到 Agent 误解了请求,在停止按钮上慢了半秒。邮件已经发出,工单已经创建,部署已经在跑。停止按钮取消的是下一个 token,而非上一个 token 的后果。
Bug 不在取消处理程序里。Bug 是那个假设——从团队路线图上所有其他流式 UI 借来的假设——即"逐步渲染的输出"就是"逐步可逆的输出"。工具调用并不遵守这个契约。它们是时间点上的提交,流式层一边欢快地触发它们,响应的其余部分还在生成中,而取消按钮无法沿着线路追赶它们。
这是那种没人认领的失败模式,因为它存在于两个团队各自干净交付的接缝处。UX 团队上线了流式,因为用户研究中表现更好;平台团队上线了工具调用,因为框架支持。两个团队都没有开过会问:当响应已经离开大楼时,"停止"应该是什么意思?
流式 UX 继承了工具调用并不遵守的预期
用户带入流式响应的心智模型,是过去三年所有聊天 UI 给他们的心智模型:文字出现,你跟着读,如果不喜欢走向就按停止,剩下的就不会出现。这里的隐含契约是:你还没看到的部分,就还没发生。对纯文本来说这是对的。输出只是 buffer 里堆积的字节,取消流就取消了 buffer 的剩余部分。
工具调用打破了这个契约,却没有重绘屏幕。从模型的视角看,发出一个 tool_use 块在结构上和发出一句话完全一样——同样的流,同样的逐 token 投递。从系统的视角看,在编排器解析出一个完整的工具调用块的那一刻,它就把调用派发给运行时,而运行时做的就是它该做的事:执行。send-email 工具发邮件,create-ticket 工具创建工单。在模型决定调用工具和副作用降临世界之间,没有任何缓冲区。
多个 Agent 框架都把这个缺口浮现为看起来很小、但在上下文里读起来令人警觉的 bug。OpenAI Agents SDK 有个 open issue,RunResultStreamed.stream_events() 无法用标准的 asyncio 原语取消,意味着超时实际上停不下工作。Ruby LLM 库被提了同样的投诉:没有机制中止流式请求,只能停止消费 chunk,而上游调用照样继续,账单照样累加。OpenAI Python 客户端有一个长期未决的关于流式响应取消的线程。这些不是异域的边缘案例。这是默认行为。
于是用户按了停止。前端停止渲染。与模型服务器的连接可能关、也可能不关——取决于框架——但即使关了,已经派发出去的工具调用还在它们自己的进程里跑,与它们自己的 API 对话,按它们自己的时钟。取消按钮,只是系统中用户能看到的那一部分的音效。
阻抗失配是协议鸿沟,不是 bug
把这件事归档成"取消处理 bug"、让平台团队让停止按钮"真正"停下,是很有诱惑力的。这个框架是错的,并且会引出不管用的修复。真正的问题在于:流式响应和工具调用派发器对"事件"是什么,讲的是两种不同的协议,而 UI 层假装它们是一样的。
流式响应协议是尽力而为且可逆的。每一个 chunk 都是输出走向的暗示;下一个 chunk 可能与上一个矛盾;整个东西可以毫无后果地丢弃,因为它既没被渲染也没被持久化。
工具调用协议是 exactly-once 且会提交的。每一次调用都是 Agent 被授权对外部系统执行的动作,一旦离开编排器,外部系统就拥有了结果。没有"我们发了一封邮件"的 chunk 级回滚,世界上没有任何提供商支持"算了用户改主意了,请把客户的发票撤回"。
这个失配是结构性的。只要模型被允许在与正文同一个流里发出工具调用,只要派发器被允许急切触发这些调用,取消按钮就不可能是用户以为的意思。最近一波关于 agentic UX 的设计模式综述把这一点说得很明确:如果 AI 要做一件不可逆的事,你就不能在没有预览的情况下让它做;而可逆事情的撤销,需要明显到用户不必在压力下去自行发现。
诚实版的修复,是不再假装两种协议可以互换,然后在它们之间放一个缓冲边界。
推测式工具调用模式,作为承载爆炸半径动作的默认值
真正能修复这个失败模式的模式,是在框架层把工具调用标记为 eager 或 speculative,并把 speculative 模式默认设为"缓冲到流干净完成为止"。Eager 工具——lookup_record、search_docs、任何只读且幂等的——可以在流中间触发,因为流即使之后被取消也不会出坏事;最坏情况是浪费一次读和一点延迟。Speculative 工具——send_email、charge_card、delete_row、post_message、任何触碰世界的——会被记录进一个 buffer,派发器只在流以干净的 stop 原因终止(并且,视策略而定,带上明确的用户确认)后才 flush。
机制并不奇异。编排器在从流里解析 tool_use 块的时候已经看到它们了。改动是一个分支:如果工具被标为 speculative,就追加到 per-stream buffer;如果是 eager,就立即派发。取消处理程序于是有了两个截然不同的行为。中止一次生成,意味着关闭模型连接、丢弃 speculative buffer。回滚一个副作用——对那些已经触发的 eager 调用——意味着触发注册表知道的任何补偿钩子,并明确理解其中一些会是"什么都没有,动作已经走了"。
代价是延迟。一个 speculative-by-default 的 Agent 拿不到"模型决定发邮件那一刻邮件就发出去"的爽快属性;邮件在模型生成完成之后才发出,可能晚上几秒。对那些这件事很要紧的工作流——客户支持 Agent、语音 Agent、任何有人在等的场景——应对是把渲染输出拆开:正文立刻流给用户,推测式工具调用作为单独的事件在结尾 flush,中间往往还有一步确认。用户看到推理过程,看到 Agent 打算做什么,然后在任何会产生后果的调用离开盒子之前,要么批准要么取消。
每个工具的爆炸半径标签,属于注册表
框架自己做不出 eager 还是 speculative 的判断。update_record 是 eager 还是 speculative,取决于哪一条记录、哪一个字段、下游哪一个系统拥有数据——这是工具定义的属性,而不是调用点的属性。最近一些 agentic UX 设计模式的写作,都汇聚到同一个建议:在每个工具注册时,给它打上一个可逆性层级标签。
一种好用的分类法有四层。只读 工具默认 eager;它们没有副作用,可以自由触发。按钮可逆 工具能被 Agent 已经具备的一次后续调用撤销——切换 feature flag、撤回草稿、重置计数器——也可以在注册了自动补偿钩子的前提下保持 eager。软不可逆 工具能被撤销,但不能只靠 Agent——退款、撤回已发邮件链接、恢复软删除的行——这些是 speculative,补偿路径有文档,但需要明确的人工或脚本动作触发。硬不可逆 工具在任何实际意义上都无法撤销——发短信、拨电话、执行立即清算的交易、向公开频道发帖——这些永远是 speculative,常常配合一次明确的确认提示,而不是流完成时的隐式派发。
分类法本身不那么重要,重要的是强制在注册时打标签这种纪律。一个加入注册表却没有可逆性层级的工具,应该在校验时失败,就像一个没有鉴权要求的 HTTP 端点会失败一样。"未知时默认 eager"这个失败模式,正是流式提交 bug 被发出来的方式:框架没问,工具作者没想到要声明,派发器做了最宽容的事。
取消处理程序必须区分中止与回滚
修复的另一半,在取消处理程序本身。今天它通常做两件事之一——关闭连接,或设置一个标志位——两者都不够。一个 agentic 系统里像样的取消处理程序,有三项独立的工作,框架需要把它们暴露为各自的动词。
第一项工作是 中止生成:关闭模型流、停止上游 HTTP 请求、如果提供商支持就释放速率限制配额里的 slot。这是几乎所有框架今天对纯文本生成处理对的事情,也是停止按钮当前唯一做的事情。
第二项工作是 丢弃 speculative buffer:抛弃任何已排队待派发但尚未触发的工具调用。这项工作在非 speculative 模式的框架里根本不存在,而正是这项工作把取消按钮从装饰品变成承重件。
第三项工作是 对已提交的 eager 调用执行补偿:回溯已派发的读写调用,查找每一个注册的回滚钩子并执行。对某些调用,回滚是 no-op(读发生了,没问题)。对另一些,会是真正的补偿动作(软删除的行被反删;草稿邮件在定时发送触发前被撤回)。对少数,会是一个有文档记录的"无法撤销",框架要把它浮现给用户,理想情况下附带发生了什么的回执,以便人工修复。
把这些动词拆开来,会逼着团队对每个工具的取消含义诚实起来。对话从"停止按钮应该停下 Agent"——这无法实现——转变成"停止按钮应该中止生成、丢弃推测式 buffer、运行补偿,以及这是补偿为 null 的那些调用"。第二句是可实现的,一旦你能实现它,你也就能向用户解释它。
组织失败模式:把流式当作打磨上线
这个 bug 出现在生产 Agent 里,不是因为构建 Agent 框架的工程师们没想过它。Ruby LLM 的线程是一位踩到坑并提了 issue 的开发者。OpenAI Agents SDK 的 issue 是一位踩到坑并提了 issue 的开发者。关于如何处理它的模式写作,至少能追溯到一年前。它仍然被发出来,原因是组织上的,也是很多 Agent 失败模式被发出来的同一原因:流式被当作 UX 打磨条目,归属于拥有聊天界面的团队,而"停止"对工具调用应该是什么意思,从来没有被上升到拥有动作面的团队。
那次上升应该在流式特性上线之前发生,而不是在第一次事故之后。对话不长。三个问题:我们的工具注册表里哪些工具有硬不可逆的副作用,一个未标记工具的默认派发模式是什么,以及取消按钮对每个层级做什么。如果你的团队回答不出这些问题,那么不管你有没有踩过坑,你都在发布流式提交失败模式。只是用户还没在错误的时刻按下停止。
可操作的下一步,不是框架迁移,也不是跨多季度的平台项目。是一次半天的审计:把工具注册表拉出来,给每一项打上可逆性层级标签,识别硬不可逆的那些,确认派发器的行为与层级匹配。如果审计揭示出"未知时默认 eager"这条路径仍然存在,那就是 bug。修掉它,下一次用户在读流式输出、意识到 Agent 误解了请求时,从他们意识到到他们按下停止按钮的那半秒,就会是一半秒重要的半秒。
会员专享
余下内容仅对会员开放。
会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
- —完整文章,包含未公开存档的部分
- —可落地的工作框架,附带权衡与决策依据
- —新文章抢先看,先于公开发布
随时取消 · 一次订阅,畅读全部