深入 Android 端侧 AI 推理的 GGUF 格式适配与 llama.cpp 引擎移植全链路

去年在做一款本地知识库应用时,遇到了一个棘手问题:如何在一台骁龙 8 Gen 2 手机上流畅跑通 7B 模型。当时市面上的方案要么绑死厂商 SDK,要么推理速度慢到不可用。绕了一圈,最终落在了 GGUF 格式加 llama.cpp 这条技术栈上。

下面把整个移植和调优过程拆开来讲。

GGUF:为什么不是 Safetensors 或 ONNX

端侧推理和服务器推理的核心矛盾是 内存带宽。手机 LPDDR5 理论带宽约 50 GB/s,而一个 7B 参数的 FP16 模型体积约 14 GB——光是加载就爆内存,更不用说推理。

GGUF(GPT-Generated Unified Format)解决的就是这个。思路很直接:用低位宽量化换内存占用和推理速度的平衡。一个 7B 模型量化到 Q4_K_M 后,体积压缩到约 4 GB,在 8 GB RAM 的设备上刚好能跑。

对比另外两种常见格式:

Safetensors 的设计目标是安全加载而非推理优化。它缺少量化级别的元数据约定,每个模型作者可以随意定义自己的量化方案,生态碎片化严重。在端侧用 Safetensors 做推理,需要自己补一整套量化配置层,得不偿失。

ONNX 作为推理框架足够通用,但大语言模型场景下对 KV Cache、RoPE 等结构的支持需要大量适配。ONNX Runtime 在 Android 上的移动端优化也远不如它的服务器版本成熟,实际跑起来性能差距明显。

GGUF 的切分粒度和 bfloat16 支持都内置在规范里,不需要额外约定层。文件结构上,它用 key-value 元数据头加 tensor 数据区的单文件布局,mmap 加载时零拷贝——这是移动端节省内存的关键。

llama.cpp 的结构与 Android 交叉编译

llama.cpp 不是框架,它是一个推理引擎内核:纯 C/C++ 实现,不依赖 Python,核心代码不到 5 万行。在 Android 上移植,就是交叉编译一个 C++ 项目。

基本编译命令:

cmake -B build-android \
  -DCMAKE_TOOLCHAIN_FILE=$NDK_ROOT/build/cmake/android.toolchain.cmake \
  -DANDROID_ABI=arm64-v8a \
  -DANDROID_PLATFORM=android-26 \
  -DGGML_OPENMP=OFF \
  -DGGML_CPU_ARM_ARCH=armv8.2a \
  -DCMAKE_BUILD_TYPE=Release

cmake --build build-android -j$(nproc)

踩过一个坑:armeabi-v7a 架构下 llama.cpp 的 NEON 指令集路径会走 Fallback,性能断崖式下跌。32 位设备直接放弃,只支持 arm64-v8a 就够了。

编译产物是 libllama.so 和推理命令行工具。实际集成时我用 JNI 封装一层,把 C 接口暴露给 Kotlin:

// 关键 JNI 接口:加载模型并创建上下文
extern "C" JNIEXPORT jlong JNICALL
Java_com_example_llama_LlamaEngine_nativeInit(
    JNIEnv *env, jobject, jstring model_path, jint n_ctx) {
    
    const char *path = env->GetStringUTFChars(model_path, nullptr);
    
    llama_model_params mparams = llama_model_default_params();
    llama_model *model = llama_load_model_from_file(path, mparams);
    
    llama_context_params cparams = llama_context_default_params();
    cparams.n_ctx = n_ctx;
    cparams.n_threads = std::thread::hardware_concurrency();
    
    llama_context *ctx = llama_new_context_with_model(model, cparams);
    env->ReleaseStringUTFChars(model_path, path);
    return reinterpret_cast<jlong>(ctx);
}

n_ctx(上下文窗口大小)和 n_threads(推理线程数)这两个参数直接影响内存分配和推理延迟,后面调优会反复涉及。

