ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSE流式接口压测实践:首token延迟与token节奏才是大模型体验关键

SSE流式接口压测实践:首token延迟与token节奏才是大模型体验关键

做 AI 应用测试的人,大概率都遇到过这样一个尴尬:接口明明返回 200,用户还是觉得“卡死”。尤其大模型问答、Agent 流式回复这类场景,页面上文字是一段一段蹦出来的,传统压测工具测出来的响应时间 1.2 秒看起来很正常,但真实浏览器里第一句话要憋 4 秒才出现。差别就在于很多工具按“最后一个字节到达”来计时,根本没有测量“流”本身,首 token 延迟和 token 生成节奏全被平均掉了。

最近我一直在用 7DGroup 开源的一套 SSE 流式输出性能测试工具来补这个盲区。它的思路很简单:不把接口当成一个“一次性请求-响应”的黑盒,而是把一个 SSE 连接当成一条持续的数据管道,逐行解析 text/event-stream 协议,记录每个 data 事件的到达时刻。这样测出来的东西才是用户真正感受到的东西。这篇文章就把我怎么用它、怎么设计压测、怎么定位问题的过程完整拆开,适合正在做 LLM 应用研发、性能测试、SRE 的同学参考。

1. 传统压测工具为什么会在 AI 接口上“失准”

要理解这套工具的价值,先得搞清楚 SSE 流式接口和普通 HTTP 接口到底差在哪。很多人把它当成“响应比较慢的普通接口”,这是第一个认知误区。

1.1 SSE 协议的本质:一条按事件交付数据的 HTTP 长连接

SSE(Server-Sent Events)走的是 HTTP 协议,请求就是一次普通 POST 或 GET,但响应头会声明Content-Type: text/event-stream,服务器不再一次性把整个响应体发完,而是把数据切成一个个事件,通过 chunked transfer 持续推给客户端。一个典型的大模型流式响应长这样:

data: {"token":"你","index":0} data: {"token":"好","index":1} data: {"token":",","index":2} data: [DONE]

每行data:就是一个数据块,空行代表一个事件结束。很多场景下服务器会故意在事件之间停顿几十到几百毫秒,模拟“思考”的过程,这就让响应体有了一个时间维度上的“形状”。

传统接口测试关心的是“从发请求到拿全响应体的总时间”。但流式接口里,用户不关心总时间,用户关心的是——第一句话多久出现?之后每句话之间是否均匀?有没有中间卡住十几秒?这些指标在总响应时间里可能完全看不出来。

1.2 三大主流工具在流式场景下的具体失真点

我用 JMeter、wrk、ab 都踩过坑,分别说一下它们的问题:

  • JMeter:默认 HTTP 采样器会等整个响应接收完毕才算结束,测得的时间是“最后一个字节”,首 token 延迟完全不可见。硬写正则表达式匹配流式响应也只能拿到最终拼好的文本,中间到达节奏依然是黑盒。
  • wrk:设计目标是高并发短连接压测,对连接建立后的长时读取场景支持薄弱,而且它关注的是请求/秒和延迟聚合,无法细致还原流式事件时间轴。
  • ab:单请求工具,能力更基础,遇到长连接直接按超时处理,基本不适合。

最麻烦的问题是:这些工具都会引入额外的 buffering。发压机和被测服务之间的连接如果在某一层被缓冲了,比如代理服务器攒够 4KB 才转发,工具看到的结果可能“看起来很快”,因为缓冲区一次性灌进来了。真实浏览器里的 SSE 事件却被代理的缓冲策略切成了一段一段的毛刺。这就是为什么会出现压测全绿、线上全红的情况。

1.3 流式接口真正该测的四类指标

拿这套工具对接大模型接口的时候,我的关注点会落到四类指标上:

指标类别具体含义用户体验关联
首 token 延迟(TTFT)请求发出到第一个 data 事件到达的时间“第一句话什么时候开始显示”
token 间延迟 / 节奏相邻 data 事件到达的时间差“后续文字是不是均匀输出、会不会卡顿”
token 生成速率单位时间内收到的有效 token 数量“整段回答的吞吐效率”
连接生命周期连接建立、事件完成、连接断开的时间点“是否会半路断流、超时重连”

