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 初始化后的主界面:

App初始化

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

盲道识别回传

障碍物识别

真机上的实际运行效果:

真机首界面

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

MNN千问模型测试

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

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 版本的原因很直接:

  1. 够轻,手机端持续跑 3-10 FPS 没压力
  2. 输出结构化,检测框 + 分割掩码,便于后处理
  3. 延迟可控,比生成式视觉大模型稳定得多
  4. 工程链路成熟,训练、导出、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=1
  • ResponseWithHistory Result 后面出现可读中文
  • 没有 AndroidRuntimeFATAL

我本机的实际日志:

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 输出中文完全正常,但推到手机后就变成标点、重复符号或者多语混杂。

排查方向按顺序来:

  1. 对比 MNN 源码 commit 版本
  2. 对比 MNNConvert 版本
  3. 对比 llmexport.py 版本
  4. 检查量化参数是否一致
  5. 检查 tokenizer.txt 编码
  6. 确认 libMNN.so / libmnnllmapp.so 来源一致
  7. 用 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 上完成:

Figma UI设计

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

UI布局编辑器

模拟器上的运行效果:

模拟器运行效果

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 搜索、步行路线规划:

腾讯地图MCP测试

用户问"附近最近的地铁站在哪"或"去天安门怎么走"时,系统会通过 SSE 协议调用腾讯地图 MCP,拿到结果后交给 Qwen 整合成自然语言播报给用户。

滴滴打车 MCP

用于价格预估、App 跳转辅助:

滴滴MCP测试连接

安全边界很重要:设置页禁止调用 taxi_create_ordertaxi_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 + 大模型的组合,希望这篇文章能给你一些参考。代码全部开源在项目仓库里,有问题欢迎交流。


参考资源

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