弱网环境下流式对话的前端保活与降级体验

大语言模型(LLM)的流式生成交互本质上是长连接上的单向或双向数据流。在移动网络弱网(如电梯、地下车库、高铁跨基站切换)或网络抖动场景下,长连接极易发生静默断开(TCP Half-Open)、乱序丢包或 SSE 流意外中断。
如果前端缺乏严密的保活与断点续传设计,用户就会遭遇文字卡死在半途、重复生成相同内容、或因频繁弹窗报错而失去耐心。构建一套具备“心跳保活、断点重连、内容幂等去重与视觉优雅降级”的流式传输韧性层,是保障对话体验的核心底座。
长连接的静默断开与双向心跳探测
许多长连接中断并不会立即触发浏览器的 close 或 error 事件。为了在 5~10 秒内感知连接假死,必须建立主动的心跳探测(Heartbeat Ping/Pong)机制。
export interface StreamMessage {
id: string;
seq: number;
content: string;
isFinished: boolean;
}
export class ResilientStreamClient {
private url: string;
private eventSource: EventSource | null = null;
private heartbeatTimer: number | null = null;
private lastActiveTimestamp = 0;
private timeoutThreshold = 12000; // 12秒无心跳判定断连
private lastReceivedSeq = -1;
private retryCount = 0;
private maxRetries = 5;
private onChunkCallback: (chunk: string) => void;
private onStatusChange: (status: 'connected' | 'reconnecting' | 'failed') => void;
constructor(
url: string,
onChunk: (chunk: string) => void,
onStatusChange: (status: 'connected' | 'reconnecting' | 'failed') => void
) {
this.url = url;
this.onChunkCallback = onChunk;
this.onStatusChange = onStatusChange;
}
public connect(conversationId: string) {
this.cleanup();
this.onStatusChange('connected');
this.lastActiveTimestamp = Date.now();
// 携带断点游标 Last-Event-ID / seq 参数发起请求
const connectUrl = `${this.url}?conversationId=${conversationId}&lastSeq=${this.lastReceivedSeq}`;
// 使用 fetch + ReadableStream 替代原生 EventSource 以便支持自定义 Header 与 POST
this.startFetchStream(connectUrl);
this.startHeartbeatCheck();
}
private async startFetchStream(url: string) {
try {
const response = await fetch(url, {
headers: { 'Accept': 'text/event-stream' },
});
if (!response.ok || !response.body) {
throw new Error(`HTTP Error: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
this.lastActiveTimestamp = Date.now();
buffer += decoder.decode(value, { stream: true });
// 按 SSE 协议解析行
const lines = buffer.split('\\n\\n');
buffer = lines.pop() || ''; // 保留未闭合数据段
for (const line of lines) {
this.parseSSELine(line);
}
}
} catch (err) {
console.warn('流式连接中断,准备触发降级重连:', err);
this.handleDisconnect();
}
}
private parseSSELine(rawBlock: string) {
if (rawBlock.startsWith(': heartbeat') || rawBlock.startsWith(': ping')) {
// 收到服务端保活注释包
this.lastActiveTimestamp = Date.now();
return;
}
if (rawBlock.startsWith('data: ')) {
const jsonStr = rawBlock.replace(/^data:\\s*/, '');
try {
const msg: StreamMessage = JSON.parse(jsonStr);
// 幂等去重检查
if (msg.seq > this.lastReceivedSeq) {
this.lastReceivedSeq = msg.seq;
this.onChunkCallback(msg.content);
this.retryCount = 0; // 成功接收数据,重置重试计数器
}
} catch (e) {
console.error('SSE JSON 解析异常', e);
}
}
}
private startHeartbeatCheck() {
this.heartbeatTimer = window.setInterval(() => {
const elapsed = Date.now() – this.lastActiveTimestamp;
if (elapsed > this.timeoutThreshold) {
console.warn(`心跳超时 (${elapsed}ms),判定为静默断开,触发重连`);
this.handleDisconnect();
}
}, 3000);
}
private handleDisconnect() {
this.cleanup();
if (this.retryCount < this.maxRetries) {
this.retryCount++;
const backoffDelay = Math.min(1000 * Math.pow(2, this.retryCount), 10000);
this.onStatusChange('reconnecting');
setTimeout(() => {
this.connect('current-conv-id');
}, backoffDelay);
} else {
this.onStatusChange('failed');
}
}
public cleanup() {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
序号序列化(Sequence ID)与幂等拼接
断点重连的最大隐患是“数据重复或错位”。服务端生成每个流式 Token 块时,必须附加单调递增的 seq 序号。前端通过 lastReceivedSeq 向服务端声明:“我已经完整接收到第 142 个 Token,请从 143 号开始补发”。
这样即使客户端因超时断开而发起重连,服务端也能精准重放缓冲区中的剩余片段,客户端对已接收的序号直接抛弃,杜绝生成内容“重头再来”或“局部重复”。
视觉优雅降级:消解用户的等待焦虑
在网络重连期间,UI 层决不能粗暴地打断用户的阅读节奏:
流式交互的魅力在于如行云流水般的即时反馈,而在复杂的真实网络世界中,只有在底层构筑好严密的心跳防线与断点契约,才能让这种流畅在风浪中稳健如初。



