如何为万亿参数编程 agent 服务数万亿 token
Modal 的工程团队拆解如何为万亿参数的编程 agent 服务数万亿 token:从推理基础设施、token 缓存到调度与批处理,逐层讲清支撑这一规模的做法。
中文
复制

软件正在吞噬世界,而智能体正在吞噬软件工程。软件工程师必须理解的,不只是智能体软件——如今他们最重要的工具——还要理解智能体软件的智能核心如何运转,也就是大型生成式语言模型的推理。这倒不一定是因为工程师需要理解和掌控自己的工具,至少也是因为推理即将消耗比计算机其他所有用途更多的算力、产生更大的收益。为编程智能体提供推理服务,核心事实是必须在相对性能和绝对性能上都做到极高。相对性能指的是达到所用硬件峰值速率或“光速”的很大一部分。绝对性能指的是峰值速率本身的量级大,且每个请求完成的工作量大。如今像 Tensor Cores 这样的矩阵运算加速器运行在每秒 petaFLOP 量级。智能足以自动化软件开发的大型生成式序列模型拥有数万亿个浮点参数,即使只服务一个请求,每个参数每秒也必须被访问多次。由于这些要求,经济上可行的编程智能体推理服务目前只有在规模足够大、能够摊薄硬件和工程成本时才成立——大致是数万亿输入和输出 token 的规模。我们做到了,也愿意分享做法。在 Modal,我们运营着若干这个规模的编程智能体推理服务,也与多家做同样事情的客户合作。你可以通过 OpenRouter 或 Vercel AI Gateway 这样的推理路由平台间接使用我们的服务,也可以直接通过我们的 Shared Endpoints 使用。这篇博客将逐步介绍,我们在为编程智能体提供 Moonshot AI 的 Kimi K2.6 模型推理时,是如何优化推理性能的。按这个领域的标准,这个模型已经“老”了(字面意义上的几百天!),但序列建模、硬件和扩展的基本原理变化得足够慢,核心脉络和许多细节与我们为更新的模型所做的工作一致——那些模型在智能和性价比上已经取代了 K2.6,比如 Kimi K3。我们的优化让推理副本的单副本性能提升了:每个用户 2.8 倍,副本上跨用户 5.6 倍:

这张图把单个用户的体验(每用户每秒解码 token 数,即 交互性)放在 x 轴,把整个系统的性价比(每 GPU 每分钟总 token 数,即 token 吞吐量)放在 y 轴,每个点上标注了并发用户数。说得更直白一点,这就是下面右边那种 UX、贵得离谱的服务,和左边那种 UX、价格有竞争力的服务之间的差距:随后我们把这些单容器副本扩展成了 deployment 和 service。其中一个 service 每天处理数千亿 token,累计达到数万亿:

下面我们想让这段性能工程对普通软件工程师也能读懂。通过分享我们和我们的客户是如何运行这些服务的,希望能帮你做到同样的事——比如在 Modal 上部署一个 Dedicated Endpoint。
先理解工作负载。
我们把它拆成两部分:理解对每个请求的响应做推理的序列模型,以及理解跨请求的工作负载结构。
最前沿的 coding agent 背后是万亿参数神经序列模型,它们并行处理输入、串行推理输出。
当代 coding agent 由 Unicode 序列的概率生成模型驱动,这些模型主要经过无监督掩码序列预测的预训练,再主要通过对输出软件正确性的强化做后训练。和编译器的 parser 一样,它们处理的不是原始字符串,而是 token 化后的序列,所以我们把它们的输入和输出称为 token。因为归根结底我们是在猜输出 token 应该是什么,所以这叫 推理。如果你更喜欢演绎,那就去用数据库和操作系统吧。如今底层的序列模型是混合注意力、混合专家(mixture-of-experts)的 Transformer 神经网络。这些网络既做 序列中每个 token 内部 的计算,也做 序列中 token 之间 的计算。注意力 已经演变成一个泛指跨 token 计算的词。混合专家 指的是动态路由的块稀疏矩阵乘法,每 token 的大部分计算都由它完成。这些计算会迭代更新网络的内部表示,也就是 隐 表示。想深入了解这些序列模型内部在发生什么,可以看 Anthropic 的《A Mathematical Framework for Transformer Circuits》(2021 年的文章,至今仍未被超越)。对这样一个神经网络做一次 前向传播,既会产生大量内部状态,也会为序列中的每个位置给出下一个(或下几个)token 的概率分布。因为我们是在自己的输出(“auto”)基础上做预测(“regress”),所以这叫 自回归 序列建模。要响应一个客户端请求,我们通常会把多次前向传播串起来,像这样:

