跳到主要内容

投机采样(Speculative Decoding)是一项流式传输协议决策,而非推理优化

· 阅读需 14 分钟
Tian Pan
Software Engineer

每一篇关于投机解码(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”,则是内容反转。检测反转通常意味着计算被拒绝后缀与其替代物之间的字符串距离或语义距离,虽然这需要更多工作,但能捕获那些在用户测试中表现糟糕的情况。

经得起考验的架构模式

在改造架构的成本变得高不可攀之前,有几个模式值得尽早采用:

在网关层进行分场景协议选择。 服务层应至少暴露两种流式模式——Token 推送(token-push)和先接受再刷新(accept-then-flush),客户端应通过请求声明其需要的模式。相同的模型、相同的草稿模型、相同的验证器,可以同时服务于这两种模式:区别仅在于网关是将 token 暂存在缓冲区直到验证确认,还是立即释放。将协议视为请求时的选择,而非部署时的配置,是最干净的解耦方式。

服务端提交边界(Commit boundaries)。 即使在 Token 推送模式下,网关也可以选择在语义边界(如句末、JSON 对象结束、代码块结束)进行刷新,而不是每个 token 都刷新。这是一种软性的“先接受再刷新”,它在归还大部分延迟优势的同时,无需承担全量缓冲的回撤风险。句末刷新模型几乎是 TTS 流水线的最终选择;没有理由让它成为 TTS 特有的行为。

感知回撤的客户端协议。 如果你要进行 Token 推送,网络格式需要显式的“回退到位置 N”帧,客户端需要将其作为一等事件处理,而不是将其视为格式错误的流。基于 SSE 和 WebSocket 的栈通常需要在顶层增加一个薄封装来表达这一点;基于 QUIC 的栈可以更自然地通过流偏移量(stream offsets)来建模。无论如何,“流是单调追加的”不应是协议默许的属性。

工具调用执行门禁。 无论中间 token 是否为了显示目的而流式传输给客户端,在完整 JSON 被目标模型验证之前,不得触发任何工具调用。显示流(display stream)和动作流(action stream)是两个不同的流,具有不同的提交语义。将它们混为一谈,就是投机解码演变为“混淆代理”(confused-deputy)漏洞的根源。

这场组织内部对话的本质

投机采样(Speculative decoding)通常作为推理团队的一项举措引入推理服务栈:3 倍的吞吐量提升,通过了困惑度(perplexity)和下游评估套件的验证,准备发布。评估套件确实证明了质量得到了保留。吞吐量数据也确实证明了延迟得到了改善。缺失的是与消费这些流的业务层进行的跨职能评审:聊天产品团队、语音智能体(voice agent)团队、智能体工具框架,以及客户用于集成的 SDK。

这些业务层与推理层之间存在一些隐式契约,而推理团队并未正式签署这些契约。“流是只增不减(append-only)的”并不是推理团队承诺过的属性 —— 但它是每个下游消费者都默认的属性,因为之前的每种解码策略都免费提供了这一特性。

应该采取的模式是:像对待协议变更一样对待投机采样的推广:向消费端发送明确的 RFC,采用客户端选择性加入(opt-in)机制,制定特定于业务层的评估指标,并为不支持撤回(retraction)机制的客户端提供弃用路径。推理团队可以交付加速效果。产品业务层可以控制这种加速的呈现方式。而组织也不必再通过事故复盘来重新认识到:“完全一致的输出分布”并不等同于“完全一致的用户体验”。

结语

投机采样做得很好的一点是借鉴了 CPU 分支预测的一个技巧:在低成本路径上进行推测,并行验证,如果错误则回滚。CPU 几十年来一直能行得通,是因为这种推测是不可见的 —— 回滚的工作在流水线外部永远无法被观察到。硬件承担了推测的成本,但软件契约得以保持。

现代推理服务栈在开始通过公共网络将草稿 token(draft tokens)流式传输给远程客户端的那一刻,就打破了这一不变性。根据定义,这种推测现在是可观察的,因为加速的前提就是它必须可观察 —— 这正是提前推送 token 的核心意义。我们面临的架构任务是将这种可观察性放回它该在的地方:放在定义明确的提交边界上,置于客户端控制之下,并使用衡量用户体验而不仅仅是引擎吞吐量的指标进行评估。投机采样将长期存在。而围绕它的流式传输协议,则是目前仍处于设计阶段的部分。

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