最近大模型(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

这个文件是整个功能的灵魂。我们需要解决两个大难题:

  1. 解码:小程序返回的是 ArrayBuffer,需要兼容性好的解码方案(推荐 text-encoding-shim)。
  2. 粘包处理:网络传输是不讲道理的,可能一次给你半条数据,也可能一次给你三条。必须要有 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)提前把连接切断了。

解法

  1. 检查后端代码,确保流结束时正确关闭了 Writer。
  2. 如果在开发工具里看到这个,只要数据接收完整,可以忽略。这往往是开发工具对连接关闭状态的误判。

❌ 错误 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 的子域名下。

📝 总结

在小程序里做大模型流式输出,其实就是一场 “前端 + 网络 + 后端” 的综合战役。

  1. 前端:Taro + enableChunked 是基础,TextDecoder + Buffer 队列是核心。
  2. 体验:不要直接渲染,用定时器做个缓冲池,模拟“打字机”效果,丝滑度提升 10 倍。
  3. 后端:Nginx 必须关闭 Buffering,否则流式变阻塞。
  4. 避坑:HTTP/2 是流式的大敌,遇到诡异断连,请果断切回 HTTP/1.1。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