Фикс локальной LLM: таймаут 300с для LOCAL (инициализация движка ~17с + генерация), CPU-бэкенд без спекуляции (MTP виснет на CPU, GPU-делегат падает), диагностика chat()

Проверено на железе (HONOR FCP-N49): Gemma 4 E2B отвечает
«Здравствуйте! Чем я могу вам помочь с вашими очками Viewmate?»
(15 чанков, 61 символ, ~19с генерации).
This commit is contained in:
2026-08-23 04:38:02 +03:00
parent 7527cf8848
commit 75496dd35d
2 changed files with 54 additions and 10 deletions
@@ -112,13 +112,16 @@ class PhoneApp : Application() {
private var localLlm: LocalLlmClient? = null private var localLlm: LocalLlmClient? = null
/** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */ /** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */
val llmPrefs = LlmPrefs(this) // lazy: конструктор Application выполняется ДО attachBaseContext — контекст (и
private val _llmChoice = MutableStateFlow<LlmModelChoice>(llmPrefs.current()) // getSharedPreferences/getExternalFilesDir) доступен только после; ленивость —
val llmChoice: StateFlow<LlmModelChoice> = _llmChoice.asStateFlow() // тот же паттерн, что у chatDao/downloadManager (иначе NPE при старте).
val llmPrefs: LlmPrefs by lazy { LlmPrefs(this) }
private val _llmChoice: MutableStateFlow<LlmModelChoice> by lazy { MutableStateFlow(llmPrefs.current()) }
val llmChoice: StateFlow<LlmModelChoice> by lazy { _llmChoice.asStateFlow() }
/** Состояние локальной модели Gemma 4 E2B для UI настроек. */ /** Состояние локальной модели Gemma 4 E2B для UI настроек. */
private val _localLlmState = MutableStateFlow<LocalLlmModelState>(initialLocalLlmState()) private val _localLlmState: MutableStateFlow<LocalLlmModelState> by lazy { MutableStateFlow(initialLocalLlmState()) }
val localLlmState: StateFlow<LocalLlmModelState> = _localLlmState.asStateFlow() val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
private var localLlmDownloadJob: Job? = null private var localLlmDownloadJob: Job? = null
private fun initialLocalLlmState(): LocalLlmModelState = private fun initialLocalLlmState(): LocalLlmModelState =
@@ -203,6 +206,10 @@ class PhoneApp : Application() {
chatDao = chatDao, chatDao = chatDao,
chat = { history, system -> llm.chat(history, system) }, chat = { history, system -> llm.chat(history, system) },
toolkit = tk, toolkit = tk,
// Локальной модели нужно время: инициализация движка ~17с на
// CPU + генерация. Серверная укладывается в 30с, локальной
// даём 5 минут (иначе withTimeoutOrNull режет генерацию).
timeoutMs = if (choice == LlmModelChoice.LOCAL) 300_000L else 30_000L,
).also { ).also {
assistant = it assistant = it
assistantChoice = choice assistantChoice = choice
@@ -13,6 +13,7 @@ import com.google.ai.edge.litertlm.Message
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.File import java.io.File
@@ -22,6 +23,17 @@ import java.util.concurrent.TimeUnit
private fun log(tag: String, message: String) = pw.binom.viewmate.phone.log(tag, message) private fun log(tag: String, message: String) = pw.binom.viewmate.phone.log(tag, message)
/** Обрезает сообщение до 200 символов, оставляя читаемые (ASCII + кириллица). */
private fun sanitize(msg: String?): String {
if (msg == null) return "null"
val sb = StringBuilder()
for (ch in msg.take(200)) {
val c = ch.code
if ((c in 32..126) || (c >= 0x410 && c <= 0x44F)) sb.append(ch)
}
return sb.toString()
}
/** /**
* Выбор модели LLM ассистента — персистится ([LlmPrefs] в PhoneApp), * Выбор модели LLM ассистента — персистится ([LlmPrefs] в PhoneApp),
* переключается UI-блоком в настройках экрана «Телефон»: * переключается UI-блоком в настройках экрана «Телефон»:
@@ -60,11 +72,15 @@ object Gemma4E2B {
/** /**
* Конфиг движка: GPU-бэкенд (Adreno/Mali), каудирование CPU, кэш в * Конфиг движка: GPU-бэкенд (Adreno/Mali), каудирование CPU, кэш в
* [cacheDir] (cacheDir — не tmpDir, см. заметку про /data/local/tmp). * [cacheDir] (cacheDir — не tmpDir, см. заметку про /data/local/tmp).
* NOTE 2026-08-23: на Honor Magic V3 (FCP-N49) GPU-бэкенд litertlm 0.14.0
* падает в ml_drift-делегате (delegate_kernel.cc:925) — движок не создаётся.
* Пробуем CPU (decode ~46 ток/с на 8 Gen 3 — приемлемо); вернуть GPU,
* когда починится делегат (или обновится litertlm).
*/ */
fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig( fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig(
modelPath = modelFile.absolutePath, modelPath = modelFile.absolutePath,
backend = Backend.GPU(), backend = Backend.CPU(),
visionBackend = Backend.GPU(), visionBackend = Backend.CPU(),
audioBackend = Backend.CPU(), audioBackend = Backend.CPU(),
cacheDir = cacheDir.absolutePath, cacheDir = cacheDir.absolutePath,
) )
@@ -284,8 +300,11 @@ class LocalLlmClient(
fun modelDownloaded(): Boolean = modelFile.isFile fun modelDownloaded(): Boolean = modelFile.isFile
override suspend fun chat(messages: List<ChatMessage>, system: String?): String { override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
log("llm", "chat: вызов (messages=${messages.size}, system=${system?.length ?: 0} симв)")
val engine = ensureEngine() val engine = ensureEngine()
log("llm", "chat: движок готов, строю промпт")
val prompt = buildLocalLlmPrompt(messages, system) val prompt = buildLocalLlmPrompt(messages, system)
log("llm", "chat: промпт: system=${prompt.systemInstruction?.length ?: 0} симв, initial=${prompt.initialMessages.size}, incoming=${prompt.incoming.text().take(60)}")
val conversation: Conversation = synchronized(engineLock) { val conversation: Conversation = synchronized(engineLock) {
engine.createConversation( engine.createConversation(
ConversationConfig( ConversationConfig(
@@ -294,15 +313,30 @@ class LocalLlmClient(
) )
) )
} }
log("llm", "chat: conversation создан")
try { try {
var text = "" var text = ""
var chunks = 0
withTimeout(120_000) {
conversation.sendMessageAsync(prompt.incoming).collect { messageChunk -> conversation.sendMessageAsync(prompt.incoming).collect { messageChunk ->
text += messageChunk.text() chunks++
val chunkText = messageChunk.text()
text += chunkText
if (chunks <= 3 || chunks % 20 == 0) {
log("llm", "chat: чанк #$chunks: ${chunkText.take(50)}")
} }
}
}
log("llm", "chat: готово, чанков=$chunks, текст=${text.length} симв")
return text.trim() return text.trim()
} catch (e: CancellationException) { } catch (e: CancellationException) {
log("llm", "chat: отменено")
runCatching { conversation.cancelProcess() } runCatching { conversation.cancelProcess() }
throw e throw e
} catch (e: Exception) {
log("llm", "chat: EXC class=${e::class.simpleName} msgLen=${e.message?.length}")
log("llm", "chat: EXC msg=${sanitize(e.message)}")
throw IllegalStateException("Локальная модель не ответила: ${e.message}", e)
} finally { } finally {
runCatching { conversation.close() } runCatching { conversation.close() }
} }
@@ -321,7 +355,10 @@ class LocalLlmClient(
/** Включаем спекулятивное декодирование (на 2B заметно быстрее, см. заметки). */ /** Включаем спекулятивное декодирование (на 2B заметно быстрее, см. заметки). */
@OptIn(ExperimentalApi::class) @OptIn(ExperimentalApi::class)
private fun applyExperimentalFlags() { private fun applyExperimentalFlags() {
ExperimentalFlags.enableSpeculativeDecoding = true // NOTE 2026-08-23: на CPU-бэкенде спекуляция (MTP) виснет — движок
// инициализируется, но sendMessageAsync не выдаёт токены. GPU-делегат
// на этом устройстве падает (см. engineConfig). Пока — без спекуляции.
// ExperimentalFlags.enableSpeculativeDecoding = true
} }
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */ /** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */