简介面向需要快速搭建 AI 聊天机器人微信小程序的开发者这份模板基于 HBuilder 编写提供对话界面、消息列表、输入区、基础交互与页面组件等前端结构不含可直接调用的后端接口接口与业务逻辑需自行对接适合具备一定小程序与 HBuilder 基础的开发者二次开发也可用于课程设计、毕业设计或产品原型验证。压缩包共约 2000 个文件、7.04MB以 JavaScript、TypeScript、JSON、Vue、WXML/WXSS 及 Markdown 文档为主涵盖页面逻辑、组件样式、工程配置、依赖说明与使用文档目录层级较完整便于按模块检索和替换。已有 969 人学习可作为 AI 对话类小程序的界面原型与工程起点帮助读者节省搭建基础框架、整理组件结构和理解配置流程的时间并在此基础上接入自有模型服务也可参考其中的页面组织与组件拆分方式。1. 一套 AI 聊天机器人微信小程序模板卡住你的通常不是界面很多人第一次拿到 AI 聊天机器人微信小程序模板第一反应是把 Logo 换掉、把主色调成自己的品牌色然后接上模型接口就以为完事了。真跑起来才发现界面反而是最省事的部分难的是对话页背后的那套数据流消息怎么存、上下文怎么裁、流式回复怎么一段一段喂给视图、切到后台再回来消息还在不在。这套模板解决的正是这段脏活——它把对话页、历史会话、请求封装、上下文裁剪这几块拆开放在不同文件里配置项集中在一个 config 目录换掉接口地址和模型名就能跑通一轮问答。适合两类人一类是想在一两周内做出一个能用的 AI 对话入口的独立开发者另一类是手里已经有后端、只缺一个前端壳子的团队。前提是你得愿意读它的目录结构而不是把它当成一个只改文案的皮。2. 模板骨架拆解四个目录决定你后面改得多快2.1 目录主干与页面注册拿到压缩包解压后先别打开 pages/chat/chat.wxml先看 app.json 和 config。这两处决定了小程序启动哪个页面、导航栏怎么画、接口地址从哪里读。模板的页面注册通常长这样{ pages: [ pages/chat/chat, pages/history/history, pages/setting/setting ], window: { navigationBarTitleText: AI 助手, navigationBarBackgroundColor: #ffffff, navigationBarTextStyle: black, backgroundColor: #f5f6f8 }, style: v2, sitemapLocation: sitemap.json }pages 数组的第一项就是启动页这一点没有商量余地。如果你想把历史会话做成首页把pages/history/history挪到第一位即可。style: v2开的是新版组件样式模板里的按钮、输入框尺寸都是按 v2 调过的关掉它会整体错位别手贱去改。navigationBarTitleText建议在 chat 页用自己的wx.setNavigationBarTitle覆盖因为多会话场景下标题应该跟着当前会话名走写死在 app.json 里反而僵。真正的改动集中在下面这几处# 解压后先看这一层只有 config 和 utils 需要你动 ├── app.js # 全局登录态、基础库版本检查 ├── app.json ├── app.wxss # 主题变量气泡圆角、主色、字号 ├── config/ │ └── index.js # 接口地址、模型名、超时、上下文预算全在这 ├── utils/ │ ├── request.js # 统一请求封装 │ ├── stream.js # 分块接收与 SSE 解析 │ ├── session.js # 会话 ID 与上下文裁剪 │ └── storage.js # 本地缓存读写 ├── components/ │ ├── msg-bubble/ # 单条消息气泡 │ └── typing-dots/ # 等待动画 └── pages/ ├── chat/ # 唯一有复杂逻辑的页面 ├── history/ └── setting/这个划分的好处是职责单一config 里没有逻辑utils 里没有 UIpages 只负责把两者拼起来。坏处也很明显——新手容易在 chat.js 里再写一遍请求导致 utils/request.js 里改的超时时间根本不生效。我一般会在 chat.js 顶部只留一行const { post } require(../../utils/request)凡是出现第二个wx.request就当 bug 处理。还有一处容易漏基础库版本。enableChunked这个流式接收的开关对基础库有要求模板的 project.config.json 里通常会写一个libVersion。如果你要在老设备上跑得在 app.js 里做能力检测而不是假设所有用户都是新版本。2.2 对话页的三层状态消息列表、输入态、连接态chat.js 的 data 是整个模板的心脏。我见过不少二开版本把所有东西塞进一个list数组里结果发送中状态、重试状态、网络错误全混在消息对象上越改越乱。模板的正确姿势是分三层Page({ data: { // 第一层消息列表界面渲染的唯一数据源 messages: [], // [{ id, role, content, status, ts }] // 第二层输入态只影响底部输入区 inputValue: , sending: false, // 第三层连接态控制正在输入动画和重试入口 streaming: false, lastError: }, onInput(e) { // 输入框只改 inputValue不碰 messages this.setData({ inputValue: e.detail.value }); } });三层分开之后很多问题自然消失用户打字时不需要重渲染整个消息列表流式输出时不会因为setData把输入框内容冲掉网络断了只需要把streaming置 false 并写lastError消息本身的status保持failed即可重试时按 id 找回那条消息重发。messages里每条消息的字段建议固定成五个不要随手加字段类型说明idstring时间戳加随机串本地生成用于定位和重试rolestringuser / assistant / systemcontentstring纯文本内容流式时会被不断追加statusstringpending / streaming / done / failedtsnumber毫秒时间戳用于排序和按天分组status是这个结构里最关键的一个字段。没有它你无法区分“这条消息是发出去还没回”和“回了但只回了一半”UI 上也就没法决定是显示转圈、显示光标还是显示重试按钮。很多模板二开之后气泡一直转圈不消失八成是 assistant 消息的 status 忘了从streaming改成done。2.3 把消息气泡做成组件单向数据流别破坏消息气泡看起来简单实际是最容易写崩的地方。用户消息靠右、助手消息靠左、系统消息居中流式时末尾要有个闪烁光标失败时要有重试按钮长按要能复制。这些逻辑如果全写在 chat.wxml 里一个文件能写到八百行。模板的做法是抽成components/msg-bubble// components/msg-bubble/msg-bubble.js Component({ properties: { role: { type: String, value: assistant }, // user | assistant | system content: { type: String, value: }, status: { type: String, value: done }, // streaming | done | failed msgId: { type: String, value: } }, methods: { onCopy() { // 组件内部只读 properties不回写保证数据源唯一 wx.setClipboardData({ data: this.data.content }); }, onRetry() { // 把事件抛给页面由页面决定怎么重发 this.triggerEvent(retry, { id: this.data.msgId }); } } });这里有一条硬规矩组件的 properties 只能读不能写。气泡里点复制、点重试都必须通过triggerEvent抛给 chat 页处理。原因很简单——真正的事实源在 chat 页的messages数组里组件自己改了content页面上的数组还是旧的下次setData一刷就回滚表现为“点了按钮没反应”或者“改了一下又变回去”。这类玄学问题排查起来非常费时间。流式输出时气泡的 content 会被高频更新。模板里通常用status streaming来驱动光标显示用 CSS 动画而不是 JS 定时器避免在低端机上出现动画和 setData 抢主线程的情况。3. 接入对话后端从 wx.request 到分块渲染3.1 鉴权与密钥为什么不能放小程序端先讲一个必须提前说清楚的边界模型的 API Key 绝对不能写在小程序代码里。小程序包是可以被解包查看的任何写进 config/index.js 的密钥等同于公开。模板的默认设计是“小程序只认自己的服务端”也就是你在 config 里填的是一个业务域名服务端再拿着密钥去请求模型。// config/index.js —— 只放可以公开的东西 module.exports { baseUrl: https://your-domain.com, // 你自己的服务端域名需在小程序后台配 request 合法域名 chatPath: /v1/chat, model: your-model-name, timeout: 60000 // 一轮对话的超时流式场景要给足 };timeout 这个值值得单独说。非流式请求一般 20 秒够用但流式输出时首字延迟加上生成时间很容易超过 30 秒。设成 60 秒是常见做法同时服务端那边要有独立的超时控制不能真让它挂一分钟。请求封装统一收敛到 utils/request.js// utils/request.js —— 鉴权、超时、错误码都在这一个地方改 const cfg require(../config/index); function authHeader() { const token wx.getStorageSync(token) || ; return { Content-Type: application/json, Authorization: token ? Bearer token : }; } function post(path, data, extra {}) { return new Promise((resolve, reject) { wx.request({ url: cfg.baseUrl path, method: POST, data, header: Object.assign(authHeader(), extra.header || {}), timeout: extra.timeout || cfg.timeout, success: (res) { if (res.statusCode 200 res.statusCode 300) return resolve(res.data); // 401 单独处理清掉本地 token回到未登录态 if (res.statusCode 401) wx.removeStorageSync(token); reject(new Error(HTTP_ res.statusCode)); }, fail: (err) reject(new Error(err.errMsg || NETWORK_ERROR)) }); }); } module.exports { post };这段代码的关键不在写法而在“只有一个出口”。所有请求都走 post意味着你改超时、加签名、加重试、换域名都只需要动一处。反过来如果哪次排查中发现某个页面的请求没带 Authorization基本可以断定它绕过了这个封装。extra.header这个参数是为流式场景留的后面会用到。401 时清 token 是血泪经验——用户在小程序里挂久了token 过期如果不主动清页面会一直卡在“发送中”状态反复失败用户以为程序坏了。3.2 流式输出enableChunked 与 onChunkReceived这是整个模板里最有技术含量的一段。非流式的对话体验很差用户问一句界面转五秒然后一次性蹦出一大段文字。流式输出把这段等待拆成了逐字出现体感差别巨大。小程序原生的做法是wx.request的enableChunked: true配合onChunkReceived回调// utils/stream.js —— 分块接收对话响应 const cfg require(../config/index); const { decodeChunk } require(./utf8); const { splitSSE } require(./sse); function chatStream({ messages, onDelta, onDone, onError }) { const task wx.request({ url: cfg.baseUrl cfg.chatPath, method: POST, header: { Content-Type: application/json, Authorization: Bearer (wx.getStorageSync(token) || ) }, data: { model: cfg.model, stream: true, // 告诉服务端按 SSE 逐块返回 messages: messages }, enableChunked: true, // 关键开关关掉的话 onChunkReceived 不会触发 responseType: arraybuffer, // 分块数据是 ArrayBuffer需要自己解码 success: () { /* 收到完整响应头不代表内容结束 */ }, fail: (e) onError onError(e) }); task.onChunkReceived((res) { // 1) 二进制转文本注意半字符问题见 3.3 const text decodeChunk(res.data); // 2) 按 SSE 协议拆成一行行 data: const lines splitSSE(text); lines.forEach((payload) { if (payload [DONE]) return onDone onDone(); const delta pickDelta(payload); if (delta) onDelta onDelta(delta); }); }); return task; // 返回 task页面卸载时调 task.abort() }pickDelta是从 SSE 的 JSON 里取出增量文本各家字段名不一样choices[0].delta.content、delta.text都见过这一块按你服务端的实际返回改不要照抄。有三处必须交代清楚。第一success回调在流式场景下不代表内容结束它只是响应头到达真正的结束信号是[DONE]或者连接断开别把onDone写在 success 里。第二onChunkReceived触发时并不保证一次是一行可能半行、可能三行所以必须有行缓冲。第三task一定要在页面onUnload里abort()否则用户退出页面后回调还在跑往一个已销毁的页面 setData控制台会刷一屏警告同时白白消耗流量。流式开关打开之后服务端那边也要配合关掉 gzip 压缩压缩会把分块攒起来、关掉反向代理的缓冲、设置Content-Type: text/event-stream、Cache-Control: no-cache。这几点少一个前端收到的就是一次性的一大坨看起来跟没开流式一模一样。3.3 中文乱码与半行两块必须自己补的拼图上面代码里引了两个工具函数都是血的教训换来的。第一个是 UTF-8 解码的边界问题。一个汉字在 UTF-8 里占 3 个字节而网络分块是按字节切的完全可能在第二个字节处断开。如果你对每个 chunk 单独解码断开的地方就会变成乱码方块。解决方法是保留残余字节等下一块拼上再解// utils/utf8.js —— 保留不完整的 UTF-8 尾巴下一块再拼 let pending new Uint8Array(0); function concat(a, b) { const out new Uint8Array(a.length b.length); out.set(a, 0); out.set(b, a.length); return out; } // 从尾部回退找到最后一个完整字符的结束位置 function safeCut(buf) { for (let i buf.length - 1; i Math.max(0, buf.length - 4); i--) { const b buf[i]; if ((b 0xC0) 0x80) continue; // 10xxxxxx 是续字节继续往前 const need b 0x80 ? 1 : (b 5) 0x06 ? 2 : (b 4) 0x0E ? 3 : 4; return (i need buf.length) ? buf.length : i; // 不够就切在首字节前 } return buf.length; } function decodeChunk(arrayBuffer, flush false) { const merged concat(pending, new Uint8Array(arrayBuffer)); const cut flush ? merged.length : safeCut(merged); pending merged.slice(cut); // 残余留给下一块 return new TextDecoder(utf-8).decode(merged.slice(0, cut)); } module.exports { decodeChunk };safeCut的逻辑是从尾部往回找最多 4 个字节找到第一个“首字节”非 10 开头算出它需要几个字节如果不够就从这个首字节的位置切开。这样每次留给TextDecoder的都是完整序列。TextDecoder在部分基础库环境下不可用模板里通常带一个 polyfill如果你不想引就换成wx.arrayBufferToBase64再自己转但性能会差一些。第二个是 SSE 行缓冲。分块可能把data: {cho和ices:[...]}切成两半直接 split 会解析失败// utils/sse.js —— 半行留到下一块凑齐了再解析 let lineBuf ; function splitSSE(text) { lineBuf text; const parts lineBuf.split(\n); lineBuf parts.pop(); // 最后一段可能是半行塞回缓冲 return parts .map(l l.trim()) .filter(l l.startsWith(data:)) .map(l l.slice(5).trim()) .filter(l l.length 0); } module.exports { splitSSE };lineBuf是模块级变量意味着同一时间只能有一个流在跑。模板默认也是单会话单流如果要做多会话并行得把它挪到闭包里每个 task 一份。这一点在二开时很容易踩表现为两个会话同时回复时内容互相串。4. 会话状态与多轮上下文怎么不串味、不超限4.1 会话 ID 与上下文裁剪策略多轮对话的本质是把历史消息一并发给模型。问题在于历史会无限增长。一个聊了五十轮的会话如果把全部消息都塞进请求体token 数会爆炸响应变慢、费用翻倍最后撞上模型的上下文长度上限直接返回 400。模板的处理方式是“从后往前累加超预算就停”// utils/session.js —— 按字符预算裁剪上下文最近的优先保留 const MAX_TURNS 8; // 最多保留 8 轮问答 const MAX_CHARS 6000; // 字符预算按你的模型和费用自行调 function buildContext(messages, systemPrompt) { // 1) 过滤掉还没成功、或者本身就是系统提示的消息 const usable messages .filter(m m.status done m.role ! system) .slice(-MAX_TURNS * 2); // 2) 从最新往回累加超预算立刻停 let total systemPrompt.length; const kept []; for (let i usable.length - 1; i 0; i--) { total usable[i].content.length; if (total MAX_CHARS) break; kept.unshift(usable[i]); } // 3) 裁完之后如果第一条是 assistant去掉它保证 user 开头 while (kept.length kept[0].role assistant) kept.shift(); return [ { role: system, content: systemPrompt }, ...kept.map(m ({ role: m.role, content: m.content })) ]; } module.exports { buildContext };从后往前累加是关键。如果从前往后你会保留最早的一堆开场白把最近几轮真正相关的上下文丢掉表现就是“聊着聊着它就忘了刚才说什么”。第 3 步的首条对齐同样重要不少模型服务端要求消息序列以 user 开头、user 和 assistant 交替出现中间有一对不匹配就可能直接报 400。MAX_CHARS这个值不要拍脑袋。粗略换算中文一个字大约 11.5 个 token6000 字符大约三四千 token加上 system prompt 和输出预算多数模型都能兜住。如果你的模型上下文窗口更大可以往上调但费用是按实际消耗算的调之前先算一笔账。会话 ID 建议在新建会话时本地生成格式用s_${Date.now()}_${随机6位}同时带上服务端返回的会话标识如果有。本地 ID 用于前端定位和缓存 key服务端 ID 用于查日志两者分开存别指望一个字段包打天下。4.2 本地缓存的读写与容量控制微信小程序给了本地缓存但有明确上限单个 key 最大 1MB所有 key 加起来 10MB。听起来不小可聊天记录是纯文本堆积一个长会话几万字符很常见二十个会话很容易顶到边界。模板的 storage 封装做了两件事分 key 存和只留最近 N 个会话。// utils/storage.js —— 一个会话一个 key索引单独存 const INDEX_KEY chat_session_index; // 只存 [{id, title, ts}] const MAX_SESSIONS 20; // 超出就淘汰最老的 const MAX_MSGS_PER_SESSION 200; // 单会话消息条数上限 function saveSession(session) { // 1) 单会话截断别让一条会话撑爆单个 key const msgs session.messages.slice(-MAX_MSGS_PER_SESSION); wx.setStorageSync(chat_ session.id, { ...session, messages: msgs }); // 2) 维护索引按时间倒序 let index wx.getStorageSync(INDEX_KEY) || []; index index.filter(i i.id ! session.id); index.unshift({ id: session.id, title: session.title, ts: Date.now() }); // 3) 超出上限的会话连数据带索引一起清理 while (index.length MAX_SESSIONS) { const dropped index.pop(); wx.removeStorageSync(chat_ dropped.id); } wx.setStorageSync(INDEX_KEY, index); } module.exports { saveSession };索引和数据分开存是因为历史列表页只需要 id、标题和时间不需要把每个会话的全部消息都读出来。如果全塞在一个大 key 里打开历史页就要反序列化几 MB 的 JSON低端机上直接卡住。这个坑我在别的小程序上吃过——列表页滚动掉帧排查了半天才发现是读缓存读的。MAX_MSGS_PER_SESSION设为 200 是个折中够用户翻回去看又不至于让单个 key 逼近 1MB。真要归档长会话正确做法是存到服务端本地只留最近的部分。4.3 长消息渲染别硬啃 MarkdownAI 回复里经常带列表、加粗、代码块。模板默认按纯文本渲染text换行用\n加white-space: pre-wrap简单但够用。如果你想渲染 Markdown要注意小程序不支持直接用v-html那一套需要引入towxml之类的解析库把 Markdown 转成节点树再渲染。代价是渲染成本。一段两千字的回复从流式追加到节点树重建每次都重算一遍低端机上会明显卡顿。我的建议是流式过程中按纯文本渲染等status变成done之后再转成富文本节点只转一次。这样既保住了输出时流畅的逐字效果又能在结束后显示漂亮的排版。代码块是另一个小坑。小程序里等宽字体支持不完整font-family: monospace在部分安卓机型上会退化成默认字体代码对不齐。常见做法是显式写上一串候选字体再配合overflow-x: auto让长代码行可以横向滚动不要指望它自动换行——换行之后的代码块基本没法看。5. 避坑与排查真机上跑不通的五类高频问题5.1 先看现象五类故障的快速分诊模板在开发者工具里跑得好好的一上真机或者一接自己的后端就出问题这是常态。下面这张表是我整理的分诊清单先按现象对号入座再往下看具体原因。现象高概率原因先去查回复一次性蹦出来没有逐字效果enableChunked 没开 / 服务端有缓冲或压缩stream.js 与响应头中文里夹杂方块或问号分块切断多字节字符utf8.js 的残余缓冲聊到二三十条后卡顿或报错setData 数据量过大chat.js 的更新方式请求直接失败提示域名不合法未配置 request 合法域名小程序后台与开发工具设置越聊越慢最后返回 400上下文没裁剪超模型上限session.js 的预算参数5.2 流式不生效与中文乱码现象本地开发者工具里逐字输出正常真机上要等五六秒然后整段出现。或者反过来出现一堆锟斤拷式的乱码中间偶尔夹着几个正常汉字。原因第一种情况多半是enableChunked没打开或者服务端那条链路上有东西在攒数据——gzip 压缩是最常见的其次是反向代理的响应缓冲。前端这边看起来什么都没错数据就是一次性到齐的。第二种情况是 UTF-8 边界问题网络分块刚好切在汉字中间对每块单独解码就会出乱码。解决流式这边先确认三处——enableChunked: true写了没有、responseType是不是arraybuffer、服务端是不是text/event-stream且关了压缩和缓冲。验证方法很简单在onChunkReceived里打一行日志看回调触发了几次只触发一次就说明是服务端在攒。乱码这边把 3.3 里的decodeChunk换上确保残余字节有缓冲如果还有零星乱码检查TextDecoder的fatal选项设成 true 会直接抛错反而更容易定位。还有一个隐蔽的情况onChunkReceived在某些安卓机型上会因为页面切后台而暂停回调用户切回来时收到的是一大块。这不是 bug是系统行为处理办法是在onHide时保留 task 但标记状态onShow时如果内容已经完整就直接刷一次不要再等流。5.3 setData 卡顿与存储超限现象刚开始聊很流畅聊到二三十轮之后每次发送都要卡一下输入框打字有延迟。严重的时候控制台报setData 数据量过大或者直接白屏。原因每次流式增量都调this.setData({ messages: 整个数组 })数组越大序列化和跨线程传输的开销越大。小程序的数据是从逻辑层传到视图层的一次传几百 KB 的 JSON低端机上就是肉眼可见的卡。解决只更新变化的那一条用路径写法让框架自己做差量// 错误写法每次都把整个数组扔过去 this.setData({ messages: this.data.messages }); // 正确写法只更新第 i 条的 content 字段 const i this.data.messages.length - 1; this.setData({ [messages[ i ].content]: this.data.messages[i].content, [messages[ i ].status]: streaming });路径写法只传那一个字段数据量小一个数量级。另外流式追加不要每来一个字符就 setData 一次用一个 3050ms 的节流攒一小段再刷肉眼完全看不出差别主线程压力小很多。存储这边单个 key 超过 1MB 会直接写入失败但wx.setStorageSync不一定抛异常可能只是静默失败表现为“重启小程序历史记录没了”。所以MAX_MSGS_PER_SESSION这个上限一定要留并且在写入前后做一次长度判断超了就截断别指望它自己兜住。5.4 域名、鉴权与上下文超限现象开发者工具里一切正常扫码到真机上点发送就失败提示“不在以下 request 合法域名列表中”。原因开发者工具的“详情 → 本地设置”里有个“不校验合法域名”的勾选项勾上之后本地随便请求。真机没有这个开关必须在小程序后台把baseUrl的域名加进 request 合法域名而且要求是 HTTPS、已备案、不能带端口。解决把域名配好之后还要注意两个细节。一是域名变更后需要重新编译并重新扫码热更新不生效。二是如果你的接口走了 CDN 或者有多个子域名每一个都要单独加通配符是不支持的。鉴权这块的坑在于 token 过期。表现为挂机半小时后发消息一直转圈然后失败重进小程序又好了。原因就是 token 失效但本地没清请求带着过期 token 反复被拒。解决办法是在 request 封装的 401 分支里清 token 并触发一次静默登录登录成功后再重放原请求一次只重放一次避免死循环。上下文超限的表现更隐蔽不是报错而是模型开始答非所问或者回复被截断。真报 400 的时候错误信息里通常带context_length_exceeded之类的字样。这时候别急着调大服务端的限制先回头看MAX_CHARS这个预算是不是设太大了以及有没有把失败的、空的 assistant 消息也一起塞进去了——buildContext里那个status done的过滤条件就是干这个用的。6. 上线前的验证把模板改成能交付的版本模板能跑通和能交付中间还差一份自检。我一般会在提审前把下面这几项挨个过一遍每项都对应一个具体的观察点不是走形式。检查项怎么验通过标准流式首字延迟真机 4G 下连发三条首字 2 秒内出现弱网表现开发者工具切“弱网”模式有加载态不白屏不卡死断网重连飞行模式开关一次有错误提示和重试入口长会话稳定性连续对话 30 轮无卡顿、无 setData 报错切后台恢复输出中按 Home 再回来内容不丢状态正确冷启动历史杀掉进程重进历史会话完整可读这张表里最容易被跳过的是“切后台恢复”。开发者工具模拟不了真实的生命周期必须真机操作。我在这个点上翻过车流式输出到一半切后台回来之后光标一直在闪但内容永远停在那里因为onChunkReceived的回调被挂起了而页面的streaming状态还是 true。修复方式是在onHide里记一个时间戳onShow时如果超过一定时间还没收到[DONE]就主动 abort 并把消息标成failed给用户一个重试的机会而不是让他对着一个永远转圈的光标干等。还有一个技巧值得单独提给[DONE]加一个兜底。正常情况下服务端会发这个结束标记但网络抖动时可能丢。我的做法是在chatStream里起一个 90 秒的定时器超时未收到[DONE]就当作异常结束把已收到的内容保留、状态置为done但标记truncated。用户看到的是“回复可能不完整”比一直转圈体面得多。定时器记得在正常结束时清掉不然会误伤下一轮。最后是数据落地的习惯。模板默认把会话存本地但真正上线时我建议加一个“同步到服务端”的可选开关本地只做缓存和快速回显服务端做持久化和跨端同步。这样用户换手机、清缓存都不至于把记录丢光而前端也不至于被存储上限卡住。开关默认关愿意开的人自己开成本可控。从那以后我每次交付这类对话小程序都会强制走一遍“断网—切后台—长会话—冷启动”这条链路一个都不跳。这四个场景覆盖了绝大部分线上投诉提前十分钟跑完比上线后半夜被叫起来强。希望帮到你。本文还有配套的精品资源点击获取