安卓端侧大模型高效部署实战:模型加载与性能优化全攻略
上一篇文章完成了 llama.cpp 在 Android 平台的编译与 so 动态库加载,打下了基础设施。本文聚焦核心环节——将量化模型部署至手机端运行,涵盖 JNI 桥接、模型生命周期管理以及 UI 交互实现,逐步解析完整流程。
1. Kotlin 桥接层:Llama.kt
先从 Kotlin 端的桥接类入手,源码位于 app/src/main/java/com/example/llamatest/Llama.kt。通过 System.loadLibrary 加载预编译的 ggml 与 llama 原生库,并声明三个外部函数:loadModel(加载模型)、unloadModel(卸载模型)和 chat(对话生成)。采用 object 单例模式确保全局仅存在一个模型实例。
object Llama {
init {
System.loadLibrary("ggml")
System.loadLibrary("llama")
}
external fun loadModel(path: String): Boolean
external fun unloadModel()
external fun chat(prompt: String): String
}
2. JNI C++ 完整实现:llama_wrapper.cpp
核心的 JNI 实现位于 app/src/main/cpp/llama_wrapper.cpp,封装了三个关键接口:模型加载(loadModel)、文本生成(generate)和内存释放(releaseModel)。全局变量管理是设计要点——模型指针、上下文指针和词表指针均以 static 变量持有,需严格控制生命周期。
模型加载:首先释放旧资源,再从文件加载模型并配置上下文参数(上下文窗口设为 1024,采用单线程推理),最终返回布尔值指示加载结果。
#include
#include
#include
#include
#ifdef __cplusplus
extern "C" {
#endif
#include "llama.h"
#ifdef __cplusplus
}
#endif
#define LOGD(...) __android_log_print(ANDROID_LOG_INFO, "LLAMA_FIX", __VA_ARGS__)
// 全局变量
static llama_model* g_model = nullptr;
static llama_context* g_ctx = nullptr;
static const llama_vocab* g_vocab = nullptr;
// 加载模型
extern "C" JNIEXPORT jboolean JNICALL
Ja va_com_example_llamatest_MainActivity_loadModel(JNIEnv* env, jobject, jstring modelPath) {
// 清理旧资源
if (g_ctx) { llama_free(g_ctx); g_ctx = nullptr; }
if (g_model) { llama_model_free(g_model); g_model = nullptr; }
g_vocab = nullptr;
const char* path = env->GetStringUTFChars(modelPath, nullptr);
llama_model_params mparams = llama_model_default_params();
mparams.n_gpu_layers = 0;
g_model = llama_model_load_from_file(path, mparams);
env->ReleaseStringUTFChars(modelPath, path);
if (!g_model) return JNI_FALSE;
g_vocab = llama_model_get_vocab(g_model);
llama_context_params cparams = llama_context_default_params();
cparams.n_ctx = 1024;
cparams.n_threads = 1;
g_ctx = llama_init_from_model(g_model, cparams);
return g_ctx ? JNI_TRUE : JNI_FALSE;
}
采样与生成:采样逻辑采用贪心策略,直接选取 logits 最大值对应的 token。生成循环逐 token 解码并追加至结果字符串,直至遇到 EOS 或达到最大生成长度(32 tokens)。注意 batch 生命周期管理——代码使用了 llama_batch_get_one 创建的临时 batch,因此无需调用 llama_batch_free。
// 采样 token
static llama_token sample_token() {
float* logits = llama_get_logits_ith(g_ctx, -1);
int n_vocab = llama_vocab_n_tokens(g_vocab);
int best = 0;
float max_logit = -1e9;
for (int i = 0; i < n_vocab; i++) {
if (logits[i] > max_logit) {
max_logit = logits[i];
best = i;
}
}
return (llama_token)best;
}
// 生成
extern "C" JNIEXPORT jstring JNICALL
Ja va_com_example_llamatest_MainActivity_generate(JNIEnv* env, jobject, jstring prompt) {
if (!g_ctx || !g_model || !g_vocab) {
return env->NewStringUTF("模型未加载");
}
// 拼装提示词格式
const char* prompt_c = env->GetStringUTFChars(prompt, nullptr);
std::string input = "user\n" ;
input += prompt_c;
input += "\nmodel\n" ;
env->ReleaseStringUTFChars(prompt, prompt_c);
// 分词
std::vector tokens(512);
int n_tokens = llama_tokenize(g_vocab, input.c_str(), (int)input.size(),
tokens.data(), 512, true, false);
if (n_tokens <= 0) {
return env->NewStringUTF("分词失败");
}
// 推理提示词
llama_batch batch = llama_batch_get_one(tokens.data(), n_tokens);
llama_decode(g_ctx, batch);
std::string result;
const int MAX_GEN = 32;
const llama_token eos = llama_vocab_eos(g_vocab);
for (int i = 0; i < MAX_GEN; i++) {
llama_token token = sample_token();
if (token == eos || token == 0) break;
char buf[256] = {0};
llama_token_to_piece(g_vocab, token, buf, sizeof(buf)-1, 0, false);
result += buf;
// 推理下一个 token
llama_batch b = llama_batch_get_one(&token, 1);
llama_decode(g_ctx, b);
}
return env->NewStringUTF(result.c_str());
}
3. 布局文件:activity_main.xml
UI 层使用简洁的 LinearLayout 垂直布局,包含三个核心组件:显示结果的 TextView(ID:tv_result)、输入问题的 EditText(ID:et_input),以及“选择模型”和“发送”两个按钮。控件 ID 均采用下划线命名风格,与 Kotlin 代码中的 findViewById 调用保持一致。
"1.0" encoding="utf-8"?>
"http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gra vity="center"
android:orientation="vertical"
android:padding="16dp">
"@+id/tv_result"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="24sp"
android:textColor="#FF0000"
android:textStyle="bold"
android:gra vity="center"
android:minHeight="300dp" />
"@+id/et_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp" />
4. Activity 完整逻辑:MainActivity.kt
这是整个 App 的控制器,负责文件选择、模型加载、线程管理及 UI 更新等关键环节。以下设计要点值得关注:
- 模型加载状态标志:通过
isModelLoaded布尔变量控制发送按钮的启用状态,防止模型未加载时发起请求。 - 文件选择与复制:使用
Intent.ACTION_OPEN_DOCUMENT触发系统文件选择器,让用户选取 gguf 模型文件,随后异步复制至应用私有目录。此举可规避非 root 设备的文件访问权限限制。 - UI 线程安全:所有 UI 更新经由
uiHandler.post在主线程执行,而模型加载与推理等耗时操作则置于子线程处理。 - 资源释放:在
onDestroy生命周期中调用releaseModel释放模型资源,杜绝内存泄漏风险。
package com.example.llamatest
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.EditText
import android.widget.TextView
import ja va.io.File
import ja va.io.FileOutputStream
class MainActivity : Activity() {
private lateinit var tvResult: TextView
private lateinit var etInput: EditText
private lateinit var btnSelectModel: android.widget.Button
private lateinit var btnSend: android.widget.Button
private val REQUEST_FILE = 100
private val uiHandler = Handler(Looper.getMainLooper())
private var isModelLoaded = false
external fun loadModel(modelPath: String): Boolean
external fun generate(prompt: String): String
external fun releaseModel()
companion object {
private const val TAG = "LLAMA_DEBUG_FINAL"
init {
try {
Log.d(TAG, "【初始化】加载库:llama_jni")
System.loadLibrary("llama_jni")
Log.d(TAG, "【初始化】✅ 库加载成功")
} catch (e: Exception) {
Log.e(TAG, "【初始化】❌ 库加载失败", e)
}
}
}
override fun onCreate(sa vedInstanceState: Bundle?) {
super.onCreate(sa vedInstanceState)
setContentView(R.layout.activity_main)
tvResult = findViewById(R.id.tv_result)
etInput = findViewById(R.id.et_input)
btnSelectModel = findViewById(R.id.btn_select_model)
btnSend = findViewById(R.id.btn_send)
btnSelectModel.setOnClickListener {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(intent, REQUEST_FILE)
}
btnSend.setOnClickListener {
if (!isModelLoaded) {
updateUIText("❌ 请先选择并加载模型!")
return@setOnClickListener
}
val prompt = etInput.text.toString().trim()
if (prompt.isEmpty()) {
updateUIText("请输入问题")
return@setOnClickListener
}
Thread {
try {
val reply = generate(prompt)
uiHandler.post {
updateUIText("你:$prompt\n\nAI:$reply")
etInput.setText("")
}
} catch (e: Exception) {
uiHandler.post { updateUIText("错误:${e.message}") }
}
}.start()
}
}
override fun onDestroy() {
super.onDestroy()
try {
releaseModel()
isModelLoaded = false
} catch (e: Exception) { /* 忽略 */ }
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_FILE && resultCode == RESULT_OK) {
val uri = data?.data ?: return
Thread {
val file = File(filesDir, "gemma.gguf")
try {
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(file).use { output -> input.copyTo(output) }
}
// 等待3秒后加载模型
Thread.sleep(3000)
val success = loadModel(file.absolutePath)
uiHandler.post {
if (success) {
isModelLoaded = true
updateUIText("✅ 模型加载成功!可以聊天了!")
} else {
updateUIText("❌ 模型加载失败")
}
}
} catch (e: Exception) {
uiHandler.post { updateUIText("错误:${e.message}") }
}
}.start()
}
}
private fun updateUIText(s: String) {
runOnUiThread {
tvResult.text = s
tvResult.postInvalidate()
}
}
}
5. CMakeLists.txt 构建脚本配置
构建脚本位于 app/src/main/cpp/CMakeLists.txt。将 libllama.so 和 libggml.so 作为预编译库导入,编译 llama_wrapper.cpp 生成 libllama_jni.so。关键点在于头文件路径必须指向 llama.h 所在目录,且链接顺序需正确——llama_jni 依赖 llama 和 ggml,最后额外链接 androidlog 用于日志输出。
cmake_minimum_required(VERSION 3.22.1)
project(llamatest)
add_library(llama SHARED IMPORTED)
set_target_properties(llama PROPERTIES
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libllama.so)
add_library(ggml SHARED IMPORTED)
set_target_properties(ggml PROPERTIES
IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libggml.so)
target_include_directories(llama INTERFACE ${CMAKE_SOURCE_DIR}/llama)
target_include_directories(ggml INTERFACE ${CMAKE_SOURCE_DIR}/llama)
add_library(llama_jni SHARED
llama_wrapper.cpp)
target_include_directories(llama_jni PRIVATE
${CMAKE_SOURCE_DIR}/llama)
target_link_libraries(llama_jni
llama
ggml
androidlog)
6. build.gradle.kts 构建配置要点
App 模块的构建文件需配置 CMake 路径、ABI 过滤(仅保留 arm64-v8a)及 C++ 标准。注意 compileSdk 和 targetSdk 均设为 36,minSdk 为 35,以适配较新的 Android 版本。
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.llamatest"
compileSdk = 36
defaultConfig {
applicationId = "com.example.llamatest"
minSdk = 35
targetSdk = 36
versionCode = 1
versionName = "1.0"
externalNativeBuild {
cmake {
cppFlags += "-std=c++17"
}
}
ndk {
abiFilters.add("arm64-v8a")
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
compileOptions {
sourceCompatibility = Ja vaVersion.VERSION_11
targetCompatibility = Ja vaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
// 其他 compose 依赖...
}
7. 预置动态库部署
将上一篇文章编译生成的 libllama.so 和 libggml.so 放置于 app/src/main/jniLibs/arm64-v8a/ 目录下。确保 ABI 架构与 build.gradle 中的配置一致。
8. 运行流程说明
本示例使用的模型为前文《端侧 AI 模型部署实战三(模型转换)》中量化得到的 gemma-3-4b-it-q4_K_M.gguf。在非 root 手机上直接加载外部存储文件会触发权限问题,因此采用文件选择器授权方案——用户通过系统文件选择器选取模型文件,再复制至应用私有目录后加载。以下为手机离线环境下的运行结果截图。
9. 运行效果展示
10. 实践中的关键难点
项目使用了今年 3 月的 llama.cpp 分支(b8648),版本较新。AI 生成的参考代码在编译与运行时频繁崩溃,耗费大量时间排查。最终直接将 PC 端 llama-cli.exe 的源码提供给 AI,要求其基于最新主干输出 JNI 移植版本,才使运行趋于稳定。
一个值得分享的经验:模型加载与推理生成的 JNI 接口移植是端侧部署的核心,基础代码框架可由 AI 辅助生成,但核心流程与关键逻辑仍需手动阅读源码、理解原理,不可全权依赖 AI。
11. 待解决的遗留问题
目前仅实现了纯文本 LLM 的推理,尚未支持多模态。此外,文本生成速度较慢——单次推理耗时超过 10 秒,存在较大优化空间。实时性仍无法满足对话场景需求,后续计划探索 RAG 方案在手机端的落地实现。