最近为 AI 对话功能接入流式接口,说实话一开始有点想简单了。网页端用 EventSource 监听 message 事件就能拿到 token 流,小程序里没有 EventSource,wx.request 默认又是等整个响应结束才进 success,真机上 WebSocket 还会因为域名、网络切换、后台挂起各种掉线。这篇文章记录我整个接入过程,从协议选型到代码骨架,再到真机排查,希望对正在做类似功能的开发者有用。
这个场景不是个例。小程序的流式接口需求集中出现在 AI 对话、实时日志推送、动态榜单和 IM 消息同步。如果后端只给你一个 SSE 接口,小程序这边不能直接“SSE 一把梭”,必须想清楚用 request chunked、WebSocket 还是轮询。我会先说清楚原因,再给出我实际使用的方案。不管你手头是已经跑通基础版本,还是刚被需求砸中,这篇文章里的协议设计和异常处理思路应该都能直接借鉴,我尽量把当时踩过的坑写在对应的位置。
1. 小程序网络环境里,流式接口为什么总要多走一步
1.1 浏览器里“顺手就能用”的流式能力
在浏览器端,接入流式接口真的不难。SSE 直接用 EventSource,数据来了会触发 onmessage;想更底层一点,fetch 返回的 response.body 是一个 ReadableStream,可以自己逐块读。浏览器已经帮你处理好了连接、重连、数据帧解析这些底层细节,开发者只需要关心业务事件。
小程序的情况不是这样。它是运行在 WebView 与原生层之间的混合环境,网络 API 并没有完整暴露给开发者。EventSource 在小程序里基本不可用;fetch 也不是标准实现,用的是 wx.request 这套独立封装。也就是说,浏览器里那些现成的流式工具,到了小程序里都得自己重建一遍。
这个本质差异决定了接入方案不能直接照搬网页端。最开始我用浏览器思维去写,结果在开发者工具里跑通之后,真机上不是连不上、就是消息收不完整,走了不少弯路。
1.2 小程序网络层的三个关键限制
真正影响流式接入的约束有三个,我逐个说清楚。
第一个是 API 形态不同。wx.request 默认是一次请求一次响应,success 回调拿到的是完整 body。虽然基础库 2.20.2 之后支持 enableChunked 和 onChunkReceived,但它和浏览器的 ReadableStream 使用方式很不一样,需要自己处理缓冲和拆包,后面我会详细展开。
第二个是 WebSocket 域名限制和证书校验。开发工具里不校验域名,但真机上只要 wss 域名没有在 mp 后台配置,或者证书链有问题,连接会直接失败。这个坑在前期开发时不容易暴露,很多人都是拿到真机测试才发现。
第三个是生命周期限制。小程序切到后台后,长连接很可能被系统挂起。用户再切回来时,连接可能已经失效,而页面 UI 还停留在“已连接”。所以流式小程序不能只写一个 connect(),还要处理前后台切换时的活性探测和重连。
这三个限制叠加在一起,就把“接入流式接口”从一个简单需求,变成了需要协议设计、状态管理和异常处理的小型工程。
1.3 什么样的业务场景才会被流式需求逼到这一步
如果你只是定时拉一次数据,wx.request 就够了,根本不需要流式接口。真正需要流式的业务,通常有一个共同点:数据在时间轴上不确定产生,而且对实时性有要求。
典型比如 AI 对话。大模型生成文本是逐字吐出来的,如果等服务端全部生成完再返回,用户要白等几十秒。换成流式后,第一个字往往几百毫秒内就到,感知上的响应速度完全不一样。
再比如运维日志和监控大盘。日志事件是持续产生的,轮询既浪费流量又做不到秒级感知,用 WebSocket 推送更合理。实时行情、在线协作、IM 消息,本质上都是这类时间轴不确定的数据流。
这些场景的共同点是:你需要的不只是“拿到数据”,而是“持续拿到数据,并且把状态实时反映在界面上”。理解了这一点,后面的协议选择就有了依据。
2. 两条主路:wx.request 分块读取与 WebSocket 长连接
实际动手之前,我先在后端那边立了个规矩:所有流式接口,要么给出 SSE,要么给出 WebSocket 地址,二选一。然后我在小程序端分别验证了两条路的可行性。
2.1 wx.request 的 enableChunked:能接 SSE,但别期待太高
wx.request 支持一个 enableChunked 参数,基础库 2.20.2 开始可用。开启之后,可以通过 RequestTask.onChunkReceived 监听数据分块到达。这个能力让我一度以为可以无缝对接后端的 SSE 接口。
代码大概是这样的:
const task = wx.request({ url: 'https://api.example.com/sse/chat', method: 'POST', enableChunked: true, header: { 'Content-Type': 'application/json' }, success: () => { // 注意:这里触发时整个流已经结束了 } }); task.onChunkReceived((res) => { // res.data 是当前 chunk handleChunk(res.data); });我踩过的坑主要在这里:onChunkReceived 拿到的 res.data 是原始字节块,不是按 SSE 的data:事件分割好的文本。一次回调可能只拿到半个 UTF-8 字符,也可能一次拿到好几条 event。你需要自己维护一个 buffer,把字节拼接成字符串,再按 SSE 的帧分隔符去解析。
还有一个容易被忽略的点:使用 enableChunked 时,success 回调只在全部接收完成后触发。如果流式接口是无限流,比如实时音频转写,那 success 可能永远不触发,业务逻辑不能依赖 success 去收尾。
所以我的结论是:如果后端只有 SSE 接口,且前端无法改动后端,enableChunked 是一个可用的妥协方案。但它有两个明显短板。一是断线重连得自己实现,二是服务端只能单向推送,客户端无法在连接中途发送“停止生成”指令。对于 AI 对话这种需要中断控制的场景,这条路不够好。
2.2 WebSocket:适合双向交互的主链路
WebSocket 在小程序里有完整的 SocketTask 支持,连接建立后可以双向收发,正好补上了 enableChunked 的两个短板。
它和底层 TCP 的关系需要理解一下:WebSocket 协议本身有帧边界,消息不会出现半个帧。但这不代表你收到一条消息就是一个完整业务事件。服务端在应用层可能把一个业务事件拆成多帧发,也可能把多个事件拼在一帧里发,业务层仍然需要消息边界设计和缓冲处理。
小程序侧 WebSocket 的约束主要是连接数和域名。每个小程序实例同时最多只能维持有限数量的 WebSocket 连接,所以全局要收敛,不能每个页面都开一条。域名也必须在后台配置 Socket 合法域名,并且支持 wss 协议,不能直接用 ws。
对于 AI 对话,WebSocket 还有一个天然优势:用户点击“停止回答”时,客户端可以直接发一条控制消息给服务端,让它立即中断生成。这个能力在对话体验里非常重要,也是我最终把它作为主链路的核心原因。
2.3 短轮询这个备胎,什么时候才值得用
还有一个很多人会想到的方案:短轮询。每 500 毫秒去拉一次最新文本,看起来也能实现“流式”效果。
我只能说这是最后的备胎。轮询的问题很明显:延迟取决于轮询间隔,间隔太短又浪费请求和流量。小程序的请求并发数是有限的,频繁请求还会触发平台的频率限制,导致请求失败。
短轮询唯一合理的场景是:旧版本小程序基础库不支持 enableChunked,后端也没有能力提供 WebSocket,且文本长度短、更新频率低。我用它做过一个兜底策略,当 WebSocket 连续重连失败时,自动降级到 2 秒一次的轮询,保证功能可用,但体验上会有可感知的延迟。
这个降级开关必须在配置里能远程关闭,不然线上出了问题想撤都撤不回来。不要把降级逻辑硬编码在页面里,否则后续想灰度就非常被动。
2.4 选型表:我最后为什么选了 WebSocket + 房间协议
综合下来,我最后的选择是:AI 对话、实时日志这类双向交互场景用 WebSocket;纯服务端通知、无中断控制需求的场景用 enableChunked 接 SSE;短轮询只做降级兜底。
我整理了一个对比表,你可以根据自己后端的情况直接套:
| 维度 | wx.request + enableChunked | WebSocket | 短轮询 |
|---|---|---|---|
| 基础库要求 | 2.20.2 以上 | 2.0.0 以上 | 无 |
| 单向/双向 | 只能接收 | 双向实时 | 单向请求 |
| 支持停止生成 | 不行 | 可以主动发控制消息 | 不支持 |
| 断线重连 | 自己实现 | 自己实现 | 天然冗余 |
| 域名配置 | request 合法域名 | socket 合法域名 | request 合法域名 |
| 延迟 | 低 | 低 | 高 |
| 适合场景 | SSE 通知、服务端单向推送 | AI 对话、IM、实时互动 | 兜底降级 |
我的后端一开始其实只提供了 SSE 接口。我在接入时做了一层适配:小程序端优先连 WebSocket,如果服务端没有 ws 协议,再退回 enableChunked 模式。这样有两个好处,一是新功能可以逐步灰度到 WebSocket,二是线上万一 WebSocket 挂了,不会让整个聊天功能直接瘫痪。
到这里,选型就已经很明确了。接下来进入实现环节,我把可复用的代码骨架写出来。
3. WebSocket 流式接入的协议设计与代码骨架
3.1 连接参数:把 token 放在哪里更稳
小程序里建立 WebSocket 连接时有几种携带身份信息的方式:直接拼在 URL 的 query 里,或者通过 header 传给后台。两种方式我都试过。
从兼容性来看,我建议优先放在 header 里,但对于无法自定义 header 的网关,或者出于日志脱敏的考虑,放在 query 也可以。要特别注意,query 方式不要明文拼接敏感信息,至少做一次编码,并确保使用 WSS 传输,否则连接参数可能在链路上被暴露。
const url = `wss://api.example.com/ws/chat?token=${encodeURIComponent(token)}`; wx.connectSocket({ url, success: () => {}, fail: (err) => { console.error('connect fail', err); } });关于失败处理,connectSocket 的 fail 只能捕获到同步错误。到了真机上你会发现,域名配置错、证书错误这类问题并不会在 fail 里立刻抛出来,而是后续在 onError 里出现。所以一定要同时监听 error 和 close,用状态机去管理连接生命周期,这一点后面会细说。
3.2 消息边界设计:用换行做分帧,粘包也不怕
前面说了,WebSocket 的帧边界不等于业务消息边界。我最后和后端约定的协议很简单:每条业务消息是一行 JSON,以\n结尾。
比如服务端持续推送内容时,会发出这样一串:
{"type":"start","sessionId":"abc123"}\n {"type":"delta","content":"你好"}\n {"type":"delta","content":"世界"}\n {"type":"done","finishReason":"stop"}\n这样做的原因是:无论底层怎么粘包、半包,客户端只要维护一个字符串 buffer,遇到换行就把完整行切出来,再交给 JSON.parse 解析即可。相比 SSE 的\n\n,单换行更容易处理,也适合高频 token 流。
这里有一个细节:中文字符的 UTF-8 字节可能被 TCP 分割到两个 chunk 里。如果回调给的是 ArrayBuffer,就必须用 TextDecoder 的 stream 模式解码,不能每次单独 decode 后直接丢弃内部状态。否则会出现中文乱码,而且这种乱码非常难排查。
3.3 心跳和断线重连:指数退避的落地写法
WebSocket 连接空闲一段时间后,可能会被网络设备或服务端静默断开。客户端往往要等到下一次发送数据时才发现连接已经没了,这在流式场景里不可接受,所以心跳是必须的。
我的做法是:连接建立后,每 15 秒发送一条{"type":"ping"},服务端有 pong 说明链路正常。连续两个周期没有响应,就主动 close 并进入重连流程。注意心跳包不要和大流量数据竞争发送,最好单独用一个定时器,不要和业务消息混在一起。
断线重连的代码我习惯写成指数退避,退避时间从 1 秒开始,每次翻倍,最大到 15 秒。同时要避免重连风暴,在 onError 和 onClose 里只触发一次scheduleReconnect。
scheduleReconnect() { if (this.reconnecting) return; this.reconnecting = true; const delay = Math.min(1000 * Math.pow(2, this.retryCount), 15000); setTimeout(() => { this.retryCount++; this.reconnecting = false; this.connect(); }, delay); }3.4 一个可复用的 StreamClient 骨架
我把上面的逻辑收敛成一个 StreamClient 类,页面只需要关注 onMessage 和 onStatusChange 两个回调。核心结构如下:
class StreamClient { constructor({ url, token, onMessage, onStatusChange }) { this.url = url; this.token = token; this.onMessage = onMessage || (() => {}); this.onStatusChange = onStatusChange || (() => {}); this.buffer = ''; this.retryCount = 0; this.reconnecting = false; this.decoder = new TextDecoder('utf-8'); this.connected = false; this.heartbeatTimer = null; } connect() { const task = wx.connectSocket({ url: `${this.url}?token=${encodeURIComponent(this.token)}` }); task.onOpen(() => { this.connected = true; this.retryCount = 0; this.onStatusChange('connected'); this.startHeartbeat(); }); task.onMessage((res) => { this.handleChunk(res.data); }); task.onError(() => { this.onStatusChange('error'); this.cleanup(); this.scheduleReconnect(); }); task.onClose(() => { this.connected = false; this.onStatusChange('closed'); this.cleanup(); this.scheduleReconnect(); }); this.task = task; } handleChunk(chunk) { if (typeof chunk !== 'string') { this.buffer += this.decoder.decode(chunk, { stream: true }); } else { this.buffer += chunk; } let newlineIndex; while ((newlineIndex = this.buffer.indexOf('\n')) >= 0) { const line = this.buffer.slice(0, newlineIndex).trim(); this.buffer = this.buffer.slice(newlineIndex + 1); if (!line) continue; try { const msg = JSON.parse(line); this.onMessage(msg); } catch (e) { console.warn('invalid json line:', line); } } } send(obj) { if (this.task && this.connected) { this.task.send({ data: JSON.stringify(obj) }); } } close() { this.retryCount = Number.MAX_SAFE_INTEGER; if (this.task) this.task.close({}); this.cleanup(); } startHeartbeat() { this.heartbeatTimer = setInterval(() => { this.send({ type: 'ping' }); }, 15000); } cleanup() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } }这个骨架看起来简单,实际用起来却很稳。页面里只需要 new 一个 StreamClient,然后在 onMessage 里把type: 'delta'的消息内容更新到界面上即可。
在这个基础上,AI 对话场景还需要处理“停止生成”:用户点击停止时,调用client.send({ type: 'abort' }),服务端收到后中断模型生成,然后发一条type: 'done'收尾。这部分逻辑不复杂,但对后端协议有要求,需要在设计阶段就明确下来。
4. 流式内容解析后的渲染优化:setData 的代价和替代思路
代码骨架写完,第一版功能很快就通了。开发工具里一切流畅,真机一跑就露馅:文字出来的时候,页面明显一卡一卡。原因不在流式解析,而在渲染策略。
4.1 从 WebSocket 回调到 WXML 的完整链路
先理清一条数据在页面上是怎么走完的。WebSocket 消息到达后,handleChunk 解析出 JSON,onMessage 回调触发,然后你把新文本 setData 到 data 里,WXML 重新渲染。
这条链路本身没毛病,但流式对话场景下,后端一秒钟可能会推过来几十个 delta 消息。如果每个 delta 都触发一次 setData,小程序相当于在一秒内连续发起了几十次“逻辑层到渲染层”的更新。这个更新不是模板原地改一改,而是整个 data 对象里的相关字段被序列化、跨线程传递、再计算 diff。频率一高,卡顿和掉帧是必然的。
4.2 setData 的代价:流量、序列化和渲染三座大山
很多人只知道 setData 频繁调用会卡,不知道到底卡在哪。我拆开来解释一下。
小程序逻辑层运行在 JavaScriptCore 或 V8 里,视图层是独立的 WebView 渲染进程。setData 会把传入的数据做一次序列化,通过原生层转交给 WebView,再和视图层的数据 diff、更新节点。这里每一步都有开销:序列化越大越慢,传输频率越高越占带宽,diff 范围越大渲染越卡。
更致命的是,如果你每次把已经显示过的全量文本和新增文本拼在一起再 setData,传入的数据会随着对话越来越长。前面还好,到几千字的时候,一次 setData 的数据体积可能达到几十 KB,这里面的绝大部分是重复传输的旧文本,完全没有必要。
4.3 我在 AI 对话页用的刷新策略:100ms 聚合
我的解决方案是“时间窗口聚合”。新增内容先放进 pendingText,不立即 setData。维护一个 100ms 的 flush 定时器,时间到了,再把 pendingText 追加到展示字段上。
appendText(text) { this.pendingText += text; if (!this.flushTimer) { this.flushTimer = setTimeout(() => this.flush(), 100); } } flush() { this.flushTimer = null; if (!this.pendingText) return; this.setData({ displayText: this.data.displayText + this.pendingText }); this.pendingText = ''; }这里仍然是把全量文本传给 setData,但至少将每秒几十次的更新降到了每秒 10 次左右,相当于把渲染压力缩小了一个数量级。对于大多数 AI 对话页,一万字以内的文本,这个策略已经足够顺滑。
如果还想进一步减小 setData 体积,可以把文本改成一个数组,每次 push 新增片段:
this.setData({ textChunks: this.data.textChunks.concat([this.pendingText]) });WXML 里用{{textChunks}}直接拼接展示。这样做的好处是 setData 传入的增量只是新片段,不包含旧内容。但代价是数组越来越大时,视图层拼接一样会变慢,所以数组要定期合并、清空旧片段,或者切换成新的展示字段。
还有一个更底层的思路:用 WXS 做格式化。WXS 运行在视图层,可以避免部分跨线程通信,但它的定位是处理展示格式,不太适合承担频繁拼接大字符串的逻辑。我目前的项目里没有走到那一步,所以这里不做过多展开。
4.4 自动滚动的细节:别把性能问题变成体验问题
流式对话还有一个容易忽视的点:当内容超过一屏后,要自动滚到底部。很多开发者会在每一次新增文本后立刻执行滚动跳转,结果页面一边渲染一边滚动,肉眼可见地跳。
我的做法是:用 scroll-view 的 scroll-into-view,把滚动触发也放进 flush 逻辑中。也就是说,只有真正执行 setData 的那次才去调整滚动位置,避免高频滚动操作。
<scroll-view scroll-y scroll-into-view="{{scrollTarget}}" scroll-with-animation> <view id="bottom">{{displayText}}</view> </scroll-view>对应 flush 里:
this.setData({ displayText: this.data.displayText + this.pendingText, scrollTarget: 'bottom' });setData 一次搞定文本更新和滚动位置,渲染层只需要处理一次变更,体感会平滑很多。
5. 真机环境下的三个必现问题,以及我一整条排查链路
代码写完了,不代表就完了。从“开发工具能跑”到“真机稳定”,中间还有很长的路。下面三个问题我都在项目里真实遇到并解决过,每一个排查链路都值得沉淀。
5.1 必现问题一:开发者工具正常,真机 WebSocket 一直“连接中”
这是最常见的坑,也是最好排查的。我当时的现象是:开发者工具里连接秒开,消息正常;换成 iOS 真机,状态一直停在 connecting,然后几秒后 onError。
排查链路可以按这个顺序来:
- 检查 mp 后台的 Socket 合法域名是否已配置。开发者工具默认不校验域名,所以这块很容易漏。域名必须是 wss 协议,HTTP 和 ws 在真机上不允许。
- 检查证书链是否完整。有些私有证书在小程序端校验不通过,表现为连接被直接关闭。可以先用浏览器访问 wss 地址看证书是否有告警。
- 在 onError 里打印完整错误码和信息,不要只打印 message。不同错误码对应的原因差异很大。
- 用真机调试而不是预览模式。真机调试模式下能显示部分网络事件,更容易定位问题。
我那次最后定位到的问题是:域名大小写和后台配置不一致。证书校验是区分大小写的,一个字母大小写问题,页面在开发工具里完全正常,真机却无论如何都连不上。
5.2 必现问题二:消息时断时续,偶尔还会出现半个 JSON
这个问题的本质是粘包和半包。业务逻辑如果默认“一次 onMessage 就是一条完整消息”,那就会遇到解析失败或者内容跳跃。
我的排查链路是这样的:
- 先往日志里打印每个 onMessage 的原始数据,特别是长度和结尾字符。
- 你会发现,服务端发了一条完整 JSON,小程序端却可能拆成两个 chunk;服务端连续发了两条消息,小程序端可能在一个 chunk 里收到。
- 定位到原因后,不要在业务回调里做临时拼串,而是把缓冲和解析收敛到 StreamClient 的 handleChunk 里统一处理。
- 解析失败时不要静默忽略,一定要输出 warning 日志,方便统计异常比例。
用换行分帧之后,这类问题基本不会再出现。但要注意 TextDecoder 的 stream 模式,前面已经提过,这是处理 ArrayBuffer 半字符的关键。
5.3 必现问题三:切后台再回来,流式推送没反应
小程序切到后台一段时间,长连接会被系统断开或者挂起。用户回来之后,页面看起来还在,但你往 StreamClient 里发消息已经没有响应。很多开发者以为连接还活着,实际上它已经死了。
我的方案分成三步:
- 在 App 的 onHide 里记录一个时间戳,或者直接把 StreamClient 标记为“后台暂停”。
- 在页面 onShow 时,如果连接不是 connected 状态,主动触发重连;如果还显示 connected,就发送一次 ping 做活性探测,超时没有 pong 就关掉重连。
- 重连成功后,需要从服务端恢复未渲染完的流式内容。这个能力依赖后端接口设计,一般会提供一个“续传”参数,按 sessionId 和 lastSeq 拉取断点之后的数据。
第三步容易被忽略,但它才是长对话场景里真正提升体验的关键。没有续传,断线重连只能保证连接恢复,内容还是会丢一截。
5.4 排查工具组合:真机调试、vConsole、Network 面板
最后说排查工具。我日常的组合是真机调试 + vConsole + 业务日志上报。
真机调试可以看 console 日志,搭配 vConsole 可以在真机上直接看页面数据,适合复现 UI 类问题。Network 面板对 WebSocket 的展示不如普通请求直观,所以更依赖于你在 onError/onClose 里手动打的日志。
我习惯在所有连接事件里打结构化日志,包含事件名、连接状态、retryCount、时间戳,再通过日志平台汇总。线上有一些偶发的连接异常,在真机调试时很难复现,但靠这些日志可以快速圈定是服务端主动断开、网络切换还是证书问题。
6. 留在生产代码里的小习惯
文章最后一个部分,分享几个我觉得值得留在生产代码里的小习惯。
6.1 状态机:把连接状态收敛到一处
如果你的页面里直接用connected一个布尔值管理 WebSocket,很快就会发现不够用。因为真实场景里有连接中、已连接、重连中、已关闭、降级轮询中这些状态,布尔值表达不了。
我把状态收敛为一个枚举:connecting / connected / reconnecting / closed / degraded。所有的事件回调只负责修改状态机的状态,页面 UI 根据状态变化去更新按钮和提示。这样代码逻辑会干净很多,也不容易出现“明明断了,界面还显示已连接”的尴尬。
6.2 一个简单的可观测性习惯
WebSocket 的问题有一个特点:大部分是环境相关,不是每次必现。所以我在 StreamClient 里加了一个简单的采样上报,每次 onMessage 都会累加计数,并定期上报连接时长、消息总数、重连次数。
不需要第三方 SDK,自己用埋点接口就能实现。关键是这些数据要和 sessionId 绑定,出问题时能按用户回放当时的网络链路。
就我个人经验来说,很多小程序流式接口的稳定性问题,最后都不是代码写错了,而是缺少足够细致的观测数据。把连接状态和消息计数暴露出来,比堆一堆 try/catch 更管用。
6.3 别忘了给降级留一条路
我现在所有接流式接口的小程序项目,都会留一个降级通道。WebSocket 连续重连超过 3 次后,自动尝试 enableChunked 方式去请求 SSE 接口;如果连 SSE 也不行,就切到短轮询。
降级不是可选项,而是必备项。小程序运行在不同的系统 WebView、不同网络、不同基础库版本上,总有一些极端环境是开发阶段测试不到的。提前留好降级路径,至少能保证用户在关键时刻还能看到内容,而不是一个写满“网络连接失败”的空白页。
最后聊一点个人的感受:微信小程序接入流式接口,真正难的不是某个 API 不会用,而是要把“网络不可靠”这件事当成默认前提去设计代码。从协议选型、消息边界、心跳重连,到渲染频率、状态观测,每一层都在为这个前提兜底。把这几件事做扎实了,流式的体验自然就稳了。