把 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 适合测网关能否握手,不适合拿来决定能同时开多少路生成。