对应 Maps to:Stanford CS349D AI Inference Infrastructure(Spring 2026),#15 Mon May 18 Agentic Workflows。必读:M. Luo, X. Shi, C. Cai, T. Zhang, J. Wong, Y. Wang, C. Wang, Y. Huang, Z. Chen, J. E. Gonzalez, I. Stoica, Autellix: An Efficient Serving Engine for LLM Agents as General Programs(2025);C. Lin, Z. Han, C. Zhang, Y. Yang, F. Yang, C. Chen, L. Qiu, Parrot: Efficient Serving of LLM-based Applications with Semantic Variable(OSDI 2024)。选读:S. Hong 等, MetaGPT: Meta Programming for a Multi-Agent Collaborative Framework(ICLR 2024);O. Khattab 等, DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines(2023)。 依据说明:原课没有公开 slides。本讲依据 Autellix 和 Parrot 的全文;MetaGPT 和 DSPy 在本讲里作为”工作负载从哪里来”的背景,只用了它们的方法部分和与系统相关的统计。§4 的对比、§5 的系统视角和 §6”之后的发展”是我补充的。讲义里的纸上数字全部来自配套代码的实际运行。 配套代码 Code:
code/lecture13/(numpy;4 个脚本 + 1 个模块agent_sim.py:以”程序”为单位的推理服务模拟器——程序是 LLM 调用与工具调用组成的有向无环图,引擎做连续批处理,调度与路由策略可插拔;代码 01 约 75 秒、代码 04 约 110 秒,其余不到半分钟) 预计阅读时间:3–4 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标 Learning goals
学完这一讲,你应该能够:
- 说清”智能体程序”和”复合 AI 系统”给推理服务带来的负载长什么样:多次调用、顺序依赖与并行、工具与人的中断、逐轮累积的上下文、动态且不可预知的结构。
- 说出以请求为单位的推理服务因此丢掉了哪些信息、各造成什么代价:客户端往返与重新排队、错误的优化目标、看不见的共享前缀、程序级的排头阻塞、跨引擎的数据局部性。
- 解释 Parrot 的语义变量(Semantic Variable)怎样把应用的数据流暴露给服务端,以及它支撑的四种优化:服务端执行依赖、推断性能目标、共享前缀、以应用为中心的调度。
- 解释 Autellix 的思路:把程序当作调度的基本单位,按程序”已经获得的服务”排序(PLAS),多线程程序按关键路径(ATLAS),用离散的多级队列、防饿死和局部性感知的路由把它做成可用的系统。
- 用操作系统和集群调度的经典概念(最少已获服务优先、多级反馈队列、最短剩余时间、关键路径、数据局部性)理解这些设计,并能说出调度与缓存之间的相互作用。
0.2 先修与本讲范围 Prerequisites & scope
| 默认你已经会 Assumed | 本讲只给直觉,后面再展开 Previewed only |
|---|---|
| 第 2 讲:连续批处理、迭代级调度、先来先服务与抢占 | 智能体的记忆管理(第 14 讲) |
| 第 3 讲:前缀缓存、SGLang 的前端语言 | 强化学习里的 rollout 调度(第 15 讲) |
| 第 11 讲:多副本路由、前缀亲和 | 提示词优化的算法(DSPy 只讲系统相关的部分) |
| 前置讲 P3、P4:进程调度、排队论 | 智能体本身的能力与评测 |
0.3 本讲地图 Map
§1 从请求到程序:智能体与复合 AI 系统的负载
§2 Parrot:用语义变量暴露数据流 ← 代码 03
§3 Autellix:把程序当作调度的基本单位 ← 代码 01、02、04
§4 两条路线的对比
§5 系统视角:经典调度思想的回归,以及调度与缓存的相互作用
§6 之后的发展
§7–11 小结、常见误区、练习、延伸阅读、下一讲预告
0.4 记号 Notation
| 符号 | 含义 |
|---|---|
| 程序 program | 为完成一个任务而发出的一组 LLM 调用及其间的工具 / 人的中断,组成一个有向无环图(DAG) |
| 调用 call | 一次 LLM 请求(prefill + decode) |
| 中断 interrupt | 程序在 LLM 引擎之外花的时间:工具调用、代码执行、等人输入 |
| 服务时间 service | 调用在引擎里被执行的时间;程序的服务时间 = 它已完成的调用的服务时间之和 |
| 关键路径 critical path | DAG 里从起点到终点、服务时间之和最长的那条依赖链 |
| 程序级 token 延迟 | 程序的响应时间 ÷ 它生成的 token 总数(Autellix 的指标;多线程程序按关键路径的响应时间算) |
| 容量 capacity | Parrot 的引擎在同一时刻运行的 token 总数上限,决定每个 token 的生成延迟 |
0.5 关于数字 · About the numbers
本讲的数字来自一个离散事件模拟器(代码 agent_sim.py):每个引擎做连续批处理 + 分块 prefill,每一步的时间来自第 1 讲的纸上 GPU(Autellix 的实验设定:A100 + Llama-3.1-8B;Parrot 的实验设定:A100 + LLaMA-13B、A6000 + LLaMA-7B);负载的平均长度仿照两篇论文报告的统计,其余(长尾的形状、工具执行时间等)是假设值。调度器不预知程序的结构,只有 SRPT 作为理论参照”偷看”每个程序剩余的工作量。论文里的数字写成”论文报告”。
1. 从请求到程序 · From requests to programs
1.1 程序长什么样
到目前为止,我们几乎一直把推理服务的输入当作一个个独立的请求。可越来越多的流量来自程序:
- 链式与 map-reduce(Parrot 图 1):长文档超过上下文窗口,就切成块,每块调一次 LLM 写摘要,再把摘要逐块串起来(链式,每一步依赖上一步)或者一次合并(map-reduce,map 之间可以并行)。
- 多轮对话与 ReAct 智能体(Autellix 图 1):对话是”LLM ↔ 人”的循环,ReAct 是”LLM 推理并选择工具 → 执行工具 → 把结果加进上下文 → 再调 LLM”的循环,循环几次由模型自己决定。
- 多智能体协作:MetaGPT 把软件公司的标准作业流程(SOP)编码成角色——产品经理写需求文档,架构师写系统设计,项目经理分配任务,工程师写代码并执行测试、根据报错迭代(最多 3 次),QA 写测试用例。角色之间通过一个共享的消息池按”发布—订阅”交流结构化的文档;一个角色只有在它依赖的文档都到齐后才行动。
- 搜索:蒙特卡洛树搜索(例如 LATS)每一轮并行扩展若干候选、并行地让 LLM 评估它们、再反思,一个程序有一百多次调用。
- 编译出来的流水线:DSPy 把提示词工程变成编程——用”签名”(例如
question -> answer)声明一个模块做什么,用模块像 PyTorch 的层一样搭流水线,再由”编译器”(teleprompter)在训练样例上运行流水线、收集成功的轨迹当作示例,自动生成每个模块的提示(或微调小模型)。编译本身就要发出大量调用,编译出来的流水线也是多次调用的程序。
Autellix 总结了这类程序的三个性质:动态(同一个程序面对不同的输入,执行结构完全不同)、不确定(什么时候结束要到运行时才知道)、分布式(很多程序有并行的调用)。所以推理服务”事先不知道”程序的形状。
1.2 它们给推理服务带来的负载
两篇论文都先量了负载(论文报告):
| 应用 | 每个任务的调用次数 | 每次的 token | 跨调用重复的内容 |
|---|---|---|---|
| 长文档分析(Parrot 的生产应用) | 2–40 | 3.5K–80K(总) | 3% |
| 聊天搜索(Parrot 的生产应用) | 2–10 | 5K(总) | 94% |
| MetaGPT | 14 | 17K(总) | 72% |
| AutoGen | 17 | 57K(总) | 99% |
| 多轮聊天(ShareGPT,Autellix) | 平均 6.66,最多 80 | 输入 256、输出 277(每次) | — |
| ReAct(BFCL,Autellix) | 平均 10.75,最多 70 | 输入 735、输出 34(每次) | — |
| 树搜索(LATS,Autellix) | 平均 159.7 | 输入 467、输出 73(每次) | — |
还有几条观察:Parrot 在一个使用链式工作流的生产应用里测到,一次 LLM API 调用有 30%–50%(最坏 70% 以上)的延迟花在引擎之外(网络、排队),而且提示越长、这部分越大;Autellix 测到负载升高时,程序的大部分时间在等待;同一个程序内部的调用前缀缓存命中率在 90% 以上,不同程序之间只共享系统提示,命中率随输入变长急剧下降。
1.3 以请求为单位的服务丢掉了什么
今天的公有 LLM 服务提供的是 Completion(prompt) → text 这样的请求级接口。应用在客户端把提示拼好、发出去、等结果、解析、再拼下一个提示。服务端看到的只是一个个孤立的请求,于是:
- 依赖被藏起来:下一个请求要等客户端收到上一个结果才能发出,多了网络往返;而且它到达时要重新排队,排在这期间到达的别人后面。
- 目标被弄错:服务端只能假设每个请求都要低延迟,可应用关心的是整个任务的完成时间(例如 map-reduce 的最终摘要),中间的 map 请求其实应该追求吞吐。
- 共享被看不见:系统提示、示例、文档、对话历史在请求之间大量重复,可服务端不知道提示的结构,逐 token 比对又太贵,很难在集群层面把共享前缀的请求放到一起。
- 程序级的排头阻塞:调度器不知道哪些请求属于同一个程序、这个程序已经跑了多久,长程序的一连串调用会反复挡住短程序。
- 跨引擎的局部性:一个程序的后续调用如果被派到别的引擎,它累积的上下文就要重算。
Parrot 主要解决 1–3(通过让应用”说出”数据流),Autellix 主要解决 4–5(通过在服务端”观察”程序)。
2. Parrot:用语义变量暴露数据流 · Parrot
论文卡片 Paper card · Parrot
- 问题:LLM 应用由多个请求组成,但公有 LLM 服务只看到孤立的请求,丢掉了依赖、目标和共享这些应用级信息,只能盲目地优化单个请求的延迟。
- 关键想法:语义变量——提示里一段有特定用途的文本区域(指令、示例、输入、输出),同时也是连接多个请求的数据管道。服务端据此做经典的数据流分析:恢复请求之间的 DAG、推断每个请求的性能目标、按提示结构快速发现共享前缀。
- 优化:服务端执行依赖的请求(省掉往返与重新排队);从最终输出的目标反推中间请求的目标(任务组用大批);按语义变量切分的前缀哈希找共享、配合共享前缀的注意力内核;以应用为中心的调度(同一应用的请求放在一起、目标相同的请求放在一起)。
- 结果(论文报告):在长文档分析、Bing Copilot / GPTs 式的热门应用、MetaGPT 多智能体编程、混合负载上,最多 11.7 倍加速或 12 倍吞吐。
- 局限:只支持没有动态控制流的编排(条件分支、原生函数仍要在客户端执行,出于安全考虑没有放到服务端);需要开发者用 Parrot 的接口写程序。
2.1 语义变量与语义函数
Parrot 把一次 LLM 请求看作一个用自然语言实现、由 LLM 执行的语义函数(这个名字借自 Semantic Kernel)。提示里的占位符就是语义变量,标明是输入还是输出。论文图 7 的例子(简化):
@P.SemanticFunction
def WritePythonCode(task: P.SemanticVariable):
""" You are an expert software engineer.
Write python code of {{input:task}}.
Code: {{output:code}} """
@P.SemanticFunction
def WriteTestCode(task: P.SemanticVariable, code: P.SemanticVariable):
""" You are an experienced QA engineer.
You write test code for {{input:task}}. Code: {{input:code}}.
Your test code: {{output:test}} """
def WriteSnakeGame():
task = P.SemanticVariable("a snake game")
code = WritePythonCode(task)
test = WriteTestCode(task, code)
return code.get(perf=LATENCY), test.get(perf=LATENCY)
和 LangChain 这类编排框架的模板不同之处在于:模板在提交之前就被填好了,服务端看不到结构;Parrot 把模板和变量一起提交,结构保留到服务端。调用一个语义函数只是提交(submit)一个请求、立即返回输出变量的”future”;应用通过 get 取值,并在 get 时声明性能目标(端到端延迟或吞吐)。异步的设计让服务端能一次收到一个任务的所有请求,“即时”地分析它们的关系。
2.2 请求间分析的原语
基于语义变量,服务端有两类信息(论文 §4.2、图 8):
- DAG:每个用户会话维护一个由请求和语义变量组成的图。
GetProducer、GetConsumers找出一个变量由谁产生、被谁消费,从而恢复请求之间的依赖;GetPerfObj取得最终输出上声明的目标。 - 提示结构:
PrefixHash在每个语义变量的位置把提示切开、算前缀哈希。例如WritePythonCode的提示有两个可能共享的前缀:{{input:task}}之前的文本,以及{{output:code}}之前的文本。
2.3 四种优化
服务端执行依赖的请求(§5.1)。一个图执行器不断检查哪些请求的前驱都已完成,一就绪就发给引擎;语义变量的值通过为它分配的消息队列直接在服务端传递,还支持像 Kafka 那样的字符串变换(例如从 JSON 输出里抽出一个字段),覆盖 LangChain 的大部分输出解析方式。
代码 03 演示 1(一个 16 块的链式摘要,A100 + LLaMA-13B,网络往返 200–300 毫秒):
| 场景 | 以请求为单位 | Parrot | 加速 |
|---|---|---|---|
| 无背景流量,每次输出 50 token | 19.04 s | 15.24 s | 1.25× |
| 无背景流量,每次输出 25 token | 12.31 s | 8.51 s | 1.45× |
| 背景流量 1 请求/秒 | 20.92 s | 17.22 s | 1.21× |
| 背景流量 2 请求/秒 | 61.35 s | 28.57 s | 2.15× |
没有背景流量时,差的 3.80 秒恰好约等于 15 次往返(平均 0.25 秒);有背景流量时,以请求为单位的服务每一步都要重新排队,Parrot 让正在进行的应用的下一步直接接上。论文报告相对 vLLM 最多 1.38 倍、有背景流量时 2.38 倍;多个链式摘要应用同时提交时平均快 1.68 倍。
推断性能目标(§5.2)。如果一个变量要求吞吐,产生它的所有请求(直接或间接)都标为吞吐优先——适合离线的批量分析。延迟敏感的情形更微妙(论文图 4、图 9):从要求低延迟的最终变量出发逆拓扑序分析,直接产生它的请求和它们的直接前驱标为延迟敏感,同一阶段的并行请求归为一个任务组;调度器要最小化整个任务组的完成时间——这往往意味着更大的批。论文图 4 的例子:map-reduce 摘要如果每个请求都按低延迟调度,引擎只能限制并发;知道应用只关心最终摘要后,map 阶段用大批、reduce 阶段追求低延迟,端到端快 2.4 倍。
代码 03 演示 3(一个引擎,以请求为单位时把运行中的 token 总数限制在 4,096 以内):
| map 块数 | 以请求为单位 | Parrot(map 阶段用满容量) | 加速 |
|---|---|---|---|
| 8 | 6.92 s | 5.38 s | 1.28× |
| 16 | 10.54 s | 6.70 s | 1.57× |
| 32 | 17.01 s | 9.28 s | 1.83× |
共享提示前缀(§5.3)。Parrot 维护一个”前缀哈希 → 请求列表”的键值表,只在语义变量的位置查哈希,就能在线发现静态的(系统提示)和运行时生成的(多智能体之间的对话历史)共享,即使跨应用也行。它还写了一个结合 FlashAttention 和 PagedAttention 的 decode 内核:共享前缀的 KV 只从显存读到共享内存一次,先算出前缀部分的中间结果(注意力分数、最大值、指数和),再与各请求自己的部分合并——这正是第 4 讲的级联注意力(cascade attention)的思路。论文报告:Bing Copilot 式的长系统提示(约 6,000 token)下,比不共享快 1.8–2.4 倍,比 vLLM 的静态前缀共享快 1.1–1.7 倍(来自内核);4 个 GPTs 应用在 4 块 A6000 上能承受 12 倍的请求率,关掉”同一应用放在一起”的调度只剩 3 倍。
以应用为中心的调度(§5.4、算法 1)。两条原则:把性能要求相似的请求放在一起(避免冲突),尽量利用共享。算法按拓扑序处理队列;属于一个任务组的请求尽量整组放到同一个引擎;有共享前缀的请求(在队列里或已在某个引擎上)放到一起;都没有才单独找引擎。找引擎时要”满足调度偏好、把负面影响降到最低”:例如一个引擎本来能跑 64,000 个 token 的吞吐型请求,放进一个延迟敏感的请求,容量就得降到 2,000;如果它本来就在跑延迟敏感的请求,再放一个几乎没有损失。
2.4 实现与评测
Parrot 约 14,000 行 Python:前端提供语义变量和语义函数,管理器负责请求、变量、通信和调度,引擎基于 vLLM、xFormers 的内核和自己的内核。为了接入不同的引擎,它定义了一个最小的”通用引擎接口”:Fill(处理提示 token、填 KV)、Generate(逐 token 生成)、FreeContext;创建和分叉上下文用 context_id / parent_context_id 表达。把一次请求拆成 Fill 和 Generate,也让常量文本和输入可以先处理,打开了更多并行的机会。
除了上面提到的数字,论文还报告:MetaGPT 多智能体编程(架构师 → 多个程序员 → 多个审阅者,三轮”审阅—修改”)比延迟优先的基线快 11.7 倍(主要来自目标推断让任务组用大批),比吞吐优先的基线快 2.45 倍(来自共享动态生成的上下文,单这一项 2.35 倍;不共享时 KV 会撑爆显存);聊天与 map-reduce 混合时,聊天的归一化延迟比延迟优先、吞吐优先的基线分别好 5.5 倍和 1.23 倍,map-reduce 比它们快 3.7 倍和 1.05 倍。
代码 03 演示 4 做了混合负载的纸上版(4 块 A6000 + LLaMA-7B,聊天 1 请求/秒 + map-reduce 应用 0.5 个/秒):
| 配置 | 聊天:延迟 / 输出 token | 聊天:每 token 解码 | map-reduce 完成 |
|---|---|---|---|
| 都压低容量(延迟优先) | 854.6 ms | 30.9 ms | 135.6 s |
| 都用满容量(吞吐优先) | 40.4 ms | 39.8 ms | 10.6 s |
| Parrot:按目标分开放 | 25.6 ms | 24.5 ms | 21.9 s |
Parrot 保住了聊天的解码速度,但 map-reduce 因为少了一部分引擎,比”都用满容量”慢一倍——没有做到论文报告的两全。我们的分配方式更粗(整个引擎让给聊天);第一版路由甚至把所有聊天都放在一个引擎上排队,见代码 README。
3. Autellix:把程序当作调度的基本单位 · Autellix
论文卡片 Paper card · Autellix
- 问题:智能体程序在推理引擎上的大部分时间在等待,原因是两层的排头阻塞:长调用挡住短调用(调用级),长程序的一连串调用挡住短程序(程序级)。现有引擎只看单个调用。
- 关键想法:像操作系统调度进程一样调度程序:按程序已经获得的服务时间排序——跑得多的程序大概率还要跑很久,往后放;不需要任何先验知识。单线程程序用 PLAS,多线程程序用 ATLAS(按关键路径上的服务时间)。
- 系统:有状态的会话接口 + 全局进程表;离散的多级队列、时间片降级、程序级的防饿死;多步调度和合并传输的换出内核降低抢占的开销;局部性感知的多引擎路由。
- 结果(论文报告):基于 vLLM v0.6.1,在聊天、ReAct、树搜索、混合负载和 8B / 70B / 180B 模型上,相同延迟下吞吐是 vLLM 的 4–15 倍;多引擎时比轮转和最少使用高最多 1.4 倍。
- 局限:不预知,和预知剩余工作量的 SRPT 之间仍有差距;研究原型的前端没有防篡改。
3.1 两层的排头阻塞
Autellix 图 2 用一个小例子说明问题:四个程序在 t = 0 同时到达,A、B 是长程序(A 有 4 次调用,分别要 4、3、1、1 步;B 有 3 次调用,3、3、4 步),C、D 是短程序(C:1、2 步;D:4 步),引擎一次最多跑 2 个调用。代码 01 演示 1 的复现:
| 策略 | 总等待(单位时间) | 论文 |
|---|---|---|
| 先来先服务(vLLM 的默认) | 18 | 18 |
| MLFQ(调用级抢占) | 17 | 18 |
| PLAS(程序级已获服务) | 14 | 12 |
- 先来先服务:A、B 的长调用先占住两个位置,C、D 等到 t = 3、4 才开始;之后每个新调用都排到队尾——这是调用级的排头阻塞。
- MLFQ(多级反馈队列,FastServe 用于 LLM):调用用完时间片就降级,短调用能插进来,缓解了调用级的阻塞。但它不知道程序:A、B 的下一个调用到达时又从最高优先级开始,继续挤占 D——这是程序级的排头阻塞。
- PLAS:按程序已经获得的服务排序。A、B 做完第一个调用后已经得到很多服务,它们后续的调用排到 C、D 后面。
(我们的 FCFS 与论文一致;MLFQ 和 PLAS 的数字不同,因为论文没有写明平局规则和时间片。)
3.2 PLAS 与 ATLAS
为什么是”已获服务”。最短作业优先(SJF)、最短剩余处理时间(SRPT)在单机和多机上能最优地最小化响应时间,但它们要知道作业有多长——智能体程序做不到。最少已获服务优先(LAS,Least-Attained-Service)是”不预知”场景下的经典替代:数据中心网络的流调度、深度学习集群的作业调度都用过它。它的直觉是:已经跑了很久的作业,很可能还要跑很久(工作量是重尾分布时尤其如此),所以让刚来的、还没怎么跑的先走。
PLAS(Program-Level Attained Service)把 LAS 推广到程序:程序的第 个调用的优先级是这个程序之前所有已完成调用的执行时间之和(数值越大,优先级越低)
调度器从进程表里直接读出程序的累计服务时间,调用完成时更新。
ATLAS(Adaptive Thread-Level Attained Service)用于多线程程序。程序的完成时间由 DAG 的关键路径决定:无论引擎能并行处理多少调用,程序要等关键路径上的调用都做完才结束;不考虑关键路径的调度会让完成时间变长(论文图 9 的例子里从 11 变成 14 个单位)。ATLAS 给每个新调用的优先级是它的父调用的优先级加上父调用的执行时间中的最大值:
也就是”到它为止的最长服务时间链”——对关键路径的一个不预知的估计。实现上,进程表为每个程序只存一个数:已观察到的最长关键路径;每个新调用继承这个值,调用完成时如果自己的路径更长就更新它。这样一个程序的所有并行调用优先级相同,自然被成组调度,不会出现”掉队的线程”拖住整个程序。
3.3 把它做成系统:离散队列、降级与防饿死
连续的优先级会退化成最坏情况下的轮转(不断地互相抢占,比先来先服务还差),还会带来频繁的上下文切换和 KV 在 CPU 与 GPU 之间的换入换出。所以 Autellix 把优先级离散成 个队列 (论文算法 1、图 10):
- 调用到达时,按它所在程序的优先级 落入对应区间的队列——不同于传统 MLFQ 的”新来的一律进最高队列”;
- 在队列里得到这个队列的时间片,同一队列内先来先服务;时间片用完就降到下一个队列;
- 防饿死:如果一个程序总的等待时间与服务时间之比 ,就把它的调用提到 ;只清零这个调用的等待和执行时间,让同一程序的其他活跃调用很可能一起被提上来。 在平均响应时间与公平之间权衡。传统的”等太久就提上来”会让 Autellix 退化成普通的 MLFQ(长程序的调用在 里打断短程序),所以要用程序级的比值。
显存管理:抢占让同时活跃的调用变多,KV 块要频繁在 GPU 与 CPU 之间换。Autellix 每 N 个 decode 步才调度一次(多步调度,并在 GPU 上多放几个排队的调用,防止提前完成的空位闲着);vLLM 对每个 KV 块单独调用 cudaMemcpyAsync,Autellix 把要换的块先聚到一块连续的主机缓冲区,一次传完——换出次数最多少 18 倍,换出时间少 3–7 倍,吞吐高 1.3 倍。
有状态的接口:程序在用户本地运行,开始时向 Autellix 建立会话,之后的调用自动带上会话、程序、线程的编号;进程表记录每个程序的服务时间、等待时间、所在引擎、活跃的调用、最近一次调用到达与完成的时刻(用于发现长时间的外部中断)。
3.4 我们的复现:收益在哪里,代价在哪里
代码 01 演示 2 在一个引擎(A100 + Llama-3.1-8B,最多 64 个调用同时运行)上比较各种调度(程序级 token 延迟,秒/token,平均值;节选):
| 负载 | 程序/秒 | 无前缀缓存 FCFS | FCFS | MLFQ | PLAS / ATLAS | SRPT(预知) |
|---|---|---|---|---|---|---|
| 多轮聊天 | 2.0 | 0.156 | 0.043 | 0.039 | 0.034 | 0.025 |
| 多轮聊天 | 3.0 | 0.253 | 0.170 | 0.079 | 0.112 | 0.029 |
| ReAct | 3.0 | 2.894 | 0.109 | 0.101 | 0.221 | 0.079 |
| 树搜索 | 0.35 | 0.197 | 0.017 | 0.019 | 0.015 | 0.011 |
| 混合 | 0.9 | 0.713 | 0.077 | 0.049 | 0.030 | 0.025 |
(每行的粗体是不预知的策略里最好的。)
- 相对 vLLM 的大倍数主要来自前缀缓存。论文的 4–15 倍是相对没有前缀缓存的 vLLM;不开前缀缓存时,聊天和 ReAct 的每次调用都要重算整段历史,差 3.6–40 倍。论文也说 vLLM 最差”是因为没有前缀缓存”。
- 接近饱和、批开始排队时,程序级调度确实有用:聊天 2.0、树搜索 0.35、混合 0.9 程序/秒时,PLAS / ATLAS 都比先来先服务和 MLFQ 好;混合负载里程序的差异最大,收益也最大(0.030 vs 0.077),与论文”程序越异构收益越大”一致。代码 02 演示 1 显示了原因:先来先服务下只有一次调用的短程序,等待时间是执行时间的 1.75 倍,MLFQ 0.48,PLAS 只有 0.15;代价是长程序等得更久。
- 可是负载再高,PLAS 反而不如 MLFQ,ReAct 上甚至不如先来先服务——这与论文不同,原因是调度与缓存的相互作用(§5.2)。
- 需要预知的 SRPT 在有排队的格子里都最好,与所有不预知的策略之间有明显差距;论文的模拟(图 18)也看到了这个差距。
3.5 多个引擎:局部性感知的路由
程序的调用多了,一个引擎不够(论文:一个引擎每秒只能处理 0.2 个树搜索程序)。怎样在多个引擎之间分配?Autellix 的观察(§3.2、图 7):同一程序内的调用共享逐轮累积的上下文,跨程序的调用只共享系统提示。于是一个很简单的规则(算法 2):提示不超过 2,048 token 的调用派给负载最轻的引擎(它们的输入主要是系统提示,在哪里都能命中,而且钉住它们可能让大型并行程序压满某个引擎);更长的调用钉在程序第一次被派到的引擎上(它们的输入主要是程序自己的历史)。论文报告比轮转(Kubernetes 的默认)和”最少使用”的吞吐高最多 1.4 倍,聊天负载上最明显;引擎数增加时,最大到达率线性增长。
代码 04 的结果(4 个引擎,格式:平均延迟,秒/token;前缀缓存命中率):
| 负载 | 轮转 | 最少使用 | 局部性感知 |
|---|---|---|---|
| 多轮聊天 6 程序/秒 | 0.057;0.47 | 0.029;0.90 | 0.025;0.98 |
| 多轮聊天 8 程序/秒 | 0.098;0.33 | 0.043;0.90 | 0.038;0.98 |
| 树搜索 1.6 程序/秒 | 0.061;0.73 | 0.031;0.88 | 0.017;0.99 |
和第 11 讲的结论一脉相承:缓存在每个引擎上各有一份,路由决定了能不能命中。一个与预想不同的结果:扫描”多长才钉住”的阈值时,两种负载上都是钉得越多越好,“总是钉”最好或与 2,048 持平,没有观察到负载不均的代价——我们的负载里程序很多、每个只占引擎的一小部分,而且聊天没有长的共享系统提示。阈值的价值要在”少数巨大程序 + 很长的共享系统提示”的负载下才显现。
3.6 其他评测
论文报告的其他结果:单引擎上,聊天负载 Autellix 的吞吐最多是 vLLM 的 8 倍、vLLM-opt 的 2 倍、MLFQ 的 1.5 倍;树搜索上分别是 5 倍、2 倍、2.5 倍(MLFQ 激进地优先短调用,反而打乱了同一程序的线程);混合负载 15 倍、4 倍、5.5 倍。尾延迟上,MLFQ 在聊天负载下平均延迟好但 P95 / P99 差,Autellix 在 8 个场景里有 7 个尾延迟更低。离线批处理(所有程序一开始就提交)时完成全部程序的时间少 10%–40%;4,000 个程序时 MLFQ 因为活跃调用太多、频繁换出,即使有 1.2 TB 的换出空间也内存溢出了。
4. 两条路线的对比 · Parrot vs Autellix
| Parrot | Autellix | |
|---|---|---|
| 应用级信息从哪来 | 开发者用语义变量声明(提示结构、数据流、性能目标) | 服务端在运行时观察(会话、调用的完成时间) |
| 程序结构 | 静态的 DAG,提交时就知道(不支持动态控制流) | 动态的 DAG,运行时逐步构建,完全不预知 |
| 程序在哪里运行 | 编排逻辑在服务端执行(省掉往返) | 程序在用户本地运行,只把调用发给服务端 |
| 优化目标 | 应用声明的:端到端延迟或吞吐 | 程序的端到端延迟(程序级 token 延迟) |
| 核心调度思想 | 按目标分组、同一应用放在一起、利用共享 | 最少已获服务优先(PLAS / ATLAS) |
| 共享 / 局部性 | 前缀哈希 + 共享前缀的注意力内核 | 长调用钉在程序的引擎上 |
| 代价 | 要改编程接口;动态控制流和原生函数还在客户端 | 不知道程序还剩多少,和 SRPT 有差距;抢占带来换出开销 |
两者其实互补:Parrot 说明了”服务端如果知道数据流和目标,能做什么”,Autellix 说明了”即使不知道,只靠观察也能做很多”。一个设计良好的智能体服务很可能两者兼有:程序声明它能声明的(数据流、目标、共享的上下文),服务端观察它声明不了的(实际的长度、已获服务)。
5. 系统视角 · A systems view
5.1 经典调度思想的回归
这一讲出现的几乎所有调度概念都来自操作系统和集群调度:
- 最少已获服务优先(LAS)与多级反馈队列(MLFQ):在不知道作业长度时,用”已经跑了多久”来猜,让短作业先走;
- 最短剩余处理时间(SRPT):已知长度时的最优;
- 关键路径:DAG 作业(例如 Dryad、Tez 这类数据分析系统)的完成时间由它决定;
- 数据局部性:把计算派到数据所在的地方(MapReduce 的调度、延迟调度);
- 任务组、拓扑序、防饿死、进程表、有状态会话。
LLM 推理把这些老问题带回来,同时加上了新的约束:每个”作业”会持有一份随时间增长的显存状态(KV cache),抢占意味着换出这份状态;同一程序的作业之间共享大量前缀,派错地方要重算。
5.2 调度与缓存的相互作用
代码 02 揭示了一个两篇论文都没有讨论的现象。高负载下(聊天 3 程序/秒、ReAct 3 程序/秒):
| 负载 | 策略 | 程序级延迟 | 前缀缓存命中率 | 进行中的程序 |
|---|---|---|---|---|
| ReAct | 先来先服务 | 0.109 | 0.997 | 92.0 |
| ReAct | MLFQ | 0.101 | 1.000 | 97.5 |
| ReAct | PLAS | 0.221 | 0.847 | 115.2 |
| 聊天 | MLFQ | 0.079 | 1.000 | 207.2 |
| 聊天 | PLAS | 0.112 | 0.709 | 214.6 |
MLFQ 让每个程序的下一个调用一到达就进最高优先级,这个程序的上下文刚刚还在用、仍在缓存里,所以命中率始终是 1.000——哪怕同时进行的程序有 200 多个。关键不是同时有多少程序,而是同一个程序相邻两次调用之间隔了多久。PLAS 恰恰把上下文最长的长程序往后放;它们排队期间,上下文被 LRU 挤出缓存,轮到时要把几千 token 的历史整段重算——本来就被延后,现在还变得更贵,过载时陷入恶性循环。
调度器看的是”服务时间”,缓存看的是”最近用过没有”,两者各自合理,合在一起却互相拖累。Autellix 的防饿死在这里帮不上忙(β = 3 时 ReAct 反而从 0.221 恶化到 0.805:一批饿久了的长程序同时被提上来,而它们的缓存早已没了);让缓存优先保留”进行中程序”的上下文有一点帮助(ReAct 0.221 → 0.192),但缓存本身不够大时补救不完全。更彻底的办法需要让两者协同:把”缓存是否还在”算进优先级,或者在程序等待工具时给它的上下文设一个保留期。
5.3 更丰富的接口
两篇论文都在扩展推理服务的接口:Parrot 的 submit / get 与语义变量,Autellix 的会话与线程编号。这呼应了第 3 讲的 SGLang 前端语言(fork / join、gen、共享前缀)——应用和服务之间需要比 Completion(prompt) 更丰富的契约,才能让服务端做全局优化。代价是生态:开发者要么改写程序,要么接受”只能观察”带来的上限。
6. 之后的发展 · Since then
- 推理模型与长程智能体让程序更长:推理模型一次生成上万个思考 token,编程和网页智能体一次任务调用几十到几百次工具。第 10 讲的 DeepSeek-V3.2 专门规定了工具调用之间保留思考内容的规则,部分原因就是让前缀缓存能命中(第 10 讲练习 8)。
- 工作流感知的 KV 管理:在智能体等待工具结果时保留它的 KV、按工作流的下一步预取或预热缓存,是近来研究的方向——正是 §5.2 揭示的问题。
- 推测执行:Autellix 在讨论中提出,虽然完整预知程序不现实,但预测”下一步”是可行的,可以让未来的调用在前面的调用完成之前就开始;Parrot 也提出按历史剖析预先启动高概率的分支。
- 强化学习的 rollout:Autellix 指出,推理模型的强化学习训练要大量采样多步轨迹,更好的程序级调度能直接缩短每一轮采样的时间——这是第 15 讲的主题。
7. 小结 · Summary
- 智能体和复合 AI 系统把推理服务的负载从”独立请求”变成”程序”:多次调用、依赖与并行、中断、累积的上下文,而且结构动态、事先不可知。
- 以请求为单位的服务丢掉了依赖、目标、共享、程序归属和局部性,分别造成往返与重新排队、错误的批大小、重复计算、程序级排头阻塞和跨引擎的重算。
- Parrot:语义变量让应用把提示结构和数据流交给服务端;服务端执行依赖(代码 03:链式摘要 1.13–1.45 倍,有背景流量时 2.15 倍)、从最终目标反推任务组用大批(map-reduce 1.28–1.83 倍)、按前缀哈希共享、按目标把请求分开放。
- Autellix:把程序当作一等公民,按程序已获得的服务(PLAS)或关键路径上的服务(ATLAS)排序,配合离散队列、防饿死、多步调度和局部性感知的路由。接近饱和时它确实最好(代码 01:混合负载 0.030 vs FCFS 0.077、MLFQ 0.049),短程序几乎不用等;相对 vLLM 的大倍数主要来自前缀缓存。
- 调度与缓存会相互作用:在我们的模拟里,过载时 PLAS 把长程序延后,让它们的上下文被挤出缓存,反而不如 MLFQ(代码 02)。经典调度思想需要和 KV cache 这种”随作业增长的状态”一起重新考虑。
8. 常见误区 · Common pitfalls
- “智能体的请求和普通聊天请求一样,按请求调度就行。” 依赖、目标、共享和程序归属都被丢掉了,代价可以是数倍(代码 01、03)。
- “抢占式调度(MLFQ)已经解决了排头阻塞。” 它解决的是调用级的;长程序的每个新调用又从最高优先级开始,程序级的阻塞仍在(代码 01 演示 1)。
- “最少已获服务优先总比先来先服务好。” 接近饱和时是;过载时它把长程序延后,与前缀缓存的 LRU 相互作用,可能更差(代码 01、02)。
- “每个请求都应该追求最低延迟。” 应用关心的是整个任务的完成时间;中间的并行请求应该用大批(代码 03 演示 3)。
- “服务端执行依赖只是省了网络往返。” 有其他流量时,更大的收益来自不用重新排队(代码 03 演示 1)。
- “Parrot 的分组调度总能让所有应用都更快。” 平均更快,但让一部分应用先完成,就可能有别的应用后完成(代码 03 演示 2)。
- “多引擎时按负载均衡派发就好。” 程序的上下文在哪个引擎上,决定了下一次调用要不要重算(代码 04)。
- “Autellix 相对 vLLM 快 4–15 倍,全是调度的功劳。” 很大一部分来自对照组没有打开前缀缓存(代码 01 演示 2)。
9. 练习 · Exercises
练习 1、3、4、5 的参考答案里的数字来自代码的实际输出或
agent_sim.py的函数。
练习 1(图 2 的先来先服务)。按 §3.1 的四个程序、批大小 2,手工排出先来先服务的执行过程(同一时刻释放的调用按程序名排序),算出总等待时间。
参考答案
t = 0–3:A1、B1 运行(C1、D1 等待,C1 等到 t = 3、D1 等到 t = 4)。t = 3:B1 完成,B2 释放;队列里 C1(0)、D1(0)、B2(3),C1 先运行(t = 3–4)。t = 4:A1、C1 完成,A2、C2 释放;按释放时刻 D1(0)、B2(3)先运行:D1 4–8,B2 4–7。t = 7:B2 完成,B3 释放;A2(4)先运行 7–10。t = 8:D1 完成,C2(4)运行 8–10。t = 10:B3(7)运行 10–14,A3 运行 10–11,A4 11–12。等待:C1 3、D1 4、B2 1、A2 3、C2 4、B3 3,合计 18,与论文和代码 01 一致。注意 B3 在 t = 7 释放时不能直接接上——第一版代码就错在这里(README 第 1 条)。
练习 2(ATLAS 的优先级)。一个程序的 DAG:根调用 R 执行 2 秒;R 之后并行的 X(3 秒)和 Y(1 秒);Z 依赖 X 和 Y,执行 2 秒。(a) 按公式 (2),X、Y、Z 到达时的优先级各是多少?(b) 关键路径是哪条、多长?(c) 进程表里只存”已观察到的最长关键路径”这一个数,Z 到达时它是多少?
参考答案
(a) X、Y 的父调用都是 R:;Z 的父调用是 X、Y:。(b) R → X → Z,2 + 3 + 2 = 7 秒。(c) R 完成时记 2,X 完成时更新为 5,Y 完成时它的路径 3 < 5 不更新;Z 到达时进程表里是 5,与公式一致。这个程序在调度器眼里的”年龄”是 5 秒,而不是总服务 2 + 3 + 1 = 6 秒——并行的 Y 不算进去,因为它不在关键路径上。
练习 3(链式摘要省了多少)。代码 03 演示 1 里,16 块的链式摘要无背景流量时,以请求为单位 19.04 秒、Parrot 15.24 秒。网络往返在 0.2–0.3 秒之间均匀分布。(a) 理论上省下多少?(b) 为什么输出越短,加速越大?
参考答案
(a) 第一块不依赖别人,后 15 块各省一次往返,平均 0.25 秒,合计约 3.75 秒;实测相差 3.80 秒(随机往返的抽样误差)。(b) 往返的绝对时间不变,而每一步的生成时间随输出变短而变短,往返占的比例变大:输出 25 token 时 1.45 倍,100 token 时 1.13 倍(论文也观察到输出越长、优势越小)。
练习 4(map-reduce 的容量)。每个 map 请求的提示约 1,084 token、输出 50 token。(a) 引擎容量限制在 4,096 token 时,最多同时运行几个 map 请求?16 个 map 请求大约要分几轮?(b) LLaMA-13B 在 A100-80GB 上的 KV 容量约 56,113 token,不限制时呢?
参考答案
(a) 每个请求最终约 1,134 个 token,4,096 / 1,134 ≈ 3.6,最多 3 个同时运行,16 个要约 6 轮。(b) 16 × 1,134 ≈ 18,000 token,远小于 56,113,可以一次全部放进批里。decode 是 memory-bound 的:每一步要把权重读一遍,批从 3 个变成 16 个,每步时间增加不多,总时间却少了很多——代码 03 里 16 块时 10.54 秒 → 6.70 秒。
练习 5(PLAS 的离散队列)。代码里 PLAS 的队列边界是 0.5、2、8、32 秒,各队列的时间片是 16、32、64、128、256 个 decode 步。(a) 一个程序已获得 1.2 秒的服务,它的新调用进哪个队列、时间片多长?(b) 一个刚到达的程序呢?已获得 40 秒的呢?(c) 为什么要离散成队列,而不直接按连续的服务时间排序?
参考答案
(a) 1.2 秒落在 [0.5, 2) 里,进第 2 个队列(编号 1),时间片 32 步。(b) 新程序服务为 0,进最高队列、时间片 16 步;40 秒的程序进最低队列(编号 4),时间片 256 步——低优先级的队列时间片长,一旦轮到就多跑一会儿,减少切换。(c) 连续优先级下,几个程序会不断地互相超越、互相抢占,退化成轮转;每次抢占都可能要换出 KV、浪费 GPU。离散成队列后,同一队列内先来先服务,只有跨越边界时才改变相对顺序。
练习 6(防饿死)。一个长程序已获得 20 秒服务,已经等了 50 秒;它的当前调用已执行 1 秒、等了 15 秒。β = 3 时会不会被提到最高队列?提上来以后为什么只清零这个调用的等待和执行时间?
参考答案
,会被提上来。只清零调用自己的计数、保留程序的累计值,是为了让这个程序的其他活跃调用(它们共享程序的累计等待与服务)在接下来的检查里也很可能被提上来——一个程序的线程一起前进,不会因为只提了一个而被剩下的拖住。代码 02 也提醒我们,防饿死与缓存有交互:被饿久了的程序,上下文可能早已被淘汰。
练习 7(局部性路由的阈值)。一个应用的系统提示 1,500 token,ReAct 程序每次调用新增约 250 token 的工具结果。(a) 按 Autellix 的规则(≤ 2,048 给最空的引擎,否则钉住),一个程序的前几次调用会怎样被派发?(b) 如果系统提示是 3,000 token 呢?这说明了阈值的什么问题?
参考答案
(a) 第 1 次约 1,500 + 用户输入,第 2 次约 1,500 + 250 + 输出……大约到第 3 次就超过 2,048,开始钉在程序被派到的引擎上。前两次去最空的引擎,那里只要缓存了系统提示就能命中大部分。(b) 系统提示 3,000 token 时,第一次调用就超过阈值、被钉住;可它的输入几乎全是系统提示,在任何引擎上都能命中,钉住没有收益、只有可能的负载不均。阈值本应区分”主要是共享前缀”和”主要是程序自己的历史”,用一个固定的 token 数只是近似;更好的规则是看”这个程序自己的上下文占多少”。
练习 8(思考:缓存感知的程序级调度)。根据 §5.2,设计一个同时考虑”已获服务”和”缓存是否还在”的优先级规则。它在什么情况下比 PLAS 好?会带来什么新问题?
参考答案(要点)
一种做法:优先级 = PLAS 的优先级 − λ × “如果现在不运行,这个程序的上下文被淘汰的风险 × 重算代价”;或者更简单,让在缓存里的程序的调用”免费”地前进一档队列,而淘汰时优先淘汰已结束的程序、再淘汰最近不会轮到的程序。它在过载、程序上下文很长的负载(多轮聊天、ReAct)下比 PLAS 好,因为避免了”延后 → 被淘汰 → 更贵 → 更延后”的循环。新问题:长程序更容易一直占着缓存和优先级,短程序又会被挡住(回到 MLFQ 的问题);而且调度器要知道缓存的状态,在多引擎、分层缓存(第 9 讲)的系统里这本身就需要协调。
练习 9(思考:Parrot 能不能支持动态控制流)。Parrot 只在服务端执行没有动态控制流的编排,条件分支和原生函数还在客户端。为什么?如果要支持,会怎样做、有什么风险?
参考答案(要点)
原因是安全:把任意的用户代码放到公有 LLM 服务端执行,就要防范恶意注入,需要沙箱(第 12 讲的安全容器)和资源隔离。可信的私有部署或有可信执行区时,可以扩展接口:允许条件连接和原生函数提交,服务端在沙箱里执行;这还打开了新的优化,例如按历史剖析推测执行高概率的分支、提前开始下一步。风险包括:沙箱的冷启动和开销(第 12 讲)、推测执行浪费的计算、以及调度器要为不确定的分支预留资源。Autellix 的选择则相反:程序留在客户端,服务端只观察。
10. 延伸阅读 · Further reading
本讲的必读材料
- M. Luo, X. Shi, C. Cai, T. Zhang, J. Wong, Y. Wang, C. Wang, Y. Huang, Z. Chen, J. E. Gonzalez, I. Stoica. Autellix: An Efficient Serving Engine for LLM Agents as General Programs. arXiv:2502.13965,2025。
- C. Lin, Z. Han, C. Zhang, Y. Yang, F. Yang, C. Chen, L. Qiu. Parrot: Efficient Serving of LLM-based Applications with Semantic Variable. OSDI 2024(arXiv:2405.19888)。
选读
- S. Hong et al. MetaGPT: Meta Programming for a Multi-Agent Collaborative Framework. ICLR 2024。
- O. Khattab et al. DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines. arXiv:2310.03714,2023(ICLR 2024)。
相关工作
- B. Wu et al. Fast Distributed Inference Serving for Large Language Models(FastServe,跳级 MLFQ). 2023。
- L. Zheng et al. SGLang: Efficient Execution of Structured Language Model Programs. NeurIPS 2024(第 3 讲)。
- S. Yao et al. ReAct: Synergizing Reasoning and Acting in Language Models. ICLR 2023。
- A. Zhou et al. Language Agent Tree Search Unifies Reasoning, Acting, and Planning in Language Models(LATS). ICML 2024。
- R. H. Arpaci-Dusseau, A. C. Arpaci-Dusseau. Operating Systems: Three Easy Pieces,“Scheduling: The Multi-Level Feedback Queue”一章。
背景
- 第 2 讲:迭代级调度与抢占;第 3 讲:前缀缓存与 SGLang;第 4 讲:级联注意力;第 11 讲:前缀感知路由;前置讲 P3、P4:进程调度与排队论。
11. 下一讲预告 · Next lecture
智能体要完成长任务,就要”记住”很多东西:之前的对话、读过的文档、用户的偏好、做过的尝试。上下文窗口是它的工作记忆,可窗口有限,而且这一讲已经看到,上下文越长,每次调用越贵、缓存越难保住。第 14 讲:记忆读四篇论文:MemGPT 借鉴操作系统的虚拟内存,把上下文分成”主存”和”外存”,让 LLM 自己通过函数调用换入换出;mem0 从对话里抽取、合并、检索关键记忆(增、改、删、不变);Cartridges 离线用”自学习”把一个大语料压缩进一个小 KV cache,用离线计算换推理时的显存和吞吐;ACE(Agentic Context Engineering)让上下文像一本不断修订的”手册”一样演化。我们会从系统的角度比较三种做法——把东西放进上下文、检索出来、还是写进参数——各自的显存、吞吐和准确率代价。