基于 MNN 官方 MnnLlmChat v0.8.3 的 fork 版本,主要解决一个痛点:模型文件只能存在 app 内部沙箱,无法跨 app 共享、无法用文件管理器访问。本版本支持把模型下载到外部存储,方便共享和管理。

背景

MNN 是阿里开源的轻量级推理引擎,官方的 MnnLlmChat 是一个功能完整的 Android 端侧多模态 LLM 应用,支持 Qwen、Llama、Gemma 等模型在本机离线推理。

但用了一段时间发现一个明显问题:模型文件默认存在 /data/data/com.alibaba.mnnllm.android/files/.mnnmodels/,这是 app 私有目录:

  • ❌ 其他 app 无法访问(Android 沙箱隔离)
  • ❌ 文件管理器看不到
  • adb pull 需要 root
  • ❌ 同一个模型下多个 app 各下一份,浪费存储
  • ❌ 内部存储空间紧张时无法迁移到 SD 卡

对于动辄几个 GB 的大模型,这个限制很烦人。于是我 fork 了一份,加了模型存储路径自定义功能。

版本信息

项目
Fork 仓库 pisces312/MNN
Release v0.8.3-pisces.1
基线版本 upstream MnnLlmChat 0.8.3 (versionCode 830)
Fork 版本 0.8.3-pisces.1 (versionCode 95000831)
MNN 引擎 3.6.0
架构 arm64-v8a
Flavor standard
APK 大小 42 MB

版本号约定(fork 项目):

  • versionName: 0.8.3-pisces.N0.8.3 是 upstream 基线,-pisces.N 是 fork 第 N 个 release
  • versionCode: 950008319500 命名空间前缀(避开 upstream),0831 = 基线 + 序号

主要改进

1. 最新 MNN 3.6.0 native libraries

重新编译了 MNN 3.6.0 的 libMNN.so,包含:

  • ARM82 fp16 优化
  • OpenCL GPU 后端
  • 低内存模式
  • 权重反量化 GEMM
  • Transformer 融合优化
  • 视觉 / 音频 / Diffusion 全支持

2. 模型存储路径自定义(核心功能)

痛点:默认模型存在 app 私有目录,无法跨 app 共享。

方案:在设置里加了"模型存储路径"选项,支持选外部存储目录,比如 /storage/emulated/0/mnn-models/

设置路径:

Settings → Model storage path → Browse / Type path

效果:

  • ✅ 模型下到外部存储,文件管理器直接可见
  • adb pull 无需 root
  • ✅ 多个基于 MNN 的 app 可共用同一份模型
  • ✅ 方便备份 / 迁移到 SD 卡

3. Flat Mode 下载(FUSE 兼容)

这个是核心技术难点,单独说一下。

问题

MnnLlmChat 下载模型时,文件结构仿照 HuggingFace cache:

.mnnmodels/
├── modelscope/
│   └── models--MNN--MiniMind2-MNN/
│       ├── blobs/
│       │   └── {sha256}        ← 真实文件
│       └── snapshots/
│           └── _no_sha_/
│               └── config.json ← symlink → ../../blobs/{sha256}

blobs/ 存真实文件,snapshots/ 用 symlink 指过去。这种设计在 HF 官方是为了跨 commit 去重。

但 Android 外部存储(/storage/emulated/0/)是 FUSE 伪文件系统,不支持 symlink! 即使有 MANAGE_EXTERNAL_STORAGE 权限,Files.createSymbolicLink 也会抛 AccessDeniedException

第一个文件 .gitattributes 的 symlink 就失败,整个下载直接挂。

方案:Flat Mode

检测路径是否支持 symlink,不支持时切换 flat mode:

内部存储(默认,ext4) 外部存储(flat mode,FUSE)
结构 blobs/{sha} + snapshots/{sha}/ symlink 文件直接在 snapshots/{sha}/
空间 1x(symlink 不占额外空间) 1x(无 blobs 层,无双倍)
跨 commit 去重 有(HF 设计初衷) 无(但本项目只下 main 单 commit,无去重需求)

flat mode 结构:

mnn-models/
└── modelscope/
    └── models--MNN--MiniMind2-MNN/
        └── snapshots/
            └── _no_sha_/
                ├── config.json     ← 真实文件
                ├── model.mnn       ← 真实文件
                ├── tokenizer.json  ← 真实文件
                └── ...

实现要点

  • DownloadFileUtils.isSymlinkSupported(path):在目标路径创建 probe symlink 检测
  • flat mode 下 blobPath == pointerPath,文件直接下到 snapshots/,跳过 blobs/
  • ModelFileDownloader.downloadFile:检测 blobPath == pointerPath 时跳过 createSymlink(否则会删除刚下载的文件)
  • 兼容性:内部存储保持 legacy symlink 结构,外部存储用 flat mode,已下载模型不受影响
