投机采样(Speculative Decoding)是一项流式传输协议决策,而非推理优化
每一篇关于投机解码(Speculative Decoding)的论文中提到的“等效输出”保证,其实是对 token 分布的保证,而不是对用户所见内容的保证。仔细阅读证明过程,你会发现一个纯粹的数学等效性:拒绝采样的接受标准旨在确保投机后的输出分布与目标模型(target model)独立生成的分布完全一致。这一保证约束的是离开推理引擎的字节流,而对于五百毫秒前已经到达用户屏幕、现在却必须收回的字节,它只字未提。
如果你在小模型生成草稿 token 的那一刻就将其流式传输给客户端,那么每当验证器拒绝某个后缀时,你实际上是在对自己的用户进行 A/B 测试。半个段落会自行重写。函数名在 IDE 已经完成语法高亮后发生改变。语音合成(TTS)可能已经读出了“答案很可能是否定的”,随后验证器却将其替换为“答案是肯定的,但有几点需要注意”。数学逻辑上,最终分布与慢速路径一致;但从用户体验来看,他们亲眼目睹了模型在公开场合“反悔”。
这是投机解码中未被计入加速倍数的部分。它也将所谓的“免费 3 倍吞吐量”变成了一个没人预料到的、长达一个半季度的流式协议开发工作。
从流式传输的角度看机制
投机解码将一个小型的草稿模型与一个大型的目标模型配对。草稿模型一次性建议 K 个 token。目标模型并行验证这 K 个 token —— 只需一次前向传递而非 K 次 —— 并根据比较两个模型 token 概率的拒绝采样标准接受其中的一段前缀。每当一个 token 被拒绝时,草稿中该位置之后的所有内容都会被丢弃,目标模型会从最后一个被接受的位置生成一个校正后的 token。接着循环重新开始。
在生产环境的 EAGLE 类系统中,接受率通常在 60–80% 之间。EAGLE-3 在不同的草稿位置保持了稳定的接受率而没有衰减,这就是它比自回归解码(autoregressive decoding)实现 3–6 倍实际加速的原因。但“60–80% 的接受率”是针对每个 token 的,且处于一个 4 到 8 个 token 的步长窗口内。在给定的投机窗口中 所有 token 都被接受的概率要低得多 —— 而在长回复中 某个 投机窗口出现拒绝的概率几乎是 100%。
因此,对于流式服务栈来说,问题不在于是否会发生拒绝。在任何有意义的回复中,拒绝必然会发生。问题在于,当拒绝发生时,你的协议会怎么做。
两种协议,两种不同的产品
你只能从以下两者中择其一:
逐 token 推送(Token-by-token push)。 服务器在每个草稿 token 生成时立即将其推送到客户端。延迟是最低的。首个 token 延迟(TTFT)取决于草稿模型的速度,而非目标模型。代价是任何被拒绝的后缀都必须被撤回 —— 通常是发送一个“回退到位置 N”的控制帧,然后传输校正后的后续内容。客户端的文本组件会实时重写。
验证后推送(Accept-then-flush)。 服务器将草稿 token 保存在缓冲区中,直到验证确认了幸存的前缀。只有经过验证的 token 才会离开服务器。输出是单调递增的 —— 它只会增长,绝不回退。代价是增加了延迟:大约一个“草稿+验证”周期的缓冲,如果进行批处理,延迟可能更高。
今天大多数聊天界面采用的是伪装成第一种的第三种方案:没有撤回支持的逐 token 推送,并假设用户不会注意到或不在乎。他们可能确实不在乎。直到出问题为止 —— 比如当他们已经开始复制的代码补全在流式传输中途发生了变化,或者笔记本单元格中生成的 SQL 查询在他们按下运行键后被更换了列名。
有趣的设计启示是,这本质上不是一个服务器端的性能选择。这是一个产品界面选择。你应该根据每个具体的界面(surface)来选择协议,而不是根据部署来统一选择。
撤回代价极低的界面
在滚动视口中渲染为纯文本的纯对话式聊天,对这类问题的容忍度很高。用户已经习惯了生成文本会有微小的校正;当之前的句子仍在屏幕上且修改发生在局部时,“模型编辑了上一句话”的认知负荷并不高。
渲染后的 Markdown 聊天在 Markdown 被部分渲染为结构化元素之前也是宽容的 —— 比如一个突然多了一列的表格,一个被重新划定边界的代码块,或者一个在流式传输中语法正确但在之后被替换为另一种有效形式的 LaTeX 公式。token 级别的撤回可能会产生无效 Markdown 的中间状态,这意味着渲染器要么闪烁,要么必须进行防抖处理,而这意味着富元素的首次渲染时间会比协议承诺的更糟。
如果你的客户端将流式内容渲染到 textarea 或 contenteditable 元素中,你还必须考虑光标行为、选择区域和撤消栈(undo stack)。简单的“重写后缀”在用户于流式传输期间点击回复中间位置之前都没问题。
回撤会造成灾难性后果的场景
语音 TTS 流水线是最明显的例子。一旦音素被合成并播放,你就无法“取消播放”。如果你的 TTS 正在逐 token 消费 LLM 流,以将“口到耳”的延迟控制在 1 秒以内,那么你已经为那些随后被拒绝的后缀提交了音频。此时你的选择都很糟糕:要么在单词中间掐断音频并重新合成(这非常刺耳),要么让错误的内容播完并在下一句推翻自己,或者在 TTS 开始前缓冲足够的 token,但这又放弃了最初采用流式传输所追求的延迟优势。正确的做法通常是在句子或子句边界进行缓冲——这意味着 TTS 层需要“先接受再刷新”(accept-then-flush)协议,而 不是逐 token 协议,无论同一后端上的聊天 UI 使用的是什么。
**结构化表单填写和工具调用(Tool calls)**也属于此类。如果模型正将 JSON 流式传输到类型化解析器中,从而增量更新 UI 表单(如日期选择器、下拉选择、地址栏),解析器可能已经提交了一个值,触发了副作用(如自动补全 API 查询、验证),或向用户显示了确认信息。回撤意味着回滚 UI 状态,而大多数表单库在设计时并未考虑这一点。更糟糕的是,如果工具调用已经开始执行(因为工具名称和参数的 JSON 在验证完成前就已被流式传输并解析),你实际上是将“写后读”竞争(read-after-write race)从推理引擎转移到了工具执行层。
在沙盒中运行的生成代码是 Agent 系统中最令人头疼的变体。如果你的运行环境将生成的代码流式传输到 Worker 中,并在语法完整的语句出现时立即开始执行,那么被拒绝的后缀就不只是 UI 重写那么简单——它可能意味着一个执行了一半的文件写入、一个被部分修改的数据库行,或者一个已经发出的 HTTP 请求。在这种情况下,与目标模型输出的数学等价性毫无意义。系统已经根据验证器随后丢弃的 token 采取了行动。
模式是连贯的:一旦 LLM 流下游的消费者难以回滚,你的投机解码(speculative decoding)协议就不再仅仅是一个推理细节。它是整个流水线的正确性属性。
能捕获此问题的评估指标:用户可见的 Token 抖动
推理团队通常使用接受率(acceptance rate)和每秒 Token 数(tokens-per-second)来衡量投机解码。对于流式传输问题,这两个指标都不合适。接受率是模型配对的属性,而不是用户体验的属性。每秒 Token 数是推理引擎的吞吐量——它无法区分“用户看到了单调增长的输出”和“用户看到一个 200 字符的段落重写了三次”。
你真正需要的指标是用户可见的 Token 抖动 (User-visible token churn):对于每个输出,计算 (流向客户端的总 token 数) / (响应中的最终 token 数) 的比例。在纯逐 token 推送且接受率为 70% 的情况下,这个数字很容易达到 1.3–1.5——这意味着你通过网络发送的字节中,大约有三分之一在响应最终确定前被回撤了。而使用“先接受再刷新”模式,这个数字在结构上精确为 1.0。
将此指标添加到评估框架中,可以将对话从“我们获得了 3 倍加速,发布吧”转变为“我们获得了 3 倍加速,但有 X% 的回撤开销,这是各产品场景的分解数据”。它还为你提供了一个调节旋钮:减小投机窗口 K,可以用接受率驱动的吞吐量换取更少的回撤。聊天、语音和代码执行的最优值各不相同,数据会告诉你答案。
一个有用的子指标是回撤字节严重程度 (Retracted-byte severity):并非所有回撤的 token 都是等价的。如果回撤的前缀“the answer is”被替换为“the answer is”(相同前缀,不同后续),这对用户是不可见的。但如果回撤的“yes”被替换为“no”,则是内容反转。检测反转通常意味着计算被拒绝后缀与其替代物之间的字符串距离或语义距离,虽然这需要更多工作,但能捕获那些在用户测试中表现糟糕的情况。