前向传播开销很大,所以要尽量摊薄。逐 token 计算的开销,很大一部分靠把多条序列 _ 批处理 _ 在一起来摊薄;跨 token 计算的开销,则靠 _ 缓存 _ 内部状态来摊薄。出于历史原因,这被称为 _ key-value cache _(KV cache,或直接叫 _ KV _),尽管像 Kimi 这样的当代模型已经没有独立的 key 和 value 了。关于这里的“餐巾纸数学”,可以读 Kipply 那篇很出色的《Transformer Inference Arithmetic》(2022 年写的,至今仍未被超越)。前向传播处理一个请求的输入 token 时,我们称之为 _ prefill _,因为它在“预填充”KV cache;前向传播产生响应的输出 token 时,我们称之为 _ decode _,因为我们在把模型对历史状态的“编码”“解码”成对未来的预测。那两者兼有的前向传播呢?是啊,我们也不喜欢这套术语。Prefill 性能主要看完成一个请求全部 prefill 的延迟,也就是首 token 时间(TTFT);decode 性能主要看之后输出 token 的生成速率,也就是每秒输出 token 数(TPS)。两者都可以在客户端或服务端测量,由此引发的混乱没完没了。本文涉及的特定序列模型是 Moonshot AI 的 Kimi K2.6。该模型的矩阵乘法由大约一万亿个数字参数化(矩阵中的 _ 权重 _),其中大部分以四比特整数(INT4)存储。但我们用四比特浮点数(FP4)来服务这个模型。四比特只能给出十六个不同的值,因此还需要一种微缩放格式,对张量内部的各个块独立缩放。我们选择了 NVFP4 微缩放格式,它在 Blackwell Streaming Multiprocessor Architecture GPU(如 B200 和 B300)的 Tensor Core 中有原生硬件支持,算力可达 petaFLOP/s 量级。由于我们运营的是动态 GPU 集群,而当下算力供给紧张,我们的部署同时准备在 B200 和 B300 上运行。下面的结果全部来自 B200 GPU;B300 实质上类似,但由于可用于缓存的高带宽内存(HBM)更多,请求并发度更高。我们选择 SGLang 推理引擎作为基础。我们发现了几处可以通过给引擎打补丁来提升性能的机会。作为 SGLang 项目的贡献者,我们把这些补丁都上游了,下文会逐一描述并给出链接。
要优化用户体验和性价比,就必须理解这些跨请求序列的结构。
把这类模型直接拿来服务 coding agent 流量,结果会很糟。

这张图说明,并发用户超过 6 个之后,吞吐和交互性都会迅速崩塌。而且即便还没到那个峰值,交互性也已经低于用户预期,系统效率也谈不上合格。所以接下来,你要在降低自身成本的同时,提高交互性和吞吐,给用户更好的结果。要做到这一点,就得比“进 token、出 token”更深入地理解这个负载里的序列。单个输出 token 请求是在“会话”中产生的:用户、生成模型和工具调用反复串联,一层层堆出输入序列,上下文——以及价值——随时间不断累积。这种意义构建、信息发现与理解形成的迭代过程,在我们看来正是序列建模和序列行动的本质,因此我们预计这种模式的生命力会远远超出“coding agent”。具体来说,一个会话大致是这样:

也就是说,第 T 轮的输入序列(绿色)是截至 T 的整个会话历史(深绿色)加上新增的部分(浅绿色)。这带来两个关键后果。第一,请求的输入序列相对于输出序列(上图粉色)天然很长——第 T 轮的输入里有 T-1 个过去的输出序列,而 T 有几十。我们在优化中使用的核心负载、也是生产环境所服务的负载,这个比例是 200:1;请求大约包含 100k 输入 token,产出大约 500 输出 token。这意味着处理掉的 token 大部分都是输入 token(看看你 coding agent 软件里的 token 用量就知道了)。第二,输入序列与之前处理过的输入序列高度重叠——也就是第 1 轮到第 T-1 轮的那些。这意味着在服务第 T 轮的过程中,第 1 轮的 token 被处理了 T 次。这让缓存变得至关重要——我们可以避免线性增长的重算来省力,但同时也引入了线性增长的状态,这些状态必须被管理,而且有它自己的性能特征。如何权衡这一点,就是本文要处理的核心工程问题。 有了这幅负载图景,我们转向优化。
然后,优化单个副本。
要优化性能,先搭出一个能跑的系统,找到瓶颈,然后把它抬上去。重复这个过程,直到赢为止。虽然我们的最终目标是优化整个服务,但我们把这个问题拆成了两个更简单的问题:先优化单个副本,再从单副本扩展到多副本。我们又把单副本性能问题拆成两个子问题:先最大化交互性,再在不牺牲交互性的前提下最大化吞吐量。交互性主要影响请求延迟。请求延迟和吞吐量通过_并发度_——即同时在途的请求数——相互关联,这来自Little's Law的一个变形:

