接入 vivo 端侧模型 BlueLM

这次在 ReadVerse 里接入 vivo 端侧模型,主要是为了让阅读问答功能不完全依赖云端。网络好的时候可以走云端模型,回答质量更稳;网络不好或者想做本地兜底时,就可以尝试调用端侧模型,让手机本地也能完成一部分轻量问答。

我们项目里接入的是 vivo 端侧 BlueLM,前端通过 Capacitor 插件和 Android 原生侧通信。整体思路不复杂:前端先判断当前是不是 Android 真机,再检查 BlueLM 插件是否可用;插件可用后初始化模型,最后通过流式事件把模型生成的 token 逐步显示出来。

接入思路概览

整个流程可以简单理解成四步:

  1. Android 端准备好 BlueLM 原生能力和模型文件。
  2. 前端通过 Capacitor 注册 BlueLm 插件。
  3. 调用 initialize 初始化端侧模型。
  4. 调用 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 里,端侧模型不是直接裸跑问题,而是先结合阅读上下文。

大致流程是:

  1. 获取当前阅读文档和当前段落。
  2. 从本地文档缓存或本地向量索引中找相关片段。
  3. 把当前段落、相关片段和用户问题整理成 prompt。
  4. 调用 BlueLM 生成回答。
  5. 如果端侧不可用,再提示用户或回退到云端链路。

这样做的目标是让回答尽量基于用户正在读的内容,而不是让模型随便发挥。

简单说,云端模型负责更强的理解和总结,端侧模型负责移动端本地可用性和弱网兜底。两边不是互相替代,而是配合使用。

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 的文档检索、上下文组织结合起来。

最终效果就是:用户在手机上阅读资料时,不只能在线问云端模型,也可以在端侧模型可用的情况下获得本地化的辅助理解体验。