AI 对话流式输出:从前端架构到生产落地

一、引言

去年我们 KMS 知识管理平台上线了 AI 对话模块,用户可以通过自然语言检索文档、生成摘要、提取知识点。上线后用户反馈一个尖锐问题:

不是模型慢,是感知慢。一个 2000 tokens 的回答,后端生成只需要 8 秒,但用户盯着空白屏幕等 8 秒,和看着文字逐字出现 8 秒,体验天差地别。数据也验证了这一点:接入流式输出后,对话模块的用户留存率提升了 23%,平均会话时长增加了 40%

流式输出不只是"打字机效果"。它是一套从前端 SSE 解析到状态管理再到渲染管线的完整架构。经过几次迭代,我们的 AI 对话模块支撑了三条流式路径(SSE、WebSocket、IPC),这篇文章就聊聊这背后的架构设计和技术选型。

二、技术选型:流式传输协议怎么选

接到流式输出需求时,第一个问题是:用什么协议?

方案优点缺点适用场景
短轮询实现简单,兼容性好浪费请求,延迟不可控不考虑
EventSource (浏览器原生)零代码,自动重连仅 GET 请求,不支持自定义 Header,只能 text/event-stream简单场景
Fetch + ReadableStreamPOST 请求,自定义 Header,可控需手写 SSE 解析主路径
WebSocket双向通信,一次握手需要额外服务端支持服务端 Agent

为什么最终选了 Fetch + ReadableStream 作为主路径?

三个原因:

  1. 模型切换需要 POST。AI 对话的消息历史可能很长(多轮上下文可达几十 KB),必须用 POST 发送,而浏览器 EventSource 只支持 GET。

  2. 认证需要自定义 Header。KMS 平台的 API 认证走 JWT Token,必须通过 Authorization Header 传递,EventSource 无法自定义 Header。

  3. 可控的 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 来协调。

流程变为:

  1. 前端通过 tRPC 创建一个服务端 Agent 任务
  2. 建立 WebSocket 连接到 Agent Gateway
  3. Gateway 推送流式事件:stream_startstream_chunktool_starttool_endstream_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 越来越不堪重负。我们拆成了两层:

在这里插入图片描述

关键设计:操作状态(isGeneratingisInReasoning 等)通过 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 的旧请求。这个模式在 sendMessagecreateAssistantMessage 等入口处统一使用。

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 体验流畅。

八、总结

回头看这一年,从最初"能不能让字一个字一个字出来"的简单需求,到支撑三条流式路径的架构,核心设计决策有三个:

  1. 手写 SSE 解析而非用 EventSource——灵活性的代价是 300 行解析代码,但换来 POST 请求、自定义 Header、可控生命周期,这笔交易非常划算。

  2. 三条路径统一 Chunk 类型——外部传输层的差异用适配层消化,内部全部汇入同一条 dispatch 管线,后续添加新路径(比如 WebRTC)成本很低。

  3. Zustand 双层 Store + stabilizeReferences——全局会话管理 + 隔离实例 + 引用稳定,三者配合让渲染性能在多会话并行时依然可控。

如果你的项目只需要简单的流式输出,EventSource + 直接 setState 足够。但如果你在做一个生产级的 AI 对话产品,希望这篇文章的架构思路能给你一些参考。


本文代码基于 KMS 知识管理平台 AI 对话模块的生产实践。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值