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 五层架构全景
二、数据协议(核心前置定义)
统一前后端通信协议,避免后续开发混乱。所有消息均遵循此格式,覆盖文本、商品卡、结束、错误四种核心场景,支持后续扩展。
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;
// 其他业务字段...
}
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 };
}
四、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];
}
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 关键说明
-
节流优化:
updateUI使用 50ms 节流,避免流式渲染时频繁setData导致小程序卡顿——这是小程序落地的关键优化点。 -
消息 ID:使用
uuidv4生成唯一标识,避免wx:for渲染时 key 冲突。 -
状态字段:AI 消息增加
status字段(streaming / done / error),便于 UI 显示加载中 / 完成 / 错误三种状态。 -
三步流水线:每次 chunk 到达都经过
parseChunk → normalize → render,确保 Block 始终是最新完整形态。
六、UI 渲染层(小程序:可直接复制使用)
适配 useChat 的
messages 状态,渲染用户消息、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 关键说明
-
滚动优化:使用
scroll-view的scroll-into-view属性,确保每次收发消息时自动滚到最新位置。 -
商品卡组件:
<product-card>为自定义组件,需根据业务需求实现(渲染商品图片、标题、价格等)。 -
生命周期:
onUnload中必须调用closeChat()关闭 WebSocket,避免资源泄漏。
七、Node BFF 层(配合协议,直接落地)
BFF 层核心职责:对接后端 LLM(如 OpenAI、内部模型),将 LLM 输出转换为符合 ChatChunk 协议的流式数据,通过 WebSocket 推送给前端。
7.1 核心要求
- 输出格式:直接返回 ChatChunk 格式的 JSON 字符串,每行一个 chunk(流式输出)。
- 通信方式:BFF 需支持 WebSocket,将 LLM 的 SSE / RPC 响应转换为 WS 消息推送。
-
商品卡适配:当 LLM 返回工具调用(tool_call)时,BFF
解析商品数据并生成
type: 'product'的 ChatChunk。
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"}
八、扩展能力(预留接口,便于后续升级)
提前预留扩展接口,避免后续迭代时大规模重构,降低维护成本。
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 协议,降低联调成本,便于后端迭代升级 |
十、落地注意事项(关键避坑点)
wss://),否则无法连接。开发阶段可通过「详情 →
本地设置」勾选「不校验合法域名」临时绕过。
markdown-it、lodash、uuid
等依赖。小程序需执行 npm init +
npm install,再在开发者工具中点击「构建 npm」。
messages
数组会持续增长,建议做消息分页或虚拟列表(仅渲染可视区域的消息),避免
setData 数据量过大。
html: false 已禁用原始
HTML 渲染,防止 XSS。若确实需要富文本能力,建议白名单过滤而非直接开启。
十一步、下一步升级建议(按需选择)
若需进一步完善,可优先实现以下功能,直接对接当前方案:
- 断线重连 + 会话恢复:用户断线后重新连接,恢复之前的对话记录(需 BFF 配合持久化 session)。
- LLM 对接:BFF 层对接 OpenAI / 内部 LLM,实现 tool_call 自动转换为商品卡 Block。
-
Markdown 插件扩展:引入
markdown-it插件生态(代码高亮、表情、自定义商品卡语法糖等)。 - 长对话优化:实现消息分页、历史消息缓存、虚拟滚动列表,解决百轮以上对话的性能瓶颈。
- 多路并发控制:支持用户在上一个回答 streaming 时发送新问题(中断当前流 + 开启新的流)。