☰
小程序接入大模型全链路实战:从流式输出到异常兜底
2026/10/20 3:26:31 网站建设 项目流程

简介:一份面向小程序开发者与AI应用初学者的完整源码包,演示如何在小程序端快速接入并落地大模型聊天机器人,涵盖从模型选型、服务部署到前端交互的主要实现环节,适合用于技术选型参考、二次开发或学习微信/支付宝等小程序内嵌AI对话能力。压缩包共2003个文件,约2.45MB,以js、ts、json居多,包含业务逻辑与工具脚本、工程配置、wxss/wxml界面样式与页面结构、wxs数据处理片段,另有少量markdown文档和license说明,目录划分清晰,便于对照排查。资源已吸引406人浏览学习,可作为小程序对话场景的最小可运行示例。通过阅读源码,用户可快速理解前端如何调用大模型接口、处理流式回复、剥离公共逻辑,并复用其中封装好的请求与渲染方案,缩短自己从零搭建聊天机器人的时间。

1. 小程序里接入大模型:从「能对话」到「像样对话」的完整落地

如果你以为小程序接入大模型,就是调一个wx.request把用户问题丢给接口再渲染返回值,那我建议你先停一下。真实场景里,响应慢、渲染卡顿、上下文丢失、密钥泄露、流式输出断流……任何一个环节没处理好,用户感知到的不是「智能」,而是「这破玩意又转圈了」。这篇文章拆一个基于原生小程序语法、对接大模型 Chat 接口的聊天机器人项目,覆盖从技术选型、接口封装、流式渲染到异常兜底的全链路。适合两类人:一是想快速给小程序加上 AI 对话能力的开发者,二是想搞清楚大模型接口在端侧到底怎么接、怎么调、怎么优化的同学。读完你能直接照着改出一版能上线的机器人,而不是停留在「调通了 Demo」的阶段。

2. 总体设计:为什么是原生小程序 + 云端中转

2.1 技术选型:不折腾框架,把精力留给对话链路

现在小程序开发基本绕不开两个选择:原生 WXML/WXSS/JS 还是跨端框架(如 Taro、uni-app)。这个项目选原生,理由很直接——微信小程序的 API 生态最稳定,wx.request、wx.connectSocket、wx.setStorageSync这些能力在原生环境里调用链路最短,调试时能直接定位到微信开发者工具的 Network 面板,省去一层框架编译的干扰。

大模型能力侧,项目没有直接在小程序里调大模型厂商的 HTTP 接口,而是走了一层云端转发(本项目用微信云开发或自建 Node 中转均可)。这样设计不是多此一举,而是出于两个硬性要求:

  • 密钥安全:大模型接口的 API Key 一旦写进小程序前端代码,等于公开给所有用户,云函数或服务端中转可以把密钥收敛在受控环境里。
  • 协议适配:大模型接口通常返回 SSE(Server-Sent Events)流式数据,原生小程序不支持直接的 EventSource,需要在云端把 SSE 转成小程序能消费的 WebSocket 或一次性 JSON。

一句话概括:小程序只负责「收集输入、渲染输出」,真正的模型调用、流式转发、上下文管理全在云端完成。

2.2 目录结构:一个小程序工程该有的样子

整个项目目录不复杂,但划分得很清楚,值得直接套用:

miniprogram/ ├── app.js # 小程序入口,初始化全局数据 ├── app.json # 页面路由、窗口配置 ├── app.wxss # 全局样式 ├── pages/ │ ├── chat/ │ │ ├── chat.js # 对话页逻辑:消息列表、发送、渲染 │ │ ├── chat.wxml # 对话页模板 │ │ ├── chat.wxss # 对话页样式 │ │ └── chat.json # 对话页配置 ├── services/ │ ├── llm.js # 大模型接口封装(请求/流式/中止) │ └── token.js # Token 鉴权与刷新 ├── utils/ │ └── message.js # 消息对象构造、历史记录管理 └── cloudfunctions/ └── chatProxy/ # 云函数:转发请求到大模型

核心思路浓缩在services/与pages/chat/两个目录的协作里:页面只管状态与视图,所有网络逻辑下沉到 service 层,后续换模型供应商(比如从 A 厂商换到 B 厂商)只需要改llm.js,页面代码纹丝不动。

2.3 对话流程:一次消息从输入到渲染的完整链路

先看整体时序,再逐段拆代码。用户在小程序输入一句话,点击发送后发生的完整链路如下:

用户输入 → chat.js 构造消息对象 → 写入本地历史记录 → 调 services/llm.js → 通过云函数 chatProxy 转发 → 大模型接口返回流式数据 → 云函数逐段转发给小程序 WebSocket → chat.js 收到增量内容 → 更新当前消息气泡 → 滚动到底部

