一、引言
去年我们 KMS 知识管理平台上线了 AI 对话模块,用户可以通过自然语言检索文档、生成摘要、提取知识点。上线后用户反馈一个尖锐问题:慢。
不是模型慢,是感知慢。一个 2000 tokens 的回答,后端生成只需要 8 秒,但用户盯着空白屏幕等 8 秒,和看着文字逐字出现 8 秒,体验天差地别。数据也验证了这一点:接入流式输出后,对话模块的用户留存率提升了 23%,平均会话时长增加了 40%。
流式输出不只是"打字机效果"。它是一套从前端 SSE 解析到状态管理再到渲染管线的完整架构。经过几次迭代,我们的 AI 对话模块支撑了三条流式路径(SSE、WebSocket、IPC),这篇文章就聊聊这背后的架构设计和技术选型。
二、技术选型:流式传输协议怎么选
接到流式输出需求时,第一个问题是:用什么协议?
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 短轮询 | 实现简单,兼容性好 | 浪费请求,延迟不可控 | 不考虑 |
| EventSource (浏览器原生) | 零代码,自动重连 | 仅 GET 请求,不支持自定义 Header,只能 text/event-stream | 简单场景 |
| Fetch + ReadableStream | POST 请求,自定义 Header,可控 | 需手写 SSE 解析 | 主路径 |
| WebSocket | 双向通信,一次握手 | 需要额外服务端支持 | 服务端 Agent |
为什么最终选了 Fetch + ReadableStream 作为主路径?
三个原因:
-
模型切换需要 POST。AI 对话的消息历史可能很长(多轮上下文可达几十 KB),必须用 POST 发送,而浏览器
EventSource只支持 GET。 -
认证需要自定义 Header。KMS 平台的 API 认证走 JWT Token,必须通过
AuthorizationHeader 传递,EventSource无法自定义 Header。 -
可控的 Stream 生命周期。
EventSource自动重连有时反而是负担——比如用户点击"停止生成",我们需要立刻中断流式连接,而不是等它自动重连。
WebSocket 作为辅助路径保留,用在服务端 Agent 模式下(后面会讲)。
三、SSE 底层实现:手写一个解析器
选定了 Fetch + ReadableStream,下一步就是手写 SSE 解析。
SSE 协议本身不复杂,核心是三个字段:
event: text
data: "你好"
event: reasoning
data: "让我想想..."
event: text
data: ",我是AI助手"
一个空行分隔一条消息。看似简单,但手写解析器有几个坑。
3.1 核心架构:三层管道
解析流程拆成三层,每层职责单一:

3.2 getBytes:读取流
async function getBytes(
stream: ReadableStream<Uint8Array>,
onChunk: (arr: Uint8Array) => void,
) {
const reader = stream.getReader();
let result: ReadableStreamDefaultReadResult<Uint8Array>;
while (!(result = await reader.read()).done) {
onChunk(result.value);
}
}
getReader() 拿到 ReadableStream 的 reader,循环 read() 直到流关闭。每次读到的是 Uint8Array 字节块,透传给下一层。
3.3 getLines:跨 chunk 的行解析
这是最容易出错的层。一个 SSE 事件可能跨多个 TCP 包,需要处理 chunk 边界:
function getLines(onLine: (line: Uint8Array, fieldLength: number) => void) {
let buffer: Uint8Array | undefined;
let position: number;
return function onChunk(arr: Uint8Array) {
// 如果还有未处理完的 buffer,拼接新数据
if (buffer === undefined) {
buffer = arr;
position = 0;
} else {
buffer = concat(buffer, arr);
}
// 逐字节遍历找行结束符 \n 或 \r\n
while (position < buffer.length) {
const lineEnd = findLineEnd(buffer, position);
if (lineEnd === -1) break; // 行还没结束,等下一个 chunk
onLine(buffer.subarray(lineStart, lineEnd), fieldLength);
lineStart = position;
}
// 已处理完的行释放内存,仅保留未完成的行
if (lineStart === buffer.length) {
buffer = undefined;
} else {
buffer = buffer.subarray(lineStart);
}
};
}
关键处理:当遍历到 buffer 末尾发现行没结束(没有 \n),直接 break 等下一个 chunk 拼接。已处理完的 subarray 释放引用,避免大上下文场景下内存膨胀。
3.4 getMessages:组装事件对象
function getMessages(
onId: (id: string) => void,
onMessage?: (msg: EventSourceMessage) => void,
) {
let message = newMessage();
const decoder = new TextDecoder();
return function onLine(line: Uint8Array, fieldLength: number) {
if (line.length === 0) {
// 空行 = 一条消息结束
onMessage?.(message);
message = newMessage();
} else if (fieldLength > 0) {
const field = decoder.decode(line.subarray(0, fieldLength));
const value = decoder.decode(line.subarray(valueOffset));
switch (field) {
case 'data':
message.data = message.data ? message.data + '\n' + value : value;
break;
case 'event':
message.event = value;
break;
case 'id':
onId(message.id = value);
break;
}
}
};
}
遇到空行触发回调,遇 data/event/id 字段写入消息对象。注意 data 字段的多行拼接——SSE 协议允许多个 data: 行组成一个完整消息体。
3.5 串联
function fetchEventSource(input, { onmessage, onopen, onerror, signal }) {
return new Promise((resolve) => {
async function create() {
try {
const response = await fetch(input, { headers, signal });
await onopen(response);
await getBytes(
response.body!,
getLines(
getMessages(
(id) => { headers['last-event-id'] = id; },
onmessage,
),
),
);
resolve();
} catch (err) {
onerror?.(err);
}
}
create();
});
}
一个值得注意的设计:last-event-id 通过闭包写入 headers 对象。如果流中断后需要重连,headers['last-event-id'] 会带上最后收到的消息 ID,服务端可以从断点续传。
四、三条流式路径的架构设计
SSE 解析解决了底层传输,但 KMS 平台的上层需求更复杂。