常规的“平均响应时间”“TPS”在这里只能作为参考,不能作为核心结论。一个接口 TTFT 正常,但 token 间延迟忽快忽慢,给人的体验依然是“卡”;另一个接口平均 token 速率很高,但第一个 token 憋了 10 秒,体验同样差。只看聚合数据,这两个问题都会被掩盖。

2. 这套工具把 SSE 压测拆成了装配、施压与观测三件事

我最初拿到 7DGroup 这个项目时,最直观的感受是:它没有把“压测”做成一个大杂烩,而是拆成了三个相对独立的模块——请求装配、并发施压、结果观测。这种拆分在工程上非常实用,因为三个环节的迭代频率完全不同。

2.1 装配:请求模板与流式规则分离

工具用一套配置文件描述“怎么发请求”,里面把 URL、请求头、请求体、流式超时策略都拆开。例如要压测一个 OpenAI 兼容的流式对话接口,配置大致是这个样子:

target: "http://10.1.20.5/v1/chat/completions" method: "POST" headers: "Authorization": "Bearer sk-demo-key" "Content-Type": "application/json" body: | { "model": "demo-llm", "stream": true, "temperature": 0.7, "messages": [ {"role": "user", "content": "请详细介绍一下分布式系统设计原则"} ] } concurrency: 20 duration: "60s" stream: idle_timeout: 15000 max_response_bytes: 10485760 report: percentiles: [50, 90, 99] output: "report.html"

关键点在于stream这块。idle_timeout不是普通 HTTP 读超时,它表示“两个 data 事件之间的最大安静时间”,只有在这段时间内没有新的数据流到达,才算真正的流超时。普通工具把整个请求的读超时设成一个值,SSE 场景下会出现两个极端错误:要么超时太短误杀长回答,要么超时太长压测结束后虚耗大量等待时间。

2.2 施压:每个虚拟用户都是一条独立 SSE 流

施压引擎的逻辑值得细讲。工具启动后,每个并发虚拟用户不是循环地“发一次请求等一次响应”,而是:

  1. 建立 HTTP 连接,发送请求。
  2. 进入行读取循环,逐行解析data:字段。
  3. 为每个成功解析的 data 事件记录时间戳。
  4. 遇到[DONE]标记或者连接被服务端关闭,本次会话结束。
  5. 若设置了固定压测时长,则提前终止并记录未完成会话。

这种逐行读取、逐事件打点的方式是正确还原 SSE 流的关键。如果像普通工具那样一次性读响应体,你拿到的只是一个字符串数组,时间信息全部丢失。

工具对每个事件都计算了三种时间戳:事件到达的系统时间、距请求发送的相对时间、与上一个事件的间隔。最后输出时,TTFT 等于第一个 data 事件的相对时间,token 间延迟等于所有间隔的分布(而不仅仅是均值)。

2.3 观测:压测结束后到底能拿到什么

压测结束后的报告不是只有一张汇总表,而是分了三层:

  • 会话级汇总:成功会话数、失败会话数、平均会话时长、总吞吐量。
  • 指标分布:TTFT、token 间延迟、会话时长的 P50/P90/P99 分位数。
  • 时间序列视图:按秒聚合的 token 速率曲线、活跃连接数曲线、错误率曲线。

第三层最有用。它让我能看到“从第 20 秒开始 token 速率从 40 token/s 断崖式掉到 5 token/s”这样的现象,而不是看到一个笼统的“平均 18 token/s”。线上用户反馈“越到后面越慢”时,基本都能在时间序列曲线上找到对应拐点。

3. 环境准备与第一个真实 SSE 压测跑通

工具本身是开源的,安装过程不算复杂,但有几个环境细节值得留意。我先说最简路径,再说容易出问题的地方。

3.1 拉取代码、编译与前置检查

