跳到主要内容

具有两种延迟的 AI 功能:你衡量的是一种,用户感知的是另一种

· 阅读需 10 分钟
Tian Pan
Software Engineer

传统的 HTTP 请求只有一个关键的延迟:从请求到响应的时间。那个数字的 p95 就是契约。SRE 监视它,SLO 是针对它编写的,当它退化时就会有人收到告警。一个数字,一个仪表盘,一个真相。

流式 AI 功能在响应变为流的一刻就打破了这一模型,而大多数团队还未察觉。现在有了两种延迟,而且它们是发散的。首字延迟(Time-to-first-token) 是用户在任何事情发生前盯着加载图标的时间。完成时间(Time-to-completion) 是直到回答完全写完的时间。它们受不同力量的影响,由不同的杠杆修复,并且用户感受到的情感权重完全不同 —— 而几乎每个团队都只衡量第二个指标,因为那是 HTTP 框架免费提供给他们的数字。

结果就是一个技术上准确但在体验上盲目的仪表盘。你的 p95 看起来很健康。你的用户却在盯着一个四秒钟的加载动画。这两件事同时发生,而它们之间的差距是当今生产环境 AI 功能中最常见的、未被测量的失败模式。

为什么框架默认会对你说谎

打开任何 Web 框架的请求追踪中间件。它记录请求到达的时间和响应结束的时间,并报告两者之差。对于 JSON API,这完全正确。对于流式端点,它是 整个流 的持续时间 —— 也就是说,是完成时间。框架无法看到第一个 token 离开服务器的时刻,因为就传输层而言,响应是一个长连接,要么成功,要么失败。

因此,默认的测量手段衡量的是用户 最不关心 的延迟。一旦答案开始渲染,用户就开始阅读了;他们已经停止检查功能是否坏了。等待中最痛苦的部分 —— 即他们怀疑是否应该刷新页面的部分 —— 完全发生在第一个 token 出现之前,而这个间隔在标准仪表盘上是不可见的。

这创造了一种特定且危险的指标形态。完成时间主要受长回答的影响。一个产生 1,200 个 token 的正确、有用输出的请求需要几秒钟才能完成,这 没问题 —— 用户一直都在愉快地阅读。但这种数秒的完成时间与真正糟糕的体验处于同一个直方图中:在出现单个 token 之前有四秒钟的停顿。合并数字的 p95 被“长但没问题”的回答推高,被短时间的停顿拉低,真正糟糕的体验被平均化,从而变得不可见。

你可能会有一个 3 秒的 p95 完成时间,其中包含零个不满意的用户;也可能有一个 3 秒的 p95,其中全是不满意的用户。这个数字无法告诉你属于哪种情况。这不是一个你可以通过增加小数位来解决的精度问题。这是一个迹象,表明你衡量了错误的量。

两种延迟,两种不同的物理原理

你无法从一种延迟推导出另一种延迟的原因是,它们是由不同的机制产生的。

首字延迟受 预填充(prefill) 阶段的限制:模型必须摄取整个提示词 —— 系统指令、检索到的上下文、对话历史、用户的消息 —— 并在发出第一个 token 之前对所有内容计算注意力。预填充成本随 输入 大小而变化。一个将 30,000 个 token 的检索文档塞入上下文的功能,无论模型随后生成的速度有多快,其首字生成都会很慢。网络往返、冷模型路由以及推理服务器前的排队也都属于这一类。

完成时间在此基础上增加了 解码(decode) 阶段:token 是逐个生成的,每个都很便宜,总时间大约是输出长度除以生成速度。完成成本随 输出 大小而变化。简洁的回答完成得快;冗长的回答完成得慢,而这与功能的响应速度几乎没有关系。

在负载下,这两者会朝相反的方向拉扯。在 GPU 上批处理更多请求可以提高吞吐量和解码速度 —— 这对完成时间有利 —— 同时会延长每个请求在预填充前的排队时间,从而损害首字延迟。盲目地针对其中一个进行优化,你可能会悄无声息地使另一个退化。一个只关注完成时间的团队可能会发布一个批处理更改,让仪表盘看起来更好,但产品体验却更差,而他们的监控中没有任何东西会反驳他们。

人类感知研究给出了具体的阈值。500 ms 以下的首字延迟感觉响应迅速;超过 1 秒用户就会察觉;超过 2 秒就会产生挫败感。Token 间的生成速度有其自身的阈值 —— 快到超过阅读速度就足够了;再快也感知不到。这些是针对不同指标的不同预算,单一的合并 SLO 无法对其进行编码。