我们在延迟、并发度和吞吐量上的关键瓶颈都始于 GPU HBM。延迟上的关键瓶颈是解码阶段的 HBM 带宽。我们通过把矩阵乘法跨 GPU 并行化(张量并行,TP),以及应用自研的 DFlash 投机解码——每次内存加载做更多计算,哪怕这些计算未必用得上——把它抬了上去。这又在并发度上制造了瓶颈,卡在 HBM 的_容量_上:我们能把多少工作留在缓存里,而缓存加载比直接重算结果更快。我们通过清理 HBM 中的中间结果、把中间结果量化到更低的浮点精度,以及用 HiCache 把缓存层级扩展到 CPU RAM,把它抬了上去。我们用缓存命中率(CHR)作为衡量缓存改进的针对性指标。对大多数编码 agent 负载来说,CHR 达到 1 个 9 到 2 个 9 完全可行。
我们从最大化交互性开始。
提高交互性,就是提高单个用户所感受到的系统性能。我们选择先做这件事,有几个原因。第一,也最简单:我们发现编码 agent 用户喜欢更快的 token,也愿意为更快的 token 付更多钱,所以高交互性是打造我们和客户都想要的服务的关键。第二,交互性特别适合用投机解码来改进。因为它是一种简单的、基于学习的技术,性能收益会随算力和数据一起扩展:机器学习著名的“苦涩教训”,在 ML 系统的性能工程里又回来了。而且我们知道怎么扩展训练。选择把交互性拉满还带来两个额外好处,一个在运维上,一个在吞吐量上,这两个好处尤其重要,因为我们运营着一支动态、自动扩缩容的集群,包含数千块 GPU。交互性最大化的副本更小,因此更容易服务。 把多个处理器放在一起用,需要一张互连网络(interconnect)来通信。Nvidia GPU 上延迟最低、带宽最高的互连是 NVLink。NVLink 在一个“域”内的一组处理器之间工作,域有大小上限。单个主机操作系统最多支持 8 块 GPU 的 NVLink 域。目前普遍可用的最大 NVLink 域包含 72 个加速器(位于多节点的 IMEX 域中)。要用更多加速器,就得换更慢的互连(IB/RoCE,或者更糟的普通以太网)。这意味着,为了最大化交互性,我们不应指望每个副本用超过 72 个加速器——通信开销几乎肯定会盖过任何单请求延迟上的收益。但这并不意味着我们_必须_用满 72 个加速器。看看 SemiAnalysis 针对同一个 NVFP4 Kimi K2.6 模型做的 InferenceX 基准测试结果,它们展示了多种部署下每 GPU 吞吐量随交互性变化的曲线,并标注了 GPU 数量:

每个副本只用八块 GPU 的部署方式,交互性最高。而且这种交互性是在每 GPU 吞吐量相当的情况下实现的,也就是说,选择更小的域并不会明显牺牲峰值吞吐的性价比(在我们那条交互性约束之下)。为了让图表保持可读,我们只挑了一小部分与自身最接近的部署,但这个规律在更多加速器类型、更多模型上都成立,可以在 InferenceX 基准测试里自行查看(点这里)。一般来说,只用四块或八块 GPU 就能在每 GPU 吞吐量相当的前提下拿到最高的交互性,再通过横向扩展更小的副本得到相同的总吞吐量。Modal 无服务器平台的核心让这种扩展既高效又可靠。
这是运维上的巨大胜利。单元更小、更简单,扩展起来就更容易。八块 GPU 可以由一个宿主 OS 内核驱动。而一个 NVL72 域由九个这样的子系统组成,共享同一个地址空间(是的,你应该感到不寒而栗)。可用性受限,合同周期长且不灵活。相比之下,八卡 Blackwell 系统足够标准化,可以通过按需和 spot 市场获得,这让应对波动负载的性价比高得多。一块、两块或四块 GPU 的副本还可以塞进单台八卡物理机里——这台机器已经具备再启动一个副本所需的全部资源(模型权重、JIT 产物)。当然,如果算力供给和用户需求发生变化,我们很乐意重新审视这个选择。
提高交互性会间接提高吞吐量。 降低单个请求的延迟,就腾出了资源给新请求,从而间接提升吞吐量。Agentic 编码负载在单个会话内大致是“闭环”的。会话几乎总是链式的——用户输入的 token、工具调用的响应、模型的输出。因此,会话中的下一个请求几乎总是在上一个响应生成完毕之后一段时间才到达。这段时间里,会话的下一个请求处于潜伏状态,不在推理系统内——工具调用是几十毫秒到几秒,尾部可达几分钟;用户回复是几秒到几分钟,尾部可达几小时甚至更长。在这段时间里,同一个节点可以处理其他请求。当负载足以占满活跃容量时,节点总有请求可处理;当容量足以承载活跃负载时,请求总有节点可映射。这两点都由我们快速自动扩缩容系统保证。请求路由的更多细节,我们会在扩展到多副本那一节里讲。
用自定义投机解码,在交互性瓶颈上每次多干一点活
交互性衡量的是每个用户每秒能拿到多少输出 token。按最朴素的理解,像 Transformer 这样的自回归序列模型是一个一个顺序产出 token 的。Amdahl 那条令人心碎的定律又来了。每产出一个 token,都要把几十亿字节甚至更多的模型权重和 KV cache 从 GPU HBM 搬到流式多处理器 L1 缓存,而这通常比真正计算下一个 token 的 KV 状态和输出还要慢。瓶颈就卡在内存带宽上。并行确实能带来更多带宽,但它对逐 token 的计算更有用,对跨 token 的计算帮助不大——而在长序列上,跨 token 计算恰恰会成为瓶颈,编码类 agent 的工作负载就是明证。于是我们换了个思路,用投机解码把这个瓶颈抬高一档。说到底,投机解码做的权衡和处理器里的投机执行是一样的:当操作之间存在串行依赖、因而空出运算带宽时,你可以拿这些带宽去跑一些最后未必用得上的操作。只要能以较高概率猜中会被用到的操作,有效运算吞吐就会提升,而关键就在于用尽可能少的代价把这个概率做上去。放到自回归序列模型的推理上,每次迭代多跑一些操作的“诀窍”,是用另一个更快的语言模型(“投机器”或“草稿模型”)去猜接下来若干个 token 是什么,再让被服务的模型(“目标模型”或“验证器”)并行地验证这些猜测。