仓库里提供了预编译二进制,如果你用的是主流 Linux 发行版,直接下载对应平台的压缩包解压就能跑。想从源码编译也很简单,项目核心是用 Go 写的,依赖较少,只要本机有可用的编译环境和网络代理配置,执行构建命令即可:

git clone https://github.com/demo/sse-bench # 这里是示意地址,实际以仓库为准 cd sse-bench make build

编译产物会生成在bin/目录下。跑命令前建议先做两个系统级检查:

ulimit -n sysctl net.ipv4.ip_local_port_range

这两条非常关键。SSE 压测会长时间占用连接,如果ulimit -n是默认的 1024,并发一高就会报 “too many open files”。而ip_local_port_range决定了一个发压机最多能同时建立多少条出站连接,默认通常够用,没有必要提前调大,但压测前确认一下可以让排查更顺。

3.2 先别打真服务,用本地模拟流校准工具

我的习惯是:第一次跑通时,先打一个本地 SSE 模拟服务,确认工具本身没问题,再切换到真实模型服务。因为真实服务受模型推理速度影响,输出节奏不稳定,如果压测曲线很乱,分不清是工具问题还是服务问题。

这里给一个最小可用的本地模拟服务,用 Python 实现,不需要额外框架:

import json import sys import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer class SSEHandler(BaseHTTPRequestHandler): def do_POST(self): self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.end_headers() for i in range(50): payload = json.dumps({"token": f"word-{i}", "index": i}) self.wfile.write(f"data: {payload}\n\n".encode("utf-8")) self.wfile.flush() time.sleep(0.02) # 模拟模型逐 token 输出 self.wfile.write(b"data: [DONE]\n\n") self.wfile.flush() if __name__ == "__main__": server = ThreadingHTTPServer(("0.0.0.0", 18888), SSEHandler) print("SSE mock server on :18888", flush=True) server.serve_forever()

启动它之后,配置文件的 target 指向http://127.0.0.1:18888/v1/chat/completions,发 concurrency=10、duration=10s 的压测。正常结果应该能看到 50 个 token 在约 1.3 秒内均匀到达,TTFT 很低,token 间延迟接近 20ms。如果这个结果都对不上,那一定是环境或配置出了问题。

3.3 命令行参数与输出解读

工具支持通过命令行参数覆盖配置文件里的大部分内容,比如:

sse-bench -f ./config.yaml -c 50 -d 60s -o ./result/

设计意图是:config.yaml保存稳定的请求结构和流式规则,-c、-d这些是每次压测才变的参数,不用每次改文件。运行结束后,终端会打印一版简要的文本报告,同时把详细时间序列数据落盘到指定目录。

我第一次跑的时候,看到终端输出里有一列imbalance指标,这个字段我特意查了文档:它表示会话完成时间与数据到达时间的比值。如果比值接近 1,说明服务器发完最后一个 token 后立刻关掉连接,没有多余的尾部等待;如果比值远大于 1,说明流结束后还有一段空闲的“尾巴”,这种情况要么是服务端没有及时发送关闭信号,要么是连接池没有主动回收连接。

4. 一个典型定位案例:慢的不是首屏,而是 token 节奏

工具的正确用法不只是压出几个数字,而是能帮你把问题钉死在某一层。我前段时间帮一个团队排查他们的问答应用,现象是“用户提问后第一句话能出来,但越往后越卡,最后经常卡死”。他们是做 Agent 类产品的,用户问一个复杂问题,后端要多次调用大模型,中间还要调检索服务,链路很长。

4.1 第一次压测直接把矛头指向了 Agent 编排层

我按他们的真实调用链做了压测配置:并发 30,压 5 分钟,模拟固定 prompt 流式请求。报告出来,几个核心数字如下:

指标P50P90P99
TTFT480ms1.2s3.4s
token 间延迟42ms180ms900ms
会话总时长7.8s15.3s28.6s
token 速率38/s11/s4/s