链路里有三个容易出问题的节点:本地历史记录的管理、流式数据的增量拼接、以及消息滚动时机的控制。接下来逐个拆开看。

3. 核心模块拆解:一个请求从进入到返回的完整链路

3.1 消息模型与本地历史记录管理

聊天页面最基础的数据结构就是消息列表。每条消息需要区分角色、内容、状态三类信息,否则渲染阶段分不清哪些是用户发的、哪些是机器人正在生成的:

// utils/message.js function buildMessage(role, content, status = 'done') { const now = Date.now(); return { id: `${now}_${Math.random().toString(36).slice(2, 8)}`, role, // 'user' | 'assistant' content, // 文本内容 status, // 'pending' | 'streaming' | 'done' | 'error' createTime: now }; } function loadHistory(limit = 20) { const raw = wx.getStorageSync('chat_history') || []; // 只保留最近 N 条,避免存储超限 return raw.slice(-limit); } function saveHistory(list) { // 最多存 50 条,超了从头部丢弃 const trimmed = list.slice(-50); wx.setStorageSync('chat_history', trimmed); return trimmed; } module.exports = { buildMessage, loadHistory, saveHistory };

这里有一个容易被忽略的细节:status字段。机器人回复是流式的,从用户点击发送到完整内容渲染出来,中间可能经过好几秒甚至十几秒。如果不在消息对象上标记streaming状态,渲染层就无法区分「这句话已经完整了」和「这句话还在生成中」,也就无法做打字机效果和加载占位。

参数说明:limit控制取历史记录条数,默认 20 条刚好覆盖一次正常对话的上下文长度;saveHistory的 50 条上限是综合考虑了微信小程序本地缓存 10MB 限制和对话连续性后的折中值,太长会撑爆存储,太短则大模型无法理解前文语境。

3.2 页面逻辑:发送消息与增量渲染

对话页的 JS 逻辑是整个项目里最重的一块。它不仅要把用户消息推进列表,还要管理 WebSocket 的接收状态、处理流式数据拼装、控制滚动行为:

// pages/chat/chat.js const { buildMessage, loadHistory, saveHistory } = require('../../utils/message'); const llmService = require('../../services/llm'); Page({ data: { messages: [], inputValue: '', isStreaming: false, scrollIntoView: '' }, onLoad() { // 页面加载时恢复上次对话 this.setData({ messages: loadHistory() }); }, onInput(e) { this.setData({ inputValue: e.detail.value }); }, async onSend() { const content = this.data.inputValue.trim(); if (!content || this.data.isStreaming) return; // 用户消息立即渲染 const userMsg = buildMessage('user', content); const pendingMsg = buildMessage('assistant', '', 'pending'); const newList = this.data.messages.concat([userMsg, pendingMsg]); this.setData({ messages: newList, inputValue: '', isStreaming: true, scrollIntoView: `msg-${pendingMsg.id}` }); saveHistory(newList); try { // 组装上下文:把所有历史消息映射成 [{role, content}] 格式 const history = this.data.messages .filter(m => m.status === 'done' && m.content) .map(m => ({ role: m.role, content: m.content })); await llmService.chatStream({ messages: history, onMessage: (delta) => this.appendDelta(pendingMsg.id, delta), onDone: () => this.finishStream(pendingMsg.id), onError: (err) => this.failStream(pendingMsg.id, err) }); } catch (err) { this.failStream(pendingMsg.id, err); } }, appendDelta(msgId, delta) { const messages = this.data.messages.map(msg => { if (msg.id !== msgId) return msg; // 增量拼接:直接把 delta 追加到 content 末尾 return { ...msg, content: msg.content + delta, status: 'streaming' }; }); this.setData({ messages, scrollIntoView: `msg-${msgId}` }); }, finishStream(msgId) { const messages = this.data.messages.map(msg => { if (msg.id !== msgId) return msg; return { ...msg, status: 'done' }; }); this.setData({ messages, isStreaming: false }); saveHistory(messages); }, failStream(msgId, err) { const messages = this.data.messages.map(msg => { if (msg.id !== msgId) return msg; return { ...msg, status: 'error', content: `请求失败:${err.errMsg || err.message || '未知错误'}` }; }); this.setData({ messages, isStreaming: false }); saveHistory(messages); } });

逻辑说明:onSend里先渲染用户消息和一条pending状态的机器人占位消息,目的是让界面立刻产生反馈,用户不用等网络往返就能看到自己的话上屏。llmService.chatStream是异步流式接口,内部通过回调往页面上做增量更新。

