
你有没有发现做AI问答类应用最终比拼的往往不是模型本身而是产品体验。同样一个大模型接口有人做出来像命令行工具有人做出来却像一个完整的沉浸式助手。差别就藏在一套技术方案里用 Vue UniApp 这种跨端栈时Markdown 渲染怎么做、流式输出怎么不卡、图片语音等多模态交互怎么组织、H5/小程序/App 三端差异怎么抹平。这篇文章会把我从零搭建一个智能 AI 问答助手的完整过程写出来包括架构设计、核心代码、踩过的坑和最终验证过的方案。项目的定位一句话说清基于 UniApp 构建一套可同时编译到 H5、微信小程序和 Android/iOS App 的 AI 问答助手支持 Markdown、数学公式、代码高亮支持图片、语音、链接卡片等多模态消息并且带会话持久化、流式打字机效果和全端打包上线的完整能力。如果你是前端开发或者正准备在 UniApp 里接入大模型接口这篇应该能帮你省下不少试错时间。1. 聊天助手谈“沉浸式”不是形容词是一组技术约束很多人一听到“沉浸式”觉得是玄学做技术的人容易忽略体验背后对应的硬性指标。我最初做这个项目时需求方只给了一句话能不能做一个像 ChatGPT 一样好用的企业问答助手这句话展开之后就变成了一堆具体问题每一个都是技术活。1.1 沉浸式体验分解成的技术指标我把“好用”拆成了五个维度每个维度都对应前端要解决的具体问题内容呈现消息不只是纯文本而是 Markdown 富文本包含标题、列表、表格、代码块、数学公式。普通聊天组件根本不能满足这种渲染要求。交互反馈AI 回复必须像打字机一样流式出现用户能看到“正在思考”的过程随时可以停止生成也能重新生成上一次回复。多模态内容用户发的不只有文字还有图片、语音、链接AI 回复里也可能包含视频卡片、文件卡片、可点击的引用链接。状态保持杀进程、切后台、重新打开会话上下文不能丢输入框里没发出去的草稿也得能恢复。视觉沉浸全屏对话列表、暗黑模式、底部安全区适配、键盘弹起时输入框不被遮住、代码块颜色随主题切换。如果只做一个小程序 demo这些需求可能不需要全部实现。但既然定了全端覆盖H5、微信小程序、App 三端的接口能力、渲染机制、权限规则都不一样沉浸式就成了一个需要逐端验证的工程问题不是一个样式上的追求。1.2 全端能力差异为什么不能只写一套逻辑就完事下面这个表格是我在项目启动阶段整理的它基本决定了后续所有技术选型。能力项H5 端微信小程序端App 端流式请求fetch ReadableStream 可用uni.request 开启 enableChunked onChunkReceived原生请求可用但部分低版本 WebView 受限富文本渲染v-html 可用但不安全需净化rich-text / mp-html操作受限制WebView 方案或原生富文本组件Markdown 解析可直接操作 DOM库选择多无 DOM需要预解析为 HTML 字符串取决于 WebView 还是 nvue录音浏览器录音权限兼容性参差微信录音接口需要答权原生录音权限Android/iOS 配置不同获取定位需 HTTPS公众号内还要 JS-SDK 签名需在小程序后台配置接口原生定位权限分享公众号 JSSDK 分享小程序 onShareAppMessage 自定义分享原生分享或拉起微信小程序扫码基本无可用方案uni.scanCode原生扫码插件这个表说明一件事UniApp 虽然号称一套代码多端运行但落到具体功能时条件编译和平台分支是逃不掉的。我的原则是“核心逻辑一套平台差异收口到独立模块”。比如流式请求、Markdown 渲染、录音转写这三块都单独封装各端只提供自己的实现上层完全不用关心是哪个端在跑。2. 技术选型的取舍Vue3、UniApp 与大模型接口的边界划分选型的时候不是没考虑过 Flutter 和 React Native。Flutter 的渲染一致性好但团队 Vue 背景深招人、写业务、接 UI 组建库都更快React Native 更偏 App遇到微信小程序还是得另起炉灶。UniApp 最大的价值在小程序端——微信小程序不用单独维护一套代码这一点对很多中小团队来说是决定性的。确定 Vue3 后组合式 API 写聊天这种状态驱动型业务很舒服加上 Pinia 做状态管理比 Vue2 的 options API 清晰多了。2.1 大模型接口层的统一封装AI 助手的核心是对话接口大模型厂商再不同接口风格现在基本收敛到了 chat/completions 这种形态返回内容有一次性返回和 SSE 流式返回两种。沉浸式体验必须用流式否则用户盯着一个 loading 转圈几十秒体验直接崩塌。各端流式请求的差异我统一收口在一个requestStream模块里。核心接口长这样// api/stream.js export async function requestStream({ messages, signal, onChunk }) { // #ifdef H5 const resp await fetch(YOUR_API_BASE/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ model: your-model, messages, stream: true }), signal }) const reader resp.body.getReader() const decoder new TextDecoder() while (true) { const { done, value } await reader.read() if (done) break const text decoder.decode(value, { stream: true }) onChunk(parseSSEChunk(text)) } // #endif // #ifdef MP-WEIXIN const task uni.request({ url: YOUR_API_BASE/chat/completions, method: POST, enableChunked: true, header: { Content-Type: application/json, Authorization: Bearer ${token} }, data: { model: your-model, messages, stream: true }, success() {} }) task.onChunkReceived((res) { const text new TextDecoder().decode(new Uint8Array(res.data)) onChunk(parseSSEChunk(text)) }) // #endif }别急着抄有几点必须说明第一parseSSEChunk要处理多行 data 拼接和空行分隔因为后端返回的 SSE 数据不是每次恰好一个完整 JSON经常拆成两半。我自己踩过这个坑调试半天以为是网络问题其实是解析状态机没写对。第二H5 端终止请求可以直接用AbortController小程序端不支持需要手动接管task.abort()。所以停止生成按钮的逻辑也要按端封装。第三不要在客户端硬编码大模型的 API Key。规范做法是请求打到你自己的后端服务由后端转发给模型服务客户端只拿着登录态。前端硬编码 Key 的后果就是小程序代码包被人一解包就泄露别问我是怎么知道的。2.2 消息状态管理为什么不能只用 ref 数组聊天页面最直观的状态是消息列表新手很容易写一个ref([])就往里 push。但一旦加入多会话、历史恢复、消息状态发送中/已发送/失败/停止生成/正在输入这些维度裸数组根本撑不住。我用 Pinia 维护三个核心 store// store/chat.js export const useChatStore defineStore(chat, { state: () ({ currentSessionId: , sessions: [], // 会话摘要列表 messages: [], // 当前会话的消息 streaming: false, // 是否正在流式输出 draft: {} // 各会话的输入草稿key 是 sessionId }), getters: { currentMessages: (state) state.messages }, actions: { async sendMessage(content, type text) { /* ... */ }, async stopStream() { /* ... */ }, async switchSession(id) { /* 切会话时保存当前草稿再加载目标会话 */ } } })为什么要把草稿和消息分开存因为用户经常在输入框打一半就切换会话草稿跟着切换不留痕地丢失的话用户会骂人。draft这个字典保证每个会话都有自己的临时内容切回来还在。另外messages用的是普通数组而不是reactive深层代理也是性能考虑聊天消息结构深、数量大深层代理的递归开销在这种高频更新场景下能感知出来。3. 会话页核心渲染Markdown、公式、代码高亮的跨端方案这是整个项目里最需要耐心的一环。聊天内容里什么都有普通文本、列表、表格、代码块、行内公式、块级公式混在一起。指望一个组件通吃三端目前没有只有取舍。3.1 为什么不能直接把 Markdown 字符串用 v-html 渲染直觉做法是前端装一个 marked.js 把 Markdown 转成 HTML然后 H5 端v-html输出小程序端用rich-text输出。这套方案 demo 跑通很快但有两个硬伤第一XSS 安全。AI 生成的内容不可控如果内容里夹带script或者事件属性v-html直接执行。虽然很多情况下模型不会主动输出恶意脚本但用户可能故意粘贴一段危险内容让模型复述一旦复述出来你的页面就跟着遭殃。第二样式不好控制。v-html出来的 HTML 里pre、code、table的样式会受全局样式影响代码高亮、表格边框、暗黑模式适配全靠一大坨 CSS 硬盖写起来很痛苦小程序端的rich-text还有节点数量限制内容一长就崩。3.2 我的方案预处理为 HTML 再交给 mp-html经过几轮对比我选了“Markdown 解析统一在后端或前端预解析服务中完成输出带样式的 HTML 片段再交给跨端富文本组件展示”这条路。具体做法是后端返回的原始消息分两部分存content是纯 Markdown 原文rendered是解析好的 HTML 字符串。前端展示时优先用rendered需要重新按主题渲染时再从content解析。富文本展示组件选 mp-html它同时支持 H5、小程序能处理本地图片、链接点击事件代码高亮和公式都有扩展能力。mp-html :contentmessage.rendered :themetheme linktaponLinkTap /为什么不自研渲染器因为聊天消息的富文本种类太多了自研意味着要处理 Markdown 的完整规范、表格合并、代码块高亮、公式、嵌套列表工程量至少按周算。mp-html 成熟稳定社区活跃遇到 bug 还能改源码。它唯一让我不舒服的是体积有点大但用分包加载之后可以接受。3.3 公式渲染KaTeX 需预渲染成 HTML公式是最麻烦的。H5 端可以直接引入 KaTeX 的 JS 在浏览器里算但小程序没有 DOM直接跑 KaTeX 会报错。我最后用的方式是Markdown 解析阶段先拿到全文用 KaTeX 的renderToString把$...$和$$...$$部分渲染成 HTML 片段再拼回到整个 HTML 里交给 mp-html。import katex from katex function renderFormula(math, displayMode) { try { return katex.renderToString(math, { displayMode, throwOnError: false, output: html }) } catch (e) { return code${math}/code } }这里注意两个坑一是$符号的解析时机。Markdown 里代码块的$不应该被当公式解析所以必须先分割出代码块对代码块跳过公式处理其他部分再识别行内公式。我用 marked 的扩展机制在渲染 token 阶段做替换能解决大部分情况但遇到反斜杠转义还是会漏测试用例得备足。二是 KaTeX 的字体文件在小程序端会被打成 base64体积瞬间膨胀。我最后把公式服务做成了按需加载只有消息内容里确实出现$符号时才触发公式渲染否则不加载 KaTeX首页性能提升很明显。3.4 代码高亮与明暗主题切换代码块在问答场景出现频率极高高亮是刚需。H5 端用 highlight.js 没问题小程序端同样因为无 DOM 的原因我采用预渲染思路在解析 Markdown 时就把代码块的 token 传给 highlight.js 的highlight方法生成的 HTML 里带上语义 class再引入配套 CSS 主题。暗黑模式切换时代码块背景、关键字颜色、字符串颜色都需要跟着变。我之前一直用两套 highlight.js CSS 主题文件切换虽然能用但切换瞬间会有轻微闪烁。后来优化成 CSS 变量方案在主题 CSS 里把关键颜色抽成变量.hljs { background: var(--code-bg, #f6f8fa); color: var(--code-text, #24292f); } .hljs-keyword { color: var(--code-keyword, #cf222e); }这样暗黑模式只需切换几个 CSS 变量不用整份替换样式表体感好很多。4. 多模态交互图片、语音、媒体消息的数据流设计“多模态交互”听起来高大上落到代码层面其实就是消息体要支持多种类型并且每种类型都要能进大模型上下文。我最终定了一套消息结构后面所有功能都从这套结构延伸出去。4.1 消息类型的统一数据结构// types/message.js export const MessageType { TEXT: text, MARKDOWN: markdown, IMAGE: image, AUDIO: audio, VIDEO: video, FILE: file, CARD: card } // 一条消息对象 { id: msg_001, role: user, // user / assistant / system type: image, // MessageType content: https://cdn.xxx.com/a.jpg, // 根据类型不同可能是文本或资源地址 markdown: , // 当 typemarkdown 时保存原始 Markdown rendered: , // 当 typemarkdown 时保存渲染好的 HTML meta: { fileName: a.jpg, fileSize: 102400, width: 800, height: 600, duration: 0 // 音视频时长 }, status: success, // sending / success / failed / stopped createdAt: Date.now() }我把type单独拉出来而不是靠content后缀判断是因为后续展示卡片、下载、预览都依赖类型分发用后缀判断会写出一堆 if-else。渲染层根据type映射到不同组件清爽很多。4.2 图片上传临时路径不能直接持久化图片这一步的坑主要在跨端路径管理上。uni.chooseMedia返回的临时路径在 H5 端是 blob URL在小程序端是 wxfile:// 开头的本地文件路径在 App 端又是一个本地缓存路径。这些路径离开当前会话就失效所以你如果敢把临时路径存进会话历史下次打开铁定是裂图。正确的姿势是选完图之后立刻上传到对象存储或你自己的服务器拿到永久 URL 再存进消息对象// 图片消息发送流程 async function sendImage() { const res await uni.chooseMedia({ count: 1, mediaType: [image], sizeType: [compressed], sourceType: [album, camera] }) const tempFilePath res.tempFiles[0].tempFilePath const { url } await uploadFile(tempFilePath) // 上传得到永久 URL const message { id: genId(), role: user, type: image, content: url, status: sending } chatStore.addMessage(message) await requestAI([...chatStore.messages, message]) }上传时机有两种选择用户选中图片立刻传还是用户点发送再传。我各试过一遍最终选了“选中立刻传”。原因是用户点发送的时候往往还带着文字如果这时再串行上传图片用户体感卡顿明显提前上传后发送消息时图片已经是 URL整个发送流程丝滑很多。代价是用户放弃发送时会残留孤儿文件需要一个定时清理策略。权衡之下这比等待上传更能提升沉浸感。4.3 语音输入录音权限与格式差异语音输入用了uni.getRecorderManager()这个 API 三端都有但细节差异很大。App 端录制的是 m4a 格式微信小程序端是 mp3H5 端在很多浏览器里需要先用navigator.mediaDevices获取麦克风权限。如果要做语音转文字后端一定要同时兼容 mp3 和 m4a 两种格式转写服务只认一种会导致 App 能用、小程序报错这种诡异现象。另一个容易忽略的是权限申请时机。热词里提到“能不能实时监听权限申请框的出现和消失”实际做的时候不要监听申请框正确的做法是提前在用户点击语音按钮前就申请权限失败的时候给出明确的引导文案。iOS 如果第一次拒绝之后只能去设置页手动打开这个引导文案写得细致一点能救回不少用户。async function requestRecordPermission() { // #ifdef APP-PLUS const result await new Promise((resolve) { plus.android.requestPermissions( [android.permission.RECORD_AUDIO], (res) resolve(res.granted), (err) resolve(false) ) }) if (!result) { uni.showModal({ title: 需要麦克风权限, content: 请在系统设置中允许使用麦克风否则无法发送语音消息, confirmText: 去设置, success: () uni.openAppAuthorizeSetting() }) return false } // #endif return true }音频上传后我会额外生成一个时长字段放进meta.duration前端在语音卡片上展示时长长按可以播放。播放器用uni.createInnerAudioContext()注意 App 端模拟器和真机的 audio 路径解析规则不一样真机必须用绝对路径这个调试的时候能折腾一晚上。4.4 链接卡片与流媒体m3u8支持聊天里用户经常直接贴一个链接我希望它自动变成好看的卡片而不是一坨灰色 URL 字符串。实现方式是在发送文本时做一次 URL 识别发现以 http/https 开头的文本段就去请求一个后端接口获取卡片的标题、描述和缩略图然后把这条消息的类型变成card。AI 回复里如果贴了视频地址或 m3u8 流地址我用video组件内嵌播放配合 poster 图沉浸感不错。Vue 项目里播放 m3u8 通常要引入 hls.js但小程序原生video对 m3u8 的支持比 H5 还好所以这里我给小程序和 App 用原生组件H5 端才动态加载 hls.js按条件编译处理。4.5 多模态内容如何送进大模型上下文多模态消息发送给大模型时不能直接把 HTML 内容丢给它。字符数有限制token 会爆。我的做法是发消息时构造一个精简上下文数组图片消息只保留{ type: image_url, image_url: { url } }语音消息先转成文字再以文本方式提交文件卡片只提取文件名和摘要。这样大模型既能理解用户发了什么又不会因为一张大图 base64 撑爆上下文窗口。5. 会话持久化与历史记录Storage 乱象与数据结构建模聊天应用不做持久化等于白做。用户清掉小程序或 App 缓存后历史会话全没了信任感瞬间归零。这一节说说我是怎么设计存储的以及为什么不能无脑把全部消息塞进一个 Storage key。5.1 各端存储容量与坑位端主力存储容量限制注意点H5localStorage通常 5MB隐私模式可能不可写微信小程序wx.setStorageSync单 key 最大 1MB总上限 10MB连续 setStorage 有频率问题Appuni.setStorageSync无严格统一限制但也会随平台变化nvue 与 vue 存储不完全互通如果你直接把所有历史消息放进一个 key几轮长对话下来就会顶到小程序单 key 1MB 的上限。我最初就是这么干的结果崩溃得莫名其妙后来加了个异常上报才发现是存储溢出了。5.2 我的存储策略session 维度 分段裁剪思路是把“会话列表”和“历史消息”分开存并且给每个会话单独一个 key// store/persist.js const SESSION_KEY chat_sessions_v1 function saveSessionMeta(session) { // 只存会话的封面信息标题、最后一条消息摘要、时间戳、消息条数 } function saveMessages(sessionId, messages) { // 每个会话的消息单独存一个 key: chat_messages_${sessionId} const key chat_messages_${sessionId} // 按 token 估算裁剪超过 200 条就先截断旧消息只保留最近 150 条 const trimmed trimMessages(messages, 150) uni.setStorageSync(key, JSON.stringify(trimmed)) }裁剪不是简单丢消息。丢掉的消息我会在后端留一份完整记录本地存储只做“快速恢复最近会话”用。用户想看特别早的历史消息走接口拉取。这个设计在云同步场景下其实更合理本地不需要做整个宇宙的备份。5.3 草稿箱、会话恢复和 Markdown 图片路径输入框草稿的存储我采用“一体化恢复”策略进入页面时同时读会话消息和draft把未发送内容塞回输入框。这个动作放在页面onShow里而不是onLoad因为小程序从后台切回前台时onLoad不会触发onShow才可靠。热词里提到“markdown 图片路径”这里也有个相关坑如果用户粘贴了 Markdown 里的本地图片路径比如这个路径在别的端打不开恢复会话时会裂图。我的处理是在消息入库前把 Markdown 里的所有本地路径替换成上传后的 URL形成一个干净版本再存。这样会话恢复后图片永远能显示代价是资源会有冗余上传但这是保证多端可用的最稳方案。6. 全端适配与打包上线H5、小程序、App 的实战排坑到了这里才是真正考验耐心的地方。一套代码多端编译本地开发三端各自跑看起来差不多了一打包上线就花式报错。这块的热搜词也最多我挑几个最有代表性的展开。6.1 manifest.json 配置与启动页修改UniApp 项目的 manifest.json 是绕不开的。微信小程序端要填真实 AppID才能用自定义分享、获取定位等能力App 端要在“App 模块配置”里勾选需要的原生模块比如地图、定位、音视频漏勾一个对应 API 就直接报错“未添加该模块”。默认启动页白屏或者闪一下 HBuilderX 的默认图观感很差。修改方式有两个层级简单做法是在 manifest.json 的app-plus节点配置splashscreen替换启动图进阶做法是做一个自定义首页 loading 页先展示品牌 logo 和加载文案等核心数据准备完成再进入主界面。热词里“修改刚进入的加载页面”说的就是这个场景。我的实现是首页组件onLoad时展示 loading 状态等到会话列表从 Storage 读出来并且大模型配置检查通过后再切到真正的聊天界面。这样用户从点击图标到开始提问视觉上是连贯的。6.2 H5 嵌入微信公众号获取定位H5 端发版后如果要在微信公众号里打开uni.getLocation会直接失败因为公众号网页需要调用微信 JS-SDK 并完成签名。签名需要后端用jsapi_ticket配合当前页面 URL 生成前端拿到location开关权限后再调用uni.getLocation。// #ifdef H5 async function ensureWxSdk() { const url encodeURIComponent(location.href.split(#)[0]) const { appId, timestamp, nonceStr, signature } await getWxJsSdkSignature(url) return new Promise((resolve, reject) { wx.config({ debug: false, appId, timestamp, nonceStr, signature, jsApiList: [getLocation] }) wx.ready(() resolve()) wx.error((err) reject(err)) }) } // #endif签名接口的生命周期很短并且必须用当前页面的完整 URL很多后端会忽略location.href.split(#)[0]这一步导致 SPA 路由切了几次之后签名失效。我第一次接的时候签名一直失败最后发现就是 URL 不匹配的问题折腾了三个小时换来的教训。6.3 微信小程序分享好友、从App拉起小程序、扫码小程序端分享走onShareAppMessage自定义分享卡片标题、图片和路径。这里有个容易踩的坑分享路径里带参数时参数中的特殊字符要先encodeURIComponent否则某些端会把参数截断。我分享的是某个具体会话就把sessionId包装进路径参数用户点开分享卡片恢复到指定会话。从 App 端拉起微信小程序需要用到plus.share或者集成微信 SDK用plus.oauth拿到微信授权后再调用拉起能力。这块不是纯前端能完成的需要到 uni_modules 里拉一个“微信分享登录”插件然后配置微信开放平台账号。我一开始以为 App 内可以随便跳小程序实际必须满足“App 与小程序同一微信开放平台账号”等条件文档读两遍少走弯路。扫码功能比较简单uni.scanCode()直接调原生扫码拿到的结果按业务处理。我把它用在了“扫描二维码加入团队会话”“扫码关联设备”两个场景上。注意小程序端uni.scanCode只能扫小程序码和普通二维码H5 端在普通浏览器没有这个能力需要引入第三方扫码库。6.4 打包安卓、iOS 与上架应用市场的流程要点打包这块热词里很集中我按实际流程梳理一遍。Android 打包在 HBuilderX 里选择“发行 - 原生App-云打包”配置 Android 证书keystore填写包名和版本号。上架应用市场前要准备好隐私政策页面、权限使用说明并且做一次加固。市场审核时会重点检查权限是否和功能匹配比如你申请了短信权限却只有聊天功能大概率会被拒。我项目的权限列表最后精简到了麦克风、相机、相册、定位、网络每一项都对应实际功能过审顺利很多。iOS 打包需要苹果开发者账号生成证书和描述文件云打包时填入。iOS 的权限描述字符串必须在 Info.plist 里写清楚例如麦克风权限的用途描述要具体到“用于语音输入与AI对话”写得太模糊审核人员会打回。iOS 审核对 AI 类应用还强调内容过滤聊天里要能过滤敏感词、举报内容用户协议和隐私政策要放到 App 内可访问我在设置页做了二级入口审核没在这个点上卡过。6.5 Vue2 转 Vue3 与打包后布局异常排查很多项目是之前用 Vue2 写的新需求要求转 Vue3。热词里正好有这个。实际转换中除了改语法最大的坑在响应式 API 差异Vue2 的this.someData xxx改成ref和reactive后模板里很多隐式绑定会失效必须检查组件的 key 同步。我用了一个辅助工具做自动迁移但它只能处理语法层逻辑层还是得人肉过一遍。热词里另一个高频问题“Vue 打包后布局异常”我遇到过三种情况单位混用开发时用 rpx 写小程序又在同一组件里用 vw/vh 写 H5 样式打包后 App 端布局错乱。统一之后问题消失。条件编译残留// #ifdef H5里写的布局代码在其他端会被跳过如果某块样式只在小程序调试时加了而对应的 H5 或 App 分支忘写打包后页面就歪了。排查方式是逐端编译而不是只在最常用的端调试。安全区忽略iPhone X 以上机型底部没有适配env(safe-area-inset-bottom)输入框和底部操作栏直接顶到 Home Indicator 上。加一行padding-bottom: env(safe-area-inset-bottom)能解决大部分问题。排查布局异常我建议按这个顺序先看编译产物 CSS 是否包含对应端代码再看控制台尺寸和手动缩放的差异最后检查字体渲染和 rem 换算。不要一上来就怀疑框架 bug大概率是样式冲突。7. 性能与细节优化让滚动、输入、流式输出都不“出戏”沉浸感的敌人是卡顿和跳变。聊天页面数据量一大每个 token 流式进来都要重渲染不做优化连本地开发都会卡成ppt。7.1 小程序 setData 的性能瓶颈微信小程序里任何数据变化最终都通过 setData 传到视图层而 setData 的传输量直接影响性能。如果每收到一个流式 token 就把整个 messages 数组 setData 一遍对话不到 20 条就会出现明显卡顿。我的做法是流式输出时只构建当前正在生成的这条消息// chatStore 内部 function appendChunk(sessionId, messageId, chunkText) { // 不直接 mutation 整个数组 const target state.messages.find((m) m.id messageId) target.content chunkText // 触发当前消息卡片组件的局部刷新而不是列表整体刷新 refreshMessage(sessionId, messageId) }配合渲染层的v-for使用:key指定消息 id并且给每条消息包一层独立的子组件。这样 setData 时只有当前消息子组件更新其他消息不动。React/Vue 的 diff 优化思维在小程序里同样适用但实现得更底层一点。7.2 长列表渲染虚拟列表还是裁剪渲染聊天消息的渲染高度不固定Markdown 又让高度估算难上加难。我试过真正意义上的虚拟列表但聊天场景带动画、让图片懒加载、有代码块折叠实现成本太高。我最后用的是先简单后复杂的渐进方案默认只渲染最近 30 条消息。用户上滑到历史区域时每次插入更多消息“插入”时记录滚动位置并做偏移修正保证视觉不跳动。图片用lazy-load代码块默认折叠成最多 8 行点击“展开”才显示完整内容。这套方案在单会话 500 条消息内实测不卡用户体验也说得过去。如果你要撑几千条消息再去上真正的虚拟列表吧但聊天场景我的结论是“渲染最近渐进加载”比虚拟列表更可靠。7.3 流式输出的节流渲染大模型返回 token 的速度非常快尤其是高质量网络下每秒能返回几十个 token。如果每个 token 都渲染一次哪怕只更新当前消息帧率也撑不住。我在appendChunk里加了一个 50ms 的节流器let renderTimer null let pendingText function appendChunk(sessionId, messageId, chunkText) { pendingText chunkText if (renderTimer) return renderTimer setTimeout(() { const target state.messages.find((m) m.id messageId) target.content pendingText pendingText renderTimer null refreshMessage(sessionId, messageId) }, 50) }50ms 的延迟人眼基本感知不到但渲染次数直接减少到原来的几十分之一。实际测试下来流式输出时的 CPU 占用明显下降手机也不发烫了。7.4 键盘弹起、安全区与暗黑模式切换移动端聊天最怕的就是键盘把输入框顶掉。小程序端在input组件设cursor-spacing确保光标离键盘有间隙H5 端要监听visualViewport高度变化手动把输入框顶上去App 端plus.key的键盘事件各版本行为不一致我干脆在软键盘弹起时给页面加一个padding-bottom: 260rpx的补偿实测在 iOS 上够用。暗黑模式我是用 CSS 变量统一管理并在会话消息的 Markdown 渲染结果里也埋了主题变量。切换按钮放在设置页同时跟随系统prefers-color-scheme。这里要注意一旦切换到暗黑模式要重新渲染所有已加载消息的 Markdown HTML因为代码高亮颜色和表格边框颜色都是渲染时确定的。我的做法是切主题时清空rendered字段重新走一遍解析代价是会有短暂的白屏闪烁优化方法是先在内存里缓存两种主题的渲染结果切换时直接取。缓存两套 HTML 会让内存占用翻倍但换来的是切换零闪烁我认为值得。8. 从 MVP 到下一版我的扩展路线和遗留下来的坑这个项目从无到有用了大概三周期间推翻重来了两次。第一次失败在过度设计一上来就想做插件化多模态渲染代码写了一半发现自己在造轮子第二次失败在忽视了存储容量上线测试没几天就出现消息丢失。第三次是按前面这套“消息结构统一、能力收口模块、平台差异最小化”的思路跑通的。如果你也要做类似的 AI 问答助手我个人的建议是先在单一端把消息协议和服务端流式接口彻底跑稳再去铺多端能力。消息协议是地基地基一旦设计歪了后面每个端都会跟着返工。其次是尽早埋点监控流式请求失败率、消息渲染耗时、存储溢出次数这些指标上线后一定要能看见不然用户反馈“聊天偶尔没反应”你根本无从下手。扩展方向我目前排了一个优先级第一优先级是接入 AI Agent 的工具调用能力让模型可以主动调取用户知识库、搜索网页、执行特定业务逻辑这个算是“从问答到执行”的关键一跃第二优先级是 TTS 语音朗读把 AI 回复读出来配合语音输入形成完整体验闭环第三优先级是做多会话文件夹和全文检索方便用户在几百个历史会话里快速找到想要的内容。遗留的坑也不少。比如小程序分包后 mp-html 的字体文件加载策略还没完全优化到最优H5 端在部分国产浏览器内核里ReadableStream的兼容性不稳定目前做了降级方案但效果一般App 端的暗黑模式在部分国产 ROM 上切换时会闪白屏。这些问题都不致命但真实存在写出来算是给后来人探个路。做全端项目永远不要指望一次完美能稳扎稳打地推进版本就是胜利。