三端AI问答助手实践:Vue3+UniApp实现流式输出与Markdown渲染
上个月我们接了个典型的三端同步需求小程序、H5、App 要同时上线一个 AI 问答助手支持流式打字机回复、Markdown 代码块和数学公式渲染还得能传图做多模态问答。排期只有四周我选了 Vue3 UniApp 而不是三套原生。这篇文章记录的是从脚手架搭建到打包上架的完整过程重点说清楚 Markdown/公式/多模态这三块在同构代码里怎么落地以及三端编译时那些文档里不会写的坑。1. 为什么是 Vue3 UniApp而不是三套原生1.1 先拆解 AI 问答助手到底由哪几部分组成很多人一上来就写聊天界面结果做到一半发现要补的东西一大堆。AI 问答助手从工程上看其实由几块独立模块组成会话列表与历史记录、单轮对话的消息流、消息内容的富文本渲染、多模态输入链路、网络传输层、以及各端的能力适配。核心难点不在 UI而在消息流的动态渲染和传输层这两块一旦设计好三端复用率可以做到非常高。我见过不少团队把大量时间花在哪个气泡圆角好看上却没有先定义好消息数据结构结果后端的流式输出一到前端就乱套。这个项目我建议的顺序是先定数据模型再写网络层最后才做界面。1.2 多端方案的横向对比当时摆在桌面上的方案有 UniApp、Taro、React Native、Flutter各有一个明显倾向。方案语言栈多端覆盖流式/长文本渲染团队上手成本我们的结论UniAppVue小程序/H5/App需自己做适配但生态方案多低Vue 开发者多选了它TaroReact小程序/H5/React Native同样需适配中React 语法备选React NativeReactiOS/Android不支持小程序/H5较高排除FlutterDartiOS/Android/WebWeb 端是痛高排除UniApp 最打动我的一点是它对小程序生态的贴合度最高生命周期、路由、组件都保留了小程序习惯而 Taro 在这方面虽然也在追但遇到微信特有的能力时还是需要写条件编译。小程序是这次需求的第一优先级所以 UniApp 胜出。1.3 整体架构客户端只负责一件事我在架构上做了一个明确分工客户端只做展示和采集所有 AI 相关逻辑全部放后端网关。前端把用户输入文本或图片发给网关网关负责调用大模型、做内容安全过滤、解析多模态结果、再把流式增量推回来。这样做的好处有三个第一多模态图片处理不需要暴露密钥或复杂的签名逻辑到客户端第二流式协议可以统一成一种后面细说第三如果未来换模型供应商客户端一行代码都不用改。这个架构对后面 Markdown 和公式渲染也很有用——我们直接在网关做了服务端预解析客户端少背了一大堆解析库。2. 工程初始化与 manifest 配置别在这上面浪费时间2.1 脚手架选型Vite Vue3 TypeScript项目创建用的是官方 Vite 模板命令很简单npx degit dcloudio/uni-preset-vue#vite-ts my-ai-assistant cd my-ai-assistant npm install npm run dev:mp-weixin这里我强烈建议直接选 TypeScript 模板。AI 项目里消息结构、流式状态、附件类型都很复杂随便一个字段拼错在小程序端很难排查有了类型定义能省至少三分之一调试时间。值得注意的一点是这个模板默认的langts只在 script 里生效模板里的类型提示需要额外装vue/runtime-core的补丁不过不装也不影响编译只是编辑器提示会弱一些。2.2 manifest.json 里的隐藏配置项manifest.json是 UniApp 的灵魂文件但很多人只改了 appid 就跑后面打包时才发现漏了一堆权限。我整理了一份必查清单小程序 appid在mp-weixin节点下填不填真机预览都跑不起来。H5 路由模式如果部署在 nginx 子路径下h5.router.base要配成实际路径否则刷新页面就 404。App 模块权限AI 问答助手常需要相册/相机传图提问、定位如果要做附近推荐、分享。在app-plus.modules里只勾需要的不要全勾全勾会导致包体变大、审核被问。App 隐私弹窗配置从 2024 年开始国内 Android 应用市场强制要求隐私政策弹窗这块配置在app-plus.privacy节点必须在 manifest 里先写清楚收集哪些信息否则上架直接被拒。H5 的跨域代理开发环境调试 AI 接口必须配h5.devServer.proxy不然浏览器跨域能卡你半天。2.3 目录结构与状态管理我用了比较扁平的结构api/放网关请求、types/放消息类型、stores/放 Pinia 状态、components/放消息渲染组件、pages/只放页面级代码。实际开发下来发现 AI 项目里stores和types是核心值得多花时间设计因为消息流是全局共享的不能在页面里随手 setData。Pinia 是必须的虽然会话页面看起来是单个页面但音频播放、输入框状态、历史记录都要跨页面共享。别用 Vuex 了Vue3 新项目没有理由不选 Piniamutation 那层样板代码纯属浪费时间。3. 沉浸感的核心流式输出与消息流数据模型3.1 为什么流式输出决定了沉浸感AI 大模型的完整回复通常要 5 到 20 秒才能生成完如果让用户盯着一个 loading 转圈体验会非常煎熬而流式输出能让用户一边看答案一边读神经科学上叫即时反馈补偿体感等待时长能缩短一半以上。所以这个项目的第一个技术硬指标就是必须做到逐字/逐词地在屏幕上打出来而不是一次性渲染整段文字。这个指标会连带影响很多决策网络协议、消息状态管理、滚动策略、甚至是 Markdown 的渲染时机。如果等 Markdown 全部解析完再显示流式就名存实亡如果每来一个 token 就全量解析一次性能又会崩。后面第 4 章细说。3.2 消息流数据模型设计我在types/chat.ts里是这样设计的export type ChatRole user | assistant | system; export type ChatStatus pending | streaming | done | error | aborted; export interface ChatAttachment { type: image | file | audio | video; url: string; // 展示用的完整地址 thumbUrl?: string; // 缩略图聊天列表很需要 name: string; size: number; mimeType: string; extra?: Recordstring, any; } export interface ChatMessage { id: string; // 客户端生成用于本地更新 role: ChatRole; content: string; // markdown 原文 renderedHTML?: string; // 服务端预解析后的 HTML小程序端用 status: ChatStatus; attachments?: ChatAttachment[]; error?: string; createdAt: number; }注意id一定要客户端生成不能用后端返回的 ID 去绑定渲染状态因为流式过程中消息还没入库后端根本给不了 ID。content存 Markdown 原文renderedHTML是缓存的服务端解析结果下一次打开同一会话时不用重新请求解析服务。会话列表的消息摘要可以直接取最后一条content的前 60 个字符不需要额外字段省得同步麻烦。3.3 三端流式传输方案SSE 与 WebSocket 的取舍这是项目里最需要动脑子的部分。标准方案是 SSEServer-Sent EventsH5 端用fetchReadableStream可以很舒服地解析const response await fetch(/api/chat/stream, { method: POST, body: JSON.stringify(payload) }); const reader response.body.getReader(); const decoder new TextDecoder(utf-8); while (true) { const { value, done } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); handleChunk(chunk); }但问题来了微信小程序没有ReadableStreamuni.request也不支持流式读取。虽然微信基础库提供了wx.request的enableChunked: true配合onChunkReceived来收分块数据但我实测下来有两个坑一是小程序的onChunkReceived拿到的是 ArrayBuffer需要自己拼接和按行切分 SSE 协议二是 iOS 和安卓上的行为不完全一致低版本基础库有概率直接吞掉第一块数据。我个人更推荐团队在网关层直接做WebSocket 网关三端统一走uni.connectSocket服务端把大模型的流式增量实时转发给客户端。这样做的理由很实际uni.connectSocket在小程序、H5、App 三端行为基本一致不需要条件编译写三套网络层WebSocket 天然支持二进制和文本未来要传音频流、图片流也方便断线重连、心跳机制可以统一封装一次长期维护成本低于 SSE 的三端特判。我们的网关实现大致是客户端connectSocket后发送一个{ type: chat, sessionId, content }的 JSON网关返回{ type: delta, delta }、{ type: done }、{ type: error }三类消息。前端只需要维护一个onMessage分发器代码量非常可控。3.4 打字机效果与自动滚动的性能问题流式数据进来后如果每次更新都重新渲染整个消息列表小程序端肯定卡。我做了三个优化只更新一条消息用 Pinia 的 action 只更新指定id的消息内容组件通过computed精确依赖那一条数据其他消息不参与更新渲染。节流渲染WebSocket 推过来的增量可能非常频繁我设置了 60ms 的节流窗口窗口内的增量合并到content后再触发更新。实测对打字机效果感知无影响渲染性能却提升明显。智能滚动用户正在阅读历史回复时如果强制滚到底部体验会很差。我在scroll-view上监听scrolltoupper和滚动位置只有当用户距离底部小于 100px 时才自动跟随滚动还加了回到底部的悬浮按钮。还有个很容易被忽略的点微信小程序的setData有 1MB 单次限制如果一条消息很长AI 回答经常超过 2000 字把整个 content 塞进 setData 会直接报警告。解决方法是把长消息拆成多个子组件每个子组件只渲染一个内容块或者用virtualHost特性减少页面级数据量。实测下来拆分子组件是最有效的方案还能顺带把 Markdown 代码块的高亮逻辑隔离掉。4. Markdown 与数学公式渲染小程序里最麻烦的一环4.1 H5 端直接用 markdown-it需要改这些配置H5 端有完整的 DOM 能力我直接用了markdown-it但是默认配置不能直接拿来渲染 AI 回复必须改几个选项import MarkdownIt from markdown-it; import hljs from highlight.js; import katex from katex; const md new MarkdownIt({ html: false, breaks: true, // 关键AI 模型常用 \n 分段默认不开启会挤成一段 linkify: true, highlight(code, lang) { if (lang hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value; } return ; } });breaks: true是我特别想强调的。很多 AI 模型的输出里单个换行只是为了分段但 Markdown 规范里单换行不产生br这导致用户看到一大坨文字完全没有层次感。开启后观感会立刻好很多代价是用户手动写的换行也会被转成br在聊天场景这个代价可以忽略。4.2 小程序端没有 DOM怎么渲染 Markdown小程序端没有 DOM 和innerHTML这是所有 Markdown 方案绕不过去的坎。常见方案有这些方案原理优点缺点towxmlJS 解析为 JSON/HTML 再渲染老牌维护少样式老旧体积大mp-html富文本组件支持 markdown 子插件活跃性能好标签覆盖广代码高亮能力弱需二次开发服务端预解析 mp-html后端渲染好 HTML前端只展示客户端无解析负担效果统一依赖服务端rich-text 组件小程序原生富文本简单标签支持有限table 基本废我实测后选的是服务端预解析 mp-html这条路线。原理很简单网关在收到大模型完整回复后用 Node.js 环境里的markdown-ithighlight.jskatex把 Markdown 转成标准 HTML再连同content一起返回给客户端小程序端直接用mp-html的content属性渲染这个 HTML。有人会质疑服务端预解析是不是就无法做流式打字机了其实不是。流式期间前端先把content纯文本显示出来不解析 Markdown等done事件到了服务端已经生成好的renderedHTML也一起返回这时再切换到富文本渲染。整个体验是文字先流式打完紧接着格式刷新成 Markdown 效果毫秒级完成用户感知不到中断。这个方案帮我省掉了在小程序端引入 Markdown 解析器的大半个心智负担。4.3 数学公式 KaTeX 的按需加载公式渲染是 AI 问答里绕不开的功能尤其数学、物理、代码场景。H5 端直接用katex没问题。但小程序端如果还要客户端本地解析 KaTeX字体文件会撑爆体积——KaTeX 的字体库动辄几百 KB放在微信小程序分包里完全不可接受。我们的处理是KaTeX 渲染也放到服务端。Node.js 端用katex.renderToString()把$...$和$$...$$公式转成带 KaTeX class 的 HTML前端 mp-html 只需要引入一份精简的 KaTeX CSS。这里有一个隐藏坑KaTeX 依赖特定字体文件woff2在小程序里字体文件不能直接通过相对路径加载需要把字体转成 base64 内联进 CSS或用网络链接加载。我们测试下来将字体文件转 base64 内联配合 mp-html 的样式隔离效果最稳离线也能显示。4.4 表格、换行、图片路径这些小坑Markdown 渲染里最容易翻车的三个小点表格窄屏溢出AI 经常输出数据表格在小屏手机上会撑爆布局。H5 端我给table外包了一层横向滚动容器小程序端 mp-html 可以通过tag-style给table设置display: block; overflow-x: auto实测效果理想。图片路径AI 回复里的图片链接可能是相对路径或需要鉴权的 URL。我们在服务端预解析时统一处理为绝对地址需要鉴权的图则临时生成带签名的 URL并设置较短有效期避免用户看到一堆裂图。Markdown 表格导出 Excel用户经常看完表格想直接导出我们做了个顺手的功能在 H5 端给表格加复制为 CSV按钮小程序端则提示长按表格区域复制 Markdown 原文。成本极低但用户反馈很好属于典型的低成本高感知功能。5. 多模态交互不只是发一张图5.1 多模态消息的输入链路设计多模态交互听起来很高级落到工程上其实要打通一条链路用户选图 - 图片预处理 - 上传/转码 - 消息带上附件 - 后端调用多模态模型 - 文本回复。在 UniApp 里第一步是uni.chooseImageconst res await uni.chooseImage({ count: 1, sizeType: [compressed], sourceType: [album, camera] });这里有个三端差异要特别留意chooseImage返回的tempFilePaths在小程序端是wxfile://临时路径在 App 端是本地沙盒路径在 H5 端是 blob URL。这些路径客户端自己打开没问题但直接传给后端大部分后端是拿不到的所以必须先上传后引用不能直接把临时路径塞进消息体。5.2 图片压缩与上传base64、临时文件还是 OSS三种方式我都试过结论很明确base64适合小图一两百 KB 可以接受再大就会让请求体积爆炸网关日志都打不出来。直接上传临时路径小程序端因为路径隔离后端无法访问不可行。先压缩再上传 OSS/CDN消息里带 URL最稳也是我们最终采用的方案。压缩我用uni.compressImage参数调成宽度最大 1280质量 0.7。这个参数是实测出来的再低图片细节会丢再高上传速度变慢多模态模型对 720p 到 1080p 之间的图片理解能力差异不大。压缩完上传到文件服务拿到 URL 后填进ChatAttachment。多模态的回复里如果模型返回了图片引用后端也会把图传到 CDN前端只需要渲染一个image消息块。这块我用了一个独立的AiAttachmentList组件可以横向滑动查看多图。5.3 多模态消息在会话记录里的存储结构会话历史如果只存纯文本下次打开多模态会话时图片就丢了。我在存储上做了点扩展ChatAttachment会持久化到本地uni.setStorageSync或服务端图片只存 URL 和缩略图 URL。回会话列表时列表页拿lastMessage.attachments[0].thumbUrl显示小缩略图这样用户在历史会话里也能一眼看到这个对话里发过图。有个体验细节用户拍了一张图准备提问但大模型响应很慢这时候立刻进入下一轮对话会导致上下文错乱。我在 UI 层对图片已选择但尚未发送的状态做了隔离图片附件必须跟随一条文本消息一起发送不允许单独占一条记录。聊天数据流保持每条消息都有明确的 role 和 content后续做导出、分享、训练集清洗都方便。5.4 安全合规多模态内容审核不能省多模态交互意味着用户上传的信息类型更复杂这个环节必须强调内容安全审核是必要的工程组件而不是可选项。图片上传到服务端时我们会做前置审核识别违规内容直接拦截文本也一样所有发送给大模型的内容都经过内容安全服务过滤大模型返回的内容同理。这个不是形式主义是国内应用市场上架审核的硬性要求也是产品能不能活下来的底线。我们在网关层用统一插件实现客户端无感服务端严格执行。6. 全端适配、打包上架与踩坑实录6.1 微信小程序分享、扫码、权限监听的特殊处理小程序端我踩过三个有意思的坑。第一个是分享。onShareAppMessage在选项式 API 里可以用this拿页面数据但我在组合式 API 里用箭头函数写结果this指向 undefined排查了很久。正确做法是onShareAppMessage((res) { return { title: this?.currentMessage?.content?.slice(0, 30) ?? AI 问答助手, path: /pages/index/index?share1 }; });注意这里不能用箭头函数包onShareAppMessage的配置要用普通函数来获取当前页实例。第二个坑是扫码。AI 助手里部分场景需要扫码登录或扫码添加设备我用uni.scanCode真机测试发现 iOS 系统相机权限弹窗出来后页面onHide会被触发。这本身不是问题但如果你在onHide里做了自动保存或中断请求就会误伤扫码流程。解决方案是给扫码动作加一个全局标志位在onShow里判断是否需要恢复。第三个坑是热搜里那条uniapp 能不能实时监听权限申请框的出现和消失。实测结论是小程序没有提供系统权限弹窗出现/消失的直接事件。微信只是模糊地告诉你应用从后台切回前台了。如果需要判断用户是否在权限弹窗上操作只能通过wx.getAppBaseInfo配合wx.getSetting轮询或者用wx.onAppShow的scene参数间接推断。我的建议是不要依赖这个时序做关键业务逻辑权限回调结果才是真正应该依赖的判断依据。6.2 H5 端路由、跳小程序和视频播放的兼容H5 端最容易忽略的是部署环境。AI 助手经常会输出包含视频链接的回复比如教程类答案H5 端我建议用hls.js播放 m3u8 流因为 iOS Safari 原生不播 m3u8video idvideo controls/video script import Hls from hls.js; const video document.getElementById(video); if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(url); hls.attachMedia(video); } /script小程序端直接用video组件就行App 端用video-player或 WebView。这算是一个小的全端富媒体适配成本不高但用户对 AI 助手的印象分会提升不少。还有一个H5 跳小程序的需求用户在微信里打开 H5 版问答助手我们想引导他跳转小程序继续对话。正确姿势是引入微信 JS-SDK调用wx.miniProgram.navigateTo({ url: /pages/index/index })。但注意这个 API 只在微信内置浏览器里有效普通浏览器打开会静默失败要做好降级提示比如显示请在微信中打开。6.3 App 端云打包、离线打包和 Android 上架App 端我走了两条路开发测试阶段用 HBuilderX 云打包正式上架前切到离线打包因为云打包的包名、签名证书可定制性有限部分 Android 应用市场对云打包的数字签名不清晰会提出质疑。离线打包要注意的点很多我捡重点说首先是uts 插件如果项目里用了 uts 插件比如某类原生能力封装离线打包时必须在原生工程里执行插件编译脚本不能只把插件放到项目里就完事。这个坑我踩过一次编译通过但运行时 log 一直报找不到模块最后发现是原生工程没同步 uts 产出的 framework。其次是上架 Android 市场的隐私声明。目前国内主流应用市场都要求应用内弹窗展示隐私政策、权限说明和第三方 SDK 列表。UniApp 的 Android 基座里自带了不少模块即使你没用到打包后也可能被扫描出 SDK所以我前面强调 manifest 里只勾选必要权限这样隐私声明能写得更短更干净。6.4 从 vue2 迁移到 vue3 时容易翻车的几个点虽然这个项目直接用了 Vue3但团队里有人之前写 vue2 的 uni-app 项目迁移时翻过几次车我记几个典型this上下文选项式 API 中this在onLoad里可用但在组合式 API 的setup里没有this。迁移时如果直接把this.someMethod()复制过来会报 undefined。v-model变化vue3 的v-model:value改成了v-model对于自定义组件modelValue和update:modelValue的事件名也必须同步改。过滤器移除vue3 删掉了filter我见过有人还是写{{ text | truncate }}直接白屏。要用computed或方法替代。全局 API 不再默认挂载Vue.prototype改成了app.config.globalProperties在 uni-app 里可以这样// main.ts app.config.globalProperties.$api apiInstance;这些坑单独看都不大但迁移时如果不小心会浪费一整天在莫名的编译错误上。7. 实测下来我最想提醒后来者的几件事项目收尾时我复盘了一下有几条优先级最高的经验值得单独整理出来。第一流式协议要提前跟后端定死别等联调再改。我们一开始定的私有协议是{ type: delta, data }前端解析没问题。但后来发现有些后端框架会自动对消息做 JSON 包装导致客户端收到的是{ event: message, data: { data: xxx }}嵌套层数又多了一层。这种东西看似小事联调时能让前后端互相甩锅一整天。建议前端先定义好接收协议的类型定义后端照着实现能省很多事。第二Markdown 渲染的优先级应该排在流式体验之后。用户对 AI 助手的第一感知是回答得流畅其次才是排版好看。我们前两周只做了纯文本流式显示第三周才接入服务端预解析 HTML效果反而更好。如果你想先跑通 Demo完全可以先不做代码高亮和公式先用纯文本 简单换行跑起来再逐步加固。第三小程序分包和主包体积要提前规划。AI 助手的本地依赖容易膨胀尤其是网络库、MP-html、图标字体。把这些东西尽量放进分包主包控制在实际开发中很关键微信小程序主包 2MB 限制是硬门槛超过后体验分和审核通过率都会受影响。第四别忽视异常态的 UI 设计。AI 项目里请求失败、超时、限流、内容被安全过滤的概率比普通应用高得多。我们最后专门做了重试/修改问题/换一个角度问的按钮以及一条抱歉回答被截断/已被拦截的提示样式。这些代码不多但直接决定了用户对产品的信任度。这个项目做完后我的一个很深的体会是全端 AI 问答助手的难点不在 AI 本身而在流式传输、富文本渲染、多模态文件链路、三端编译差异这些工程细节上。把这些地基打好后面换模型、加语音、接 Agent 能力都是水到渠成的事。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →