1. SSE流式接口的性能画像与普通HTTP接口差在哪
1.1 一个反直觉的测试结果:总耗时正常的接口,体验却很糟
先讲一个我踩过的真实场景。团队内部做AI大模型应用压测,用传统的HTTP压测工具去测一个SSE流式接口,统计出来的平均响应时间只有600毫秒,P99也就1.2秒。从数据上看,这接口“很快”。但实际打开前端页面去试,体感完全是另一回事:鼠标点下去之后,页面空转了将近3秒才蹦出第一个字,等第一个字出来后,后面的字倒是刷刷刷往外冒,一两秒就把整段回答打完了。
问题出在哪?传统压测工具统计的是“从请求发出到响应完全结束”的时间,也就是端到端总耗时。但SSE这种流式协议,真正影响用户体验的节点是“首包到达时间”和“后续数据的到达节奏”,这两个数字在传统工具里完全看不到。600毫秒的总耗时里,可能包含了4秒的“等首字”和后面急促的“补偿式输出”,平均下来数字很好看,用户体验却一塌糊涂。
这就是这篇要解决的问题:针对AI大模型应用里最常见的SSE协议,性能指标到底该怎么定义、怎么埋点、怎么计算,才能真实反映用户感知,并且能定位性能瓶颈。
1.2 SSE的底层传输模型:事件流、长连接与缓冲
SSE的全称是Server-Sent Events,服务端推送事件。它跟WebSocket那种全双工通信不一样,SSE是单向的,服务端到客户端,客户端只管接收。协议本身很轻,基于普通HTTP,Content-Type是text/event-stream,数据格式是一行一行的data:前缀字段,事件之间用空行分隔。比如:
code复制data: {"token": "大明"}
data: {"token": "模型"}
event: done
data: [DONE]
每隔一段数据就是一个事件,客户端通过EventSource或者自己写的fetch流式解析器去逐个读。
这里要特别强调一个关键点:SSE的服务端必须主动flush。很多第一次写流式接口的人,在框架层或者日志层开了缓冲,导致模型吐出来的Token全积压在服务端缓冲区里,直到整个响应结束才一次性发给客户端。这种情况下,SSE名义上还是“流式”,实际上退化成普通JSON接口,首Token延迟直接变成完整响应时间。
另外,SSE长连接里还有个隐藏机制:为了保住连接不被中间网络设备掐断,很多服务端会定期发注释行(:开头的行)或者心跳事件。这类非业务数据在计算性能指标时要不要算进去、怎么排除,后面会单独讲。
1.3 传统HTTP压测指标为什么套不进SSE场景
传统HTTP性能指标主要看三件事:响应时间、吞吐量、错误率。这些指标的前提假设是“响应是一个完整的、有明确边界的对象”。请求发出去,服务端处理完,响应报文一次性或分块返回,客户端拿到全部字节后,这个请求才算结束。
SSE把这个假设打破了。响应不是“一次性返回”,而是一条持续打开的数据管道。于是产生了三类传统指标回答不了的问题:
第一,响应时间的语义分裂了。同样是“响应时间”,首Token延迟、流式生成时间、总耗时,三个数字描述的是完全不同的性能特征,不能合并成一个平均值。比如一个接口首Token 3秒、生成速率80 tokens/s、总耗时4.2秒,跟另一个接口首Token 300毫秒、生成速率20 tokens/s、总耗时4秒,端到端总耗时接近,但用户体验天差地别。
第二,吞吐量的计算前提变了。传统QPS看的是“每秒完成多少个完整请求”,流式接口则要看“每秒输出多少个Token”。同样一个请求连接,可以在管道里维持几十秒,计算“每秒请求数”毫无意义。
第三,错误判定不稳定。传统接口要么成功要么失败,边界清晰。流式接口可能出现“第一包成功、第二包失败”“连接保持正常但数据不再刷新”“输出到一半悄悄截断”等中间态。这种部分失败场景,传统错误率统计根本覆盖不到。
所以我做7D-AI系列项目的时候,第一件事就是把SSE性能指标体系单独拉出来设计,而不是继续套用普通HTTP那套压测模型。下面的内容就是这套体系里的指标定义和计算方法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 指标计算之前,先把四个量化维度定清楚
2.1 TTFT首Token延迟:用户感知的“第一把尺”
TTFT(Time To First Token),从请求发起到收到第一个业务Token的时间。这个名字在AI大模型领域已经比较通用,但很多团队实际落地时测的并不是“首Token”,而是“首包”或者“首个SSE事件”,数字差得不是一点半点。
我见过一个典型的错误做法:在服务端入口打点,收到HTTP请求时记录t0,然后在循环生成Token的代码里,第一次进入循环时记录t1,用t1 - t0作为首Token延迟。这个口径在服务端内部算模型处理延迟没问题,但它完全不等于用户侧感知的TTFT。
用户侧感知的TTFT,应该加上三部分额外时间:
- 网络传输时间:服务端到客户端的链路延迟;
- 应用层转发延迟:如果是网关转发、模型编排层,每个环节都要吃时间;
- 客户端解析时间:收到字节到渲染出第一个字之间的处理耗时。
7D-AI里我推荐的做法是:以客户端主时钟为准,记录从发送请求到客户端解析到第一个有效业务事件的时间差。这样虽然包含网络开销,但对用户来说,这就是他真实感受到的“等待时间”,用这个数字做体验评估才靠谱。
2.2 Token生成速率:吞吐数字里的口径陷阱
Token生成速率,单位是tokens/s,衡量模型每秒能吐出多少个Token。这个指标看起来简单,其实口径很容易打架。
第一种算法:用总Token数除以总耗时。比如一次流式响应共输出500个Token,从请求发出到流结束用时10秒,得出50 tokens/s。这个数字是“端到端有效速率”,它的数值会被TTFT稀释。如果TTFT是3秒,那么后面真正生成500个Token只用了7秒,实际的流式生成速率是71 tokens/s,两者差了40%。
第二种算法:用总Token数除以(总耗时 - 首Token耗时)。这个是“稳态流式速率”,能反映模型在持续生成阶段的真实吞吐能力。
两种算法的用途完全不同:
- 评估用户交流体感、对外承诺SLA时,用端到端有效速率;
- 评估模型推理性能、做硬件选型、调生成参数时,用稳态流式速率。
在7D-AI指标表里,这两个指标我都会单独记录,否则后续排查“为什么用户觉得慢”时,你分不清是首Token延迟贡献了占比,还是模型本身的生成速率真的上不去。
2.3 数据间隔稳定性:卡顿感的量化来源
TTFT解决了“多久开始出字”的问题,Token速率解决了“平均出字快不快”的问题,但还有一个常见的体验痛点它们都描述不了:动画式的打字机输出变成一顿一顿的。
想象一下,用户看到的输出节奏不是平滑滚动,而是“突然一口气跳出一整段文字,然后静止两三秒,又突然跳出一段”。这种体验在指标上表现为:单个数据事件之间的到达间隔极不均匀。
量化这个现象,我建议记录相邻业务事件的时间间隔序列:delta_i = t_i - t_{i-1}。然后对这个序列计算:
- P50、P95、P99分位数;
- 最大间隔;
- 超过1秒的间隔次数占总间隔数的比例。
这个方法我在实际项目里验证过很多次,比单纯看平均间隔有效得多。平均间隔可能只有200毫秒,看起来很流畅,但P95达到2秒、最大间隔5秒,体验就是严重卡顿。分位数能暴露这些长尾问题。
2.4 完整性校验:流有没有漏,比快不快更严重
SSE流式场景里有一个很隐蔽的问题:数据部分丢失。TCP层面不丢包,但应用层可能丢事件。比如客户端解析逻辑写错了,某类事件被当作心跳跳过了;或者服务端在异常分支里提前close了连接,最后一段Token没发完。
完整性校验维度,我主要检查三件事:
- 业务结束标记是否收到。规范的做法是服务端发完数据后发一个
event: done或者data: [DONE],客户端只有收到这个标记才算完整结束; - 事件总数是否落在预期范围。比如一个固定模板的回复,事件数通常波动不大,明显偏少说明有漏;
- 内容拼接后的长度是否合理。流式拼接后如果明显比预期短,或缺少结束语句,基本可以判定流被截断。
为什么完整性要放进性能指标体系?因为一个“输出到一半断掉但连接看起来正常”的流式接口,如果只统计TTFT和速率,数据会非常漂亮,用户得到的却是一个残废的回答。性能指标不能只看快慢,还得看对不对。
把这四个维度的指标汇总成一张表,方便直接对照:
| 指标 | 计算口径 | 服务端埋点位置 | 客户端计算方式 | 单位 |
|---|---|---|---|---|
| TTFT首Token延迟 | 请求发起到第一个业务事件 | 首Token产生时输出时间戳 | t_first_business - t_request_start |
ms |
| 端到端有效速率 | 总Token数/总耗时 | 流结束时输出总Token数 | total_tokens / (t_last - t_request_start) |
tokens/s |
| 稳态流式速率 | Token数/(总耗时-TTFT) | 同埋点即可 | total_tokens / (t_last - t_first) |
tokens/s |
| 事件间隔抖动 | 相邻事件到达间隔分位数 | 每个事件输出序列号+时间戳 | P95(delta_i)、max(delta_i) |
ms |
| 卡顿率 | 超阈值间隔占比 | 同埋点即可 | count(delta_i > 1000) / (n-1) |
% |
| 完整性 | 是否有done标记、事件数达标 | 结束标记事件 | done_received && count >= expect_min |
布尔 |
3. 时间戳埋点与公式细节:这套计算方法可以直接抄
3.1 服务端三个埋点加客户端两个埋点:位置别放错
这一节讲具体怎么打点。先说结论:服务端打三个点,客户端打两个点,就能把上面那套指标体系全部算出来。
服务端三个点:
req_start:收到HTTP请求的时刻;first_token_sent:第一个业务Token真正写入响应流并flush的时刻;last_token_sent:最后一个业务Token写入并flush,或者发完done标记的时刻。
客户端两个点:
client_request_start:客户端发起请求的时刻;client_event_time:客户端每收到一个业务事件记录一次本地时间戳。
组合方式是这样:
- 服务端
first_token_sent - req_start,表示入口到首Token的“服务端纯处理时长”; - 客户端
client_first_business_event - client_request_start,表示用户感知的TTFT; - 两者相减得到的差值,基本就是网络传输加中间转发损耗。
这里有一个值得注意的点:服务端打点一定放在flush之后,而不是放在写数据之前。因为流式场景下,write只是把数据写进内核缓冲区,真正让客户端感知到的是flush。放在write之前打点,时间戳会偏早,算出来的TTFT会比实际小。
客户端打点的代码,用fetch流式解析来写大概是这个结构:
javascript复制const response = await fetch(url, { method: 'POST', body: JSON.stringify(payload) });
const reader = response.body.getReader();
const decoder = new TextDecoder();
let firstEventTime = 0;
let lastEventTime = 0;
let eventCount = 0;
let lastTime = performance.now();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
// 按SSE事件分割逻辑处理
const events = splitSSEEvents(text);
for (const ev of events) {
if (!isBusinessEvent(ev)) continue;
const now = performance.now();
if (firstEventTime === 0) firstEventTime = now;
lastEventTime = now;
eventCount++;
// delta记录用于抖动计算
logDelta(now - lastTime);
lastTime = now;
}
}
3.2 TTFT计算时如何排除握手消息和保持连接注释行
SSE接口的“第一个到客户端的事件”,很可能是握手或心跳,不一定是第一个业务Token。
我遇到过一种服务端实现:建立连接后先推送一个event: connected事件,通知客户端“链路已通”。如果直接把客户端收到第一个事件的时间当TTFT,数值会偏低,因为业务Token其实还没开始生成。这种情况一定要按事件类型过滤,只认业务事件,比如data:内容里带"type":"token"字段的事件。
还有一种更隐蔽的情况:网络层或负载均衡器定期发送SSE注释行(以:开头)保活。这些注释行严格来说不算事件,但有的客户端解析器图省事,也会把它当成事件触发回调。如果这些心跳包的间隔很短,比如每5秒一次,而模型生成需要几十秒,那“事件间隔P95”就会很好看,实际上业务数据一直没来。
所以我在7D-AI的客户端解析逻辑里,对事件做了一个分级:业务事件、连接事件、心跳/注释事件。只有业务事件进入TTFT计算、事件间隔抖动计算和Token速率计算。分级逻辑很简单,event:字段是定死的值就归为连接事件,data:内容不符合约定的JSON结构就归为心跳事件。你必须在解析层就分类,而不是等统计的时候再猜。
3.3 Token速率的两种公式:流式速率与端到端速率的取舍
这里给出可以直接套用的公式。
设:
t_req:客户端请求发起时间;t_first_business:客户端收到第一个业务事件的时间;t_last_business:客户端收到最后一个业务事件的时间;total_tokens:本次响应累计收到的业务Token数。
端到端有效速率:
code复制rate_e2e = total_tokens / (t_last_business - t_req)
稳态流式速率:
code复制rate_stream = (total_tokens - 1) / (t_last_business - t_first_business)
total_tokens - 1的用意是排除首Token已经消耗掉的那段时间,让分母严格等于“从第一张牌到最后一章牌”的流式生成窗口。如果Token数很大,减不减这个1影响微乎其微,但严谨起见建议保留。
两个公式的适用场景,我的经验是:
- 对外展示“接口快不快”,用
rate_e2e,因为它包含首Token等待成本,用户体感更接近; - 对内评估“模型吐字速度有没有达标”,用
rate_stream,因为它剥离了网络和首Token排队的影响。
如果你有中间转发层、缓存层、限流器,rate_e2e会把这些组件的延迟全部吞进去。一旦rate_e2e和rate_stream差距拉大,就说明瓶颈不在模型生成,而在首Token链路。
3.4 间隔抖动、卡顿率的阈值与计算方式
事件间隔抖动,不要直接拿原始间隔做平均值,那样长尾会被抹掉。我的方法是:每一个业务事件到达时记录delta_i,把所有delta存下来,最后计算分位数和超阈值比例。
这里有一个细节很多人忽略:SSE事件的合并到达问题。网络不好或者代理缓冲时,客户端可能一次reader.read()就读到了两个事件的数据,前后两个事件的到达时间差记录为0或者很小。这不是“真实间隔”变短,而是缓冲导致的“假聚集”。所以计算间隔时,如果两个事件在同一个数据块里解析出来的,传统统计会用同一个时间戳,此时的delta_i = 0。这种聚合效应反映在指标上就是“P50极低、P95极高”,非常撕裂。我在7D-AI的日志表里会特意加一列batch_flag,标记该事件是否与其他事件同批到达,方便后续判定是不是网络缓冲造成的抖动。
参考经验值,我在本地部署AI大模型场景(量化7B~14B模型,单机多卡)实测下来的数据:
- 事件间隔P50通常在50~200ms之间;
- P95在100~500ms之间,超过1s基本能感到明显停顿;
- 超过3s的间隔大概率伴随代理缓冲或模型停顿。
卡顿率我用“超过1000ms的间隔数除以(总业务事件数 - 1)”。这个比率在本地直接连接时通常低于1%,如果超过3%,用户体验已经接近“不可用”。
3.5 一组完整的事件日志样例和聚合脚本
埋点落成日志,每行一个事件,字段别省。我这里给出一个简化版样例:
json复制{"request_id":"req_0001","event_seq":1,"event_type":"connect","time_ms":1000.0,"client_time_ms":1000.0,"token_count":0,"batch":0}
{"request_id":"req_0001","event_seq":2,"event_type":"business","time_ms":2140.5,"client_time_ms":2100.3,"token_count":12,"batch":0}
{"request_id":"req_0001","event_seq":3,"event_type":"business","time_ms":2190.8,"client_time_ms":2155.2,"token_count":7,"batch":0}
{"request_id":"req_0001","event_seq":4,"event_type":"business","time_ms":2250.1,"client_time_ms":2290.7,"token_count":9,"batch":1}
聚合计算脚本的核心逻辑,用Python写大概这样:
python复制import json
events = []
for line in open("sse_events.jsonl"):
events.append(json.loads(line))
business = [e for e in events if e["event_type"] == "business"]
t_req = min(e["client_time_ms"] for e in events if e["event_type"] == "connect")
t_first = business[0]["client_time_ms"]
t_last = business[-1]["client_time_ms"]
total_tokens = sum(e["token_count"] for e in business)
delta_list = [
business[i]["client_time_ms"] - business[i-1]["client_time_ms"]
for i in range(1, len(business))
]
gaps_over_1s = sum(1 for d in delta_list if d > 1000)
result = {
"ttft_ms": t_first - t_req,
"rate_e2e_tps": total_tokens / ((t_last - t_req) / 1000),
"rate_stream_tps": (total_tokens - 1) / ((t_last - t_first) / 1000),
"p95_gap_ms": sorted(delta_list)[int(len(delta_list) * 0.95)] if delta_list else 0,
"max_gap_ms": max(delta_list, default=0),
"stall_rate": gaps_over_1s / max(len(delta_list), 1) * 100,
"done_received": any(e["event_type"] == "done" for e in events),
}
print(result)
这个脚本可以放在CI里跑定时回归,也可以压测时批量出报告。事件级别的时间戳数据尽量保留原始,因为不同的业务分析视角需要的指标可能不一样,原始事件日志留着随时能重算。
4. 实测数据落盘与聚合:把原始日志变成可查指标
4.1 JSONL事件流水怎么设计字段
前面提到了JSONL日志,这里把字段设计讲透。我的7D-AI采集端日志要求至少包含这些字段:
request_id:每次请求的唯一ID,全链路追踪的锚点;event_seq:服务端事件序号,从1递增,客户端能用来发现丢事件;event_type:connect、business、heartbeat、done,解析阶段就要分好类;service_time_ms:服务端生成该事件的时间戳;client_time_ms:客户端收到该事件的时间戳(本地);token_count:该事件携带的业务Token数;batch_flag:该事件是否和别的业务事件在同一次读操作中到达;extra:模型名、量化级别、并发标记等环境信息。
为什么要同时记录service_time_ms和client_time_ms?因为不是所有场景都能保证服务端和客户端在同一台机器。跨机部署时,两个时间戮直接相减算延迟,前提是两台机器时钟同步。我踩过NTP漂移导致TTFT算成负数的坑,所以设计上做了区分:用服务端时间戳算生成侧指标,用客户端时间戳算体验侧指标,两边不混用。
如果你实在没有统一时钟,又需要测跨机延迟,我的建议是:以客户端时间戳为主,服务端时间戳只用于判断生成节奏。不要试图从两个不同时钟域的时间戳相减得出精确延迟,这不是指标计算问题,而是分布式系统的基本约束。
4.2 聚合计算后怎么分层存储与查询
事件流水是明细数据,量很大,不适合每行都落到在线数据库。我的做法分两层:
明细层:JSONL文件或对象存储,按天分目录,保留原始事件流水。这一层只负责存,为问题排查提供全量证据。SSE流式的事件量远没有日志系统高峰那么可怕,一天几百万行也能轻松存下。
聚合层:按request_id聚合后的单请求指标,落到关系型数据库或ClickHouse这类OLAP库里。每个请求一行,字段对应前面那张指标表:request_id, ttft_ms, rate_e2e_tps, rate_stream_tps, p95_gap_ms, max_gap_ms, stall_rate, done_received, total_tokens, created_at。
聚合层的数据可以直接用来做日报、看趋势、报警。比如P95的TTFT超过2秒,或者卡顿率超过5%,就可以触发告警。明细层的JSONL则是告警之后的侦探工具,通过request_id反向追踪具体是哪个环节拖慢。
4.3 一套自拟的参考阈值:本地模型与API场景分开看
阈值这个东西,不同部署环境差异太大,直接给绝对值容易误导。我只能给一套做过一轮本地压测的经验参考,重点看相对变化。
我自己在本地部署环境(NVIDIA 4090/3090多卡,vLLM或Ollama,7B~14B量化模型)实测的情况:
| 指标 | 本地直流(同机房) | 走公网API/代理 | 说明 |
|---|---|---|---|
| TTFT | 200ms~1.5s | 500ms~3s | 越高越要查排队、网关缓冲 |
| 稳态速率 | 30~80 tokens/s | 20~120 tokens/s | 本地量化模型偏低,API浮动大 |
| 事件间隔P95 | 50~300ms | 200~800ms | 超过1s需要排查 |
| 卡顿率 | <1% | <3% | 超过3%基本不可接受 |
注意,这套数值的前提是单路测试、无并发争抢。一旦并发拉起来,共享GPU算力,TTFT和稳态速率都会明显恶化。所以做性能剖析时,单路指标和并发指标要分开记录,不要混在一个报告里。
出现过一次很典型的案例:本地部署的模型单路测试TTFT很好,到了10路并发,TTFT从500ms涨到6秒。排查下来是预填充(prefill)阶段GPU算力被并发请求吃满,解码阶段也在互相排队。如果只看单路指标做容量规划,这个坑必然踩。
4.4 真实案例:P95分布很好看但流式体验仍然卡
这个案例对我影响很大,值得单独拿出来讲。
当时测试一个通过内网网关转发到云端API的SSE接口,聚合指标出来后:TTFT P50=800ms,P95=1.8s;事件间隔P95=300ms;卡顿率1.2%。单看这些数字,这个接口的流式体验应该及格了。
但实际人工操作时明显觉得输出不跟手,总是“一卡一卡”。后来我翻明细日志,发现事件到达时间戳的分布是双峰的:一段连续几百毫秒内密集到达10~20个事件,然后突然静默1~2秒,又密集到达一批。这种“批量+静默”的交替模式,会让分位数指标完全失真——P95算出来只有300ms,但用户感受到的是“每次静默都像卡死”。
根因也简单:网关开启了响应缓冲,模型吐字不是实时透传,而是攒一批再放行。SSE协议本质上被网关的缓冲篡改了时间特性。
这个案例说明一个道理:分位数指标再漂亮,也替代不了对事件时间戳分布形态的检查。我后来在聚合脚本里加了一个“双峰检测”:统计相邻间隔的分布直方图,如果出现明显的长尾或双峰,自动标记该请求为“疑似缓冲/卡顿”,再结合原始日志确认。这个习惯一直保留到现在。
5. 流式指标计算最容易算歪的几个地方
5.1 事件边界与服务端关闭语义:close不等于业务结束
SSE连接关闭有两种情况:服务端发完数据后主动关闭;网络异常或超时导致连接断开。对性能指标计算来说,这两者的含义完全相反——前者是正常结束,后者是异常截断。
麻烦的是,TCP层面看到的都是连接关闭。如果客户端只凭“读到EOF”就认为流结束了,那超时断流也会被当成一次正常完成。它的性能指标算出来可能还挺好,因为断流之前模型输出很快,用户看到的却是一个残缺回答。
我建议客户端做两个判断:
- 是否收到
done/[DONE]标记; - 事件序列号是否连续,服务端发出的最后一个事件序号是否等于实际收到的最大序号。
两个条件都满足才算完整。只满足第二个条件但没收到done,属于疑似截断;只满足第一个条件但序号不连续,属于中间漏了事件。这些状态在7D-AI里都会单独打标,不会跟正常请求混在同一个性能统计口径里。
判断流结束时还有一个细节:不要用“多少毫秒没收到数据”来判断结束。模型生成本来就是有顿挫的,思考、长句停顿都可能超过几秒。我之前见过有人设了5秒无数据就判流超时,结果一个合法的大模型长回复被硬生生掐断,还要被指标系统记录为“慢请求”。正确做法是数据超时只标记“疑似悬挂”,必须结合结束标记做最终判定。
5.2 网关与代理缓冲导致“一包一大坨”:时间聚合失真
这个坑在前面案例里已经出现过:代理层把若干个SSE事件缓冲后合并成一个大包一次性转发,导致客户端观察到的TTFT和事件间隔都不是模型真实的输出节奏。
对指标计算的影响很直接:TTFT会变成“代理攒够第一批数据的时间”,事件间隔P50会低到接近0(因为一批事件同时到达),P95又可能高得离谱(因为两次放行之间是静默期)。这种情况下算出的稳态流式速率、卡顿率统统失真。
怎么识别这个问题?我常用的方法是:把客户端每个事件到达时间戳画成散点图,或者直接看batch_flag字段。如果一个请求里大多数事件都标记为“同批到达”,基本可以认定存在代理缓冲。
要解决也不难,从源头入手:网关如果是Nginx,关闭缓冲相关配置;如果是自研转发层,去掉io.Copy这种傻拷贝方式,改成边读边flush。但这属于改造方案的范畴,指标系统要做的是把失真情况暴露出来,而不是被缓冲层美化后的假数据骗过去。
5.3 首事件是“握手消息”还是“业务Token”:事件类型要先分级
这个点前面提过,但还想再展开一下,因为它直接影响TTFT这个核心指标的计算结果。
SSE服务端在连接建立后,至少有三种可能的首事件:
- 业务Token;
- 连接握手消息(比如
event: connected); - 心跳/注释行。
事件类型不分级的统计方式,会把握手消息当成首Token,TTFT就会虚低——因为握手消息通常比真实业务Token生成得快得多,可能提前几百毫秒甚至几秒到达。
有一次我在测试一个编排型应用,服务端先做意图识别再调模型,首事件是一个“意图确认”消息,隔了2秒之后模型Token才真正开始输出。如果不区分事件类型,TTFT只有800ms,把意图确认当成业务开始;区分类型之后,业务TTFT是2.8秒。后者才是用户的真实等待感。
所以事件类型分级不是可选项,是必选项。我的做法很简单:服务端在data:里带一个"type":"token"或"role":"assistant"字段,客户端解析时按这个字段过滤;如果服务端没有设计这个字段,客户端就按事件名的约定去匹配。总之,进入TTFT计算的第一条业务事件,必须确认它真的携带了业务内容。
5.4 并发请求混算:不同request_id的事件不能混在一起算间隔
最后一个常见错误跟前几个不太一样,但后果同样严重:并发测试时,把多个SSE连接的事件混在一起算间隔和速率。
每个SSE连接都是独立的数据管道。如果两个请求并发进行,事件交替到达客户端,把它们放到同一个时间序列里算“相邻事件到达间隔”,那间隔会被严重缩短,P95和卡顿率都会失真。比如请求A每隔200ms来一个事件,请求B也每隔200ms来一个事件,混在一起后间隔变成了平均100ms,看起来“更快”了,完全不符合任何一边用户的真实体验。
正确做法是按request_id分组,先算单请求指标,再对指标做分位数聚合。单请求的卡顿率、P95间隔,这些基础数据必须独立成型,然后才谈得上全局P95、全局趋势。7D-AI聚合脚本里,第一行代码永远是group by request_id,这个习惯建议无脑抄。
还有一点要提醒:并发场景下把多个请求的Token算在一起得一个总tokens/s,用来评估模型吞吐量是可以的,但它不等于用户感知速率,也不等于单个请求的稳态流式速率。出门汇报的时候把两个口径分清楚,不然容易给出误导性的结论。
6. 最后说几个我在生产里养成的习惯
做完这套指标体系之后,我在新的AI大模型项目里会默认坚持几件事,这里一并分享出来。
第一,所有SSE接口上线必须带request_id,而且要在客户端、服务端日志、网关日志三层同时透传。没有request_id,指标算出来再漂亮,出了问题也追不到根因。我在7D-AI的日志设计里把request_id放在第一列,因为它是整个观测体系的地基。
第二,每个版本的模型部署和每次提示词模板变更,都跑一遍同样的流式指标回归。我吃过一次亏:把系统提示词加长之后,首Token延迟从800ms涨到2.5秒,但当时没人注意到,直到线上反馈“变卡了”。事后重测才发现是预填充阶段的输入Token数量增加了,拖慢了首Token。流式性能指标不是上线前测一次就完事的东西,它应该具备持续回归的能力。
第三,不要只信平均值,也不要只信分位数,要培养看数据分布形态的习惯。平均值会掩盖长尾,分位数会掩盖双峰,原始事件时间戳的分布图才是最终裁判。遇到任何“指标正常但体验差”的案例,第一件事就是捞原始事件日志,看时间戳分布。
SSE协议的性能指标计算,本质上回答的就是三个问题:用户什么时候开始看到内容、内容输出得均不均匀、内容有没有完整给到。把这三个问题对应的指标定义清楚、埋点埋对、口径统一,流式接口的性能评估就算立住了一半。剩下的一半是持续观测和真实流量验证,那是另一个长期工程,但至少现在你手里有了一把不会骗你的尺子。