耐心预算并非单一数字

拆分指标的更深层原因是,用户并没有单一的耐心预算。他们有两个预算,而且规模迥异。

“这东西到底动了没”的耐心预算极小——只有一两秒。而“还没完吗”的耐心预算则很大,因为一旦 Token 开始流式传输,用户就得到了反馈,可以开始阅读,并进入了一种主动而非被动的等待状态。这与让骨架屏在相同延迟下比加载动画(spinner)感觉快约 30% 的心理学原理相同:可见的进度将“死寂时间”转化为了“可忍受时间”。流式传输本身就是这一技巧的最大规模应用。它并没有让答案更早到达;它只是让“第一丝生命迹象”更早出现,并刻意用略微变差的完成时间来换取大幅提升的感知体验。

只有当你同时衡量这两者时,这种权衡才有意义。一个渲染耗时 400ms 且总计 10 秒的答案感觉很快。而一个在 10 秒死寂后突然全部出现的 10 秒答案则感觉像是坏了。完成时间相同,但产品体验天差地别。如果你的唯一指标是完成时间,那么这两种体验在你的仪表盘上完全一致——你根本无法察觉到自己交付的是第二种体验。

因此,SLO 必须拆分。首个 Token 响应时间(Time-to-first-token)需要一个激进的目标——如果可以的话请设定在亚秒级——因为它在把控那个较小的耐心预算。完成时间(Time-to-completion)的目标可以放得更宽松,并与答案长度挂钩,因为它在把控那个较大的预算。一些团队还会增加第三个指标,即 Token 间延迟,以捕捉那些开头很快但随后卡顿的流。重点不在于精确的数字;而在于单一阈值无法同时服务于两个预算。

拆分指标的具体要求

解决这个问题主要在于监控指标的规范化,而且它比听起来要便宜。

显式地为首个 Token 记录时间戳。 不要从框架提供的请求跨度(request span)中推断延迟。在消费模型流的代码路径中,记录首个数据块到达的实际时间(wall-clock moment)并将其作为独立指标上报。这只需要几行代码,却是整篇文章中杠杆效率最高的改动。其他一切都取决于是否拥有这个数字。

追踪百分位数,永远不要看平均值,并且按指标分别追踪。 一个平均首个 Token 时间为 200ms 的系统,其 p99 可能长达三秒——这意味着 1% 的用户等待时间是平均值的 15 倍。流式体验是一场长尾游戏;尾部表现即是真实体验。请分别为首个 Token 响应时间和完成时间保留 p50、p95 和 p99 指标。这两个直方图的形状不同,绝不能合并。

考虑采用类似“有效吞吐量”(goodput)的成功定义。 不要只设定一个阈值,而是只有在同时满足所有约束条件时才将请求计为“良好”——首个 Token 低于 X,Token 间延迟低于 Y,完成时间低于 Z。通过所有门槛的请求比例是一个诚实的数字,任何“虽长但没问题”的答案都无法粉饰它。

针对现在可见的指标进行调优。 一旦首个 Token 延迟变得可见,一整类优化就变得有价值了:缓存提示词前缀(caching the prompt prefix)使静态指令和示例完全跳过预填充阶段(prefill phase)——这能大幅降低首个 Token 延迟;在第一跳使用更小、更快的模型;预热路由以避免请求撞上冷启动路径;以及削减为了微小的质量提升而导致预填充膨胀的检索上下文。在仅看完成时间的仪表盘上,这些优化都无法证明其价值,因为当你修复它们时,完成时间几乎不会改变。

指标背后的组织裂痕

这种差距之所以持续存在,是组织问题而非技术问题。SRE 团队负责延迟仪表盘,而该仪表盘是在延迟还是标量的流式传输前时代设计的。AI 团队负责感知体验,但通常不拥有监控栈,因此对他们至关重要的数字永远不会出现在面板上。谁都没有错;真相的两半只是分属于两个不同的部门,从未在同一个视角下被拆解开来。

解决方法是在 AI 功能评审中禁止使用“延迟”一词,除非它带有修饰语。不要问“我们的延迟是多少”,而要问“我们的首个 Token 响应时间 p95 和完成时间 p95 是多少”。当问题强制要求提供两个数字时,那些将四秒钟的加载动画隐藏在健康聚合指标下的团队将无处遁形。

当你的响应变成流的那一刻起,延迟就不再是一个标量。一个仍在报告单一数字的团队,其实是在报告其用户从未体验过的两种体验的平均值——并悄悄地优化着那个最不重要的指标。

References:Let's stay in touch and Follow me for more thoughts and updates