两年前写《从数据库到 AI》时,我刚结束数学知识重建的第一阶段,准备从数据库内核转去做 AI。当时写了满满一篇,记录自己如何系统性地重建数学知识网络。
前几天翻工作周报,又看到 2024-09-23 那条:“Vllm 链路初步掌握了”。那时还为搞清楚 vLLM 怎么跑起来庆祝了一下。从那天到今天,不知不觉,我在 AI 已经待了两年。这两年里完成了两次转型:一次是从数据库内核钻进 LLM 推理,在百炼把 PD 分离从 blade-kvt 一路做到 vLLM HybridConnector;另一次是今年上半年,又从推理一头扎进千问 RL 后训练 infra。一时间心有所感,趁着记忆还热乎,就想把这段经历记下来。
blade-kvt
PD 分离最早那一版还是基于 BladeLLM。分工很自然:框架层、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/KDA,一直没变过。
现在回头看,这背后其实是一段非常具体的成就感。从对推理引擎只有零散概念,到能在脑子里把请求生命周期、scheduler、worker、KV Cache 一路连起来,blade-kvt 这一段给我最大的收获不是写了多少代码,而是真正摸到了 PD 分离这件事的底。你得先知道 KV 在引擎里是怎么被切开的,传输是怎么被触发的,错误是从哪里冒出来的,后面再去设计更高一层的抽象时才不会飘。
HybridConnector
后来团队决定迁到 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 里异步推进。对于推理引擎来说,其主链路完全不需要考虑任何 PD 分离实现细节。
我个人最喜欢这套设计的一点,是它把请求生命周期和 KV 生命周期拆开了。请求结束,并不意味着它的 KV Cache 立刻可以释放,因为远端可能还在读,或者 P 节点还在往 D 节点写。于是我们复用 vLLM 的 block refcnt:传输开始前保护 block,传输真正结束后再释放。这样 scheduler 不需要为了 PD 分离维护一堆额外状态,KV Cache 也不会因为请求提前结束而被误回收。感兴趣的同学可以继续阅读 大家好, 我是练习AI一年半的。
不要轮询!
迁到 vLLM 后,还有一个让我很开心的演进:终于有机会把早期的 busy polling 改成事件通知。早在 BladeLLM 阶段,我就在折腾怎么把这套轮询砍掉,但直到负责 vLLM 端到端实现,才把这件事彻底做完。
早期 BladeLLM 链路里,上层需要在每个 step 调用 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]
]
# Body: see utils.
SEND_DONE_REQ = 0x20181218
SEND_DONE_RESP = 0x81218102
它一开始真的只是一个很朴素的内部 RPC 小工具,名字也写得很随意。但后来 load done、save done、send done、prefill、transfer_kv、abort 等链路都逐渐依赖它。一个看起来临时的胶水实现,就这么一路跟着系统长大,最后支撑了很大规模的线上链路。
Rapid transfer_kv
我们这套 PD 分离最直观的一点是:请求可以先发到 P,立即开始 prefill,之后再被调度到 D;D 准备好后,通过 transfer_kv RPC 从 P 拉取 KV Cache,接续 decode。P 开始计算,不需要先等 D 的调度和准备完成。这样,前面的计算可以先跑起来,后面的调度保留自己的空间,两件事不必从一开始就绑在一起。
在 KV Cache 传输期间,请求对 D scheduler 完全不可见;只有传输完成后,请求才会进入 D scheduler。它不需要在每个 step 都追问一句:“KV Cache 好了没啊?”就这部分等待带来的调度开销而言,可以说是 zero overhead。
接下来还得解决“D 已经准备好了,KV 能不能赶紧过来”:P 能不能尽快响应 D 发来的 transfer_kv RPC 请求?如果传输总要等到 P 的下一个计算 step 才能开始,这份调度自由就会被额外等待吃掉一截。为此我们做了 Rapid transfer_kv,让传输能够在 P 当前的 forward 计算过程中及时启动,把已经算好的 KV 尽早送出去,减少单纯等待 step 边界的时间。效果很朴素:该计算的时候计算,该传的时候就传,让 D 尽早接上。
(小字:我心中)最好的 PD 分离
直到现在,在我了解的众多开源、闭源 PD 分离实现方案中,我仍然觉得我们这套 PD 分离实现是目前已知业界最优雅、效率最高的实现。
这句话当然有点自夸,但我觉得这里可以自夸一下。因为它背后是一整套我认可的系统设计:runtime/backend 分层、请求生命周期和 KV 生命周期解耦、事件通知、简单 RPC、按模型 layout 扩展 ParseBlock、异常路径有明确完成语义。这些东西合在一起,才是我真正满意的部分。
HybridConnector 这套设计后来很自然地走出了 PD 分离。KVS 要在显存和共享存储之间搬 KV,请求迁移要在原节点和新节点之间搬 KV,它们都有同一个核心需求。HybridConnector 用统一的方式承载了这些搬迁,让后续能力可以沿着同一套抽象生长。
这份私心,来自很具体的成就感:从底层传输开始,一路做出一套调度上有自由、传输上少等待、面对新需求还能继续复用的系统。尤其是后来一个新场景出现时,发现当初想清楚的边界仍然管用,那种开心,很难被一张性能图完全表达出来。
水到渠成
这一阶段的工作往前走,我自己也往前走了一步。晋升这件事如果写得太多,容易变成材料复述,所以只想简单记上一笔。首先当然要感谢老板给机会,也感谢团队给了足够真实的战场。很多时候,一个人能不能做出东西,不只取决于能力,也取决于有没有一个足够大的问题可以承担。对我来说,这个结果更像是前面那些具体工作的自然累积。一次确认,然后继续往前走。
真正让我印象更深的是,晋升结果还没出来,下一段路已经在敲门。那段时间我本来想着先缓一缓,老板忽然提了一句:“好想把你调过去搞 rl。”于是故事又拐到了下一站。
RL
刚接触强化学习(RL),脑子里先冒出来的还是 policy、reward、PPO、GRPO。理论当然要补,后来也陆续写了 Sutton Reinforcement Learning: An Introduction 学习笔记和对 PPO-clip/penalty 一种理解。工程上,我先从数据流进入:rollout 生成样本与轨迹,训练侧再消费这些数据。这中间,数据在哪里缓存,跨进程复制几次,什么时候可以释放?这些问题一摊开,又回到了熟悉的系统世界。
有时我觉得 RL 很像数据库。工程同学提供基本算子和数据分发能力,算法老师们表达训练意图,系统再把这些意图组织成可以执行、可以扩展的过程。以前做执行器时积累的直觉,在这里还挺好用。
Suncake
最初看到链路里大量通过 ray.put / ray.get 交换对象,我就想仔细看看背后的代价。Ray 是一个很好的分布式执行框架,我自己也用了很多年,所以反而清楚 object store 不是“免费传 Python 对象”的魔法。对象要被序列化,要进入 object store,要被引用、传输、反序列化。对于控制面对象,这当然很方便;但当 rollout 数据规模变大、对象生命周期很短、生产消费节奏很紧时,把它当成高性能数据中转站,就会开始暴露问题。
这种判断来自数据库里那些再熟悉不过的场景:一个通用机制在抽象上很优雅,一旦放到某条高频、重数据、短生命周期的路径上,就可能变成瓶颈。你不能只看 API 好不好用,还要看它背后的内存模型、复制路径和生命周期语义。
于是我们尝试着把 Mooncake 接进来。结果比预期更明显,第一版接入就跑出了非常可观的提升。这之后,事情就由需求推着往前走了。在 RL 场景下,我们又重新审视了 unify memory pool、local master、softpin 语义和 evict 策略,做了主动释放 lease,也补了给开发自己用的 fault inject。这些演进过程,我已经在《Qwen3.8: 从 0 到 1 用 Rust 重写 Mooncake: Suncake》里展开过,这里不再重复。
R3
在做 Qwen QSA indexer R3 时,我也遇到了一个很有意思的问题。简单说,推理时稀疏 attention 选中了哪些位置,需要录下来交给训练回放,尽量减少这部分离散选择造成的训推差异。一条长轨迹附带的 top-k 数据就能到 GB 级,后面还要经过多轮样本整理、调度和训练布局变换,搬起来相当沉。
把整条链路看过一遍后,我发现很多中间组件只关心 token 属于哪个样本、被排到哪里,却一直带着它们并不需要理解的大张量。那就让这些环节只携带引用,真正的数据放在共享存储里,等训练确定了最终位置、确实要消费时再读回来。
这样一来,控制链路中流转的内容从 GB 级 payload 变成了 KB 级引用清单。实际数据当然还在,也仍然需要写入和读取,但不必再陪着中间环节来回搬运。最终,相对关闭 indexer R3,完整开启录制、存储、读取和回放后的 step 额外耗时不足 7%。具体过程见《RL r3 的超高校级的实现》。这件事最让我高兴的,是关键判断发生在写代码之前:先弄清楚每一跳到底需要什么,再决定哪些数据应该经过它。这个问题想清楚,后面的实现才有了方向。
Zero overhead memory profile
还有一件事,是给 RL 补了一套能常开的 memory profile。这次关心的是 CPU 内存:Python 对象和 native buffer 在长时间运行中慢慢堆起来,曲线告诉你内存在涨,却不告诉你是谁分配的。等到 OOM 把进程杀掉,现场也没了。所以我需要周期性留下快照,至少能翻到进程被杀前最后一份记录,看看哪些分配栈对应的内存一直没有降下来。
最初试了 Memray,又尝试给它加采样。追下去才发现,减少内存事件的记录,并没有消掉持续跟踪 Python 调用的成本。于是我换了方向:平时不持续维护调用栈,真正抽中一次内存分配时,再抓取当时的 Python 栈;采样、释放记账和快照导出则复用 jemalloc 已有的能力。
最后在我们实际跑的 RL 任务里,开着它,端到端 step 耗时基本没增加,甚至还略降了一点。这就是我说的 zero overhead memory profile。采样、抓栈和导出都有成本,只是换用 jemalloc 的分配性能收益把这些成本抵消了。能够一直开着,才有机会等到那些跑很久才出现的问题。详细过程放在《大规模 RL 下 oom profile 实践》里。
写到这里,忽然又和前篇接上了。当年就是因为看不懂 jemalloc 的采样原理,我才开始补概率、补微积分,最后一路重建数学知识。那时只觉得终于看懂了,真开心,还兴高采烈地写了篇《Jemalloc Profile 背后的数学原理》。几年后,这些知识竟然跟着 RL 任务上班了。以前担心学过的东西会被遗忘,现在觉得,它们可能只是在等一个合适的问题。
置身 AI 内
AI 真是令人惊叹的技术突破。作为一个多次从零踏入新领域的人,我太知道卡在一个概念上的滋味了。还记得重建数学知识体系时,一个关键概念无法理解,一个证明步骤卡住,整个学习进程就会停下来。那时我不得不辗转于各大高校数学学院的 QQ 群,向陌生人求助;熬夜写邮件咨询;在 Math Stack Exchange 上等待解答。每一次,都可能要等很久,才能等到一句让自己继续往下走的提示。AI 改变了这种体验。它像一位随时可以请教的老师,有耐心陪我拆解概念、检查推导,同一个问题可以换几个角度反复讨论。对一个不断进入新领域的学习者来说,这种即时获得帮助的体验,实在太珍贵了。
而当实现一个想法变得容易许多,我也越来越在意:那个想法究竟从哪里来?我在日记里写过一个判断标准:一个人做出来的东西,有没有自己的想法、自己的品味?是他在驱动 AI,还是被 AI 带着走?所谓品味,在我这里很具体:愿意为哪些能力保留空间,认为哪些复杂性值得承担,遇到一个现成方案时,有没有理由接受它,又有没有理由改掉它。PD 分离里,我希望计算和调度各自有空间;Mooncake 里,我希望存储围绕 RL 的生命周期工作;R3 里,我先问大张量为什么必须经过这些中间环节。这些判断做出来以后,AI 才能帮我把它们更快地变成现实。
借助 AI,大家都可以很快 rush 出一个版本,我也一样。但速度很容易让人满足于“已经能跑了”,省略对问题本身的追问。我会提醒自己,代码生成得再快,仍然要能解释为什么这样做,知道哪里做了取舍,也愿意对结果负责。我希望最后留下的东西,能看出自己的思考。
回头看这两年多,从数据库到推理,再到 RL,每一次切换都有重新开局的忐忑,也都有终于把问题想明白的兴奋。以后还会遇到新的模型、新的系统问题,我大概也还会继续补课、记笔记,偶尔为一个想通的小问题高兴半天。与此同时,我面临的另一重修行,是在喧嚣浪潮中学会与内心的浮躁对话,沉静深耕。说来奇妙,这仿佛成了某种温柔的呼应:2021 年数据库领域炙手可热时,我正埋首其中,时常收到各方邀约与咨询;而当我转身投入 AI 领域,它也恰在此时站上技术浪潮的潮头。相似的际遇再度悄然降临。这些经历反倒提醒我——真正的成长,不在于身处何方,而在于能否在热闹中守住专注,在浮华里沉淀价值。