和投机执行一样,这种加速不改变程序行为,也就是不改变目标序列模型的概率分布。正如我们在发布 Qwen 3.5 和 3.6 系列模型 speculators 的博客文章中所说,投机解码带来的收益很大——是成倍的提升,而不是百分之几十。

反直觉的是,平均而言,让一个投机模型预测输出中接下来四个、八个甚至更多 token 是相当容易的,尤其是在 coding agent 这类负载上。原因大致有两个:目标模型为投机模型创造了有利条件,而且大多数 token 并不需要动用目标模型的全部智能。
投机模型可以复用目标模型的计算结果。
首先,目标语言模型在前向传播过程中已经为序列生成了极其有用的表示——从每个 token 的静态 embedding 开始,模型的每一层都逐步丰富这一表示,直到最后的“语言建模头”层把表示变成下一个 token 上的分布。更妙的是,这些表示已经存在 KV cache 里了。像 DFlash 这样的最先进投机架构(以及 DSpark 之类的衍生方案)直接把这些状态当作输入复用,因此它们可以比目标模型小几个数量级(也快几个数量级):站在巨人的肩膀上,指出巨人下一步可能走向哪里。
Token 序列重复度高,信息密度低。
看看下面这段 coding agent 的示例输出:
You're absolutely right! I should have read that file before I
"straight yeeted it to prod". Checking now.
Tool call: Read src/config.py L42-L96
用过近期模型的人都能猜到 You’re absolutely 后面会接什么(绝不会是 wrong)。而且这段引文来自之前的用户输入,所以引号一打开,接下来的 token 就变得高度可预测。
再往深一层看,想想这段序列用模型的“chat template”加上特殊控制 token 格式化之后是什么样子:
You're absolutely right! I should have read that file before I "straight yeeted it to prod". Checking now.
functions.Read:0
{"path":"src/config.py","line_start":42,"line_end":96}
这段序列有大量结构,生成它们并不需要多高的智能。当然,结构内部的细节仍然影响正确性,所以目标模型的能力依然重要!
那么,目标模型的大部分容量很可能都用在了丰富这些 token 的表示上,以便预测许多步之后的 token。如果你已经知道接下来若干个 token 是什么,就可以并行计算它们的表示。
这不是什么奇技淫巧:同时提供并行和串行的前向传播,是现代序列模型相对于传统循环神经网络的根本特性。无论是“经典”Transformer 还是线性/混合注意力模型都具备这一点,因此可以预期它会长期存在。
出于这个原因以及其他考虑,我们在投机解码上投入了大量资源,也建议你这么做。
自定义投机器可以大幅提升接受长度。
最快的投机器不仅要学会预测目标模型的总体行为,还要学会预测它在特定数据集上的行为。投机器体积小,建模容量有限,而这份容量应当只用在生产环境中真正会出现的情况上。给懂 ML 的人补一句:投机器的损失是相对目标模型的 Kullback-Leibler 散度,这鼓励它寻找众数,而不是覆盖整个分布。
但和神经网络的一般规律一样,我们的实验表明,更好的做法是先从一个强大的基础出发,再把投机器适配到具体任务上——也就是微调。因此我们先在通用数据混合上训练了一个用于 Kimi K2.6 的 DFlash 投机器,然后用目标模型输出的代码轨迹对它做微调。之后在服务生产流量时,还可以用目标模型的输出持续训练这个草稿模型。
在真实流量上运行时我们遇到一个问题:把 token 映射成字符串再重新分词,并不是一个恒等映射,因为分词本身就是个糟糕的 hack。但常见的日志记录方式,比如 HTTP 请求日志,处理的是字符串而不是 token。于是我们修改了 SGLang,让它通过 sglext 输出原始 token id,并把这部分工作贡献到了上游。
在具有代表性的轨迹上,微调把接受长度从每步 5.00 个 token 提升到 5.84 个,带来 20% 的额外加速。
张量并行是实现最高交互性的最佳并行策略。
给一个慢任务加人手会让它更慢,但计算机没有这个弱点——前提是你把工作并行化、把数据分片分对。
序列模型推理的主要并行策略把工作拆在:
- 单个请求内部,跨模型前向传播(prefill-decode 分离),
- 单次模型前向传播内部,跨层(流水线并行),
- 一批请求内部,跨序列(数据并行),
- 单个序列内部,跨 token(上下文并行),
- 单个模型层内部,跨矩阵乘法(专家并行),以及
- 单次矩阵乘法内部,跨行/列(张量并行)。
这些选择里,只有上下文并行、专家并行和张量并行是在单个请求内部拆分工作,因此能直接改善交互性。张量并行(TP)是最底层的并行——再往下就是 kernel 执行内部的并行,那是 legion 的领域,不在本文讨论范围内(我们在别处分享过这方面的一些工作)。这意味着 TP 与其他策略组合时效果更好,因此是个不错的第一个目标。
展开说:张量并行把矩阵乘法的输入拿过来,把输出处理的工作拆给多个并行 worker,这些 worker 因此可以分片存储处理所需的矩阵数据,也就是模型权重。更多内容见 Megatron 论文(2019 年的,但至今无人超越)。
尽管有这套第一性原理的论证,我们还是考察了其他多种并行策略,因为 1)交互性会间接受其他地方的优化影响,2)你不知道你不知道什么。不过我们发现,Tensor Parallelism Is All You Need™,交互性拉满。比如,我们发现数据并行注意力让我们通过分片 KV cache 实现了更高的并发,但延迟更差。事实上差到让每 GPU 的整体吞吐都下降了,尽管并发是提高了。
选择并行策略的同时,还要决定并行 worker 的数量。Kimi K2.6 跑在 B200 GPU 上、序列长度可能达到几十万 token,可行的配置只有四卡(TP4)和八卡(TP8)两种。
先做个粗略估算:一张 B200 有 180 GB HBM,而 Kimi K2.6 的权重有 595 GB(超过一万亿参数,每个权重一个 nybble)。把权重溢出到 CPU 内存或磁盘会毁掉延迟,所以 TP1 和 TP2 都不可行。用四张或八张 GPU 分片存放权重,剩下大约 125 GB 或 845 GB 给 KV。每个 KV 条目有 576 个元素,以两字节 BF16 格式存储,模型有 61 层,每层每个 token 各有一个 KV 条目,因此单个 token 占用约 72 KB = 576×2×61 字节。算下来 TP4 能放下约 50 万 token 的 KV,TP8 约 300 万——硬件翻倍,缓存容量是六倍。
| 配置 | HBM 总量 | HBM 减去权重 | 每 GPU 约 KV 大小 | 约 KV 容量 |
|---|---|---|---|---|
| TP1 | 180 GB | -415 GB | - | - |
| TP2 | 360 GB | -235 GB | - | - |
| TP4 | 720 GB | 125 GB | 31.3 GB | 0.45 Mtokens |
| TP8 | 1.44 TB | 845 GB | 105.6 GB | 3.0 Mtokens |
这么看 TP8 相当有吸引力。但在目标负载上、在与我们交互性目标相容的并发度下,TP8 只跑 prefill 的每 GPU 吞吐,和 TP4 同时跑 prefill 与 decode 大致相当——这个对比已经明显偏向 TP8,而它还是没赢。

