跳到主要内容

用户过早行动的流式 Token

· 阅读需 9 分钟
Tian Pan
Software Engineer

一个用户询问你的助手某项配置更改是否可以安全发布。模型流式返回了:“是的,你可以安全地部署。” 300 毫秒后,它继续写道:“——但在 us-east 区域除外,那里的旧连接池仍在排空。”但用户已经读完了前半部分,感受到了绿灯带来的放松,并点击了部署。这句修正说明到达时,人已经走开了。

这里没有人犯错。模型是正确的。用户阅读了屏幕上的内容。渲染器忠实地显示了每个到达的 Token。然而结果却是一次糟糕的部署,因为流式传输将模型的“中间状态”变成了用户视为“最终结果”的东西。

这是逐 Token 流式传输中一种隐蔽的失效模式。我们采用它是为了对抗延迟 —— 为了让助手显得快速且鲜活,而不是冻结在加载图标后面。这奏效了。但在这样做时,我们将模型未完成的思考暴露了出来,就好像它是完整的答案一样,而且我们没有给用户任何区分这两者的方法。半截句子和最终结论在屏幕上看起来一模一样。它们都只是黑色文字。

流式传输暴露了草稿并将其称为答案

非流式响应具有一个非常有用的属性:它是原子性的(atomic)。在模型完成之前,用户什么也看不到,然后会看到全部内容。模型生成的任何限定条件、警告和修正,在第一次接触时都会完整呈现。用户阅读的是一个完整的想法。

流式传输故意打破了这种原子性。它用消息的完整性换取了感官上的速度。每个 Token 在到达时被绘制出来,这意味着用户正在阅读一份仍在编写中的文档。对于大多数内容来说,这没问题 —— 散文会优雅降级,渲染了一半的段落显然就是渲染了一半。

危险在于特定且狭窄的场景:当文本的“含义”在两个 Token 之间发生变化时,问题就出现了。语言经常如此。“你可以部署这个”和“你可以部署这个 —— 除了”并不是同一个观点增加了细节;它们是相反的观点。“迁移是可逆的”和“只有在没有运行回填(backfill)的情况下,迁移才是可逆的”在程度上有本质区别。流式句子的前半部分不仅可能是不完整的,而且可能具有极大的误导性,而模型无法知道用户会在后半部分到达之前就采取行动。

大语言模型采用单次前向传递(forward pass)从左到右生成。它们在浮现“除非”之前就已经确定了“是”。这不是一个待修复的 bug —— 这是自回归生成(autoregressive generation)的工作原理。模型的限定条件确实出现在它的断言“之后”。流式传输提取了这种内部顺序,并将其呈现为一个用户可以随时打断的公开时间线。

设计文档中没人提到的竞态条件

把它看作是两个进程之间的竞争。一个是模型发射 Token。另一个是用户的眼睛和手。一个流利的成年人的阅读速度大约是每分钟 200 到 300 个单词,但“决策”速度要快得多 —— 一个寻找“是/否”答案的用户可以在不到一秒的时间内提取出“你可以安全地部署”,并将光标移动到按钮上。

一个能力出众的模型流式传输速度可能很快,但并非总是如此,也并非均匀。它会停顿。它在处理较难的片段时会变慢。us-east 的警告可能在绿灯亮起后的 300 毫秒到达,也可能在需要四秒钟才能返回的工具调用(tool call)之后到达。在那个窗口期,屏幕上显示的是一回事,最终答案又是另一回事,而挡在用户和错误操作之间的唯一障碍,仅仅取决于他们是否刚好在等待。

你构建了一个正确性取决于用户耐心的 UI。这谈不上是设计 —— 这只是一场披着功能外衣的掷硬币。这种竞态条件永远不会出现在测试中,因为测试它的开发人员已经知道真实答案,并且出于职业习惯会等待流式传输结束。它出现在生产环境中,出现在那些已经被其他所有应用训练得相信“屏幕上的文字就是可信文字”的用户手中。

为什么“加个加载图标”解决不了问题

本能的做法是在消息上打个“生成中……”的标记,并认为问题解决了。这确实有帮助,但它没有解决实际问题,原因有二。

首先,指示器在争夺注意力时输给了内容。用户的眼睛会盯着文字,因为文字才是答案所在。角落里一个跳动的小点不够强,无法覆盖“屏幕正告诉我我可以部署”的信息。