自动清理 legacy 残留

如果用户之前在 legacy 模式下下载失败,blobs/ 里会留孤儿文件。切换 flat mode 后,首次下载会检测并清理:

  • blobs/ 存在 snapshots/ 还没有 flat 文件 → 清理 blobs/ + snapshots/
  • 避免孤儿文件占用空间

4. 应用内日志查看器

下载失败时,logcat 不一定方便看。本版本加了 in-memory logger:

Settings → View Logs

可以看到最近 200 条日志,包括:

  • 下载启动 / 路由(HF / ModelScope)
  • fetchRepoInfo 请求 / 响应码
  • 文件下载进度
  • 失败详情(异常类名 + message)

支持复制到剪贴板 / 清空。

5. 下载失败日志增强

之前下载失败只打 Log.d(Debug 级,logcat 默认过滤),且不写入 AppLogger。改为:

  • Log.e 带 stacktrace
  • AppLogger.e 同步写入内存日志
  • 整个下载链路(ModelDownloadManager.startDownload / onDownloadFailed / updateCacheDir / fetchRepoInfo / downloadChunk / 重定向处理)都加了 Log.i/e

使用教程

1. 下载安装

Release 页面 下载 app-standard-debug.apk

Debug build,applicationIdSuffix .debug,可与官方 release 版共存。

2. 授权

首次启动会请求 “All files access” 权限(MANAGE_EXTERNAL_STORAGE),这是使用外部存储路径的前提。

3. 设置外部存储路径

Settings → Model storage path → Browse

选择一个外部存储目录,比如 /storage/emulated/0/mnn-models/

也可以 “Type path” 手动输入绝对路径,或 “Restore default” 恢复默认内部存储。

4. 下载模型

进入 Model Market,选一个模型下载。下载完成后,用文件管理器查看:

/storage/emulated/0/mnn-models/modelscope/models--MNN--MiniMind2-MNN/snapshots/_no_sha_/
├── .gitattributes
├── README.md
├── config.json
├── model.mnn
├── model.mnn.weight
└── tokenizer.json

5. 推理

下载完后在 “My Models” 里能看到该模型,点击即可对话。flat mode 结构对推理无影响——native 层只关心能不能通过路径读到文件内容。

6. 跨 app 共享

其他基于 MNN 的 Android app,只要有存储权限,可以直接指向这个目录加载模型,省一份存储。

技术细节

为什么 HF 要用 symlink

HF 官方 cache 用 blobs/{sha} + snapshots/{commit}/ symlink 的设计,核心目的是跨 commit 去重:同一个文件(同 sha256)在多个 commit 里出现时,只存一份 blob,每个 commit 的 snapshot 用 symlink 指向它。HF 仓库历史长,跨 commit 重复文件多,去重收益明显。

为什么 MnnLlmChat 不需要

MnnLlmChat 每个模型只下载 main 分支单个 commit,单 commit 内每个文件 sha 唯一,没有跨 commit 重复。所以:

  • blobs 层在本项目里去重收益为 0
  • symlink 唯一作用就是满足 HF cache 的目录结构规范

flat mode 在单 commit 场景下没有空间代价(1x,与 legacy 相同),只是结构更扁平。

Android FUSE 限制

Android 11+ 对外部存储的访问收紧:

  • /storage/emulated/0/ 通过 FUSE 挂载
  • FUSE 不支持 symlinkhardlinkmknod 等操作
  • 即使 MANAGE_EXTERNAL_STORAGE 权限也只是放宽了访问范围,不改变 FUSE 文件系统能力

所以即使有权限,symlink 也会失败。flat mode 是绕过这个限制的唯一方案。

内部存储 vs 外部存储对比

内部存储(默认) 外部存储(自定义)
文件系统 ext4 FUSE
symlink 支持
其他 app 访问 ❌ 沙箱隔离 ✅ 有权限即可
文件管理器
adb pull ❌ 需 root
跨 app 共用模型
下载结构 legacy(blobs+symlink) flat mode
推理 正常 正常

Release

相关链接

  • MNN 官方仓库: https://github.com/alibaba/MNN
  • MnnLlmChat 官方文档: https://www.codebuddy.cn/docs/workbuddy/Overview
  • 本 Fork 仓库: https://github.com/pisces312/MNN
  • Release: https://github.com/pisces312/MNN/releases/tag/v0.8.3-pisces.1

如果觉得有用,欢迎 star ⭐ 和提 issue 反馈。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