第一眼看 TTFT,P50 只有 480ms,似乎“第一屏很快”;但看 P99 会话总时长,已经 28.6 秒,且 P99 token 间延迟 900ms,意味着每满一秒才蹦出下一个 token。用户体感自然是“后半截完全卡死”。

如果沿用普通压测工具的总响应时间视角,你只会得到“平均 10 秒左右”这种结论,然后大概率去优化模型本身。但这个曲线告诉我们:问题根本不在模型推理速度,因为 token 速率在中间某个时间点发生了断崖——事件时间序列显示,大量会话在第 10 秒后 token 间延迟从 40ms 级跳到 200ms 级。

4.2 顺着 token 断崖挖到缓冲区

我让开发同事在 Agent 编排服务里打了点,重点看“收到模型数据”和“向客户端转发”两个时间戳。结果发现,编排服务内部用了带缓冲的流式转发组件,攒够固定大小才往外出数据。模型服务本身输出稳定,但这个缓冲区把稳定的流切成了一个一个“大包”,数据到达客户端就变成了急促的脉冲式节奏。

最典型的证据是:时间序列曲线里出现了“整块到达”的现象——某秒 token 量是 60,下一秒是 0,再下一秒又 60。这不是模型生成的问题,是中间转发层缓冲导致的。

关掉该层的缓冲,改成边收边转,再压一次,P99 token 间延迟从 900ms 降到 150ms 以内。用户侧的问题也基本消失。整个过程里,如果用普通工具压测,几乎不可能定位到这一层,因为普通工具只会告诉你“整体响应慢”,不会告诉你“慢在流的哪一段”。

4.3 真实链路压测必须叠加多轮上下文

这个案例还有另一个教训:他们的 Agent 场景里用户往往连续追问,每轮都要携带前十轮的历史消息。第一次压测我只用了固定单轮 prompt,没有覆盖到“请求体随上下文膨胀”这个变量。

后来我在配置里用了一个外部文件来动态读取请求体模板,把历史轮数分成三档:3 轮、10 轮、20 轮。结果发现,同一并发下,20 轮上下文的 TTFT 比 3 轮时升高了 60%。原因在于请求体变大后,网关的请求解析耗时长,而且大请求体和 SSE 响应在同一个 TCP 连接上的读写会产生 head-of-line blocking。这个现象,单轮压测是发现不了的。

5. 压测过程中绕不开的四个工程坑

工具跑通只是第一步,真正落地到生产级压测还会遇到一堆破事。这里把我踩过的四个坑和解决办法列出来。

5.1 idle_timeout 不是设得越大越好

我见过有人把idle_timeout设成 60 秒,理由是“模型最极端的情况可能要思考很久”。这会让压测结束时间被拉得很长,因为工具在每个会话的流结束后,还要继续等待直到时间阈值触发才判定“完成”。

更合理的做法是:先看产品里用户可接受的“停止输出”时间,比如前端一般超过 8 秒没有新 token 就会显示加载失败,那么idle_timeout设 10 秒即可。这样既能捕获真断流,又不会让压测被极端情况拖垮。参数的意义是“超过正常思考时间的异常”,而不是“模型最长思考时间”。

5.2 发压机的文件描述符与源端口耗尽

对 SSE 这类长连接场景,单台发压机能承载的并发数远小于普通短连接压测。因为每条连接会一直占着一个文件描述符和一个本地端口,直到会话结束。大模型一次会话动辄 20 秒,发压机 30 秒最多能起多少新连接,取决于ip_local_port_range的端口总数。

我压 200 并发时遇到过“connect: Cannot assign requested address”。排查下来不是目标服务挂了,而是发压机本地端口耗尽。解决办法不算难:要么加发压机节点,要么缩短单次会话时长,要么用多台压测机分散源端口压力。改内核参数只是临时缓解,长连接场景下端口回收速度跟不上建立速度,这是物理限制。

5.3 [DONE] 标记与连接关闭的先后关系

真实模型服务的流式结束行为并不统一。有的服务发完data: [DONE]之后立刻关连接,有的会故意保持连接多等几秒,还有的根本不发[DONE],直接关闭连接来表示结束。

