最近大模型(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. 链路设计图
在开始贴代码前,先看下数据是怎么流转的:
graph TD
A["用户发起对话"] --> B["Taro.request (开启 enableChunked)"]
B --"建立连接"--> C["服务端响应 (Transfer-Encoding: chunked)"]
C --"二进制流 chunk"--> D["onChunkReceived 监听"]
D --> E["TextDecoder 解码 (ArrayBuffer 转 String)"]
E --> F["SSE 协议解析 (提取 data 字段)"]
F --> G["写入缓存队列 (Buffer)"]
G --> H["定时器控制 (打字机效果)"]
H --> I["更新 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。
希望这篇文章能帮你少掉几根头发!如果你觉得有用,点赞、收藏、转发 三连走起!🚀