Ministral-3-3B: Architektur mistral3 steht nicht in der Liste des rkllm-Toolkits, dazu FP8-Gewichte -- nie konvertiert. Config auf Llama umbiegen waere moeglich gewesen, aber ein falsch eingelesenes Modell haette plausiblen Unsinn produziert. Phi-4-mini: konvertiert (5,06 GB), laedt aber nicht auf der NPU. partial_rotary_factor 0.75 ergibt 48 LongRoPE-Faktoren, die Runtime erwartet starr head_dim/2 = 64. Rockchips "Phi3" meint Phi-3-mini. SmolLM3-3B: laeuft, in 2 von 3 Faellen schneller als Qwen3, scheitert aber inhaltlich -- 13 statt 61 Todos, JSON-Schluessel als Listeneintraege, Englisch-Drift im deutschen Text. rkllm_server.cpp: Chat-Template kommt jetzt aus der Umgebung (RKLLM_TMPL_SYSTEM / _USER_PRE / _USER_POST), Defaults = bisheriges ChatML-Verhalten, mit Qwen3 gegengeprueft. Damit ist der naechste Kandidat eine Drop-in-Zeile statt einer Neuuebersetzung. Doku: Changelog 2026-09-29 (4) mit Messwerten und allen Fallstricken, Kapitel 05 um Architektur-Vorpruefung und Template-Konfiguration erweitert. Messstrecke: Zeitlimit 45 -> 90 Minuten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017A91sWU5aWrqZ2DWNTLVph
30 KiB
05 — LLM-Analyse (Qwen3-4B auf der NPU)
Modell
| Modell | Qwen3-4B-Instruct-2507 (non-thinking), qwen3-4b-w8a8g128-16k.rkllm (5,3 GB), seit 2026-07-16 |
| Runtime | rkllm 1.3.0; Chat-Template = ChatML (<|im_start|>user / <|im_end|><|im_start|>assistant) in rkllm_server.cpp |
| Quantisierung | grouped w8a8 (g128) — gleiche Wahl wie bei Gemma (plain w8a8 riskant, w4a16 auf RK3588 nicht unterstützt) |
| Kontext | 16k konvertiert, Runtime 12288 → Whole-Pass bis 32 000 Zeichen (WHOLE_MAX_CHARS) — Qwen3 schafft die Synthese, an der Gemma ab ~6k Token scheiterte |
| Tempo | Laden ~96 s (READY-Timeout deshalb 180 s!), 22k-Zeichen-Whole-Pass ~12-15 min, Kurzclip-Analyse ~7 min (gedrosselt) |
| Historie | Qwen2.5-3B (4096 ctx, Feld-Echos) → Gemma-3-4B-it (gut ≤ 20k, Synthese-blind) → Qwen3-4B (A/B 2026-07-16: 3/3 Prompts besser, Whole-Pass 21,8k fehlerfrei gewichtet). Rollback: rkllm_server{,.cpp}.bak-gemmatmpl + Drop-in auf gemma3-Modell zurückstellen. gemma3n-E4B zu groß (10,4 GB). |
Hart: niemals Online-LLM — alles lokal auf der NPU.
Pfadwahl nach Transkriptlänge
flowchart TB
T["Transkript"] --> A{"≤ 4 800 Zeichen?"}
A -- ja --> SINGLE["analyzeSingle:<br/>EIN Call = bereinigter Dialog + JSON-Analyse<br/>(Fallback: separater JSON-Call bei Echo/Parse-Fehler)"]
A -- nein --> LEN{"> 9 000 Zeichen?<br/>(SECTION_MIN_CHARS, seit 2026-07-29)"}
LEN -- "ja (Meeting-Länge)" --> LONG
LEN -- nein --> SPK{"labeled + ≥ 3 Sprecher?<br/>(MANY_SPEAKERS, countSpeakers)"}
SPK -- "ja (Round-Robin-Meeting)" --> LONG
SPK -- nein --> B{"≤ 32 000 Zeichen?"}
B -- ja --> WHOLE["analyzeWhole:<br/>GANZES Gespräch in einem Analyse-Call<br/>(kohärente Summary, kleine JSON-Ausgabe)<br/>Instruktion VOR und NACH dem Transkript"]
WHOLE --> WCHECK{"JSON parsebar +<br/>Zusammenfassung ≥ 20 Zeichen?"}
WCHECK -- "JSON kaputt" --> REPAIR["Repair-Call: NUR der kaputte<br/>Output (klein) → gültiges JSON"]
REPAIR --> WCHECK
WCHECK -- "weiterhin leer/Junk" --> LONG
WCHECK -- ja --> OUT
B -- nein --> LONG["analyzeLong (Sektions-Konglomerat):<br/>Chunks à 3 200 Zeichen (seit 2026-07-29;<br/>bei 8k wird der Inhalt generisch/namenlos,<br/>bei 4,2k konkurrieren zwei Themen pro Chunk);<br/>chunkText splittet an Zeilen = Turn-Grenzen"]
LONG --> MAP["MAP pro Chunk, sequenziell mit ROLLING CONTEXT<br/>(1-Satz-Kurzfassungen der vorherigen Abschnitte →<br/>Änderungen erkennbar: 'jetzt X, vorher Y')<br/>+ TAIL: die letzten 700 Zeichen des VORIGEN Chunks<br/>WÖRTLICH, ausdrücklich 'nicht zusammenfassen'<br/>(Bezugswörter für Sätze am Chunk-Anfang):<br/>Mini-JSON thema (Überschrift ≤ 6 Wörter) +<br/>zusammenfassung (JEDE berichtende Person, beruflich;<br/>JEDES Thema mind. 1 Satz; nicht verallgemeinern;<br/>Smalltalk max. Halbsatz) + eigennamen[] (EXTRAKTION)<br/>+ teilnehmer + todos (nur beruflich, Verantwortlicher<br/>in Klammern)"]
MAP --> SALV{"JSON parsebar +<br/>kein Feldbeschreibungs-Echo?"}
SALV -- nein --> RETRY["bis 3 Versuche, Prompt VARIIERT<br/>(2.: ohne Kontext, 3.: ohne Hint —<br/>manche Inhalte echoen sonst JEDES Mal)<br/>dann Feld-Salvage per Regex<br/>(zusammenfassung/thema-Strings überleben fast immer),<br/>zuletzt Prosa-Rettung (≥ 60 Zeichen, sonst verwerfen)"]
RETRY --> ABS
SALV -- ja --> ABS{"Entwurf behauptet etwas Grundsätzliches?<br/>Skopus-Wort (nur/ausschließlich/statt/…)<br/>UND kollektives Subjekt (das Team/wir/künftig/…)"}
ABS -- ja --> VERIFY["PRÜF-Aufruf NUR für diesen Abschnitt:<br/>Abschnitt wörtlich + Entwurf →<br/>'gilt das allgemein oder nur für EINEN Beitrag?'<br/>Antwort < 60 % der Entwurfslänge wird verworfen"]
VERIFY --> COLLECT
ABS -- nein --> COLLECT["Sektionen einsammeln"]
COLLECT --> DROP{"letzter Abschnitt =<br/>nur Verabschiedung?<br/>(2 Floskeln ODER Überschrift 'Abschluss…')"}
DROP -- ja --> DROPPED["Abschnitt verwerfen<br/>(nie ein innerer, nie der einzige)"]
DROPPED --> TITLE
DROP -- nein --> TITLE["TITEL-Mikro-Call: Thema + 2-Satz-Kurzfassungen<br/>OHNE letzten Abschnitt (Schluss-Smalltalk-Filter)<br/>→ nur {titel} — kleines Input/Output = zuverlässig"]
TITLE --> OVERVIEW["ÜBERBLICK-Mikro-Call über dieselben Kurzfassungen<br/>→ {ueberblick} 2-3 Sätze, alle Abschnitte gleich gewichtet<br/>(Key-tolerant parsen: auch 'überblick' oder<br/>irgendein langer String-Value; leer = weglassen)"]
OVERVIEW --> ASSEMBLE["KONGLOMERAT baut CODE (kein LLM-Reduce):<br/>Überblick-Absatz + pro Sektion 'Thema:\nSummary (Namen: …)'<br/>chronologisch + 'Genannte Namen: …' am Ende<br/>→ deterministisch, keine Fehlgewichtung möglich; Cap 16 000"]
ASSEMBLE --> OUT["Ergebnis"]
SINGLE --> OUT
Länge schlägt beides: Sektions-Pfad ab 9 000 Zeichen (2026-07-29)
Aufnahme #79 (23-min-Marketing-Jour-fixe, 3 diarisierte Sprecher, 21k Zeichen im gelabelten Transkript)
erfüllte keine der beiden bisherigen Bedingungen — unter WHOLE_MAX_CHARS 32 000 und unter
MANY_SPEAKERS 5 — und lief deshalb als Whole-Pass. Ergebnis: 1 082 Zeichen Zusammenfassung, in der
das erste Tagesordnungsthema (Timo/Sabbatical, die ersten 2,5 Minuten) komplett fehlte; MS Teams
lieferte für dasselbe Meeting 5 Themenblöcke. Die Sprecherzahl war also nie der eigentliche Treiber:
oberhalb von ~3k Token komprimiert das 4B den ANFANG des Prompts weg, egal wie viele Leute reden.
- Neu
SECTION_MIN_CHARS = 9 000(LLM_SECTION_MIN): alles darüber geht inanalyzeLong, das jeden Abschnitt bauartbedingt abdeckt.MANY_SPEAKERSzusätzlich 5 → 3. - Wirkung auf #79 (gleiches Transkript, gleiche Diarisierung): 1 082 → ~5 300 Zeichen, 7 Themenblöcke, alle Teams-Themen enthalten, 15–18 ToDos mit Verantwortlichen.
- Preis: der Sektions-Pfad braucht auf diesem Board ~20 statt ~8 Minuten.
Zwei Fehlerbilder des Sektions-Pfads — und was wirklich half (2026-07-29)
Beide wurden über zehn Läufe derselben Aufnahme gemessen, nicht geraten.
1. Verlorenes Thema am Chunk-Anfang. Bei 4 200 Zeichen enthielt Chunk 1 von #79 zwei zusammenhanglose
Themen (Timo-Gespräch, dann Video-Review) — das Modell fasste nur das längere zusammen, das Timo-Thema
verschwand in 1 von 3 Läufen. Fix: CHUNK_CHARS 4 200 → 3 200 plus die Prompt-Zeile „Enthält der
Abschnitt mehrere verschiedene Themen, behandle JEDES in mindestens einem Satz — auch ein kurzes Thema
ganz am Anfang." Ein früherer Versuch mit dem Zusatz „…und lasse weg, was unklar bleibt" machte es
schlimmer (das war die Lizenz, kurze Themen zu streichen) und wurde zurückgenommen.
2. Überdehnte Einzelaussagen. „Achso, ja, dann lassen wir LinkedIn raus, dann kommen wir es nur auf Instagram" — gesagt über EIN Ragebait-Video — wurde zu „das Team veröffentlicht nur noch auf Instagram", also der Umkehrung der echten Entscheidung (LinkedIn soll fachlicher werden). In 5 von 8 Läufen falsch, und keine Prompt-Formulierung hat das zuverlässig behoben (abstrakte Regel, dann Gegensatzpaar mit Beispiel — beide blieben drin, beide reichten nicht). Was gemessen wurde und half:
- Ein Skript, das
buildBasis+chunkTextnachbaut, lokalisierte die Auslöser: „lustigen Sachen nur bei Instagram" liegt 4 % in Chunk 6, also ohne Bezugswort. →TAIL_CHARS= 700: jeder Chunk bekommt die letzten 700 Zeichen des vorigen wörtlich als ausdrücklich gekennzeichneten Kontextblock. Das behob genau diesen Abschnitt. - Der zweite Auslöser sitzt mitten in Chunk 5 (51 %), hat also Kontext und wird trotzdem überdehnt. → gezielter Prüf-Aufruf, wenn der Entwurf ein Skopus-Wort UND ein kollektives Subjekt enthält. Erste Fassung des Triggers suchte wörtlich nach „nur noch"/„statt auch" und feuerte deshalb NIE auf den echten Fehlsatz „Das Team entscheidet sich dafür, nur Instagram zu nutzen statt LinkedIn".
Stand: die harte Umkehrung („LinkedIn wird beendet") ist weg, der Rest-Effekt ist eine abgeschwächte Formulierung („verstärkter Fokus auf Instagram") in einem von sieben Abschnitten, während Überblick und LinkedIn-Abschnitt die Strategie korrekt wiedergeben. Ein vollständiger Verifikationsdurchlauf über ALLE Abschnitte würde die Analyse verdoppeln — dafür ist der Rest-Fehler zu klein.
Round-Robin-Meetings: Sprecherzahl schlägt Länge (2026-07-17)
Aufnahme #74 (33-min-Weekly, 13 diarisierte Sprecher, 31k Zeichen) lag knapp UNTER WHOLE_MAX_CHARS
und lief als Whole-Pass — Ergebnis: Summary fast nur aus den LETZTEN Sprechern (Gasblender, Sitas,
Zeiterfassung), alle frühen Wochenberichte fehlten. Ein 4B kann 15+ gleichgewichtige Themen aus einem
~10k-Token-Prompt nicht ausbalancieren — der Whole-Pass funktioniert nur für narrative Gespräche mit
wenigen Sprechern (dort validiert, #70/#71). Deshalb routet analyze() labeled Transkripte mit
≥ 5 Sprechern (MANY_SPEAKERS) immer in den Sektions-Pfad, der jede Gesprächsphase per Konstruktion
abdeckt. Der Sektions-Pfad wurde dabei auf Copilot-Niveau ausgebaut (3 Iterationen auf #74):
- Themen-Überschrift pro Chunk (
thema-Feld) → Konglomerat liest sich wie Besprechungsnotizen („Performanceproblem bei SAP-System:" statt „Teil 2/10:"). - Jede berichtende Person MUSS vorkommen (Prompt-Regel) — vorher fielen einzelne Sprecher weg.
- Smalltalk-Dämpfung: Privates (Essen, Familie, Auto, Urlaubserzählungen) max. ein Halbsatz; Todos nur beruflich, mit Verantwortlichem in Klammern (37 → 30 saubere Todos auf #74).
- Überblick-Mikro-Call (
PROMPT_OVERVIEW) liefert den Intro-Absatz; gleiche kleine Input/Output-Klasse wie der Titel-Call. GOTCHA: Qwen antwortet teils mit Key"überblick"(ü!) statt"ueberblick"— Parser akzeptiert jeden langen String-Value. - Summary-Cap 6 000 → 16 000 Zeichen: 10 Sektionen ≈ 8k, der alte Cap schnitt mitten im Satz ab.
Validiert auf #74: alle 13 Sprecher + alle Themen (DWP-Bank, Diakonie, SAP-GUI, Sitas, Zeiterfassung,
Wellpass, LinkedIn, Polavis, Gasblender) abgedeckt, kein Recency-Bias mehr. Backup llm.js.bak-standup.
Warum kein LLM-Reduce mehr (Prompt-Lab 2026-07-16)
Der alte Reduce (Teil-Summaries → LLM baut EINE Gesamt-Summary) war die Fehlgewichtungs-Quelle. Im
Prompt-Lab auf #70 (temporärer /api/debug/ask-Endpoint, ~15 Varianten) scheiterte JEDE Synthese-Form
reproduzierbar: Gewichtungsregeln ignoriert (Auto-Smalltalk blieb im Titel), Namen durch Rollen ersetzt
(„Personalwesen"), Themen-Tabellen mit 4× demselben Eintrag, Repetitionsschleifen. Abschnittsübergreifende
Synthese liegt jenseits von Gemma-3-4B — Verstehen einzelner 4k-Abschnitte dagegen funktioniert gut
(Com4Tec, René, Tobi+Niklas, 20 Bewerbungen alle korrekt extrahiert). Also: LLM versteht, Code führt zusammen.
Whole-Pass-Grenze = Modellfrage (Gemma-Geschichte, gelöst durch Qwen3)
Mit Qwen3-4B liegt die Grenze bei 32 000 Zeichen (Kontext-Budget: 12288 − 1536 ≈ 10 752 Token) — der 21,8k-Whole-Pass von #70 kam in EINEM Call korrekt gewichtet zurück (Auto-Smalltalk raus, Namen drin). Zur Geschichte: mit Gemma-3-4B war die Grenze 20 000 Zeichen und zwar als MODELL-Grenze — Aufnahme #70 (26 min, 22,6k Zeichen ≈ 6 900 Token) scheiterte REPRODUZIERBAR an Gemma selbst (vier Iterationen, alle Fehlbilder real beobachtet):
- Instruktion nur oben → Modell „vergisst" die Aufgabe, antwortet mit englischem Geplauder
über das Gespräch („Okay! That was a fascinating conversation…"). Fix: Instruktion nach dem
Transkript wiederholen (ohne
{"titel": ...}-Skelett — die...-Platzhalter wurden sonst wörtlich kopiert!). - Recency-Bias: Titel/Summary beschreiben den SCHLUSS des Gesprächs (Auto-Smalltalk der letzten 3 min) statt der Hauptthemen — mehr Kontextfenster liest nicht gleichmäßiger.
- „themen"-Inventar-Feld als Gegenmittel → Repetitionsschleife mit zerfallendem JSON (typografische Anführungszeichen, doppelte Keys, 16-min-Generierung bis Token-Cap).
Konsequenz: > 20k Zeichen geht IMMER über Map-Reduce; dessen Reduce gewichtet stattdessen nach
Abschnitts-Anteil. WHOLE_MAX_CHARS nicht erhöhen ohne Inhalts-Revalidierung.
Strukturierte Zusammenfassung (seit 2026-07-15)
Das LLM liefert getrennte Felder; composeSummary() baut daraus den Summary-Text:
<Überblick, 2–4 Sätze>
Kernpunkte:
• <Punkt>
• <Punkt>
Entscheidungen:
• <Entscheidung>
GUI rendert mit white-space: pre-line (style.css) → Bullets und Absätze sichtbar. Todos bleiben eigenes
Feld/eigene Liste. Teilnehmer kommen NICHT vom LLM, sondern aus der Diarization (Kap. 04) — das LLM
hatte alle im Gespräch erwähnten Namen als Teilnehmer gelistet (11 statt 3 bei Aufnahme #69).
Sektions-Konglomerat: was im Text landet
Der Body der Zusammenfassung ist deterministisch aus den Chunk-Ergebnissen gebaut (kein LLM-Reduce):
pro Abschnitt thema-Überschrift + Text, davor der Überblick-Absatz, am Ende eine Zeile
„Genannte Namen: …".
- Die pro Abschnitt extrahierten Eigennamen (
s.names) werden seit 2026-07-27 nicht mehr unter jeden Absatz gedruckt („(Namen: Kai, Dennis, …)") — sie sind Arbeitsdaten für den Rolling Context zwischen den Chunks (halten die Schreibweise von Personen/Firmen stabil), kein Lesestoff.
Namen nachträglich in eine fertige Analyse übernehmen (seit 2026-07-27)
Die Zusammenfassung entsteht aus einem Dialog, in dem jeder Sprecher ein Label trägt: seinen Namen, falls beim Analysezeitpunkt bekannt, sonst „Sprecher N". Wer erst DANACH benannt wird (Sprecher-Popover, Namensmarker), stand vorher weiter als „Sprecher 5" im fertigen Text — und eine Neu-Analyse kostet auf diesem Board ~20–30 min.
flowchart LR
A["analyzeRecording()"] --> B["buildBasis() vergibt Labels<br/>{speaker_09: 'Sprecher 5'}"]
B --> C[("recordings.speaker_labels")]
B --> D["LLM: Zusammenfassung/Todos<br/>mit genau diesen Labels"]
E["Sprecher umbenennen<br/>(Popover / Marker)"] --> F["relabelAnalysis()"]
C --> F
F --> G["Whole-Word-Ersetzung in<br/>summary · todos · transcript_clean"]
F --> H["Teilnehmerliste NEU gebaut<br/>(Reihenfolge des ersten Auftretens)"]
F --> C
speaker_labels(JSON, pro Aufnahme) hält genau das Label, das jeder Cluster im LLM-Text hatte — deshalb ist die Ersetzung eindeutig und braucht keine erneute Analyse (Sekunden statt Minuten).- Nach der Ersetzung wird das gespeicherte Label auf den Namen gesetzt, damit ein zweites Umbenennen wieder funktioniert.
- Teilnehmerliste wird neu aufgebaut statt ersetzt (String-Swap ließ Sprecher stehen, die im Text nie vorkamen, und konnte Namen doppeln).
- Mehrdeutige Labels werden übersprungen: trug dasselbe Label zwei Cluster (die Stimm-Erkennung hatte „Fabio" einmal auf zwei Cluster gelegt), lässt sich im Text nicht entscheiden, wer gemeint war — dann bleibt der Text unangetastet. Sauber wird das erst durch „Analyse neu (nur KI)" mit korrekten Namen.
- GUI: Umbenennen → Toast „Name in Zusammenfassung übernommen", Detailansicht lädt neu.
Robustheit gegen Gemma-Marotten (alle real aufgetreten)
| Marotte | Gegenmaßnahme |
|---|---|
| Antwortet Prosa statt JSON bei großen Inputs | kleine Chunks (8 000), minimaler Chunk-Prompt, Reduce-Input gekappt + Retry, Prosa-Salvage |
| Kaputtes JSON leakt in die Summary | {...}-Blobs werden vor Salvage entfernt |
Todos als Objekte ({"aufgabe": …}) statt Strings |
cleanList() flacht Objekt-Werte zu Strings ab |
"null" / "[object Object]" / lose ? in Listen |
cleanList() filtert/trimmt |
| Echot Feldbeschreibungen statt Inhalt | echoed()-Guard → separater JSON-Call |
| Echot konkrete Beispiele aus dem Prompt (Phantom-Todos, „Kompressor“-Summary) | Korrektur-Regeln bewusst OHNE konkrete Beispiele (CORRECTION_HINT abstrakt) |
| Erfindet Fantasie-Komposita („Audio-Dualeintragung“) | NO_INVENT-Regel in allen Summary-Prompts |
| Chunk-Parse-Fehler variieren pro Lauf (Sampling-Münzwurf: 3/3 ok, nächster Lauf 1/3) | 1 Retry pro Chunk (P(fail) → P(fail²)), danach Salvage; bei schwachem Ergebnis „Analyse neu“ |
| Typografische Anführungszeichen (“”„) als JSON-Delimiter, trailing commas | extractJson()-Sanitizer: erst strikt parsen, bei Fehler Quotes/Kommas reparieren und nochmal |
| Whole-Pass > ~6k Token: englisches Geplauber, Recency-Bias, JSON-Zerfall | WHOLE_MAX_CHARS 20 000 hart; End-Instruktion; Repair-Call; Sanity-Check (Zusammenfassung ≥ 20 Zeichen) → sonst Chunk-Fallback |
Kopiert ...-Platzhalter aus Schema-Beispielen wörtlich |
End-Instruktion nennt nur Feldnamen, nie ein Skelett mit Platzhaltern |
| 8k-Chunk: Inhalt wird generisches namenloses Blabla (JSON trotzdem valide!) | Chunks à 4 200 Zeichen — dort bleibt der Inhalt geerdet mit echten Eigennamen |
| Echot Feldbeschreibung INHALTS-abhängig bei JEDEM Versuch (Auto-Abschnitt #70, 2× identisch) | Echo-Guard + bis 3 Versuche mit VARIIERTEM Prompt (ohne Kontext / ohne Hint) |
eigennamen-Array degeneriert (Punkte-Loops, Junk-Tails), String-Felder intakt |
Feld-Salvage per Regex holt zusammenfassung + Namen einzeln raus |
| Cross-Section-Synthese: Regeln ignoriert, Namen→Rollen, Themen-Dubletten | LLM-Reduce ABGESCHAFFT — Konglomerat baut deterministischer Code |
Eigennamen-Glossar (Whisper hört z. B. „Aime“ als „Amy“)
flowchart LR
G[("glossary-Tabelle<br/>GUI-Tab 'Glossar', /api/glossary")] --> Q{"Eintrag hat<br/>Soundalikes?"}
Q -- ja --> DET["DETERMINISTISCH: Ganzwort-Ersetzung<br/>direkt auf rec.words VOR der Analyse<br/>(applyGlossaryText / glossaryMap)"]
Q -- nein --> HINT["nur als Hinweis in den LLM-Prompt<br/>(glossaryHint)"]
DET --> LLM2["LLM sieht schon den korrekten Begriff"]
LLM2 --> BELT["Gürtel+Hosenträger: Ersetzung nochmal<br/>auf Titel/Summary/Todos des LLM-Outputs"]
Wichtig: Begriffslisten niemals als Whisper---initial-prompt (Echo-Loop, verifiziert) und dem 3–4B-Modell
keine „ersetze X durch Y“-Anweisung anvertrauen (unzuverlässig) — deshalb die deterministische Text-Ersetzung.
Kontextnotiz des Nutzers (seit 2026-08-17)
Nach dem Stop bietet die Detailansicht ein Freitextfeld an: „Kontext zum Gespräch“. Was dort steht, geht als Hintergrundwissen in die Analyse — nicht als Gesprächsinhalt. Damit weiß das Modell, wer dabei war, worum es ging und wie Eigennamen geschrieben werden, statt das alles aus fehlerbehaftetem ASR-Text zu erraten.
Bewusst EIN Feld, kein Formular (Entscheidung des Nutzers): die Box benutzen später auch technisch nicht versierte Menschen, und „Kontext hinzufügen“ ist eine Hürde weniger als Teilnehmer-, Anlass- und Begriffsfelder auszufüllen. Die Struktur zieht sich die Analyse selbst aus der Prosa. Geführt wird über den Platzhaltertext („Wer war dabei, worum ging es, wichtige Namen oder Begriffe? … z. B.: Call mit Herrn Meier von Firma Krämer wegen Angebot Halle 3 …“) statt über Pflichtfelder.
flowchart TB
STOP["Stop gedrückt"] --> BOX["Kontextbox klappt AUF und ist fokussiert<br/>(state.ctxPromptFor, main.js)"]
BOX --> SAVE["PATCH /api/recordings/:id/context<br/>db.setContextNote → recordings.context_note<br/>(Deckel 4 000 Zeichen, leer = löschen)"]
SAVE --> WHEN{"Wie weit ist die<br/>Verarbeitung?"}
WHEN -- "status=transcribing" --> AUTO["applies='auto':<br/>Analyse liest die Notiz danach → fließt sicher ein"]
WHEN -- "analysis_status=pending" --> RUN["applies='running':<br/>Analyse läuft schon, kann die Notiz verpasst haben<br/>→ GUI verweist auf '↻ Analyse neu'"]
WHEN -- "done / error" --> RE["applies='reanalyze':<br/>GUI fragt nach und startet reanalyze-llm<br/>(Sprecher bleiben, nur LLM neu)"]
AUTO --> READ
RE --> READ
READ["analyzeRecording: db.getContextNote(id)<br/>ERST kurz vor dem LLM-Aufruf gelesen<br/>(Sprecher-Erkennung davor dauert Minuten —<br/>was der Nutzer in der Zeit tippt, zählt noch)"] --> INJ["llm.analyze({context}) → contextBlock()"]
INJ --> P1["analyzeSingle: PROMPT_CLEAN(_LABELED) + PROMPT_JSON"]
INJ --> P2["analyzeWhole: PROMPT_WHOLE_ANALYZE"]
INJ --> P3["analyzeLong: JEDER Chunk-Prompt (gekürzt)<br/>+ Titel- und Überblick-Mikro-Call"]
Halluzinationsbremse. Die Notiz steht in einem eigenen, mit """ abgegrenzten Block mit der
ausdrücklichen Auflage: nur für Schreibweisen, Zuordnung, Anlass und Gewichtung nutzen, keine
Aussagen, Ergebnisse, Entscheidungen oder Aufgaben daraus übernehmen, Notiz nicht erwähnen. Ohne diese
Klammer schreibt ein 4B-Modell die Notiz als Gesagtes in die Zusammenfassung zurück — dieselbe Falle wie
beim CORRECTION_HINT (siehe dessen STRENG-Satz).
Zwei Deckel, weil die Notiz je Pfad unterschiedlich oft im Prompt landet:
| Konstante | Wert | Gilt für |
|---|---|---|
db.CONTEXT_MAX |
4 000 Zeichen | Speicherung (Spalte ist TEXT) |
LLM_CONTEXT_CHARS |
1 200 Zeichen | Prompts, die die Notiz einmal sehen (Single/Whole/Titel/Überblick) |
LLM_CONTEXT_CHUNK_CHARS |
600 Zeichen | Chunk-Prompts des Sektions-Pfads — dort geht die Notiz in jeden Chunk (CHUNK_CHARS = 3 200, eine lange Notiz wäre ein Drittel Ballast) |
Im Sektions-Pfad fällt die Notiz auf dem letzten von drei Versuchen zusammen mit dem hint weg: hat
das Modell zweimal die Feldbeschreibung nachgeplappert, ist jeder Zusatztext vor dem Abschnitt verdächtig.
Grenze: die Notiz verbessert Titel, Zusammenfassung, Teilnehmer und ToDos. Das Transkript selbst
verbessert sie nicht — Whisper ist zu dem Zeitpunkt schon gelaufen, und Begriffslisten dürfen ohnehin
nicht als --initial-prompt an Whisper (Echo-Loop, siehe Glossar-Abschnitt). Wer Schreibweisen dauerhaft
korrigiert haben will, nimmt das Glossar; die Notiz gilt nur für dieses eine Gespräch.
Zusammenspiel mit Whisper auf der NPU
rkllm (LLM) und Whisper-NPU-Encoder können nicht gleichzeitig resident sein (RAM-Contention, äußerte sich
als rc=-6). Kurzclips (≤ 30 s) nutzen deshalb den CPU-Whisper-Daemon ohne rkllm-Swap; lange Aufnahmen
swappen seriell unter dem NPU-Lock.
Thermik: analyzeRecording() läuft seit 2026-07-16 unter dem A76-Takt-Cap (.bak-anacap) — rkllm
zieht neben der NPU ~1,7 CPU-Kerne, eine lange ungedeckelte Gemma-Generierung erreichte 95–97 °C
(lüfterlos, Schutzabschaltungs-Risiko). Gleicher Cap wie transcribeWav/rediarize (Kap. 03).
Speicherhaushalt: nur EIN großes Modell resident (seit 2026-08-07)
Bis dahin hat jeder Helfer für sich entschieden, wann er lädt und wer dafür weichen muss — und das ging
schief. Gemessene Spitzen (VmHWM) auf 7,74 GB Gesamtspeicher:
| Prozess | Spitze | Anmerkung |
|---|---|---|
rkllm_server (Qwen3-4B w8a8) |
6,5 GB | davon 4,79 GB unevictable NPU-Shmem — nicht auslagerbar |
embed_text.py (e5-small, Suche) |
1,15 GB | faul, bleibt danach resident |
transcribe_daemon_cpu.py (CT2) |
0,90 GB | Kurz-Clip-Pfad, bleibt per Design warm |
spk_embed (TitaNet) |
0,70 GB | faul, bleibt danach resident |
Zwei Ausfälle am 2026-08-07, beide aus derselben Wurzel (Aufnahme #101, 34 s, 2 Personen):
- Die 2-Sekunden-Aufnahme #100 davor hatte den warmen CT2-Daemon gestartet. Beim anschließenden
rkllm-Load war er noch resident →
rkllm_serverwurde in allen 5 Startversuchen OOM-getötet, GUI: „KI-Analyse fehlgeschlagen". Ein Retry ohne Aufräumen stirbt zwangsläufig genauso wie der erste Versuch. - Nach einer Analyse blieb rkllm für immer resident (belegt: der Prozess von 10:37 lebte noch um
13:27). Der Suchindex am Ende von
analyzeRecording()startet aber das e5-Modell — 1,15 GB neben 6,5 GB → der Kernel erschlug rkllm (13:27:09,dmesg). Das war auch der Dauerzustand „nur noch ~100 MB frei", in dem jeder weitere Helferstart Glückssache war.
⚠️ zram rettet das nicht: der komprimierte Swap liegt selbst im RAM. 0,9 GB ausgelagert kosten weiter ~0,3–0,45 GB physisch — und rkllms 4,79 GB Shmem sind ohnehin nicht auslagerbar.
Regel jetzt: wer ein großes Modell lädt, räumt vorher auf. gate.js führt dazu eine Registry
(registerReleaser / freeRam(keep), verdrahtet in server.js — dasselbe Muster wie setProbe, damit
gate.js keines der Module importieren muss). Alle Helfer starten faul neu; es geht nur Ladezeit
verloren, kein Zustand.
flowchart TB
subgraph REG["gate.js — Registry"]
R1["rkllm → llm.stopServer()"]
R2["cpu-transcriber → rec.stopTranscribeDaemon()"]
R3["text-embedder → emb.stop()"]
R4["spk-embedder → spk.stop()"]
end
A["NPU-Whisper startet<br/>(recorder.js)"] --> FA["freeRam('npu-whisper')<br/>→ beendet ALLE vier"]
B["rkllm lädt<br/>(llm.startServer, vor JEDEM<br/>der 5 Versuche)"] --> FB["freeRam('rkllm')<br/>→ beendet die anderen drei"]
FA --> REG
FB --> REG
REG --> W["SIGTERM, auf Prozessende warten,<br/>1,5 s Kernel-Reclaim<br/>(gate.killAndWait)"]
W --> LOAD["großes Modell lädt allein"]
LOAD --> END["Analyse fertig →<br/>llm.stopServer() VOR dem Suchindex<br/>(analyzeRecording)"]
Verifiziert am 2026-08-07 mit genau der Abfolge, die vorher scheiterte: 12-s-Clip → warmer
CT2-Daemon resident → Analyse von #101 → Journal
ram: für rkllm freigegeben — cpu-transcriber, text-embedder, spk-embedder, rkllm lud in 22 s (nicht
96 s: die Modelldatei lag noch im Seiten-Cache), Analyse in 221 s fertig, kein OOM. Danach 5,3 GB
frei statt 100 MB.
Preis: die nächste Analyse lädt rkllm neu. Der Kurz-Clip-Pfad verliert damit seinen „rkllm bleibt warm"-Vorteil (Kap. 03) — dafür ist die Analyse verlässlich statt schnell-aber-manchmal-tot.
Modellwechsel und A/B-Tests
Das aktive Modell steht in einer systemd-Zeile:
cat /etc/systemd/system/callrec.service.d/gemma-model.conf
# [Service]
# Environment=RKLLM_MODEL=/opt/rkllm-models/qwen3-4b-w8a8g128-16k.rkllm
systemctl daemon-reload && systemctl restart callrec
llm.js:10 liest process.env.RKLLM_MODEL (Fallback: das alte qwen2.5-3b). Ein Wechsel ist damit
umkehrbar, solange die alte .rkllm liegen bleibt. Vor jedem Restart prüfen, ob eine Analyse läuft
— ein Restart killt sie ersatzlos.
Gemessen wird mit lab/ab-llm.mjs (seit 2026-09-29). Es ruft reanalyze-llm auf, also ohne
Neu-Diarisierung: gleiches Transkript, gleiche Sprecher, gleiche Kontextnotiz — der einzige Unterschied
zwischen zwei Läufen ist das Modell. snapshot sichert die Originalanalysen vorher, restore schreibt
sie zurück, report legt Tabelle und Volltext-Vergleich nebeneinander.
flowchart LR
SNAP["snapshot <ids><br/>Originalanalysen sichern"] --> RUNA["run A<br/>aktuelles Modell"]
RUNA --> SWAP["gemma-model.conf umstellen<br/>daemon-reload + restart"]
SWAP --> RUNB["run B<br/>neues Modell"]
RUNB --> REP["report A B<br/>Dauer / Laenge / Todos + Volltext"]
REP --> DEC{"besser?"}
DEC -->|ja| KEEP["Drop-in bleibt<br/>altes .rkllm liegt als Rueckweg"]
DEC -->|nein| BACK["Drop-in zurueck<br/>restore schreibt Analysen zurueck"]
Die Konvertierung eines neuen Modells passiert nicht auf dem Board (kein CUDA), sondern auf dem PC
mit tools/rkllm-qwen35/ — rkllm-toolkit-Version muss exakt zur Runtime auf dem Board passen.
Bevor ein Kandidat geladen wird: Architektur prüfen
Das rkllm-Toolkit 1.3.0 kennt nur diese Architekturen (aus loader.so/common.so extrahiert):
Baichuan, ChatGLM, Deepseek, DeepseekOCR, Gemma/2/3/3n/4, InternLM2, Llama,
MiniCPM/3, Phi/Phi3, Qwen2/2VL/2.5VL/3/3VL/3.5, RWKV7, SmolLM3, TeleChat/2
curl -sL https://huggingface.co/<repo>/raw/main/config.json und architectures dagegenhalten —
Sekunden statt Gigabyte. Gelistet heißt aber nicht lauffähig: Phi-4-mini trägt Phi3ForCausalLM,
scheitert aber an der Geometrie (partielle Rotary Embeddings, 48 statt 64 LongRoPE-Faktoren), weil
Rockchips Phi3-Pfad auf Phi-3-mini zugeschnitten ist. Ebenso zu prüfen: quantization_config —
FP8-Gewichte (z. B. Ministral 3) kann das Toolkit nicht lesen.
Chat-Template aus der Umgebung
rkllm wendet das HF-Template nicht selbst an. Ohne passende Marker bekommt das Modell den nackten
Prompt und faselt generisches Füllmaterial. Seit 2026-09-29 kommen die Marker aus der Umgebung:
RKLLM_TMPL_SYSTEM, RKLLM_TMPL_USER_PRE, RKLLM_TMPL_USER_POST (Defaults = ChatML, also unverändert
für Qwen und Gemma3). \n/\t werden im Server dekodiert, weil systemd-Environment= keine echten
Umbrüche kann — und aus demselben Grund beim Schreiben des Drop-ins zitiertes Heredoc statt printf.
Denk-Modelle (Qwen3.5 und jünger)
Modelle mit Denk-Modus liefern sonst Gedankengang statt JSON. RKLLMInput.enable_thinking = false genügt
nicht — der Assistenten-Zug muss mit einem leeren Denkblock vorbefüllt werden. Dafür gibt es
RKLLM_SUPPRESS_THINK=1: dann hängt rkllm_server.cpp <think>\n\n</think>\n\n an das Chat-Template.
Ohne die Variable bleibt alles wie bisher.
Ergebnis des ersten Modellvergleichs (2026-09-29)
Qwen3.5-4B gegen das produktive Qwen3-4B, gleiche Konvertierungsparameter, drei Aufnahmen: 21-45 % langsamer, Zusammenfassungen auf ein Drittel bis Viertel gekürzt, und von 61 Todos blieben 2 — das Modell hält die JSON-Vorgabe nicht durch (13 Parse-Fehler mit Wiederholungen). Die Prosa selbst war nicht schlechter. Qwen3-4B bleibt produktiv. Lehre: bei diesem Aufbau entscheidet nicht der Benchmark, sondern ob ein Modell über lange deutsche Transkripte hinweg sauberes JSON liefert.
Am selben Tag zusätzlich geprüft: Ministral-3-3B (Architektur mistral3 nicht unterstützt, dazu
FP8-Gewichte — nie konvertiert), Phi-4-mini (konvertiert, aber rkllm_init failed wegen partieller
Rotary Embeddings) und SmolLM3-3B (läuft, stellenweise schneller als Qwen3, scheitert aber inhaltlich:
13 statt 61 Todos, JSON-Schlüssel als Listeneinträge, Englisch-Drift). Drei Kandidaten, drei Absagen —
Details im Changelog 2026-09-29 (4).