工具对这两种情况都有记录,但在统计“成功完整流式会话”时,会区分“正常收到结束标记”和“连接被关闭”。如果报告里completed数量远小于closed数量,说明服务端对流的结束协议处理不规范。这时候要与开发确认:是否所有场景都会发完成标记?如果服务端设计上就不发[DONE],那压测时也要相应调整判定逻辑,否则所有会话都会被算成异常。

5.4 大模型接口的不确定性让压测结果天然带噪声

LLM 接口和普通接口最大的不同是:即使同样的 prompt,每次输出的 token 数、生成速度都不一样。模型采样有随机性,服务端是否触发缓存、是否命中投机解码,都会影响指标。这就导致你压 1000 次请求,P99 波动可能非常大。

处理办法是“控制变量”:

  • 固定 prompt,并设置max_tokens上限,让输出长度尽量一致。
  • 尽量关掉模型的随机采样(temperature 设为 0),减少输出不确定性。
  • 压测批次之间留足时间,避免模型服务本地的动态批处理把上一批的尾部延迟传染给下一批。
  • 需要横向对比时,同一场景至少压三轮,比较中位数而不是单次结果。

还有一点容易被忽略:如果你压的是 Agent 类服务,中间环节可能触发了真正的第三方 API 调用,网络抖动会直接放大压测方差。这种场景要在结果里区分是 Agent 编排耗时还是模型流式耗时,否则很难判断是业务逻辑退化了还是底层模型出了问题。

6. 我测下来最顺手的用法与工具边界

用了一个多月,最后分享几个我自己形成的工作流,以及这套工具目前不适合做什么。

6.1 最有价值的用法:回归压测与容量规划

我现在每个迭代版本上线前,都会跑三组固定压测:基础对话流式接口、带 20 轮上下文的会话接口、带 Agent 工具调用链路的流式接口。三组各压 3 分钟,只对比 P50 和 P99 的 TTFT 与 token 间延迟。

这个习惯帮我抓住过两次真实的性能回退,都是平均指标没变、但 P99 token 间延迟从 120ms 涨到了 300ms。如果只看平均值根本不会发现。

容量规划时,我会用工具的“阶梯并发”模式:分别以 10、20、50、100 并发压同一接口,画出 token 总吞吐量随并发变化的曲线。当并发继续增长、token 总吞吐量不再线性增长时,基本就到了系统能够承受的服务能力边界。这个拐点数据丢给运维做扩缩容,比拍脑袋定 QPS 靠谱得多。

6.2 工具边界:不要指望它直接做全链路追踪

说实话,这套工具定位很明确——它是一把“SSE 流式接口压测的专用尺子”,不是全链路监控系统。

它测的是“从发压机视角看到的数据到达情况”,不会主动告诉你瓶颈在模型、网关还是下游工具调用。定位问题还得靠业务链路里的 tracing 数据去对拍,就像我在案例里做的那样——先用压测指标圈定问题窗口,再去业务链路里找精确原因。

另外,它也不适合测低于 100ms 的普通短线接口,这种场景有更成熟通用的压测工具,没必要用它。

6.3 最后一个小技巧:把时间序列曲线存下来

我建议压测结束后不要只看 HTML 报告,一定要保留原始的时间序列 CSV 文件。排查线上问题的时候,把线上真实流量的 token 到达曲线和压测曲线叠在一起看,能非常直观地判断线上卡顿到底是“模型变慢”还是“网络/网关抖动”。

曾经有一次线上用户反馈“输出明显变卡”,压测复现却一切正常。后来拿线上某次请求的完整流式时间轴和压测曲线对比,发现线上 token 间延迟呈现规律的“每 10 个 token 就有一个 300ms 毛刺”,顺着这个规律我们把问题定位到了底层网关的帧调度策略,而不是 AI 服务本身。这就是保存时间序列数据的最大价值——它让你在出事的时候,手里有一张“正常情况长什么样”的底片。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进