[特殊字符] Taro + 微信小程序实现大模型流式响应(SSE):最全实践指南
虽然 WebSocket 全双工很强,但对于大模型对话这种“一问多答”的场景,SSE(Server-Sent Events)其实更轻量、更符合 HTTP 语义。但在微信小程序里,我们并没有标准的API。不过别慌,微信的wx.request(Taro 中是)支持了参数。这就给了我们操作的空间!😏在小程序里做大模型流式输出,其实就是一场“前端 + 网络 + 后端”的综合战役。前端:Taro +是基础
最近大模型(LLM)可以说是火得一塌糊涂。各家公司都在往小程序里塞 AI 对话功能。但是,大家有没有发现一个问题?如果你像传统请求那样,等 AI 把几千字的小作文全写完再返回给前端,用户的等待体验简直是灾难级的!😫
所以,流式输出(Streaming) 也就是我们常说的“打字机效果”,是提升 AI 产品体验的必杀技。
今天,我就带大家深入硬核地扒一扒,如何在 Taro 框架 下,利用 微信小程序原生能力 实现丝滑的 SSE(Server-Sent Events)流式响应。
如果你在实施的过程中遇到这些问题:
- 明明代码按官方写的但是就是不流式输出?
- 模拟器跟真机效果不一样?
- 为什么安卓可以。IOS却不行
- 为什么可以输出内容但是控制台总是会在请求结束后报错
那么请放心,这中间的坑,我已经替大家踩平了
🧐 为什么是 SSE 而不是 WebSocket?
虽然 WebSocket 全双工很强,但对于大模型对话这种“一问多答”的场景,SSE(Server-Sent Events)其实更轻量、更符合 HTTP 语义。
但在微信小程序里,我们并没有标准的 EventSource API。不过别慌,微信的 wx.request(Taro 中是 Taro.request)支持了 enableChunked 参数。
这就给了我们操作的空间!😏
🛠️ 核心技术实现:前端篇
我们先来看核心的 Hook 实现。这里我封装了一个 useSSE,它负责建立连接、解码二进制流、解析 SSE 协议以及控制打字机速度。
1. 链路设计图
在开始贴代码前,先看下数据是怎么流转的:
建立连接
二进制流 chunk
用户发起对话
Taro.request (开启 enableChunked)
服务端响应 (Transfer-Encoding: chunked)
onChunkReceived 监听
TextDecoder 解码 (ArrayBuffer 转 String)
SSE 协议解析 (提取 data 字段)
写入缓存队列 (Buffer)
定时器控制 (打字机效果)
更新 UI (Markdown 渲染)
2. 核心 Hook:useSSE.ts
这个文件是整个功能的灵魂。我们需要解决两个大难题:
- 解码:小程序返回的是
ArrayBuffer,需要兼容性好的解码方案(推荐text-encoding-shim)。 - 粘包处理:网络传输是不讲道理的,可能一次给你半条数据,也可能一次给你三条。必须要有 Buffer 机制。
直接上代码,CV 也就是复制粘贴就能用:
import Taro, { RequestTask } from '@tarojs/taro' import { useState, useRef } from 'react' // 假设 config 和 request 是你项目里的基础配置,需根据实际情况替换 import { apiUrl } from '~/config' import { ResChatMessagesDTO } from '~/request' import { getAuthorization } from '~/utils/authorization' // 这是一个非常重要的库,用于在小程序环境解码 Uint8Array import * as TextEncoding from 'text-encoding-shim' export type ChatMessage = Omit<ResChatMessagesDTO, 'id'> & { id?: string | number } export function useSSE() { // 最终展示在 UI 上的内容(经过打字机效果处理) const [displayContent, setDisplayContent] = useState('') // 完整的内容(后台实时接收到的) const [content, setContent] = useState('') // 缓冲池,用于平滑打字机效果 const bufferRef = useRef('') const typingTimerRef = useRef<any>(null) const typingSpeedRef = useRef(120) // 打字速度 ms const typingStepRef = useRef(1) // 每次渲染字符数 let requestTask: RequestTask<any> | null = null /** * 将接收到的片段追加到缓冲池,并启动打字机 */ function appendChunk(text: string) { if (!text) return bufferRef.current += text setContent((prev) => prev + text) // 如果没有定时器在跑,就启动一个 if (!typingTimerRef.current) { const msPerChar = Math.max(10, Math.floor(1000 / typingSpeedRef.current)) typingTimerRef.current = setInterval(() => { if (!bufferRef.current) { return } const step = typingStepRef.current // 从缓冲池头部切出字符 const take = bufferRef.current.slice(0, step) bufferRef.current = bufferRef.current.slice(step) setDisplayContent((prev) => prev + take) }, msPerChar) } } /** * 动态调整打字机速度(可选) */ function setTyping(opts: { speed?: number; step?: number } = {}) { if (typeof opts.speed === 'number' && opts.speed > 0) { typingSpeedRef.current = opts.speed } if (typeof opts.step === 'number' && opts.step > 0) { typingStepRef.current = Math.max(1, Math.floor(opts.step)) } // 重置定时器逻辑略...(参考完整代码) } /** * 发起对话请求 */ async function chat(params: ChatMessage) { const url = apiUrl + '/api/web/member/v1/pets/stream/chat' const header = { Authorization: getAuthorization(), Accept: 'text/event-stream', // 告诉服务端我要流 'Content-Type': 'application/json' } let buffer = '' // 用于处理 SSE 分包/粘包的局部 buffer // 解码器:ArrayBuffer -> String function decode(arr: ArrayBuffer | string): string { if (typeof arr === 'string') return arr try { const uint8Array = new Uint8Array(arr) return new TextEncoding.TextDecoder('utf-8').decode(uint8Array) } catch (e) { // 降级处理 const ints = new Uint8Array(arr) let str = '' for (let i = 0; i < ints.length; i++) { str += String.fromCharCode(ints[i]) } return str } } // SSE 协议解析器 function parseSSE(text: string): string[] { buffer += text // SSE 消息通常以 \n\n 结尾 const blocks = buffer.split('\n\n').filter((b) => b.trim().length > 0) // 如果最后一个块不是以 \n\n 结尾,说明数据没传完,放回 buffer 等待下一次 if (!buffer.endsWith('\n\n') && blocks.length > 0) { buffer = blocks.pop() || '' } else { buffer = '' } const out: string[] = [] for (const blk of blocks) { const lines = blk.split('\n') // 提取 data: 开头的数据 const dataLines = lines.filter((l) => l.startsWith('data:')) if (dataLines.length) { const payload = dataLines.map((l) => l.replace(/^data:\s*/, '')).join(' \n') out.push(payload) } } return out } // 🚀 核心请求逻辑 requestTask = Taro.request({ url, method: 'POST', header, data: params, enableChunked: true, // 👈 开启分块传输,关键! responseType: 'arraybuffer', // 👈 必须接收二进制,否则中文乱码 enableHttp2: false, // 👈 避免 HTTP/2 协议报错,后面会细说 timeout: 60000, success: () => {}, fail: (err) => console.error('请求错误', err), complete: () => console.log('请求完成') }) requestTask.onHeadersReceived(() => console.log('连接成功')) // 监听数据包 requestTask.onChunkReceived((res: { data: ArrayBuffer }) => { const text = decode(res.data) const msgs = parseSSE(text) for (const chunk of msgs) { // 这里可以做一些特殊的字符处理,比如 markdown 的处理 const _chunk = String(chunk).replace(/(#+)/, '$1 ') appendChunk(_chunk) } }) } return { chat, close: () => { if (requestTask) { requestTask.abort() requestTask = null } // 清理定时器逻辑... }, content, displayContent, // UI 绑定这个 setTyping } }
3. UI 组件实现:Markdown 渲染 + 光标动画
前端展示不仅要流式出字,还得支持 Markdown(代码高亮、表格等)。小程序里推荐使用 towxml 或类似的库。同时,为了拟真,我们加个闪烁的光标。
index.tsx:
import { memo, FC, useEffect } from 'react' import { View, Text } from '@tarojs/components' import styles from './index.module.less' import { ChatMessage, useSSE } from '../../useSSE' // 假设你引入了 towxml 组件 import towxmlFun from '../../../../../components/towxml' import { MMLoading } from '@wmeimob/taro-design' interface IStreamMessasgeProps { params: ChatMessage showTip?: boolean } const Component: FC<IStreamMessasgeProps> = (props) => { const { params, showTip = true } = props const { chat, displayContent } = useSSE() useEffect(() => { chat(params) }, []) return ( <View className={styles.chatItem_ai}> {!displayContent ? ( // 思考时的 Loading 状态 <View className={styles.ai_loading}> <Text>AI 正在疯狂烧脑中...</Text> <MMLoading /> </View> ) : ( <> <View className={styles.aiBubble}> {/* Markdown 渲染区域 */} <towxml nodes={towxmlFun(displayContent, 'markdown')} /> {/* 模拟光标 */} <View className={styles.cursor} /> </View> {showTip && <Text className={styles.aiTitle}>回答由AI生成,仅供参考备份</Text>} </> )} </View> ) } export default memo(Component)
index.module.less (光标动画):
.cursor { display: inline-block; width: 6px; height: 16px; margin-left: 2px; background-color: #333; vertical-align: bottom; animation: blink 1s step-start infinite; } @keyframes blink { 50% { opacity: 0; } }
🌩️ 服务端配置:Nginx 的那些坑
很多同学前端代码写得完美无缺,一跑起来:要么卡顿,要么报错,要么干脆不流式直接返回一坨数据。
这锅通常得 Nginx 背。🙅♂️
Nginx 默认会开启缓冲(Buffering),它想存够一波数据再发给客户端,这直接把我们的“流”给截断了。
1. 黄金 Nginx 配置
请把这段配置焊死在你的 Nginx 配置文件里:
location /api/web/member/v1/pets/stream/chat { # 1. 关键:关闭所有缓冲 proxy_buffering off; proxy_cache off; proxy_request_buffering off; # 2. 禁用 gzip 压缩 (压缩需要缓冲区,会破坏流式) gzip off; # 3. HTTP 协议设置 proxy_http_version 1.1; proxy_set_header Connection ''; # 清空 Connection 头,保持长连接 # 4. 编码设置 chunked_transfer_encoding on; # 5. 禁用 Nginx 的加速缓冲 (X-Accel-Buffering) proxy_set_header X-Accel-Buffering no; proxy_hide_header X-Accel-Buffering; # 6. 超时设置 (流式响应通常时间较长) proxy_read_timeout 24h; proxy_send_timeout 24h; # 7. 响应头处理 add_header Cache-Control "no-cache"; add_header X-Accel-Buffering "no"; # 8. 强制小缓冲区 (避免 Nginx 自作主张存数据) proxy_buffer_size 1k; proxy_buffers 4 1k; proxy_busy_buffers_size 1k; # 转发到你的后端服务... proxy_pass http://backend_service; }
🐛 常见报错与神坑排查
在开发过程中,你可能会遇到下面这两个“顶级”错误,我都碰到过,这里直接给解法。
❌ 错误 1: net::ERR_INCOMPLETE_CHUNKED_ENCODING
现象: 控制台报错 POST net::ERR_INCOMPLETE_CHUNKED_ENCODING 200。虽然状态码是 200,数据也收到了,但就是红一片。
原因: 这是因为服务端告诉客户端 Transfer-Encoding: chunked,但在流结束时,没有发送标准的终止块(0\r\n\r\n),或者中间代理(比如公司网关、WAF)提前把连接切断了。
解法:
- 检查后端代码,确保流结束时正确关闭了 Writer。
- 如果在开发工具里看到这个,只要数据接收完整,可以忽略。这往往是开发工具对连接关闭状态的误判。
❌ 错误 2: net::ERR_HTTP2_PROTOCOL_ERROR
现象: 流式传输过程中,随机中断,报错 net::ERR_HTTP2_PROTOCOL_ERROR。
硬核分析: 这是微信小程序网络栈在 HTTP/2 下处理 Chunked 响应 的已知不稳定点。当你的域名开启了 HTTP/2,且使用了 wx.request + enableChunked 时,微信客户端底层在处理分块时极其容易崩溃。
终极解法: 降级到 HTTP/1.1。
这就是为什么我在前端代码里写了 enableHttp2: false:
requestTask = Taro.request({ // ... 其他配置 enableHttp2: false, // 👈 救命稻草 })
注意:有时候前端设置 enableHttp2: false 还不够,因为微信底层可能还是会复用之前的 H2 连接。最稳妥的方式是 服务端针对该流式接口强制只走 HTTP/1.1,或者干脆把流式接口挂载到一个没有开启 H2 的子域名下。
📝 总结
在小程序里做大模型流式输出,其实就是一场 “前端 + 网络 + 后端” 的综合战役。
- 前端:Taro +
enableChunked是基础,TextDecoder + Buffer 队列是核心。 - 体验:不要直接渲染,用定时器做个缓冲池,模拟“打字机”效果,丝滑度提升 10 倍。
- 后端:Nginx 必须关闭 Buffering,否则流式变阻塞。
- 避坑:HTTP/2 是流式的大敌,遇到诡异断连,请果断切回 HTTP/1.1。
更多推荐


所有评论(0)