私信智能回复的首字延迟
「回声」的私信页有一个“智能回复”按钮。用户收到一条长消息,点一下按钮,系统根据最近几条对话生成一段建议回复。最早的实现是一次性调用:请求发出后,界面显示转圈,等模型把整段话生成完,再一次性渲染。
这个交互在演示时没有问题,上线后却收到大量“点了没反应”的反馈。复盘时把等待拆开看:模型生成完整回复的 P50 是 2.8 秒,P95 是 7.4 秒;用户从点击到看到任何文字,平均要等 2.9 秒。人在即时通讯场景里对“没有反馈”的容忍度很低,超过 1 秒就会怀疑按钮没生效,超过 3 秒就会重复点击或者直接退出。
流式输出解决的第一个问题不是“更快生成完整回复”,而是“更早给出第一个字”。同一个模型、同一段回复,首 token 的 P50 可以降到 380 毫秒,完整回复仍然要 2.8 秒。用户看到文字开始滚动,就知道系统在工作,剩下的等待被“正在生成”这个事实吸收了。
文中出现的延迟、比例与耗时,除非特别标注为本机测试输出,都是案例假设值,用来演示预算分配,不是任何真实产品的线上数据。
一条流经过的四段
从模型到用户屏幕,一条流至少经过四段。每一段都可能缓冲、改写或截断数据,所以“流式”不是某一个组件打开开关,而是一条链路的共同契约。
| 段落 | 主要职责 | 流式下的典型失败 |
|---|---|---|
| 提供方 | 按 token 生成增量 | 首包慢、限流、生成中途断连 |
| 网关 | 鉴权、路由、预算、协议收敛 | 缓冲整段再转发、错误码泄漏 |
| BFF | 面向端形态组装数据 | 等待完整结果再返回、丢失用量 |
| 客户端 | 渲染增量、处理取消与重连 | 重复拼接、切后台后继续占用连接 |
提供方
提供方负责真正的生成。它通常以 SSE 或类似的长连接返回一串增量,每个增量可能只包含几个 token,也可能一次包含一整句。提供方的字段名、结束原因、用量上报时机都不一样,这是后面必须做协议收敛的原因。
提供方还有两个容易被忽略的特性。第一,首包时间和后续包间隔不是一回事:首包受排队与预填充影响,后续包受解码速度影响。第二,用量往往在流的末尾才上报,生成中途断开时可能拿不到官方用量,只能按已输出内容估算。
网关
网关是模型接入层的统一入口。它负责鉴权、租户配额、模型路由、预算控制和错误归一化。流式请求经过网关时,最忌讳的是“先收完再转发”:有些反向代理默认会缓冲响应,配置不当会让流式退化成一次性返回,首字延迟反而更差。
网关还要决定对外暴露什么。把提供方的原始事件直接透传给业务方,短期省事,长期会把厂商字段变成事实标准。换一个提供方,所有客户端都要改。正确的做法是在网关内完成协议收敛,对外只暴露稳定的 StreamEvent。
BFF
BFF 面向具体端形态。私信页需要的不只是文本,还可能需要“这条回复引用的是哪条消息”“是否命中敏感词”“生成耗时多少”。这些信息由 BFF 组装,但 BFF 不应该等整段回复生成完再返回,否则前面的流式全部白做。
BFF 的另一个职责是计量。一次流式请求即使被用户取消,已经生成的 token 也已经产生成本。BFF 要把用量和取消原因一起记下来,而不是只在成功时记账。
客户端
客户端拿到增量后要做三件事:按顺序拼接、维护“正在生成”状态、在用户切走时取消。拼接看起来简单,实际最容易出错的是重连:如果客户端在首帧之后重试,用户会看到同一段话出现两遍。这个问题在 5.3 节展开。
协议先于实现:StreamEvent
在写任何管线代码之前,先把对外协议固定下来。下面这段代码在 1.3 节已经出现过,本章原样复用,不修改。它定义了五种事件、SSE 编解码,以及“格式错误的帧直接抛弃”的容错原则。
// file: src/protocol/stream.ts
// tier: L1
// verify: node --test test/core/foundation.test.ts
import type { Usage } from "../core/types.ts";
/**
* 流式事件是网关对外的协议,不是提供方协议的直出。
* 增加新事件必须向后兼容:旧客户端忽略未知事件而不是崩溃。
*/
export type StreamEvent =
| { type: "start"; requestId: string; modelId: string }
| { type: "delta"; text: string }
| { type: "usage"; usage: Usage }
| { type: "done"; finishReason: "stop" | "length" | "tool_call" | "content_filter" }
| { type: "error"; kind: string; message: string; retryable: boolean };
/** 把事件编码成 SSE 帧;data 只放单行 JSON,避免换行破坏帧边界。 */
export function encodeStreamEvent(event: StreamEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
/** 解码一帧;空行与注释帧返回 null,格式错误直接抛弃而不是中断整条流。 */
export function decodeStreamEvent(frame: string): StreamEvent | null {
const line = frame
.split("\n")
.map((part) => part.trim())
.find((part) => part.startsWith("data:"));
if (!line) {
return null;
}
const payload = line.slice("data:".length).trim();
if (payload === "" || payload === "[DONE]") {
return null;
}
try {
return JSON.parse(payload) as StreamEvent;
} catch {
return null;
}
}
协议里有三个值得强调的决定。
第一,事件是判别联合,不是自由对象。type 字段把 start、delta、usage、done、error 分开,客户端用 switch 就能穷举,新增事件时类型检查会提醒所有消费方。
第二,delta 只带文本,不带“这是第几个 token”。序号属于传输层细节,客户端只需要按到达顺序拼接。把序号暴露出去,会让客户端误以为可以乱序重组,而 SSE 本身是保序的。
第三,decodeStreamEvent 遇到坏帧返回 null,不抛异常。流式连接里出现半帧、注释帧、心跳帧是常态,因为一个坏帧就中断整条流,是把传输层噪声升级成业务故障。
把增量翻译成事件
协议定好之后,管线要做的事情很具体:消费提供方增量,输出统一事件。下面这段代码是本节的实现,也是后续两节的共同地基。
// file: src/stream/pipeline.ts
// tier: L1
// verify: node --test test/stream/pipeline.test.ts
import type { ChatRequest, ModelProfile, Usage } from "../core/types.ts";
import type { StreamEvent } from "../protocol/stream.ts";
import { estimateCostMicros } from "../protocol/chat.ts";
export type FinishReason = "stop" | "length" | "tool_call" | "content_filter";
/**
* 提供方增量只描述“上游刚刚发生了什么”,不承担对外协议。
* 不同厂商的字段名与结束语义都在这一层被抹平。
*/
export type ProviderChunk =
| { type: "text"; text: string }
| { type: "usage"; inputTokens: number; outputTokens: number }
| { type: "finish"; finishReason: FinishReason };
export interface StreamingProvider {
readonly id: string;
stream(request: ChatRequest, model: ModelProfile): AsyncIterable<ProviderChunk>;
}
export interface PipelineOptions {
provider: StreamingProvider;
request: ChatRequest;
model: ModelProfile;
}
/**
* 把提供方增量翻译成统一 StreamEvent。
* 不变量:start 先于一切;usage 一定在 done 之前;done 只出现一次。
*/
export async function* pipeProviderStream(
options: PipelineOptions,
): AsyncGenerator<StreamEvent, void, void> {
const { provider, request, model } = options;
yield { type: "start", requestId: request.requestId, modelId: model.id };
let finishReason: FinishReason | null = null;
let reported: { inputTokens: number; outputTokens: number } | null = null;
stream: for await (const chunk of provider.stream(request, model)) {
switch (chunk.type) {
case "text": {
if (chunk.text !== "") {
yield { type: "delta", text: chunk.text };
}
break;
}
case "usage": {
reported = {
inputTokens: chunk.inputTokens,
outputTokens: chunk.outputTokens,
};
break;
}
case "finish": {
finishReason = chunk.finishReason;
// 结束帧之后的包一律丢弃,避免同一请求出现两个结束原因。
break stream;
}
}
}
const inputTokens = reported?.inputTokens ?? 0;
const outputTokens = reported?.outputTokens ?? 0;
const usage: Usage = {
inputTokens,
outputTokens,
costMicros: estimateCostMicros(model, inputTokens, outputTokens),
};
yield { type: "usage", usage };
yield { type: "done", finishReason: finishReason ?? "stop" };
}
/** 把事件里的文本拼回整段回复,供测试与非流式兜底复用。 */
export function collectText(events: Iterable<StreamEvent>): string {
let text = "";
for (const event of events) {
if (event.type === "delta") {
text += event.text;
}
}
return text;
}
这段代码维持了四条不变量,测试会逐条验证。
第一,start 先于提供方调用。生成器在第一次 next() 时先 yield 首帧,此时 provider.stream() 还没有被执行。客户端因此可以立刻把界面切到“生成中”,而不必等提供方返回第一个包。
第二,usage 一定在 done 之前。提供方可能在任意时刻上报用量,管线先把它记下来,等流结束再统一发一帧。这样客户端不需要处理“先结束后计量”的乱序。
第三,done 只出现一次。提供方偶尔会在结束帧之后继续吐包,代码用带标签的 break 直接停止消费,后续增量一律丢弃。对外协议不允许出现两个结束原因。
第四,空文本不产生 delta。有些提供方会用空字符串表示心跳或占位,如果原样转发,客户端会收到大量无意义的重渲染。过滤放在管线层,比让每个客户端各写一遍可靠。
还有一个刻意的取舍:管线在缺少结束帧时用 stop 收尾,而不是抛错。5.1 节先把“正常路径”跑通,把“缺少结束帧”视为截断,由 5.2 节的控制器和 5.3 节的错误语义处理。读者可以看到,同一个事实在不同层次有不同处理方式,这不是重复,而是分工。
测试与真实输出
测试覆盖六个场景:首帧顺序、增量顺序、空文本过滤、结束帧后丢弃、缺少结束帧、文本回收。在实验工程根目录执行:
node --test test/stream/pipeline.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ start 先于提供方调用被发出 (1.785375ms)
✔ 增量按到达顺序翻译,usage 在 done 之前 (0.265458ms)
✔ 空文本不产生 delta 帧 (0.123084ms)
✔ 结束帧之后的增量被丢弃 (0.126542ms)
✔ 没有结束帧时以 stop 收尾且用量为零 (0.12875ms)
✔ collectText 忽略非 delta 事件 (0.064542ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 152.360375
注意第一个测试的写法:它只取生成器的第一帧,然后断言提供方还没有被调用。如果把 start 放在 for await 之后,这个测试会失败。顺序不变量必须能被单独观测,否则它只是注释里的一句话。
第二个测试同时验证了顺序和计量。输入 10 token、输出 2 token,按模型档案的单价计算,成本是 5 微元。这个数字很小,但它是整数运算的结果,不会因为浮点误差在账单上漂移。
延迟预算怎么分
流式体验有两个关键指标:首字延迟和完成延迟。它们对应的优化手段不同,不能混为一谈。
| 指标 | 案例假设值 | 主要影响因素 | 优化手段 |
|---|---|---|---|
| 首字延迟 P50 | 380 ms | 排队、预填充、首包网络 | 路由到低排队模型、禁用代理缓冲 |
| 首字延迟 P95 | 1.2 s | 区域、限流、冷启动 | 预留容量、就近区域、超时兜底 |
| 完成延迟 P50 | 2.8 s | 输出长度、解码速度 | 限制输出长度、流式渲染 |
| 取消率 | 12% | 首字延迟、内容质量 | 更早出字、允许重新生成 |
这些数字是案例假设值。真实项目要把它们拆到四段链路上分别测量:提供方首包、网关转发、BFF 组装、客户端渲染。只测端到端,无法判断优化应该落在哪一段。
一个常见的误判是“首字慢就换更快的模型”。如果慢在网关缓冲,换模型没有用;如果慢在 BFF 等完整结果,换模型只会让整段结果更早到达,但首字仍然不动。先把链路拆开,再决定改哪里。
一次直接透传厂商流的事故
某次版本为了赶进度,网关没有做协议收敛,直接把提供方的 SSE 帧透传给客户端。客户端按厂商字段解析,跑通了演示。两个月后,提供方把结束原因字段从 finish_reason 改成 stop_reason,同时把用量上报从末尾挪到了倒数第二帧。
客户端的解析代码没有报错,因为未知字段会被忽略。它只是再也收不到结束原因,于是一直显示“正在生成”。用户看到文字已经停止滚动,按钮却一直转圈;刷新页面后回复消失,因为 BFF 只在收到结束帧时才落库。
事故的三个根因都指向同一件事:没有稳定的中间协议。
- 结束语义依赖厂商字段,厂商一改,客户端就失去终止条件。
- 用量上报时机没有约定,BFF 的记账逻辑假设它总在最后。
- 客户端没有“超时后按已收到内容收敛”的兜底,只能无限等待。
如果当时有 StreamEvent,厂商字段变化只会影响管线里的一个 switch 分支;done 与 usage 的顺序由管线保证;客户端即使收不到结束帧,也能在超时后把已收到的文本当作半截结果处理。协议收敛的成本是一次性的,透传的代价是每次厂商变更都要全链路排查。
小结
流式的端到端链路分为提供方、网关、BFF、客户端四段。每一段都可能缓冲、改写或截断数据,所以流式不是某个开关,而是一条链路的共同契约。
对外协议要先于实现固定下来。StreamEvent 用判别联合表达首帧、增量、用量、结束与错误,管线负责把提供方增量翻译成这五种事件,并维持四条不变量:首帧最先、用量在结束前、结束帧唯一、空文本不转发。
下一节处理正常路径之外的部分:用户切走、网络变慢、上游断流时,有界队列如何施加背压,取消信号如何让生产者停下来,半截响应如何收敛并计量。
阅读导航:上一节:4.3 路由的可解释性与灰度 · 下一节:5.2 背压、取消与半截响应 。
常见错误与边界
- 把流式理解成“把一次性响应切开”。流式改变的是首字延迟与交互状态,不是总生成时间。
- 网关直接透传厂商事件。厂商字段一变,所有客户端都要跟着改,且错误码会泄漏。
- 在 BFF 等完整结果再返回。前面的流式全部白做,首字延迟回到一次性请求的水平。
- 没有过滤空文本。心跳与占位会被当成增量,触发无意义的重渲染。
- 允许结束帧之后继续消费。同一请求出现两个结束原因,客户端状态机无法收敛。
- 只在成功时计量。取消与断流同样消耗 token,不记账会低估成本。
- 把案例假设值当成 SLA。首字延迟预算要按自己的链路测量,不能照抄表格。
- 让客户端自己解析提供方协议。协议收敛是网关的职责,不是每个端的重复劳动。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。