但选 TP4 也让我们在 KV 缓存容量上被卡得极死。
于是接下来,我们转向各种缓解这一约束的策略。
我们靠更好的 KV 缓存解开了吞吐上的并发瓶颈。
每个请求最大输入约 100k token、跑 TP4 的情况下,单个副本上只能调度大约 4 个用户的对话,再多交互性就崩了。超过这个数,缓存命中率(CHR)骤降,长输入被重新计算,交互性和吞吐一起塌掉。重算比从 HBM 加载这些 KV 条目慢得多得多。所以我们开始想办法给 KV 腾出更多空间。
对 HBM 来一次断舍离。
最直接的优化,是找出被浪费的 HBM,把它还给 KV cache。
我们查看了 SGLang 中 DFlash 草稿模型架构的实现,发现它占用的 HBM 是实际所需的两倍。
具体来说,用作草稿模型输入的目标模型中间结果,先是收集成一个指针列表,然后在 forward pass 结束时拷贝到连续内存中。我们改写为预先分配一块连续内存作为 buffer,在 forward pass 过程中把中间结果推进去,把 HBM 峰值占用砍掉了一半。而且我们没有改动基于 append 的逻辑,靠的是一点 Python 魔法。改动已通过这个 PR 合入 SGLang 上游。
用量化腾出 HBM。
腾空间第二简单的办法,是全面改用更低精度的浮点数。
但与投机解码或消除浪费不同,降低精度不是免费的午餐。模型输出可能发生剧烈变化,而且通常对应用效果不利。你可以在我们的 LLM Engineer's Almanac 里用可视化工具直观感受块量化的影响(示例如下;左侧为块量化,右侧为原始结果)。

