最近和几个做推理优化的朋友聊,大家最关心的其实还是那几件事:单卡能不能多撑点并发、长上下文会不会把显存打爆、以及有没有办法让大模型别再一个一个 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一般设 2,max_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 = 4,K = 8,t = 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, temperature=0.0): payload = { "text": prompt, "sampling_params": { "max_new_tokens": max_new_tokens, "temperature": temperature, }, } resp = requests.post(model_url, json=payload, timeout=120) resp.raise_for_status() return resp.json()["text"]这里我给草稿模型也用了相同的 HTTP 接口,但实际优化时,草稿模型最好走 SGLang 的 Python Engine 接口,省掉一轮 HTTP 开销。在原型阶段先用 HTTP 足以验证调度的正确性。
接下来是核心的 DSpark 解码循环。我实现了一个简化版本,每轮从草稿模型取候选块,送到目标模型验证,再用验证结果更新置信度并调度下一轮块大小:
def dspark_generate( prompt, max_tokens=256, min_k=2, max_k=8, high_threshold=0.7, low_threshold=0.3, ): current_prompt = prompt output_tokens = [] k = min_k ema_acceptance = 0.5 log_prob_deque = deque(maxlen=32) while len(output_tokens) < max_tokens: # 1. 半自回归:让草稿模型生成候选块 draft_out = generate(DRAFT_URL, current_prompt, max_new_tokens=k, temperature=0.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_tokens=k, temperature=0.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[:accepted+1] 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 的范围。先用固定 K=4,跑几十个请求,记录接受率的分布,再按接受率分布去设置低阈值和高阈值。
比如你发现接受率中位数在 0.55 左右,低阈值就可以设在 0.3 到 0.35,高阈值设在 0.7 到 0.75。这样调度器不会频繁触发升降档,系统比较稳定。之后再放开 K 的范围,从 [2, 6] 开始,逐步往上加。我常用的起步配置如下:
| 参数 | 推荐起始值 | 作用 | 风险提示 |
|---|---|---|---|
| min_k | 2 | 最小推测块长度 | 太小会导致每轮验证收益低;大于 4 会失去快速回退能力 |
| max_k | 8 | 最大推测块长度 | 太大时草稿生成成本上升,显存压力变大 |
| low_threshold | 0.3 | 置信度低于此值则缩小块 | 阈值过高会让 K 频繁回落 |
| high_threshold | 0.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 值、接受率、置信度分数,这样你在调参的时候能直观看到调度器在什么情况下开始冒进、什么情况下变得保守。对着日志调参,比起瞎猜阈值,效率完全不是一个级别。建议自己动手实现的时候,一定把这部分可视化保留下来,它会成为你排查问题的第一个抓手。