其次,也是更重要的一点,加载图标回答了错误的问题。它告诉用户“模型仍在输入”。它并没有告诉用户“你刚刚读到的部分可能会被你还没读到的部分推翻”。这是两回事。用户可以完全理解会有更多文字出现,但仍然合理地假设屏幕上已有的文字是稳定的 —— 因为在他们使用过的每一个非 AI 界面中,已经出现的文字都不会在随后反转其自身的含义。

问题不在于用户不知道模型还在工作。问题在于流式传输悄悄打破了“显示的文本即确定的文本”这一契约,而加载图标无法恢复这一契约。

为确定的意义而设计,而非仅仅是渲染出的 Token

解决办法是停止将 “立即渲染每个 Token” 视为自然法则。流式传输是管理 感知延迟 的工具。当模型的状态具有可操作性时,不应允许其泄露中间状态。以下几种模式使这一观点具体化。

将叙述与结论分开。 大多数助手的回答包含两个部分:可以安全进行流式传输的推理过程,以及不宜流式传输的结论或建议。让解释逐个 Token 地流出——这正是优化延迟的价值所在。将具有可操作性的行(如 是/否、“可以安全部署”、推荐的命令)保留到模型完成生成,然后将其作为一个整体呈现。用户仍然能看到即时的动态,但他们永远不会看到一个半截的结论。

延迟控件,而不仅仅是文本。 如果流式响应以 “确认” 或 “应用” 按钮结尾,那么在该按钮生成完成之前,必须是不可点击的。这在流式 UI 中等同于一个长期存在的规则:在背后的数据完整之前,不要渲染交互元素。针对半成品的建议触发的确认是毫无意义的。Material Design 的确认指南一直将确认视为完整动作的闭环——流式传输也不能例外。

使 “仍在思考” 在视觉上与 “这是答案” 区分开来。 以变淡或斜体样式渲染进行中的文本,仅在思考完成后才将其切换为全重、高对比度的文本。这为用户提供了一个符合现实的感知信号:灰色文本是你不应采取行动的草案,黑色文本是确定的。这只需要一个 CSS 类,却能将无形的竞态条件转化为用户真正可见的东西。

对于任何关键内容,以句子为单位进行缓冲,而不是以 Token 为单位。 对于具有权重的陈述,在绘制之前应保留 Token 直至句子结束的边界。句子是可靠承载完整命题的最小单位。在关键句子中逐字流式传输几乎无法带来感知延迟上的提升——反正句子在几分之一秒内就会结束——却会暴露那些可能造成危害的半截陈述。你所保护的延迟预算是以几十毫秒计的,而你所防止的失败是错误的行动。这是一笔每次都值得做的交易。

绝不要让工具调用在流式传输过程中代表用户触发。 如果助手可以触发动作,门控机制就更加重要。模型在流中说出 “我将继续重启服务” 并不是一个决定——它只是一个 Token 序列,可能仍然倾向于 “在确认维护窗口后,我将继续重启服务”。将执行绑定到完整的消息上,绝不要绑定到其前缀上。

深层原则:流是一个过程,消息是一个事实

准确定义流的本质是有帮助的。流是对 过程 的观察——模型正在工作。消息是一个 事实——模型的答案。我们一直将两者混淆,因为它们恰好在屏幕上共享同一个矩形区域。

一旦你将它们分开,设计规则就会自然显现。任何代表 过程 的内容——推理、探索、叙述、“让我查一下”——都可以自由地流式传输,因为用户明白他们正在观察工作的进行。任何代表 事实 的内容——结论、建议、行动、确认——只有在真实确定时才应出现,因为在它出现的那一刻,用户就有权相信它。

这不是一个新主意。这是渐进式披露(Progressive Disclosure)在时间轴而非空间轴上的应用。渐进式披露主张:在用户准备好之前不要向其展示信息。流式传输版本则主张:在结论准备好之前不要向用户展示。同样的原则,同样的收益——更少的认知负荷,更少的过早承诺,更少基于片面信息做出的决定。

弄错这一点的代价不只是布局错误。而是用户因为你的界面显示了一个尚未画完的绿灯,就将其部署到了 us-east。流式传输给了你速度。请确保它没有悄悄夺走助手的唯一价值:在用户决定信任它的那一刻,它是正确的。

References:Let's stay in touch and Follow me for more thoughts and updates