把 LLM 流式接口当成普通 HTTP 去打空 QPS,报表往往偏乐观:吞吐被高估,连接占用被低估,首字延迟和取消后是否停算也看不见。容量真正绑住的是「首字节之前的等待 + 整段生成期内的连接」以及取消链路有没有把后续 token 停掉。下面用 Node.js 写一套可复现的假服务和压测客户端,测的是 TTFT、在途流和取消回收,而不是一个失真的 QPS。
空 QPS 量的是请求,不是一次对话
常规 HTTP 压测默认假设:请求短、响应一次性返回、连接很快释放。LLM 的 stream: true 不是这个模型。一次对话会先空等一段时间才吐出首 token,随后在同一条连接上持续写若干秒;用户中途离开时,客户端停读并不等于服务端停算。
因此「每秒发出多少 POST」几乎没有容量含义。8 个并发流、每次生成大约 2 秒,稳态完成速率大约是每秒 4 次对话量级;同一套接口如果改成立刻返回空 JSON,空 QPS 可以高几个数量级,但那测的是框架握手,不是生成。后面的示例会把生成时间写进假服务参数里,对照时以参数为准,不要拿空打数字去做扩容。
更稳妥的主指标是:在途流数量(inflight)、首字节时间(TTFT)、正常结束 vs 取消、以及取消后服务端计数有没有跟上。QPS 最多当衍生量:完成数除以窗口时长。
流式压测要盯的三件事
第一是首字延迟。用户体感几乎由 TTFT 决定,平均总耗时会把「排队 + 首字 + 剩余 token」混在一起。客户端必须在读到第一个非空 chunk 时打点,而不是等 res.json()。
第二是按块消费。只发请求不读 body,内核缓冲或 HTTP 层会背压失真,服务端写得很快、你却以为自己很能扛。正确姿势是 ReadableStream 拉块,模拟前端逐 token 渲染。
第三是取消回收。浏览器关页、用户点停止,对应 AbortController.abort()。只在客户端记一次 abort 不够:服务端若继续 res.write,GPU 和 token 预算仍在烧。压测要同时看客户端 aborted 与服务端因 req 关闭而停下的次数。
这三件事对上的是并发预算:你能同时扛住多少条未结束的流,而不是每秒能点燃多少个请求。
假服务:把首字延迟和取消停算做成可观察
下面是一个本地假服务,路径故意写成 OpenAI 兼容的 /v1/chat/completions,响应是常见的 SSE chunk。默认首字 400ms,之后每 80ms 一片,共 24 片,一次完整生成大约 2.3 秒。它在 req 的 close 上停写,并用 /metrics 暴露 inflight / completed / cancelled。
需要 Node.js 18+。先存为 stream-mock.mjs:
import http from "node:http";
const PORT = Number(process.env.PORT ?? 8787);
const FIRST_TOKEN_MS = Number(process.env.FIRST_TOKEN_MS ?? 400);
const CHUNK_INTERVAL_MS = Number(process.env.CHUNK_INTERVAL_MS ?? 80);
const CHUNK_COUNT = Number(process.env.CHUNK_COUNT ?? 24);
const metrics = { inflight: 0, started: 0, completed: 0, cancelled: 0 };
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function sseChunk(text) {
const payload = {
id: "chatcmpl-demo",
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
};
return `data: ${JSON.stringify(payload)}\n\n`;
}
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/metrics") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(metrics));
return;
}
if (req.method !== "POST" || req.url !== "/v1/chat/completions") {
res.writeHead(404);
res.end("not found");
return;
}
metrics.inflight += 1;
metrics.started += 1;
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
});
let finished = false;
const finish = (kind) => {
if (finished) return;
finished = true;
metrics.inflight -= 1;
if (kind === "cancel") metrics.cancelled += 1;
else metrics.completed += 1;
};
req.on("close", () => {
if (!finished) finish("cancel");
});
(async () => {
await sleep(FIRST_TOKEN_MS);
if (finished || res.destroyed) return;
for (let i = 0; i < CHUNK_COUNT; i += 1) {
if (finished || res.destroyed) return;
res.write(sseChunk(i === 0 ? "你好" : "字"));
await sleep(CHUNK_INTERVAL_MS);
}
if (finished || res.destroyed) return;
res.write("data: [DONE]\n\n");
res.end();
finish("complete");
})().catch(() => {
if (!res.writableEnded) res.destroy();
finish("cancel");
});
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`stream mock http://127.0.0.1:${PORT}`);
});
启动:node stream-mock.mjs。调 FIRST_TOKEN_MS、CHUNK_INTERVAL_MS 可以分别模拟「模型很慢才开口」和「token 很稀」。不要把这里的间隔当成任何线上模型的测量值,它只是可控的时钟。
客户端:等到首字节、按块读、按比例取消
客户端用原生 fetch + AbortController + res.body.getReader()。每个请求在第一个 chunk 上记录 TTFT;约 30% 的请求在 600ms 后取消(默认刚好越过首字、停在半截生成)。压测结束只停止发新请求,等在途流自行结束,避免把「收工」误记成「用户取消」。过程中轮询 /metrics,看 inflight 是否贴着并发度。
存为 stream-bench.mjs:
const BASE = process.env.BASE ?? "http://127.0.0.1:8787";
const URL = `${BASE}/v1/chat/completions`;
const CONCURRENCY = Number(process.env.CONCURRENCY ?? 8);
const DURATION_MS = Number(process.env.DURATION_MS ?? 15_000);
const ABORT_AFTER_MS = Number(process.env.ABORT_AFTER_MS ?? 600);
const ABORT_RATIO = Number(process.env.ABORT_RATIO ?? 0.3);
const stats = {
started: 0,
completed: 0,
aborted: 0,
errors: 0,
bytes: 0,
ttft: [],
inflightSamples: [],
};
function percentile(values, p) {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const idx = Math.min(
sorted.length - 1,
Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)
);
return sorted[idx];
}
async function oneRequest() {
const ac = new AbortController();
const t0 = performance.now();
stats.started += 1;
let gotFirst = false;
let abortTimer;
try {
if (ABORT_AFTER_MS > 0 && Math.random() < ABORT_RATIO) {
abortTimer = setTimeout(() => ac.abort(), ABORT_AFTER_MS);
}
const res = await fetch(URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "demo",
stream: true,
messages: [{ role: "user", content: "写一句你好" }],
}),
signal: ac.signal,
});
if (!res.ok || !res.body) {
stats.errors += 1;
return;
}
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!gotFirst) {
gotFirst = true;
stats.ttft.push(performance.now() - t0);
}
stats.bytes += value.byteLength;
}
stats.completed += 1;
} catch (err) {
if (err?.name === "AbortError" || ac.signal.aborted) stats.aborted += 1;
else stats.errors += 1;
} finally {
clearTimeout(abortTimer);
}
}
async function worker(stopSignal) {
while (!stopSignal.aborted) {
await oneRequest();
}
}
const stop = new AbortController();
const poll = setInterval(async () => {
try {
const m = await fetch(`${BASE}/metrics`).then((r) => r.json());
stats.inflightSamples.push(m.inflight);
} catch {
// 假服务未就绪时忽略一次采样
}
}, 400);
const workers = Array.from({ length: CONCURRENCY }, () => worker(stop.signal));
await new Promise((r) => setTimeout(r, DURATION_MS));
stop.abort();
await Promise.allSettled(workers);
clearInterval(poll);
const serverMetrics = await fetch(`${BASE}/metrics`).then((r) => r.json());
const inflightMax = stats.inflightSamples.length
? Math.max(...stats.inflightSamples)
: 0;
console.log(
[
`concurrency=${CONCURRENCY}`,
`started=${stats.started}`,
`completed=${stats.completed}`,
`aborted=${stats.aborted}`,
`errors=${stats.errors}`,
`bytes=${stats.bytes}`,
`ttft_p50=${percentile(stats.ttft, 50)?.toFixed(1) ?? "-"}ms`,
`ttft_p95=${percentile(stats.ttft, 95)?.toFixed(1) ?? "-"}ms`,
`inflight_max=${inflightMax}`,
`server_completed=${serverMetrics.completed}`,
`server_cancelled=${serverMetrics.cancelled}`,
`server_inflight=${serverMetrics.inflight}`,
].join("\n")
);
另开终端:node stream-bench.mjs。先看 TTFT 的 p50/p95 是否贴着 FIRST_TOKEN_MS(本机回环通常只会多出一点点调度误差);再看 inflight_max 是否接近 CONCURRENCY。若客户端 aborted 明显增加、服务端 cancelled 几乎不动,说明取消没有传到生成循环——这正是空 QPS 报表看不见的泄漏。
怎么用这些数字做容量,而不是报一个 QPS
把假服务参数当成已知时钟:完整生成约 FIRST_TOKEN_MS + CHUNK_COUNT * CHUNK_INTERVAL_MS。无取消时,稳态完成速率的上限大致是 concurrency / 单次生成秒数。有取消时,完成速率会下降,但 inflight 仍可能顶满——取消来得晚,连接已经被占住。容量规划应先定「允许同时存在多少条未结束流」,再反推入口 RPS;反过来用空 POST 打出的 RPS 去乘平均生成时间,会把连接和上游推理配额估爆。
读数时对三张表,而不是一个峰值:
- TTFT:排队变长时 p95 会先于完成速率恶化,这是该扩并发还是该限流的信号。
- inflight:贴着预算是健康占用;长期高于预算说明有连接没释放,或取消未停写。
- 双方取消计数:客户端 aborted 与服务端 cancelled 应同量级。只在网关切断、推理进程仍在跑,属于「用户走了、账单还在」。
换真实网关时,不要改客户端语义:仍然等首字节、按块读、中途 abort。需要改的是 URL、鉴权和是否 HTTP/2。协议变了,这三件事仍然比空 QPS 更接近用户和账单。
总结
流式 LLM 接口的压测对象是占用时长,不是请求点火次数。用 Node.js 的 fetch、流式 getReader() 和 AbortController 就能把 TTFT、在途流和取消回收做成可复现实验;服务端必须在 req 关闭时停写,并用 inflight/completed/cancelled 对账。报表优先给并发预算和取消链路,完成速率只当衍生量。空 QPS 适合测网关能否握手,不适合拿来决定能同时开多少路生成。
评论