这里有个关键点:appendDelta每次 setData 都会触发视图层更新,而小程序 setData 的通信开销是实打实的性能成本。如果模型输出太密,每 10ms 推一个 token,就会导致页面频繁重绘。常见做法是在llm.js里做数据节流——积攒一小段再 setData,或者用wx.nextTick合并渲染批次,否则低端机上会明显掉帧。

另一个值得注意的细节是scrollIntoView的取值。每条消息都带msg-${id}的标识,配合 WXML 里的scroll-into-view属性实现自动滚动。但不要在appendDelta里每次都滚动——滚动到最新消息在流式场景下容易抖动,实际项目里通常是「每追加若干字符滚一次」或者「用户手动向上翻时暂停滚动」。

3.3 大模型接口封装:流式、超时与中断

services/llm.js是对外的统一入口,也是换模型时唯一需要改的文件。它负责三件事:建立连接、处理增量、响应中断。

// services/llm.js const TOKEN_URL = 'https://your-domain.com/api/token'; // 换取临时凭证 const CHAT_WS_URL = 'wss://your-domain.com/chat'; // WebSocket 流式通道 let socketTask = null; function getToken() { return new Promise((resolve, reject) => { wx.request({ url: TOKEN_URL, method: 'POST', success: (res) => res.statusCode === 200 ? resolve(res.data.token) : reject(res), fail: reject }); }); } function chatStream({ messages, onMessage, onDone, onError }) { // 先拿临时 token,避免把长期密钥暴露在小程序端 return getToken().then(token => { return new Promise((resolve, reject) => { wx.connectSocket({ url: `${CHAT_WS_URL}?token=${token}`, success: () => { socketTask = wx.getSocketTask(); socketTask.onOpen(() => { // 连接建立后把完整上下文一次性发给服务端 socketTask.send({ data: JSON.stringify({ messages }), fail: (err) => reject(err) }); }); socketTask.onMessage((res) => { try { const parsed = JSON.parse(res.data); if (parsed.type === 'delta') { onMessage(parsed.content); } else if (parsed.type === 'done') { socketTask.close(); onDone(); resolve(); } else if (parsed.type === 'error') { onError(new Error(parsed.message)); reject(new Error(parsed.message)); } } catch (e) { // 解析失败不中断会话,忽略这条消息 console.warn('parse message failed:', e); } }); socketTask.onError((err) => { onError(err); reject(err); }); socketTask.onClose(() => { // 正常关闭与异常关闭统一走这里兜底 }); }, fail: (err) => { onError(err); reject(err); } }); }); }); } function abort() { if (socketTask) { socketTask.close(); socketTask = null; } } module.exports = { chatStream, abort };

逻辑说明:前端不直接调大模型 HTTP 接口,而是先通过 HTTPS 换取一个短期 token,再走 WebSocket 建立持久连接。这样做的好处是——密钥不下发到端侧、连接可复用、服务端可以控制并向客户端转发流式数据。chatStream返回一个 Promise,且通过onMessage回调把增量内容抛给页面。

参数说明:messages必须是[{role, content}]数组,role 只有user和assistant两种取值;onMessage收到的parsed.content是纯文本片段,服务端已经在云端把 SSE 数据解包好了;token有效期建议设置在 10 到 30 分钟之间,有效期太短会导致长对话中途断连,太长则被盗用风险升高。

特别注意一个细节:wx.connectSocket是异步的,回调里wx.getSocketTask()取得的是全局唯一的 socket 任务。并发发送多条消息时,同一时间只允许一个 socket 连接,所以页面里的isStreaming锁是必要的——否则连发多条消息会把 socket 打崩。

3.4 云函数:SSE 转发到 WebSocket 的关键适配层

小程序端无法消费 SSE 数据流,云函数chatProxy就是那个做「SSE → WebSocket」转换的桥梁。以下是 Node.js 侧的核心片段,这部分逻辑通常被当成玄学,其实拆开看就是两个循环的事:

// cloudfunctions/chatProxy/index.js const cloud = require('wx-server-sdk'); const WebSocket = require('ws'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); exports.main = async (event) => { const { messages, token } = event; // 建立到下游大模型 API 的连接 const modelResponse = await fetch('https://api.llm-provider.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.LLM_API_KEY}` }, body: JSON.stringify({ model: 'gpt-4o-mini', messages: messages, stream: true }) }); if (!modelResponse.ok) { return { type: 'error', message: `model api error: ${modelResponse.status}` }; } // 读取 SSE 流并转发到小程序 WebSocket const reader = modelResponse.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE 数据格式:data: {...}\n\n,按空行切分 const lines = buffer.split('\n\n'); buffer = lines.pop(); for (const line of lines) { const payload = line.replace(/^data: /, '').trim(); if (!payload || payload === '[DONE]') continue; try { const parsed = JSON.parse(payload); const delta = parsed.choices?.[0]?.delta?.content; if (delta) { // 转发给小程序 websocket wss.send(JSON.stringify({ type: 'delta', content: delta })); } } catch (e) { // 忽略解析失败的块 } } } wss.send(JSON.stringify({ type: 'done' })); return { type: 'done' }; };

