Heimdall Path:把大模型装进手机,让 AI 成为视障者的出行之眼
Heimdall Path:把大模型装进手机,让 AI 成为视障者的出行之眼
Your AI guardian on every step.
从一个开发者视角,记录如何用 YOLOv8 + MNN + Qwen3.5 在 Android 端构建一套完整的视障出行辅助系统——从第一行代码到真机跑通的全过程。
起因:为什么我要做这个
一个被忽略的人群
中国有超过 1700 万 视障人士,其中全盲约 800 万。这个数字是什么概念?大概相当于整个深圳市的常住人口。
但你在街上几乎看不到他们。
不是因为他们不存在,而是因为出门太难了。
盲道形同虚设——被共享单车、停放的汽车、流动摊贩、施工围挡占据,是城市中最"常见"的无障碍设施,也是最"不可靠"的安全通道。导航 App 能告诉你"500 米后右转",却不会告诉你右转之后盲道被一辆货车挡住了。低视力用户白天尚能感知模糊轮廓,到了晚上或者逆光环境下,视觉信息几乎完全丧失。一个人走到陌生的地方,找不到地铁入口在哪,不知道公交站台在马路哪一侧,不知道前方是路口还是围墙——每一次求助都在暴露自己的脆弱。
久而久之,越来越不敢出门,生活半径从"整座城市"压缩到"从家到小区门口"。
Heimdall 的含义
项目取名 Heimdall Path,来自北欧神话里的守护之神海姆达尔(Heimdall)。他是阿斯加德的守望者,拥有九界的视力,日夜不眠地守望着彩虹桥,任何危险都逃不过他的眼睛。
我觉得这个名字很贴切——这个应用要做的事情,就是成为视障者出行路上的那双眼睛:永不疲倦、从不走神,用摄像头持续注视前方,用语音和震动告诉你该往哪里走、哪里有危险。
它不是要取代盲杖、导盲犬或无障碍设施,而是在手机端构建一个**"帮助用户感知前方、听懂语音提问、及时提醒风险"的 AI 出行守护系统**。
作为一个开发者的思考
作为一个整天跟代码打交道的开发者,我一直在关注端侧 AI 的进展。手机芯片越来越强,NPU 算力逐年翻倍,大模型量化技术从 FP16 到 INT8 再到 4bit 甚至更低——这些技术栈成熟到什么程度了?能不能真正跑出一个有用的东西?
同时我也在观察一个现象:市面上绝大多数 AI 应用要么是云端 API 套壳(调个 GPT 接口加个聊天 UI),要么是纯技术 demo(跑个 benchmark 截张图)。真正深入到一个具体场景里、解决一类人的实际问题的东西太少了。
所以我想做一个有挑战性的事:
- 不依赖云端推理——核心安全能力必须在端侧完成
- 不是聊天机器人套壳——要有真实的视觉感知和决策能力
- 面向真实用户群体——而不是面向开发者圈子自嗨
- 技术上要有深度——覆盖模型训练转换、运行库编译优化、多模型调度、系统级集成
这就是 Heimdall Path 的起点。一个 developer side project,带着一点理想主义,也带着大量工程上的脏活累活。
技术路线的初步选择
动手之前我先画了几条红线:
红线一:安全决策不能交给大模型。 当一个人看不见路的时候,他需要的是"向左绕行"这种确切指令,而不是"前方似乎有一个可能是自行车的物体"这种含糊描述。生成式大模型的输出不可控、有延迟、可能产生幻觉——这些特性在安全场景里是致命的。所以我决定用传统视觉模型(YOLOv8)+ 规则引擎(DecisionEngine)来做安全通道,大模型只负责自然语言交互层。
红线二:必须能在手机上离线运行核心功能。 视障用户可能在地下通道、地铁隧道、偏远路段等各种弱网甚至无网环境。如果每个请求都要走云端 API,那这套系统的可靠性就大打折扣。这意味着我需要把模型部署到端侧——而这正是 MNN 和 Qwen 发挥作用的地方。
红线三:交互必须无障碍。 用户看不见屏幕,所以所有信息输出必须是语音 + 震动。这听起来简单,但对 TTS 的延迟、播报优先级、防抖策略、打断逻辑都提出了具体要求。
基于这三条红线,我的技术选型逐渐清晰起来:
视觉安全 → YOLOv8 (ONNX Runtime) ← 快、稳、可控
文本问答 → Qwen3.5-0.8B (MNN) ← 本地、离线、中文友好
语音识别 → Android SpeechRecognizer ← 系统自带、无需额外 SDK
语音合成 → Android TextToSpeech / EdgeTTS ← 按需选择
地图服务 → 腾讯地图 MCP ← 宏观路线规划
打车辅助 → 滴滴 MCP ← 远距离出行补充
硬件加速 → Arm SME2 (MNN 运行库) ← 充分利用 CPU 新指令集
先看效果
App 初始化后的主界面:

