news 2026/10/31 1:13:53

AI聊天机器人微信小程序模板:流式输出与上下文裁剪

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI聊天机器人微信小程序模板:流式输出与上下文裁剪

简介:面向需要快速搭建 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 里没有 UI,pages 只负责把两者拼起来。坏处也很明显——新手容易在 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 / system
contentstring纯文本内容,流式时会被不断追加
statusstringpending / streaming / done / failed
tsnumber毫秒时间戳,用于排序和按天分组

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这个值不要拍脑袋。粗略换算,中文一个字大约 1~1.5 个 token,6000 字符大约三四千 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 长消息渲染:别硬啃 Markdown

AI 回复里经常带列表、加粗、代码块。模板默认按纯文本渲染<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 一次,用一个 30~50ms 的节流,攒一小段再刷,肉眼完全看不出差别,主线程压力小很多。

存储这边,单个 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。用户看到的是“回复可能不完整”,比一直转圈体面得多。定时器记得在正常结束时清掉,不然会误伤下一轮。

最后是数据落地的习惯。模板默认把会话存本地,但真正上线时我建议加一个“同步到服务端”的可选开关:本地只做缓存和快速回显,服务端做持久化和跨端同步。这样用户换手机、清缓存都不至于把记录丢光,而前端也不至于被存储上限卡住。开关默认关,愿意开的人自己开,成本可控。

从那以后我每次交付这类对话小程序,都会强制走一遍“断网—切后台—长会话—冷启动”这条链路,一个都不跳。这四个场景覆盖了绝大部分线上投诉,提前十分钟跑完,比上线后半夜被叫起来强。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 1:13:31

香橙派 RK3588 双路 yolov5s 可观测 stats:量化每一帧性能

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/31 1:11:48

机器人主控选型:瑞芯微RK3588/3576/3568如何优化BOM成本?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/31 1:11:33

EMC预测试超标频点反推与Layout整改实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/31 1:10:44

自定义黑通道协议实战:帧格式设计、CRC校验与C语言实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/31 1:10:44

RK3588 YOLOv5s:C++重写NMS,后处理加速359倍

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/31 1:10:44

一键开关机芯片选型指南:四大维度避坑与实战验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华