三条性能调优路径

性能调优分三层,互相独立可以叠加。

第一层:量化等级选择

GGUF 的 Q4_K_M 是一个 sweet spot。Q4_0 更快但困惑度(Perplexity)损失明显;Q5 精度更好但体积大一圈。实测 7B 模型在骁龙 8 Gen 2 上的数据:

量化等级模型大小首 Token 延迟生成速度
Q4_03.8 GB1.2 秒18 tok/s
Q4_K_M4.1 GB1.3 秒16 tok/s
Q5_K_M4.8 GB1.5 秒12 tok/s

Q4_K_M 的生成速度比 Q5_K_M 快约 33%,体积小 15%,精度差距在大多数问答场景中不可感知。

第二层:线程与 NUMA 亲和性

手机上大小核(big.LITTLE)架构对线程分配敏感。llama.cpp 默认把所有计算线程扔到全部 CPU 核上,大核和小核混跑反而拖慢。

我的做法是把推理线程绑在大核集群,让音频或 UI 线程留在小核。Android 上没有 sched_setaffinity 的标准 API,需要借助 taskset 或反射调用藏起来的系统接口。绑核后生成速度从 14 tok/s 提升到 16 tok/s,提升不算大但稳定。

第三层:KV Cache 量化

llama.cpp 从 b3267 版本开始支持 KV Cache 的 8-bit 量化(cache_type_k=f16; cache_type_v=q8_0),对长文本推理效果明显。开启后 4096 token 上下文的内存占用从 ~2 GB 降到 ~1.1 GB,7B 模型在 8 GB 设备上跑长对话不再频繁 OOM。

cparams.cache_type_k = GGML_TYPE_F16;
cparams.cache_type_v = GGML_TYPE_Q8_0;  // 只量化 Value 缓存

只量化 Value 而保留 Query-Key 的 FP16,是因为 Key 稍大一些扰动对 Attention 计算的影响比 Value 大。

工程化集成的一些取舍

端侧推理不是跑起来就完事。实际产品里还要处理几个工程问题。

模型分发:GGUF 模型动辄 4 GB,不能塞 APK。我用 APK Expansion Files 或私有目录下载,首次启动从 CDN 拉取,支持断点续传和文件完整性校验(SHA256)。

显存和内存的统一管理:llama.cpp 走纯 CPU 推理,但 Android 上 GPU 推理(通过 OpenCL 或 Vulkan 后端)同样值得关注。我的测试中,高通的 GPU 后端在 1B 以下小模型上有优势,7B 级别反而受限于 GPU 内存带宽,CPU 方案更稳定。但这个问题在后续芯片代际肯定会变化。

流式推理的适配:llama.cpp 的回调机制天然支持逐 token 输出,封装到 Kotlin 协程的 Flow 接口里:

fun LlamaEngine.generateStream(prompt: String): Flow<String> = callbackFlow {
    nativeGenerateStream(ctx, prompt) { token ->
        trySend(token)
    }
    close()
}

本质是用 JNI 回调把 C 的同步推理循环变成 Kotlin 的异步数据流。

从移植到上线

整个链路上,GGUF 负责解决模型存储和传输效率,llama.cpp 负责推理执行效率,工程化封装负责接入效率。三层各管一段,缺一层都不实用。

实际落地时我的几个判断:

  1. 优先选 Q4_K_M,除非你的场景对精度要求异常敏感。省下的内存给上下文窗口比给参数精度划算得多。
  2. 不要过早优化 GPU 后端。CPU 推理在当前移动芯片上的成熟度和稳定性远超 GPU,先把 CPU 路径跑稳再考虑异构。
  3. KV Cache 量化几乎零成本,建议默认开启。这是为数不多的「加量不加价」优化项。

端侧 AI 推理的技术栈这两年变化极快。关注 llama.cpp 的 Release Notes 比关注任何中间级别的封装层都更有价值——它才是真正定义端侧推理能力的核心。