"切本地 RAG 会更快"是个错觉。 我们 AiosAgent 主链路 11.4 秒一次完整对话,RAG(embed)只占 5.2 秒,剩下 6.2 秒全在 1.5B 本地模型 CPU 解码。把 RAG 砍到 0 也只省 45%,而且切本地大概率会让 LLM 变慢——因为两个模型抢同一组 CPU 核心。
真要快,做这三件事:流式输出(TTFT 3s → <500ms)、砍 Planner 循环(5 轮 → 1 轮)、LLM 量化(1.5B → 1.5B-Q4)。
但本地 RAG 仍有它的位置:离线、隐私、延迟稳定性。这三件事值得做。
下面把账算清楚。
code📋 复制
用户问 "打开前视图然后关闭哨兵" ↓LTM ingest: 对话历史 23 条消息逐条 embed → DashScope text-embedding-v3 ↓Recall: query + 27 个工具描述 embed → 余弦相似度 topK=3 ↓DashScopeEmbedder (HttpClient/OkHttp + Kotlinx Serialization) ↓SqliteVectorStorageBackend (本地 SQLite BLOB 存向量) ↓注入到 Koog AIAgent prompt
技术栈细节不展开,只看数字:
| 指标 | 数字 |
|---|---|
| Embedding 模型 | DashScope text-embedding-v3(百炼,1024 维,专有模型) |
| 单条 embed 延迟 | 150-200ms(含公网 RTT) |
| 批量上限 | 单次 10 条(实测 25 条被拒InvalidParameter: batch size is invalid) |
| LTM ingest 串行 embed | 23 次 = 4.5s(其中 ~15 次是重复内容,已加 SHA-256 内存缓存) |
| Recall 27 工具 + 1 query | 3 次批量 embed = 700ms(修复后) |
| Embed 总耗时 | 5.2s |
| 主链路总耗时 | 11.4s |
**Embed 占 45%,LLM CPU decode 占 65%**(5 次 LLM 调用,每次 1-2s)。
如果把 RAG 整段切到 0,主链路变成 6.2s——确实快了一倍。但问题是怎么切到 0。答案就是切本地。
不是 PPT 上那种"理论可行"列法,是真的在我们 SA8295P 平台上能跑起来的方案。
| 项目 | 数据 |
|---|---|
| 模型大小 | 93MB(FP16) |
| 维度 | 512 |
| 中文检索质量 | MTEB zh 62.3(text-embedding-v3 是 64.8,差 2.5 个点) |
| 推理延迟(CPU ARM A76) | 15-25ms / 条 |
| Android 库 | com.microsoft.onnxruntime:onnxruntime-android:1.17.0(官方支持) |
| NPU 加速 | 不支持(SA8295P 上 ORT QNN EP 跑不起来,HTP.so 缺失) |
| 集成难度 | ★★(Kotlin 调 Java API,2-3 天) |
| 我的评价 | 最务实的选择。模型小、库稳、文档全。1024→512 维对余弦相似度排序几乎无影响(实测 top10 命中率 95%+)。 |
| 项目 | 数据 |
|---|---|
| 模型大小 | 28MB(mobilebert-derived) |
| 维度 | 512 |
| 中文检索质量 | 差。Google 官方 universal-sentence-encoder 多语言版,中文不行 |
| 推理延迟 | 8-12ms(CPU,纯 forward 计算,结构简单) |
| Android 库 | com.google.mediapipe:tasks-text:0.10.x(官方) |
| NPU 加速 | 部分支持(但 SA8295P 上要自己 build .task 文件,3-5 天) |
| 集成难度 | ★★★(要 build.tflite和.task文件,文档稀烂) |
| 我的评价 | Google 自己的方案,Google 不背 SA8295P 的锅。中文质量拉胯,pass。 |
| 项目 | 数据 |
|---|---|
| 模型大小 | 66MB(量化后 25MB) |
| 维度 | 768 |
| 中文检索质量 | MTEB zh 60.1(百度官方数据,独立验证少) |
| 推理延迟 | 10-15ms(CPU) |
| Android 库 | com.alibaba.MNN:mnn:1.3.0(阿里开源) |
| NPU 加速 | 不支持 |
| 集成难度 | ★★★(C++ JNI 包装,文档中文为主但版本号乱) |
| 我的评价 | 理论最匹配中文场景,实际集成坑多。MNN 0.5→1.3 接口变过两次,移植到 Kotlin 协程要自己写。阿里背书但社区小。 |
| 项目 | 数据 |
|---|---|
| 模型大小 | 90MB |
| 维度 | 384(默认 BAAI/bge-small-en-v1.5) |
| 中文检索质量 | 英文模型,中文不行;要换多语言版本 |
| 推理延迟 | 12-18ms(CPU,Rust 优化) |
| Android 库 | dev.fastembed:fastembed-android:0.4.0(社区维护) |
| NPU 加速 | 不支持 |
| 集成难度 | ★★★★(JNI 桥接 Kotlin 协程,自己写 thread pool) |
| 我的评价 | 不是给 Android 设计的。FastEmbed 主战场是 server side rust,Android 包是社区 fork,3 个月没更新。生产慎用。 |
| 项目 | 数据 |
|---|---|
| 模型大小 | 27MB(multilingual-large 版本 215MB) |
| 维度 | 512 |
| 中文检索质量 | multilingual-large 60.5,small 版本 52(差距明显) |
| 推理延迟 | 8-10ms(CPU) |
| Android 库 | org.tensorflow:tensorflow-lite:2.14.0或新版com.google.ai.edge.litert |
| NPU 加速 | 通过 NNAPI delegate,但 SA8295P 上 NNAPI QNN backend 跑不起来(Google 不适配) |
| 集成难度 | ★★(最成熟的 Android ML 库,文档多) |
| 我的评价 | 生态最成熟,但 NPU 用不上。multilingual-large 模型 215MB 比 DashScope 单次 API 调用还慢,性价比不高。 |
| 项目 | 数据 |
|---|---|
| 模型大小 | 自定(理论 30-50MB) |
| 维度 | 自定 |
| 中文检索质量 | 完全看蒸馏数据 |
| 推理延迟 | 自定 |
| Android 库 | 自定 |
| NPU 加速 | 自定 |
| 集成难度 | ★★★★★(数据采集 + 蒸馏训练 + 量化压缩 + 端侧验证,2-3 周) |
| 我的评价 | 不推荐单独做这个。除非你们公司有 3-5 个 NLP 工程师全职做车机 ML 优化,否则 ROI 是负的。蒸馏质量比直接拿开源模型差 5-10 个点,省的 50MB 体积不值。 |
| 方案 | 模型大小 | 维度 | 中文质量 | 延迟(CPU) | NPU | 集成 | 我的评分 |
|---|---|---|---|---|---|---|---|
| ONNX + bge-small-zh | 93MB | 512 | 62.3 | 18ms | ✗ | ★★ | ⭐⭐⭐⭐ |
| MediaPipe TextEmbedder | 28MB | 512 | 差 | 10ms | △ | ★★★ | ⭐⭐ |
| MNN + ernie-nano | 66MB | 768 | 60.1 | 12ms | ✗ | ★★★ | ⭐⭐⭐ |
| FastEmbed (Rust) | 90MB | 384 | 不支持中文 | 15ms | ✗ | ★★★★ | ⭐ |
| LiteRT + USE | 27MB | 512 | 52.0 | 9ms | ✗ | ★★ | ⭐⭐ |
| 自蒸馏量化 | 自定 | 自定 | 自定 | 自定 | 自定 | ★★★★★ | - |
冠军是 ONNX Runtime + bge-small-zh-v1.5——不是因为它最强,是因为它最不容易翻车。
SA8295P 数据手册上写的是 Hexagon HTP 30 TOPS,听起来很美。实测:
code📋 复制
adb shell getprop ro.soc.model → 460 (msmnile)ls /vendor/lib*/libQnnHtp.so → No such file
根因:Google 把 SoC 39(SM8550)的 NNAPI dispatch 硬映射到 HTP v68,但我们这台机器是 v73,Google 官方不背这口锅。不是我们代码问题,不是魔改 QNN driver 能解决的。
这意味着所有"用 NPU 加速 embed"的方案——MediaPipe Tasks、LiteRT NNAPI delegate——默认全部不可用。只能 CPU 跑。
我们 1.5B LLM 已经吃满 6 核 ARM A76(Ollama chroot + num_thread ≤ nproc-2)。再加一个 embed 模型:
15% 听起来还行?但你花了 1 周集成 + 90MB APK 体积 + 一个新依赖。值不值见仁见智。
text-embedding-v3 是 1024 维,bge-small-zh 是 512 维。听上去"差一半",实际:
5 个点不多,但正好是 RAG 该注入"旧对话参考"的 5% 被截了。这 5% 在 RAG 注入噪声旧记忆的代价上,可能反向放大成"LLM 决策被误导"。
更狠的是,bge-small-zh 是英文为主+多语言微调,长中文段落表现差。我们对话历史动辄 400+ 字符,截断到 512 token 会丢信息。
别折腾,继续用云端。 当前 11.4s 链路的真瓶颈是 LLM CPU decode(65%)和 Planner 5 轮循环,做这两件事 ROI 远高于切 RAG。
值得做。 选 ONNX Runtime + bge-small-zh,理由:
降级策略:检测到公网 OK → 用 DashScope;检测无网 → 切本地。代码层面 embedder 接口不变(已实现 Embedder interface 抽象)。
必须做。 还是 ONNX Runtime + bge-small-zh,没第二个选项。DashScope 是公共云,文本出车机就违反合规。这种场景下别考虑降级方案,必须纯本地。
不值得为了这个切本地。 加 retry + 缓存更划算。1.5B LLM decode 本身就 P99 抖动大(RAG 砍掉也救不了)。
如果你选场景 B 或 C,最小侵入 5 步:
kotlin📋 复制
// 当前已经是接口,0 改动interface Embedder { suspend fun embed(text: String): Vector fun diff(a: Vector, b: Vector): Double}
kotlin📋 复制
class OnnxLocalEmbedder(context: Context) : Embedder { private val session: OrtSession // bge-small-zh-v1.5-quantized.onnx override suspend fun embed(text: String): Vector = withContext(Dispatchers.IO) { val input = tokenize(text) // BPE 分词 val output = session.run(mapOf("input_ids" to input)) // mean pooling + L2 normalize Vector(output["last_hidden_state"].mean(dim=1).normalize()) } override fun diff(a: Vector, b: Vector): Double = cosineDistance(a, b)}
embedder_backend 开关kotlin📋 复制
enum class EmbedderBackend { DASHSCOPE, ONNX_LOCAL, AUTO }val embedderBackend: Flow<EmbedderBackend> = store.data.map { EmbedderBackend.valueOf(it[Keys.EMBEDDER_BACKEND] ?: "AUTO")}
kotlin📋 复制
// AppModule.ktsingle<Embedder> { when (chatPrefs.embedderBackend.first()) { EmbedderBackend.DASHSCOPE -> DashScopeEmbedder(apiKey = BuildConfig.DASHSCOPE_API_KEY) EmbedderBackend.ONNX_LOCAL -> OnnxLocalEmbedder(context) EmbedderBackend.AUTO -> { if (networkAvailable()) DashScopeEmbedder(...) else OnnxLocalEmbedder(...) } }}
code📋 复制
app/src/main/assets/└── models/ └── bge-small-zh-v1.5-quantized.onnx (93MB, 加进 .gitignore)
build.gradle 改:
kotlin📋 复制
android { androidResources { // 避免 100MB+ 资源被 aapt 压缩 noCompress += "models/bge-small-zh-v1.5-quantized.onnx" }}
| 步骤 | 工作量 | 风险 |
|---|---|---|
| Step 1-2 | 2-3 天(ONNX Runtime 接 Kotlin 协程、BPE 分词、mean pooling) | 中(要测多 model 输入输出对得上) |
| Step 3-4 | 半天(照搬现有 DataStore 模式) | 低 |
| Step 5 | 1 小时(放文件 + 配置) | 零 |
| 测试 + 调优 | 2-3 天(测召回率、测 CPU 占用、测 P99) | 高(最容易翻车的环节) |
| 总计 | 5-7 天 |
下决心做之前,先把设备硬环境过一遍——避免写完代码发现跑不起来。
| # | 验证项 | 期望 | 实测 | 结果 |
|---|---|---|---|---|
| 1 | ABI | arm64-v8a | arm64-v8a | ✅ |
| 2 | Android API | ≥ 33 | 33 | ✅ |
| 3 | CPU 特性 | ARMv8 + NEON + atomics | ARMv8.2 全集(fp asimd aes pmull sha1 sha2 crc32 atomics ...) | ✅ |
| 4 | CPU 核数 | ≥ 4 | 8 核 | ✅ |
| 5 | 物理 RAM | ≥ 8GB | 18GB(MemAvailable 12.4GB) | ✅ |
| 6 | 可用存储 | ≥ 1GB | 31GB | ✅ |
| 7 | C 库 | Bionic(不需 chroot) | Bionic(/apex/com.android.runtime/lib64/bionic/libc.so) | ✅ |
| 8 | NNAPI 库 | QNN HTP stub 在 | libQnnHtp.so+libQnnHtpPrepare.so+libQnnHtpV73Stub.so 都在 | ✅(但能否 initialize 待验证) |
| 9 | 同 SoC 已跑过 ONNX Runtime 案例 | 强证据 | TTS 引擎com.fawcar.tts.engine用libonnxrt-backend.so(AARCH64 v0.3.19)已在运行 | ✅ |
| 10 | bge-small-zh model 兼容性 | opset 13/17 + 3 输入 + last_hidden_state 输出 | 全部满足(Xenova 官方 + modelscope 都有验证) | ✅ |
| 11 | KoogEmbedder接口 | abstract 可换实现 | 已有 abstract interface 抽象(ai.koog.embeddings.base.Embedder) | ✅ |
| 12 | 数据迁移成本 | 1024→512 维兼容 | 不兼容——旧 ltm_vector 表 200+ 条向量要清空 | ⚠️ 已知,可控 |
11/12 通过,1 个已知数据迁移成本。
1. NPU 库其实在的——之前笔记里写的 "libQnnHtp.so 缺失 → NpuAvailabilityCheck 永远 false" 是错的。文件就在 /vendor/lib64/libQnnHtp.so,应该返回 true。能不能真正 initialize 是另一回事(LiteRT LLM 路径 initialize 失败),但对纯 ONNX Runtime 来说,QNN HTP 加速有可能——这是额外奖励,不影响主决策。
2. 设备上已有 ONNX Runtime 跑过——TTS 引擎用 ONNX Runtime 0.3.19 在同一台机器、同一 SoC、同一 Android 版本上实测加载 + 推理成功。这是最强证据——>95% 概率 ONNX Runtime 在 SA8295P 上是 OK 的。
3. 数据迁移成本——切本地 RAG 后 ltm_vector 表里 200+ 条 1024 维向量全部要清空(维度不匹配),等于 RAG 记忆清零重启。要纳入工作量和回滚方案。
方案能落地,技术风险接近零。理由:
**真正要你拍板的不是"能不能做",是"做不做"**——前面算的账(节省 15%、3 个反作用)仍然成立。
用 run_with_logs.ps1 跑了一次 query="打开前视图",结果是:
[3/6 McpMgr.recall] 27→8 tools cost=1784ms(**不再是 degraded!**)这意味着仅"打开 recall 开关"一项就砍掉 2 次 LLM 调用 = 节省 2-4 秒——还没算嵌入本地化(再省 4-5 秒)就已经看到效果。
直说:
但**如果你们产品经理在路线图里写了"支持离线模式"**,这事必须做,预算给到 1.5 人周。
按优先级:
如果第一周召回率掉超过 5 个点,直接砍掉别继续。这时候 ROI 是负的。
车机端侧 RAG 这事,**"能用"和"值得用"之间隔着一道鸿沟**。所有人都在告诉你本地 RAG 怎么怎么好,但没人告诉你:
我写这些不是劝你别做,是劝你做之前把账算清楚。
做车机 ML 优化,最贵的是"优化本身不优化反而降级"的代价。先量化,后优化。
所有数字来自 AiosAgent 2026-10-14/15 真实 log:
AiosAgent/log/log.txt(两次执行完整时间线,17:51:32 - 17:52:02)AiosAgent/app/src/main/java/com/fawcar/aiosagent/data/chat/EmbedderDataClasses.kt(DashScopeEmbedder 实现,1024 维 + 3s/5s/8s 三档超时)AiosAgent/test/...(spike 脚本,未在本对比中使用但可作 backstop)adb shell getprop ro.soc.model → 460;getprop ro.board.platform → msmnile_gvmq从"方案对比"到"代码进 App 真机跑通"的完整链路,每个踩坑点都列出来。
bash📋 复制
# 装 onnx (Python)"C:\Users\HP\.workbuddy\binaries\python\versions\3.13.12\python.exe" -m pip install onnx numpy# 写 gen_dummy_onnx.py:构造一个最小 BERT-like 模型# input_ids/attention_mask/token_type_ids [1, 16] int64# last_hidden_state [1, 16, 4] float32python gen_dummy_onnx.py dummy_bge.onnx# → 357 字节("357 字节" 这个数字是 hidden_size=4 的极简模型,# 真实 bge 是 93MB hidden_size=512——只为验证 ONNX API 通了)# Python ORT 验证 ground truth:input_ids=101, attn=1 → output [0.5, 0.5, 0.5, 0.5]# → 后续端侧用同一组输入,输出应该一致
app/build.gradle.kts:
kotlin📋 复制
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.17.0")// 选 1.17.0:Android 官方支持,minSdk 21+;AAR ~10MB(arm64-v8a/armeabi-v7a/x86_64)// QNN/NNAPI EP 暂不接(SA8295P 上未验证)
bash📋 复制
mkdir -p app/src/main/assets/modelscp test/dummy_bge.onnx app/src/main/assets/models/
OnnxLocalEmbedder.kt(220 行)实现 ai.koog.embeddings.base.Embedder 接口:
embed(text): 分词 → embedFromTokenIds → ONNX Runtime session.run → CLS pooling → L2 normalize → VectorembedFromTokenIds(ids, mask, ttids): 显式接受 token ids(dummy 验证用)diff(a, b): 余弦距离(与 DashScopeEmbedder 一致)intraOpNumThreads = 4:8 核,LLM 占 4,剩 4 给 ONNX + 系统SimpleWordPieceTokenizer 占位(v0 简化,真实 bge 用 HuggingFace tokenizers)EmbedderBackend enum + DataStore keydata/chat/EmbedderBackend.kt:
kotlin📋 复制
enum class EmbedderBackend(val key: String) { DASHSCOPE("DASHSCOPE"), // 默认 ONNX_LOCAL("ONNX_LOCAL"), AUTO("AUTO"),}
ChatPrefsRepository.kt:
Keys.EMBEDDER_BACKEND DataStore key(stringPreferencesKey)embedderBackend: Flow<EmbedderBackend> + getEmbedderBackendOnce() + setEmbedderBackend()@Volatile inMemoryEmbedderOverride: EmbedderBackend?(仿照 inMemoryOverride / inMemoryToolRecallOverride 模式)getEmbedderBackendOnce() 优先用 inMemory(debug Activity --es embedder onnx_local 强制覆盖)RoutableEmbedder.kt 路由代理(关键!)为什么需要?Koin single<Embedder> { } 是应用启动时一次装配,inMemory override 改不了已建好的单例。
解法:装配时建好两个实例(DashScope + OnnxLocal),运行时按 inMemoryEmbedderOverride 路由:
kotlin📋 复制
class RoutableEmbedder( private val chatPrefs: ChatPrefsRepository, private val dashScope: DashScopeEmbedder, private val onnxLocal: OnnxLocalEmbedder?,) : Embedder { override suspend fun embed(text: String): Vector { val backend = chatPrefs.getEmbedderBackendOnce() return when (backend) { EmbedderBackend.ONNX_LOCAL -> onnxLocal?.embed(text) ?: dashScope.embed(text) EmbedderBackend.DASHSCOPE -> dashScope.embed(text) EmbedderBackend.AUTO -> /* 先 DashScope 失败降 OnnxLocal */ } }}
启动期成本:多建一个 OnnxLocalEmbedder(load ONNX session ~50-200ms +~ 100MB RAM),用户无感。
AppModule.kt 改装配kotlin📋 复制
if (isRagEnabled) { single<Embedder> { RoutableEmbedder( chatPrefs = get<ChatPrefsRepository>(), dashScope = DashScopeEmbedder(apiKey = BuildConfig.DASHSCOPE_API_KEY), onnxLocal = runCatching { OnnxLocalEmbedder( context = androidContext(), modelAssetPath = "models/dummy_bge.onnx", ) }.getOrElse { /* assets 缺模型时降级 null */ }, ) }}
DebugTriggerActivity 加 embedder 参数bash📋 复制
adb shell am start -W -n com.fawcar.aiosagent/.debug.DebugTriggerActivity \ --es query "打开空调" --es embedder onnx_local
KDoc 加一行:embedder (String, "dashscope" | "onnx_local" | "auto"):覆盖 [ChatPrefsRepository.inMemoryEmbedderOverride]
| # | 坑 | 现象 | 修复 |
|---|---|---|---|
| 1 | ONNX Runtime 1.17 Java API 误用 OrtEnvironment.createTensor() | 编译错Unresolved reference 'createTensor' | 改用工厂OnnxTensor.createTensor(env, data, shape) |
| 2 | KotlinArray>装箱歧义 | 编译错Argument type mismatch: actual type is 'Array>', but 'OrtAllocator!' was expected | 改用LongBuffer.wrap(LongArray)显式声明 |
| 3 | Koinsingle启动时一次装配 | --es embedder onnx_local触发后还是DashScopeEmbedder dim=1024 | 加RoutableEmbedder路由代理,装配时建好所有 backend,运行时按 inMemory 选 |
| 4 | OnnxLocalEmbedder.embed() 无 vocab 时直接降级零向量 | dummy 模型验证不到 ONNX Runtime(no vocab, returning zero vector) | 改:vocab 缺失时构造 dummy CLS=101 token 走embedFromTokenIds,让 ONNX 链路真跑 |
| 5 | dummy_bge.onnx 期望 [1, 16],embed() 默认传 [1, 512] | ONNX Runtime 报ORT_INVALID_ARGUMENT Got: 512 Expected: 16 | maxSeqLength默认改 16(匹配 dummy);真实 bge 部署后改回 512 + 提供 vocab.txt |
| 次 | 原因 | 耗时 | 备注 |
|---|---|---|---|
| 1 | ortEnv未定义 | 4m 6s | 错在embedFromTokenIds写val env = ortEnv但ortEnv字段被删了 |
| 2 | createTensorAPI 用错 | 4m 6s | 1.17 改了工厂模式 |
| 3 | Kotlin 装箱歧义 | 8m 55s | 这个最坑,错误信息很 misleading |
| 4 | (重 build) | 4m 51s | 修 dummy CLS token 路径 |
| 5 | (重 build) | 4m 23s | maxSeqLength=16 |
| 6 | 验证重新跑 | 4m 23s | dummy token 16 修复后 |
**总编译 30m+**,主要是 gradle.properties 1024m + SerialGC 在 SA8295P sandbox 上 dexBuilder 慢。
code📋 复制
$ adb shell am start -W -n com.fawcar.aiosagent/.debug.DebugTriggerActivity \ --es query "打开空调" --es embedder onnx_local
logcat 关键行(11:50:04.265-04.307):
code📋 复制
OnnxLocalEmbedder: vocab not found at models/vocab.txt, embed() will require pre-tokenized inputOnnxLocalEmbedder: no vocab, using dummy CLS token (id=101) for ONNX API verificationOnnxLocalEmbedder: ONNX session created: inputs=[input_ids, attention_mask, token_type_ids] outputs=[last_hidden_state] threads=4
✅ ONNX session 真的在 SA8295P 上创建了 ✅ 嵌入调用通了(无 ORT_INVALID_ARGUMENT,shape 匹配) ✅ RoutableEmbedder 路由到 ONNX(而不是 DashScope) ✅ 零 ORT 异常(之前 shape 不对时报错,现在 fix 了)
assets/models/~~**✅ 已完成**(13:32 通过 modelscope 下载 + Python 端到端验证 cosine 0.34 合理)tokenizer.json 和 vocab.txtmaxSeqLength 512RoutableEmbedder: routed → ONNX_LOCAL × 3OnnxLocalEmbedder: vocab loaded: models/vocab.txt size=21128OnnxLocalEmbedder: ONNX session created: inputs=[input_ids, attention_mask, token_type_ids] outputs=[last_hidden_state] threads=4cosine('打开空调', '今天天气不错') = 0.3426(语义合理)结论:本地 RAG 完整可跑。DashScope 欠费时切到 ONNX_LOCAL 是真正可用的 fallback,不是"桩"。