之前做移动端 AI 聊天产品时,最让我头疼的不是大模型本身,而是手机端的流式渲染、软键盘弹出、WebView 缓存不一致这些细节。这次我把整个免费 AI 聊天引擎的手机端从零搭了出来,不废话,直接分享一套能跑通的最小闭环,包含后端 SSE 推送、手机端 H5 聊天页面、打包到 Android WebView 的方式,以及移动端高频踩坑梳理。对正在做 AI 应用,或者想把聊天机器人搬上手机的同学,这套流程可以直接复用。
1. 背景与整体架构:手机端 AI 聊天引擎到底要做什么
AI 聊天引擎不是一个聊天窗口那么简单。它的核心职责有三个:接收用户消息并维护上下文,调用大模型或规则引擎生成回复,再把回复稳定地送回客户端。手机端是这条链路里最容易翻车的一环,因为手机端要同时面对软键盘弹起、屏幕尺寸差异、弱网断流、WebView 缓存不一致、状态栏遮挡等问题。很多人把 PC 端的聊天页面缩小后放到手机上,结果软键盘一弹出来,输入框就被顶走,流式输出也经常断,体验非常差。
从标题里说的“王炸功能”来看,手机端版本真正有价值的能力可以拆成四块:流式打字机输出、会话上下文保存、模型 Key 不下发到端上、移动端自适应。流式输出解决等待焦虑,让用户看到回复在逐字生成,而不是盯着一个转圈图标;会话上下文保存让多轮对话有连续性;模型 Key 不下发是安全底线,避免 Key 被反编译或抓包拿走;自适应则保证不同的刘海屏、屏占比、系统字号下,页面都能正常显示。
整个系统的调用链路可以先用一张简单的示意图来表示:
手机 H5 / App WebView | | POST /api/chat (SSE 流) v 模型网关服务(鉴权、限流、上下文组装) | | 模型 SDK / HTTP 流式请求 v 大模型服务(开源模型自部署 / 在线模型 API)这个流程里有几个关键点。首先是手机端不直接请求大模型服务,而是统一请求我们自己搭建的模型网关服务。这样做的好处是,模型服务的地址、API Key、使用配额都被封装在服务端,手机端拿到的是经过鉴权的会话凭证。其次是整个返回过程使用 SSE 流式协议,客户端每收到一个数据块就渲染一个字,形成打字机效果。最后是会话上下文的组装放在网关侧完成,手机端不需要维护复杂的提示词模板,只需要把用户消息传给网关。
2. 环境准备与项目结构
本文示例以后端 Node.js + Express、前端 Vue 3 + Vite 的做法为例。版本需要根据你的项目实际情况调整,本文重点演示整体思路。如果你使用的是 Java/Spring Boot 或者 Python/FastAPI,后端 SSE 的写法会不同,但协议格式一致,手机端代码可以完全复用。
本机环境至少要准备以下内容:
- Node.js 18 及以上,用于启动后端服务和前端开发服务器。
- VS Code 或其他代码编辑器。
- 手机或浏览器移动端调试模式。
- Android Studio,仅在需要把 H5 打包成 Android App 时使用。
建议把前后端拆成两个目录,一个是服务端,一个是手机端页面。示例项目结构如下:
ai-chat-mobile/ ├── server/ │ ├── index.js # Express 入口,提供 /api/chat SS E 接口 │ └── modelAdapter.js # 模型适配层,统一封装大模型调用 └── web/ ├── index.html # 移动端页面入口 ├── package.json ├── vite.config.js # 开发服务器与接口转发配置 └── src/ ├── main.js └── App.vue # 聊天主界面这里把模型调用单独抽成modelAdapter.js是有意为之。因为免费大模型来源比较多,有的是在线 API 的免费额度,有的是自行部署的开源模型。无论哪种,都不建议把调用逻辑散落在各个接口里,统一封装成一个streamChat(messages, callbacks)方法后,后续切换模型来源只需要改这一个文件。
3. 核心原理:SSE 流式输出与移动端适配
3.1 为什么聊天必须用流式输出
大模型生成一句完整回答通常需要几秒甚至几十秒,如果等整个内容生成完再返回,用户在手机端看到的就是长时间的白屏或 loading。流式输出就是为了解决这个问题。它让服务端一边生成一边推送,客户端一边接收一边渲染,用户第一句话往往在 1 秒内就能看到。
实现流式输出有两种主流方式:SSE 和 WebSocket。SSE 全称 Server-Sent Events,是一种基于 HTTP 的单向服务端推送协议。它使用text/event-stream格式,服务端可以持续向客户端写入数据。WebSocket 是双向全双工通信协议,适合需要服务端主动推送、客户端频繁上行、复杂交互的场景。对于聊天应用,如果只是模型单向生成回复,SSE 更简单,而且自带断线重连机制。本文示例使用 SSE。
SSE 协议的数据格式很直接,每个事件由若干行组成,字段之间用空行分隔。常见格式如下:
data: {"content": "你好"} data: {"content": ",我是AI助手"} data: {"done": true}客户端在解析时,只需要按空行把数据块拆开,再读取data:后面的内容即可。需要注意的是,不同服务端实现可能使用\n\n或\r\n\r\n作为事件分隔符,手机端解析时最好做兼容。
3.2 模型网关层的作用
模型网关层是整个架构里最容易被新手忽略的部分。我见过不少同学把大模型 API 的 Key 直接写进手机前端代码里,这种做法非常危险。手机 App 的包可以被反编译,H5 页面的请求可以被抓包,一旦 Key 泄漏,别人就能盗用你的调用额度,甚至产生费用。
所以手机端所有请求都必须经过自己的服务端。服务端负责三件事:校验客户端身份、组装上下文、调用大模型并转发结果。在这个示例里,modelAdapter.js就是模型网关层的核心封装。接入真实模型时,把模拟输出替换成模型 SDK 的流式调用即可,接口部分不需要改动。
3.3 手机端移动端适配关键点
移动端聊天页面有一个很特殊的难点:软键盘。在 iOS Safari 和部分 Android WebView 中,软键盘弹出会导致100vh高度计算错误,页面底部有一段被键盘遮住,输入框跑到视野之外。现在比较推荐的做法是使用100dvh作为页面高度单位,dvh代表动态视口高度,会随着软键盘和浏览器工具栏的变化而调整。同时配合env(safe-area-inset-top)和env(safe-area-inset-bottom)处理刘海屏和底部手势条区域。
滚动容器也需要注意。聊天消息列表需要设置overflow-y: auto,并且在 iOS 上加上-webkit-overflow-scrolling: touch,保证滑动顺畅。输入框建议使用textarea而不是input,因为消息可能换行,textarea支持自定义高度,并且可以通过按回车发送、Shift + 回车换行来模拟主流聊天软件的交互。
4. 完整实战:从后端网关到手机端聊天页面
4.1 创建项目与初始化依赖
先创建后端目录,安装 Express 和 CORS 中间件:
mkdir ai-chat-mobile cd ai-chat-mobile mkdir server web cd server npm init -y npm install express cors再创建前端目录,这里直接使用 Vite 创建 Vue 3 项目:
cd ../web npm create vite@latest . -- --template vue npm install如果是在已有工程里操作,也可以只保留src和index.html,避免版本干扰。Vite 版本不同,初始化交互可能会有区别,按照终端提示操作即可。接下来修改vite.config.js,把/api请求转发到后端服务,并允许手机通过局域网访问:
// web/vite.config.js import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue()], base: './', server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } });base: './'很关键。H5 页面打包后如果部署到线上子目录,或者被 WebView 加载本地文件,默认的绝对路径/assets/xxx.js会找不到资源。改成相对路径后,资源引用会根据当前页面路径解析,能有效避免白屏问题。
4.2 编写后端 SSE 接口
先写模型适配层。这里先用定时器模拟大模型流式输出,接入真实模型时,只需要把定时器部分替换为模型 SDK 的流式回调:
// server/modelAdapter.js // 模型适配层:把外部大模型服务统一封装成 streamChat 方法。 // 接入真实模型时,只需要替换这里的实现,不需要改动 index.js。 async function streamChat(messages, { onChunk, onDone, onError }) { try { const userMessage = messages[messages.length - 1]?.content || ''; // 演示阶段先用定时器模拟流式输出 const demoReply = `我收到了你的消息:“${userMessage}”。这是通过 SSE 流式接口返回的内容。接入真实模型后,这里会自动替换成模型生成的增量文本。`; let index = 0; const timer = setInterval(() => { const step = 2; if (index < demoReply.length) { const text = demoReply.slice(index, index + step); index += step; onChunk(text); } else { clearInterval(timer); onDone(demoReply); } }, 25); // 真实项目需要监听客户端断开连接,及时清理定时器,避免资源泄漏 } catch (err) { onError(err); } } module.exports = { streamChat };接着编写 Express 入口文件,提供/api/chat接口。这个接口接收消息数组,通过 SSE 协议把模型输出推送给手机端:
// server/index.js const express = require('express'); const cors = require('cors'); const { streamChat } = require('./modelAdapter'); const app = express(); app.use(cors()); app.use(express.json()); // 简易鉴权中间件,真实项目应替换为 JWT 或 Token 签名校验 app.use('/api', (req, res, next) => { const token = req.headers['authorization']; if (!token) { return res.status(401).json({ code: 401, message: '缺少认证信息' }); } next(); }); app.post('/api/chat', async (req, res) => { const { messages, sessionId } = req.body; if (!Array.isArray(messages) || messages.length === 0) { return res.status(400).json({ code: 400, message: 'messages 不能为空' }); } res.setHeader('Content-Type', 'text/event-stream;charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); const startTime = Date.now(); let totalChars = 0; await streamChat(messages, { onChunk(text) { totalChars += text.length; const payload = JSON.stringify({ content: text, sessionId }); res.write(`data: ${payload}\n\n`); }, onDone(reply) { const payload = JSON.stringify({ done: true, sessionId, usage: { chars: totalChars, ms: Date.now() - startTime } }); res.write(`data: ${payload}\n\n`); res.end(); }, onError(err) { console.error('[chat error]', err.message); const payload = JSON.stringify({ error: err.message, sessionId }); res.write(`data: ${payload}\n\n`); res.end(); } }); }); app.listen(3000, () => { console.log('AI chat server running at http://localhost:3000'); });这里解释几个关键配置。Content-Type: text/event-stream是 SSE 的标准类型,必须设置。Cache-Control: no-cache, no-transform防止中间代理缓存响应,no-transform是防止某些运营商网关压缩或改写事件流导致客户端解析异常。X-Accel-Buffering: no是针对 Nginx 关闭缓冲,如果线上环境没有使用 Nginx,这个头会被忽略,不影响本地运行。鉴权中间件中的 token 只是演示,真实项目必须做签名校验、有效期和过期逻辑。
4.3 编写手机端聊天界面
手机端使用 Vue 3 单文件组件实现。先写index.html,重点指定viewport并开启viewport-fit=cover,这是安全区适配的前提:
<!-- web/index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <title>AI 聊天引擎手机端</title> <style> html, body { margin: 0; padding: 0; } * { box-sizing: border-box; } </style> </head> <body> <div id="app"></div> <script type="module" src="/src/main.js"></script> </body> </html>然后是入口文件src/main.js:
// web/src/main.js import { createApp } from 'vue'; import App from './App.vue'; createApp(App).mount('#app');核心文件是src/App.vue。它包含顶部状态栏、消息列表、底部输入区域三个部分:
<!-- web/src/App.vue --> <template> <div class="chat-page"> <header class="chat-header"> <div class="title">AI 聊天引擎</div> <div class="status" :class="{ online: connected }"> {{ connected ? '已连接' : '未连接' }} </div> </header> <main ref="listRef" class="message-list"> <div v-for="(msg, index) in messages" :key="index" class="message-row" :class="msg.role" > <div class="bubble">{{ msg.content }}</div> </div> </main> <footer class="input-bar"> <textarea v-model="inputText" placeholder="请输入你的问题" rows="1" @keydown.enter.exact.prevent="sendMessage" ></textarea> <button :disabled="loading" @click="sendMessage"> {{ loading ? '回复中' : '发送' }} </button> </footer> </div> </template> <script setup> import { ref, nextTick } from 'vue'; const messages = ref([ { role: 'assistant', content: '你好,我是你的 AI 助手,有什么可以帮你?' } ]); const inputText = ref(''); const loading = ref(false); const connected = ref(true); const listRef = ref(null); // 演示用固定 token,真实项目应从登录接口获取并动态设置请求头 const TOKEN = 'mobile-demo-token'; async function sendMessage() { const content = inputText.value.trim(); if (!content || loading.value) return; inputText.value = ''; messages.value.push({ role: 'user', content }); messages.value.push({ role: 'assistant', content: '' }); loading.value = true; const history = messages.value.slice(0, -1).map((m) => ({ role: m.role, content: m.content })); try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` }, body: JSON.stringify({ messages: history, sessionId: 'mobile-demo-session' }) }); if (!response.ok || !response.body) { throw new Error('接口请求失败'); } connected.value = true; const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split('\n\n'); buffer = events.pop(); for (const event of events) { if (!event.startsWith('data: ')) continue; const data = JSON.parse(event.slice(6)); if (data.done) { loading.value = false; await scrollToBottom(); continue; } if (data.error) { throw new Error(data.error); } const last = messages.value[messages.value.length - 1]; if (last && last.role === 'assistant') { last.content += data.content; } await scrollToBottom(); } } } catch (err) { connected.value = false; const last = messages.value[messages.value.length - 1]; if (last && last.role === 'assistant' && !last.content) { last.content = `请求失败:${err.message}`; } } finally { loading.value = false; } } async function scrollToBottom() { await nextTick(); if (listRef.value) { listRef.value.scrollTop = listRef.value.scrollHeight; } } </script> <style scoped> .chat-page { height: 100dvh; display: flex; flex-direction: column; background: #f6f6f6; } .chat-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; padding-top: calc(12px + env(safe-area-inset-top)); background: #fff; border-bottom: 1px solid #eee; } .title { font-size: 17px; font-weight: 600; } .status { font-size: 12px; color: #999; } .status.online { color: #07c160; } .message-list { flex: 1; overflow-y: auto; padding: 16px; -webkit-overflow-scrolling: touch; } .message-row { display: flex; margin-bottom: 14px; } .message-row.user { justify-content: flex-end; } .bubble { max-width: 78%; padding: 10px 14px; border-radius: 12px; background: #fff; font-size: 15px; line-height: 1.5; word-break: break-word; white-space: pre-wrap; } .message-row.user .bubble { background: #07c160; color: #fff; border-bottom-right-radius: 4px; } .message-row.assistant .bubble { border-bottom-left-radius: 4px; } .input-bar { display: flex; align-items: flex-end; gap: 8px; padding: 10px 12px; padding-bottom: calc(10px + env(safe-area-inset-bottom)); background: #fff; border-top: 1px solid #eee; } .input-bar textarea { flex: 1; max-height: 100px; padding: 8px 10px; border: 1px solid #ddd; border-radius: 8px; font-size: 15px; resize: none; outline: none; } .input-bar button { height: 36px; padding: 0 16px; border: none; border-radius: 8px; background: #07c160; color: #fff; font-size: 14px; } </style>这段代码有几个要点需要说明。页面整体高度使用100dvh,而不是100vh。dvh会随着浏览器地址栏和软键盘的显示隐藏动态变化,能有效避免键盘弹起时页面底部被遮住。消息列表使用flex: 1撑满中间区域,配合overflow-y: auto实现滚动。输入框使用textarea,通过@keydown.enter.exact.prevent实现回车发送,同时保留 Shift + 回车换行。
流式数据解析的逻辑是:通过read()循环读取响应体,把每次读到的二进制数据用TextDecoder解码,再按空行切分成事件块。因为网络传输可能把一条 SSE 消息拆成多次返回,所以需要保留最后一段不完整数据,和下一次读取的内容拼在一起解析。每次收到content字段,就把它追加到最后一个 assistant 消息末尾,再滚动到底部,形成打字机效果。
4.4 手机端打包与真机运行
先验证本地运行。后端启动:
cd server node index.js前端启动:
cd web npm run dev这时通过电脑浏览器访问http://localhost:5173就能看到聊天页面。要让手机访问,需要确保手机和电脑在同一个局域网。Vite 配置里已经设置了host: '0.0.0.0',所以手机浏览器直接访问电脑的局域网 IP 加端口即可,例如http://192.168.1.100:5173。
如果要把 H5 页面打包成 Android App,可以使用 Capacitor。先安装脚手架并初始化:
cd web npm run build npm install @capacitor/core @capacitor/cli npx cap init npx cap add android npx cap sync android然后用 Android Studio 打开生成的android目录,连接手机后直接运行。Capacitor 的版本和使用方式更新比较快,具体命令以官方文档为准。这里的关键思路是:把 Vite 构建出的静态文件交给原生 WebView 加载,手机端页面本身不需要改动,只是多了一层原生壳。
4.5 运行效果与预期
正常运行时,终端会看到后端打印AI chat server running at http://localhost:3000。手机端页面顶部显示“已连接”,输入问题后回车,用户消息出现在右侧绿色气泡中,随后 AI 消息在左侧空白气泡里逐字出现,底部自动跟随最新内容滚动。整个过程不需要刷新页面,也不需要重新请求接口。
5. 手机端常见问题与排查思路
手机端 AI 聊天项目最常见的问题,其实并不是模型接口报错,而是移动端本身的兼容细节。我把高频问题整理成了表格:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 手机访问不到本地开发服务器 | 电脑防火墙拦截,或 Vite 未监听 0.0.0.0 | 设置server.host: '0.0.0.0',关闭系统防火墙或放行端口 |
| 软键盘弹起后输入框被遮挡 | 使用100vh导致视口计算错误 | 改用100dvh,或监听visualViewport动态设置高度 |
| Android 状态栏遮挡顶部内容 | WebView 沉浸式状态栏未适配 | 页面增加env(safe-area-inset-top)内边距 |
| 打包后打开是白屏 | 静态资源路径错误 | Vite 配置base: './'后再构建 |
| 手机端加载的还是旧页面 | WebView 缓存了旧 HTML | 静态资源加 hash,页面请求版本接口并强制刷新 |
| 流式输出一直不显示 | SSE 被网关或中间件缓冲 | 设置X-Accel-Buffering: no,检查请求头 |
| iOS 上文字自动变大 | WebView 自动调整字号 | 设置text-size-adjust: 100% |
| CLI 调试正常但手机异常 | 手机端 WebView 版本较低,语法不兼容 | 使用兼容性写法或配置 Babel 编译 |
关于 H5 资源更新后手机端版本不同的问题,在实际项目中非常常见。Vite 构建时默认会给静态文件生成 hash 文件名,但index.html本身可能被缓存。比较稳妥的做法是给页面加一个版本检查逻辑。启动时请求一个轻量的版本接口,拿返回值与本地的版本号对比,如果不同就清理本地缓存并刷新页面。示例代码如下:
// 手机端启动时检查版本 async function checkVersion() { const res = await fetch(`/version.json?t=${Date.now()}`); const remote = await res.json(); const local = localStorage.getItem('app_version'); if (local && local !== remote.version) { localStorage.clear(); location.reload(); } else { localStorage.setItem('app_version', remote.version); } }这段逻辑适合放在页面入口处执行。version.json是打包时生成的版本信息文件,?t=${Date.now()}是为了防止版本文件本身被缓存。
6. 工程建议与最佳实践
如果只是做小工具,上面的代码足够用了。但如果要部署到生产环境,有几个工程问题必须提前想清楚。
第一是安全边界。手机端一律不要保存模型密钥和内部接口地址。示例里前端代码中的 token 只是演示用途,真实项目应该由用户登录后动态获取,并且服务端校验签名和有效期,再结合 HTTPS 传输。后端接口还需要做限流,避免被批量调用刷爆额度。如果模型服务支持用量统计,最好在网关层记录每次请求的 token 消耗,并设置单用户配额和总配额预警。
第二是上下文长度控制。大模型输入长度有限,聊天轮数多了之后,历史消息会超出上下文窗口。最简单的策略是只保留最近 N 条消息,或者按 token 估算长度,超出部分丢弃最早的消息。还有一种常见做法是把长历史做摘要,再和最近消息拼接。在服务端实现这些策略时,要保证消息是按时间顺序排列的,避免多轮对话上下文错乱。
第三是流式断线处理。手机网络不稳定,SSE 请求很可能中途断开。客户端需要区分正常结束和异常断开。示例中reader.read()返回done时是正常结束,如果请求抛异常,则需要进入重试逻辑。重试需要注意幂等性,不能让用户消息被重复发送。比较稳妥的做法是为每条用户消息生成一个clientMsgId,服务端根据这个 ID 做去重。
第四是消息列表性能。聊天记录很长后,如果还是全部渲染 DOM,滚动会明显卡顿。项目初期可以先用分页加载,进入页面只加载最近 20 条,往上滚动时再加载更早的历史。更复杂的场景需要虚拟滚动,只渲染视口内的消息节点。同时注意v-for的key不要使用数组索引,建议使用数据库中的消息 ID 或本地生成的唯一 ID。
第五是日志与会话追踪。在网关层为每次会话生成sessionId,把用户输入、模型输出、耗时、token 消耗都记录下来。排查问题时,用户反馈一句“刚才那句话回答很奇怪”,你只要拿到 sessionId 就能还原完整对话链路,而不是靠猜。
7. 收尾与下一步玩法
手机端 AI 聊天的坑基本集中在四类:渲染适配、流式读写、缓存更新、接口安全。动手调试时优先确认网络请求是否正常、SSE 协议格式是否正确,这两个点解决了,大部分问题都会清晰很多。后面的迭代方向可以做语音输入、图片消息、本地历史记录存储,也可以给网关层接入 RAG 知识库,让 AI 能回答业务相关的问题。如果你也在做手机端 AI 聊天产品,欢迎在评论区交流你的踩坑记录。