能够有把握地做出原则上会损失精度、但不影响目标应用效果的改动,这一点至关重要——也是自托管定制推理应用相对于通用多租户模型 API 提供商的一项差异化能力。
和往常一样,投机解码是更简单的那一类,所以量化草稿模型是稳赚的。模型的目标产出——解码速度——是平滑下降的,不像智能那样,而且 drafter 的正确性除了性能之外不影响应用效果。我们已通过这个 PR 将 DFlash speculator 架构的 FP8 支持合入 SGLang 上游。
但有些优化虽然诱人,却不可避免地会影响应用效果。正因如此,我们正大力投入,建设评估推理服务器建模能力的能力(很快会有更多内容!)。我们也非常欣赏并支持 Moonshot 的 Kimi Vendor Verifier 这类举措,它们帮助模型使用方持续评估质量。Evals,evals,evals!
根据我们的评估,我们发现可以将模型的 KV cache 从 BF16 量化到 FP8。这使以 token 计的 cache 容量翻倍。此外,大多数 expert matmul 已经是 NVFP4——目前硬件原生支持的最紧凑格式!但每个 token 都会激活的关键“shared” expert 不是。我们发现可以将 shared expert 从 FP8 量化到 NVFP4,从而释放更多 HBM 用于 cache。在我们的评估中,这两项改动都没有明显降低模型质量(相对于运行间的非确定性)。
用 HiCache 扩展 KV cache 容量
最后,如果 cache 更大,即使这意味着它更慢,会怎样?
到目前为止,我们只考虑了用 GPU HBM 存储 KV。这意味着处理输入序列时,我们的两个选择要么是“把它放在 GPU 上超快、超贵的存储里”,要么是“把它扔进垃圾桶”。当我们需要服务的 KV cache 大小超过 HBM 容量时,cache 命中率(CHR)会下降,导致副本性能急剧恶化:

这就是为什么好的 cache 都是多层的!cache 的每一层都会在负载增加时让 CHR 多一个更平缓的下降台阶。SGLang 的 HiCache 通过增加“L2”和“L3” cache 层级来扩展 KV cache 容量,让 KV 可以存储在主机内存(L2)和分布式存储(L3)中。
更高的 cache 层级仍然更慢(否则我们就会直接把它们当作更低层级来用了!),因此它们很容易损害延迟,也可能降低吞吐。仅使用基于 CPU RAM 的 L2 cache,我们就已经获益良多。即便如此,我们基本上只用它来处理超额负载。
也就是说,没有 HiCache 时,并发一旦超过支撑峰值性能的水平,性能就会迅速劣化,我们根本没法尝试在峰值上服务。有了它,单个副本的负载短暂超出峰值时,副本的表现更平稳。

副本的请求负载并不总是恰好等于目标值,原因一是上游用户/agent 行为存在不确定性,二是请求在各副本之间的路由方式——这一点我们接下来讨论。
最后,扩展到大量副本。
单副本性能优化完成后,我们扩展到了更大的部署规模——费了这么大劲,总得服务远不止六个并发用户吧。概括地说,我们的做法是在一个 Modal Server 后面运行一个自动扩缩容的推理引擎副本池。
我们核心平台的自动扩缩容基础设施已经处理了自动扩缩容中那些典型难题——何时扩容、获取资源、拉起主机环境、快速启动副本、故障恢复、可观测性、释放资源——这些难题通常会把系统缠成一团 Kubernetes YAML 意大利面。所以我们的工作几乎全部落在路由层。
路由本身“不难”,除非副本有状态,而 KV cache 恰恰给副本引入了状态。好在这是良性状态:一种临时缓存,应用正确性并不依赖它,未命中时重新计算即可。但重新计算有性能代价,于是路由就成了性能优化的重要一环。
我们观察到两个性能问题,促使我们更仔细地审视路由:
- 排队请求数、尾部的首 token 延迟(TTFT)和端到端(e2e)延迟反复出现尖峰。
- 单副本吞吐低于预期。

