feat(llm): session cache-préfixe KV + streaming + mémoire conv. — v0.1.7
Intègre les 3 APIs livrées par l'engine (libkazeia_engine.so drop-in, façade EngineLlmEngine.kt : generateStream / newSession / LlmSession / GenStats). EngineLlmAdapter : crée une LlmSession au load (system prefillé 1×), et chaque generate() fait session.ask() en STREAMING (onToken câblé au pipeline existant). Le system n'est plus re-prefillé à chaque tour. Mesuré device (q35-lmq4, t=6, v0.1.7) : - tour : 2991→~1100 ms (prefill 2700→~300 ms, system caché). Decode réel 17 tok/s (le « 3,2 » était l'artefact prefill-inclus ; tps vient maintenant de getLastStats, débit décode). - streaming callback OK sous vraie JVM (le seul point que le dev n'avait pas pu tester) — aucun crash. - mémoire conversationnelle (bonus) : rappelle « Richard » sur 3 tours. Confidentialité : la mémoire est vidée aux bornes de conversation — EngineLlmAdapter.resetSession() (recrée la session) appelé sur CLEAR_CHAT et sur changement de profil actif (onProfileStoreChanged). Validé : après switch de profil, le LLM ne connaît plus le prénom. ⚠ Bug natif à corriger côté engine : sessionReset()+sessionAsk() renvoie vide (n_past non restauré à n_sys après seq_rm). Contourné en recréant la session (newSession). Le mode mémoire (ask→ask sans reset) est, lui, OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2acf3922c9
commit
8d548b1ca6
|
|
@ -44,8 +44,8 @@ android {
|
|||
applicationId = "com.kazeia"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 7
|
||||
versionName = "0.1.6"
|
||||
versionCode = 8
|
||||
versionName = "0.1.7"
|
||||
|
||||
// WebDAV de distribution — injecté depuis local.properties.
|
||||
buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"")
|
||||
|
|
|
|||
|
|
@ -31,12 +31,21 @@ class EngineLlmAdapter(
|
|||
private val onLog: ((String) -> Unit)? = null
|
||||
) : LlmEngine {
|
||||
private var engine: EngineLlmEngine? = null
|
||||
// Session = cache de préfixe KV : le system prompt n'est prefillé QU'UNE fois
|
||||
// (à la création), puis chaque tour ne prefille que le message user → ~2,5×
|
||||
// moins de latence/tour (mesuré ~2991→~1190 ms sur q35-lmq4) + streaming.
|
||||
// Pas créée pour plainChatml (Qwen2.5 = template ChatML différent → generateRaw).
|
||||
// Stateless : reset() avant chaque ask (comportement historique sans mémoire
|
||||
// LLM) — retirer ce reset activerait la mémoire conversationnelle.
|
||||
private var session: LlmSession? = null
|
||||
|
||||
override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) {
|
||||
if (engine != null) return@withContext
|
||||
val t0 = System.currentTimeMillis()
|
||||
engine = EngineLlmEngine(this@EngineLlmAdapter.modelPath, ctx = ctxLen, nThreads = nThreads)
|
||||
onLog?.invoke("[ENGINE] load OK (${System.currentTimeMillis() - t0}ms) path=${this@EngineLlmAdapter.modelPath} t=${nThreads}")
|
||||
val e = EngineLlmEngine(this@EngineLlmAdapter.modelPath, ctx = ctxLen, nThreads = nThreads)
|
||||
engine = e
|
||||
if (!plainChatml) session = e.newSession(systemPrompt) // prefille le system 1×
|
||||
onLog?.invoke("[ENGINE] load OK (${System.currentTimeMillis() - t0}ms) path=${this@EngineLlmAdapter.modelPath} t=${nThreads} session=${session != null}")
|
||||
}
|
||||
|
||||
override fun isLoaded(): Boolean = engine != null
|
||||
|
|
@ -48,22 +57,44 @@ class EngineLlmAdapter(
|
|||
): GenerationResult = withContext(Dispatchers.IO) {
|
||||
val e = engine ?: throw IllegalStateException("EngineLlmAdapter not loaded")
|
||||
val t0 = System.currentTimeMillis()
|
||||
val out = if (plainChatml) {
|
||||
// ChatML standard, sans bloc <think> (le natif generate l'injecterait).
|
||||
val s = session
|
||||
val out: String = if (s != null) {
|
||||
// Cache de préfixe + MÉMOIRE conversationnelle : le system reste prefillé,
|
||||
// chaque ask ne prefille que le user et conserve l'historique en KV.
|
||||
// (NB : on NE reset PAS par tour — sessionReset+ask renvoie vide,
|
||||
// bug natif n_past non restauré ; reset boundary à rétablir côté engine.)
|
||||
val sb = StringBuilder()
|
||||
s.ask(prompt, params.maxNewTokens) { piece ->
|
||||
sb.append(piece); onToken?.invoke(piece) ?: true
|
||||
}
|
||||
sb.toString().trim()
|
||||
} else {
|
||||
// plainChatml (Qwen2.5) : ChatML manuel, generateRaw bloquant.
|
||||
val p = buildString {
|
||||
append("<|im_start|>system\n").append(systemPrompt).append("<|im_end|>\n")
|
||||
append("<|im_start|>user\n").append(prompt).append("<|im_end|>\n")
|
||||
append("<|im_start|>assistant\n")
|
||||
}
|
||||
e.generateRaw(p, params.maxNewTokens).trim()
|
||||
} else {
|
||||
e.generate(systemPrompt, prompt, params.maxNewTokens).trim()
|
||||
e.generateRaw(p, params.maxNewTokens).trim().also { onToken?.invoke(it) }
|
||||
}
|
||||
val ms = System.currentTimeMillis() - t0
|
||||
onToken?.invoke(out)
|
||||
val n = out.split(Regex("\\s+")).size
|
||||
GenerationResult(out, n, ms, if (ms > 0) n * 1000f / ms else 0f)
|
||||
// Timing réel fourni par l'engine (prefill/decode séparés). tps = débit
|
||||
// DÉCODE (fini l'artefact "prefill inclus" qui donnait ~3 tok/s).
|
||||
val st = e.lastStats()
|
||||
val n = if (st.nTokens > 0) st.nTokens.toInt() else out.split(Regex("\\s+")).size
|
||||
val ms: Long = (st.prefillMs + st.decodeMs).takeIf { it > 0 } ?: (System.currentTimeMillis() - t0)
|
||||
onLog?.invoke("[ENGINE] prefill=${st.prefillMs}ms decode=${st.decodeMs}ms tok=${st.nTokens} (${"%.1f".format(st.decodeTokPerSec)} tok/s decode)")
|
||||
GenerationResult(out, n, ms, st.decodeTokPerSec.toFloat())
|
||||
}
|
||||
|
||||
override fun release() { engine?.release(); engine = null }
|
||||
/** Démarre une conversation VIERGE : recrée la session (re-prefille le system,
|
||||
* vide l'historique KV). À appeler aux bornes de conversation (changement de
|
||||
* patient, effacement du chat) — sinon la mémoire d'un patient fuiterait vers
|
||||
* le suivant. Recrée plutôt que d'appeler sessionReset() (bug natif : reset+ask
|
||||
* renvoie vide, n_past non restauré — à corriger côté engine). */
|
||||
fun resetSession() {
|
||||
val e = engine ?: return
|
||||
if (!plainChatml) session = e.newSession(systemPrompt)
|
||||
}
|
||||
|
||||
override fun release() { session = null; engine?.release(); engine = null }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@ class EngineJni {
|
|||
external fun reset(h: Long)
|
||||
external fun free(h: Long)
|
||||
|
||||
// -- Embeddings RAG (modèle e5/bge dédié). Cf docs/RAG_EMBEDDINGS_ENGINE_SPEC.md.
|
||||
// Tant que la lib ne fournit pas ces symboles, l'appel jette UnsatisfiedLinkError,
|
||||
// attrapé par EngineEmbedder -> RAG se désactive proprement.
|
||||
external fun loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long // handle, 0 = échec
|
||||
external fun embedText(handle: Long, text: String): FloatArray? // vecteur L2-normalisé
|
||||
external fun freeEmbedder(handle: Long)
|
||||
// -- Streaming + session (latence in-app). cb.onToken(piece) -> false pour arrêter.
|
||||
// generateStream = mono-tour streamé. session* = system prefillé 1×, réutilisé (cache de préfixe KV).
|
||||
external fun generateStream(h: Long, sys: String, usr: String, maxTok: Int, cb: TokenCallback)
|
||||
external fun sessionStart(h: Long, sys: String) // prefille le system + checkpoint KV
|
||||
external fun sessionAsk(h: Long, usr: String, maxTok: Int, cb: TokenCallback) // ne prefille que le user
|
||||
external fun sessionReset(h: Long) // repart du checkpoint système
|
||||
external fun getLastStats(h: Long): LongArray // [prefill_ms, decode_ms, n_tokens]
|
||||
|
||||
// -- TTS Talker (Qwen3-TTS) : I/O en embeddings, pas en tokens (vocab=3072 codes audio, pas de BPE).
|
||||
// Le caller (cf. TalkerEngine.kt) construit les embeds de prefill (text+x-vector) et de step
|
||||
|
|
@ -31,9 +32,40 @@ class EngineJni {
|
|||
external fun prefillEmbeds(h: Long, embdFlat: FloatArray, t: Int, outHidden: FloatArray): Int
|
||||
external fun decodeEmbed(h: Long, embd: FloatArray, outLogits: FloatArray, outHidden: FloatArray): Int
|
||||
|
||||
// -- Embeddings (RAG) : modèle dédié BERT-like (e5/bge), handle SÉPARÉ du LLM/TTS, CPU pur.
|
||||
// pooling: -1 = défaut du modèle (recommandé) ; 1 = MEAN (e5) ; 2 = CLS (bge).
|
||||
// embedText renvoie un float[n_embd] L2-normalisé (cosinus = dot product), ou null si échec.
|
||||
// Préfixes e5 ("query:" / "passage:") = côté appelant, PAS ici.
|
||||
external fun loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long
|
||||
external fun embedText(handle: Long, text: String): FloatArray?
|
||||
external fun freeEmbedder(handle: Long)
|
||||
|
||||
companion object { init { System.loadLibrary("kazeia_engine") } }
|
||||
}
|
||||
|
||||
/** Callback de streaming token. Renvoyer false pour interrompre la génération. */
|
||||
fun interface TokenCallback { fun onToken(piece: String): Boolean }
|
||||
|
||||
/** Découpage temporel du dernier appel (ms). */
|
||||
data class GenStats(val prefillMs: Long, val decodeMs: Long, val nTokens: Long) {
|
||||
val decodeTokPerSec: Double get() = if (decodeMs > 0) nTokens * 1000.0 / decodeMs else 0.0
|
||||
}
|
||||
|
||||
// Wrapper RAG-friendly. Un seul embedder par process (le modèle est petit ~120 MB).
|
||||
// Le MÊME GGUF doit servir à l'ingestion ET à la requête (vecteurs incompatibles sinon) :
|
||||
// versionne le modèle dans le contrat de distribution.
|
||||
class EmbedderEngine(model: String, nThreads: Int = 4, pooling: Int = -1) {
|
||||
private val jni = EngineJni()
|
||||
private val h = jni.loadEmbedder(model, nThreads, pooling)
|
||||
init { require(h != 0L) { "Kazeia-Engine: échec du chargement de l'embedder ($model)" } }
|
||||
|
||||
/** Texte -> vecteur L2-normalisé (float[n_embd]). Déterministe. */
|
||||
fun embed(text: String): FloatArray =
|
||||
jni.embedText(h, text) ?: error("embedText a échoué pour: \"${text.take(40)}\"")
|
||||
|
||||
fun release() = jni.freeEmbedder(h)
|
||||
}
|
||||
|
||||
class EngineLlmEngine(model: String, ctx: Int = 4096, nThreads: Int = 6) {
|
||||
private val jni = EngineJni()
|
||||
private val h = jni.load(model, ctx, nThreads)
|
||||
|
|
@ -44,11 +76,48 @@ class EngineLlmEngine(model: String, ctx: Int = 4096, nThreads: Int = 6) {
|
|||
// Multi-tour : prompt complet pré-formaté (voir ChatSession).
|
||||
fun generateRaw(prompt: String, max: Int = 96) = jni.generateRaw(h, prompt, max)
|
||||
|
||||
// Mono-tour STREAMÉ : émet chaque morceau via onToken (false = stop). Permet de démarrer le TTS
|
||||
// dès la 1ʳᵉ phrase. Récupère le découpage prefill/decode via lastStats() après l'appel.
|
||||
fun generateStream(sys: String, usr: String, max: Int = 96, onToken: (String) -> Boolean) =
|
||||
jni.generateStream(h, sys, usr, max, TokenCallback(onToken))
|
||||
|
||||
/** Session conversationnelle avec cache de préfixe KV : le system n'est prefillé qu'une fois. */
|
||||
fun newSession(system: String) = LlmSession(this, jni, h, system)
|
||||
|
||||
fun lastStats(): GenStats = jni.getLastStats(h).let { GenStats(it[0], it[1], it[2]) }
|
||||
|
||||
fun newChat(system: String) = ChatSession(this, system)
|
||||
fun reset() = jni.reset(h)
|
||||
fun release() = jni.free(h)
|
||||
}
|
||||
|
||||
// Session LLM persistante : prefille le system une seule fois (cache de préfixe KV), puis chaque
|
||||
// `ask` ne prefille que le nouveau message user — le KV système + l'historique des tours est conservé
|
||||
// (mémoire conversationnelle gratuite). `reset()` repart du system (nouvelle conversation).
|
||||
// Gain mesuré : tour N+1 ~1,5 s au lieu de ~5,4 s (plus de re-prefill du system ~200 tokens).
|
||||
class LlmSession internal constructor(
|
||||
private val engine: EngineLlmEngine,
|
||||
private val jni: EngineJni,
|
||||
private val h: Long,
|
||||
system: String,
|
||||
) {
|
||||
init { jni.sessionStart(h, system) }
|
||||
|
||||
/** Pose une question, streame la réponse via onToken (false = stop). Renvoie la réponse complète. */
|
||||
fun ask(user: String, max: Int = 96, onToken: ((String) -> Boolean)? = null): String {
|
||||
val sb = StringBuilder()
|
||||
jni.sessionAsk(h, user, max, TokenCallback { piece ->
|
||||
sb.append(piece)
|
||||
onToken?.invoke(piece) ?: true
|
||||
})
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun lastStats(): GenStats = engine.lastStats()
|
||||
/** Vide l'historique des tours, garde le system prefillé. */
|
||||
fun reset() = jni.sessionReset(h)
|
||||
}
|
||||
|
||||
// Conversation multi-tour : accumule l'historique et construit le ChatML Qwen3.5 + thinking-off.
|
||||
// Structure identique à celle validée (jni/dual_ctx_mt.cpp) : mémoire conversationnelle correcte.
|
||||
class ChatSession(private val engine: EngineLlmEngine, private val system: String) {
|
||||
|
|
|
|||
|
|
@ -880,8 +880,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
// sélecteur côté patient. Re-appliquée quand le profil actif change
|
||||
// ou que l'admin édite la voix d'un profil (ProfileStore notifie).
|
||||
applyActiveProfileVoice()
|
||||
lastActiveProfileId = ProfileStore.get(applicationContext).activeProfile()?.id
|
||||
if (!voiceProfileListenerRegistered) {
|
||||
ProfileStore.get(applicationContext).addListener { applyActiveProfileVoice() }
|
||||
ProfileStore.get(applicationContext).addListener { onProfileStoreChanged() }
|
||||
voiceProfileListenerRegistered = true
|
||||
}
|
||||
|
||||
|
|
@ -998,6 +999,22 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
}
|
||||
}
|
||||
|
||||
@Volatile private var lastActiveProfileId: String? = null
|
||||
|
||||
/** Réaction à un changement ProfileStore (admin édite un profil, ou le profil
|
||||
* actif change). Réapplique la voix ; et si le PATIENT ACTIF a changé, vide la
|
||||
* mémoire conversationnelle du LLM (sinon les échanges d'un patient fuiteraient
|
||||
* vers le suivant — confidentialité clinique). */
|
||||
private fun onProfileStoreChanged() {
|
||||
applyActiveProfileVoice()
|
||||
val nowId = ProfileStore.get(applicationContext).activeProfile()?.id
|
||||
if (nowId != lastActiveProfileId) {
|
||||
lastActiveProfileId = nowId
|
||||
(llm as? com.kazeia.llm.EngineLlmAdapter)?.resetSession()
|
||||
log("[LLM] profil actif changé → mémoire de session réinitialisée")
|
||||
}
|
||||
}
|
||||
|
||||
/** Applique au moteur TTS la voix du PROFIL ACTIF (ou [DEFAULT_VOICE_ID] en
|
||||
* mode invité / profil sans voix). Source unique de vérité = l'app admin. */
|
||||
private fun applyActiveProfileVoice() {
|
||||
|
|
@ -1522,6 +1539,8 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
}
|
||||
"CLEAR_CHAT" -> {
|
||||
_messages.value = emptyList()
|
||||
// Vide aussi la mémoire conversationnelle du LLM (session engine).
|
||||
(llm as? com.kazeia.llm.EngineLlmAdapter)?.resetSession()
|
||||
addMessage(ChatMessage(
|
||||
role = ChatMessage.Role.SYSTEM,
|
||||
text = "Conversation effacée."
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# jniLibs MANIFEST — état attendu de app/src/main/jniLibs/arm64-v8a/
|
||||
# Régénéré par scripts/jnilibs.sh update-manifest le 2026-06-14.
|
||||
# Régénéré par scripts/jnilibs.sh update-manifest le 2026-06-15.
|
||||
# Provenances : kazeia-engine dist (libllama/libggml*/libkazeia_*),
|
||||
# ExecuTorch build local (libexecutorch*, libqnn_executorch_backend, libfbjni),
|
||||
# QNN SDK 2.42 (libQnn*), Genie (libGenie), TTS pipeline (libtts_pipeline), libomp.
|
||||
|
|
@ -14,7 +14,7 @@ d49b53137f478b7b4ccbd58b9166d83e832c299b681609c70628291c61ae7204 libGenie.so
|
|||
66ea0c948385d8060a7296285d7a85738c21318a88ef767986444a3d9683aeea libggml-cpu.so
|
||||
7aeb209ec5c1da8857779b8f3b4c3e7519ed69c6211491948d3c87349dbced57 libggml.so
|
||||
77efdb8f4c233009c05f34635ab62a60cfc9e9702d5450397c404c0cca3baf11 libkazeia_cosyvoice.so
|
||||
daf3695e82debf425e4a53b73e96fcb9e9e01ab2fe8513bf6db8f53b09450a34 libkazeia_engine.so
|
||||
80d9a9e524da0b32acefaef04e6510234bf1fe6e5ee9547b885f9af0e40993d5 libkazeia_engine.so
|
||||
fdd730af3daf132d0819b3fc99abd8aae79800aab7a9aad371e117c16bd3a3aa libkazeia_stt.so
|
||||
114795bf0256c380dc87971598707e9e0b745a7fc868f3d1fb460e94cecdd69c libkazeia_tts.so
|
||||
349ba592462113c6e871b9c3ee508727a9e5f154a33905f946bda97535eff586 libllama-common.so
|
||||
|
|
|
|||
Loading…
Reference in New Issue