Фикс локальной LLM: таймаут 300с для LOCAL (инициализация движка ~17с + генерация), CPU-бэкенд без спекуляции (MTP виснет на CPU, GPU-делегат падает), диагностика chat()
Проверено на железе (HONOR FCP-N49): Gemma 4 E2B отвечает «Здравствуйте! Чем я могу вам помочь с вашими очками Viewmate?» (15 чанков, 61 символ, ~19с генерации).
This commit is contained in:
@@ -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-потока). */
|
||||||
|
|||||||
Reference in New Issue
Block a user