前几天翻自己的工作周报, 一眼瞥到 2024-09-23 那条: “Vllm 链路初步掌握了” 当时还为搞清楚 vllm 怎么跑起来庆祝了一下, 现在再看, 已经是另一段人生开始的样子了。
从那天到今天, 不知不觉, 我在 AI 已经待了快两年。这两年里完成了两次转型: 一次是从数据库内核钻进 LLM 推理, 在百炼把 PD 分离从 blade-kvt 一路做到 vLLM HybridConnector; 另一次是最近, 又从推理一头扎进千问 RL 后训练 infra。一时间心有所感, 趁着记忆还热乎, 就想把这段经历记下来。
从 blade-kvt 开始: 先把 KV 搬稳
PD 分离最早那一版还是基于 Blade-LLM. 分工很自然: 框架层, Python 交互, 引擎接入由其他同学负责, 我主要负责底层 blade-kvt, 把 KV Cache 真正从一端搬到另一端.
这个阶段其实就是在打地基. 一开始要解决的是数据面本身: RDMA/TCP 怎么传, 一段 KV 在显存里怎么描述, 怎么把请求里的 token 范围翻译成一组待发送 block, layer 算完之后怎么触发发送, 对端信息怎么维护. 再往后, 问题很快从 “能不能传” 变成 “能不能稳定地传”: send loop 不能卡死, notification 不能丢, 多线程下请求元数据不能踩坏, 出错了得知道到底是谁先 abort 的, metric 至少要能告诉你这一层每一段时间花在哪里.
blade-kvt 的设计还是挺克制的, 我喜欢. 整个传输模块分四层: 接入层 / ParseBlock 层 / 控制层 / 数据面. 接入层关心 tensor 和 cuda event, 数据面只关心怎么把字节写到对端显存, 中间用一个三十字节的 IpcBlock 把两边缝起来: 数据面不需要懂模型, 只需要回答”源端这一段 byte 写到目标端那一段 byte”. 这个抽象后来跨越了 BladeLLM 和 vLLM 两套框架, 从朴素的 MHA 到 GQA 到 MLA 到 DSA 再到 GDN 一直没变过.
现在回头看, 这背后其实是一段非常具体的成就感. 从对推理引擎只有零散概念, 到能在脑子里把请求生命周期, scheduler, worker, KV cache 一路连起来, blade-kvt 这一段给我最大的收获不是写了多少代码, 而是真正摸到了 PD 分离这件事的底. 你得先知道 KV 在引擎里是怎么被切开的, 传输是怎么被触发的, 错误是从哪里冒出来的, 后面再去设计更高一层的抽象时才不会飘.
接过 vLLM: 开始负责端到端
后来团队决定迁到 vLLM。老板给了我一个很大的机会:不只是继续做传输库,而是让我全权负责 vLLM PD 分离从框架到传输的端到端实现。这对我来说是一个真正的转折点。之前更多是在已有框架里补一块能力,现在要回答的是一个更大的问题:
一个 LLM 引擎到底应该以什么姿势接入 PD 分离?
我后来一直喜欢用 Linux 内核和驱动来类比 vLLM 与 PD 分离的关系。内核应该提供稳定、克制、通用的核心接口;驱动根据具体硬件和业务场景实现复杂逻辑。反过来,如果一个驱动为了自己方便,把大量硬件特有逻辑塞进内核主链路,那系统最后一定会越来越难维护。
PD 分离也是一样。KV Cache 的加载、保存、发送当然重要,但它不应该把 scheduler 主流程搅重。对于 scheduler 来说,一个请求要么还不能参与调度,要么已经具备了继续执行的条件。至于中间 KV 是怎么从远端搬来的,是 RDMA 还是 TCP,是直接写显存还是走某个 store,这些细节最好都收敛在 connector 和 backend 自己的状态机里。
这就是 HybridConnector 的出发点。它不是传输内核,也不是 scheduler 本身,而是 vLLM 里的一个异步 KV 搬迁 runtime。引擎主链路只暴露少量必要动作,比如开始 load、逐层 save;真正复杂的请求生命周期、block 引用计数、load/save/send done 聚合、abort、bypass,都在 connector 和 backend 里异步推进。
我个人最喜欢这套设计的一点,是它把请求生命周期和 KV 生命周期拆开了。请求结束,并不意味着它的 KV Cache 立刻可以释放,因为远端可能还在读,或者 P 节点还在往 D 节点写。于是我们复用 vLLM 的 block refcnt:传输开始前保护 block,传输真正结束后再释放。这样 scheduler 不需要为了 PD 分离维护一堆额外状态,KV Cache 也不会因为请求提前结束而被误回收。感兴趣的同学可以继续阅读 大家好, 我是练习AI一年半的。
从“好了没”到“我好了“
迁到 vLLM 后,还有一个让我很开心的演进:终于有机会把早期的 busy polling 改成事件通知。早在 Blade-LLM 阶段我就在折腾把这条链路砍掉, 但真正彻底完成是在 vLLM 端到端实现里。
早期 BladeLLM 链路里,上层需要通过 check_transfer_done、check_recv_done 之类接口反复查询传输是否完成。这个方式能跑,也足够直观,但语义上总觉得别扭。系统像一个人站在门口不停问:“好了没?好了没?”真正有意义的信号其实不是“我又查了一次”,而是“数据面已经完成了这个动作,可以推进下一步”。
后来的路径就顺多了。worker 在每层 attention 结束后记录 CUDA event,底层传输线程等待 event ready,再按 layer 推进发送;发送真正完成之后,通过 SEND_DONE RPC 主动通知 scheduler。系统从“我一直问你好了没”,变成“你真好了再叫我”。
这件事在性能上重要,在工程审美上也重要。它让 PD 分离从“外部附着在 step 旁边的一套轮询逻辑”,变成了“跟随模型计算时序自然推进的一条数据面流水线”。对一个做过数据库执行器的人来说,这种感觉很熟悉:不要让控制面反复猜测数据面发生了什么,而要让数据面的关键状态变化变成明确事件。
Bye! ZMQ!
我超级不喜欢 ZMQ 那种流式黏连的状态交互. 表面上你以为只是在传一条消息, 实际把连接状态, 对端状态, 重试语义, 消息边界全部揉成一团. 相比之下我更喜欢 RPC 那种一去一回, 无状态, 边界清楚的交互方式. 在做控制面的时候, 我顺手在 vLLM 里加了一个朴素到名字都很诚实的小模块, A simple RPC implementation, 消息格式简单到只有 4 字节 head 加 body,然后根据 head 分发到不同 callback:
# A simple RPC implementation
#
# Message Format:
#
# +------+------------+
# | head | body |
# +------+------------+
#
# head: 4bytes,
# body: based on the value of "head".
RpcMethodType = Callable[
[asyncio.StreamReader, asyncio.StreamWriter], Coroutine[Any, Any, None]
]
它一开始真的只是一个很朴素的内部 RPC 小工具,名字也写得很随意。但后来 load done、save done、send done、prefill、transfer_kv、abort 等链路都逐渐依赖它。一个看起来临时的胶水实现,就这么一路跟着系统长大,最后支撑了很大规模的线上链路。
(小字:我心中)最好的 PD 分离
直到现在,在我了解地众多开源/闭源 PD 分离实现方案中,我仍然觉得我们这套 PD 分离实现是目前已知业界最优雅、效率最高的实现。(小字:可能是)。
这句话当然有点自夸,但我觉得这里可以自夸一下。因为它背后是一整套我认可的系统设计:runtime/backend 分层、请求生命周期和 KV 生命周期解耦、事件通知、简单 RPC、按模型 layout 扩展 ParseBlock、异常路径有明确完成语义。这些东西合在一起,才是我真正满意的部分。
水到渠成
这一阶段的工作往前走, 我自己也往前走了一步. 晋升这件事如果写得太多容易变成材料复述, 所以我只想简单记录: 首先当然要感谢老板给机会,也感谢团队给了足够真实的战场。很多时候,一个人能不能做出东西,不只取决于能力,也取决于有没有一个足够大的问题可以承担。对我来说, 这个结果并不是某一天突然发生的, 它更像是前面那些具体工作的自然累积. 一次确认, 然后继续往前走.
真正让我印象更深的是,晋升结果还没出来,下一段路已经在敲门。那段时间我本来想着先缓一缓,老板忽然提了一句 “好想把你调过去搞 rl”。于是故事又拐到了下一站。
接触 RL
说实话, 刚听到 RL 的时候, 脑子里第一反应仍然是那些公式: policy, reward, advantage, PPO, GRPO. 理论当然还是要补, 所以后面我也陆续写了 Reinforcement-Learning 学习笔记 和 对 PPO-clip/penalty 一种理解。但我真正进入这个问题的方式, 还是先看数据流. 后训练里 rollout 会生成 samples / trajectories, 训练侧再消费 prompt, response, logprob, reward, advantage. 模型生成和训练之间, 并不是抽象的 “算法模块调用另一个算法模块”, 而是一条非常具体的数据生产和消费链路: 数据在哪里产生, 在哪里缓存, 什么时候被读取, 读完以后谁释放, 跨 worker 传输时复制了几次. 这些问题一摊开, 又回到了我熟悉的系统世界.
很快, 一个现象变得很显眼: 链路里大量使用 ray.put / ray.get 来交换对象.
Ray 是一个很好的分布式执行框架, 我自己也用了很多年, 所以反而清楚 object store 不是 “免费传 Python 对象” 的魔法. 对象要被序列化, 要进入 object store, 要被引用, 传输, 反序列化. 对于控制面对象这当然很方便; 但当 rollout 数据规模变大, 对象生命周期很短, 生产消费节奏很紧的时候, 把它当成高性能数据中转站, 就会开始暴露问题. 这种判断也并不来自 RL 经验, 而是来自数据库里那些再熟悉不过的场景: 一个通用机制在抽象上很优雅, 一旦放到某条高频, 重数据, 短生命周期的路径上, 就会变成瓶颈. 你不能只看 API 好不好用, 还要看它背后的内存模型, 复制路径和生命周期语义.
于是我们尝试性把 Mooncake 接进来. 结果比预期更明显, 第一版接入就跑出了非常可观的提升.
这之后的事情就由需求推着往前走了. 在 RL 场景下我们又重新审视了 unify memory pool, local master, softpin 语义, evict 策略, 主动释放 lease, 包括给开发自己用的 fault inject. 这些演进过程我已经在 RL 下 Mooncake Store 演进分析 那篇里展开过, 这里不再重复.
其实我现在忽然觉得:RL 有点像数据库。工程同学提供基本算子和数据分发能力,有点像执行器里的 operator 和 Motion;研究员表达训练意图,系统再把这些意图组织成可以执行、可以扩展、可以容错的计划。这里面当然没有一个传统意义上的 SQL optimizer,但那种“把意图变成高效执行”的味道很像。
再出发
写到这里,“转型”其实已经讲得差不多了,但“再出发”才是我现在更真实的感受。Qwen 3.7 的效果已经很不错了,站在训练链路里看这些变化,会更直观地感受到模型能力提升背后,是一整套系统、算法、数据和工程能力在一起往前走。对我来说,转到 RL 后训练 infra,并不是从一个熟悉领域离开,而是又一次站到一条新链路的起点上。
下一代模型也已经在路上。它会需要更大的训练规模、更高效的 rollout、更稳定的数据链路和更顺滑的工程基础设施。刚好这些问题都很具体,也都足够有挑战。前面从数据库到推理,再从推理到 RL,每一次切换都有点像重新开局;但回头看,之前积累的系统直觉并没有丢掉,只是换了一种方式继续发挥作用。
所以!团队招人!感兴趣同学联系我!