这次在 ReadVerse 里接入 vivo 端侧模型,主要是为了让阅读问答功能不完全依赖云端。网络好的时候可以走云端模型,回答质量更稳;网络不好或者想做本地兜底时,就可以尝试调用端侧模型,让手机本地也能完成一部分轻量问答。
我们项目里接入的是 vivo 端侧 BlueLM,前端通过 Capacitor 插件和 Android 原生侧通信。整体思路不复杂:前端先判断当前是不是 Android 真机,再检查 BlueLM 插件是否可用;插件可用后初始化模型,最后通过流式事件把模型生成的 token 逐步显示出来。
接入思路概览
整个流程可以简单理解成四步:
- Android 端准备好 BlueLM 原生能力和模型文件。
- 前端通过 Capacitor 注册
BlueLm 插件。
- 调用
initialize 初始化端侧模型。
- 调用
generate 发起生成,并监听 token、complete、error 事件。
在 ReadVerse 里,端侧模型主要服务于 ReaderQA,也就是阅读器内问答。用户在阅读文章时提问,系统会先结合当前文档、本地缓存和本地检索结果组织上下文,然后再调用模型生成回答。
前端插件封装
前端侧先用 Capacitor 注册一个名为 BlueLm 的插件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
const DEFAULT_MODEL_PATH = import.meta.env?.VITE_BLUELM_MODEL_PATH?.trim() || '/sdcard/1225/1.7.0.4_1225_mtk9500';
type BlueLmPlugin = { status(options?: { modelPath?: string }): Promise<{ available: boolean; initialized: boolean; initializing: boolean; generating: boolean; moderationReady: boolean; modelPath: string; }>;
initialize(options?: { modelPath?: string }): Promise<{ initialized: boolean; modelPath: string; initializationMs?: number; }>;
generate(options: { prompt: string; moderationText: string; requestId: string; }): Promise<{ requestId: string }>;
interrupt(): Promise<void>; release(): Promise<void>;
addListener( eventName: 'token', listener: (event: { requestId: string; token: string; firstTokenMs?: number }) => void, ): Promise<PluginListenerHandle>;
addListener( eventName: 'complete', listener: (event: { requestId: string; durationMs?: number; moderated?: boolean }) => void, ): Promise<PluginListenerHandle>;
addListener( eventName: 'error', listener: (event: { requestId: string; code?: number; message?: string }) => void, ): Promise<PluginListenerHandle>; };
const BlueLm = registerPlugin<BlueLmPlugin>('BlueLm');
|
这里的 DEFAULT_MODEL_PATH 是模型在 Android 设备上的路径。为了方便不同设备调试,我们也支持通过环境变量 VITE_BLUELM_MODEL_PATH 来覆盖默认路径。
判断是否支持端侧模型
端侧模型不能直接在普通浏览器里跑,所以需要先判断当前环境。我们只在 Android 真机,并且插件存在时启用 BlueLM:
1 2
| export const isBlueLmNativePlatform = (): boolean => Capacitor.getPlatform() === 'android' && Capacitor.isPluginAvailable('BlueLm');
|
如果不是 Android 真机,就直接返回不可用状态,避免开发环境或网页端误调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| export const getBlueLmStatus = async () => { if (!isBlueLmNativePlatform()) { return { available: false, initialized: false, initializing: false, generating: false, moderationReady: false, modelPath: DEFAULT_MODEL_PATH, }; }
return BlueLm.status({ modelPath: DEFAULT_MODEL_PATH }); };
|
这个判断很重要。否则在浏览器里调试时,前端会找不到原生插件,直接报错。
初始化模型
端侧模型首次加载通常比较慢,所以初始化需要单独封装,并且避免重复初始化。我们这里用了一个 initializationPromise 来复用初始化过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| let initializationPromise: Promise<{ initialized: boolean; modelPath: string; initializationMs?: number; }> | null = null;
export const initializeBlueLm = async () => { if (!isBlueLmNativePlatform()) { throw new Error('端侧 BlueLM 仅支持兼容的 Android 真机'); }
if (!initializationPromise) { initializationPromise = BlueLm.initialize({ modelPath: DEFAULT_MODEL_PATH }).catch((error) => { initializationPromise = null; throw error; }); }
return initializationPromise; };
|
这样做的好处是,如果用户连续触发多次问答,不会重复加载模型。初始化失败时,也会把 Promise 清空,方便下次重新尝试。
流式生成回答
模型生成时,我们使用 requestId 标记每一次请求。这样即使有多个事件回来,也能确认它属于哪一次生成任务。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| export const generateBlueLmStream = async ( prompt: string, moderationText: string, onToken: (token: string) => void, ): Promise<string> => { await initializeBlueLm();
const requestId = crypto.randomUUID(); let answer = ''; const listeners: PluginListenerHandle[] = [];
return new Promise<string>((resolve, reject) => { let settled = false;
const finish = async (result: { answer?: string; error?: Error }) => { if (settled) return;
settled = true; window.clearTimeout(timeoutId); await Promise.all(listeners.map((listener) => listener.remove().catch(() => undefined)));
if (result.error) { reject(result.error); } else { resolve(result.answer ?? answer); } };
const timeoutId = window.setTimeout(() => { void BlueLm.interrupt(); void finish({ error: new Error('端侧生成超时,已停止本次回答') }); }, 240_000);
void Promise.all([ BlueLm.addListener('token', (event) => { if (event.requestId !== requestId || !event.token) return;
answer += event.token; onToken(event.token); }),
BlueLm.addListener('complete', (event) => { if (event.requestId === requestId) { void finish({ answer }); } }),
BlueLm.addListener('error', (event) => { if (event.requestId === requestId) { void finish({ error: new Error(event.message || `端侧生成失败${event.code ? ` (${event.code})` : ''}`), }); } }), ]) .then((handles) => { listeners.push(...handles); return BlueLm.generate({ prompt, moderationText, requestId }); }) .catch((error) => { void finish({ error: error instanceof Error ? error : new Error('端侧生成启动失败'), }); }); }); };
|
这里有几个小细节:
token 事件用于流式显示回答;
complete 事件表示生成结束;
error 事件用于处理失败;
- 超时时会主动调用
interrupt;
- 结束后会移除监听器,避免事件重复触发。
和 ReaderQA 结合
在 ReaderQA 里,端侧模型不是直接裸跑问题,而是先结合阅读上下文。
大致流程是:
- 获取当前阅读文档和当前段落。
- 从本地文档缓存或本地向量索引中找相关片段。
- 把当前段落、相关片段和用户问题整理成 prompt。
- 调用 BlueLM 生成回答。
- 如果端侧不可用,再提示用户或回退到云端链路。
这样做的目标是让回答尽量基于用户正在读的内容,而不是让模型随便发挥。
简单说,云端模型负责更强的理解和总结,端侧模型负责移动端本地可用性和弱网兜底。两边不是互相替代,而是配合使用。
Android 真机调试注意点
端侧模型接入时,最容易踩坑的地方主要有几个:
- 必须在 Android 真机上测试,普通浏览器没有原生插件;
- 模型路径要确认存在,例如
/sdcard/1225/1.7.0.4_1225_mtk9500;
- 前端改完后记得重新构建并同步到 Android;
- 如果插件不可用,先检查 Capacitor 插件是否正确注册;
- 如果初始化失败,优先检查模型文件路径和设备兼容性;
- 生成时间可能比较长,需要设置超时和中断逻辑;
- 流式事件监听结束后一定要移除,否则容易出现重复回调。
常用命令
前端改完后,一般需要重新构建并同步 Android:
1 2
| npm run build npx cap sync android
|
然后用 Android Studio 打开项目运行,或者连接真机后安装调试包。
如果只是检查前端类型和构建问题,可以先在前端目录执行:
1 2
| npm run lint npm run build
|
小结
这次接入 vivo 端侧模型,核心不是单纯多接一个模型接口,而是让 ReadVerse 的阅读问答具备更好的移动端适配能力。
云端模型适合复杂理解和高质量回答,端侧模型适合本地缓存、弱网兜底和隐私友好的轻量问答。通过 Capacitor 插件封装,前端可以用比较统一的方式调用 Android 原生端侧能力,再和 ReaderQA 的文档检索、上下文组织结合起来。
最终效果就是:用户在手机上阅读资料时,不只能在线问云端模型,也可以在端侧模型可用的情况下获得本地化的辅助理解体验。