逻辑说明:云函数保持一条到模型 API 的长连接,用getReader()逐块读取响应体,按 SSE 的换行协议切分数据帧,提取delta.content字段后原样推给小程序端。小程序端收到后不做任何加工,直接拼进当前消息的 content 里。

参数说明:stream: true告诉模型接口返回流式数据;[DONE]是厂商约定的结束标记;buffer变量是处理半包粘包的经典手法——网络传输中一次 read 可能只读到半条 SSE 帧,所以先把数据存进 buffer,切分完成后把剩余部分还给下一轮循环。

这里我要多说一句:很多新手在这里翻车,是因为直接把整个 SSE 响应当 JSON.parse,结果在流式场景下永远报错。记住,SSE 是按行分帧的文本协议,不是一次性 JSON 返回。

4. 避坑 / 常见排查:连上了但没流式输出?这五个坑最常踩

4.1 现象:WebSocket 连上了,但消息一直不显示

排查第一步永远是分「前端问题」还是「后端问题」。如果 WebSocket 的onOpen触发了,但 messages 列表一动不动,先在云函数里打印日志,看有没有delta转发记录。

原因通常是消息体格式不对——大模型厂商有些接口要求messages首条必须是system指令,而前端拼的history数组只有 user 和 assistant,导致模型返回空内容或直接报错。解决:在云函数层做兼容,如果messages[0].role !== 'system',就 unshift 一条预设的 system prompt(比如「你是一个乐于助人的助手」)。

4.2 现象:打字机效果一卡一卡,低端机上明显掉帧

小程序 setData 通信是需要序列化和传输的,在低端安卓机上每秒 setData 超过 10 次就会出现肉眼可见的卡顿。

解决思路是节流合并:在appendDelta里不直接 setData,而是把 delta 存进this.pendingBuffer,用wx.nextTick或setTimeout每 100ms 才刷一次界面。牺牲一点点实时性换来流畅度,实际体验反而更好。

4.3 现象:断网重连后消息发不出去,卡在「连接中」

isStreaming被置为true后,如果onError回调没有被正确触发,锁就永远不会释放。wx.connectSocket 在 DNS 解析失败、网络切换时可能既触发 onError 又触发 onClose,或者只触发其中一个。

解决:在onClose里主动把isStreaming置为 false,同时把status: 'pending'的消息标记为error。不要依赖 onError 做为唯一兜底,onClose 才是连接生命周期里的「终态」。

4.4 现象:对话轮次变多后,请求体直接被服务端拒绝

大模型的上下文会随对话轮数线性增长,20 轮对话后 token 数轻松破万。有些大模型接口有单次请求 token 上限(如 4096 或 8192),超了就报Invalid request: maximum context length exceeded。

解决:在云函数侧做上下文截断。常见做法是保留首条 system 指令,然后只取最近 N 条消息(项目里设置为最近 10 条),如果总字符数超过阈值(如 4000 字),用「直接切尾」或「摘要压缩」二选一。

4.5 现象:iOS 上正常,安卓上偶尔收不到最后一条消息

这个坑隐蔽但很容易复现:WebSocket 关闭的时机不对。云函数在reader.read()循环里拿到done: true后就立刻wss.send(done),但此时数据帧可能还在传输通道里,安卓上网络栈调度差异导致收尾帧丢失。

解决:不要读完流立刻发done,延迟 200ms 再发,或者由服务端在发完done后主动等待close握手确认。这个「收尾延迟」的玄学在很多流式场景里都适用。

5. 二次开发:把「能跑的 Demo」改成「能上线的产品」

5.1 消息气泡改造:加入复制、重试与评级

当前消息列表只有纯文本渲染,实际产品里至少要支持三件事:复制某条回复、失败重试、对回复点赞点踩。复制直接用wx.setClipboardData,一行代码的事。重试则需要把status: 'error'的消息恢复成pending,再重放llmService.chatStream。点赞点踩本质上是给消息加feedback字段,完成后通过wx.request上报到后端做数据回收。

// pages/chat/chat.wxml 片段 <view class="msg-item {{item.status}}" id="msg-{{item.id}}"> <text class="role">{{item.role === 'user' ? '我' : 'AI'}}</text> <text class="content">{{item.content}}</text> <view class="actions" wx:if="{{item.status === 'done'}}"> <text bindtap="copyMessage" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询