对应 Maps to:Stanford CS349D AI Inference Infrastructure(Spring 2026),#14 Wed May 13 Load Balancing & Routing。必读:V. Srivatsa, Z. He, R. Abhyankar, D. Li, Y. Zhang, Preble: Efficient Distributed Prompt Scheduling for LLM Serving(ICLR 2025);L. Chen, J. Q. Davis, B. Hanin, P. Bailis, M. Zaharia, J. Zou, I. Stoica, Optimizing Model Selection for Compound AI Systems(LLMSelector,2025)。 依据说明:原课没有公开 slides。本讲依据两篇论文的全文(含附录),LLMSelector 算法里”平局怎么处理”参考了作者开源的实现(
llmselector/compoundai/optimizer.py)。§1 的经典负载均衡(球放进盒子、二选一、过时信息)、§2 的”集群的总缓存”分析、§5 的综合和 §6”之后的发展”是我补充的;§6 里 SGLang 路由器的算法依据它开源代码里的说明。讲义里的纸上数字全部来自配套代码的实际运行。 配套代码 Code:code/lecture11/(numpy;4 个脚本 + 2 个模块:cluster_router.py是带前缀缓存的多副本集群模拟器,llm_selector.py是复合 AI 系统的玩具世界;代码 03 约 55 秒,其余不到 20 秒) 预计阅读时间:3–4 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标 Learning goals
学完这一讲,你应该能够:
- 说清多副本集群里经典的派发策略(随机、轮转、最短队列、二选一、最少剩余工作)各自需要什么信息、好在哪里、在什么情况下会出事(请求大小重尾、负载信息过时)。
- 解释为什么前缀缓存让 LLM 的路由变难:只看负载会让每个副本都缓存同一批东西(集群的总缓存 = 一个副本的缓存),只看缓存会把请求吸到同一个副本上(扎堆)。
- 说清 Preble 的设计:全局基数树 + 每卡本地调度的两层结构;E2 的”利用 vs 探索”规则(缓存的 token 多于没缓存的就利用);负载代价 L + M + P;事后再平衡与自动扩展;用前缀共享来搭配 prefill 和 decode;本地的优先级组。
- 解释 LLMSelector 要解决的问题(复合 AI 系统里每个模块用哪个模型)、它的两个经验观察(端到端表现随模块表现单调、模块表现可以由 LLM 诊断),以及”一次改一个模块”的算法为什么能跳出端到端贪心的局部最优。
- 区分三个层次的”路由”:请求 → 副本(负载与缓存)、请求 → 模型(质量与成本)、模块 → 模型(复合系统),并说出各层需要的信息。
0.2 先修与本讲范围 Prerequisites & scope
| 默认你已经会 Assumed | 本讲只给直觉,后面再展开 Previewed only |
|---|---|
| 第 2 讲:连续批处理、迭代级调度 | 多模型共享 GPU、冷启动(第 12 讲) |
| 第 3 讲:前缀缓存、RadixAttention 的基数树与 LRU | 智能体程序的调度(第 13 讲) |
| 第 7 讲:分块 prefill、prefill / decode 的不同瓶颈 | SGLang 路由器的工程细节(第 16 讲) |
| 第 9 讲:Mooncake 的缓存感知调度、链式哈希 | 排队论的推导(前置讲 P4) |
0.3 本讲地图 Map
§1 集群里的经典负载均衡:随机、轮转、最短队列、二选一、过时的信息 ← 代码 01
§2 前缀亲和 vs 负载均衡:为什么 LLM 的路由变难 ← 代码 02
§3 Preble:E2 调度、负载代价、再平衡、本地优先级 ← 代码 03
§4 LLMSelector:复合 AI 系统里每个模块用哪个模型 ← 代码 04
§5 三个层次的路由
§6 之后的发展
§7–11 小结、常见误区、练习、延伸阅读、下一讲预告
0.4 记号 Notation
| 符号 | 含义 |
|---|---|
| 副本数(数据并行的模型实例数;每个副本可以是一块 GPU,也可以是张量并行的几块) | |
| 利用率 = 到达率 ÷ 总服务能力 | |
| cached / missed | 一个请求的提示里,已经在某个副本上缓存的 token 数 / 没缓存的 token 数 |
| Preble 的历史窗口(默认 3 分钟) | |
| Preble 的负载代价三项:副本 窗口内的计算量、为放下新请求要淘汰的缓存的重算代价、新请求在 上的 prefill 时间 | |
| Preble 再平衡的阈值:最忙副本的负载超过最轻副本的多少倍时开始分流 | |
| 、(§4) | 候选模型数、复合系统的模块数;分配 把每个模块映射到一个模型,共 种 |
| (本讲代码里)诊断器判断正确的概率 |
0.5 关于数字 · About the numbers
本讲的数字有三类:(1) 派发模拟:N 个先来先服务队列(代码 01),与 LLM 无关,单位是”平均服务时间”;(2) 集群模拟:每个副本是一块 A6000 上的 Mistral-7B(Preble 的实验设定),每一步的时间来自第 1 讲的纸上 GPU(roofline),副本内部做连续批处理 + 分块 prefill + 段级前缀缓存,路由器按不同策略派发;负载是仿照 Preble 表 1 的合成负载(平均长度和共享比例相近,热度分布、文档个数等是假设值);(3) 玩具世界:LLMSelector 的模型和题目都是生成出来的,模型名 A–E 不代表任何真实模型。论文里的数字写成”论文报告”。
1. 集群里的经典负载均衡 · Classic load balancing
1.1 为什么要有路由器
一个模型上线以后,几乎从来不只部署一份。原因有三:一块(或一组)GPU 的吞吐有限,流量大了要加副本;副本之间互为备份,坏一个不至于停服;不同地区的用户要就近服务。这些副本是数据并行的(第 6 讲):每个副本都有完整的模型,彼此独立地处理请求。于是集群前面需要一个路由器(也叫负载均衡器、网关),每来一个请求就决定”交给哪个副本”。
这个问题在 LLM 之前就研究了几十年(Web 服务器、数据库、搜索后端)。先把经典答案过一遍,因为 LLM 路由器的大部分想法都从这里来,它们的失败方式也会在 LLM 里重演。
1.2 五种派发策略
| 策略 | 需要什么信息 | 直觉 |
|---|---|---|
| 随机(random) | 什么都不需要 | 最简单;但运气不好时会连续派给同一台 |
| 轮转(round robin) | 只需要一个计数器 | 依次派给 0, 1, …, N−1;每台收到的请求数完全一样多 |
| 最短队列(JSQ,join the shortest queue;也叫 least connections) | 每台当前有几个请求 | 谁闲派给谁 |
| 二选一(power of two choices) | 随机抽 2 台的队列长度 | 只比较两台,就能拿到最短队列的大部分好处 |
| 最少剩余工作(LWL,least work left) | 每台还剩多少活(要知道每个请求的大小) | 比”排了几个”更准 |
**二选一为什么这么有效?**看一个更简单的模型:“球放进盒子”。把 个球依次放进 个盒子:纯随机放时,最满的盒子大约有 个球;每个球随机看两个盒子、放进较空的那个,最满的盒子骤降到约 (Azar 等 1994)。从”不看”到”看两个”是指数级的改善,再多看几个只改善常数。代码 01 演示 1:
| 盒子数 | 随机 | 二选一 | 三选一 |
|---|---|---|---|
| 1,000 | 5.4 | 3.0 | 2.4 |
| 100,000 | 8.2 | 3.4 | 3.0 |
排队。真实系统里请求有大小、会离开,派发器面对的是 个队列。代码 01 演示 2 模拟了 16 台先来先服务的服务器(单位是平均服务时间,格式”平均 / P99”):
| 利用率 0.9 | 随机 | 轮转 | 最短队列 | 二选一 | 最少剩余工作 |
|---|---|---|---|---|---|
| 请求大小指数分布(cv = 1) | 11.56 / 56.7 | 5.59 / 26.5 | 1.63 / 6.7 | 2.91 / 9.7 | 1.42 / 5.4 |
| 对数正态,cv = 3(更像 LLM) | 43.31 / 471.6 | 49.69 / 603.4 | 2.89 / 24.4 | 6.89 / 42.8 | 2.34 / 13.8 |
三个要点:
- 看负载的策略远好于盲目的策略。随机派发时每台服务器就是一个独立的 M/M/1 队列,平均响应时间 ,利用率 0.9 时约 10 倍服务时间(前置讲 P4);最短队列把 16 台服务器”拼”成了一个共享的队列,几乎不排队。
- 轮转的好处很脆弱。指数分布下它比随机好一倍(每台的到达更均匀);可一旦请求大小重尾,排队主要来自”少数超大请求挡在前面”,到达是否均匀不再重要:代码里换 8 个种子平均,两者相差在噪声以内。LLM 请求的长度就是重尾的:几十 token 的闲聊和几万 token 的文档问答混在一起。
- “排了几个”不等于”还有多少活”。请求大小相差悬殊时,最短队列的 P99 明显变差,知道请求大小的最少剩余工作好得多。所以 LLM 路由器常按 token 数(而不是请求数)估计负载。
1.3 过时的信息与扎堆
最短队列有一个隐含前提:路由器知道此刻每台服务器的队列长度。真实系统里这个信息往往是定期拉取的监控数据,或者是路由器自己的估计。Mitzenmacher(2000)研究了”信息过时”会怎样,代码 01 演示 3 复现了他的结论(利用率 0.9):
| 刷新间隔 | 随机 | 最短队列 | 二选一 |
|---|---|---|---|
| 实时 | 11.56 | 1.63 | 2.91 |
| 4 个服务时间 | 11.56 | 13.53 | 4.07 |
| 16 个服务时间 | 11.56 | 45.95 | 6.86 |
信息一过时,最短队列就扎堆(herding):快照里最短的那台,在下一次刷新之前收下所有请求,刷新后又换一台被淹没。刷新间隔是 4 个服务时间时它已经比随机还差。二选一几乎不受影响:每个请求只在随机抽到的两台里比较,不同请求抽到的不同,天然把流量分散开。
记住这个模式:“所有请求都根据同一份信息做同一个选择”,就会扎堆。§2 会看到,前缀亲和制造了同样的扎堆,只不过信息是”谁有我的前缀”。
1.4 LLM 推理和经典模型有什么不同
把经典结论搬到 LLM 上,要注意四点不同:
- 请求大小相差上百倍,而且事先只知道一半:提示长度到达时就知道,输出长度要等生成完才知道(第 1 讲)。
- 副本内部不是先来先服务:连续批处理每一步都重新组批(第 2 讲),多个请求同时在一个副本上推进,“队列”的概念变成了”这个副本的批有多满、显存还剩多少”。
- 显存是第二种资源:一个副本可能算力空着、显存却被 KV 占满,新请求只能等(第 3 讲)。
- 副本不再一模一样:每个副本都有自己的前缀缓存(第 3 讲),同一个请求在缓存了它前缀的副本上只需算一小段,在别的副本上要从头 prefill。服务时间取决于”派给谁”——这是经典模型里没有的,也是本讲的核心难题。
2. 前缀亲和 vs 负载均衡 · The cache–balance dilemma
2.1 真实负载:提示又长又共享
Preble 的出发点是一项负载研究(论文 §2 和附录 A)。它分析了五类 LLM 应用的公开数据集和 Azure 的 LLM 请求 trace(论文报告):
| 负载 | 平均提示 : 输出(token) | 提示里共享的比例 | 共享来源 |
|---|---|---|---|
| 工具调用(ToolBench) | 1835 : 43 | 85% | 同一个工具的说明和示例 |
| 具身 agent(ALFWorld) | 2285 : 16 | 97% | 任务示例 + 之前每一步的交互 |
| 程序生成(APPS) | 3871 : 190 | 97% | 通用代码示例 + 同一题的并行采样 |
| 视频问答(NExT-QA) | 9865 : 4 | 88% | 同一段视频的 token |
| 长文档问答(LooGLE) | 23474 : 16 | 91% | 同一份文档 |
四条结论:(1) 提示比输出长得多(Azure 的聊天 trace 平均 4 倍,视频问答 2,494 倍),所以 prefill 是大头;(2) 共享很普遍、而且比例很高;(3) 大多数请求有一段”关键部分”(key portion)——前缀树里比它所有祖先加起来还长的那个节点,例如文档本身,它决定了这个请求该和谁放在一起;(4) 真实流量的强度变化很大(Azure trace 里相邻请求的间隔从 2 微秒到 217 秒)。
所以前缀缓存(第 3 讲)在这些负载上价值巨大。问题是:缓存在每个副本上各有一份,路由器的决定直接决定了能不能命中。
2.2 两种极端都不行
只看负载(轮转、最短队列……):请求被均匀地撒到所有副本上。每个副本都会看到所有文档、所有工具,于是每个副本都试图缓存同一批热门内容,冷门内容在哪里都留不住。代码 02 演示 1(4 个副本,长文档问答 1 请求/秒):
| 路由 | 平均延迟 | 命中率 | 复制倍数 | 各副本完成的请求 % |
|---|---|---|---|---|
| 轮转 | 9.89 s | 0.32 | 1.34 | 25/25/25/25 |
| 最少剩余 token | 6.93 s | 0.33 | 1.34 | 27/24/25/23 |
| 纯前缀亲和 | 658.62 s | 0.33 | 1.00 | 0/0/0/100 |
| 命中过半才亲和 | 3.88 s | 0.58 | 1.00 | 24/22/38/16 |
(复制倍数 = 各副本缓存的 token 之和 ÷ 去重后的 token;1.00 表示每份内容只存在一个副本上。)
只看缓存(纯前缀亲和:总派给前缀匹配最长的副本):结果是灾难性的——所有请求都去了同一个副本。我原以为是热门文档压垮了一个副本,实际原因更简单:长文档负载里所有请求共享一段只有 13 个 token 的系统提示。第一个请求派到哪个副本,那个副本就有了”最长的匹配”(哪怕只有 13 个 token),于是之后的每个请求都被吸过去;其他副本一个请求都收不到,永远没有机会缓存任何东西。这就是 §1.3 的扎堆,只不过信息是”谁有我的前缀”。视频问答、工具调用上也一样(代码 02 演示 1)。
2.3 中间路线:命中够多才亲和
合理的做法是只在”值得”的时候亲和:前缀匹配占提示的比例够高(例如过半)才派给匹配最长的副本,否则按负载派。上表最后一行就是这个策略:命中率从 0.33 升到 0.58,平均延迟比最好的只看负载的路由低 1.8 倍。三种负载上低 1.4–2.3 倍。
阈值取多少?代码 02 演示 3 扫描了 0.1 到 0.9:在 0.25–0.75 之间结果几乎一样,因为真正值得共享的部分(文档、工具说明)都占提示的大头;太低(0.1)时”只共享系统提示”也会触发亲和,扎堆又回来了;太高(0.9)时长文档的问题部分(约 9%)就让很多请求够不上。Preble 的 E2 用的正是 0.5,并给了一个漂亮的理由(§3.2)。
2.4 真正的收益:集群的总缓存
前缀感知路由最根本的好处,不是”命中同一个副本”这么简单,而是让缓存容量随集群规模增长。代码 02 演示 2 把副本从 2 个加到 8 个(请求率同比例增加):
| 副本数 | 轮转的命中率 | 命中过半才亲和的命中率 |
|---|---|---|
| 2 | 0.30 | 0.46 |
| 4 | 0.32 | 0.58 |
| 8 | 0.33 | 0.66 |
轮转时每个副本面对的都是”全部文档”,能缓存的永远只是一个副本的显存那么多(Mistral-7B 在 A6000 上约 21.9 万 token,约 10 份平均长度的文档);亲和路由把文档分到不同副本上, 个副本的缓存加起来用。第 9 讲的 Mooncake 用”分布式 KV 池”解决同一个问题——那是把缓存从副本里拿出来共享;这里是在不共享存储的前提下,靠路由把内容分区。
2.5 热点:一个副本扛不住的前缀
分区也有代价:如果某个前缀特别热,它的全部流量都会落到同一个副本上。代码 02 演示 4:工具调用负载里最热的工具约占 40% 的流量,8 个副本(每个只能承担 1/8):
| 路由 | 平均延迟 | 各副本完成 % |
|---|---|---|
| 最少请求 | 1.78 s | 12/12/13/12/12/13/12/13 |
| 命中过半才亲和 | 35.42 s | 7/14/8/7/40/7/9/8 |
| 缓存感知 + 负载保护 | 1.52 s | 13/12/12/13/12/12/13/13 |
“缓存感知 + 负载保护”是 SGLang 路由器思路的简化版:各副本的未完成请求数相差太大(差值超过 8、且最多的超过最少的 1.5 倍)时,暂时只看负载。被分流出去的请求在新副本上把热门前缀算一遍,热门前缀于是自动被复制到多个副本上——命中率和亲和一样高(0.84),延迟比只看负载还低。
教训:前缀亲和必须配一个”太不均衡就放弃亲和”的保护。接下来看 Preble 怎样把这些想法系统化。
3. Preble:前缀感知的分布式调度 · Preble
论文卡片 Paper card · Preble
- 问题:前缀缓存(SGLang、vLLM)都是单 GPU 的优化;生产中的推理是分布式的,按负载均衡派发会把共享前缀的请求撒到不同 GPU 上,各算一遍;总派给同一个 GPU 又会把它压垮。
- 关键想法:E2(Exploitation + Exploration):缓存的 token 多于没缓存的就”利用”已有缓存,否则”探索”负载最轻的 GPU;负载按”GPU 计算时间”统一度量(窗口内的计算 + 淘汰代价 + 新请求的代价),共享的部分记为零;再加上事后再平衡、自动扩展、按前缀命中搭配 prefill / decode、本地的优先级组。
- 结构:全局调度器(请求级,维护全局基数树)+ 每个模型实例一个本地调度器(迭代级)。
- 结果(论文报告):Mistral-7B(2 / 4 块 A6000)和 Llama-3-70B(8 块 H100,TP4 × 2),五种负载,平均延迟比 SGLang 低 1.5–14.5 倍,P99 低 2–10 倍。
- 局限:负载代价依赖离线回归出来的 prefill / decode 时间函数;全局调度器是单点(论文估计一个能撑 70–391 块 A100);对缓存的了解来自异步的淘汰通知。
3.1 两层结构
Preble 的全局调度器做请求级的决定(派给哪个副本),本地调度器做迭代级的决定(每一步组什么批,第 2 讲)。理由:全局的决定需要全局的信息(各副本的负载、谁缓存了什么),但只需要每个请求做一次,一个调度器就能撑几百块 GPU;迭代级的决定要每几十毫秒做一次、要看到 GPU 的实时状态,只能放在本地。
全局调度器的核心数据结构是全局基数树(radix tree,第 3 讲):每个节点记录它有几个 token、哪些 GPU 缓存了它、窗口 内每个 GPU 上有几个请求共享了它。新请求到来时先在树上做前缀匹配,然后把自己插进去——插入发生在派发时,不等 GPU 算完。这一点很关键:并行生成的 8 份代码几乎同时到达,第一份还没算完,后面 7 份就已经知道”这个题目将在 GPU 3 上”,于是跟过去。代码里的 Replica.view_match 就是这个”路由器视角”:缓存里已有的段 + 已经派来、即将算出的段。本地调度器淘汰缓存时异步通知全局调度器,全局树据此更新(实现上大多是只读操作,可以无锁)。
3.2 E2:利用还是探索
对一个新请求,E2(论文算法 1)先在全局树上匹配,得到 cached(最长的匹配)和 missed = 提示长度 − cached:
- cached > missed:利用(exploit)。只在缓存了最长匹配的那些 GPU 里选,挑负载代价最低的。
- 否则:探索(explore)。先看有没有哪个 GPU 被 decode 占满(见 §3.4),有就派给它;否则在所有 GPU 里挑负载代价最低的。
为什么是”cached > missed”?论文的理由很直观:利用能省下的是 cached 个 token 的重算;而这个请求真正”可以拿去平衡负载”的只是它的 missed 部分。省下的比能平衡的多,就利用;反之,这个请求本质上是一个新的大计算,应该交给最需要活干的 GPU。这正是 §2.3 里的阈值 0.5。
3.3 负载代价:L + M + P
“负载最轻”怎么定义?Preble 把三种代价都换算成 GPU 计算时间,并且只在全局调度器里记 token 数,不去测真实时间(论文算法 2):
- :GPU 在最近窗口 (默认 3 分钟)内的计算量 = 窗口内派给它的每个请求的”prefill 时间(只算没命中的 token)+ decode 时间(用窗口内的平均输出长度估计)“。用历史窗口而不是此刻的队列,理由有二:从派发到真正执行,负载还会变;而且一个前缀放在哪里,影响的是将来很多请求,是长期的事。
- :为了放下这个请求,GPU 要淘汰哪些缓存节点(按它的淘汰顺序找出来),每个节点的代价 = 重算它的时间 × 它的命中率(窗口内共享它的请求占比)。越长、越热的节点,淘汰越贵。
- :这个请求在 GPU 上的 prefill 时间(只算 missed 部分)。decode 时间不算,因为在哪里都一样。
prefill 和 decode 的时间都来自离线回归:论文的测量显示,prefill 时间大致与提示长度成正比(线性层占大头),单个请求的 decode 时间大致与上下文长度成正比(论文附录 B)。
**我们的复现发现:三项里 L 一枝独大。**代码 03 演示 2 记录了基本 E2 每次计算代价时三项的平均值:
| 负载 | L | M | P |
|---|---|---|---|
| 工具调用 40/s | 1616.4 s | 0.000 s | 0.078 s |
| 长文档问答 1.5/s | 114.9 s | 0.032 s | 3.009 s |
| 视频问答 6/s | 152.8 s | 0.001 s | 1.030 s |
原因在 decode 项:Preble 用”单个请求 decode 的时间”估计每个请求的 decode 代价(A6000 上 Mistral-7B 每个 token 约 24 毫秒,43 个 token 就是约 1 秒),而真实的 decode 是成批做的、一个请求的边际代价小得多。窗口里几百上千个请求的 decode 估计加起来,把 P 和 M 淹没了。于是探索时 E2 近似于”选最近 3 分钟派得最少的副本”;淘汰代价 M 几乎总是 0(要淘汰的通常是冷门节点)。这不是说 E2 不好,而是说它的效果主要来自规则(利用 / 探索 + 负载均衡),而不是这个精巧的代价模型。
3.4 事后再平衡、自动扩展、prefill / decode 搭配
事后再平衡。E2 是贪心的:一个前缀派到某个 GPU 后就一直在那里,直到被淘汰。如果这个前缀越来越热,所有”利用”它的请求都去同一个 GPU——这正是 §2.5 的热点。Preble 的办法:如果最忙 GPU 的负载超过最轻 GPU 的 倍,就把本该”利用”最忙 GPU 的请求改派给最轻的 GPU。被改派的请求在新 GPU 上重算这个前缀,前缀于是被复制。如果改派之后还是扛不住(窗口内平均排队时间翻倍),就自动扩展:把这个前缀复制到多个 GPU 上,按负载切分它的子树。
代码 03 演示 3 复现了这个需求(最热工具约占 40% 流量,8 个副本,70 请求/秒):
| 配置 | 平均延迟 | P99 |
|---|---|---|
| E2(无再平衡) | 35.44 s | 147.8 s |
| E2 + 再平衡 = 2.0 | 1.58 s | 3.6 s |
| E2 + 再平衡 = 1.2 | 1.55 s | 3.5 s |
没有再平衡的 E2 和”命中过半才亲和”一样崩溃:热门工具只在一个副本上有最长匹配,负载代价只在这些副本之间比较。打开再平衡后崩溃消失,阈值不敏感。(我们没有实现自动扩展。)
用前缀共享搭配 prefill 和 decode。第 7 讲讲过,prefill 是计算密集的,decode 是带宽密集的,两者搭配才能用满 GPU。Preble 的观察是:如果一个请求的提示几乎全部命中缓存,它几乎只剩 decode,可以看作”decode 单元”;一个提示很长、没命中、输出很短的请求则是”prefill 单元”。所以在探索时,如果某个 GPU 上 decode 单元太多(算力闲着),就把这个要重算的请求(prefill 单元)优先派给它。这条规则排在负载代价比较之前。此外本地调度器还照常做分块 prefill(第 7 讲)。
3.5 本地调度器:优先级组
每个副本的等待队列按什么顺序接纳?先来先服务(vLLM)不管缓存;最长前缀优先(SGLang,第 3 讲)可能让命中少的请求饿死。Preble 的折中:按”已缓存的比例”把请求分成 个优先级组(例如 100 个 token 里 63 个已缓存、 = 10,就进第 6 组);组批时按比例从各组取,高优先级的组取得多,例如要取 55 个请求,就从第 10 组取 10 个、第 9 组取 9 个……第 1 组取 1 个。命中多的请求优先,但每个组都有份额。显存不够时按 LRU 淘汰树上的节点(可以只淘汰一个节点的一部分)。
3.6 评测与我们的复现
论文报告:五种负载、两种模型、两种集群上,Preble 比”轮转负载均衡 + 每个实例自己做前缀缓存的 SGLang”平均延迟低 1.5–14.5 倍、P99 低 2–10 倍;编程负载提升最小(平均 1.56–1.8 倍),因为它的输出最长、decode 占比最大;在 Azure trace 的到达模式下混合工具调用和视频问答,平均延迟、P99、TTFT、TPOT 都有数倍改善。消融(论文图 5,工具调用、Zipf 1.1 热度):基本 E2 已经有明显改善,再平衡主要改善 P99,prefill / decode 搭配两者都改善,优先级队列改善 P99 而不改善平均延迟。全局调度器每秒能处理 245(ToolBench,前缀树最复杂)到 2,931(视频问答)个请求。
代码 03 演示 1 是图 3 的纸上版(格式”平均 / P99”,秒;节选):
| 负载 | 请求/秒 | 轮转(基线) | 缓存感知 + 保护 | E2 + 再平衡 | Preble 完整版 |
|---|---|---|---|---|---|
| 工具调用 | 40 | 4.23 / 13.6 | 1.92 / 4.3 | 2.12 / 6.1 | 1.95 / 4.6 |
| 多步 agent | 50 | 0.51 / 2.1 | 0.45 / 1.8 | 0.45 / 1.8 | 0.45 / 1.8 |
| 并行生成代码 | 8 | 9.58 / 78.4 | 7.49 / 61.4 | 7.72 / 62.0 | 7.91 / 66.2 |
| 视频问答 | 4 | 6.69 / 23.4 | 1.74 / 8.3 | 1.90 / 7.0 | 2.02 / 7.3 |
| 视频问答 | 6 | 122.06 / 232.9 | 10.79 / 30.4 | 6.09 / 15.6 | 3.91 / 13.7 |
| 长文档问答 | 1.5 | 52.51 / 106.8 | 6.30 / 24.2 | 7.31 / 26.9 | 9.90 / 37.3 |
| 长文档问答 | 2 | 158.80 / 288.0 | 26.69 / 73.8 | 15.88 / 44.3 | 14.49 / 45.3 |
和论文一致的地方:长文档和视频问答收益最大(共享的上下文长、缓存放不下几份);编程负载收益最小;工具调用和 agent 的共享部分每个副本都放得下,轮转也能命中大部分,收益小。基线和 Preble 的平均延迟之比在 1.0–31.2 倍之间,但比值最大的格子里基线已经过载、队列越排越长,比值取决于模拟时长,不能当作”加速比”。
和论文不一样、值得记住的地方(详见代码 README 的”关于实验结果”):
- 简单的”缓存感知 + 负载保护”在中低负载下不输 Preble,长文档 1/s 时甚至更好(3.88 vs 5.35 秒);Preble 只在最高的几档负载下明显更好,而对比”E2 + 再平衡”和”完整版”两列,那里很大一部分优势来自副本内部的优先级排队(视频 8/s:90.0 → 20.6 秒),而不是路由。
- prefill / decode 搭配可能有害(代码 03 演示 2):工具调用上 P99 从 6.1 降到 4.6 秒,长文档和视频问答上反而变差——它们的”探索”请求是几千到几万 token 的 prefill,塞给 decode 多的副本,会挡住那里正在 decode 的请求。
- 本地优先级组主要改善平均延迟(代码 03 演示 5,视频 6/s:先来先服务 6.09 秒 → 优先级组 3.96、最长前缀优先 3.91),命中少的请求的 P99 和先来先服务时差不多,与论文”改善 P99、不改善平均”正好相反;而且优先级组和最长前缀优先几乎一样。
- 窗口 越长越好一些(长文档 10.09 → 6.76 秒),但没有数量级差别;论文说不敏感。
4. LLMSelector:每个模块用哪个模型 · LLMSelector
论文卡片 Paper card · LLMSelector
- 问题:复合 AI 系统(compound AI system)由多次 LLM 调用组成。现有优化大多调提示词、调模块之间的连接,所有模块用同一个模型。每个模块用哪个模型? 个模型、 个模块有 种分配。
- 两个经验观察:(1) 其他模块固定时,端到端表现往往随某个模块的表现单调变化;(2) 模块级的表现可以由一个 LLM 读执行记录准确判断。
- 算法:轮流提名一个模块,用”LLM 诊断器”估计每个候选模型在这个模块上的表现,换上最好的;直到连续几轮不再变化。每轮的 API 调用数与模型数成正比,总轮数与模块数成正比。
- 结果(论文报告):10 个真实模型(GPT-4o、Claude 3.5 Sonnet、Gemini 1.5 Pro、Llama 3.1 405B 等),三种复合系统、六个数据集,比”所有模块用同一个最好的模型”高 5%–70%,也比 DSPy 的提示词优化(MIPROv2)好。
- 局限:只考虑静态系统(模块数、调用顺序、分配都固定);单调性假设并不总成立;没有考虑成本。
4.1 为什么一门推理系统课要讲”选模型”
前面三节的”路由”是把请求派给同一个模型的不同副本。另一类路由是把请求派给不同的模型:简单的问题给便宜的小模型,难的给大模型(模型路由、级联);一个应用的不同步骤用不同的模型。这直接决定了推理集群的形态——要部署哪些模型、每个模型多少副本、流量怎么分——也决定了成本。LLMSelector 研究的是这类路由里结构最清楚的一种:复合 AI 系统里每个模块的模型分配。
复合 AI 系统的例子(论文图 2、图 5):
- 定位-求解(locate-solve):一次调用从长表格里找到指定 ID 的题目,另一次调用求解;
- 自我改进(self-refine):生成 → 批评 → 改写;
- 多智能体辩论(multi-agent debate):三个生成者各给一个初始答案,三个辩论者讨论哪个是对的。
4.2 问题与假设
把系统写成一个有向无环图,每个节点是一个 LLM 模块。一个分配 给每个模块指定一个模型;目标是最大化端到端表现 ( 是一道题)。为了能分析,论文做了三个假设:表现是二元的(对或错);端到端表现是各模块表现的单调递增函数;模块表现满足两个单调性:
- 模块内单调:对一个模块来说,模型 比 好,不管其他模块用什么都成立;
- 模块间单调:把一个模块换成更好的模型,不会让其他模块变差。
在这些假设下,“每个模块各自选最好的模型”就是全局最优,而且按模块逐个优化, 轮就能收敛(论文定理 4.1,要求每道题的最优分配唯一)。
4.3 算法:一次改一个模块,用诊断器打分
LLMSelector(论文算法 1)的循环:
- 从一个随机分配开始;
- 轮流提名模块 ;对每个候选模型 ,把模块 换成 ,在训练集上跑一遍;
- 对每道题,用 LLM 诊断器估计模块 的表现:把系统描述、问题、每个模块的输出、最终答案和正确答案交给一个 LLM,问”是不是模块 导致了错误”(答对的题一律判”没错”)。模块得分 = 诊断器的判断 + × 端到端是否答对;
- 每道题记下所有得分最高的模型,选出”在最多道题上并列最高”的模型(这是作者开源实现里的聚合方式;论文写作”取众数”);
- 连续 次分配都不变就停止。每轮评估 个分配,预算按 累加。
**为什么不直接看端到端准确率?**因为端到端准确率会把”一个模块的改进”藏起来。论文的 TableArithmetic 案例(图 4):Claude 3.5 Sonnet 定位从不出错、却总以为 10.9 < 10.11(求解出错),Gemini 1.5 Pro 正相反;GPT-4o mini 两样都中等。从”两个模块都用 GPT-4o mini”出发,只换定位或只换求解都不能提高端到端准确率,所以”逐模块看端到端”的贪心停住了;诊断器却能看出”换成 Claude 以后定位不再出错”,于是 LLMSelector 接受这个改动,下一轮再把求解换成 Gemini,准确率达到 100%。论文报告 LLMSelector 只需评估 10 个分配(穷举要 25 个)。
4.4 我们的玩具复现
代码 04 演示 1 构造了同样的结构:模型 A 定位强、求解弱;B 正相反;C 两样都中等;每道题只有一个难度,同一个模型在两项技能上”一起顺手、一起不顺手”。训练集上 25 种分配的准确率(行 = 定位,列 = 求解,节选):
| 定位 \ 求解 | A | B | C |
|---|---|---|---|
| A | 0.02 | 0.96 | 0.44 |
| B | 0.00 | 0.01 | 0.00 |
| C | 0.01 | 0.44 | 0.45 |
C+C(0.45)是局部最优:换掉一半(A+C 0.44、C+B 0.44)都不更好——C 做对的恰好是”对 C 来说容易”的题,换掉一个模块,另一个模块仍然只在这些题上做对。从 C+C 出发,贪心停在原地(测试集 0.51),LLMSelector 走到 A+B(测试集 0.99),诊断器准确率降到 0.8 也能找到。从全部 25 个初始分配出发,贪心失败 8 次,LLMSelector 25 次全部成功。
**在随机模型池上呢?**代码 04 演示 2 生成了 10 个随机模型池(每池 5 个模型,能力 = 通用水平 + 各技能的专长),三种系统各跑一遍(测试集准确率,池平均):
| 系统 | 单一最好 | 贪心 | 随机搜索 | LLMSelector | 穷举 | 评估的分配数(贪心 / LS / 穷举) |
|---|---|---|---|---|---|---|
| 定位-求解 | 0.599 | 0.657 | 0.624 | 0.654 | 0.667 | 20 / 19 / 25 |
| 生成-批评-改写 | 0.832 | 0.908 | 0.904 | 0.907 | 0.916 | 28 / 31 / 125 |
| 三人辩论 | 0.824 | 0.832 | 0.820 | 0.840 | 0.850 | 52 / 192 / 15,625 |
三点:(1) 混用模型确实比最好的单一模型好(LLMSelector 高 1.6–7.5 个百分点,穷举高 2.6–8.4);(2) 在随机池上贪心和 LLMSelector 差不多,都接近穷举——演示 1 那种卡住贪心的结构在随机池里不常见;论文主实验没有和贪心比较,这是我们的补充;(3) 同样的预算下随机搜索更差:搜索空间大时,“一次改一个模块”的结构本身就很有用。
诊断器不准时(代码 04 演示 3):三人辩论上,诊断器判断正确的概率从 1.0 降到 0.6,LLMSelector 评估的分配数从 68 涨到 633——模块得分变嘈杂,“连续几轮不变”的停止条件更难满足;准确率下降得比较慢(并且不单调)。诊断器是 LLMSelector 跳出局部最优的唯一凭据,也是它额外的成本来源。
4.5 假设不成立时
论文承认两个单调性假设”并不总成立”,它的定性例子(附录图 6、图 7)正好是两种反例,代码 04 演示 4 在玩具世界里重现了它们:
- 模型看不出自己的错(违反模块间单调):论文的例子里 Claude 3.5 Sonnet 当批评者时倾向于同意自己生成的答案,换成 GPT-4o 当批评者才发现错误。我们让”批评者与生成者是同一个模型时发现错误的概率乘以 0.3”:10 个池的最优分配全都让二者用不同的模型,比”同一个模型”最好的高 7.7 个百分点;LLMSelector 10 次都找到了这个结构。
- 辩论需要多样的初始答案(违反模块内单调):论文的例子里三个 GPT-4o 生成者都答 8(错),辩论者无从挑选;换成三个不同的模型,答出 8、3、−18,辩论者认出了 3。玩具世界里同一个模型错的时候总错成同一个答案:6 / 10 个池的最优分配用了 2–3 种生成模型,比”三个生成者用同一个模型”高 2.6 个百分点。(第一版代码里辩论者可以在三个答案都错时凭自己的知识答对,结果多样性几乎不重要;改成和论文一致的”只在初始答案里挑”后才出现这个效应,见代码 README。)
4.6 系统视角
LLMSelector 本身是一个离线的”编译”步骤:给定系统结构、训练集和模型池,输出一个分配。但它的结果改变了推理集群要服务的流量:一个分配可能让某个小模型承担大部分调用、让某个大模型只在一个模块里出现。如果再把成本加进来(每个模型的价格不同),目标就变成”在预算内最大化准确率”或”在准确率不降的前提下最省钱”,这与请求级的模型路由(把简单问题交给便宜模型)是同一类问题(练习 7)。
5. 三个层次的路由 · Three levels of routing
| 层次 | 决定什么 | 需要的信息 | 本课相关 |
|---|---|---|---|
| 请求 → 副本 | 同一个模型的哪个副本 | 各副本的负载(最好按 token)、缓存了什么、显存余量 | 本讲 §1–§3;第 9 讲 Mooncake;第 7 讲 Dynamo 的 Smart Router |
| 请求 → 模型 | 用哪个模型回答 | 问题难度的估计、各模型的质量与价格 | §6 的模型路由与级联 |
| 模块 → 模型 | 复合系统里每一步用哪个模型 | 模块级的表现(诊断器)、端到端的评测 | 本讲 §4;第 13 讲的智能体程序 |
三个层次有共同的教训:
- 信息决定上限,信息的新鲜度决定会不会出事。最短队列需要实时的队列长度,信息过时就扎堆;前缀亲和需要知道谁缓存了什么,Preble 在派发时就更新全局树、SGLang 路由器用请求历史维护近似的树;LLMSelector 需要模块级的表现,就请一个 LLM 来诊断。
- “所有决定都追逐同一个最优”会制造热点。最短队列的扎堆、前缀亲和的崩溃、“三个生成者都用最强的模型”的多样性丧失,是同一种现象。随机性(二选一)、显式的负载保护(再平衡)、显式的多样性,都是解药。
- 简单规则拿走大部分收益。二选一只看两台;“命中过半才亲和 + 负载保护”不输 Preble;贪心在多数模型池上不输 LLMSelector。精巧的设计在边界情况(热点、局部最优、过载)下才显出价值。
6. 之后的发展 · Since then
- 生产中的缓存感知路由器。SGLang 的路由器(2024 年起,Rust 实现)为每个工作进程维护一棵近似基数树(根据派过去的请求记录,存原始字符而不是 token,避免分词开销;后台定期按 LRU 淘汰叶子),不去查询工作进程真实的缓存:最长匹配的比例超过阈值就派给它,否则派给”树最小”(缓存空余最多)的工作进程;各进程未完成请求数的差值超过绝对阈值、且最大值超过最小值的若干倍时,退化为最短队列。代码 02、03 的”缓存感知 + 负载保护”是它的简化版(不过阈值时我们派给负载最轻的,而不是树最小的)。第 16 讲会再讲。NVIDIA Dynamo 的 KV 感知路由(第 7 讲)用各工作进程上报的 KV 事件维护全局索引,同时考虑命中和负载;Kubernetes 生态里也出现了带前缀感知打分的推理网关。
- 共享的 KV 池。第 9 讲的 Mooncake 把 KV 放进跨机器的分布式池,路由器可以把请求派给空闲的机器、再从池里拉前缀,部分绕开”亲和 vs 均衡”的两难;代价是传输带宽。
- 模型路由与级联。FrugalGPT(Chen 等 2023,与 LLMSelector 同一第一作者)按顺序尝试由便宜到贵的模型,答案足够可信就停;RouteLLM(Ong 等 2024)训练一个路由器在强、弱两个模型之间选择。它们是”请求 → 模型”这一层的代表。
- 提示与模型的联合优化。DSPy、TextGrad 优化提示词,LLMSelector 优化模型分配;把两者结合、再加入成本约束,是复合系统优化的自然方向。第 13 讲会从”程序”的角度看复合系统的调度。
7. 小结 · Summary
- 多副本集群的路由是一个经典问题:看负载的策略远好于盲目的策略;二选一只看两台就拿到大部分好处;请求大小重尾时要按工作量(token)而不是请求数估计负载;负载信息过时会让最短队列扎堆,二选一则很稳(代码 01)。
- 前缀缓存让副本不再一模一样:只看负载,每个副本缓存同一批东西,集群的总缓存只有一个副本那么大;只看缓存(纯亲和),一段共享的系统提示就能把所有请求吸到一个副本上。“命中过半才亲和”兼顾两者,并让缓存容量随副本数增长;但一个超热的前缀仍会压垮单个副本,必须有负载保护(代码 02)。
- Preble:全局基数树(派发时就记下”前缀将在哪里”)+ 本地迭代级调度;E2 在 cached > missed 时利用、否则探索;负载代价 L + M + P;事后再平衡复制热点;用前缀命中搭配 prefill / decode;本地优先级组。我们的复现里长文档 / 视频问答收益最大,负载代价几乎只剩 L 一项,简单的缓存感知启发式在中低负载下不输 Preble(代码 03)。
- LLMSelector:复合系统里每个模块用哪个模型;一次改一个模块、用 LLM 诊断器打模块分,能跳出端到端贪心的局部最优(案例 0.51 → 0.99);随机模型池上贪心与它相当;诊断器不准时预算上涨;单调性假设不成立时(看不出自己的错、需要多样性),最优分配会刻意混用模型(代码 04)。
- 路由有三个层次(请求 → 副本、请求 → 模型、模块 → 模型),共同的教训是:信息与其新鲜度决定上限;所有决定追逐同一个最优会制造热点;简单规则拿走大部分收益。
8. 常见误区 · Common pitfalls
- “轮转把请求分得最均匀,所以最好。” 它只让请求数均匀,不看请求大小、不看谁闲;请求大小重尾时它和随机差不多(代码 01 演示 2)。
- “最短队列总是最好的。” 它依赖实时信息;信息过时几个服务时间,它就比随机还差(代码 01 演示 3)。
- “前缀亲和就是把请求派给前缀匹配最长的副本。” 不加阈值和负载保护的纯亲和会崩溃,一段很短的共享系统提示就足以把所有请求吸到一个副本上(代码 02 演示 1)。
- “前缀感知路由的收益只是命中率高一点。” 更根本的是集群的总缓存随副本数增长;只看负载的路由下,加副本不会提高命中率(代码 02 演示 2)。
- “Preble 的收益来自精巧的负载代价模型。” 在我们的复现里,L 项比 M、P 大 40–2 万倍,探索近似于”选最近派得最少的副本”;收益主要来自利用 / 探索的规则和再平衡(代码 03 演示 2)。
- “prefill / decode 搭配总是有益的。” 当”探索”的请求是很长的 prefill 时,把它塞给 decode 多的副本会挡住那里的 decode(代码 03 演示 2)。
- “每个模块都用最强的模型,整个系统就最强。” 模型可能看不出自己的错、多个相同模型会犯同样的错;最优分配常常刻意混用模型(代码 04 演示 4)。
- “LLMSelector 总比贪心强。” 它的优势在贪心会卡住的结构里;在随机模型池上两者相当,诊断器不准时它还更费预算(代码 04 演示 2–3)。
9. 练习 · Exercises
练习 1、3、4 的参考答案里的数字是用
cluster_router.py的函数实际算出来的;练习 6 的数字来自代码 04 的输出。
练习 1(球放进盒子)。 = 100 万。(a) 按渐近公式,纯随机和二选一时最满的盒子大约有几个球?(b) 代码 01 演示 1 在 = 10 万时量到随机 8.2、二选一 3.4,而公式给出 4.71 和 3.53。为什么随机的实测值比公式大得多?
参考答案
(a) ;。(b) 渐近公式只给出增长的阶,省略了常数和低阶项;纯随机时精确的结果大约是 ,这个 在 时还很大(收敛极慢)。二选一的公式是 ,常数项小,所以吻合得更好。要比较的是”增长速度”:从 1,000 到 10 万,随机从 5.4 涨到 8.2,二选一只从 3.0 涨到 3.4。
练习 2(随机派发 = M/M/1)。泊松到达、指数服务时间,随机把请求派给 16 台服务器中的一台。(a) 每台服务器看到的是什么队列?利用率 0.9 时平均响应时间的理论值是多少?(b) 代码 01 的模拟给出 11.56,为什么和理论值有差距?
参考答案
(a) 泊松流随机分流后仍是泊松流,每台都是独立的 M/M/1 队列,平均响应时间 个服务时间, = 0.9 时是 10。(b) 有限长度的模拟有统计误差:利用率 0.9 的 M/M/1 队列”记忆”很长(一次长队要很久才消散),5 万个请求分到 16 台,每台只有约 3,000 个,均值的波动可达一两成。换种子或加长模拟会更接近 10。高利用率下的模拟要么跑很长、要么多个种子取平均——代码 01 演示 2 对重尾分布就这么做了。
练习 3(E2 的利用 / 探索)。4 个副本(A6000 + Mistral-7B)。一个长文档请求的提示有 23,000 个 token:13 token 的系统提示、21,000 token 的文档、1,987 token 的问题。副本 2 缓存了系统提示 + 这份文档,其他副本只缓存了系统提示。(a) E2 会利用还是探索?候选副本有哪些?(b) 如果派给副本 2,要 prefill 多少 token、约多少时间?派给副本 0 呢?(用 StepModel(MISTRAL_7B, A6000).prefill(n),忽略已缓存部分对注意力的额外开销。)
参考答案
(a) cached = 21,013,missed = 1,987,cached > missed → 利用;候选只有副本 2(只有它有最长匹配)。如果副本 2 已经很忙、超过最轻副本的 倍,再平衡会把它改派到最轻的副本。(b) 副本 2:约 2,000 个 token,prefill(2000) 约 0.27 秒(2,100 token 时 0.2817 秒);副本 0:约 23,000 个 token,prefill(21000) 就要 3.77 秒,再加问题部分超过 4 秒——相差约 15 倍。这就是为什么”cached > missed”时要利用:省下的计算远大于”能拿去平衡的”那 2,000 个 token。
练习 4(集群的总缓存)。Mistral-7B 在 A6000 上(显存的 90% 可用)能放多少 token 的 KV?长文档负载有 300 份文档、平均约 21,000 token。(a) 一个副本最多能缓存几份平均长度的文档?(b) 4 个副本、轮转 vs 理想的分区,集群一共能缓存几份不同的文档?(c) 按 Zipf 1.1 的热度,最热的 3 份文档占多少流量?
参考答案
(a) kv_capacity_tokens(MISTRAL_7B, A6000) = 219,089 token(48 GB × 0.9 − 14.5 GB 权重,每 token 128 KiB),约 10 份(还要给运行中的请求留空间,实际更少)。(b) 轮转:每个副本缓存的都是”最近最常见”的那 10 份左右,彼此大量重复,集群里不同的文档仍然只有十来份(代码 02 测得复制倍数 1.34);理想分区:约 40 份,互不重复。(c) 最热的 3 份约占 20.3% + 9.5% + 6.1% ≈ 36%。所以即使只缓存最热的几份,命中率也有上限;能缓存 40 份比 10 份多覆盖的是中等热度的长尾——代码 02 演示 2 里命中率从 0.32 升到 0.58 主要来自这部分。
练习 5(优先级组)。Preble 的本地调度器有 = 10 个优先级组。(a) 一个请求 100 个 token 里有 63 个已缓存,进第几组?(b) 为什么”要取 55 个请求时从第 10 组取 10 个、第 9 组取 9 个……”?这个方案在什么情况下仍然会让命中少的请求等很久?
参考答案
(a) 第 6 组(论文的例子;按”已缓存比例 × 10”向下取整)。(b) 10 + 9 + … + 1 = 55,每组的份额与优先级成正比:命中多的请求优先(它们只占很少的 prefill 预算、能很快完成),但命中少的组每轮也有份额,不会饿死。仍会等很久的情况:低优先级组里排了大量请求而份额只有 1 个 / 轮;或者显存不够、命中少的请求需要的空间大,轮到它时放不下,只能继续等。代码 03 演示 5 里没有出现饿死:命中少的请求的 P99 和先来先服务时差不多。
练习 6(LLMSelector 的预算)。(a) 10 个模型、6 个模块(三人辩论),穷举要评估多少个分配?LLMSelector 至少要评估多少个(假设从一开始就是最优分配)?(b) 代码 04 演示 1 里 LLMSelector 从 C+C 出发评估了 20 个分配,论文报告它在 TableArithmetic 上只需 10 个。为什么不同?
参考答案
(a) 穷举 个。LLMSelector 每轮评估 10 个分配;停止条件是”连续 次分配相同”,历史从初始分配算起,即使一开始就最优,也要 6 轮( 轮)才能凑齐 7 个相同的记录,即 60 个分配。(b) 记账方式不同。我们的停止条件来自开源实现:C+C → A+C(第 1 轮)→ A+B(第 2 轮)→ A+B(第 3 轮)→ A+B(第 4 轮),4 轮 × 5 = 20;论文图 4(c1) 统计的可能是找到最优分配之前评估的分配数(2 轮 × 5 = 10),不含确认收敛的轮次。读论文的预算数字时,要弄清楚”收敛的确认”算不算在内。
练习 7(加入成本)。假设代码 04 演示 1 的五个模型每次调用的价格分别是 A 5、B 3、C 1、D 0.5、E 0.2(单位任意)。(a) A+B 每道题的成本是多少?比 C+C 贵几倍?(b) 如果目标是”准确率每提高一个百分点最多多花 0.1”,该选哪个分配?(c) 设计一个把成本纳入 LLMSelector 的办法。
参考答案(要点)
(a) A+B:5 + 3 = 8;C+C:2;贵 4 倍。(b) A+B 比 C+C 的测试准确率高 0.99 − 0.51 = 48 个百分点,多花 6,每个百分点 0.125,略高于 0.1 的门槛——严格按这个标准应该留在 C+C,或者去找更便宜的组合(例如 A 定位 + 更便宜的求解模型,如果有的话)。这说明成本约束会改变”最优分配”。(c) 一种办法:把模块得分改成”诊断器得分 − λ × 该模型的价格 / 最高价格”,用不同的 λ 跑几遍得到一条”成本—准确率”曲线,再按预算选点;另一种是在 LLMSelector 收敛后,逐个模块尝试换成更便宜的模型,只要端到端准确率下降不超过阈值就接受。
练习 8(思考:近似树会错在哪里)。SGLang 路由器不查询工作进程真实的缓存,而是根据”派过去的请求”维护近似的基数树,并定期按 LRU 淘汰叶子。(a) 近似树和真实缓存会在什么时候不一致?(b) 不一致会导致什么后果?(c) Preble 的全局树和 Dynamo 的 KV 事件各怎样减少这种不一致?
参考答案(要点)
(a) 工作进程因为显存压力淘汰了某个前缀,近似树却还记着它(树的淘汰节奏和真实的不同);或者请求在工作进程上被拒绝、超时,前缀根本没算出来;工作进程重启后缓存清空。(b) 路由器把请求派给”以为有缓存”的进程,结果要从头 prefill,而且是在一个本来就承接了很多同类请求、可能很忙的进程上;更糟的是这些误派会反复发生,直到近似树把这个前缀淘汰。(c) Preble:本地调度器淘汰时异步通知全局调度器,全局树据此删除对应 GPU 的记录(仍有通知延迟);Dynamo:工作进程上报 KV 块的存储 / 删除事件,路由器的索引直接反映真实状态(代价是事件流的开销)。这是”信息新鲜度”的又一个例子。
练习 9(思考:Preble 遇上 prefill / decode 解耦)。第 7 讲的解耦部署把 prefill 和 decode 放在不同的实例上。如果要在 xPyD 部署上做前缀感知的路由,哪些决定要变?Preble 的”用前缀命中搭配 prefill / decode”还有意义吗?
参考答案(要点)
前缀缓存主要服务 prefill,所以亲和的对象变成 prefill 实例:请求先按前缀命中和负载选 prefill 实例,再按 decode 实例的显存和负载选 decode 实例(两步路由,或者联合优化 KV 传输的距离)。多轮对话的输出也要回写到 prefill 侧的缓存(或像 Mooncake 那样放进共享的 KV 池),否则下一轮命中不了。Preble 的”搭配”规则是为了在同一个实例里混合 prefill 和 decode,解耦部署里两者已经分开,这条规则就没有意义了;但”命中多的请求几乎只剩 decode”这个观察仍然有用——它意味着这类请求的 prefill 实例开销很小,甚至可以直接在 decode 实例上补算那一小段(一些系统就是这样做的)。
10. 延伸阅读 · Further reading
本讲的必读材料
- V. Srivatsa, Z. He, R. Abhyankar, D. Li, Y. Zhang. Preble: Efficient Distributed Prompt Scheduling for LLM Serving. ICLR 2025(arXiv:2407.00023)。
- L. Chen, J. Q. Davis, B. Hanin, P. Bailis, M. Zaharia, J. Zou, I. Stoica. Optimizing Model Selection for Compound AI Systems. arXiv:2502.14815,2025。
经典负载均衡
- Y. Azar, A. Broder, A. Karlin, E. Upfal. Balanced Allocations. STOC 1994(球放进盒子的”二选一”)。
- M. Mitzenmacher. The Power of Two Choices in Randomized Load Balancing. IEEE TPDS 2001。
- M. Mitzenmacher. How Useful Is Old Information? IEEE TPDS 2000(过时信息与扎堆)。
相关工作
- M. Zaharia et al. The Shift from Models to Compound AI Systems. BAIR Blog,2024。
- O. Khattab et al. DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines. ICLR 2024(第 13 讲的选读)。
- L. Chen, M. Zaharia, J. Zou. FrugalGPT: How to Use Large Language Models While Reducing Cost and Improving Performance. TMLR 2024。
- I. Ong et al. RouteLLM: Learning to Route LLMs with Preference Data. 2024。
- SGLang 路由器(sgl-project/sglang 仓库中的 cache-aware 策略实现与文档)。
背景
- 第 3 讲:RadixAttention(Preble 的单机前缀缓存);第 7 讲:分块 prefill、Dynamo 的 Smart Router;第 9 讲:Mooncake 的 KVCache-centric 调度;前置讲 P4:排队论。
11. 下一讲预告 · Next lecture
这一讲的集群里只有一个模型,每个副本独占自己的 GPU。可现实中的推理平台要服务成百上千个模型:大多数模型流量很小、而且随时间起伏,给每个模型都留几块 GPU 太浪费;把多个模型塞进同一块 GPU,又会互相干扰,冷门模型一来请求还要先花几十秒把权重加载进来。第 12 讲:多租户与 GPU 共享读两篇论文:LithOS 想给 GPU 做一个”操作系统”——按 TPC(GPU 上比 SM 更粗一级的单元)细粒度地分配算力、把大内核切成小块以便抢占、按需调整频率省电;Prism 让多个模型共享 GPU 显存,像”气球”一样按需伸缩各模型的 KV 空间,统一空间共享和时间共享;选读 Fork in the Road 分析生产级 serverless 平台冷启动的延迟从哪里来。我们会模拟多个模型在流量漂移下的静态划分 vs 弹性共享、时间片 vs 空间共享的延迟,以及冷启动的代价。