前言
最近半年起, 我读代码的体验被 AI 拉高了整整一个数量级!
在这之前, 我其实有过好几段啃大型项目源码的经历: linux kernel, PostgreSQL/Greenplum, NCCL 等等. 那时候读代码最痛苦的地方, 倒不是某一处逻辑有多难, 而是那种一叶障目, 盲人摸象的无力感——你看到一个字段, 不知道它到底是干嘛的; 你看到一个 if 分支, 不知道它为什么要存在, 更不知道它是为了兜住哪个犄角旮旯里的 case. 你只能一处一处地啃, 啃到最后脑子里全是碎片, 却始终拼不出一张全局的图. 更别提读着读着还会陷进某个细节里出不来, 抬起头才发现主干早跟丢了.
现在完全不一样了. 有了 AI, 我可以先从全局入手: 这个模块大概分几层? 这条链路的主干是什么? 这个字段是谁写的, 又是谁在消费? 先把骨架搭起来, 再往里填细节, 遇到看不懂的分支随手就能问一句”这里为什么要这么写”. 尤其是最近半年, 我用 AI 读 verl, sglang 这些项目源码时, 那种幸福感真是无以言表.
代码读得多了, 就会不由自主地在读代码的同时大脑开始想象链路如何执行,数据如何流动,状态如何该更,也会自然而然发现一些问题. 之前读 vllm async scheduling 那条链路时, 我就一口气 读出了 3 个 bug; 这次在学习 sglang 时, 一边读一边把觉得可疑的地方记下来, 读着读着, 手里居然攒了 13 个问题:
| # | 简述 | 子系统 |
|---|---|---|
| 1 | add_chunked_req 的 extend_input_len 未按 page_size 对齐 |
调度 (PrefillAdder) |
| 2 | EAGLE verify 在 topk>1 时 batch size 反推错误 |
推测解码 |
| 3 | init_load_back H2D 分配失败后,前缀节点缺失 mamba state 仍被当作已算 |
HiMambaRadixCache |
| 4 | storage prefetch 在 last_hash=None 时页 hash 链算错,命中失效/错配 |
HiMambaRadixCache / storage |
| 5 | _insert_helper_host 部分加载时 mamba_host_value 与实际 loaded 前缀不一致 |
HiMambaRadixCache |
| 6 | prefetch 时保护 old mamba host 的时机晚于分配新 slot | HiMambaRadixCache |
| 7 | dec_lock_ref 在锁定窗口内节点状态变化时多减 full KV lock |
HiMambaRadixCache |
| 8 | tombstone leaf 在 host_ref_counter>0 时被删,后续 assert v==node 可能失败 |
HiMambaRadixCache |
| 9 | write_backup 对已在 host 的 mamba 重复发起 D2H |
HiMambaRadixCache |
| 10 | evict() 未回滚 full-kv 附带的 mamba_num_evicted,可能过度驱逐 |
HiMambaRadixCache |
| 11 | HybridReqToTokenPool 映射 tensor 首维用了 max_mamba_cache_size |
内存池 |
| 12 | load_video 调用把 frame_count_limit 误绑到 use_gpu 形参 |
多模态 |
| 13 | draft chunked prefill 中间块 shift 补的是采样 token 而非真实下一 prompt token | 推测解码 |
但人脑执行终究是人脑执行, 它有个致命的弱点: 我以为的, 未必是真的. 我盯着代码推演出来的 bug, 换成电脑真刀真枪地跑一遍, 到底会不会复现? 那些我觉得”这里肯定越界”“这里断言肯定挂”的地方, 真的会挂么? 一个自然而然的想法: 我能不能让一个 AI agent 帮我把这些问题挨个验证一遍? 让它去读代码、构造场景、写复现, 看看电脑执行的结论和我人脑执行的结论到底对不对得上. 本文用的 agent 是 qodercli + qwen3-max(400K 上下文). 下面几节就是它给出的复现报告. 所用 prompt:
请帮我验证位于 /zhanyi/docs/sglang/sglang.bug.md 文件中记录的 bug 1 是否在当前 main 分支中仍然存在。最接近我学习的版本是社区 v0.5.10.post1 tag。
SGLang 启动命令如下:
python -m sglang.launch_server --model-path /mnt/models/Qwen3-Next-80B-A3B-Instruct --tp 2 --port 30000 --chunked-prefill-size=256 --page-size=64 --disable-cuda-graph --mamba-radix-cache-strategy extra_buffer --enable-hierarchical-cache --hicache-ratio=1 --hicache-mem-layout page_first_direct具体要求:
- 如有必要,可以通过修改源码来模拟可能发生的场景(例如手动调用驱逐代码来模拟驱逐场景),但必须确保代码模拟的情况是可能在线上实际场景中出现的,修改源码的目的仅为提高复现概率。不要加 assert 这种代码,这种代码对问题复现没有帮助。
- 由于 SGLang 是 editable build,修改源码后重启服务即可使更改生效
- 仅进行 bug 复现和验证,不需要修复问题
- 完整保留所有运行命令、服务启动日志、代码修改记录(可通过 git commit 或创建 branch 的方式保存)以便后续审查
- 如果需要 L3 backend,可以考虑使用 fake file backend 实现:put 操作直接写入本地文件,get/exists 操作查询本地文件
- 鼓励使用各种 subagent 来避免主 agent 窗口被污染
- 记录完整的复现过程、观察到的现象以及相关证据,包括但不限于错误日志、性能指标变化等
- 对于每个 bug,请明确说明其状态:已修复、仍存在、或无法复现
- 提供详细的测试步骤和环境信息以确保结果可重现
环境:lmsysorg/sglang:nightly-dev-cu12-20260715-50d1edaa
bug1:Chunked Prefill 与 Page 对齐
SGLang 的 chunked prefill 会把一个长 prompt 拆成多轮 extend 来执行,每轮处理 chunked_prefill_size(比如 256)个 token。与此同时,KV cache 的分配是以 page_size(比如 64)为单位的——一个 page 里放 64 个 token 的 KV 索引。这就引出了一个关键约束:每一轮 chunk 参与计算的 token 数必须是 page_size 的整数倍。 否则 radix tree 的 key 无法对齐到 page 边界,下游的 mamba SSM state 跟踪也会出现错位。在 PrefillAdder 里,处理新请求的 add_one_req 方法是知道这个约束的:
# schedule_policy.py, add_one_req 的 chunked 路径
trunc_len = self.rem_chunk_tokens // self.page_size * self.page_size
# 显式 floor 对齐到 page_size
但处理”上一轮遗留的 chunked 请求”的 add_chunked_req 方法,却完全忘了这回事:
# schedule_policy.py:827, add_chunked_req
new_len = min(cand_extend_input_len, _rem_tokens)
# 没有任何 page_size 对齐!
这里的 _rem_tokens = min(rem_chunk_tokens, int(rem_total_tokens)),其中 rem_total_tokens 是当前 KV 池的可用容量(available + evictable - offset)。这个值是个运行时动态变化的数,几乎不可能是 page_size 的整数倍。一旦 rem_total_tokens < rem_chunk_tokens(也就是 KV 池吃紧、成为瓶颈时),new_len 就会被截成一个非对齐值。
触发后果:Mamba State 错位。add_chunked_req 设置了非对齐的 extend_range 后,下游的 mamba state 跟踪链路会把这个错位一路放大。具体来说:mamba_last_track_seqlen 被计算为非 page 对齐值:这个字段记录”mamba SSM 状态实际覆盖了多少个 token”。它是基于 extend_range.length 按 mamba_cache_chunk_size 对齐的,但如果 prefix_indices 本身就带着上一轮遗留的非对齐尾巴,对齐后的结果仍然不是 page_size 的整数倍。Radix tree 插入时静默截断:UnifiedRadixCache.cache_unfinished_req 会把 radix key 用 .page_aligned() 截断到 page 边界。结果就是:mamba state 覆盖 420 个 token 的 SSM 隐状态,但 tree 节点的 key 只覆盖 384 个 token。 那多出来的 36 个 token 的 state 信息,就这么凭空”超前”了。
后续请求复用 prefix cache 时加载到错误的 mamba state:当另一个请求的前缀恰好匹配到这个 384-token 节点时,它会从节点上拿到那个”420-token 版本”的 mamba state,然后以此为基础继续生成。SSM 层从一个错误的隐状态出发,而 attention 层却从正确的 384-token KV cache 出发——两种层看到的历史不一致,输出自然就跑偏了。
验证证据:我让 agent 用 Qwen3-Next-80B-A3B-Instruct(GDN 混合线性注意力 MoE 模型)+ –chunked-prefill-size=256 –page-size=64 的配置做了完整的复现。为了模拟 KV 池压力,在 add_chunked_req 里把可用 token 数 cap 到 100(不是 64 的倍数),这在逻辑上等价于线上 KV 池吃紧时 rem_total_tokens 自然落到非对齐值。
证据 1:日志直接捕获 mismatch。在 UnifiedRadixCache.cache_unfinished_req 里加了一行对比日志,每次 chunked prefill 插入 radix tree 时输出 mamba state 对应的 token 长度和实际插入 tree 的 token 长度:
[BUG1-REPRO] cache_unfinished_req: mamba_state_seqlen=420 != tree_insert_len=384 (dropped 36 tokens)
[BUG1-REPRO] cache_unfinished_req: mamba_state_seqlen=520 != tree_insert_len=512 (dropped 8 tokens)
[BUG1-REPRO] cache_unfinished_req: mamba_state_seqlen=620 != tree_insert_len=576 (dropped 44 tokens)
[BUG1-REPRO] cache_unfinished_req: mamba_state_seqlen=720 != tree_insert_len=704 (dropped 16 tokens)
一次 800 token 的请求里就出现了 4 次 mismatch,dropped 范围为 8~44 tokens。mamba state 始终”跑在” tree key 前面。
证据 2:Tensor 级铁证。最硬的证据来自直接 dump mamba state 的 temporal tensor(shape [36, 16, 128, 128],即 36 个 mamba 层 × 16 通道 × 128×128 隐状态,单张约 36MB)。在 buggy 和 clean 服务器上分别运行相同的请求序列,把 follow-up 请求从 radix tree 节点加载到的 mamba state 张量存下来做逐元素对比:
| prefix_len | cosine similarity | max element diff | 结论 |
|---|---|---|---|
| 384 | 0.871 | 1.57 | 部分相似(错位 36 tokens) |
| 512 | 0.001 | 9.50 | 完全不同 |
| 640 | 0.001 | 7.94 | 完全不同 |
| 768 | 0.816 | 1.55 | 部分相似 |
| 896 | 0.000 | 9.19 | 完全不同 |
| 1024 | -0.000 | 8.96 | 完全不同(几乎正交) |
cosine similarity 接近 0 甚至为负意味着,buggy 服务器加载到的 mamba 隐状态向量和 clean 服务器的几乎正交——它们描述的是完全不同的上下文历史。考虑到这些 tensor 的标准差只有约 0.015,max diff 达到 9.5 相当于偏差超过 600 个标准差。这不是数值噪声,不是浮点精度的小扰动,而是 SSM 隐状态被根本性地替换成了一个错误的值。
后语
(知道为什么我只贴了一个么!因为剩下 12 个 main 分支都已经修复了!!!羞=。=
工欲善其事, 必先利其器——我一直觉得, 在你打算介入一个项目、去做新特性或者 bugfix 之前, 一定要先把整体链路(尤其是那些边界情况)摸清楚. 不是”大概知道数据从哪进从哪出”那种熟, 而是能在脑子里把边界条件、异常路径、并发时序都过一遍那种熟. 上面这 14 个问题, 几乎全都藏在边界上: page_size 对齐、topk > 1 的树形草稿、分配失败的回退分支、并发窗口里的驱逐时机——正常路径跑一万遍都碰不到, 可一旦踩中就是正确性问题.
AI 火爆的这两年, 想赶紧在 AI infra 里做出点东西的团队和个人特别多, 我看到最多的情形是: 对整条链路的理解还没搭全, 就先上手了; 短期看确实快, demo 也跑得挺漂亮. 只是功能一点点叠上去之后, 最终在长稳测试里炸,在精度测试里漂,在线上出各种莫名其妙的问题。
说到底, 读代码本身不是目的, 在脑子里建立起对系统完整状态空间的心理模型, 才是目的. 有了它, 你写下的每一行代码才清楚自己在兜住哪个 case; 少了它, 就容易掉进按下葫芦浮起瓢的循环.