llm-local
Lokales LLM in zid: KI-Chat über llama-server, Selbsteinrichtung von Engine und Modell, Engine- und Modellwahl, gepinnte Engines unter engines/, Messregeln aus llm-bench/, Agent-Werkzeuge (Tool-Callin
SKILL.md.claude/skills/llm-local/SKILL.md
GitHubLokales LLM in zid
zid spricht ausschliesslich mit llama-server und nutzt nur lokale Backends; Cloud-Anbieter (Claude, OpenAI) hat der Projektinhaber ausgeschlossen.
Backend-Wahl beim Start
UI.init, sofern nicht --ai=off. Standard ist der llama.cpp-Vulkan-Build
engines/llama.cpp-vulkan/build/bin/llama-server mit
models/gemma-4-E2B-it-Q4_0.gguf (ggml-org, sha256 8e30dff3…), beides relativ zur
Repo-Wurzel (src/ai/paths.zig, unit-getestet: Wurzel aus <repo>/zig-out/bin der
ausführbaren Datei, sonst das Arbeitsverzeichnis; nichts über $HOME). Fehlt der Build,
kommen Engine und Modell aus dem Datenverzeichnis, das zid sich selbst einrichtet
(src/ai/selfsetup.zig: Knopf im Chat, llama.cpp b11062 und das Modell von ggml-org
nach <AppData>/zid bzw. ~/.local/share/zid). LLAMA_SERVER_PATH und
LLAMA_MODEL_PATH überschreiben beides.
Warum gemma4-E2B Q4_0: gleiche Werkzeugwahl wie Qwen3-4B-Instruct-2507 (10/10 in
bench/agent_eval.py, e2e_ai_tools grün), aber auf beiden Referenzmaschinen schneller:
27.6 gegen 19.3 tok/s auf der P1000 des Laptops (i7-8850H, 4 GB VRAM, Modell passt ganz
hinein), 18.2 gegen 11.9 tok/s auf der CPU des i5-13500T. Erstes Delta im Chat 8 s auf der
P1000 (Qwen3: 15 s). Antwortet auf deutsche Fragen deutsch (bench/probe.py, Chat-Suite).
Messreihen: llm-bench/results/windows-i5-13500T-gemma4-vs-qwen3.md,
llm-bench/results/linux-p1000-gemma4-vs-qwen3.md.
llama-server starten
agent.zig prüft Engine- und Modelldatei, fragt --list-devices ab und wählt per
device_select.zig (unit-getestet) eine diskrete GPU mit mindestens 3 GB, sonst CPU
(-dev none -t N). iGPUs werden übersprungen, sie liefern laut Bench ein Drittel
der CPU-Leistung.
Argumente: --jinja -c 8192 --log-disable --chat-template-kwargs {"enable_thinking":false},
auf GPU zusätzlich -dev VulkanN -ngl 99. Port 8080 (default_llama_port).
Ohne -dev landet das Modell womöglich auf der iGPU, ohne --jinja stimmt das
Qwen3-Chat-Template nicht. enable_thinking=false ist für Qwen3-Instruct wirkungslos,
schaltet aber bei gemma4 das Denken ab; --reasoning-budget 0 tut das nicht (gemma4 denkt
dann im Antwortkanal weiter, 0/10 Werkzeugwahl).
Unter Windows heisst die Engine llama-server.exe (paths.exe_suffix); ohne Endung schlägt
der Existenztest fehl und zid hält die Engine für nicht vorhanden.
Werkzeug-Prompt klein halten. Das command-Werkzeug trägt die 106 Kommandos nur als
Enum; eine Liste mit Label und Kürzel im Text kostete 1000 Token und auf CPU 20 s vor dem
ersten Delta (2392 → 1362 Token, 45 → 22 s auf dem i5-13500T) und liess gemma4
open_folder statt command wählen. Jeder Stream fordert stream_options.include_usage
an und loggt usage: prompt_tokens=… prompt_ms=…; vor Prompt-Änderungen vorher/nachher
ablesen. Messreihe: llm-bench/results/windows-i5-13500T-gemma4-vs-qwen3.md.
Streaming und Zustand
streamChatCompletion (SSE, stream: true) schiebt jedes Delta per
Scheduler.pushResult als ai_chat_delta; der Chat zeigt die wachsende Antwort
(stream_text, Markdown wird bei neuem Text neu gebaut). Die finale Antwort kommt als
ai_chat_reply. Escape setzt cancel_flag, der Worker beendet den Stream
(ai_chat_cancelled), der Teiltext bleibt mit „(abgebrochen)".
Kontextfenster voll: Prompt und Antwort teilen sich -c 8192. Läuft die Antwort ans
Ende, meldet llama-server finish_reason: "length" (agent.finishReason); endStream
liefert dann error.ReplyTruncated. Der Worker zeigt den Teiltext mit „(Antwort
abgeschnitten: Kontextfenster voll.)“, ein abgeschnittener Werkzeugaufruf wird nie
ausgeführt (unvollständiges JSON), ohne Text erscheint eine Fehlermeldung. Kein
max_tokens: das würde lange write_file-Inhalte kappen. E2E
python3 scripts/e2e_ai_truncated.py (12 KB wiedergeben lassen, ~3 min).
AgentStatus (none, initializing, ready, failed) ist der echte
Verbindungszustand: Statuspunkt, Kopfzeile (agentTitle: Modell · Gerät) und
sendMessage hängen daran. Warmup schickt „ping" mit max_tokens = 1, ohne Limit
dauert der Start minutenlang.
Fehlen Engine oder Modell, lädt der Chat sie auf Knopfdruck selbst nach; nie synchron,
das blockiert den Start minutenlang (selfsetup.zig arbeitet im eigenen
Thread, der Fortschritt kommt aus der Grösse der .part-Datei).
Chat-Eingabe ist der CodeEditor
AIChatState.input_editor mit show_gutter = false, show_minimap = false,
compact_menu = true und word_wrap = true. Enter sendet, Shift+Enter fügt eine Zeile
ein (dispatchAction(.InsertNewline); die Keymap kennt Enter nur ohne Modifier). Das UI
reicht Shift/Ctrl/Alt an den Chat-Editor weiter, applyThemeToEditors färbt ihn mit.
Editor-Neuerungen gelten damit automatisch auch im Chat.
Keine Unit-Tests für ai_chat.zig: die Datei importiert CodeEditor und UI, also
kein eigenes Test-Root möglich. Logik dort klein halten.
Codeblock-Antworten und zigdown: endet der Text genau mit ``` ohne Zeilenumbruch,
erzeugt handleLineCode einen leeren Tag und greift auf tag[0] zu (Absturz, Submodul
nicht gepatcht). chat_markdown.finishForParser hängt deshalb immer einen Zeilenumbruch
an und schließt einen offenen Zaun.
Prüfen
python3 scripts/e2e_ai_chat.py # Warmup, erstes Delta, Escape, kurze Antwort
python3 scripts/e2e_ai_chat.py --only-off # nur der --ai=off-Pfad
python3 scripts/e2e_ai_read_limits.py # read_file 4–40 KB: richtig?, Dauer, Prompt-Token (JSON in tmp/)
python3 scripts/e2e_ai_read_limits.py 12000:last 12000:last # einzelne Fälle wiederholen
Antworten streuen (Temperatur 0.7): Vorher/Nachher nie an einem Lauf entscheiden, sondern denselben Fall mehrmals wiederholen und die Prompt-Token vergleichen. Gleiche Token heißen gleiche Eingabe, dann ist ein anderer Ausgang Zufall.
RPC chat_state: Status, Detail, Titel, loading/initializing,
streaming_len, alle Nachrichten.
Engines und Modelle
Layout: engines/ ist ignoriert, kein Submodul. Ein lokaler
Build unter engines/llama.cpp-vulkan/build/ (b10524 = 9ee9fc0, GGML_VULKAN=ON,
derselbe Build für GPU und CPU) hat Vorrang; fehlt er, lädt die Selbsteinrichtung das
Release b11062. Wer lokal baut, klont llama.cpp selbst dorthin. models/ hält alle GGUFs flach
(per *.gguf ignoriert, nie committen).
BitNet ist nicht im Repo. zid nutzt es nicht (braucht eine eigene gepinnte Engine ohne
Vulkan und einen Tokenizer-Override). Für Nachmessungen holt llm-bench/setup/linux.sh
die Engine gepinnt auf 01eb415 (Submodul 3rdparty/llama.cpp auf 1f86f05 = b3962)
nach engines/BitNet, spielt llm-bench/patches/bitnet-mad-const-y_col.patch ein und lädt
models/bitnet-b1.58-2B-4T/.
cmake brennt absolute Pfade ein. Nach einem Umzug finden llama-server und
llama-bench ihre libllama.so nicht. llm-bench/setup/fix-rpath.sh schreibt die
RUNPATHs der Builds per patchelf auf $ORIGIN-relative Pfade um (Kopie patchen und
darüberschieben, weil ein laufender llama-server die Datei gemappt hält: „Text file
busy"). Nach jedem Neubau oder Verschieben erneut ausführen. Die Build-Verzeichnisse
selbst kann cmake nach einem Umzug nicht neu konfigurieren, ein Neubau muss von vorn
beginnen.
llm-bench/ ist das Repo bitnet-colibri-bench als git subtree.
results/*.md sind Messprotokolle und werden nicht angefasst.
Messregeln
Die drei BitNet-Regeln gelten nur, wenn BitNet per llm-bench/setup/linux.sh wieder
aufgebaut ist.
- Die BitNet-Engine ist gepinnt, und das ist keine Vorsicht. Der aktuelle Stand von
microsoft/BitNet zeigt auf einen Fork-Branch, mit dem BitNet-b1.58-2B-4T unbrauchbar
ist (Endlosschleife, Perplexity ×3,7, Werkzeugwahl 0/10) — bei unauffälligem
Durchsatz. Vor jeder Messung:
./engines/BitNet/build/bin/llama-bench -m <i2_s.gguf> -p 8 -n 8 -r 1I2_S - 2 bpw ternaryin der Modellspalte heißt brauchbar,Q1_0heißt nicht messen. - BitNet braucht
--override-kv tokenizer.ggml.pre=str:llama-bpe(dem GGUF fehlt das Feld; ohne Override zerfallen Werkzeugnamen, 8–9/10 wird zu 4/10). Nur für BitNet. - Feste Engine-Zuordnung: BitNet i2_s nur auf der gepinnten BitNet-Engine (auf b10524 ist i2_s kaputt); Qwen3, Phi-4 und Gemma-3 nur auf b10524 (b3962 kennt die Architekturen nicht und hat kein taugliches Vulkan); Llama-3.2-3B läuft auf beiden und ist die Brücke. Zahlen nie ohne diese Verschiebung über die Engine-Grenze vergleichen.
- Jede Zahl braucht drei Kennungen: Engine-Commit, Submodul-Commit, Modell-sha256.
Perplexity nur mit
llm-bench/bench/ppl-corpus.txtbei-c 512. - Entscheidungen des Projektinhabers und die Liste „nicht erneut aufrollen" stehen in
llm-bench/CLAUDE.md. models/hält nur das Standardmodell gemma-4-E2B-it Q4_0 (ggml-org). Ein erneuter Vergleich mit anderen Modellen braucht den Download, Quelle und sha256 stehen inllm-bench/results/(Qwen3-4B-Instruct-2507: unsloth,3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597).
Chat: Kürzel-Werkzeug und Verlauf kopieren
- Die Tastenkürzel stehen nicht im Prompt (das kostete 1000 Token). Der Agent holt sie
über das Werkzeug
list_shortcuts(agent_actions.zig), optional mitfilter. Gemessen: gemma-4-E2B ruft es von selbst auf und beantwortet „Which key opens quick_open?" mitCtrl+P; bei einer umschreibenden Frage („Datei-Schnellsuche") rät es trotz Liste gelegentlich daneben — das Werkzeug liefertcommand,labelundkey, das Übersetzen bleibt Sache des Modells. - Markieren geht über Bubble-Grenzen: jede Nachricht ist eine eigene
MarkdownView, beim Ziehen markierthandleMouseMovedie Startnachricht ab dem Anker (selectFromAnchorToEnd), die dazwischen ganz (selectAllContent) und die zuletzt erreichte bis zur Maus (selectFromStartTo).selectedTextverkettet alle markierten Nachrichten. - Werkzeugergebnisse stehen als
✅ okbzw.❌ Fehlervor dem Namen. Das Zeichen allein genügt nicht: unter Windows greift die Emoji-Rückfall-Kette nicht, dort bliebe ein leeres Kästchen ohne Wort daneben. - Ganzen Verlauf kopieren: Knopf
ai_copy_allin der Kopfzeile oder Ctrl+Shift+C (AIChatState.conversationTextals Markdown,## Du/## AIje Nachricht). Ctrl+C bleibt die markierte Bubble. Der Weg läuft überUI.setClipboard, damitui_stateihn headless prüfen kann.
Selbsteinrichtung der KI (src/ai/setup.zig, download.zig, install.zig, selfsetup.zig)
- Ein installiertes zid hat kein
engines/undmodels/neben sich. Fehlt beides, lädt der Chat auf Knopfdruck llama-server (gepinnt aufb11062, Vulkan-Build) undgemma-4-E2B-it-Q4_0.ggufvon ggml-org in<AppData>/zidbzw.~/.local/share/zid. - Geladen wird in
<ziel>.part, umbenannt erst am Schluss; eine abgebrochene Übertragung setzt per Range-Header auf.File.Writerschreibt positional ab 0: ohnefw.pos = haveüberschreibt die Fortsetzung die schon geladenen Bytes und die Datei wächst nie. - Kein eigener zählender Writer: der müsste Zigs Pufferprotokoll bedienen (erst
w.buffer[0..w.end], danndata, Rückgabe = ausdataverbrauchte Bytes). Ein Writer, der das nicht tut, meldet nie Fortschritt undfetchdreht sich endlos. Der Fortschritt kommt deshalb aus der Grösse der.part-Datei. - Die Release-Archive sind unterschiedlich gebaut: Windows-ZIP flach, Linux-Tar mit
llama-<tag>/davor (install.stripComponents).
Agent-Werkzeuge: der Agent kann, was der Editor kann
- Natives Tool-Calling (OpenAI
tools-Feld,tool_callsin der Antwort,role: toolzurück). Geprüft mit llama-server b10524 + Qwen3-4B +--jinja: funktioniert nicht-streamend und streamend (delta.tool_callsje Index zusammensetzen), das Modell nutzt Tool-Ergebnisse. Kein MCP, kein RPC: Agent und Editor sind derselbe Prozess; MCP wäre nur für externe Agenten interessant. - Definitionen in
src/ai/tools.zig(Modulai_tools, unit-getestet):command(Enum ausshortcuts.Command, Beschreibung mit Label + Kürzel jedes Kommandos → jedes Menü/Kürzel ist automatisch Agent-Werkzeug),open_file,read_file(≤ 200 KB),write_file,replace_text,list_files(≤ 200 Einträge),open_folder,find_in_editor.toolsJsonliefert das Schema,parseEnvelopedie Aufrufe aus dem Worker-Ergebnis{"content","tool_calls"}. - Ausführung auf dem Main-Thread in
src/ui/agent_actions.zig(UI.driveAgentToolsinupdate()):command→executeCommand; Dateien nur innerhalb voncurrent_directory(ai_tools.resolveInProject,..und fremde absolute Pfade →{"error": "outside the project"}). Ergebnisse gehen als JSON intool-Nachrichten. Ruft ein Modell einen Enum-Wert als Werkzeugnamen (toggle_explorerstattcommand{name}, kleine Modelle tun das), führtagent_actionsihn als Kommando aus (ai_tools.commandFromToolName) statt „unknown tool" zu melden. - Regeln in Code, nicht im Prompt (Qwen3-4B hält Prompt-Regeln unzuverlässig ein, siehe
replace_text-Umweg). Der Systemprompt nennt nur Rolle und "Pfade relativ zum Projekt".
ai_tools.choosePaneForFile(unit-getestet) entscheidet füropen_file: Chat nicht im aktiven Pane → dort öffnen; Chat aktiv und zweites Pane vorhanden → dort; sonst vertikal splitten (Chat oben, Datei unten). Danach geht der Fokus zurück zum Chat-Pane, man kann weiterschreiben. Dafür lädt main.zig Tab-Wechsel für alle Leaves (UI.leavesWithPendingSwitch; der Block biegtactive_panepro Leaf kurz um). write_file/replace_textauf eine offene Datei laden den Buffer und alle Editoren darauf neu (UI.reloadFileFromDisk:setText,setLanguageFromPath,last_save = root, Tabs gelten als gespeichert). Zed/VS Code lösen das Sichtbarkeitsproblem mit einem Chat-Dock neben den Editor-Panes; das bleibt eine Option, der Split reicht vorerst.- Bestätigung über den normalen Dialog ("AI agent", Allow/Deny):
write_fileauf bestehende Datei;replace_text, wennold≥ halbe Datei ist (replaceCountsAsRewrite; Qwen umging so die write_file-Regel). Antwort wird inupdate()verarbeitet, nie im Dialog-Callback. Deny →{"error":"the user denied this action"}ans Modell.close_tab/delete_entryfragen über ihre bestehenden Dialoge. Max. 8 Werkzeugrunden pro Frage (max_tool_rounds). - Anzeige: Assistant-Aufrufe als
🔧 name(args), Ergebnisse als✅/⚠️ name → JSON…. - Kein
run_shell(bewusst, erst mit Sandbox). Kein Diff-Review vor dem Schreiben (Zed zeigt Agent-Änderungen erst als Vorschlag); wäre der nächste Schritt nach dem Dock. - Kontextgrenze: llama-server (
-c 8192) kürzt nicht still, sondern antwortet HTTP 400exceed_context_size_error(gemessen: 24 017 Tokens abgelehnt; ein 8014-Token-Prompt brauchte auf der P1000 123 s).agent.zigmacht darauserror.ContextTooLong, der Chat zeigt einen verständlichen Hinweis. Vorbeugend schicktsubmitCompletionnur das jüngste Stück der Historie, das inhistory_budget_chars(12 000 Zeichen) passt:src/ai/history.zig(keepFrom, unit-getestet) behält die laufende Runde (letzte Frage, Aufrufe, Ergebnisse) immer ganz, auch über dem Budget, und beginnt nie mit einem verwaistentool-Ergebnis. Ein Ergebnis ohne Frage und Aufruf verwirft das Chat-Template, das Modell antwortet dann ohne jeden Kontext („What would you like to do?"). Die Anzeige im Chat bleibt vollständig. read_fileliefert den rohen Dateiinhalt, ohne JSON und ohne Kopf. Als JSON-String sah das Modell\nund\"statt echter Zeilen (das gemma4-Template reicht den String unverändert durch); ein Kopf wiepath: …galt ihm als erste Dateizeile (erste Zeile 0/3 statt 3/3). Roh spart 6–7 % Prompt-Token beim Ergebnis. Fehler bleiben{"error":…}, daran erkenntpushToolResultsie (Präfix, nicht Teilstring: Dateien dürfen"error"enthalten).- Zu große Werkzeugergebnisse werden erst nach der Ablehnung gekürzt:
handleErrorkürzt beiContextTooLongdas größtetool-Ergebnis der Runde (ai_tools.shrinkToolResult: Anfang auf zwei Drittel, höchstens 24 000 Bytes, an einer Zeilengrenze; am Ende die Zeile[zid: file truncated: N bytes total, first M shown]; JSON mitcontent-Feld wird im Feld gekürzt) und sendet neu, bis es passt. Was passt, geht ungekürzt raus; eine feste Grenze inread_filehätte Dateien gekappt, die ganz ins Fenster passen. Die Ablehnung kostet kaum Zeit. Messwerte P1000 (python3 scripts/e2e_ai_read_limits.py, erste/letzte Zeile aus N Bytes Zig-Code): Grundlast 1 366 Prompt-Token, 4 KB 2 628 Token und 20 s, 12 KB 4 974 Token und 47 s, 28 KB nach dem Kürzen 75 s (20 KB passte noch ungekürzt: 7 511 Token, 87 s, gemessen mit JSON-Ergebnis). Die „letzte Zeile" streut ab 12 KB bei gleichem Prompt (Temperatur 0.7); Fälle wiederholen. - Gemessene Grenzen (Bench
~/projects/bitnet-colibri-bench, Engine b10524 Commit 9ee9fc0, Qwen3-4B-Instruct-2507-Q4_K_M sha256 3605803b982cb64a…): Ein-Datei-Fix gelingt; Ursachen über einen Import hinweg scheitern (Qwen3 bricht gefahrlos ab, Llama-3.2-3B schrieb destruktiv). Der Agent soll nur in Git-Repos ändern: nur die Bestätigungsdialoge sichern, eine Warnung außerhalb eines Repos ist eine offene Produktentscheidung. Prompt-Verarbeitung auf der P1000 ~96 tok/s. - Temperatur bleibt 0.7, auch mit Tools (negatives Ergebnis):
bench/agent_eval.pyWerkzeugwahl bei 0.7 dreimal 10/10, bei 0.0 ebenfalls 10/10 — kein Unterschied, keine Sonderregel. - CPU ohne
-tb(negatives Ergebnis,-dev none -ngl 0, Prompt 6×-Absatz):-t 8prompt 52,9 tok/s / gen 10,8 tok/s;-t 8 -tb 12prompt 50,6 / gen 11,5 — im Rauschen,agent.zigbleibt bei-t min(Kerne, 8). - Nach jeder Änderung an
agent.zigmusspython3 scripts/e2e_ai_tools.pygrün bleiben; Messzahlen immer mit Engine-Commit und Modell-sha256 notieren, Referenzbitnet-colibri-bench/results/linux-i7-8850H-gpu-und-neue-modelle.md. - RPCs:
focus_chat(Chat-Tab in irgendeinem Pane aktivieren),file_text(path)(Inhalt des offenen Buffers),ui_state.pane_count,ui_state.all_tabs,ui_state.agent_confirm_pending,chat_state.tool_rounds/pending_tools, Nachrichten mittool_calls/tool_call_id. - E2E
python3 scripts/e2e_ai_tools.py: Explorer per Chat aus/ein, Datei anlegen+öffnen ohne zweites Pane → Split, Fokus bleibt im Chat, Buffer im Nachbar-Pane geladen; lesen; kleine replace_text-Änderung ohne Dialog mit Tab-Reload; Überschreiben → Dialog → Deny → unverändert;/etc/hostnameabgelehnt. Messung: erste Runde nach Warmup 13–14 s, danach 3–9 s je Frage.