核心逻辑是这样的:当盲人用户走在路上,手机后置摄像头实时采集画面,YOLOv8 模型在端侧跑推理,检测到盲道之后直接引导用户对齐方向。如果前方有障碍物——汽车、行人、电线杆——系统会立刻预警。


真机上的实际运行效果:

本地部署的千问模型正在工作:

甚至 thinking 模式也能正常输出:

技术架构:双通道分层设计
做这个项目之前,我先想清楚了一个问题:安全决策和大模型问答必须解耦。
为什么?因为当一个人看不见路的时候,他需要的是"向左绕行",而不是"前方似乎有一个可能是自行车的物体"。生成式大模型的输出不可控、有延迟、可能产生幻觉——这些特性在安全场景里是致命的。
所以我的架构分成两条通道:
═══ 高优先级:实时安全通道 ═══
CameraX 实时画面
→ YOLOv8 障碍物检测 + 盲道分割(ONNX Runtime 端侧推理)
→ PostProcessor 后处理(检测框解析、NMS、掩码重建)
→ DecisionEngine 安全决策(虚拟通行走廊 + 风险规则)
→ RoadState 结构化路况 JSON
→ 震动反馈 / 短语音 / UI 辅助显示
═══ 低优先级:语音问答通道 ═══
用户语音(讯飞 ASR / Android SpeechRecognizer)
→ 意图分发(前方问题?地图问题?打车问题?普通问答?)
→ "前方安全吗?" → RoadState 确定性回答(不经过大模型)
→ 通用问题 → Qwen3.5-0.8B-MNN 本地文本推理
→ MCP 工具调用(腾讯地图 / 滴滴打车)
→ EdgeTTS 语音播报
关键原则就一句话:高风险安全提醒不等待大模型生成。大模型只负责把结构化状态转换为更自然的简短回答。
视觉模型选型:为什么是 YOLOv8
视觉安全通道我选了两个 YOLOv8n 模型:
| 模型 | 用途 | 大小 |
|---|---|---|
obstacles_det.onnx |
障碍物检测(行人、车辆、自行车、路桩、树木等) | ~11.5 MB |
blind_path_seg.onnx |
盲道/通行区域语义分割 | ~12.5 MB |
两个模型加起来才约 24 MB。选择 nano 版本的原因很直接:
- 够轻,手机端持续跑 3-10 FPS 没压力
- 输出结构化,检测框 + 分割掩码,便于后处理
- 延迟可控,比生成式视觉大模型稳定得多
- 工程链路成熟,训练、导出、ONNX Runtime 推理全链路我都跑通了
YOLO 的推理入口在 YoloModel.java 里,核心调用大致是这样:
// YoloModel.java - 核心推理流程
public class YoloModel {
private OrtSession session;
public List<DetectionResult> runInference(Bitmap bitmap) {
// 1. 预处理:缩放到 640x640,归一化
float[][][] inputBuffer = preprocess(bitmap, 640, 640);
// 2. 构建 ONNX 输入 tensor
OnnxTensor inputTensor = OnnxTensor.createTensor(ortEnv, inputBuffer);
// 3. 推理
OrtSession.Result result = session.run(
Collections.singletonMap("images", inputTensor)
);
// 4. 后处理:NMS + 置信度过滤
return postProcess(result);
}
}
文本模型选型:Qwen3.5-0.8B on MNN
语音问答通道的"大脑",我选择了魔搭社区官方提供的 MNN/Qwen3.5-0.8B-MNN。
选 0.8B 这个规格是经过权衡的:
- 体积可控:4bit 量化后约 400-800 MB,16GB RAM 的手机完全扛得住
- 已有官方 MNN 包:降低自转换的兼容风险
- 多模态内置:包里自带
visual.mnn/visual.mnn.weight,不需要额外引入视觉模型 - 能力边界清晰:它负责自然语言理解和短回答生成,不接管避障决策
模型文件结构长这样:
D:\AI model\mnn\Qwen3.5-0.8B-MNN\
├── config.json # 模型配置
├── llm_config.json # LLM 运行配置(chat template 等)
├── llm.mnn # 主模型图结构
├── llm.mnn.weight # 权重文件
├── tokenizer.txt # 词表
├── visual.mnn # 视觉模块(多模态兜底用)
└── visual.mnn.weight # 视觉权重
Android 端加载和调用的核心代码在 QwenTextManager.java 中:
// QwenTextManager.java - MNN 模型加载与推理
public class QwenTextManager {
private static final String MODEL_DIR_NAME = "qwen3_5_0_8b_mnn_official";
private LlmSession llmSession;
public void loadModel(Context context) {
File modelDir = new File(context.getFilesDir(),
"mnn_models/" + MODEL_DIR_NAME);
File configFile = new File(modelDir, "config.json");
// 创建 LlmSession 并加载模型
llmSession = LlmSession.create(configFile.getAbsolutePath());
// 设置最大生成长度,控制延迟
llmSession.setMaxNewTokens(80);
}
public String ask(String userQuestion, String roadStateJson) {
// 构造 system + user 两段 prompt
List<ChatMessage> history = new ArrayList<>();
history.add(new ChatMessage("system", buildSystemPrompt()));
history.add(new ChatMessage("user",
buildUserPrompt(userQuestion, roadStateJson)));
// 提交推理请求
String response = llmSession.submitFullHistory(history);
return postProcessResponse(response);
}
}
这里有个细节值得说:我把输入从早期版本的"把所有约束塞进一整段 user prompt"改成了 system + user 两段式,更贴近模型 llm_config.json 里的 chat template 设计,实测下来答非所问的概率明显降低了。
重头戏:MNN 端侧部署全流程
这一节是整篇文章最硬核的部分——如何把一个大模型真正跑到手机上。
第一步:从魔搭社区拉取官方 MNN 模型
好消息是,MNN 团队已经在魔搭社区提供了预转换好的 Qwen3.5-0.8B-MNN 模型包,不需要自己从 PyTorch 格式转换。拉取命令如下:
# 创建本地模型目录
New-Item -ItemType Directory -Force -Path "D:\AI model\mnn"
# 从魔搭社区克隆模型仓库
git clone https://www.modelscope.cn/MNN/Qwen3.5-0.8B-MNN.git `
"D:\AI model\mnn\Qwen3.5-0.8B-MNN"
# 如果已 clone,更新并拉取 LFS 大文件
git -C "D:\AI model\mnn\Qwen3.5-0.8B-MNN" pull
git -C "D:\AI model\mnn\Qwen3.5-0.8B-MNN" lfs pull
拉完之后务必校验一下文件完整性:
Get-ChildItem -LiteralPath "D:\AI model\mnn\Qwen3.5-0.8B-MNN" `
| Select-Object Name, Length
必须确认以下 7 个文件都在:
config.json ✓ (必需)
llm_config.json ✓ (必需)
llm.mnn ✓ (必需)
llm.mnn.weight ✓ (必需)
tokenizer.txt ✓ (必需)
visual.mnn ✓ (多模态必需)
visual.mnn.weight ✓ (多模态必需)
第二步:PC 端 smoke test —— 先确保模型本身没问题
在推到手机之前,我习惯先在 PC 上验证模型能正常工作。这需要编译 MNN 自带的 llm_demo 工具:
cmake `
-S "D:\AI model\framework\MNN" `
-B "D:\AI model\framework\MNN\build_mingw_llm" `
-G "MinGW Makefiles" `
-DCMAKE_MAKE_PROGRAM="D:/mingw64/bin/mingw32-make.exe" `
-DCMAKE_C_COMPILER="D:/mingw64/bin/gcc.exe" `
-DCMAKE_CXX_COMPILER="D:/mingw64/bin/g++.exe" `
-DMNN_BUILD_LLM=ON `
-DMNN_BUILD_LLM_OMNI=ON `
-DMNN_BUILD_CONVERTER=OFF `
-DMNN_BUILD_TEST=OFF `
-DMNN_BUILD_BENCHMARK=OFF `
-DCMAKE_CXX_STANDARD_LIBRARIES="-lws2_32" `
-DCMAKE_C_STANDARD_LIBRARIES="-lws2_32"
cmake --build "D:\AI model\framework\MNN\build_mingw_llm" --target llm_demo -- -j 8
编译完成后,准备一个测试 prompt 然后跑起来:
$Demo = "D:\AI model\framework\MNN\build_mingw_llm\llm_demo.exe"
$Config = "D:\AI model\mnn\Qwen3.5-0.8B-MNN\config.json"
# 直接交互模式
& $Demo $Config
# 或者用 prompt 文件模式
"前面安全吗?请用一句中文回答。" | Set-Content -Encoding UTF8 prompt.txt
& $Demo $Config "prompt.txt"
通过标准很简单:能加载 config.json,能输出可读中文,没有乱码或无限重复。我本机跑出来的结果类似这样:
前方盲道畅通,请留意右侧停放着的一辆自行车等待通行。
是的。
看到这个输出,说明模型文件、tokenizer 和 PC 端 MNN runtime 都没问题,可以放心往手机上推了。
第三步:编译包含 Arm SME2 加速的 Android MNN 运行库
这里是整个部署链路中最容易踩坑的地方,也是我想重点讲的。
先澄清一个常见误解:Arm SME2 不是模型转换开关,它是 MNN 运行库 libMNN.so 的编译能力。
也就是说:
.mnn模型文件决定"模型是什么、权重怎么量化"libMNN.so决定"这台手机上用什么 CPU kernel 跑"- 只要
libMNN.so包含 SME2 kernel 且当前手机 CPU 支持 sme2,MNN 会在运行期自动选择 SME2 路径;如果不支持,自动回退到 i8mm、bf16、dotprod 或通用 arm64 路径
编译 SME2 版本的 libMNN.so 需要 NDK 环境。在 Windows 上我用 Git Bash 来执行 MNN 的构建脚本:
# 设置环境变量
export ANDROID_HOME="/c/Users/27890/AppData/Local/Android/Sdk"
export ANDROID_NDK="$ANDROID_HOME/ndk/<你的NDK版本号>"
# 进入 MNN Android 构建目录
cd "/d/AI model/framework/MNN/project/android"
mkdir -p build_64_sme2
cd build_64_sme2
# 执行构建 —— 注意这里的 CMake 选项
../build_64.sh "-DMNN_BUILD_LLM=ON \
-DMNN_BUILD_LLM_OMNI=ON \
-DMNN_LOW_MEMORY=ON \
-DMNN_SUPPORT_TRANSFORMER_FUSE=ON \
-DMNN_CPU_WEIGHT_DEQUANT_GEMM=ON \
-DMNN_ARM82=ON \
-DMNN_SME2=ON \
-DMNN_OPENCL=ON \
-DMNN_USE_LOGCAT=ON \
-DLLM_SUPPORT_VISION=ON \
-DLLM_SUPPORT_AUDIO=ON \
-DMNN_BUILD_OPENCV=ON \
-DMNN_IMGCODECS=ON \
-DMNN_BUILD_AUDIO=ON \
-DMNN_SEP_BUILD=OFF \
-DCMAKE_SHARED_LINKER_FLAGS='-Wl,-z,max-page-size=16384'"
逐个解释关键选项的含义:
| 选项 | 作用 |
|---|---|
-DMNN_SME2=ON |
核心开关:把 Arm SME2 matrix extension kernel 编进 libMNN.so |
-DMNN_ARM82=ON |
启用 Armv8.2 FP16 相关指令优化 |
-DMNN_BUILD_LLM=ON |
构建 LLM runtime(没有这个就无法跑 Qwen) |
-DMNN_BUILD_LLM_OMNI=ON |
多模态支持(visual.mnn 需要) |
-DMNN_OPENCL=ON |
GPU OpenCL 后端,作为 CPU 的补充 |
-DMNN_USE_LOGCAT=ON |
日志输出到 Android logcat,方便调试 |
-Wl,-z,max-page-size=16384 |
兼容新设备 16 KB page size 要求 |
构建成功后,产物在 build_64_sme2 目录下:
libMNN.so ← MNN 核心 engine(包含 SME2 kernel)
libmnnllmapp.so ← LLM 应用层封装
libc++_shared.so ← C++ 共享运行时(如果用动态 STL)
重要:这三个 .so 必须来自同一份 MNN 源码和同一次构建。 不要只替换 libMNN.so 却留着旧版 libmnnllmapp.so,否则运行时会出各种奇怪的问题。
把它们复制到项目的 jniLibs 目录:
yolo v8/APP/app/src/main/jniLibs/arm64-v8a/
├── libMNN.so
├── libmnnllmapp.so
└── libc++_shared.so
项目目前只打包 arm64-v8a 一种 ABI,在 build.gradle.kts 中的配置:
ndk {
abiFilters += listOf("arm64-v8a")
}
第四步:构建 APK 并安装到真机
Set-Location "D:\Users\27890\Desktop\魔搭社区创意app\heimdall-path\yolo v8\APP"
# 使用 Android Studio 自带 JBR
$env:JAVA_HOME = "D:\Program Files\Android\Android Studio\jbr"
$env:ANDROID_HOME = "C:\Users\27890\AppData\Local\Android\Sdk"
# 构建 debug 包
.\gradlew.bat assembleDebug --rerun-tasks
如果你的项目路径包含中文字符(就像我的项目名那样),AGP 会报错 Your project path contains non-ASCII characters。解决办法是在 gradle.properties 中加一行:
android.overridePathCheck=true
安装到手机:
$Adb = "C:\Users\27890\AppData\Local\Android\Sdk\platform-tools\adb.exe"
& $Adb install -r `
"D:\Users\27890\Desktop\魔搭社区创意app\heimdall-path\yolo v8\APP\app\build\outputs\apk\debug\app-debug.apk"
看到 Success 就说明安装成功了。
第五步:推送模型到手机
APK 只是个壳,模型文件需要额外推送到 App 的私有存储目录。我写了个 PowerShell 脚本来做这件事:
# push_qwen35_mnn_to_device.ps1 核心逻辑
$Adb = "C:\Users\27890\AppData\Local\Android\Sdk\platform-tools\adb.exe"
$ModelDir = "D:\AI model\mnn\Qwen3.5-0.8B-MNN"
$Package = "com.example.shadowwalk"
$Stage = "/data/local/tmp/heimdall_qwen35_push"
$Target = "/data/data/com.example.shadowwalk/files/mnn_models/qwen3_5_0_8b_mnn_official"
# 1. 在手机创建临时目录
& $Adb shell "rm -rf '$Stage' && mkdir -p '$Stage'"
# 2. 逐个推送模型文件
& $Adb push "$ModelDir\config.json" "$Stage/config.json"
& $Adb push "$ModelDir\llm_config.json" "$Stage/llm_config.json"
& $Adb push "$ModelDir\llm.mnn" "$Stage/llm.mnn"
& $Adb push "$ModelDir\llm.mnn.weight" "$Stage/llm.mnn.weight"
& $Adb push "$ModelDir\tokenizer.txt" "$Stage/tokenizer.txt"
& $Adb push "$ModelDir\visual.mnn" "$Stage/visual.mnn"
& $Adb push "$ModelDir\visual.mnn.weight" "$Stage/visual.mnn.weight"
# 3. 复制到 App 私有目录
& $Adb shell "run-as '$Package' mkdir -p '$Target'"
& $Adb shell "run-as '$Package' cp '$Stage'/* '$Target'/"
# 4. 校验
& $Adb shell "run-as '$Package' ls -lh '$Target'"
第六步:验证 SME2 加速状态
这是我最喜欢的一部分——我在项目里加了一套完整的 SME2 诊断能力,可以在 App 启动时自动采集 CPU 特征和 MNN 库符号信息。
触发诊断的命令:
$Adb = "C:\Users\27890\AppData\Local\Android\Sdk\platform-tools\adb.exe"
# 清空日志,重启 App 并触发诊断
& $Adb logcat -c -b all
& $Adb shell am force-stop com.example.shadowwalk
& $Adb shell am start -W -n com.example.shadowwalk/.MainActivity `
--ez mnn_accel_test true
Start-Sleep -Seconds 3
# 过滤诊断结果
& $Adb logcat -d -b all -t 1000 -v time |
Select-String -Pattern "MNN acceleration info|Debug MNN acceleration test result|MnnAcceleration"
诊断采集的信息包括:
{
"cpu": {
"supports_sme": false,
"supports_sme2": false,
"supports_i8mm": true,
"supports_bf16": true,
"supports_dotprod": true
},
"mnn_library": {
"contains_sme2_symbols": true,
"contains_kleidi_symbols": true,
"symbol_counts": {
"sme2": 27,
"sme": 66,
"kleidi": 35,
"i8mm": 6,
"dotprod": 9,
"bf16": 3
}
},
"sme2_dispatch_possible": false,
"expected_dispatch": "i8mm fallback candidate",
"note": "APK has SME2 symbols but CPU lacks sme2 feature"
}
这段诊断信息的采集逻辑在 MnnAccelerationInfo.java 里实现,WebView 前端也可以通过 bridge 调用:
// nativeBridge.ts - 前端获取 MNN 加速状态
export interface MnnAccelerationInfo {
cpu: {
supports_sme: boolean;
supports_sme2: boolean;
supports_i8mm: boolean;
supports_bf16: boolean;
supports_dotprod: boolean;
};
mnn_library: {
contains_sme2_symbols: boolean;
contains_kleidi_symbols: boolean;
symbol_counts: Record<string, number>;
};
sme2_dispatch_possible: boolean;
expected_dispatch: string;
}
// 三种获取方式:
window.AndroidBridge.getMnnAccelerationInfoJson() // 同步获取缓存
window.AndroidBridge.requestMnnAccelerationInfo() // 请求重新采集
subscribeMnnAccelerationInfo((info) => { ... }) // 订阅更新
我当前测试机(小米 24129PN74C)的结果是:APK 内的 libMNN.so 已经包含了完整的 SME2/KleidiAI 符号(sme2: 27 个,kleidi: 35 个),但这台手机的 CPU 没有暴露 sme2 特性,所以 MNN 自动回退到了 i8mm 路径。这不是 bug,是硬件限制——换到支持 SME2 的设备后就能走通完整加速路径。
判断标准总结成一张表:
| cpu.supports_sme2 | contains_sme2_symbols | sme2_dispatch_possible | 含义 |
|---|---|---|---|
| true | true | true | 完美:设备 + APK 都支持 SME2 |
| false | true | false | 正常回退:APK 有 SME2,但手机不支持,走 i8mm/bf16 |
| 任意 | false | false | 需要重建:当前 libMNN.so 没有 SME2,需重新编译 |
第七步:真机问答验证
最后一步,验证端到端的文本问答是否正常工作:
$Adb = "C:\Users\27890\AppData\Local\Android\Sdk\platform-tools\adb.exe"
& $Adb logcat -c -b all
& $Adb shell am force-stop com.example.shadowwalk
& $Adb shell am start -W -n com.example.shadowwalk/.MainActivity `
--es qwen_test_question "前面安全吗?"
Start-Sleep -Seconds 25
# 检查日志
& $Adb logcat -d -b all -t 3500 -v time |
Select-String -Pattern "QwenTextManager|MNN_DEBUG|ResponseWithHistory Result|load_success|AndroidRuntime|FATAL"
通过标志:
load_success=1ResponseWithHistory Result后面出现可读中文- 没有
AndroidRuntime或FATAL
我本机的实际日志:
LIFECYCLE: LlmSession CREATED ..., load_success=1
ResponseWithHistory Result 前路情况良好,未检测到障碍物...
Qwen text output accepted: 前路情况良好,未检测到障碍物。
到这里,一条完整的 MNN 端侧部署链路就跑通了。
如果你想自己转换模型
虽然官方已经提供了预转换好的 MNN 模型包,但有时候你需要用自己的量化参数或者尝试不同版本的 Qwen。这时就需要从魔搭社区的原始 PyTorch 模型自己转换。
下载原始模型
三种方式任选其一:
# 方式一:ModelScope CLI
pip install -U modelscope
modelscope download --model Qwen/Qwen3.5-0.8B --local_dir "D:\AI model\raw\Qwen3.5-0.8B"
# 方式二:Python snapshot_download
python -c "
from modelscope import snapshot_download
snapshot_download('Qwen/Qwen3.5-0.8B', local_dir=r'D:\AI model\raw\Qwen3.5-0.8B')
"
# 方式三:Git LFS
git lfs install
git clone https://www.modelscope.cn/Qwen/Qwen3.5-0.8B.git "D:\AI model\raw\Qwen3.5-0.8B"
git -C "D:\AI model\raw\Qwen3.5-0.8B" lfs pull
用 llmexport.py 转换为 MNN 格式
Set-Location "D:\AI model\framework\MNN\transformers\llm\export"
# 安装依赖
pip install -r requirements.txt
pip install -U modelscope transformers torch safetensors sentencepiece
# 执行转换
python llmexport.py `
--path "D:\AI model\raw\Qwen3.5-0.8B" `
--dst_path "D:\AI model\mnn\Qwen3.5-0.8B-MNN-self" `
--export mnn `
--quant_bit 4 `
--quant_block 64 `
--mnnconvert "D:\AI model\framework\MNN\build_mingw_converter\MNNConvert.exe"
参数说明:
| 参数 | 说明 |
|---|---|
--path |
原始 PyTorch/Safetensors 模型目录 |
--dst_path |
导出的 MNN 模型目录 |
--export mnn |
导出为 MNN 格式 |
--quant_bit 4 |
4bit 权重量化,体积小,移动端友好 |
--quant_block 64 |
量化块大小 |
--mnnconvert |
MNNConvert 可执行文件路径 |
再次强调:这里没有 -DMNN_SME2。SME2 是运行库的事,跟模型转换无关。
转换产物的典型结构:
D:\AI model\mnn\Qwen3.5-0.8B-MNN-self\
├── config.json
├── embeddings_bf16.bin
├── llm_config.json
├── llm.mnn
├── llm.mnn.json
├── llm.mnn.weight
├── tokenizer.txt
└── onnx/
├── llm.onnx
└── llm.onnx.data
如果直接导出 MNN 失败(某些模型版本可能有兼容性问题),可以分两步走:先导 ONNX,再用 MNNConvert 转:
# Step 1: 导出 ONNX
python llmexport.py `
--path "D:\AI model\raw\Qwen3.5-0.8B" `
--dst_path "D:\AI model\mnn\Qwen3.5-0.8B-ONNX" `
--export onnx
# Step 2: ONNX -> MNN
& "D:\AI model\framework\MNN\build_mingw_converter\MNNConvert.exe" `
--modelFile "D:\AI model\mnn\Qwen3.5-0.8B-ONNX\onnx\llm.onnx" `
--MNNModel "D:\AI model\mnn\Qwen3.5-0.8B-MNN-self\llm.mnn" `
--keepInputFormat `
--weightQuantBits=4 `
--weightQuantBlock=64 `
-f ONNX `
--transformerFuse=1 `
--allowCustomOp `
--saveExternalData
一个踩过的坑:自导出模型 PC 正常但 Android 乱码
这个问题我遇到过不止一次:PC 端 llm_demo.exe 输出中文完全正常,但推到手机后就变成标点、重复符号或者多语混杂。
排查方向按顺序来:
- 对比 MNN 源码 commit 版本
- 对比 MNNConvert 版本
- 对比 llmexport.py 版本
- 检查量化参数是否一致
- 检查 tokenizer.txt 编码
- 确认 libMNN.so / libmnnllmapp.so 来源一致
- 用 MNN Chat 加载同一份自导出模型,看是不是也异常
如果 MNN Chat 也异常,说明问题在模型产物本身;如果只有你的 App 异常而 MNN Chat 正常,说明问题在 App 侧的配置或调用方式。
这也是为什么我一直推荐比赛演示用魔搭官方的 MNN/Qwen3.5-0.8B-MNN 作为基线——自转换模型必须通过 PC、MNN Chat、Heimdall APK 三端验证才能替换上去。
UI 开发:React + WebView 混合架构
前端部分我用了 React + Vite 构建,然后打包进 Android 的 assets 目录,通过 WebView 加载。这样做的好处是可以独立迭代 UI,不用每次改个按钮都重新编 APK。
UI 设计最初在 Figma 上完成:

然后在 Android Studio 的布局编辑器中对接:

模拟器上的运行效果:

Android 和前端之间的通信通过自定义 bridge 实现:
// nativeBridge.ts - WebView <-> Android Native 桥接
class HeimdallNativeBridge {
// 获取 MNN 加速信息
getMnnAccelerationInfoJson(): string {
return window.AndroidBridge?.getMnnAccelerationInfoJson() ?? '{}';
}
// 触发 MNN 加速诊断
requestMnnAccelerationInfo(): void {
window.AndroidBridge?.requestMnnAccelerationInfo();
}
// 订阅加速信息变化
subscribeMnnAccelerationInfo(callback: (info: MnnAccelerationInfo) => void) {
document.addEventListener('heimdall-mnn-acceleration-info', ((e: CustomEvent) => {
callback(e.detail);
}) as EventListener);
}
}
对应的 Java 侧暴露接口:
// MainActivity.java - WebView Bridge
public class MainActivity extends Activity {
@JavascriptInterface
public String getMnnAccelerationInfoJson() {
return MnnAccelerationInfo.toJson(currentAccelInfo);
}
@JavascriptInterface
public void requestMnnAccelerationInfo() {
collectAndBroadcastMnnAccelerationInfo();
}
}
MCP 工具集成:地图和打车
除了本地 AI 能力,我还接入了两个 MCP(Model Context Protocol)工具来提供宏观出行服务。
腾讯地图 MCP
用于位置查询、POI 搜索、步行路线规划:

用户问"附近最近的地铁站在哪"或"去天安门怎么走"时,系统会通过 SSE 协议调用腾讯地图 MCP,拿到结果后交给 Qwen 整合成自然语言播报给用户。
滴滴打车 MCP
用于价格预估、App 跳转辅助:

安全边界很重要:设置页禁止调用 taxi_create_order 和 taxi_cancel_order,语音链路只做价格预估和跳转准备,绝不后台自动下单。真机测试中已验证价格预估、订单查询和安全拦截均正常。
结构化路况:RoadState 设计
为了让大模型的输入更稳定、减少幻觉,我没有把原始画面直接扔给 Qwen,而是维护了一份结构化的 RoadState JSON:
{
"timestamp": 1760000000000,
"path": {
"status": "found",
"alignment": "center",
"instruction": "直行",
"confidence": 0.82
},
"corridor": {
"blocked": false,
"base_x": 78,
"risk_level": "low"
},
"obstacles": [
{
"class": "bicycle",
"direction": "right",
"distance_level": "near",
"risk": "medium",
"confidence": 0.76
}
],
"system": {
"fps": 5,
"thermal": "normal",
"battery_saver": false
}
}
用户问"前方安全吗"时,系统根据这份 JSON 直接生成确定性回答:
结构化状态:instruction=直行,右侧近处有自行车,风险中等。
→ 生成播报:"盲道在前方,请继续直行。右侧近处有自行车,注意保持距离。"
风险等级达到 critical 时,回答变成安全指令:
结构化状态:instruction=停止,正前方有无盖井盖,风险=critical。
→ 生成播报:"先停下!正前方近处有无盖井盖!不要继续直走。"
视觉未开启或置信度不足时,明确回答"当前视觉信息不足,不能确认前方有什么"——不假装看见,不编造障碍物。这在安全敏感场景里至关重要。
性能与资源管理
端侧跑多个 AI 模型,资源管理必须精打细算。我采用了分频调度策略:
| 模块 | 推荐频率 | 说明 |
|---|---|---|
| YOLO 检测/分割 | 3-10 FPS | 安全主链路,持续运行但无需 30 FPS |
| DecisionEngine | 跟随 YOLO 输出 | 每次视觉结果后快速生成指令 |
| 震动提醒 | 状态变化触发 | 至少 1-2 秒防抖 |
| 短语音提醒 | 风险变化触发 | 只在进入/解除危险时播报 |
| Qwen3.5 文本模型 | 用户问答时运行 | 输入结构化状态,输出短句 |
| Qwen 视觉兜底 | 用户主动触发 | 7 秒内最多一次,低频防抖 |
我的测试机配置是 16GB RAM + 512GB 存储。在这个配置下,推荐的比赛档位组合是:
YOLOv8 检测/分割 (~24 MB,常驻)
DecisionEngine (纯规则引擎,几乎无开销)
Android SpeechRecognizer(按需启动)
Qwen3.5-0.8B-MNN (400-800 MB,半常驻)
Android TextToSpeech (按需播报)
这个组合既保证了安全通道的实时性,又留够了资源给文本模型做语义理解,同时不会对手机造成明显的发热和续航压力。
总结与思考
做完这个项目之后,有几个体会想分享:
第一,端侧 AI 不是万能药,但有些场景它确实是最优解。 视障出行辅助需要实时响应、隐私保护、离线可用——这些恰恰是端侧部署的强项。云端 API 延迟高、依赖网络、数据出境,在这种场景下反而成了劣势。
第二,架构设计比模型大小更重要。 我见过不少项目一味追求上最大的端侧模型,却忽略了安全通道和问答通道的分离。YOLOv8 nano 做避障 + 0.8B 做问答,这个"小模型组合"在实际体验上很可能优于"单个大模型包打天下"。
第三,MNN 是一个成熟且实用的端侧推理框架。 从模型转换、Android 运行库编译、SME2 加速支持到 LLM runtime,整套链路都有文档支撑。虽然中间踩过不少坑(中文路径、STL 兼容、自导出模型乱码等),但每一步都是可以解决的工程问题。
第四,Arm SME2 代表了端侧 AI 加速的一个重要方向。 虽然 my 当前测试机还不支持 sme2,但 MNN 的自动回退机制让我无需关心底层细节——同一份 APK 在 SME2 设备和非 SME2 设备都能正常工作,只是在支持的设备上会更快。这种"编译一次,自适应运行"的模式,对于面向广大用户群的应用来说非常实用。
如果你也在做端侧 AI 部署,尤其是 Android + 大模型的组合,希望这篇文章能给你一些参考。代码全部开源在项目仓库里,有问题欢迎交流。
参考资源
- MNN 官方仓库:https://github.com/alibaba/MNN
- MNN LLM 文档:https://mnn-docs.readthedocs.io/en/latest/transformers/llm.html
- Arm SME2 产品页:https://www.arm.com/technologies/sme2
- Arm KleidiAI 文档:https://learn.arm.com/learning-paths/cross-platform/kleidiai-explainer/
- ModelScope 官方 Qwen3.5-0.8B-MNN 模型:https://www.modelscope.cn/MNN/Qwen3.5-0.8B-MNN
- YOLO iOS 示例项目:https://github.com/ultralytics/yolo-ios-app
更多推荐



所有评论(0)