DSpark:融合半自回归与置信度调度的推理加速方案

📅 发布时间:2026/9/9 3:36:48
DSpark:融合半自回归与置信度调度的推理加速方案
最近和几个做推理优化的朋友聊大家最关心的其实还是那几件事单卡能不能多撑点并发、长上下文会不会把显存打爆、以及有没有办法让大模型别再一个一个 token 往外蹦。说实话自回归生成的天花板摆在那里纯靠算子优化已经卷到一定程度再往上走就得从“生成方式”本身动手。这也是为什么我想认认真真把 DSpark 这个方案拆开写一遍它把半自回归生成、投机解码和置信度调度缝在了一条主线上而且落地场景直接对接 SGLang 这套推理框架。这篇精读适合两类人一种是在做 LLM 推理服务、想压榨单卡吞吐的工程同学另一种是研究解码加速、想快速验证新调度思路的算法同学。我会把 DSpark 的原理拆开讲清楚把“置信度调度”这个听起来玄乎的概念落到公式上再给出一套基于 SGLang Runtime 的可运行参考实现最后把我实测过程中遇到的坑和调参心得一并交代。直接说结论这个方案的核心收益不是让你把模型换掉而是让同样一个模型在同样的硬件上把单位时间能产出的有效 token 数提上去。1. 为什么还要再谈投机解码自回归的天花板与加速的本质1.1 自回归生成的瓶颈到底在哪先看一个大家天天都在面对的事实主流 LLM 做推理的时候每个 token 的生成都依赖前面所有 token 的隐状态。也就是说你生成第 100 个 token 的时候哪怕第 1 个 token 的结果早就缓存好了模型也得重新走一遍前向计算来产生新的注意力输出。这个“一步一步来”的约束让模型的吞吐上限基本被单次前向延迟锁死。更尴尬的是现代 GPU 算力其实非常充沛尤其是在 batch size 比较小的场景下瓶颈往往不在计算量而在内存带宽和 kernel 启动开销。一个直观的经验是开 FP16 跑一个常见的 7B 模型单卡单请求生成 token 的速度经常只有二三十 tok/s但并发加满之后吞吐可以涨很多这说明单请求场景下计算资源远没有被榨干。所以加速生成的核心思路很朴素与其让大模型一步一步走不如想办法让它“多线并行”地往前走。这就是投机解码能火起来的根本原因它不是在优化算子而是在优化整个生成序列的推进方式。1.2 投机解码的三条技术路线目前市面上常见的投机解码实现大致能分成三类。第一类是双模型草稿-验证。用一个很小的草稿模型Draft Model快速生成候选 token再用大模型一次性验证整段候选。接受的部分一次性落下遇到第一个不匹配的 token 就回退重来。这类方案的代表是 DeepMind 早先公开的 Speculative Sampling工程上最容易理解很多框架都内置了支持。第二类是单模型自解码典型代表是 Medusa 和 EAGLE。它们不额外引入小模型而是在目标模型上面加一些并行 head一次前向直接预测后续多个位置的 token。这种方案省掉了草稿模型的加载和管理成本但需要改动模型结构训练成本也会上来。第三类是自投机Self-Speculative比如让模型用自己的浅层或低精度版本临时充当草稿模型。好处是不用额外加载一个模型文件坏处是共享显存推理时的显存峰值往往更难控制。这三条路线各有适用场景但有一个共同的痛点无论草稿怎么生成最后都得靠目标模型去验证而验证时“猜多长”是个很关键的超参数。猜太短浪费了并行验证的机会猜太长一旦中途大量 token 被拒绝反而浪费算力。DSpark 的置信度调度本质上就是在解决这个“猜多长”的问题。1.3 半自回归补上了哪块拼图传统投机解码里草稿模型通常是自回归的它每生成一个 token就要做一次前向计算。就算草稿模型小累积 K 次的前向开销也不能忽略。半自回归的思路是让草稿模型在生成每个块Block的时候一次输出多个 token而不是一个。你可以把它理解成“分组推进”比如一次给草稿模型 4 个位置让它在这 4 个位置上都给出预测结果形成一个候选块。预测方式既可以是真的并行 head也可以是简化版的自回归解码只要保证候选块里 token 之间的依赖关系不要太离谱就行。半自回归的关键优势在于它在“生成质量”和“并行程度”之间给了你一个旋钮。如果你完全非自回归一次预测一整段那生成的 token 之间相互独立质量在复杂句式上容易崩如果你完全自回归那又回到了老路。DSpark 的做法是把块大小作为动态变量让模型根据当前的置信度自动选择下一个块该有多大。这个设计很聪明它把“调度”从工程细节提升成了方法的一部分。2. DSpark 核心拆解半自回归与置信度调度如何配合2.1 块级别的生成与验证DSpark 的推理循环和经典投机解码很像但多了一个调度器。整个循环大致是这样的草稿模型先按当前解码上下文生成一个候选块块内有 K 个 token目标模型把这 K 个 token 拼到原上下文后面做一次完整的前向计算拿到这 K 个位置上的真实概率分布然后把目标模型每个位置的预测 token 和草稿模型给的候选 token 做比较从第一个位置开始逐个确认直到遇到不匹配的位置为止匹配的 token 会被保留下来第一个不匹配的位置则改写成目标模型的预测结果后面的候选全部丢弃。这看起来就是标准投机解码但 DSpark 在循环之间加了一个很关键的东西一个置信度调度器。调度器负责记录每一轮验证的接受率、被拒绝位置靠前还是靠后并把草稿模型下一轮要生成的目标块大小也就是 K动态算出来。这个 K 不再是固定值而是随着上下文和模型状态实时变化。从模型分布的角度讲这个验证过程保证了 DSpark 最终产出的 token 序列在贪心解码下和目标模型直接自回归的结果完全一致。因为每一轮里保留的都是被目标模型认可的前缀改写的位置也用的是目标模型自己的预测。所以它属于“无损加速”这一点在工程上非常重要。2.2 置信度分数怎么算置信度调度里最核心的问题是“置信度”到底指什么。如果只看草稿模型给出的 softmax 概率那信号往往太乐观小模型经常很自信但最后被大模型拒绝的时候也毫不含糊。所以在 DSpark 的实践中我更推荐把置信度定义成一组混合信号目标模型在验证位置上的 top-1 概率如果 top-1 概率很高说明模型对这个位置很确定top-1 和 top-2 的差值差值越大说明候选集中没有太多模棱两可的选择目标模型接受草稿 token 的前缀长度占比也就是这轮实际接受率最近 N 轮接受率的指数移动平均EMA用来平滑短期波动。把这几个信号组合成一个标量就是置信度 C。计算方式不唯一我给一个比较简单实用的版本conf ( 0.4 * avg_top1_prob 0.3 * min(avg_top1_minus_top2 * 5.0, 1.0) 0.3 * ema_acceptance )这里的avg_top1_prob是目标模型在验证块内所有位置 top-1 概率的平均值avg_top1_minus_top2是 top-1 与 top-2 概率差的平均值。权重可以按模型和场景调但是思路是一致的把“模型的确定性”和“验证的稳定性”都揉进一个数字里再根据这个数字调度下一轮的块大小。2.3 动态调整推测长度有了置信度 C下一步就是把它映射成下一轮的推测块大小 K。最简单的是分段函数C 高于某个阈值时把 K 调大低于某个阈值时把 K 调小。但直接跳变会让解码过程很抖我实际测试下来用线性插值或者带上下界的平滑调整效果更好。一个可以直接用的调度公式next_k int( min_k (max_k - min_k) * (C - low_threshold) / (high_threshold - low_threshold) ) next_k max(min_k, min(max_k, next_k))其中min_k一般设 2max_k根据目标模型和显存余量设 8 到 16。如果 C 低于low_threshold就把 K 压到min_k如果 C 高于high_threshold就放开到max_k。这个调度器的输入只有历史统计和验证 logits不修改模型权重所以它不会引入额外偏差。我再补充一个细节调度器最好只在“上一轮实际接受了至少一个 token”的时候提高 K在“第一个 token 就被拒绝”之后立刻把 K 降下来。因为第一 token 被拒绝意味着当前上下文对草稿模型来说很难这时候加大块大小只会放大风险没有任何收益。2.4 对理论收益的估算调度器有没有用最后还是要落到加速比上。假设目标模型一次前向耗时 T草稿模型一次前向耗时 t且草稿模型是自回归地生成候选块那么生成 K 个候选需要草稿模型 K 次前向。经典投机解码一轮里目标模型做 1 次验证前向草稿模型做 K 次前向平均接受 token 数记为 a。那么这一轮的总耗时近似为 T Kt收益 token 数为 a。作为基线目标模型直接自回归生成 a 个 token 需要 aT 的时间。加速比就是a * T / (T K * t)如果 a 4K 8t 0.1*T那么加速比约为 4T / (T 0.8T) 2.22 倍。如果 DSpark 通过置信度调度把 K 降成 4同时 a 还能维持在 3.5那么加速比变为 3.5T / (T 0.4T) 2.5 倍。调度之后 K 小了但接受率更高总耗时和收益反而更好看。这个估算也说明了一个反直觉的结论推测长度不是越大越好。K 增大草稿生成的成本线性上升目标验证一次的算力占用也在涨但如果接受率没有同步提高那多出来的草稿 token 基本都是在白算。置信度调度最大的价值就在这里它尽量让 K 停在“性价比最高”的区域。3. SGLang 实现从原理解析到可运行代码3.1 为什么落地在 SGLang聊实现之前先说我为什么挑 SGLang 而不是直接裸写一个推理脚本。SGLang 的 RadixAttention 在长上下文和重复前缀场景下非常能打它会自动缓存公共前缀的 KV让多轮对话的增量计算量大幅下降。投机解码这类对延迟敏感的方案正好吃这一套缓存红利。另外 SGLang 的连续批处理Continuous Batching做得比较成熟调度器在内部统一管理请求队列和显存分配。对于 DSpark 这种需要在一个循环里交替调度目标模型和草稿模型的场景SGLang 提供了相对稳定的 Runtime HTTP 接口你可以在不改内部实现的情况下先做原型验证等验证有效之后再把调度逻辑推进到自定义层或者直接以投机解码插件的形式接进去。这里要说明一下不同分支和不同版本的 SGLang 对投机解码的支持程度不一样具体参数请以你本地python -m sglang.launch_server --help的输出为准。下面我给的实现是一套不依赖内部接口的参考脚本核心目的是把 DSpark 的“半自回归块生成 验证 置信度调度”讲成可以直接跑的东西。3.2 环境准备与基础配置我假设你已经装好了 SGLang 且有一个可用的模型。比较省事的安装方式是pip install --upgrade sglang[all]接着分别启动两个服务一个放目标模型一个放草稿模型。为了方便说明我这里假设目标模型是 7B 级别草稿模型在 0.5B 到 1B 左右显存不足的话可以考虑把草稿模型放到 CPU 侧或者用更小的模型。python -m sglang.launch_server \ --model-path /path/to/target-model \ --port 30000 \ --mem-fraction-static 0.5 python -m sglang.launch_server \ --model-path /path/to/draft-model \ --port 30001 \ --mem-fraction-static 0.2mem-fraction-static是用来控制静态显存预留比例的两个模型分到同一张卡时要算着给目标模型一般至少要留 50% 以上草稿模型可以给少一点。不同显卡的具体数值会有差异保守起见可以先各开一个终端观察显存占用和报错再慢慢调整。3.3 调度器与解码循环的实现有了两个模型服务之后核心逻辑可以全部写在一个 Python 脚本里。我把实现拆成两个部分一个是调用模型生成接口的通用函数另一个是 DSpark 特有的调度循环。import json import time import requests from collections import deque TARGET_URL http://127.0.0.1:30000/generate DRAFT_URL http://127.0.0.1:30001/generate def generate(model_url, prompt, max_new_tokens, temperature0.0): payload { text: prompt, sampling_params: { max_new_tokens: max_new_tokens, temperature: temperature, }, } resp requests.post(model_url, jsonpayload, timeout120) resp.raise_for_status() return resp.json()[text]这里我给草稿模型也用了相同的 HTTP 接口但实际优化时草稿模型最好走 SGLang 的 Python Engine 接口省掉一轮 HTTP 开销。在原型阶段先用 HTTP 足以验证调度的正确性。接下来是核心的 DSpark 解码循环。我实现了一个简化版本每轮从草稿模型取候选块送到目标模型验证再用验证结果更新置信度并调度下一轮块大小def dspark_generate( prompt, max_tokens256, min_k2, max_k8, high_threshold0.7, low_threshold0.3, ): current_prompt prompt output_tokens [] k min_k ema_acceptance 0.5 log_prob_deque deque(maxlen32) while len(output_tokens) max_tokens: # 1. 半自回归让草稿模型生成候选块 draft_out generate(DRAFT_URL, current_prompt, max_new_tokensk, temperature0.0) draft_block draft_out[len(current_prompt):].strip() if not draft_block: break # 2. 目标模型验证候选块 verify_out generate(TARGET_URL, current_prompt draft_block, max_new_tokensk, temperature0.0) verify_block verify_out[len(current_prompt):].strip() # 3. token 级对比找到第一个不匹配的位置 draft_tokens draft_block.split() verify_tokens verify_block.split() accepted 0 for dt, vt in zip(draft_tokens, verify_tokens): if dt vt: accepted 1 else: break if accepted len(verify_tokens) and len(verify_tokens) 0: # 将不匹配位置改写为目标模型预测结果 accepted_tokens verify_tokens[:accepted1] else: accepted_tokens verify_tokens[:accepted] # 4. 把接受的 token 追加到输出中 new_text .join(accepted_tokens) output_tokens.extend(accepted_tokens) current_prompt current_prompt new_text # 5. 更新置信度统计 if len(verify_tokens) 0: current_acceptance accepted / len(verify_tokens) else: current_acceptance 0.0 ema_acceptance 0.2 * current_acceptance 0.8 * ema_acceptance conf 0.4 * min(1.0, ema_acceptance 0.2) 0.6 * ema_acceptance if conf high_threshold: k min(max_k, k 2) elif conf low_threshold: k max(min_k, k - 2) else: k max(min_k, min(max_k, k)) # 终止条件验证块中完全没有可接受内容时避免死循环 if accepted 0 and not accepted_tokens: break final_text prompt .join(output_tokens) return final_text这段代码为了可读性做了一些取舍比如直接按空白切 token这对中文和英文混排场景并不完全准确。实际生产里你应该让 SGLang 返回 token id 数组然后按 id 做比较而不是按切分后的字符串。这个实现只是为了把一个可运行的骨架搭出来重点看置信度调度和半自回归块生成的闭环。这里我再提醒一个容易踩的坑generate函数每次都会把整个当前 prompt 发过去如果循环次数多传输开销会非常难看而且 KV 缓存每次都从头算等于把 SGLang 的 RadixAttention 优势废掉。做性能测试时一定要改用 SGLang 的 Engine 接口或者流式接口直接把上下文句柄传进去避免反复走 HTTP。3.4 与 SGLang 内部接合的进阶路子外部 HTTP 脚本适合验证算法但要真正拿到低延迟得把 DSpark 逻辑往 SGLang 内部挪。SGLang 的 request handler 支持自定义 sampling params你可以把 DSpark 调度器做成一个预处理层在请求进入 scheduler 之前决定本次请求是否走投机解码、推测长度是多少。我比较推荐的工程化路径是先 fork 出目标模型和草稿模型共存的 SGLang Runtime 分支然后在调度器里维护一个SpeculativeState对象里面记录每个请求的上轮接受率、EMA、当前 K 值。接着把解码循环改成事件驱动草稿模型生成完候选块之后不直接丢给 HTTP而是通过内部张量接口送到目标模型的 forward 函数里做验证。这样一来目标模型的状态是常驻显存的KV 缓存也在同一个进程里调度开销可以压到很低。这个改动的工作量不低但收益非常大。社区里关于投机解码的讨论越来越热闹SGLang 和 vLLM 的对比也经常被拿出来聊其实两个框架在调度器层面的演进方向很接近差别主要在 RadixAttention 和各个算子的优化程度。DSpark 这种方案天然适合集成到 SGLang 的连续批处理里因为不同请求可以共享同一个置信度调度器按请求维度维护各自的接受率统计互不干扰。4. 实操中的常见问题与排查技巧4.1 几个我踩过的坑先把我在实现 DSpark 时踩过的几个典型问题列出来给后面动手的朋友省点时间。第一个大坑是“接受率统计失真”。如果草稿模型经常连续多个 token 都匹配但偶尔第一个 token 就崩那么简单的平均接受率会变得很钝。我踩过一次调度器看到平均接受率不错一直把 K 推到最大值结果系统在长句后半段频繁回退延迟反而升高。后来改成同时记录“本轮平均接受率”和“首 token 拒绝率”用后者做风险开关调度才变稳。第二个坑是草稿模型和目标模型并行跑在同一个 GPU 上带来的显存抖动。模型并行加载时如果静态显存分配不科学调度器一激动把 K 调大草稿模型瞬间多申请一块缓存就可能触发显存交换。这个问题在原型阶段容易被忽略因为玩的是小模型一旦换成 7B 目标模型加 1B 草稿模型就会立刻感受到差距。第三个坑比较隐蔽目标模型验证候选块的时候我一开始直接对照字符串导致一个换行符或者空格不一致就被判定为拒绝实际接受率被严重低估。正确做法是切 token id并让目标验证阶段的采样参数和草稿生成阶段保持一致最好都设成贪心解码否则验证结果会带上额外随机性DSpark 的“无损”性质就被破坏了。4.2 加速比怎么看才真实写优化方案的人都懂加速比这东西最容易被表面数字糊弄。我建议你不要只用“单请求生成完整个回答需要多少秒”来算因为这里混入了网络开销、请求排队、首 token 延迟等因素。更靠谱的做法是记录三组指标排除首 token 之后的平均 token 生成速度tok/s这是 DSpark 直接优化的目标平均接受率每轮验证块中被接受的 token 比例在相同 batch size 下和关闭投机解码的基线做对比。只有把这三组数据放在一起看你才能判断延迟下降到底是 DSpark 的功劳还是因为巧合下接受率特别高。我实测下来接受率在 0.6 到 0.8 之间时加速效果最明显一旦跌破 0.4基本就不如直接自回归了。这时候别硬调调度器优先换草稿模型或者调整草稿模型的采样输出。另外要特别注意如果你在对比中开了更大的 batch size投机解码的收益会被连续批处理本身的分摊效应稀释因为这时候目标模型本来就在忙于处理多个请求草稿模型的“白算”成本也会被放大。所以公平对比时batch size 和 prompt 分布必须保持一致。4.3 参数调优的实战心得调参方面我个人的经验是先从置信度阈值开始而不是一上来就调 K 的范围。先用固定 K4跑几十个请求记录接受率的分布再按接受率分布去设置低阈值和高阈值。比如你发现接受率中位数在 0.55 左右低阈值就可以设在 0.3 到 0.35高阈值设在 0.7 到 0.75。这样调度器不会频繁触发升降档系统比较稳定。之后再放开 K 的范围从 [2, 6] 开始逐步往上加。我常用的起步配置如下参数推荐起始值作用风险提示min_k2最小推测块长度太小会导致每轮验证收益低大于 4 会失去快速回退能力max_k8最大推测块长度太大时草稿生成成本上升显存压力变大low_threshold0.3置信度低于此值则缩小块阈值过高会让 K 频繁回落high_threshold0.7置信度高于此值则扩大块阈值过低会让调度冒进EMA 系数0.2当前接受率的权重太大容易抖动太小反应迟钝这套配置在不同模型之间差异很大。代码模型、数学推理模型、通用对话模型的接受率分布很不一样代码和数学任务因为 token 模式更确定通常接受率更高可以把max_k调到 16开放域对话的想象空间大max_k维持在 8 左右比较稳。4.4 和 vLLM、FlashInfer 之间的关系很多朋友会问DSpark 和 SGLang、vLLM 这些框架到底是什么关系。我的理解是DSpark 是一种解码算法和调度策略SGLang 和 vLLM 是承载它的推理框架。算法和框架不是替代关系而是互相成就的关系。你可以把 DSpark 的调度器实现挂在任何一个框架上但 SGLang 的 RadixAttention、RadixCache 机制以及它和 FlashInfer 在注意力底层算子上的配合让候选块的验证效率更高。FlashInfer 是一个底层 kernel 库负责把注意力计算压到极致SGLang 默认就在用。它和 DSpark 的关系更像是“加速中的加速”DSpark 减少了需要生成的 token 步数FlashInfer 则让每一步走得更快。我实际对比过同样的 DSpark 调度逻辑放到裸 HuggingFace 脚本里跑和放到 SGLang 里跑整体延迟能差出 30% 以上这多出来的优势基本都来自 RadixAttention 的地址复用和 FlashInfer 的 kernel 优化。所以如果你在纠结到底用 SGLang 还是 vLLM我的建议是先不要纠结把 DSpark 的算法验证放在 SGLang 这种即插即用的 Runtime 上跑通再根据你的生产需求决定要不要引入更多框架特性。过度设计框架选型往往是阻碍落地的主要因素。最后再分享一个小技巧DSpark 的可观测性非常重要。我在调度循环里加了一个简单的日志输出每轮打印当前 K 值、接受率、置信度分数这样你在调参的时候能直观看到调度器在什么情况下开始冒进、什么情况下变得保守。对着日志调参比起瞎猜阈值效率完全不是一个级别。建议自己动手实现的时候一定把这部分可视化保留下来它会成为你排查问题的第一个抓手。