🏠

useChat 结构化流式多模态对话引擎
工程设计方案

聚焦工程落地 · 全链路可复用设计 · 支持小程序 / H5 / React 多端

本文档聚焦工程落地,明确"小程序 UI → useChat → protocol → transport → WebSocket → Node BFF"全链路设计,将流式渲染、Markdown 解析、商品卡组件贯穿各层,确保 useChat 钩子可复用、可扩展,同时补全工程必备细节(心跳、重连、性能优化),直接用于开发落地。

一、整体架构(落地版)

架构分层清晰,职责单一,便于开发维护和跨端扩展(支持小程序、H5、React 等),各层依赖自上而下。

用户交互 → 状态管理 → 协议解析 → 通信传输 → 后端适配
graph BT
    subgraph UI["UI 渲染层"]
        A1[文本渲染]
        A2[Markdown]
        A3[商品卡组件]
    end

    subgraph State["useChat 状态层"]
        B1[消息状态管理]
        B2[流式 Block 拼装]
    end

    subgraph Protocol["Protocol 协议层"]
        C1[Chunk 解析]
        C2[文本合并 normalize]
        C3[Markdown 渲染 render]
    end

    subgraph Transport["Transport 通信层"]
        D1[WebSocket 连接管理]
        D2[消息收发]
        D3[心跳/重连]
    end

    subgraph Backend["Node BFF 后端"]
        E1[SSE/RPC 转 WS]
        E2[结构化输出]
    end

    UI --> State --> Protocol --> Transport --> Backend
            
图:useChat 五层架构全景
小程序 UI(渲染层)
负责文本 / Markdown / 商品卡的最终渲染展示,适配小程序原生组件体系。
useChat(State 层)
状态管理 + 流式拼装,对外暴露 send / closeChat API,对接 setData 更新 UI。
protocol(协议层)
将后端流式 chunk 转换为结构化 Block,处理文本合并与 Markdown 渲染。
transport(通信层)
稳定收发消息,与业务完全解耦,内置心跳检测 + 自动重连机制。
Node BFF(后端适配层)
SSE / RPC → WebSocket 转换,按 ChatChunk 协议输出结构化流式数据。

二、数据协议(核心前置定义)

统一前后端通信协议,避免后续开发混乱。所有消息均遵循此格式,覆盖文本、商品卡、结束、错误四种核心场景,支持后续扩展。

ChatChunk:后端流式 chunk 格式

// 后端输出给前端的流式chunk格式(Transport层接收数据)
type ChatChunk =
  | { type: 'text'; content: string }     // 流式文本片段
  | { type: 'product'; data: Product }   // 商品卡数据
  | { type: 'done' }                     // 流式结束标识
  | { type: 'error'; message: string }   // 错误信息

Product:业务商品卡模型

// 业务商品卡模型(可根据实际业务扩展)
interface Product {
  id: string;
  title: string;
  price: number;
  image: string;
  link: string;
  // 其他业务字段...
}
💡 设计原则:使用 Tagged Union 类型确保类型安全,每条消息的 type 字段决定后续数据形状,编译期即可拦截非法组合。

三、Transport 层(通信层:稳定收发,解耦业务)

核心职责:仅负责 WebSocket 连接的建立、消息收发、异常处理,不关心业务逻辑,确保通信稳定性,支撑流式渲染的流畅性。

3.1 核心接口定义

// 通信层接口,后续可扩展其他通信方式(如HTTP长轮询)
interface Transport {
  connect(): Promise<void>;                          // 建立连接
  send(data: any): void;                               // 发送消息(给后端)
  onMessage(cb: (data: ChatChunk) => void): void;     // 监听消息
  close(): void;                                       // 关闭连接
  getStatus(): 'connecting' | 'open' | 'closed';       // 连接状态
}

3.2 小程序 WebSocket 实现

/**
 * 创建小程序WebSocket实例,实现Transport接口
 * @param url - WebSocket连接地址(由BFF提供)
 * @returns Transport 通信实例
 */
