Memoria-智能影记创新实训博客(四):Qwen3.5-0.8B 模型的端侧部署与跑通
Memoria-智能影记创新实训博客(四):Qwen3.5-0.8B 模型的端侧部署与跑通
博客主题:技术基础
时间跨度:2026.04.23 - 2026.04.26(第8周)
进度总结:上一篇博客中提到了故事生成功能提供了两种故事生成方式,一种是直接调deepseek根据标签和时空数据来生成,一种是本地vlm打caption,再交由deepseek生成。本博客实现了本地vlm的端侧部署与成功调用。
1. 目标
把项目里的Qwen3.5-0.8B本地模型真正部署到手机侧,并让它在 App 内稳定跑起来。当前项目采用一条更适合工程落地的路径:把llama.cpp的可执行文件、Qwen GGUF 模型和mmproj一起准备好,打进 APK 或外部部署目录,由 Android 侧通过MethodChannel拉起常驻llama-server,再让 Flutter 通过127.0.0.1的 OpenAI 兼容接口发起请求。这样做的目标有三个:第一,保证图片可以在手机本地完成理解,不把原图直接上传云端;第二,把模型加载和推理留在 Android 原生层,避免 Dart 层直接承受本地模型运行成本;第三,为后面的本地 caption、本地故事、多图理解等功能提供统一底座。
2. 部署前提
项目真正依赖的是三类文件:llama-server与llama-mtmd-cli两个可执行文件,libggml / libllama / libmtmd等共享库,以及Qwen3.5-0.8B-Q4_K_M.gguf + mmproj-F16.gguf这组模型文件。代码里对模型和投影文件的命名是写死匹配的,尤其会优先寻找checkpoints/qwen/Qwen3.5-0.8B-Q4_K_M.gguf和checkpoints/qwen/mmproj-F16.gguf,所以文件名和目录结构不能随意改。
3. 目录组织与打包方式
项目里对本地模型资源的打包路径已经约定好了,Gradle 会在构建阶段自动把本地资源同步到 APK 资产目录中。核心逻辑在android/app/build.gradle.kts:
from("../../third_party/llama.cpp/install-android-baseline/bin"){include("llama-server","llama-mtmd-cli")into("local_llm/install-android-baseline/bin")}from("../../third_party/llama.cpp/install-android-baseline/lib"){include("libggml-base.so","libggml-cpu.so","libggml.so","libllama.so","libmtmd.so")into("local_llm/install-android-baseline/lib")}from("../../checkpoints/qwen"){include("Qwen3.5-0.8B-Q4_K_M.gguf","mmproj-F16.gguf")into("local_llm/checkpoints/qwen")}这段配置说明了两件事:第一,项目默认把third_party/llama.cpp/install-android-baseline视为本地运行时来源,把checkpoints/qwen视为模型来源;第二,真正打进 APK 之后,这些文件都会统一落到assets/local_llm/...下面。
4. 安装与运行
Android 侧在OnDeviceInternvlBridge.kt里做了两层转移:先把打包进 APK 的资源解压到noBackupFilesDir/local_llm,再把真正需要执行的llama-server、llama-mtmd-cli和共享库复制到noBackupFilesDir/internvl_runtime。这样做的原因很实际:资产目录只适合读取,不适合直接作为可执行运行时目录;而私有目录既能持久保存,又可以给二进制文件设置执行权限。
代码里对应的关键逻辑是:
privatefunensureBundledAssetsInstalledIfNeeded(){copyAssetTree("$packagedAssetRoot/install-android-baseline/bin",File(bundledInstallRoot,"bin"))copyAssetTree("$packagedAssetRoot/install-android-baseline/lib",bundledLibDir)copyAssetTree("$packagedAssetRoot/checkpoints/qwen",bundledCheckpointsRoot)}以及:
privatefunensureRuntimeServerStaged(){copyFileIfChanged(sourceServer,appRuntimeServerFile)sourceLibFiles.forEach{sourceLib->valtargetLib=File(appRuntimeLibDir,sourceLib.name)copyFileIfChanged(sourceLib,targetLib)}}所以,真正的运行路径不是 APK 内部,而是:
- 安装目录:
noBackupFilesDir/local_llm - 运行目录:
noBackupFilesDir/internvl_runtime
5. 拉起本地服务
Flutter 侧并不直接操作模型文件,而是通过MethodChannel("memoria/on_device_internvl")调 Android 原生桥。桥接入口在MainActivity.kt,真正处理逻辑在OnDeviceInternvlBridge.kt。Dart 侧最关键的几个方法是:
getServerDeploymentStatus():检查本地依赖是否齐全getServerStatus():查看当前服务有没有运行、能不能连通ensureServerStarted():真正启动或复用本地常驻服务stopServer():停止服务
启动时,Android 侧会拼出一条标准llama-server命令:
valcommand=mutableListOf(linkerPath,appRuntimeServerFile.absolutePath,"-m",modelFile.path,"--mmproj",mmprojFile.path,"--host",serverHost,"--port",serverPort.toString(),"--threads",threads.toString(),"--ctx-size",contextSize.toString(),"--alias",serverModelAlias,"--no-webui","--no-mmproj-offload",)这里几个参数很重要:
- 模型路径和
mmproj路径都来自部署状态检查结果 - 服务固定监听
127.0.0.1:8080 - 模型别名固定为
local-qwen3.5-0.8b-vl - 线程数和上下文大小由设备画像动态推荐
也就是说,这条部署方案本质上是“App 内自启动一个本地 OpenAI 兼容服务”,而不是“每次调用都临时起一个进程”。
6. 验证部署成功
在这个项目里,“文件存在”不等于“部署成功”。代码对成功的判断分了三层。
第一层是依赖完整。getServerDeploymentStatus()会检查:
llama-server是否存在lib目录是否存在Qwen GGUF是否存在mmproj是否存在linker64是否存在
只要这些项目里有缺失,就会返回missingItems,同时isRunnable = false。
第二层是端口可达。即使服务已经拉起,如果127.0.0.1:8080还连不上,也不能说明服务真的可用。
第三层是推理预热成功。项目不会只检查端口,而是会主动向/v1/chat/completions发一个带 1x1 测试图的 warmup 请求。只有端口可达并且 warmup 成功,ready才会变成true。也就是说,当前代码里真正意义上的“部署成功”是:
isRunnable = truerunning = truereachable = trueready = true
这比“看到进程 PID”更严格,也更贴近真实可用状态。
7. 总结
当前项目里,Qwen3.5-0.8B的本地部署已经形成了一条完整链路:先在仓库中准备llama.cpp运行时和 Qwen 模型文件,再由 Gradle 在构建阶段把这些资源打进 APK;应用首次运行时把资产解压到私有目录,再把服务可执行文件和共享库复制到运行目录;随后通过MethodChannel拉起常驻llama-server,用127.0.0.1/v1/chat/completions承接推理请求,并通过测试页验证部署、启动、预热和推理结果是否全部正常。它的价值不只是“本地能跑一个模型”,而是为后面的本地 caption、多图故事、本地 VLM 辅助生成等功能提供了稳定、统一、可复用的本地推理底座。
