1. SGLang 部署 Qwen3/kimi-k3 时 KV cache 到底难在哪
如果你最近在折腾 SGLang 部署 Qwen3 或者 kimi-k3 这类 Hybrid Linear Mamba 架构的模型,大概率会遇到一个很反直觉的现象:显存监控里 Full Attention 的 KV cache 还剩一大半,但服务就是起不来,或者并发被死死卡在十几路。报错信息通常长这样:
max_running_requests is capped to 16 by the mamba state cache (max_mamba_cache_size=80, 5 state slots per request).这就是 Hybrid Linear Mamba 架构给 KV cache 管理带来的新问题。传统 Transformer 的 KV cache 是"按 token 追加"的,序列越长占得越多,逻辑非常线性。而 Qwen3 用的是 GQA + Linear 的 3:1 混合,kimi-k3 用的是 MLA + KDA(Kimi Delta Attention)的 3:1 混合,它们的线性注意力层根本不保存每个历史 token 的 K/V,而是把历史压缩进一个固定大小的递归状态里。
换句话说,dense 层(Full Attention / MLA)的 KV cache 随序列长度线性增长,而 linear 层(GDN / KDA)每个请求只需要一份固定大小的 conv state + SSM state,每处理一个 token 就原地覆盖更新。SGLang 内部把这类需要"每请求一个递归状态 slot"的模型统称为 mamba-ish。
这个差异直接导致两个后果。第一,两类 cache 的显存增长曲线完全不同,用同一个--mamba-full-memory-ratio去卡死比例,短请求和长请求的最优值能差好几倍。第二,linear 层的状态不能像 dense KV 那样逐 token 甚至按 page 存储,因为单个 state 张量本身就很大(kimi-k3 在 TP8 下单层 SSM state 就有 12×128×128 个元素),存太密浪费显存,存太稀又掉命中率。
这篇就围绕 SGLang 里 Qwen3 与 kimi-k3 的 Hybrid Linear Mamba KV cache 管理展开,把启动参数、cache 配置片段、压测对比和常见报错排查都过一遍。适合已经在跑 SGLang、但被 mamba state 卡住并发的同学。
2. 先搞懂 SGLang 里 mamba-ish 的 state 到底存了什么
在动手改参数之前,得先知道 SGLang 为每个 linear 层分配了哪些 state。搞不清这个,后面调--mamba-full-memory-ratio就是瞎调。
2.1 Temporal/SSM state 与 Conv state 两部分
SGLang 中一个 Mamba-style cache 通常包含两部分:conv state 和 temporal/SSM state。
Temporal/SSM state 保存长期递归状态。在 Mamba2 里是标准 SSM state,在 GDN/KDA 里通常是线性注意力状态矩阵,shape 大致是[head_num, v_head_dim, k_head_dim]。普通 MHA 保存每个 token 的 K、V 作为 KV cache,而 linear attention 保存的是v_t k_t^T矩阵乘的累加状态S_t。它的大小从原来的head_num * head_dim变成head_num * k_head_dim * v_head_dim。
Conv state 则来自 Short Conv。对 QKV 投影后的向量做 depthwise causal convolution,kernel_size 通常是 4。为什么除了 SSM state 还要存 conv state?因为当前 token 的 q、k、v 向量需要和之前 kernel_size-1 个 token 做 mixing,所以必须把最近 3 个 token 的 q、k、v 向量存下来。这部分有点像 SWA 模型存最近 N 个 token 的 KV,只不过这里连 q 也要存。
所以 conv state 的大小是:
layer * (conv_kernel_size - 1) * head_num * (q_head_dim + k_head_dim + v_head_dim) * dtype_sizekernel_size 取 4 时就是layer * 3 * head_num * (2*k_head_dim + v_head_dim) * dtype_size。
2.2 kimi-k3 的 KV cache 实际占用算一遍
拿 kimi-k3 举例,它的 linear:dense = 3:1,93 层 = 23 模块 × (3 linear + 1 MLA) + 最后一层 MLA,也就是 69 层 KDA linear + 24 层 MLA dense。
Dense 层用 MLA,KV cache 大小和 DeepSeek V3.1 一致,每层每 token 576 个元素(kv_lora_rank 512 + qk_rope_head_dim 64)。24 层 × 576 × 2 字节(BF16)= 27 KB/token。如果 KV cache 用 FP8,直接减半到 13.5 KB/token。
Linear 层在 TP8 下,每个 GPU 分到 96/8 = 12 个 head。SSM state 是[12, 128, 128],conv state 是[3, 12, 3*128]。算下来:
SSM: 69 * 12 * 128 * 128 * 2 = 25.9 MB Conv: 69 * 3 * 12 * 3 * 128 * 2 = 1.82 MB TP8 总和: (25.9 + 1.82) * 8 = 221.8 MB注意这是每个请求的 linear state 占用。也就是说,如果你要支持 100 路并发,光 linear state 就要 22 GB 左右,还没算 dense KV。这就是为什么 mamba state 会成为并发瓶颈。
2.3 为什么 mamba state 不能逐 token 存
Dense 层的 KV cache 是按 token 存储的,每个 token 存固定大小的 K、V,MLA 只存一个 hidden cache。而 linear attention 的 mamba state 是在一个 stage 上循环累加的,理论上一个请求最少只需要存一个 state 张量。
但单个 mamba state 容量很大,存太密导致存储需求巨大,存太稀又可能命中率降低。SGLang 的实际策略是:prefill 部分每次 chunked prefill 完成后存一次;decode 部分当(输入长度+输出长度) % mamba_track_interval == 0时更新一次,但只 offload 最终那一个 state。
所以 mamba state 存储数量最大约为in_len // chunked_prefill_size + 2个。比如输入 13600、chunked prefill 6144,会做 3 次 prefill 存 3 个 state,decode 完成再存一次,总共 4 个。
3. 可复制的 SGLang 启动参数与 cache 配置片段
理论讲完,直接上能跑的配置。下面这套是我在 TP8、单机 8 卡上跑 kimi-k3 的启动命令,Qwen3 把模型路径换掉即可。
3.1 基础启动命令与关键参数
python -m sglang.launch_server \ --model-path /models/kimi-k3 \ --tp-size 8 \ --host 0.0.0.0 \ --port 30000 \ --kv-cache-dtype fp8_e4m3 \ --mamba-ssm-dtype bfloat16 \ --mamba-full-memory-ratio 0.15 \ --chunked-prefill-size 6144 \ --max-running-requests 64 \ --mamba-radix-cache-strategy extra_buffer \ --enable-linear-replayssm-spec \ --speculative-dspark-block-size 7 \ --linear-replayssm-cache-len 16 \ --cuda-graph-max-bs-decode 64几个参数的作用要讲清楚:
--kv-cache-dtype fp8_e4m3把 dense 层 KV cache 量化到 FP8,直接省一半显存。kimi-k3 的 FP8 是全部 token 直接量化,不像 DeepSeek V3.2 那样部分 BF16 部分 FP8。
--mamba-ssm-dtype bfloat16把 SSM state 从 FP32 降到 BF16,state 大小直接减半。这是缓解 mamba 并发瓶颈最有效的一招。
--mamba-full-memory-ratio 0.15设置 Mamba 状态与 Full KV 的显存预算比例。这个值需要根据你的请求长度分布调,后面会讲怎么算。
--mamba-radix-cache-strategy extra_buffer是生产环境推荐值,支持 overlap scheduler 和分叉点状态缓存,代价是每请求预留 5 个 slot。
3.2 一份可直接落地的 settings 配置片段
如果你用配置文件管理启动参数,可以写成这样一份 JSON:
{ "model_path": "/models/kimi-k3", "tp_size": 8, "kv_cache_dtype": "fp8_e4m3", "mamba_ssm_dtype": "bfloat16", "mamba_full_memory_ratio": 0.15, "mamba_radix_cache_strategy": "extra_buffer", "mamba_track_interval": 256, "chunked_prefill_size": 6144, "max_running_requests": 64, "enable_linear_replayssm_spec": true, "speculative_dspark_block_size": 7, "linear_replayssm_cache_len": 16, "cuda_graph_max_bs_decode": 64, "enable_overlap_schedule": true }这里mamba_track_interval默认 256,控制输出部分的 mamba state 更新粒度。值越小缓存粒度越细、前缀命中后重算的 token 越少,但状态保存更频繁;值越大保存开销低但复用效果差。
linear_replayssm_cache_len默认 16,不是上下文长度也不是 block size,而是 ReplaySSM 为每个请求、每个 KDA 层分配的临时环形缓存深度。它必须满足L >= 2 × 最大 speculative verify token 数,且必须是 2 的幂。DSpark 里 gamma=7,target verify window = 1 anchor + 7 drafts = 8,所以 L 最小为 16。
3.3 三件套:Base URL、Key、Model ID 怎么配
如果你是通过 API 方式调用 SGLang 服务,客户端配置需要三件套齐全。以 OpenAI 兼容接口为例:
from openai import OpenAI client = OpenAI( base_url="http://127.0.0.1:30000/v1", api_key="EMPTY", ) resp = client.chat.completions.create( model="kimi-k3", messages=[{"role": "user", "content": "用一句话解释 KDA 的递归状态"}], max_tokens=128, ) print(resp.choices[0].message.content)Base URL 指向 SGLang 的/v1,Key 本地部署填EMPTY即可,Model ID 用启动时注册的模型名。如果你要接远程的推理服务做对比测试,把 base_url 换成对应服务的地址、api_key 换成真实 Key 就行。
4. 验证请求与压测:不同 cache 策略下的吞吐与显存对比
配置写完,得用压测验证效果。下面这套流程可以帮你定位自己的部署到底卡在哪。
4.1 用 benchmark 脚本跑一轮并发压测
SGLang 自带 benchmark 工具,直接跑:
python -m sglang.bench_serving \ --backend sglang \ --host 127.0.0.1 \ --port 30000 \ --model kimi-k3 \ --dataset-name random \ --random-input-len 16384 \ --random-output-len 3072 \ --num-prompts 128 \ --max-concurrency 32 \ --request-rate 4跑完后重点看两个指标:Request throughput和Output token throughput。同时开另一个终端盯显存:
watch -n 1 nvidia-smi --query-gpu=index,memory.used,memory.total --format=csv4.2 对比 FP8 与 BF16 下的 mamba state 分配
启动日志里会打印 mamba cache 的分配情况。对比两组配置:
BF16 KV + FP32 SSM:
Mamba Cache is allocated. max_mamba_cache_size: 51, conv_state size: 0.09GB, ssm_state size: 2.63GB KV Cache is allocated. dtype: torch.bfloat16, #tokens: 974656, KV size: 25.10 GBFP8 KV + BF16 SSM:
Mamba Cache is allocated. max_mamba_cache_size: 80, conv_state size: 0.14GB, ssm_state size: 2.05GB KV Cache is allocated. dtype: torch.float8_e4m3fn, #tokens: 1947648, KV size: 25.08 GB可以看到 FP8 + BF16 组合下,KV token 容量从 97 万涨到 194 万,mamba slot 从 51 涨到 80。同样的显存预算,并发能力几乎翻倍。
4.3 观察 mamba usage 与 full token usage 的失衡
压测过程中如果看到这样的日志:
Decode batch, #running-req: 16, #full token: 206080, full token usage: 0.11, mamba num: 64, mamba usage: 0.80说明 full KV 只用了 11%,但 mamba state 已经用到 80%,并发被 mamba 卡死了。这时候要么调大--mamba-full-memory-ratio,要么用--mamba-ssm-dtype bfloat16把 state 减半,要么开SGLANG_OPT_MAMBA_SKIP_DECODE_LOCK=1把每请求预留 slot 从 5 降到 4。
4.4 用 ReplaySSM 降低投机解码的 state 开销
开启--enable-linear-replayssm-spec后,日志会多出 ReplaySSM ring buffer 的分配:
GDN ReplaySSM ring buffers allocated (L=16): d=0.379GB, k=0.379GB, g=0.758GB rawv=0.379GB, rawk=0.379GB, beta=0.006GB这 6 个 buffer 分两组:d/k/g 用来从 checkpoint 快速重建输出,rawv/rawk/g/beta 在 speculative verify 接受 token 后精确重放递推并提交新状态。相比每步保存完整[V,K]state,ReplaySSM 只保存轻量输入记录,接受后按原顺序重放,大幅省显存。
注意这部分 ring buffer 不需要 L2/L3 offload,SGLang 把它们列入了_NON_TRANSFER_STATE_FIELDS,HiCache transfer 只遍历可持久化状态。
5. 本篇常见报错排查
部署过程中最容易撞的几个坑,对照着查。
5.1 max_running_requests is capped by mamba state cache
完整报错:
max_running_requests is capped to 16 by the mamba state cache (max_mamba_cache_size=80, 5 state slots per request). To raise it: increase --mamba-full-memory-ratio or --max-mamba-cache-size, or halve the state size with --mamba-ssm-dtype bfloat16.原因:resolve_max_num_reqs用总 mamba slot 除以每请求预留 slot 算最大并发。extra_buffer 模式下每请求预留 5 个(基础安全容量 3 + overlap ping-pong buffer 2),80/5=16。
解决三条路:调大--mamba-full-memory-ratio;用--max-mamba-cache-size手动指定物理槽位;用--mamba-ssm-dtype bfloat16把 state 减半。如果显存实在紧,试SGLANG_OPT_MAMBA_SKIP_DECODE_LOCK=1降到 4 个 slot。
5.2 401 与 local proxy failed
如果你通过 API 网关调用,遇到 401 通常是 Key 没配对。本地 SGLang 填EMPTY,远程服务要填真实 Key。local proxy failed一般是 base_url 写错,检查是不是漏了/v1或者端口不对。
5.3 reading choices 报错与 OAuth 问题
reading choices报错通常是响应体不是标准 OpenAI 格式,检查 SGLang 版本和客户端 SDK 版本是否匹配。OAuth 相关报错多见于接第三方托管服务,本地部署不会遇到,确认你的 base_url 指向的是自己的 SGLang 实例。
5.4 mamba-full-memory-ratio 设置不合理导致并发受限
这是最隐蔽的坑。不同请求长度需要不同的 ratio:短输入需要设大值,长输入需要设小值。设错了就会出现 full KV 空闲但 mamba 用满的情况。
SGLang 官方有个计算器(Kimi-K3 - SGLang Documentation),但公式有缺陷:没考虑 chunked prefill 存储逻辑,也没算--mamba-max-states-per-path的影响。实际调的时候,先按官方计算器给个初值,然后压测看 mamba usage 和 full token usage 是否均衡,再微调。
只开 TP 不开 DCP 时,mamba 部分按 GPU 做 head 切分,大小除以 TP 数,所以比例更低;开 DCP 时没这个冗余,mem ratio 要乘以 TP 数。
5.5 四种 radix cache 策略怎么选
--mamba-radix-cache-strategy有四个选项:
| 策略 | Overlap | 分叉点缓存 | 每请求 slot | 适用场景 |
|---|---|---|---|---|
| auto | 自动 | 自动 | 取决于解析 | 通常首选 |
| no_buffer | 不支持 | 未实现 | 3 | 显存紧张、兼容优先 |
| extra_buffer | 支持 | 支持 | 5 | 吞吐优先、生产配置 |
| extra_buffer_lazy | 必须开 | 支持 | 4 | mamba state 成瓶颈 |
extra_buffer 的 ping-pong 机制是为了让 CPU 读旧快照和 GPU 写新快照不打架:第 t 轮读 A 写 B,第 t+1 轮读 B 写 A。extra_buffer_lazy 只预留 4 个 slot,适合单体服务、mamba state 显存成瓶颈的场景,但不支持 PD disaggregation 和 DFLASH。
5.6 unified memory 的兼容性限制
如果--mamba-full-memory-ratio怎么调都不满意,可以试--enable-unified-memory,让 Full KV 和 Mamba Cache 共用一块显存,从相反方向动态增长。长请求多就多给 Full KV,短请求多就把空闲 Full KV 转成 Mamba 槽位。
但它当前和多项技术不兼容:
--enable-unified-memory is not yet compatible with PD disaggregation. --enable-unified-memory is not yet compatible with speculative decoding. --enable-unified-memory is not yet compatible with hierarchical host-tiered KV cache. --enable-unified-memory is not yet compatible with decode context parallelism (--dcp-size > 1).底层实现是分配一块torch.empty(total_bytes, dtype=torch.uint8),然后在上面构造 MHA/MLA/Mamba/SWA 的不同 view,分配器保证两边实际占用区域不重叠。如果你的场景不涉及上面这些特性,值得一试。
6. 把 cache 管理调顺之后
调完这一轮,你会发现 Hybrid Linear Mamba 架构的 KV cache 管理核心就一句话:dense 层按 token 线性增长,linear 层按请求固定占用,两类 cache 的预算比例必须跟着业务请求长度分布走。
几个实测下来比较稳的经验:--mamba-ssm-dtype bfloat16几乎是必开项,state 直接减半;--mamba-radix-cache-strategy extra_buffer是生产默认;--enable-linear-replayssm-spec在开投机解码时能省下可观的 state 开销;--mamba-full-memory-ratio别信计算器,压测看 usage 均衡再定。
如果你还没开始搭环境,可以先从模型对话把接口跑通,确认请求链路没问题;要长期跑编码或 Agent 任务,Coding Plan 的配额和并发更适合持续压测;接入文档里有完整的参数说明和示例,API Keys 页面可以管理你的调用凭证。把这几步走完,再回头调 cache 参数,心里就有数了。