export function createWS(url: string): Transport {
  let socket: WechatMiniprogram.SocketTask | null = null;
  let messageHandler: ((data: ChatChunk) => void) | null = null;
  let connectStatus: 'connecting' | 'open' | 'closed' = 'closed';

  function connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      if (connectStatus === 'connecting' || connectStatus === 'open') {
        resolve();
        return;
      }
      connectStatus = 'connecting';
      socket = wx.connectSocket({
        url,
        header: { 'Content-Type': 'application/json' }
      });

      socket.onOpen(() => {
        connectStatus = 'open';
        startHeartbeat();
        resolve();
      });

      socket.onError((err) => {
        connectStatus = 'closed';
        reject(`WebSocket连接失败:${err}`);
      });

      socket.onMessage((res) => {
        let data: ChatChunk;
        try {
          data = JSON.parse(res.data);
        } catch (e) {
          console.warn('WebSocket接收无效JSON:', res.data);
          return;
        }
        messageHandler && messageHandler(data);
      });

      socket.onClose(() => {
        connectStatus = 'closed';
        autoReconnect();
      });
    });
  }

  function send(data: any): void {
    if (connectStatus !== 'open') {
      console.warn('WebSocket未连接,无法发送消息');
      return;
    }
    socket!.send({ data: JSON.stringify(data) });
  }

  function onMessage(cb: (data: ChatChunk) => void): void {
    messageHandler = cb;
  }

  function close(): void {
    if (socket) {
      socket.close();
      connectStatus = 'closed';
      clearInterval(heartbeatTimer);
    }
  }

  function getStatus() {
    return connectStatus;
  }

  // ==================== 心跳 & 重连(工程落地关键)====================
  let heartbeatTimer: number | null = null;
  const HEARTBEAT_INTERVAL = 30000;  // 心跳间隔 30s
  const RECONNECT_INTERVAL = 5000;   // 重连间隔 5s

  function startHeartbeat(): void {
    clearInterval(heartbeatTimer!);
    heartbeatTimer = setInterval(() => {
      if (connectStatus === 'open') {
        send({ type: 'ping' });
      }
    }, HEARTBEAT_INTERVAL);
  }

  function autoReconnect(): void {
    setTimeout(() => {
      if (connectStatus === 'closed') {
        console.log('WebSocket自动重连...');
        connect().catch(err => console.warn('重连失败:', err));
      }
    }, RECONNECT_INTERVAL);
  }

  return { connect, send, onMessage, close, getStatus };
}
⚠️ 关键点:心跳间隔建议 30s(微信小程序服务端默认超时约 60s),重连间隔 5s 避免频繁请求。生产环境应加入最大重试次数限制。

四、Protocol 层(协议解析层:核心价值层)

核心职责:将后端发送的"混乱流式 chunk"转换为"UI 可直接渲染的结构化 Block",同时处理 Markdown 渲染和文本合并(流式渲染关键),是连接通信层与状态层的核心桥梁。

4.1 输入输出定义

// 输入:Transport层接收的ChatChunk
// 输出:UI层可直接渲染的Block
type Block =
  | { type: 'text'; raw: string; html?: string }   // 文本块
  | { type: 'product'; data: Product }              // 商品卡块