Path A:Client SSE(默认模式)
最简单直接:前端直接调 AI 厂商 API,通过上面写的 fetchEventSource 拿到 SSE 流,解析后 dispatch 到 Store。适用于调用 OpenAI、Claude 等外部模型。
Path B:Gateway WebSocket(服务端 Agent 模式)
当 AI 需要调用插件(比如搜索 KMS 内部文档、执行 SQL 查询),这些操作不能在前端完成。这时候需要服务端 Agent 来协调。
流程变为:
- 前端通过 tRPC 创建一个服务端 Agent 任务
- 建立 WebSocket 连接到 Agent Gateway
- Gateway 推送流式事件:
stream_start→stream_chunk→tool_start→tool_end→stream_end
Gateway 模式的关键不同在于它推的不是纯文本,而是带类型的事件流:
// Gateway 事件类型(简化)
type GatewayEvent =
| { type: 'stream_start'; messageId: string }
| { type: 'stream_chunk'; content: string; chunkType: 'text' | 'reasoning' }
| { type: 'tool_start'; toolName: string; toolCallId: string }
| { type: 'tool_end'; toolCallId: string; result: string }
| { type: 'stream_end' }
| { type: 'error'; code: string; message: string };
Path C:Heterogeneous Agent(桌面 CLI 模式)
桌面端通过 Electron IPC 拉起子进程(比如 Codex CLI),子进程的 stdout 即为流式输出。这一路径通过 Pipe 传输,事件模型与 Gateway 类似,但传输层从 WebSocket 换成了进程间通信。
三条路径的统一抽象
外部形态完全不同(SSE / WebSocket / IPC),但内部通过统一的 StreamChunk 类型收敛:
interface StreamChunk {
type: 'text' | 'reasoning' | 'tool_calls' | 'grounding' | 'usage' | 'speed';
text?: string;
tool_calls?: MessageToolCall[];
grounding?: GroundingSearch;
usage?: ModelUsage;
}
每个路径的 handler 负责将原始事件映射为 StreamChunk,之后全走同一条 dispatch 管线。
五、增量渲染管线:从 token 到屏幕
流式数据到了前端后,怎么高效地渲染到屏幕上?这是整个架构中最"前端"的部分。
5.1 数据流:chunk → dispatch → parse → render

