Интеграция On-Device LLM (Llama.cpp) для офлайн AI-ассистента в мобильном приложении
Запустить языковую модель прямо на смартфоне без интернета — это уже реальность. Llama.cpp даёт работающий инференс на CPU с опциональным Metal/Vulkan ускорением. Главный вопрос не «можно ли» а «какую модель и в каком квантовании выбрать, чтобы устройство не перегрелось через 5 минут».
Модели и их реальные требования
Llama.cpp работает с моделями в формате GGUF. Популярные варианты для мобиля:
| Модель | Квантование | Размер | RAM | Скорость (iPhone 14) |
|---|---|---|---|---|
| Llama-3.2-1B | Q4_K_M | 0.8 ГБ | ~1.2 ГБ | 25–35 t/s |
| Llama-3.2-3B | Q4_K_M | 2.0 ГБ | ~2.5 ГБ | 10–15 t/s |
| Phi-3-mini-4k | Q4_K_M | 2.2 ГБ | ~2.8 ГБ | 8–12 t/s |
| Gemma-2-2B | Q4_K_M | 1.6 ГБ | ~2.0 ГБ | 12–18 t/s |
| Qwen2.5-1.5B | Q4_K_M | 1.0 ГБ | ~1.4 ГБ | 20–28 t/s |
На iPhone SE 2nd gen (3 ГБ RAM) Llama-3.2-3B Q4 работает на пределе — OOM возможен при длинных контекстах. Безопасный выбор для широкого парка устройств — модели до 1.5–2 ГБ.
Сборка llama.cpp для iOS
# Клонируем репозиторий
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
# Сборка через CMake для iOS
cmake -B build-ios \
-DCMAKE_TOOLCHAIN_FILE=ios.toolchain.cmake \
-DPLATFORM=OS64 \ # arm64 only
-DLLAMA_METAL=ON \ # Metal GPU ускорение
-DLLAMA_STATIC=ON
cmake --build build-ios --config Release
Результат — libllama.a статическая библиотека. Создаём Swift Package с C-bridging header:
// llama_bridge.h
#include "llama.h"
// Обёртки для Swift-дружественного API
void* llama_create_context(const char* model_path, int n_ctx, int n_gpu_layers);
const char* llama_generate_token(void* ctx, const char* prompt);
void llama_free_context(void* ctx);
n_gpu_layers — количество слоёв, выгружаемых на Metal GPU. Значение -1 означает все слои на GPU. На iPhone 14 с 6 ГБ unified memory — ставьте -1. На устройствах с 3 ГБ — экспериментируйте: слишком много слоёв на GPU вызывает OOM.
Swift-обёртка для стриминга токенов
import Foundation
actor LlamaSession {
private var context: OpaquePointer?
private var model: OpaquePointer?
func load(modelPath: String, contextSize: Int32 = 2048, gpuLayers: Int32 = -1) throws {
var params = llama_model_default_params()
params.n_gpu_layers = gpuLayers
model = llama_load_model_from_file(modelPath, params)
guard model != nil else { throw LlamaError.modelLoadFailed }
var ctxParams = llama_context_default_params()
ctxParams.n_ctx = UInt32(contextSize)
ctxParams.n_batch = 512
context = llama_new_context_with_model(model, ctxParams)
}
func generate(prompt: String) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task.detached(priority: .userInitiated) {
// Токенизация
var tokens = [llama_token](repeating: 0, count: 4096)
let nTokens = llama_tokenize(self.model, prompt, Int32(prompt.utf8.count),
&tokens, 4096, true, false)
// Инференс — по одному токену
for i in 0..<nTokens {
llama_batch_add(&batch, tokens[Int(i)], llama_pos(i), [0], false)
}
while true {
llama_decode(self.context, batch)
let nextToken = llama_sample_token_greedy(self.context, &candidates)
if nextToken == llama_token_eos(self.model) { break }
// Конвертация токена в строку
var buf = [Int8](repeating: 0, count: 64)
llama_token_to_piece(self.model, nextToken, &buf, 64, 0, true)
let piece = String(cString: buf)
continuation.yield(piece)
}
continuation.finish()
}
}
}
}
Стриминг токенов через AsyncThrowingStream — пользователь видит текст по мере генерации, не ждёт весь ответ. Это критично для UX: 10 токенов в секунду воспринимается нормально, если текст появляется постепенно.
Android: llama.cpp через NDK
// CMakeLists.txt в jni/
add_library(llama_jni SHARED llama_jni.cpp)
target_link_libraries(llama_jni llama ggml)
// Kotlin side
class LlamaEngine {
init { System.loadLibrary("llama_jni") }
external fun loadModel(modelPath: String, nGpuLayers: Int): Long // возвращает handle
external fun generateNext(handle: Long, tokens: IntArray): String
external fun freeModel(handle: Long)
}
На Android — Vulkan backend вместо Metal: в CMakeLists включаем LLAMA_VULKAN=ON. Поддерживается на устройствах с Vulkan 1.1+, то есть практически всё с Android 10+.
Проблема с Android: процесс не имеет ограничения памяти как целого пула — система может убить приложение (SIGKILL) при нехватке RAM без предупреждения. ComponentCallbacks2.onTrimMemory(TRIM_MEMORY_RUNNING_CRITICAL) — последний шанс освободить контекст перед убийством процесса.
Скачивание модели: прогресс и верификация
GGUF-файлы весят 1–4 ГБ. Скачиваем через URLSession (iOS) или WorkManager с DownloadManager (Android):
// iOS: Background URLSession для скачивания в фоне
let config = URLSessionConfiguration.background(withIdentifier: "model-download")
config.isDiscretionary = false
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
let task = session.downloadTask(with: modelURL)
task.resume()
// Верификация SHA256 после скачивания
func verify(fileURL: URL, expectedHash: String) -> Bool {
guard let data = try? Data(contentsOf: fileURL) else { return false }
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined() == expectedHash
}
SHA256 хэш модели публикуют в репозитории на HuggingFace — сверяем до загрузки в память. Повреждённый GGUF вызывает краш при парсинге заголовка или позже при инференсе — лучше поймать на верификации.
Тепловые ограничения
Llama.cpp на iPhone при длительной генерации разогревает устройство. iOS throttling: при перегреве система снижает тактовую частоту, скорость генерации падает с 25 t/s до 8–10 t/s. Это не баг — поведение системы.
Практическое решение: ограничивать максимальный контекст (n_ctx) до 1024–2048 для коротких сессий. Между запросами — пауза. Мониторить ProcessInfo.processInfo.thermalState на iOS:
NotificationCenter.default.addObserver(forName: ProcessInfo.thermalStateDidChangeNotification, ...) { _ in
let state = ProcessInfo.processInfo.thermalState
if state == .critical || state == .serious {
// Приостановить генерацию, уведомить пользователя
}
}
Процесс
Подбор модели под парк устройств → сборка llama.cpp под целевые платформы → Swift/Kotlin обёртка с async стримингом → реализация скачивания и верификации → UI чат-интерфейса → стресс-тест на тепловые ограничения.
Ориентиры по срокам
Одна платформа, базовый чат-интерфейс с выбранной моделью — 3–5 недель. Обе платформы, несколько моделей на выбор, фоновое скачивание, управление контекстом — 7–12 недель.







