大多数已发布的推理基准测试都只用一个数字来打头阵:一个首 token 的中位时间,一个峰值每秒 token 数。这些数字并非不真实,但它们是在有利于服务商的条件下测得的:低并发、实例已预热、提示词很短。该条件与你的生产流量之间的差距,正是延迟意外潜伏的地方。
本文提出两个论点,并事先说明哪一个是新的。对于单个交互式请求,延迟分布的形状比其中心值更重要:p95 和 p99 描述的是你的用户中真实的一部分人所感受到的体验,而中位数描述的是那个没有人抱怨的请求。这个论点并不新鲜,DigitalOcean 已经就此发表了很好的工作,本文通篇引用了这些内容。
这里真正新颖的是第二个论点:对于一个通过一连串顺序模型调用来完成单个任务的智能体来说,单次调用的延迟几乎算错了分析单位。任务完成时间是多次调用的总和,而该链中至少一次调用落入尾部的概率会随每次额外调用而增加。
在本文中,我将涵盖以下内容:如何正确解读百分位数,如何将延迟分解为与你工作负载匹配的指标,为什么智能体链会彻底改变分析方式,一份批判性阅读任何已发布基准测试的检查清单,以及我针对 DigitalOcean 的无服务器推理运行的测试协议,其中包含每一个测量数值和精确脚本,因此你可以根据自己的流量形态重新运行,而不是轻信任何人的头条数字——包括我的。
如果你对延迟分析还不熟悉,请先快速浏览一遍,之后按需查阅。
| 术语 | 含义 |
|---|---|
| 百分位数 | 给定百分比的请求落在这个值以下。如果你的 p99 是 4 秒,那么 99% 的请求都能在这之前完成,而 1% 的请求需要更长时间。 |
| 中位数(p50) | 中间值:一半请求更快,一半请求更慢。这是“典型”请求,也是大多数基准测试用到的头号数字。 |
| p95 / p99 | 第 95 和第 99 百分位数。它们描述了你最慢的 5% 和 1% 的请求,也就是用户真正会抱怨的那些请求。 |
| 尾部 / 尾部事件 | 延迟分布中慢速的一端,通常超过 p95。尾部事件就是任何落在这个范围内的单个请求。 |
| 首 token 时间(TTFT) | 从发送请求到输出出现第一个 token 之间的时间。这是聊天用户盯着空白屏幕时所感受到的。 |
| token 间延迟 | 输出开始流动后,每个 token 之间的间隔。它决定了一段较长的流式回答是流畅还是卡顿。 |
| 总完成时间 | 单个请求从开始到结束的完整时间。当没有人观看流(例如在智能体内部)时,这个指标才真正重要。 |
| 任务完成时间 | 智能体在整个任务中所有调用的总完成时间之和。这是本文的核心指标。 |
| token | 模型读写的基本单位,大约相当于四分之三个英文单词。 |
| 预填充 | 模型在生成任何内容之前处理你整个提示词的阶段。提示词越长,TTFT 就越长。 |
| 流式输出 | 边生成边接收 token,而不是在结束时一次性接收。只有开启流式输出时,TTFT 才作为一个指标存在。 |
| 并发 | 同时处于处理中的请求数量。在并发为 1 的情况下运行的基准测试,描述的是一个真实负载无法保持的最佳情况。 |
| 排队 | 请求在排队等待可用容量。你的等待时间取决于排在你前面的人,而不仅仅取决于模型运行的速度。 |
| 批处理 | 平台将多个请求分组,以便 GPU 一起处理。你的请求可能需要等到批次填满才能开始。 |
| 冷启动 | 当模型没有已加载副本在运行,而平台必须首先加载它时产生的额外延迟。这会直接影响 TTFT。 |
| 吞吐量 | 每单位时间完成的工作量,通常以每秒 token 数表示。它是批处理任务的正确指标,因为此时没有人在等待任何一个单独请求。 |
| 变异系数 | 标准差除以平均值:衡量一致性。低值意味着请求行为相似;高值意味着它们散布范围很大。 |
| p99:p50 比率 | p99 除以 p50。接近 1 意味着延迟可预测;较大的比率意味着最坏情况与典型情况相去甚远。 |
| 智能体 | 一种循环调用模型、根据每个响应采取行动以完成任务的系统。每个任务通常需要 5 到 20 次顺序调用。 |
| 链 | 智能体为单个任务执行的一系列模型调用。每次调用都依赖前一次的结果,因此它们的延迟会累加。 |
| 服务商 | 通过 API 提供模型服务的公司。同一个模型在不同服务商上的速度可能会有巨大差异。 |
| 自助法重采样 | 通过从真实测量的请求中反复抽取随机样本并求和,来估计更长的链会是什么样。本文仅用于扩展测量数据,并且始终与直接测量的链一起展示。 |
本文使用的唯一公式是 P = 1 − (1 − q)^n,即当每次调用独立地以概率 q 落入尾部事件时,n 次调用中至少有一次落入尾部的概率。该公式在首次出现的“智能体乘数”一节中会逐步解释。
把这一点直说在最前面。如果你的中位数首Token时间是干净的300毫秒,但p99是4秒,并且你每天服务10,000个请求,那么每天大约有100个请求会遭遇比仪表盘上的数字长十倍以上的等待。这不是噪声。百分位数是一种定义,而非测量伪影:按照构造,p99就是你的流量中有1%会超过的值,并且在你以该速率运行的每一天都是如此。
对于单个请求,这已经是越过中位数去看的理由。对于包含多个请求的会话,风险会复合。如果会话中的每个请求有独立1%的概率落入尾部,那么一个包含20个请求的会话至少命中一次尾部事件的概率为:
P(at least one tail event) = 1 − (1 − 0.01)^20 ≈ 18.2%
我通过直接计算验证了这一点。在该长度的会话中,接近五分之一会包含至少一个坏请求,即使任何单个请求只有1%的可能是坏的。这正是整篇文章所基于的机制:独立的小风险,重复足够多次后,就不再是小事。
这种分歧有一系列具体、机制性的原因,每一个都在关于推理芯片与架构的配套文章中深入讨论过。简要来说:
并发下的排队意味着你的请求开始时间取决于它前面有多少其他请求,而不仅仅取决于模型运行的速度。批处理动态意味着你的请求可能要先等待批处理窗口填满,GPU才会开始处理它。即使你自己的提示词很短,一个共享你的批次或GPU的长提示词邻居也可能延长你的prefill时间,因为prefill是计算密集型的,而大型并发提示词会消耗你的请求所需的那部分计算资源。在缩放到零的基础设施上,冷启动会为任何在空闲期后恰好排在第一个的请求增加完整的模型或适配器加载时间。DigitalOcean自己对微调无服务器部署的分析发现,冷启动会专门推高首Token延迟,而不是稳态生成速率;定期发送保活请求是一种实用的缓解措施。来源:无服务器架构上的微调LLM,DigitalOcean社区。
尾部是曲线下的一小部分面积。在真实流量规模下,它也是一个固定的、重复出现的真实请求数量,而非舍入误差。
大规模、对延迟不敏感的批处理工作负载、评估套件、离线摘要、合成数据生成以及大规模内容审核,关心的是吞吐量和每个token的成本,而不是任何单个请求的等待时间,因为没有用户盯着加载动画。DigitalOcean自己的批处理推理明确地将该类流量与实时服务隔离。其文档化的限制指出,批处理调度不会使实时推理p99延迟降低超过5%,这正是平台自身的承诺,表明这两者属于具有不同指标的不同工作负载类别。来源:DigitalOcean推理限制。如果你的工作负载属于此类,那么吞吐量和每个成功请求的成本才是正确的指标,本文其余部分不会改变你的评估。
延迟不是单一数值。它可分解为三个测量项,每个测量项主导不同类型的工作负载。
首Token时间是发送请求与收到第一个输出token之间的延迟。这就是聊天界面的用户在屏幕上出现任何内容之前所盯着的那段时间,它由prefill(预填充)主导,即在生成开始之前处理整个输入提示词的计算密集型步骤。
总完成时间是 TTFT 加上整个生成时间,当没有人在观看流式输出时,它是唯一重要的指标,而这正是批处理任务或代理管道中的中间调用的情况。这些定义的来源:Artificial Analysis 语言模型基准测试方法论。
三种不同的时钟,三种不同的工作负载。为其中之一优化的提供商不会自动在其他两个方面表现强大。
询问哪个提供商的 TTFT 最快是一个规定不明确的问题,除非你还说明了并发数、提示长度以及你正在测量的输出长度,因为在这些条件下排名会重新排列。
这不是一种模糊的说法。你可以直接对照 Artificial Analysis 自己发布的提供商比较 进行验证,该比较明确按输入令牌数区分速度,并在判断默认基准工作负载不具有代表性时对其进行更新。他们在一个比较页面上的注释明确指出,“默认性能基准测试工作负载已更新为 10k 输入令牌,以更好地反映生产用例”,这是 Artificial Analysis 承认他们之前的默认值并不代表真实流量。
为相同模型服务的提供商之间的差距足够大,以至于在没有附加条件的情况下,“最快提供商”是一个毫无意义的短语。在 GLM-5.2 上,Artificial Analysis 报告最快和最慢提供商的输出速度之间相差 963%。在 gpt-oss-120b 上,报告差距为 4,149%,其中 Cerebras 以每秒 1,677.5 个令牌位居榜首。来源:Artificial Analysis,GLM-5.2 提供商比较 和 Artificial Analysis,gpt-oss-120b 提供商比较。我想准确说明哪些是我自己推导的,哪些是直接陈述的:Artificial Analysis 直接陈述了百分比差距和最快提供商的速度。根据这些百分比反推,意味着最慢的提供商在 GLM-5.2 上约为每秒 45 个令牌,在 gpt-oss-120b 上约为每秒 39 个令牌。这两个推断出的数字是我根据 Artificial Analysis 陈述的差距自己计算的,而不是 Artificial Analysis 作为独立数字发布的数据,你应该将它们视为估计值,而不是确认的数据点。
两种情况下模型完全相同。提供商不同,而最好和最差提供商之间的差距,使你在竞争模型之间发现的任何差异都相形见绌。
一个提供商可以在 TTFT 上胜出,却在实际上决定你的用户是否在放弃之前读完的指标上落败。首个令牌很快,但令牌间延迟很慢,会在最初一刻给人响应迅速的感觉,然后就开始拖沓。对于长流式回答来说,这不是一个好的特征,但对于代理来说却几乎无关紧要,因为代理的编排层不会观察令牌是否到达,它是在等待完整响应后再决定下一步行动。这就是为什么脱离你的工作负载,就不存在单一的“最快提供商”答案。正确的问题应指明指标、并发数和令牌长度,而不仅仅是提供商名称。
这是数学必须完全正确的部分,所以我会展示我的推导过程,而不是直接断言结论。
代理任务很少通过一次模型调用完成。一个典型的代理工作流——工具调用、推理步骤、工具调用、综合——需要执行 5 到 20 次顺序模型调用才能完成一个任务。任务延迟不是任何单次调用的延迟,而是链中每次调用的总和,因为每次调用都要等待前一次调用的结果来确定其下一个输入。这重新定义了整个问题:预测用户体验的指标不是任何单个调用的 p50 或 p99,而是这个总和的分布。
关于这种效应,我看到过一种说法,我想直接核查而不是重复:即针对每次调用 p99 尾部率的 10 次调用链,会使得尾部事件对该任务而言几乎必然发生。我直接检查了这一点,数字并不支持这种说法。
使用与上面单会话案例相同的公式,其中 q 是每次调用发生尾部事件的概率,n 是链中的调用次数:
P(at least one tail event in the chain) = 1 − (1 − q)^n
在每次调用 1% 的尾部率(即 p99 阈值所隐含的比率)下,一个 10 次调用链得出:
1 − (0.99)^10 ≈ 9.6%
这是一个真实且重大的风险,大约是单次调用自身1%概率的十倍。按照"近乎确定"这一说法的任何合理定义,它都算不上"近乎确定"。我计算了一下,要使单次调用1%概率的事件至少发生一次的概率超过95%,实际需要的链长度为:大约298次调用。
| 单次调用尾部概率 | 5次调用 | 10次调用 | 15次调用 | 20次调用 | 25次调用 |
|---|---|---|---|---|---|
| 1%(p99阈值) | 4.9% | 9.6% | 14.0% | 18.2% | 22.2% |
| 5%(p95阈值) | 22.6% | 40.1% | 53.7% | 64.2% | 72.3% |
这一说法的诚实版本是:在现实长度的链中,智能体链并不会让p99尾部事件变得"近乎确定"。它们只是让这类事件的发生概率比单次调用的概率提示的要高出数倍;而在p95阈值下,大约14次调用时风险就会超过50%,这完全落在典型智能体的调用次数范围内。这仍然是一个值得阐述的观点。它比"近乎确定性"更小、更精确,也是数学上真正支持的观点。
更长的链会增加在序列中某处遭遇尾部事件的概率,但增加的速度在很大程度上取决于你将哪个百分位数视为尾部事件。在现实的智能体链长度下,两条曲线都没有达到某些论证框架所声称的"近乎确定"水平。
更有用也更具原创性的观点,不是关于单次糟糕调用的概率,而是关于:当赢得单次调用基准测试的模型并不是最快完成任务的模型时,整个链上的总耗时会发生什么变化。
我在DigitalOcean Serverless Inference平台上,用真实模型运行了本文后面描述的完整测试协议。
所测模型中,有两个清晰地说明了这一点。在基准测试的头条指标——并发为1时首令牌时间中位数上,Llama 4 Maverick以300毫秒对1,202毫秒的成绩,比GPT-OSS 120B快4倍。如果你根据TTFT排行榜选模型,Maverick会赢,而且优势明显。随后,两个模型运行了相同的实测智能体工作负载:30条独立的链,每条链包含10次顺序调用,每次调用的输出都作为下一次调用的输入。
| 实测,单次调用(75个请求,并发1) | Llama 4 Maverick | GPT-OSS 120B |
|---|---|---|
| 首令牌时间(TTFT),p50 | 300 毫秒 | 1,202 毫秒 |
| 总完成时间,p50 | 3,249 毫秒 | 1,933 毫秒 |
| 总完成时间,p99 | 7,458 毫秒 | 2,932 毫秒 |
| 总完成时间,p99:p50比率 | 2.30 | 1.52 |
| 生成速度中位数 | 21 个令牌/秒 | 86 个令牌/秒 |
| 实测,10次调用智能体链(30条链) | Llama 4 Maverick | GPT-OSS 120B |
|---|---|---|
| 任务完成时间,p50 | 26.7 秒 | 20.9 秒 |
| 任务完成时间,p95 | 53.1 秒 | 28.8 秒 |
| 实测最差链 | 1,221.6 秒(20.4 分钟) | 31.8 秒 |
排名反转了。Maverick的首令牌到达很快,但在这一时间段内,其生成速度为每秒21个令牌,而GPT-OSS 120B为每秒86个令牌,因此它每次调用的总完成时间更长;在十次顺序调用中,那个"感觉上"快4倍的模型,任务完成时间中位数反而慢了28%,p95慢了84%。TTFT是排行榜展示给你的指标,任务完成时间是你的智能体用户真正等待的指标。同一天、同一平台、同一测试下,它们选出了相反的赢家。
同样的两个模型、同一平台、同一天。左图是基准测试头条所报告的内容,右图是你的智能体用户实际等待的内容。实测于2026年7月27日,DigitalOcean Serverless Inference平台。
如何解读此图:每个面板都使用不同的时钟来回答同一个问题:"哪个模型更快?"左面板只计时等待单次回复首令牌的时间,以毫秒为单位,绿色条(Maverick)明显更短。右面板计时完整的10步智能体任务,以秒为单位,此时绿色条明显更高。
"实测最差链"这一行值得单独说一句,因为它是整篇文章尾部论点的浓缩版。Maverick的300次链调用中,有一次调用在321毫秒内就输出了首令牌,这是一个相当不错的TTFT,但随后流在生成中途停滞,19.8分钟都没有完成。在请求开始时,所有头条指标都会给这次请求打上好评。包含该次调用的链花了20.4分钟,而不是27秒。这是300次调用中的1次,即0.3%的事件率,而上一节的公式会告诉你这在链中意味着什么:按每条链10次调用计算,大约每30个任务中就有1个包含此类事件。这不是模拟数字,而是超时和重试机制存在的实测原因。
任务完成时间随链长度呈复利式增长。曲线来自每个模型75次实测单次调用延迟的重采样;星标是直接实测的10次调用链中位数。当两者不一致时,请相信星标:真实的链携带不断增长的提示历史记录和实时负载变化,这是从固定样本重采样无法捕捉到的。
如何阅读此图表:向右移动意味着你的智能体在每个任务中进行更多顺序调用;向上移动意味着整个任务耗时更长。每种颜色代表一个模型,实线是典型(中位数)任务,其上方的虚线是不走运的(p99)任务。纵轴为对数刻度,因此每条网格线的步进意味着等待时间大约乘以某个倍数,而非加上一个固定值。有两点值得注意:颜色之间的差距与任何单个模型的中位数和其自身尾部之间的差距相比是巨大的,因此模型和服务选择占主导地位;而星号代表实际测量的10次调用链而非预测值,证实了线条所预测的排序。
智能体链中的尾部事件除了延迟本身之外还有第二重成本。编排层通常会实现超时机制,超过超时时间的请求会触发重试。一次重试调用意味着你需要为原始尝试的token和重试的token都付费,并且承受两者的延迟。智能体流水线中的尾部延迟直接转化为重复开销,而不仅仅是任务变慢。
上述测量数据中的停滞流正是生产环境超时机制为捕获而存在的请求:一个在30秒时放弃并重试的客户端,虽然会为重复调用付费,但比一直等待的客户端仍然早大约19分钟完成。其机制是将重试路由到更快的备用模型,而不是重试同一条缓慢的路径。
简而言之,DigitalOcean的Inference Router可以自动故障转移到备用模型,而不是将相同的请求重新发送到同一队列。同一路由层也是这些测量数据所指出的成本杠杆:上述链式结果显示,智能体流水线中的大多数调用并不需要端点上最昂贵的模型,因此将每个请求匹配到满足其延迟和质量要求的最便宜模型,并将更重的模型保留给真正需要的调用,这就是你同时控制延迟预算和按token计费账单的方法。
使用以下七个问题来批判性地审视任何已发布的延迟声明。
报告的是哪个百分位数?
仅报告中位数的声明只能告诉你典型请求的情况,而对尾部一无所知。
测试在什么并发度下运行?
在并发度为1时测量的数字描述的是最佳情况,真实负载下的排队不会保持这种情况。
使用了什么提示词和输出长度?
TTFT随提示词长度扩展,因为预填充受计算限制,而总时间随输出长度扩展。
实例是热还是冷?
包含冷启动的基准测试与仅使用热实例的基准测试回答的是不同的问题。
测量了什么时间窗口和持续时间?
非高峰时段的一次突发请求可能看起来与持续的生产流量截然不同;DigitalOcean自身的一致性测试发现,在营业时间内运行的基准测试可能看起来异常好,因为其他用户让模型保持了热状态。
使用的是流式还是非流式测量?
没有流式,TTFT就无法定义。
测量位置是包含还是排除了真实网络路径?
在与提供商相邻的基础设施上运行的基准测试会低估真实客户端所经历的延迟。
缺少这七个细节中任何一个的延迟声明,都是你尚无法据此行动的声明。它不一定是一个虚假的声明,只是一个不完整的声明。
谷歌研究人员2026年发表的一篇关于测量偏差的论文更进一步,指出了许多基准测试客户端构建方式中的一个具体技术故障模式:单进程、asyncio驱动的负载生成器在高并发下可能遇到自身的客户端排队瓶颈,作者将其建模为M/G/1队列,而这种客户端瓶颈可能会夸大基准测试想要测量的TTFT和每token延迟数据。来源:识别并缓解生产环境LLM推理基准测试中的系统性测量偏差,arXiv。作为读者,这对你的实际意义是:即使是出于真实意图运行的基准测试,其测量的对象可能既包括目标服务器,也同样包括其自身的客户端,这再次说明一个有记录、可复现的方法论比一个标题数字更有价值。
DigitalOcean自己的社区内容已经对自身进行了这种批判。其对同一模型在不同提供商上一致性的分析报告了变异系数(标准差除以平均值),在支持良好的提供商上低至21%,而在不同提供商上同一模型的变异系数高达710%,同一个模型,一致性却相差34倍。来源:为什么无服务器推理在相同模型上的一致性会有所不同,DigitalOcean社区。
| 提供商(模型:DeepSeek V4 Pro) | 中位数TTFT | p95 TTFT | 变异系数 |
|---|---|---|---|
| 测试中支持最好的提供商 | 0.39 s | 0.57 s | 21% |
| 测试的第二个提供商 | 0.55 s | 6.30 s | 541% |
| 测试的第三个提供商 | 0.73 s | 6.91 s | 710% |
来源:为什么无服务器推理在相同模型上的一致性会有所不同,DigitalOcean社区。该文将变异系数超过100%定义为冷启动和排队方差的特征,超过300%则视为不适合延迟敏感型工作的生产环境。
“可预测的延迟”并不意味着低中位数。它意味着围绕任意中位数的低方差,而衡量这一点最简洁的单一数字就是p99与p50的比率。一个p50为300毫秒、p99为350毫秒的提供商,比一个p50为200毫秒、p99为2,000毫秒的提供商更可预测,即使后者的典型情况更快。DigitalOcean自己发布的对旗下两个模型的serverless行为分析发现,典型和最坏情况下的首token时间相差在几百毫秒内,其中一个模型在典型与最坏情况之间仅从0.29秒变为0.35秒。来源:Serverless推理中的关键指标,DigitalOcean社区。接近1的p99:p50比率正是该发现用本文所用术语所描述的情况;上文一致性文章中的变异系数衡量的是围绕均值的方差这一紧密相关的量,而不是两个命名百分位数之间的比率,两者都是量化同一底层属性的合法方式。
当架构被专门设计为使这一比率接近1,而非靠运气时,Groq的LPU采用的确定性调度方法是最清晰的例子,这在关于推理芯片的相关文章中有深入介绍。
尾部延迟对于承受它的公司来说也不是一个学术指标。在DigitalOcean发布的客户成果中,Hippocratic AI运行安全关键的医疗代理,报告称在平台上超过2000万次患者交互中,生产吞吐量是原来的2倍,p99延迟降低了40%。注意那句话中的指标是p99,而不是中位数。那些产品在尾部表现不佳时会崩溃的团队,会在尾部上进行协商和衡量,下次基准测试给你一个中位数并称其为速度时,这一点值得记住。
本节描述测试设计,然后报告实际运行的结果。我于2026年7月27日,从DigitalOcean NYC2区域的一个GPU Droplet(gpu-h200x1-141gb)上,针对DigitalOcean Serverless Inference执行了此测试,使用下方打印的确切脚本进行完整协议运行。运行产生的所有内容——测试框架、所有1,590个记录请求的原始逐请求JSON、控制台日志、分析代码和图表——都已添加到此公开Github仓库:github.com/anishsingh20/serverless-inference-tail-latency-study。
针对单一模型运行,保持恒定,因此测试中的唯一变量是流量形态和并发度:
对于每个单元:中位TTFT、p95 TTFT、p99 TTFT以及p99:p50比率。对于链式测试特别指出:总链完成时间的分布,而不是任何单个调用的分布,因为该总数是预测用户或下游系统实际等待时间的数字。
两个互补测试。并发扫描显示排队如何在负载下降低单次调用性能。链式测试显示真实代理工作负载端到端的实际体验。
这里的每一步都是我为获得以下结果而实际执行的步骤,并针对DigitalOcean自己当前的页面进行了记录,以便你可以在操作时逐步验证。
https://inference.do-ai.run/v1/models 发起 GET 请求来检索 ID,或者从 Cloud Console 的 Model Catalog 中获取,并使用该调用返回的任何字符串。在下面的运行中,该调用返回了 ID mistral-3-14B、openai-gpt-oss-120b、llama-4-maverick 和 llama3.3-70b-instruct,并且使用了这些确切的字符串。参见检索可用模型。#!/usr/bin/env python3
"""
Latency benchmark for DigitalOcean Serverless Inference.
Measures, per request:
- TTFT (time to first streamed token), timestamped client-side
- total completion time
- prompt and completion token counts, from the usage object
Workloads:
1. baseline_sweep - N requests at concurrency 1, 5, and 20
2. chained_session - M independent chains of `chain_length` sequential
calls, each call's output feeding the next call's
input, with total chain time recorded per chain
Concurrency is implemented with a real OS-thread pool (ThreadPoolExecutor),
not a single-process asyncio loop. This is a deliberate choice: a 2026
measurement-bias paper (Chandrasekar and Kramberger, arXiv, cited in this
article) shows that single-process asyncio benchmarking clients can hit
their own client-side queueing bottleneck under concurrency, which
inflates the very TTFT numbers you are trying to measure. Real OS threads
release the GIL during blocking network I/O, which avoids that specific
failure mode at the modest concurrency levels used here.
Run:
export DO_MODEL_ACCESS_KEY=your_key_here
python3 do_latency_bench.py --model --out results_daytime.json
python3 do_latency_bench.py --model --out results_overnight.json
"""
import argparse
import concurrent.futures
import json
import os
import time
import urllib.request
def percentile(sorted_vals, pct):
"""Linear-interpolation percentile, no external dependency required."""
if not sorted_vals:
return None
k = (len(sorted_vals) - 1) * (pct / 100)
f = int(k)
c = min(f + 1, len(sorted_vals) - 1)
if f == c:
return sorted_vals[f]
return sorted_vals[f] * (c - k) + sorted_vals[c] * (k - f)
def summarize(ttfts_ms):
s = sorted(t for t in ttfts_ms if t is not None)
if not s:
return {"n": 0}
p50 = percentile(s, 50)
p95 = percentile(s, 95)
p99 = percentile(s, 99)
return {
"n": len(s),
"p50_ms": round(p50, 1),
"p95_ms": round(p95, 1),
"p99_ms": round(p99, 1),
"p99_to_p50_ratio": round(p99 / p50, 2) if p50 else None,
}
def streamed_request(base_url, api_key, model, messages, max_tokens=64):
"""Send one streaming chat completion. Return TTFT and total time in ms."""
payload = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": 0,
"stream": True,
"stream_options": {"include_usage": True},
}
req = urllib.request.Request(
base_url + "/chat/completions",
data=json.dumps(payload).encode(),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
)
start = time.perf_counter()
ttft = None
content_parts = []
usage = {}
try:
with urllib.request.urlopen(req, timeout=120) as r:
for raw in r:
line = raw.decode(errors="ignore").strip()
if not line.startswith("data:"):
continue
body = line[5:].strip()
if body == "[DONE]":
break
chunk = json.loads(body)
if chunk.get("usage"):
usage = chunk["usage"]
for choice in chunk.get("choices", []):
delta = choice.get("delta", {})
if delta.get("content"):
if ttft is None:
ttft = (time.perf_counter() - start) * 1000
content_parts.append(delta["content"])
except Exception as e:
return {"error": str(e), "ttft_ms": None, "total_ms": None}
total = (time.perf_counter() - start) * 1000
return {
"ttft_ms": round(ttft, 1) if ttft is not None else None,
"total_ms": round(total, 1),
"prompt_tokens": usage.get("prompt_tokens"),
"completion_tokens": usage.get("completion_tokens"),
"content": "".join(content_parts),
}
def baseline_sweep(base_url, api_key, model, n_requests, concurrency_levels, prompt):
results = {}
messages = [{"role": "user", "content": prompt}]
for c in concurrency_levels:
print(f"[baseline] concurrency={c}, {n_requests} requests", flush=True)
records = []
with concurrent.futures.ThreadPoolExecutor(max_workers=c) as pool:
futures = [
pool.submit(streamed_request, base_url, api_key, model, messages)
for _ in range(n_requests)
]
for f in concurrent.futures.as_completed(futures):
records.append(f.result())
ttfts = [r.get("ttft_ms") for r in records]
results[f"concurrency_{c}"] = {
"summary": summarize(ttfts),
"records": records,
}
return results
def chained_session(base_url, api_key, model, n_chains, chain_length, prompt_prefix):
chains = []
for i in range(n_chains):
history = [{"role": "user", "content": f"{prompt_prefix} Chain {i}, step 0."}]
start = time.perf_counter()
calls = []
for step in range(chain_length):
r = streamed_request(base_url, api_key, model, history)
calls.append(r)
history.append({"role": "assistant", "content": r.get("content") or "ok"})
history.append(
{"role": "user", "content": f"Chain {i}, step {step + 1}. Continue."}
)
total_chain_ms = (time.perf_counter() - start) * 1000
chains.append({"chain_index": i, "total_chain_ms": round(total_chain_ms, 1), "calls": calls})
print(f"[chained] chain {i} done: {total_chain_ms:.0f} ms total", flush=True)
chain_totals = sorted(c["total_chain_ms"] for c in chains)
return {
"chain_summary": {
"n_chains": len(chain_totals),
"p50_ms": round(percentile(chain_totals, 50), 1),
"p95_ms": round(percentile(chain_totals, 95), 1),
"p99_ms": round(percentile(chain_totals, 99), 1),
},
"chains": chains,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base-url", default="https://inference.do-ai.run/v1")
ap.add_argument("--model", required=True, help="Exact model ID from GET /v1/models")
ap.add_argument("--n-requests", type=int, default=75)
ap.add_argument("--n-chains", type=int, default=30)
ap.add_argument("--chain-length", type=int, default=10)
ap.add_argument("--out", default="latency_bench_results.json")
args = ap.parse_args()
api_key = os.environ.get("DO_MODEL_ACCESS_KEY")
if not api_key:
raise SystemExit("Set DO_MODEL_ACCESS_KEY before running.")
results = {
"model": args.model,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
print("== Baseline sweep ==", flush=True)
results["baseline_sweep"] = baseline_sweep(
args.base_url,
api_key,
args.model,
args.n_requests,
concurrency_levels=[1, 5, 20],
prompt="Reply with a two-sentence summary of why caching matters for LLM inference.",
)
print("== Chained session ==", flush=True)
results["chained_session"] = chained_session(
args.base_url,
api_key,
args.model,
args.n_chains,
args.chain_length,
prompt_prefix="You are debugging a production incident.",
)
with open(args.out, "w") as f:
json.dump(results, f, indent=2)
print(f"Wrote {args.out}", flush=True)
if __name__ == "__main__":
main()
完全按照文档字符串中所示的方式运行它,在每个时间窗口各运行一次。脚本会在运行时打印进度,并将每一条原始记录写入输出文件。
以下所有内容均来自该协议的一次执行,这些是运行条件,在给出数据之前先说明以便您衡量其参考价值:
gpu-h200x1-141gb,24 个 vCPU)。网络路径位于云内部且路径很短,这意味着这些数据可能略微低估了公共互联网上客户端实际会遇到的情况。但另一方面,这也是一类读者的真实场景:如果您的应用程序已经运行在 DigitalOcean 上,紧邻推理端点,那么这里测得的短路径正是您生产流量实际经过的路径,中间没有跨云跳转或出口流量。该 Droplet 的 GPU 与本次测试无关;它只是当时可用的机器。https://inference.do-ai.run/v1/chat/completions,启用流式输出,temperature 为 0,max_tokens 为 64,每个基线请求都使用相同的简短提示词,与上面的脚本发送的内容完全一致。按并发级别划分的首 token 时间,每个单元格 75 个请求:
| 模型 | 并发数 | TTFT p50 | TTFT p95 | TTFT p99 | p99:p50 比率 |
|---|---|---|---|---|---|
| Mistral 3 14B | 1 | 179 毫秒 | 288 毫秒 | 598 毫秒 | 3.34 |
| Mistral 3 14B | 5 | 172 毫秒 | 255 毫秒 | 628 毫秒 | 3.65 |
| Mistral 3 14B | 20 | 182 毫秒 | 322 毫秒 | 486 毫秒 | 2.67 |
| GPT-OSS 120B | 1 | 1,202 毫秒 | 2,028 毫秒 | 2,108 毫秒 | 1.75 |
| GPT-OSS 120B | 5 | 1,093 毫秒 | 2,001 毫秒 | 2,367 毫秒 | 2.17 |
| GPT-OSS 120B | 20 | 1,257 毫秒 | 3,104 毫秒 | 3,632 毫秒 | 2.89 |
| Llama 4 Maverick | 1 | 300 毫秒 | 1,286 毫秒 | 1,509 毫秒 | 5.03 |
| Llama 4 Maverick | 5 | 214 毫秒 | 558 毫秒 | 1,624 毫秒 | 7.60 |
| Llama 4 Maverick | 20 | 320 毫秒 | 1,064 毫秒 | 1,367 毫秒 | 4.26 |
并发数为 1 时单次调用的总完成时间,以及本文一直在讨论的一致性指标:
| 模型 | 总时间 p50 | 总时间 p95 | 总时间 p99 | p99:p50 比率 | 变异系数 | 中位生成速度 |
|---|---|---|---|---|---|---|
| Mistral 3 14B | 495 毫秒 | 1,153 毫秒 | 1,839 毫秒 | 3.72 | 50% | 202 tokens/s |
| GPT-OSS 120B | 1,933 毫秒 | 2,689 毫秒 | 2,932 毫秒 | 1.52 | 15% | 86 tokens/s |
| Llama 4 Maverick | 3,249 毫秒 | 7,002 毫秒 | 7,458 毫秒 | 2.30 | 43% | 21 tokens/s |
实测的 10 次调用 agent 链,每个模型 30 条链,每条链的总任务时间:
| 模型 | 链 p50 | 链 p95 | 最长链 |
|---|---|---|---|
| Mistral 3 14B | 6.5 秒 | 10.2 秒 | 12.3 秒 |
| GPT-OSS 120B | 20.9 秒 | 28.8 秒 | 31.8 秒 |
| Llama 4 Maverick | 26.7 秒 | 53.1 秒 | 1,221.6 秒(20.4 分钟) |
剔除 Maverick 那条流中断的链(上文 agent 倍增部分已描述),其剩余 29 条链仍测得 p50 为 26.6 秒,p95 为 47.2 秒,最长 57.7 秒:即使去掉了灾难性事件,它在每个百分位上都比 GPT-OSS 120B 更慢且分布更宽。
每条线代表 75 个真实请求。关键在于形状而非起点:Maverick 起点快但散布很宽;GPT-OSS 起点慢但保持稳定。
如何解读该图表:在线条上任选一点,它表示“这个百分比的请求(纵轴)在该时间内获得了第一个 token(横轴)。”几乎垂直上升的线代表可预测的模型:几乎所有请求的等待时间大致相同。快速上升然后向右弯成一条长而平缓的斜线的线代表存在尾部的模型:大多数请求很快,但最后几个百分比的请求等待时间要长得多。这个弯曲处正是 p95 和 p99 所在的位置,如果只发布线条跨越 50% 的点,这个尾部就完全看不见了。
在这张图表中,Mistral 3 14B 在典型和最坏情况延迟方面都“胜出”:它不仅中位首 token 时间最低,而且几乎所有请求都紧密聚集在一起——即使在尾部也是如此。Llama 4 Maverick 在中位数上接近,但其尾部宽得多,意味着少数请求耗时明显更长。GPT-OSS 120B 起步较慢,但保持着短而一致的尾部。Mistral 兼具快速起步和紧密分布,当您同时关心典型和最坏情况延迟时,它是明显的赢家。
中位数几乎不随并发从1升至20而移动。尾部移动更多,这恰恰是为什么并发为1时的中位数是基准报告中最缺乏信息量的数字。
如何阅读此图表:每个面板代表一个模型,底部从左到右表示同时处理中的请求数量,每个面板中的三条线分别代表典型请求(p50,实线)、慢请求(p95,虚线)和非常慢的请求(p99,点线)。实线平坦意味着典型体验在负载上升时保持不变。需要关注的是实线与点线之间的垂直间距:当该间距向右移动时变宽——就像GPT-OSS 120B在并发20时那样——说明队列开始拉长尾部,尽管中位数看起来仍未受影响。正是这一机制让提供商的基准数字与您的生产体验悄悄分道扬镳。
哪个模型胜出以及原因:Mistral 3 14B,且优势明显。它在每个百分位和每个并发级别上都实现了最快的首个令牌,无论有1个还是20个并发请求,中位数约为172至182毫秒,p99从未超过628毫秒;即使其测量到的最差百分位也大约是GPT-OSS 120B最佳中位数的一半。Llama 4 Maverick以快速的中位数(214至320毫秒)位居第二,但p99高出四到五倍,约为1.4至1.6秒。GPT-OSS 120B在两项指标上均垫底:启动最慢(中位数1.1至1.3秒),并且是唯一在负载下尾部明显恶化的模型,随着并发从1升至20,p95从2.0秒拉长到3.1秒,p99从2.1秒拉长到3.6秒。
可能的原因主要是模型大小以及首个令牌之前发生的事情:Mistral 3 14B是这里最小的模型,因此每个请求在输出任何内容之前所需的预填充工作成本很低,在此期间,共享池吸收了20个并发请求而没有出现可测量的排队。GPT-OSS 120B大约大一个数量级,并且在此部署中,会将其部分预算用于首个内容令牌出现之前的内部推理,因此其TTFT起点较高,并且最先感受到排队压力。不过,本文其余部分的告诫仍然适用:赢得首令牌时间只是赢得了流式聊天指标。在总完成时间和实测的10次调用链上,Mistral 3 14B也确实最先完成,但GPT-OSS 120B却击败了Llama 4 Maverick,尽管它在这张图表的每个面板上都输给了对方。
我原本打算在llama3.3-70b-instruct上运行完全相同的完整协议。我使用上述测试工具中的同一streamed_request函数运行了15个顺序请求,并包裹了一个看门狗,对每个请求强制设置150秒的硬性挂钟上限,并在每个结果完成时立即写入磁盘,因此一个停滞的流最多只会花费150秒,而不会拖垮整个运行。
所有15个请求都在上限内完成。TTFT中位数为2.1秒。64个令牌的总时间中位数为43.5秒。15个请求中有两个分别等待了37秒和68秒才获得首个令牌,这是排队或冷路径的典型特征,而其他13个请求在1.6至3.4秒内获得了首个令牌。按照这种速度,在此模型上执行10次调用的代理链在中位数情况下需要超过7分钟,因此没有运行链式测试,该模型的数字来自15个请求而非525个,应据此权重来解读。
这是本文中最尖锐的论点,而且并非事先计划:在该特定时间段内,目录中最知名的模型名称在同一端点上每个已完成的请求比三个不太知名的替代方案慢一至两个数量级,然而没有任何显眼的TTFT数字会提醒您这一点,因为其TTFT中位数是看起来合理的2.1秒。真正说明问题的是几乎没有人发布的数字:总完成时间及其分布。
右尾肥厚且主体看似正常的分布表明存在排队或批处理争用:典型请求没有问题,但一部分请求在其他流量之后等待。Maverick的实测TTFT正是这种模式:中位数300毫秒,p99是它的五倍,变异系数为75%,而GPT-OSS 120B为27%。双峰分布——一群快速请求和一组与之分离的慢速请求,两者之间存在间隔——是冷启动或拥塞路径的特征:一些请求命中了热副本,另一些没有,中间几乎没有过渡。Llama 3.3 70B的有界运行显示了这种形态,13个请求在3.4秒内获得首个令牌,2个请求等待了37秒和68秒。
对于肥尾模式,直接的缓解措施是专用端点或预留容量,因为其根本原因是与其他租户流量的竞争。具体到DigitalOcean,无服务器和专用推理属于同一平台的一部分,从共享容量转向专用容量被视为容量决策而非迁移项目,这一点很重要,因为做出该决定的正确时机是在像这样的测量之后,而不是之前。对于双峰模式,直接的缓解措施是预热池或定期的保活流量,以从根本上防止副本数降至零。如果您跨多个模型或提供商路由,会话固定可以让一系列调用保持在同一条热路径上,而不是在任务中途重新触发冷启动;这在我写的关于提示缓存和会话固定的相关文章之一中直接讨论过。
当您的工作负载是交互式、基于会话或受延迟SLA约束时,请使用p95和p99进行评估。单个面向用户的请求或短会话正是尾部真实影响用户体验的情况。
当您的工作负载是包含三个或更多顺序调用的代理流水线时,请根据任务完成时间进行评估。从您的任务时间目标推导出所需的每次调用p99,然后反向工作,而不是先选择每次调用的目标并期望总时间能令人满意。如果您的任务预算是10次调用共5秒,那么您实际的每次调用预算在尾部应接近500毫秒,而不是中位数;本文前面的实测结果说明了为什么显眼的单次调用指标是用于核对这一预算的错误数字:在实测TTFT上快4倍的模型,在实测10次调用任务中却慢了28%。
当你的工作负载是离线批处理或异步管道,没有用户或下游系统在等待任何单个请求完成时,请以吞吐量和成本为评估标准,将尾部延迟放在一边。
如果你按成本与延迟的对比来跨模型路由请求,那么对于延迟关键路径,应依据实测的p99画像进行路由,而对于成本容忍路径,则按价格路由——鉴于具体路由机制已在推理路由器文档中说明,本文仅简要提及这一决策。
三种工作负载形态,三种正确的指标。使用错误指标不仅会误导你,还可能让你完全选错模型,如上文实测的TTFT与任务时间反转所直接显示的那样。
尾延迟复合公式以及从中推导出的每一个计算数值都是我的直接计算,并通过一个简单脚本进行核对,你可以针对自己的每次调用尾率及链长度重新运行同样的公式。本文中的实测延迟数据来自对已发布测试协议的一次执行,由作者于2026年7月27日从同一云环境中的GPU droplet对DigitalOcean Serverless Inference运行,使用了测试框架部分打印的精确脚本;原始的逐请求JSON已保留,所有表格均由它计算得出,完整证据库已发布在GitHub仓库中,任何人都可以从原始记录重新计算每个表格和图表。这些数字是真实的,但它们也只是一个时间窗口、一个客户端位置和一天的情况。它们描述的是该端点在那个窗口内的行为,而不是它在你的窗口内会如何表现,这正是包含脚本的全部原因。重采样的链长度曲线被明确标记为实测单次调用数据的bootstrap扩展,并与直接测量的链一起展示;当两者不一致时,实测链是基准真相。Artificial Analysis的供应商分布数据以及DigitalOcean的一致性和指标数据是真实的、已引用并直接链接的;TTFT排名部分中的两个“隐含最慢供应商”数据是我根据Artificial Analysis公布的百分比分布自行计算得出的,并非Artificial Analysis直接发布的数字,我已在出现时标明了这一区别。如果你运行该协议,并且你的结果与这里报告的任何内容(包括在DigitalOcean自己的serverless inference上)不同,那么这一修正比本文更有价值,我更希望你发布它,而不是假设本文已经涵盖了你的情况。
本身不是。低中位数加上低p99:p50比率是最佳结果。低中位数加上高p99:p50比率意味着典型情况很快,但你的流量中有相当一部分并非如此,而单独的中位数永远不会告诉你这一点。
因为“小”是相对于你承担风险的次数而言的。单个请求有1%的概率命中尾延迟。一个20次调用的代理链有18%的概率链中至少一次调用命中尾延迟,这个数字是本文直接验证而非假设的。
并非自动如此,完全取决于你的工作负载。对于流式聊天界面中的单个交互式请求,低TTFT加上可接受的p95可能是正确的选择。对于任何将顺序调用链成一个任务的工作负载,本文的实测结果显示,TTFT胜者在中位数上以28%的差距输掉了10次调用的任务,在p95上以84%的差距输掉,这就是为什么正确的评估指标取决于你的调用次数以及你的用户实际在等待什么,而不是对某一醒目数字的固定偏好。
不,本文不应被引用为表达了这种意思。测量结果是某一天的一个时间窗口,固定64个token的输出上限,且没有评估答案质量,而答案质量通常是模型之间的决定因素。结果确实表明排名取决于指标:在此窗口内,Maverick确实赢得了TTFT,也确实输掉了任务完成时间。持久的启示是方法,而不是排行榜:在选择之前,用你自己的流量形态测量你的工作负载实际感受到的指标。
基准测试生态系统报告的是易于测量且有利于发表的内容。一个干净的中位数易于测量,而且总是令人满意。生产环境的痛点在于单个请求的分布形态,而对于智能体(agent)而言,则在于其调用链上每次调用的总和。本文对您应如何解读延迟数字提出了两点改变。对于任何单个交互式请求,要看分布而非中心值。对于任何将调用串联起来的工作负载,要看总任务时间而非单次调用的百分位数,并且要从任务级目标倒推每次调用的需求,而不是反过来。
请根据您自己的流量形态、并发度和调用链长度,运行本文中的测试协议。这是了解您自己生产环境体验的唯一途径。
——
一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。