每一步都有考量:
messagesReducer:用 immer 的 produce 做不可变更更。对 updateMessage 类型,找到目标消息索引,merge 新值并更新 updatedAt 时间戳。
parse:对话不只是一条消息接一条消息。当 Agent 调用工具时,消息树会有分支(parent-child 关系),parse() 将树形结构展平为渲染顺序的线性列表,同时处理 SuperVisor 分组、压缩消息合并等逻辑。
stabilizeReferences:每次 parse 都会产生新的数组引用,但绝大多数消息内容没变。stabilizeReferences 将未变化的子树对象"钉"回旧引用:
function stabilizeReferences(prevList, nextList) {
return nextList.map((item, index) => {
const prev = prevList[index];
// 同一 ID 且内容深层相等 → 复用旧引用
if (prev && prev.id === item.id && isEqual(prev, item)) {
return prev;
}
return item;
});
}
这保证了 React memo 只重渲染真正变化了的那一条消息,而不是整棵消息树。
5.2 文本平滑动画:双控制器设计
如果每个 token 到达直接更新 DOM,字符会生硬地闪现。我们实现了两种动画策略:
策略一:Smooth 模式(requestAnimationFrame + 动态速度队列)
function createSmoothMessage(params: {
onTextUpdate: (delta: string, text: string) => void;
startSpeed?: number;
}) {
let buffer = '';
const outputQueue: string[] = [];
const pushToQueue = (text: string) => {
outputQueue.push(...text.split('')); // 逐字符入队
};
const startAnimation = () => {
const updateText = (timestamp: number) => {
// 动态速度:基于队列积压自适应调节
const targetSpeed = Math.max(baseSpeed, outputQueue.length);
const speedChangeRate = Math.abs(outputQueue.length - lastQueueLen) * 0.0008 + 0.005;
currentSpeed += (targetSpeed - currentSpeed) * speedChangeRate;
const charsToProcess = Math.floor((accumulatedTime * currentSpeed) / 1000);
const chars = outputQueue.splice(0, charsToProcess).join('');
buffer += chars;
params.onTextUpdate(chars, buffer);
if (outputQueue.length > 0) {
requestAnimationFrame(updateText);
}
};
requestAnimationFrame(updateText);
};
}
核心思路:SSE chunk 到达后不是直接渲染,而是逐字符拆入队列。requestAnimationFrame 循环从队列取字符输出,速度自适应——队列越长出队越快,保证不会在屏幕前卡住。最终效果接近 ChatGPT 的流式打字体验。
文本和推理内容各一个控制器,独立动画。用户看到的推理面板(“让我想想……”)和正文内容,由两个 createSmoothMessage 实例分别驱动。
策略二:Buffer 模式(300ms 批量合并)
不是所有场景都需要打字动画。非平滑模式下用 300ms 定时缓冲:
let textBuffer = '';
let bufferTimer: ReturnType<typeof setTimeout> | null = null;
// 在 onmessage 回调中
textBuffer += data;
if (!bufferTimer) {
bufferTimer = setTimeout(() => {
options.onMessageHandle?.({ text: textBuffer, type: 'text' });
textBuffer = '';
bufferTimer = null;
}, 300);
}
多个 chunk 在 300ms 窗口内合并成一次 Store 更新,减少 React 渲染次数。高频场景(如 Claude 的快速输出)下,一次性收到大量小 chunk 时这个策略效果显著:渲染次数从可能的上百次降到十几次。
5.3 哨兵值:用三个点区分加载态与真实内容
一个微妙的点:新建的 assistant 消息,在第一条 chunk 到达之前应该显示加载动画。怎么区分"还没收到内容"和"内容就是三个点"?
我们定义了一个常量:
const LOADING_FLAT = '...'; // 占位符 ≠ 真实文本
创建 optimistic 消息时 content 设为 LOADING_FLAT,组件层检测:
if (content === LOADING_FLAT && isGenerating) {
return <ContentLoading />; // 三个跳动的点
}
return <MarkdownMessage content={content} />;
一旦第一条 chunk 到达产生真实 content,LOADING_FLAT 被覆盖,自动切换为 Markdown 渲染。
六、状态管理:Zustand 双层 Store 架构
随着多会话并行、Agent 工具调用等需求加入,单层 Store 越来越不堪重负。我们拆成了两层:

