做 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 流
施压引擎的逻辑值得细讲。工具启动后,每个并发虚拟用户不是循环地“发一次请求等一次响应”,而是:
- 建立 HTTP 连接,发送请求。
- 进入行读取循环,逐行解析
data:字段。 - 为每个成功解析的 data 事件记录时间戳。
- 遇到
[DONE]标记或者连接被服务端关闭,本次会话结束。 - 若设置了固定压测时长,则提前终止并记录未完成会话。
这种逐行读取、逐事件打点的方式是正确还原 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 流式请求。报告出来,几个核心数字如下:
| 指标 | P50 | P90 | P99 |
|---|---|---|---|
| TTFT | 480ms | 1.2s | 3.4s |
| token 间延迟 | 42ms | 180ms | 900ms |
| 会话总时长 | 7.8s | 15.3s | 28.6s |
| token 速率 | 38/s | 11/s | 4/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 服务本身。这就是保存时间序列数据的最大价值——它让你在出事的时候,手里有一张“正常情况长什么样”的底片。