这两个问题的根本原因都是“令人遗憾的冷预填充”——输入序列与之前见过的序列有重叠,但我们最终还是重新计算了整个 KV。而_那_的根本原因,是我们最初的无状态路由算法请求放置效率低下,这既受会话内并发的影响,也受哈希运气不佳的影响。
基于这项工作,我们更新了路由层,使其支持有状态且感知 KV cache 的路由算法。Modal Servers 可以通过 kv_aware_routing 实验性选项 使用它:
@app.server(port=8000, experimental_options={"kv_aware_routing": True}
class Server:
...
我们从无状态的“会话亲和”路由开始。
默认情况下,Modal Servers 对所有请求使用均匀随机路由。为了让某些请求“粘”在特定容器上,客户端可以提供一个 header,Modal-Session-Id。随后它会被哈希并映射到一个副本上,大致如下:

虽然并不完全是图中的环形哈希——我们使用一致性哈希,以便在副本数量或身份发生变化时获得更好的表现。详情见这个代码示例。
因此,Modal 上推理服务的编码 agent 客户端可以在同一个 agent 会话内创建并复用 session ID,把请求映射到已经见过其先前输入的副本上,从而可能让这些输入的 KV 表示已经在缓存中,带来更好的性能——通常如此。
规模救不了“运气不佳”的哈希。
当并发用户数量很大、且对并发会话数波动的容忍度较高时,无状态、均匀随机的会话路由算法在实现均衡方面表现尚可,但在低并发且容忍度很紧的情况下存在一些问题——而这正是高交互性编码 agent 推理所处的区间。
粗略讲一下数学。使用任何均匀随机哈希算法时,每台服务器的会话数分布都是二项分布,其中 N 等于总会话数,p 等于服务器数量的倒数。二项分布会很快收敛到泊松分布,其速率参数等于 Np, 也就是会话数除以服务器数。聚焦稳态动态时,得益于自动扩缩容,我们可以把它视为固定值,并等于目标并发数。这很好!但泊松分布的方差等于这个固定速率参数,这意味着每个副本会话数的离散程度不会随着规模增大而减小。它保持不变,而这会带来可预测的尾部行为。
具体来说:假设每个副本的目标并发是五个会话,你有五十个副本、总共服务 250 个会话,那么均匀随机路由算法会让某个副本只分到 ≤1 个会话,概率约 4%,表现为整体效率下降;还会让某个副本至少分到 12 个会话,概率约 0.5%,表现为尾延迟。这些概率与规模无关。

所以这类路由算法只在负载离散程度可以接受的前提下才成立——而 coding agent 的工作负载不属于这种情况。
实际情况比模型预测的还要糟。偏离模型的因素(偏离总是存在!)会让并发数产生额外的方差。我们直接观测到了这一点:方差常常是均值的数倍,右尾极重,偶尔导致非常高的延迟。下面是每个副本负载的观测结果(来自一次把目标设为五个并发请求、以提升交互性的部署)。

这就是我们观测到的 TTFT 尾延迟和吞吐不足的根因。
我们重写了路由系统,以更好地处理 coding agent 工作负载。
最终的路由系统实现了显著低于泊松分布的负载离散度(方差不到均值的一半)。下面是一个负载分布样本,同样来自一次目标为五个并发请求的部署。
为此,我们排查了尾延迟和负载过度离散的成因,并针对每一条新增了相应的路由算法。
- 会话并发发送多个请求,压垮了所在副本。 解决办法是把这些“thicc 会话”拆分到多个副本上。
- 每个会话的工作量并不均匀。 解决办法是让路由感知负载——这同时也减轻了前面提到的“运气不好的哈希”。
- 扩容时我们迁移了过多会话。 解决办法是优先把新会话映射到新副本上。
通过拆分并发会话解决热点副本问题
过度分散和尾部延迟的最大单一成因,是我们的模型把带 ID 的会话视为“闭环”——即每个会话同一时间只有一个请求——但这一假设被打破了。
在我们的系统中,会话 ID 由客户端控制,因此无法阻止客户端用同一个会话 ID 提交多个并发请求。而如果会话 ID 总是映射到同一个容器,那么每个容器的并发请求数就不再有任何上限。某个副本会变“热”,负载极高,尽管整体负载并没有增加。
典型的编码代理会话,即便带有子代理,也不需要让并发请求共享同一个会话 ID,因为典型会话是一轮一轮推进的。但在某些情况下,多个并发输入序列会共享同一个前缀。当编码代理会话呈树状结构而非链状结构时就会发生这种情况——比如你使用 /btw 的时候。
下面是多个副本上按会话 ID 统计的请求数的一个时间点采样,请求数最多的会话标红。副本 6 上最大的那个会话,其并发请求数超出目标负载好几倍。这可不行!

当出现这种“thicc 会话”时,试图保持完美的局部性,性能反而比复制一部分缓存、把并发工作分散到多个副本上更差。为了解决这个问题,我们的路由器会主动把高并发的会话拆分到多个容器。也就是说,在把某个会话发送到分配给它的某个副本之前,我们会检查该会话的负载阈值。一旦超过阈值,就把这个请求发到另一个副本。
通过细粒度的负载感知会话放置,解决路由运气不佳和会话不均衡的问题
如上所述,均匀随机算法会带来固定比例的“运气不佳”的容器/用户。
更糟的是,上述模型还假设请求处理时间是负载的固定函数。但工作量越大,处理在途工作所需的时间就越长,于是高负载服务器上的请求耗时更久,它们的负载在高位停留的时间也更长——尾部比模型所描述的更厚。
除此之外,它还假设每个会话所需的工作量相同。但有些 coding agent 会话很长,其中的请求带有数十万个 token,而另一些会话很短,只有几千个 token。
为避免这一点,我们根据更细粒度的负载信号把新会话分配到副本上,比如正在运行的 请求 数(而不只是已分配的会话数)和 KV 利用率。会话放置完成后,我们仍然保持会话亲和性,以维持 CHR。
在扩容时尽量减少缓存迁移。
负载上升期间,我们需要增加副本数量。已有副本对在途会话持有热缓存,所以我们更希望继续把这些会话路由到那里。
但在 rendezvous hashing 下,改变容器集合会导致在途会话重新平衡,而不只是新会话。这并非完全乱套——使用一致性哈希类算法的意义就在于,新增一个目标时只需重路由约 1/N 的会话即可达到平衡。但即便如此,某些会话仍需要大量 KV 重算并因此变慢。
这最终又变成了另一种形式的负载感知路由。尤其是在负载上升期间,新会话通常在被持续创建,把更多新会话映射到负载较低的副本上,会让它们优先落到较新的副本上:那些副本还没有积累任何负载!当新会话的创建速率与新副本的增加速率不匹配时,往往仍需要做一些平衡。我们同样用负载感知来解决,这一次是在会话 重新分配 上,而不只是会话/请求的分配。
部署,然后享受。
这些路由层面的改动落地之后,多副本部署的表现已经接近我们从单副本结果外推所预期的样子。TTFT 明显更稳定,即便部署规模不断扩大,单副本吞吐量也始终贴近单副本时的性能。

这些优化加在一起,让我们能够同时运行多个 Kimi K2.6 推理服务,规模达到每天数千亿 token,交互性和性价比都显著超过我们的基线以及其他方案。
此后我们又把这套基本流程重复用于多个模型——速度快了很多,因为我们更有经验,也因为我们造出了可复用的工具和基础设施。其中就包括 Moonshot 更新的 Kimi K3 模型。我们多次在竞争激烈的 OpenRouter 市场上承接了 Kimi-K3 的大部分 token 流量,这个市场会根据各家推理服务的质量来分配需求。
“科学有时也会说谎。”
在整个过程中,我们花在理解基准测试和工作负载上的时间,几乎和优化服务本身一样多。做基准测试很难。
我们关心的几乎每一个指标,都是系统和所喂给它的工作负载共同作用的结果。数据一换,观测到的结果就可能变,而系统本身没有任何改动。
最直接的办法是始终用同一份数据做基准测试,并且让这份数据与生产环境的工作负载完全一致。但生产数据敏感,访问受限;而且生产数据本身就有波动,既体现在请求之间,也体现在时间维度上。要做正经的性能工程,关键是要搞清楚系统在特定场景下的行为,而不只是看总体表现。
所以我们有时也想在生产环境日常行为模式之外做“受控实验”,一是用来建立理论,二是把性能干预的影响清晰地隔离出来并加以测量。举个例子,前面已经提到,我们以 prefill-only 模式跑 TP8,清楚地证明了在我们的场景下它不如 TP4。这就像科学家研究系统,不只靠观察自然行为,也靠在受控的实验室环境里做干预。
数据依赖性在几个地方露了头:
- TPM / GPU 取决于输出长度——在闭环场景下,许多“典型”请求可能在生成一条长序列所需的时间内就完成了。
- 投机接受长度取决于评估所用的数据——代码的接受长度大约是散文的两倍。
- CHR 取决于单条轨迹——像事后编辑消息这类对缓存不友好的模式,会让缓存的改进看起来毫无效果。
然后呢?
这项工作始于为某位客户针对特定模型优化编码 agent 负载。但我们也做过各种各样的推理负载,比如对高延迟/吞吐敏感的 analytical processing(后续会有更多介绍)。我们持续与一些将推理部署到生产环境的全球领先公司紧密合作,也很乐意与你合作!点此联系我们。
正如本文性能讨论的通用性所表明的,这项工作很容易就扩展到了支持其他客户,也用于服务其他模型了。它还推动了对我们推理服务和评估栈的长期改进,包括我们自己的 eval 平台、新的路由系统,以及更好的基准测试方法,这些我们很快会详细展开。
你可能已经注意到,我们相当偏爱我们的核心自动扩缩容云基础设施平台,本文中许多更底层的工程选择之所以可行,都要归功于它。
这也是这篇文章存在的理由——如果我们真的相信自己的平台最适合跑高性能推理,那为什么要藏起推理性能的“alpha”?这也是我们坚持开源的原因。我们不仅把推理引擎上的工作回馈给了上游,还发布了代码和配置,作为 Modal Auto Endpoints 的底层来源。现在就用 modal endpoint create 启动一个 Kimi K2.6 的 Dedicated Endpoint,你就能查看我们的配置——或者按自己的需求改。
看到这里还没走,说明你对推理性能的前沿有兴趣,也有能力再往前推一把。想和我们一起做这件事,看看 modal.jobs。对推理感兴趣的系统工程师,以及推理方向的专家,我们都想聊聊。
致谢
这项工作离不开开放权重模型提供方的工作,比如 Moonshot AI,也离不开开源推理引擎,比如 SGLang,更离不开整个研究者和工程师社区——他们把自己的成果公开出来,让别人能在此基础上继续往前走。