关键设计:操作状态(isGenerating、isInReasoning 等)通过 operationState 对象从 ChatStore 传递到 ConversationStore,而不是直接在 ConversationStore 里管理。这样每个 Conversation 实例可以独立读取自己的生成状态,互不影响。
interface OperationState {
isAIGenerating: boolean; // 当前会话是否在生成
isInputLoading: boolean; // 是否正在发送消息
getMessageOperationState: (messageId: string) => ({
isCreating: boolean; // 消息是否正在创建
isGenerating: boolean; // 消息是否正在流式生成中
isInReasoning: boolean; // 是否在推理阶段
isInterrupted: boolean; // 是否被中断
isProcessing: boolean; // 是否在处理 tool calls
});
}
getMessageOperationState 实现为工厂函数+闭包,支持每条消息独立查询自己的生成状态。这在使用工具调用的场景尤其重要——用户可能看到三条消息同时在"处理中"。
七、竞态与容错:生产环境的至暗时刻
架构图画得再漂亮,上了生产才会碰到真正的难题。以下是几个让我们凌晨爬起来修 bug 的案例。
7.1 流式中禁止数据刷新
SWR 默认有 revalidateOnFocus:用户切回标签页时自动重新请求。但如果此时会话正在流式生成中,SWR 的 refetch 会拉回数据库中的旧内容(SSE 流式内容还在内存里没落地),导致屏幕闪烁——新内容瞬间被旧数据覆盖。
// ChatList 组件中
const isStreaming = useConversationStore(messageStateSelectors.isAIGenerating);
useSWR(key, fetcher, {
revalidateOnFocus: !isStreaming, // 流式中禁止自动刷新
revalidateOnReconnect: !isStreaming,
});
7.2 写入竞态:updatedAt 决胜
流式生成期间,数据库可能收到来自其他来源的消息更新(比如另一个标签页的编辑操作)。如果在流式结束后简单用 refreshMessages 覆盖,可能丢失最新数据库变更。
解决方案:消息合并时对比 updatedAt 时间戳,取较新者:
function mergeFetchedMessagesWithLocalState(
fetchedMessages: UIChatMessage[],
localMessages: UIChatMessage[],
): UIChatMessage[] {
const localMap = new Map(localMessages.map(m => [m.id, m]));
return fetchedMessages.map(fetched => {
const local = localMap.get(fetched.id);
if (!local) return fetched;
// updatedAt 决胜:谁新用谁
return local.updatedAt > fetched.updatedAt ? local : fetched;
});
}
7.3 请求去重:AbortableRequestManager
用户快速连续点几次"重新生成",会触发多个流式请求。前一个请求的结果还没用完就被丢弃,但 HTTP 连接还在占用资源。
class AbortableRequestManager {
private controllers = new Map<string, AbortController>();
getController(key: string): AbortController {
// 取消同 key 的上一个请求
this.controllers.get(key)?.abort();
const controller = new AbortController();
this.controllers.set(key, controller);
return controller;
}
}
每次发出新请求前,自动 abort 同 key 的旧请求。这个模式在 sendMessage、createAssistantMessage 等入口处统一使用。
7.4 Gateway 事件顺序保证
Gateway 模式中,stream_start 事件携带 assistant 消息的数据库 ID,stream_chunk 需要这个 ID 才能 dispatch。但 WebSocket 消息可能乱序到达(TCP 保证顺序但中间层可能重排)。
// processingChain: Promise 链保证顺序
let processingChain = Promise.resolve();
function handleStreamEvent(event: GatewayEvent) {
processingChain = processingChain.then(async () => {
switch (event.type) {
case 'stream_start':
// 拿到 messageId
messageId = event.messageId;
break;
case 'stream_chunk':
if (!messageId) return; // stream_start 已处理完,保证 messageId 存在
dispatchMessage({ id: messageId, content: event.content });
break;
}
});
}
用 Promise 链串行化事件处理,stream_chunk 一定等 stream_start 完成后才 dispatch。
7.5 中断取消的哨兵机制
用户点击"停止生成",需要同时处理多层级的清理:
const MESSAGE_CANCEL_FLAT = '__CANCEL__';
// fetchSSE 的 onerror 中
if (error === MESSAGE_CANCEL_FLAT || error.name === 'AbortError') {
textController.stopAnimation(); // 停动画
thinkingController.stopAnimation();
finishedType = 'abort';
options.onAbort?.(output); // 保存已生成的部分内容
}
MESSAGE_CANCEL_FLAT 作为哨兵值在 abort chain 中传递,确保:动画停止、输出保存、错误不弹窗、UX 体验流畅。
八、总结
回头看这一年,从最初"能不能让字一个字一个字出来"的简单需求,到支撑三条流式路径的架构,核心设计决策有三个:
-
手写 SSE 解析而非用 EventSource——灵活性的代价是 300 行解析代码,但换来 POST 请求、自定义 Header、可控生命周期,这笔交易非常划算。
-
三条路径统一 Chunk 类型——外部传输层的差异用适配层消化,内部全部汇入同一条 dispatch 管线,后续添加新路径(比如 WebRTC)成本很低。
-
Zustand 双层 Store + stabilizeReferences——全局会话管理 + 隔离实例 + 引用稳定,三者配合让渲染性能在多会话并行时依然可控。
如果你的项目只需要简单的流式输出,EventSource + 直接 setState 足够。但如果你在做一个生产级的 AI 对话产品,希望这篇文章的架构思路能给你一些参考。
本文代码基于 KMS 知识管理平台 AI 对话模块的生产实践。

649

被折叠的 条评论
为什么被折叠?