graph LR
    A["ChatChunk
{type:'text',content}"] -->|parseChunk| B["Block
{type:'text',raw}"] C["ChatChunk
{type:'product',data}"] -->|parseChunk| D["Block
{type:'product',data}"] E["ChatChunk
{type:'error'}"] -->|parseChunk| F["Block
{type:'text',raw:'❌...'}"] G["ChatChunk
{type:'done'}"] -->|parseChunk| H["null"] B -->|render| I["Block+html
Markdown→HTML"] J["连续text Block"] -->|normalize| K["合并为一个Block"]
图:Protocol 层数据处理流水线

4.2 parseChunk:基础解析(chunk → Block)

/**
 * 解析后端ChatChunk,转换为UI可用的Block
 * @param chunk - 后端流式输出的chunk
 * @returns 结构化Block,无效chunk返回null
 */
function parseChunk(chunk: ChatChunk): Block | null {
  switch (chunk.type) {
    case 'text':
      return { type: 'text', raw: chunk.content };
    case 'product':
      return { type: 'product', data: chunk.data };
    case 'error':
      return { type: 'text', raw: `❌ ${chunk.message}` };
    case 'done':
    default:
      return null; // done标识不生成Block
  }
}

4.3 normalize:合并策略(流式渲染关键)

/**
 * 合并Block,处理流式文本的连续拼接
 * 解决流式文本"碎片化"问题,确保UI渲染不卡顿、不重复
 *
 * @param blocks - 当前已有的Block数组
 * @param newBlock - 新解析出的Block
 * @returns 合并后的Block数组
 */
function normalize(blocks: Block[], newBlock: Block | null): Block[] {
  if (!newBlock) return blocks;

  const lastBlock = blocks[blocks.length - 1];

  // 核心逻辑:连续的文本Block合并(避免流式文本碎片化)
  if (
    lastBlock &&
    lastBlock.type === 'text' &&
    newBlock.type === 'text'
  ) {
    return [
      ...blocks.slice(0, -1),
      { ...lastBlock, raw: lastBlock.raw + newBlock.raw }
    ];
  }

  // 非连续文本或商品卡,直接追加
  return [...blocks, newBlock];
}
为什么需要 normalize?后端可能以极小粒度(甚至单字符)推送文本 chunk。若不做合并,setData 会触发海量 DOM 更新,导致小程序严重卡顿。

4.4 render:Markdown 渲染(文本转 HTML)

import MarkdownIt from 'markdown-it';

const md = new MarkdownIt({
  html: false,    // 禁止解析HTML(避免安全风险)
  breaks: true,   // 自动转换换行符为<br>
  linkify: true   // 自动识别链接
});

/**
 * Markdown渲染:将文本Block的raw转换为HTML
 * @param block - 待渲染的Block
 * @returns 渲染后的Block(文本块增加html字段)
 */
function render(block: Block): Block {
  if (block.type === 'text') {
    return { ...block, html: md.render(block.raw) };
  }
  return block; // 商品卡无需渲染
}

4.5 Protocol 统一导出

// protocol/index.ts
export const protocol = { parseChunk, normalize, render } as const;

五、State 层(useChat 核心:状态管理 + 流式拼装)

核心职责:管理对话消息状态(用户消息、AI 消息),对接 Transport 层和 Protocol 层,完成流式 Block 的拼装,触发 UI 更新,对外提供可复用的 useChat 钩子。

5.1 核心数据结构

// 单条消息结构
type Message =
  | {
      id: string;
      role: 'user';
      content: string;
    }
  | {
      id: string;
      role: 'assistant';
      blocks: Block[];
      status: 'streaming' | 'done' | 'error';
    };

// 对话消息列表(useChat核心状态)
let messages: Message[] = [];

// 当前正在流式接收的AI消息(用于拼装Block)
let currentAssistantMsg: Message | null = null;

5.2 useChat 实现

import { throttle } from 'lodash';
import { v4 as uuidv4 } from 'uuid';

interface SetDataFn {
  (data: Record<string, unknown>): void;
}

interface UseChatAPI {
  messages: Message[];
  send(query: string): void;
  closeChat(): void;
}

export function createUseChat(
  transport: Transport,
  protocol: typeof import('./protocol').protocol,
  setData: SetDataFn
): UseChatAPI {

  // 初始化:建立WebSocket连接
  transport.connect().catch(err => {
    messages.push({
      id: uuidv4(),
      role: 'assistant',
      blocks: [{ type: 'text', raw: '❌ 对话初始化失败,请重试',
                 html: '❌ 对话初始化失败,请重试' }],
      status: 'error'
    });
    updateUI();
  });

  transport.onMessage(handleChunk);

  /**
   * 发送用户消息,触发AI流式响应
   */
  function send(query: string): void {
    if (!query.trim()) return;

    if (transport.getStatus() !== 'open') {
      wx.showToast({ title: '连接未建立,请稍候', icon: 'none' });
      return;
    }

    // 1. 添加用户消息
    messages.push({
      id: uuidv4(), role: 'user', content: query
    });

    // 2. 初始化当前AI消息(streaming状态)
    currentAssistantMsg = {
      id: uuidv4(),
      role: 'assistant',
      blocks: [],
      status: 'streaming'
    };
    messages.push(currentAssistantMsg);

    // 3. 发送给后端
    transport.send({ query });

    updateUI();
  }

  /**
   * 处理后端流式chunk,拼装Block
   */
  function handleChunk(chunk: ChatChunk): void {
    if (!currentAssistantMsg) return;

    // 流式结束
    if (chunk.type === 'done') {
      currentAssistantMsg.status = 'done';
      currentAssistantMsg = null;
      updateUI();
      return;
    }

    // parse → normalize → render 三步流水线
    const block = protocol.parseChunk(chunk);
    const newBlocks = protocol.normalize(currentAssistantMsg.blocks, block);
    currentAssistantMsg.blocks = newBlocks.map(protocol.render);

    updateUI();
  }

  /**
   * UI更新函数(节流优化 — 小程序关键!)
   * 50ms节流:既保证流式流畅,又避免频繁setData导致性能问题
   */
  const updateUI = throttle(() => {
    setData({ messages });
  }, 50);

  function closeChat(): void {
    transport.close();
    messages = [];
    currentAssistantMsg = null;
    updateUI();
  }

  return { messages, send, closeChat };
}

5.3 关键说明


六、UI 渲染层(小程序:可直接复制使用)

适配 useChatmessages 状态,渲染用户消息、AI 文本(Markdown)和商品卡,结构清晰,贴合小程序原生语法。

6.1 WXML 结构

<!-- pages/chat/chat.wxml -->
<view class="chat-container">
  <!-- 对话消息列表 -->
  <scroll-view
    class="chat-list"
    scroll-y="true"
    scroll-into-view="{{lastMsgId}}"
    scroll-with-animation
  >
    <block wx:for="{{messages}}" wx:key="id" wx:for-item="msg">

      <!-- 用户消息 -->
      <view class="msg-item user-msg" wx:if="{{msg.role === 'user'}}">
        <view class="msg-content">{{msg.content}}</view>
      </view>

      <!-- AI消息 -->
      <view class="msg-item ai-msg" wx:elif="{{msg.role === 'assistant'}}">
        <!-- 加载中状态 -->
        <view class="loading" wx:if="{{msg.status === 'streaming'}}">
          AI正在输入...
        </view>

        <!-- AI内容(结构化Block渲染)-->
        <block wx:for="{{msg.blocks}}" wx:key="index" wx:for-item="block">
          <!-- 文本Block(Markdown → rich-text)-->
          <rich-text
            wx:if="{{block.type === 'text'}}"
            class="text-block"
            nodes="{{block.html}}"
          />

          <!-- 商品卡Block(自定义组件)-->
          <product-card
            wx:elif="{{block.type === 'product'}}"
            class="product-block"
            data="{{block.data}}"
            bind:tap="handleProductTap"
          />
        </block>

        <!-- 错误状态 -->
        <view class="error" wx:if="{{msg.status === 'error'}}">
          ❌ 消息加载失败,请重试
        </view>
      </view>

    </block>
  </scroll-view>

  <!-- 输入框区域(省略详细代码)-->
  <view class="input-container">
    <!-- input + 发送按钮 -->
  </view>
</view>

6.2 JS 逻辑(页面集成 useChat)

// pages/chat/chat.js
import { createWS } from '../../utils/transport';
import { protocol } from '../../utils/protocol';
import { createUseChat } from '../../utils/useChat';

Page({
  data: {
    inputValue: '',
    messages: [],
    lastMsgId: ''  // 用于滚动到最新消息
  },

  onLoad() {
    // 1. 创建通信层实例
    const transport = createWS('wss://your-bff-url/ws/chat');

    // 2. 创建useChat实例
    this.useChat = createUseChat(transport, protocol, (data) => {
      this.setData(data);
      // 更新最新消息ID,确保自动滚动到底部
      if (data.messages && data.messages.length) {
        this.setData({
          lastMsgId: 'msg-' + data.messages[data.messages.length - 1].id
        });
      }
    });
  },

  handleInput(e) {
    this.setData({ inputValue: e.detail.value });
  },

  handleSend() {
    const { inputValue } = this.data;
    this.useChat.send(inputValue);
    this.setData({ inputValue: '' });  // 清空输入框
  },

  handleProductTap(e) {
    const product = e.currentTarget.dataset.data;
    wx.navigateTo({
      url: `/pages/product/detail?id=${product.id}`
    });
  },

  onUnload() {
    this.useChat.closeChat();  // 页面卸载时关闭连接
  }
});

6.3 关键说明

七、Node BFF 层(配合协议,直接落地)

BFF 层核心职责:对接后端 LLM(如 OpenAI、内部模型),将 LLM 输出转换为符合 ChatChunk 协议的流式数据,通过 WebSocket 推送给前端。

7.1 核心要求

7.2 BFF 流式输出示例

// 流式输出1:文本chunk
{"type": "text", "content": "为你推荐以下商品:"}

// 流式输出2:商品卡chunk
{
  "type": "product",
  "data": {
    "id": "1001",
    "title": "测试商品",
    "price": 99,
    "image": "https://xxx.jpg",
    "link": "/pages/product/detail?id=1001"
  }
}

// 流式输出3:文本chunk
{"type": "text", "content": "以上商品均有优惠哦~"}

// 流式输出4:结束标识
{"type": "done"}
💡 前端无需改动协议:BFF 层负责所有格式转换工作。LLM 输出的原始 SSE / tool_call 由 BFF 解析后统一包装为 ChatChunk 格式,前端只管消费。

八、扩展能力(预留接口,便于后续升级)

提前预留扩展接口,避免后续迭代时大规模重构,降低维护成本。

8.1 扩展 Block 类型

// 扩展后的Block类型(后续可新增)
type ExtendedBlock =
  | { type: 'text'; raw: string; html?: string }
  | { type: 'product'; data: Product }
  | { type: 'image'; url: string; alt?: string }       // 图片
  | { type: 'link'; text: string; url: string }         // 链接
  | { type: 'button'; text: string; action: string }    // 按钮
  | { type: 'tool_call'; name: string; params: any }    // 工具调用
  | { type: 'form'; fields: any[] };                    // 表单

8.2 扩展消息状态

// 扩展后的消息状态
type MessageStatus =
  | 'streaming'
  | 'done'
  | 'error'
  | 'paused'      // 暂停流式渲染
  | 'cancelled';   // 取消流式渲染

8.3 新增 typing 效果

// 新增ChatChunk类型
type ChatChunk =
  | { type: 'text'; content: string }
  | { type: 'product'; data: Product }
  | { type: 'done' }
  | { type: 'error'; message: string }
  | { type: 'typing'; status: boolean };  // true显示typing,false隐藏

九、核心优势(落地价值)

优势 说明
✅ 可复用 useChat 钩子解耦了通信、协议、UI,可直接复用于小程序、H5、React 等多端
✅ 可扩展 预留 Block 类型、消息状态、typing 效果等接口,新增功能无需重构核心代码
✅ 流式流畅 通过文本合并、节流更新 UI、WebSocket 心跳重联,确保流式渲染不卡顿、不中断
✅ 多模态支持 原生支持文本、Markdown、商品卡,可扩展图片、按钮等任意富组件
✅ 协议清晰 前后端统一 ChatChunk 协议,降低联调成本,便于后端迭代升级

十、落地注意事项(关键避坑点)

⚠️ 小程序 WebSocket 域名:需在小程序后台配置合法的 WebSocket 域名(wss://),否则无法连接。开发阶段可通过「详情 → 本地设置」勾选「不校验合法域名」临时绕过。
⚠️ 依赖安装:需安装 markdown-itlodashuuid 等依赖。小程序需执行 npm init + npm install,再在开发者工具中点击「构建 npm」。
💡 性能优化:长对话场景下 messages 数组会持续增长,建议做消息分页或虚拟列表(仅渲染可视区域的消息),避免 setData 数据量过大。
💡 错误处理:完善各类异常场景——连接失败、消息解析失败、后端报错——都应在 UI 层给用户明确反馈,不能静默失败。
📌 Markdown 安全:html: false 已禁用原始 HTML 渲染,防止 XSS。若确实需要富文本能力,建议白名单过滤而非直接开启。

十一步、下一步升级建议(按需选择)

若需进一步完善,可优先实现以下功能,直接对接当前方案:

  1. 断线重连 + 会话恢复:用户断线后重新连接,恢复之前的对话记录(需 BFF 配合持久化 session)。
  2. LLM 对接:BFF 层对接 OpenAI / 内部 LLM,实现 tool_call 自动转换为商品卡 Block。
  3. Markdown 插件扩展:引入 markdown-it 插件生态(代码高亮、表情、自定义商品卡语法糖等)。
  4. 长对话优化:实现消息分页、历史消息缓存、虚拟滚动列表,解决百轮以上对话的性能瓶颈。
  5. 多路并发控制:支持用户在上一个回答 streaming 时发送新问题(中断当前流 + 开启新的流)。