🚀 Taro + 微信小程序实现大模型流式响应(SSE):最全实践指南

280 阅读8分钟

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

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

  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。

希望这篇文章能帮你少掉几根头发!如果你觉得有用,点赞、收藏、转发 三连走起!🚀