跳到主要内容

在用户说"是"之前就已提交的流式响应

· 阅读需 11 分钟
Tian Pan
Software Engineer

用户正在阅读 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_recordsearch_docs、任何只读且幂等的——可以在流中间触发,因为流即使之后被取消也不会出坏事;最坏情况是浪费一次读和一点延迟。Speculative 工具——send_emailcharge_carddelete_rowpost_message、任何触碰世界的——会被记录进一个 buffer,派发器只在流以干净的 stop 原因终止(并且,视策略而定,带上明确的用户确认)后才 flush。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates