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:
Kazeia Team 2026-06-15 11:12:11 +02:00
parent 2acf3922c9
commit 8d548b1ca6
5 changed files with 142 additions and 23 deletions

View File

@ -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")}\"")

View File

@ -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 }
}

View File

@ -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) {

View File

@ -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."

View File

@ -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