Lokale Coding-Agenten im Härtetest: Reverse Engineering und Web-Apps mit zwei Mittelklasse-GPUs
Lokale Coding-Agenten im Härtetest
Reverse Engineering und Web-Apps mit zwei Mittelklasse-GPUs
Dass Claude das beste Coding-Modell ist, war nie die Frage. Die Frage war: Was ist auf eigener Hardware möglich, wenn man das Optimum herausholt? Zwei Gaming-Karten (RTX 5060 Ti 16 GB + RTX 4070 Ti 12 GB = 28 GB), ein lokales Modell, ein Open-Source-Agent (OpenCode), und Aufgaben, die man nicht mit einem Einzeiler erledigt.
Im letzten Beitrag haben wir unser Produktionsmodell schneller gemacht und gezeigt, dass es ein kleines Windows-Programm ohne Debug-Symbole aus dem Maschinencode zurückgewinnen kann. Dieser Beitrag geht zwei Schritte weiter:
- re-lab, größeres Ziel: ein echtes Open-Source-Kompressionsprogramm (heatshrink, ~1.500 Zeilen C) statt eines Übungsprogramms mit 360 Zeilen.
- web-lab, neu: Alltagsarbeit. Ein Agent entwickelt eine Web-App mit LLM-Anbindung (FastAPI + React/TypeScript) weiter. Fünf lokale Modelle im direkten Vergleich, zwei Schwierigkeitsstufen, und ein gezielter Test, was passiert, wenn das Kontextfenster voll ist.
- re-lab: Dasselbe Modell, das als eine lange Sitzung nach sechs Stunden bei 60 von 152 Prüffällen hängen blieb, schaffte 152 von 152, sobald wir die Arbeit in vier Modul-Sitzungen mit eigenem Ziel aufteilten und die Rückmeldung von „Datei weicht ab“ auf „erstes abweichendes Byte“ verbesserten.
- web-lab, Stufe 1: Von fünf Modellen schafften nur Qwen3.8 GSQ und Qwen3.6 alle 52 Tests (in 14 bzw. 10 Minuten). GLM-4.7-Flash 45, Qwen3-Coder-30B 41, Devstral Small 2 40. Die drei haben wir danach gelöscht.
- web-lab, Stufe 2 (102 Tests, Erweiterung von bestehendem Code): Qwen3.8 GSQ 102/102 mit 38–92 Schritten, Qwen3.6 brauchte mit Kompaktierung 257–282. Qwen3.6 machte im Lauf mit Aufgaben-Sitzungen beim Erweitern alten Code kaputt.
- Kontextgrenze: Der Server kürzt nie heimlich. Ab 102 % kommt sofort ein Fehler 400. Ohne automatische Kompaktierung ist eine Agentensitzung danach tot: Jeder Neustart scheitert am selben zu langen Verlauf.
- Die wichtigste Lehre aus beiden Laboren: Nicht das Modell war der größte Hebel, sondern der Zuschnitt der Arbeit. Eine Aufgabe pro Sitzung, ein prüfbares Ziel, präzise Rückmeldung.
Inhalt
Die Ausgangslage
| Baustein | Details |
|---|---|
| GPUs | NVIDIA RTX 5060 Ti 16 GB + RTX 4070 Ti 12 GB = 28 GB VRAM |
| Inferenz | llama.cpp (b10964), nativ unter Windows 11, hinter llama-swap (Modellwechsel) |
| Agent | OpenCode 2.0.27, ohne Oberfläche (opencode run --auto --format json) |
| Produktionsmodell | Qwen3.8 27B, Quant GSQ-RCO IQ3_S von ISTA-DASLab, -sm tensor, 64k Kontext, ~47 Token/s |
Warum OpenCode?
Wir haben OpenCode gewählt, weil es lokale OpenAI-kompatible Server sauber anbindet, ohne Oberfläche skriptbar ist, Berechtigungen fein regelt (welche Shell-Befehle, welche Verzeichnisse) und jede Modellantwort als JSON-Ereignis protokolliert. Aus diesen Protokollen stammen alle Kennzahlen unten: Schritte, Werkzeugaufrufe, Tool-Fehler, Kontextgröße je Schritt.
Drei Begriffe, die im Folgenden oft vorkommen
- Sitzung: ein Gesprächsverlauf zwischen Agent und Modell. Alles, was der Agent gelesen, geschrieben und ausprobiert hat, steht darin und wird bei jedem Schritt erneut an das Modell geschickt.
- Kontextfenster: wie viel davon das Modell auf einmal verarbeiten kann. Bei uns 64k Token (
-c 65536am Server). OpenCode bekommt eine etwas kleinere Grenze (60k) mitgeteilt. - Kompaktierung: Wird der Verlauf zu lang, lässt OpenCode das Modell eine Zusammenfassung schreiben und macht mit dieser statt dem vollen Verlauf weiter. Das spart Platz, kostet aber Details.
Teil 1: re-lab – heatshrink aus dem Maschinencode zurückgewinnen
Das Ziel
Nach dem Übungsprogramm minipack (360 Zeilen C++, im letzten Beitrag 120/120) wollten wir
wissen, ob der Ansatz bei echtem Code trägt. Gewählt haben wir heatshrink 0.4.1, eine
kleine LZSS-Kompressionsbibliothek für eingebettete Systeme (ISC-Lizenz, ~1.500 Zeilen C mit
Kommandozeilenprogramm).
Problem: heatshrink ist öffentlich und steckt sehr wahrscheinlich im Trainingsmaterial
der Modelle. Ein Modell könnte den Code aus dem Gedächtnis schreiben, statt ihn aus dem Binary
abzuleiten. Deshalb erzeugt ein Skript (make_source.py) eine leicht veränderte
Fassung:
| Änderung | Wirkung |
|---|---|
| Markierungsbit vertauscht: Literal = 0, Rückverweis = 1 (Original umgekehrt) | Wer das Original auswendig nachbaut, erzeugt Dateien, die kein Byte übereinstimmen |
| Rückverweis-Index mit Versatz 2 statt 1 gespeichert | dito, an anderer Stelle im Bitstrom |
Eigenes Kommandozeilenprogramm hshrink | Container mit Kopf (Kennung HSK1, Parameter, Originallänge) und Adler-32-Prüfsumme, Info-Befehl, deutsche Meldungen, feste Exit-Codes, Standard -w 10 statt 11 |
Der Bibliothekskern bleibt sonst unverändert: zwei Zustandsautomaten (Packer und Entpacker), dynamisch
angelegtes Fenster, Suchindex. Gebaut wird mit dem Microsoft-Compiler als Stufe C:
optimiert (/O2), ohne Debug-Symbole. In Ghidra heißen alle Funktionen nur
FUN_14000…, ein Teil davon ist vom Compiler ineinander eingebettet.
Die 152 Prüffälle
Wie bei minipack führt ein Prüfprogramm Original und Nachbau mit identischen Argumenten aus und vergleicht Exit-Code, komplette Bildschirmausgabe und, wo eine Datei entsteht, deren SHA-256. Zwölf Testdateien decken die Randfälle eines Kompressors ab:
| Datei | Inhalt | Prüft |
|---|---|---|
empty.bin, one.bin, two.bin | 0, 1 und 2 Byte | Minimalfälle, kein Rückverweis möglich |
text.txt | 4.600 Byte deutscher/lateinischer Text, Tabs, Windows-Zeilenenden | typischer Text |
words.txt | " eins zwei\n\ndrei\t vier \n" | kurze Eingabe mit Wiederholungen |
random.bin | 5.000 Byte Pseudozufall | nicht komprimierbar, Ausgabe wird größer |
runs.bin | 300ׄa“, 600×0x00, 256×0xFF … | lange Wiederholungen, maximale Rückverweislänge |
ramp.bin | 0,1,2,…,255,0,1,… (2.048 Byte) | Muster mit Periode 256 |
zeros.bin | 10.000 Nullbytes | extremer Fall für den Suchindex |
abc.txt | „abc“ × 1.000 | kurze Periode |
data.json | 300 JSON-Zeilen | strukturierter Text |
mixed.bin | Zufall + Text + Zufall | Wechsel zwischen komprimierbar und nicht |
Daraus entstehen sieben Gruppen:
| Gruppe | Fälle | Was genau |
|---|---|---|
cli | 16 | Aufruf ohne Argumente, Hilfe, unbekannte Option, zu wenige/viele Dateien, gleiche Ein- und Ausgabedatei, fehlende Datei, sechs ungültige Parameter (-w 3, -w 15, -l 2, -w 10 -l 10, -w abc, -w 6 -l 7) |
enc | 60 | jede der 12 Dateien mit 5 Parametersätzen (Standard, -w 8 -l 4, -w 12 -l 5, -w 4 -l 3, -w 14 -l 8) packen, Ausgabedatei muss bitgenau stimmen |
roundtrip | 24 | mit dem eigenen Packer packen, mit dem eigenen Entpacker entpacken |
dec | 12 | Dateien entpacken, die das Original gepackt hat |
info | 12 | -i: Kopf eines Containers anzeigen |
verbose | 13 | -v: Statistikausgabe beim Packen und Entpacken |
corrupt | 15 | sieben beschädigte Container (falsche Kennung, ungültige Parameter, gekipptes Bit im Inhalt, gekippte Prüfsumme, falsche Länge, abgeschnitten, nur 10 Byte), je mit Entpacken und Info, plus Info auf eine ungepackte Datei |
| Summe | 152 |
Die dec-Gruppe ist wichtig: Weil die Testcontainer vom Original stammen, lässt sich der
Entpacker unabhängig vom eigenen Packer prüfen.
Zusätzlich bewertet ein Strukturprüfer den Nachbau mit 15 Kriterien, die der Agent nicht sieht, etwa:
mindestens drei eigene struct-Typen (Puffer, Packer- und Entpacker-Zustand),
Zustandsautomaten als enum und switch, Adler-32 als eigene Funktion, Fenster
per malloc, Kompressionsfluss in getrennten Funktionen (sink/poll/finish wie im Original),
dazu der Endstand im Ghidra-Projekt (mindestens 15 Funktionen benannt, 10 kommentiert, 5 Prototypen
gesetzt).
Abschottung wie gehabt: frisches Arbeitsverzeichnis außerhalb des Repositorys, Shell nur für
check.ps1, kein Zugriff auf fremde Verzeichnisse oder das Web, frisches Ghidra-Projekt je
Lauf, und die Endbewertung baut der Testrahmen selbst.
Lauf 1: eine lange Sitzung – 60 von 152 nach sechs Stunden
Der erste Versuch übernahm den Aufbau, der bei minipack funktioniert hatte: eine Sitzung, Anleitung „Code zuerst, dann iterieren“, bis zu 360 Minuten, bei vorzeitigem Ende automatische Fortsetzung.
| Gruppe | Ergebnis |
|---|---|
info | 12/12 |
cli | 15/16 |
enc | 21/60 |
dec | 0/12 |
roundtrip | 0/24 |
verbose + corrupt | 12/28 |
| gesamt | 60/152, Struktur 6/15, Ghidra: 0 Funktionen benannt |
Warum es so ausfiel
Die Protokolle zeigen ein klares Muster:
- Das Grundgerüst ging schnell. Hilfe, Fehlermeldungen, Container-Kopf und
-iwaren nach gut einer Stunde fertig. - Dann biss sich der Agent am Packer fest. Der LZSS-Packer ist der schwierigste Teil: Suchindex, Fenster, Bitausgabe mit variabler Breite. Fast vier Stunden blieb
encbei wenigen Treffern. - Die Rückmeldung half nicht. Bei einer falschen Ausgabedatei meldete
check.ps1nur „Ausgabedatei weicht ab“ (verschiedene SHA-256). Ob das erste oder das tausendste Bit falsch war, ob es am Markierungsbit oder an der Länge lag: keine Information. Der Agent riefcheck.ps1zeitweise zwei Stunden lang gar nicht auf und analysierte stattdessen weiter. - Der Entpacker wurde nie angegangen, obwohl er einfacher ist und sich gegen die Originaldateien hätte prüfen lassen. Er hätte sogar beim Packer geholfen, weil er den Aufbau des Datenstroms zeigt.
Lauf 2: vier Modul-Sitzungen – 152 von 152
Aus diesen Beobachtungen entstanden drei Änderungen:
1. Modul-Sitzungen. Statt einer langen Sitzung arbeitet der Agent vier Module nacheinander ab, jedes in einer frischen Sitzung mit eigenem Ziel und Zeitbudget:
| Modul | Ziel (Gruppen) | Budget | Hinweis in der Anleitung |
|---|---|---|---|
| M1 Grundgerüst | cli, info | 60 min | Packen/Entpacken bleiben Stubs |
| M2 Entpacker | dec, corrupt | 120 min | Testcontainer stammen vom Original: Entpacker unabhängig prüfbar |
| M3 Packer | enc, roundtrip | 150 min | Der Entpacker zeigt, wie der Datenstrom aufgebaut ist |
| M4 Rest | alle | 30 min | -v-Ausgaben, fehlende Fälle |
Der Testrahmen prüft nach jedem check.ps1 des Agenten selbst, ob die Gruppen des Moduls
bestehen, und startet dann sofort das nächste Modul. Das Gedächtnis zwischen den Sitzungen ist allein
der Code und eine Notizdatei (plan.md), die der Agent selbst führt.
2. Präzise Rückmeldung. Weicht eine Ausgabedatei ab, zeigt check.ps1 jetzt
die erste abweichende Byte-Position mit einem Hex-Ausschnitt aus Erwartung und
Ergebnis. Damit kann der Agent gezielt im Binary nachsehen, ob Markierungsbit, Index oder Länge falsch
kodiert sind.
3. Sicherung des besten Stands. Jeder Gesamtlauf mit mindestens so vielen bestandenen Fällen wie bisher sichert den Nachbau. Macht der Agent später etwas kaputt, wird der beste Stand bewertet.
Ergebnis
| Lauf 1 (eine Sitzung) | Lauf 2 (Module) | |
|---|---|---|
| Endprüfung | 60/152 nach 360 min | 152/152 nach 313 min |
| Struktur | 6/15 | 13/15 |
| Ghidra benannt / kommentiert / Prototypen | 0 / – / – | 16 / 16 / 1 |
Verlauf der Module: M1 und M2 endeten per Zeitbudget, M3 erreichte sein Ziel nach 123 Minuten, M4 brauchte etwa 10 Minuten. Danach räumte eine Abschluss-Sitzung (41 min) den Code auf und dokumentierte das Ghidra-Projekt.
dec 12/12, corrupt 15/15). Der Testrahmen erkannte das nicht,
weil er nur die Gesamtprüfung las, und die stürzte ab: Der damals noch unfertige Packer hing in einer
Endlosschleife, und die Prüfung wartete 30 Sekunden pro Fall, länger als die Shell des Agenten erlaubt.
Beides ist inzwischen behoben (eigene Gruppenprüfung, 1 Sekunde Zeitlimit je Fall).
Was in der Strukturbewertung fehlt: Der Kompressionsfluss ist nicht wie im Original auf sink/poll/finish aufgeteilt, und in Ghidra wurde nur ein Prototyp gesetzt.
Warum die Module wirkten
- Ein Ziel pro Sitzung. Der Agent wusste jederzeit, was „fertig“ bedeutet, und der Testrahmen ebenso. Die lange Sitzung hatte nur das ferne Ziel „152“.
- Die richtige Reihenfolge. Der Entpacker zuerst: einfacher, unabhängig prüfbar, und danach liegt das Format des Datenstroms als funktionierender Code vor. Beim Packer musste der Agent dann nur noch die Umkehrung finden.
- Frischer Kontext. Jedes Modul begann mit wenigen tausend Token statt mit einem Verlauf aus Stunden. Ganz ohne Kontextprobleme lief es trotzdem nicht: Innerhalb der langen Module (bis zu zwei Stunden) meldete OpenCode sechsmal eine fehlgeschlagene Kompaktierung („Compaction produced no summary“ bzw. „Compaction summary reached the output token limit“), und die Sitzung endete. Der Testrahmen setzte sie fort, mit Code und
plan.mdals Gedächtnis ging nichts verloren. - Rückmeldung, mit der man arbeiten kann. Eine Meldung der Art „Byte 37: erwartet
0x9c, erhalten0x1c“ führt in wenigen Schritten zu einem vertauschten Bit. „Hash weicht ab“ führt nirgendwohin.
Und das Hauptziel?
Als eigentliches Hauptziel liegt eine veränderte Fassung von TinyXML-2 bereit (~5.400 Zeilen C++, Klassen und Namespace umbenannt, 179 Prüffälle). Wir haben diesen Lauf bewusst zurückgestellt. Unsere Einschätzung: Für C und kleines C++ ist die Kernaussage belegt. Ob der Modul-Ansatz auf ein großes C++-Projekt mit Klassenhierarchien skaliert, ist offen und wäre ein eigener, sehr langer Test.
Teil 2: web-lab – Web-Apps mit LLM-Anbindung
Reverse Engineering ist ein Extremfall. Im Alltag schreiben wir Web-Anwendungen, die selbst ein Sprachmodell aufrufen. Dafür haben wir ein zweites Labor gebaut: web-lab.
Die App „Assistent“
Ein Chat-Assistent mit Backend in FastAPI (Python 3.12) und Frontend in React 18 + TypeScript. Das Backend spricht ein Sprachmodell über eine OpenAI-kompatible Schnittstelle an (bei uns LiteLLM). Der Agent bekommt ein leeres Gerüst und Aufgaben als exakte Verträge: Pfade, Felder, Statuscodes, Fehlertexte.
Wie geprüft wird
- Backend: Die Tests laufen mit pytest gegen ein Mock-LLM: ein kleiner HTTP-Server, der wie die OpenAI-Schnittstelle antwortet. Jeder Test programmiert vorher, was das „LLM“ zurückgibt (Text, Fehler 500, Streaming in Stücken, absichtliches Hängen für Timeouts, Tool-Calls, Verbrauchsangaben), und prüft danach, was die App an das LLM geschickt hat: Modell, Header, Nachrichtenliste.
- Frontend: Vitest mit Testing Library in einer simulierten Browserumgebung (jsdom).
fetchist gemockt. Für Streaming liefert der Test die Antwort Stück für Stück nach und prüft dazwischen, was auf dem Bildschirm steht. - Typprüfung:
tsc --noEmitzählt TypeScript-Fehler im Code des Agenten. - Verdeckt: Die Tests liegen außerhalb des Arbeitsverzeichnisses. Der Agent darf nur
check.ps1aufrufen, das bei jedem fehlgeschlagenen Test die Assertion mit erwartetem und erhaltenem Wert zeigt. - Gegenprobe: Eine Musterlösung besteht alle Tests, das leere Gerüst keinen. Absichtlich falsche Varianten fallen durch (ein Backend, das den Stream erst sammelt und dann weitergibt, scheitert an Aufgabe 2; ein falscher Fehlertext an Aufgabe 5 und 6).
Stufe 1: sechs Aufgaben, 52 Tests
| Aufgabe | Inhalt | Tests | Was genau geprüft wird |
|---|---|---|---|
| 1 Chat | POST /api/chat ruft das LLM mit httpx | 9 | Antwort und Verbrauch, Anfrageformat (Modell, Schlüssel im Authorization-Header, stream: false), Systemnachricht zuerst, 422 bei leerer Nachricht ohne LLM-Aufruf, fehlender Verbrauch = 0, 502 llm_unavailable bei LLM-Fehler, nicht erreichbarem LLM und Timeout, Einstellungen bei jeder Anfrage neu gelesen |
| 2 Stream | POST /api/chat/stream (Server-Sent Events) | 8 | Stücke als data: {"delta": …}, Abschluss data: [DONE], Leerzeilen-Format, Rauschen im LLM-Strom (Kommentare, Rollen-Chunks, leere Deltas) ignorieren, Fehler als Event, und: das erste Stück muss ankommen, bevor das LLM fertig ist (geprüft mit echtem Server und 0,6 s Pause zwischen den Stücken) |
| 3 Unterhaltungen | SQLite, CRUD, Verlauf an das LLM | 10 | Anlegen (201), Liste neueste zuerst, 404 not_found, Löschen mit Nachrichten, Verlauf in der richtigen Reihenfolge an das LLM, Titel aus den ersten 40 Zeichen, bei LLM-Fehler nichts speichern |
| 4 Extract | strukturierte Ausgabe | 8 | response_format mit JSON-Schema, temperature: 0, Tags normalisieren, Markdown-Zaun um JSON entfernen, bei ungültiger Antwort genau eine Wiederholung mit Fehlerhinweis, danach 422 |
| 5 Chat-UI | React-Komponente | 9 | Textfeld, Senden-Knopf, Enter sendet, Shift+Enter macht Zeilenumbruch, Ladehinweis „Antwort wird erzeugt…“, Senden währenddessen gesperrt, Fehlermeldung als role="alert" |
| 6 Stream-UI | Streaming im Browser + Unterhaltungsliste | 8 | SSE selbst parsen (Events über Stückgrenzen, mehrere Events pro Stück), Text erscheint sofort in derselben Nachricht, Liste mit Auswahl und „Neue Unterhaltung“ |
Der Ablauf eines Laufs
Ein Skript (run-web.ps1) schaltet llama-swap in den Coding-Modus, lädt das Modell und
startet OpenCode. Wie bei heatshrink gibt es Aufgaben-Sitzungen: Jede Aufgabe bekommt
eine frische Sitzung und 30 Minuten. Der Testrahmen prüft nach jedem check.ps1 des Agenten
selbst und startet bei Erfolg sofort die nächste Aufgabe. Endet eine Sitzung vorzeitig, wird sie bis zu
dreimal fortgesetzt. Am Ende folgen die unabhängige Endprüfung, die Auswertung der Protokolle und das
Zurückschalten auf Produktion.
Die Kandidaten
Alle mussten vollständig in 28 GB passen, mit 64k Kontext (Devstral 48k):
| Modell | Typ | Datei |
|---|---|---|
| Qwen3.8 27B GSQ (Referenz) | dicht, 27B | ISTA-DASLab GSQ-RCO IQ3_S, -sm tensor |
| Qwen3.6 35B-A3B | MoE, 3B aktiv | bartowski Q4_K_M |
| Qwen3-Coder 30B-A3B | MoE, Coding-Spezialist | Q4_K_M |
| Devstral Small 2 24B (2512) | dicht, Mistrals Agenten-Coder | Unsloth UD-Q5_K_XL |
| GLM-4.7-Flash 30B-A3B | MoE | Unsloth UD-Q5_K_XL, Denkbudget 2048 |
Ergebnis Stufe 1
| Platz | Modell | Tests | Minuten | Schritte | Tool-Fehler | max. Kontext | Kompaktierungen |
|---|---|---|---|---|---|---|---|
| 1 | Qwen3.6 | 52/52 | 10 | 111 | 15 | 37,6k | 0 |
| 2 | Qwen3.8 GSQ | 52/52 | 14 | 61 | 9 | 21,3k | 0 |
| 3 | GLM-4.7-Flash | 45/52 | 90 | 643 | 83 | 44,0k | 8 |
| 4 | Qwen3-Coder-30B | 41/52 | 81 | 450 | 60 | 44,1k | 6 |
| 5 | Devstral Small 2 | 40/52 | 106 | 1.566 | 31 | 29,0k | 3 |
Warum es so ausfiel
Aufgabe 2 (Streaming) trennte am deutlichsten. Devstral und Qwen3-Coder bestanden davon je nur einen von acht Tests, und aus unterschiedlichen Gründen:
- Devstrals Endpunkt lieferte eine leere Antwort, kein einziges Event. Die Logik, die den Strom des LLM Zeile für Zeile liest und weitergibt, war fehlerhaft, und das Modell fand den Fehler in 30 Minuten nicht.
- Qwen3-Coder erzeugte einen Endpunkt, der mit Internal Server Error abstürzte. Auch der Test auf den Fehlerfall scheiterte deshalb.
Streaming ist die Stelle, an der man verstehen muss, wann Code läuft: Ein Generator, der erst sammelt und dann ausgibt, besteht vielleicht einen oberflächlichen Test, aber nicht den mit dem echten Server. Genau dort zeigt sich, ob ein Modell asynchronen Datenfluss wirklich versteht.
Devstral drehte sich im Kreis. 1.566 Schritte für 40 Tests: Das Modell wiederholte über lange Strecken ähnliche Aufrufe und Änderungen, ohne voranzukommen. Dazu kamen vier API-Fehler. Bei einer Aufgabe gab es nach 29 Minuten auf.
GLM-4.7-Flash machte beim Erweitern Altes kaputt. Es bestand Aufgabe 2 vollständig, aber
in der Endprüfung fielen Tests früherer Aufgaben: Die Chat-Antwort enthielt plötzlich ein Feld
"conversation_id": null (der Vertrag verlangt es nur mit Unterhaltung), und der Verlauf ging
in der falschen Reihenfolge an das LLM. Dazu Frontend-Fehler bei Ladehinweis und Fehlermeldung.
Die beiden Qwen-Modelle lösten jede Aufgabe in 1–4 Minuten. Qwen3.8 GSQ brauchte dabei nur 61 Schritte und 21k Kontext, gut die Hälfte von Qwen3.6. Es liest gezielter und schreibt seltener falsch. Qwen3.6 ist als MoE-Modell (nur 3B aktive Parameter) pro Token schneller und war deshalb trotz mehr Schritten vier Minuten früher fertig.
Deckeneffekt: Beide Qwen-Modelle bestanden alles. Welches besser ist, ließ sich mit Stufe 1 nicht entscheiden. Deshalb Stufe 2.
Stufe 2: Bestehenden Code erweitern, 102 Tests
Stufe 2 beginnt nicht mit einem leeren Gerüst, sondern mit der fertigen Musterlösung von Stufe 1. Die sechs neuen Aufgaben greifen quer in diesen Code ein. Alle 52 alten Tests laufen weiter mit: Wer beim Erweitern etwas kaputt macht, verliert Punkte. Das ist näher am Alltag als jede Aufgabe auf der grünen Wiese.
| Aufgabe | Inhalt | Tests | Was genau geprüft wird |
|---|---|---|---|
| 7 Agent | POST /api/agent: Tool-Calling-Schleife | 9 | exakte Werkzeugdefinitionen (calculate, list_conversations), Assistentennachricht mit tool_calls unverändert zurück in den Verlauf, Werkzeugergebnisse als tool-Nachrichten in Reihenfolge, Rechner ohne eval (lehnt __import__('os'), 2**10, abs(-1), 1/0 ab, ganze Zahlen als int), unbekanntes Werkzeug und kaputte Argumente beenden die Schleife nicht, höchstens 5 LLM-Aufrufe, sonst 422 |
| 8 Kontextbudget | Verlauf vor dem LLM-Aufruf kürzen | 8 | Token-Schätzung 4 + ceil(Zeichen/4) je Nachricht, älteste Nachrichten zuerst weglassen, Verlauf darf nicht mit einer Assistentenantwort beginnen, Systemnachricht zählt mit, genaue Grenze (≤ statt <), 413 bei zu langer Einzelnachricht ohne LLM-Aufruf, Datenbank wird nie gekürzt, dropped_messages und tokens in den Antworten |
| 9 Stream mit Verlauf | Aufgaben 3 und 8 auch für den Stream | 8 | 404/413 als JSON vor dem Stream, erstes Event mit conversation_id, Speichern erst nach erfolgreichem Stream, bei Fehler nichts, stream_options.include_usage und Verbrauchs-Event, zwei Stream-Runden bauen den Verlauf auf |
| 10 Anmeldung | Token im Authorization-Header, eigene Unterhaltungen | 9 | 401 an allen Endpunkten außer Health, und zwar vor der Eingabeprüfung (also nicht 422); falsch aufgebaute Header; fremde Unterhaltungen = 404 bei Lesen, Löschen, Chat und Stream; user-Feld in jeder LLM-Anfrage; Agenten-Werkzeug zeigt nur eigene; ohne Konfiguration alles wie vorher |
| 11 Verbrauch | Abrechnung und Tageslimit | 8 | jeder erfolgreiche LLM-Aufruf zählt (Extract: beide Versuche, Agent: jede Runde, Stream: aus dem Verbrauchs-Chunk), Fehler zählen nicht, je Benutzer getrennt, GET /api/usage, Limit → 429 an allen vier LLM-Endpunkten, Reihenfolge 401 → 422 → 429 |
| 12 UI-Extras | Frontend | 8 | Token-Header in Chat und Liste, „Stopp“-Knopf nur während eines Streams (AbortController, Teilantwort bleibt, kein Fehler, danach weiter senden), Markdown-Code-Blöcke als <pre><code data-lang> mit „Kopieren“-Knopf (Zwischenablage, Beschriftung wechselt zu „Kopiert“), unvollständige Blöcke und HTML bleiben Text, gestreamter Block erscheint, sobald er vollständig ist |
Die Musterlösung besteht 102/102, der Ausgangsstand 56/102 (die 52 alten Tests plus vier, die trivial bestehen).
Jedes Modell lief in drei Varianten:
- Aufgaben-Sitzungen: frische Sitzung je Aufgabe (45 min Budget).
- Eine Sitzung: alle sechs Aufgaben in einer Sitzung, Kompaktierung an. Der Kontext wächst.
- Eine Sitzung ohne Kompaktierung: derselbe Lauf, aber OpenCode darf nicht zusammenfassen.
Ergebnis Stufe 2
| Modell | Variante | Tests | Minuten | Schritte | Tool-Fehler | max. Kontext | Kompaktierungen |
|---|---|---|---|---|---|---|---|
| Qwen3.8 GSQ | Aufgaben-Sitzungen | 102/102 | 33 | 92 | 9 | 42k | 1 |
| Qwen3.8 GSQ | eine Sitzung | 56/102 | 27 | 9 | 0 | 17k | 0 |
| Qwen3.8 GSQ | eine Sitzung, Wiederholung | 102/102 | 85 | 199 | 19 | 44k | 7 |
| Qwen3.8 GSQ | eine Sitzung, ohne Kompaktierung | 102/102 | 20 | 38 | 1 | 59,4k | – |
| Qwen3.6 | Aufgaben-Sitzungen | 97/102 | 40 | 257 | 51 | 44k | 4 |
| Qwen3.6 | eine Sitzung | 102/102 | 50 | 282 | 26 | 44k | 8 |
| Qwen3.6 | eine Sitzung, ohne Kompaktierung | 83/102 | 11 | 49 | 5 | 64,4k | – |
Qwen3.8 GSQ: gezielt und sparsam
In allen drei Varianten löste GSQ alle 102 Tests (eine davon erst in der Wiederholung, siehe unten), mit Aufgaben-Sitzungen oder ohne Kompaktierung in 20 bis 33 Minuten. Mit 38 bis 92 Schritten braucht es dort ein Drittel bis ein Siebtel der 257–282 Schritte, die Qwen3.6 in den Läufen mit Kompaktierung brauchte, und macht kaum Tool-Fehler. Die Protokolle legen nahe, warum: GSQ liest zu Beginn die Verträge und den bestehenden Code und ändert dann in wenigen, größeren Schritten. Qwen3.6 arbeitet in vielen kleinen Versuchen und lässt sich vom Prüfskript führen. Das ist bei kleinen Aufgaben schnell, bei Erweiterungen von bestehendem Code aber fehleranfällig.
Qwen3.6: macht beim Erweitern alten Code kaputt
Im Lauf mit Aufgaben-Sitzungen bestand Qwen3.6 jede neue Aufgabe für sich, aber die Endprüfung zeigte
vier rote Tests aus Stufe 1: Das Modell hatte beim Umbau der LLM-Aufrufe das Feld
model aus den Anfragen entfernt. Die Anleitung sagt ausdrücklich „bereits bestandene Tests
dürfen nie schlechter werden“, und der Gesamtcheck hätte es gezeigt. Qwen3.6 prüfte aber meist nur die
aktuelle Aufgabe.
Der Ausreißer: GSQ in einer Sitzung, 56/102
Dieser Lauf brachte nicht eine Zeile Code. Das Protokoll zeigt: GSQ las alle Verträge und
alle Dateien ein, rief einmal check.ps1 auf (17k Kontext) und begann dann eine Antwort, die
nie endete. llama-swap protokollierte vier Anfragen von je sechs Minuten
und 4,8 MB Datenstrom, also bis zum Ausgabelimit von 16.000 Token. Danach brach OpenCode die Sitzung ab.
Jede der drei automatischen Fortsetzungen begann mit genau demselben Verlauf und endete genauso.
Das ist eine entartete Ausgabe: Das Modell gerät in eine Wiederholungsschleife (vermutlich beim Schreiben einer sehr großen Datei in einem Werkzeugaufruf) und findet nicht mehr heraus. Mit dem Wissen hat das nichts zu tun: Derselbe Ausgangsstand lief in den beiden anderen GSQ-Varianten glatt durch.
Die Wiederholung mit identischem Aufbau (am selben Abend) bestätigt das: 102/102 in 85 Minuten, ohne eine einzige Fortsetzung. Kein Schritt kam auch nur in die Nähe des Ausgabelimits, der größte erzeugte 3.421 Token (Limit 16.000). Der Kontext stieg bis 44k, OpenCode kompaktierte siebenmal, und keine Zusammenfassung scheiterte. Die entartete Ausgabe war also ein Zufallsausfall, kein Muster: Weil das Modell mit Zufall auswählt (Sampling), kann derselbe Ausgangsstand einmal in eine Schleife kippen und einmal nicht.
Auffällig ist dafür die Dauer: In einer Sitzung braucht GSQ mit 85 Minuten deutlich länger als Qwen3.6 (50 Minuten) und mehr als doppelt so lange wie mit Aufgaben-Sitzungen (33 Minuten). Die sieben Kompaktierungen kosten jedes Mal Zeit, und was dabei verloren geht, muss das Modell vermutlich erneut einlesen (83 Lesezugriffe in diesem Lauf).
Zwei Lehren daraus:
- Fortsetzen hilft nicht, wenn der Zustand derselbe ist. Eine Sitzung, die an einer bestimmten Stelle entartet, entartet bei gleichem Verlauf wieder. Ein frischer Start löste das Problem sofort (die Wiederholung). Das spricht erneut für Aufgaben-Sitzungen, die ohnehin oft frisch beginnen.
- Ausgabelimit und Zeitlimit pro Anfrage gehören in den Testrahmen. Sechs Minuten ohne ein einziges Werkzeugereignis sind ein klares Signal.
Teil 3: Was passiert, wenn das Kontextfenster voll ist?
Das war eine ausdrückliche Frage: Was passiert, wenn die Kontextgrenze erreicht oder überschritten wird? Wir haben sie auf zwei Ebenen getestet.
Ebene 1: der Server (llama-server)
Ein Prüfskript (ctx_probe.py) schickt Prompts aus nummerierten Zeilen der Form „Zeile
00042: Der Wert dieser Zeile ist 37840.“ in Größen von 50 % bis 150 % des Kontextfensters. Am Ende
steht die Frage nach dem Wert in Zeile 00000, ganz am Anfang des Textes (der richtige
Wert ist 4242). So sieht man nicht nur, ob der Server antwortet, sondern auch, ob das Modell den Anfang
noch „sieht“. Gemessen mit 64k und mit einer Variante, die nur 24k Kontext hat:
| Prompt-Größe | Status | Anfang gesehen? |
|---|---|---|
| 50 % | 200 | ja (4242) |
| 90 % | 200 | ja |
| 98 % | 200 | ja |
| 102 % | 400 exceed_context_size_error, nach 0,2 s | – |
| 120 % | 400 | – |
| 150 % | 400 | – |
| 90 % Prompt, Antwort bis 30 % | 200, finish_reason: length | Antwort genau am Kontextende abgeschnitten |
Das Ergebnis war bei 64k und 24k identisch:
- llama.cpp kürzt nie heimlich. Es gibt kein „Kontext verschieben“ und kein stilles Wegschneiden des Anfangs. Wer zu viel schickt, bekommt sofort einen klaren Fehler mit der genauen Token-Zahl: „request (66326 tokens) exceeds the available context size (65536 tokens)“.
- Bis knapp unter die Grenze bleibt der Anfang verfügbar. Bei 98 % Füllung fand das Modell den Wert aus der ersten Zeile.
- Eine Antwort, die über die Grenze laufen würde, wird ohne Fehlermeldung beendet. Der Server schreibt, bis das Fenster voll ist (bei 64k waren es genau 65.536 minus Prompt = 6.991 Token), und meldet dann nur
finish_reason: length. Eine Anwendung muss diesen Wert prüfen, sonst hält sie eine abgeschnittene Antwort für vollständig.
chat_template_kwargs: {"enable_thinking": false}).
Ebene 2: der Agent (OpenCode)
In Stufe 1 haben wir vier Szenarien mit GSQ in einer langen Sitzung gefahren:
| Szenario | Server -c | OpenCode-Grenze | Kompaktierung | Tests | Ergebnis |
|---|---|---|---|---|---|
| normal | 64k | 60k | an | 52/52 | Grenze nie erreicht (max. 42k) |
| knapp | 64k | 24k | an | 52/52 | 2 Kompaktierungen, kein Schaden |
| Überlauf | 24k | 60k | an | 52/52 | Server lehnt mit 400 ab, Sitzung bricht ab |
| ohne Kompaktierung | 64k | 60k | aus | 52/52 | Grenze nie erreicht (max. 49k) |
Stufe 1 war für einen echten Überlauf zu klein. Stufe 2 dagegen hat die Grenze gesprengt (Tabelle oben).
Was im Überlauf passiert
Fall 1: OpenCode glaubt, mehr Platz zu haben, als der Server hat (Szenario „Überlauf“). OpenCode kompaktiert erst kurz vor seiner Grenze (60k), der Server ist aber schon bei 24k voll. Die nächste Anfrage mit 24.625 Token wird abgelehnt, die Sitzung endet mit einem API-Fehler. Erst beim Fortsetzen schrumpfte OpenCode den Verlauf: Die nächste Sitzung begann mit etwa 6k statt 24k Token. Ohne die automatische Fortsetzung unseres Testrahmens wäre der Lauf an dieser Stelle einfach stehen geblieben.
Fall 2: keine Kompaktierung, echter Überlauf (Qwen3.6, Stufe 2). Der Verlauf wuchs auf 64,4k, die nächste Anfrage hatte 65.585 Token, der Server lehnte ab. Und dann: nichts mehr. Jede Fortsetzung schickt denselben zu langen Verlauf und scheitert sofort, mit null Schritten. Die Sitzung ist tot. Ergebnis 83/102: Was bis dahin geschafft war, blieb erhalten, der Rest fehlte.
Fall 3: keine Kompaktierung, knapp darunter (GSQ, Stufe 2). 59,4k von 64k, und 102/102 in 20 Minuten, der schnellste Lauf überhaupt. Ohne Kompaktierung arbeitet das Modell mit vollständigem Wissen über alles bisher Gelesene. Das ist schnell, solange es reicht, und ein Totalausfall, wenn nicht.
Fall 4: Kompaktierung an, viel Kontext (Qwen3.6, Stufe 2, eine Sitzung). Acht Kompaktierungen, und trotzdem 102/102. Es kostete Zeit (50 statt 20–40 Minuten), aber das Modell verlor den Faden nicht.
Die Regeln, die daraus folgen
limit.contextin OpenCode immer unter dem-cdes Servers. Bei uns 60k gegen 64k. Liegt die Grenze darüber, kompaktiert OpenCode zu spät, und der Server lehnt ab.- Kompaktierung eingeschaltet lassen. Ohne sie ist ein Überlauf kein Leistungsverlust, sondern das Ende der Sitzung.
- Eine zu knappe Grenze schadet kaum. 24k statt 60k bedeutete nur zwei Kompaktierungen mehr.
- Am besten gar nicht an die Grenze kommen: Aufgaben-Sitzungen mit Notizdatei halten den Kontext klein (in Stufe 2 bei GSQ maximal 42k) und vermeiden das Problem von vornherein.
- Kompaktierung kann selbst scheitern. Im re-lab-Modullauf endeten sechs Sitzungen, weil die Zusammenfassung leer blieb oder ins Ausgabelimit lief. Je länger eine Sitzung, desto öfter muss zusammengefasst werden, und desto öfter kann es schiefgehen.
Was wir umgestellt haben
- Coding bleibt bei Qwen3.8 GSQ mit
-sm tensor(wie seit 18.10.). Es ist zugleich unser Produktionsmodell für alle anderen Dienste. Ein Modell für alles. - Qwen3.6 steht in OpenCode als schnelle Alternative zur Auswahl, für kleine, abgeschlossene Aufgaben.
- Gelöscht: GLM-4.7-Flash, Devstral Small 2, Qwen3-Coder-30B, DeepSeek-Coder-V2-Lite, Qwen2.5-Coder-32B (78 GB).
- Beide Labore (re-lab, web-lab) liegen als Repositorys bereit und laufen mit einem Befehl. web-lab hat zusätzlich ein
demo.ps1, das das Ergebnis eines beliebigen Laufs im Browser startet, mit echtem Modell dahinter.
Fazit
Was es dafür braucht, und das ist die eigentliche Erkenntnis aus beiden Laboren:
- Arbeit zuschneiden. Eine Aufgabe pro Sitzung, mit klarem Ziel und frischem Kontext. In re-lab machte das aus 60 von 152 die vollen 152. In web-lab waren die Aufgaben-Sitzungen die robusteste Variante.
- Präzise Rückmeldung. „Byte 37 weicht ab“ statt „Datei weicht ab“, „erwartet 401, erhalten 422“ statt „Test fehlgeschlagen“. Lokale Modelle sind gut im Reparieren, wenn sie wissen, was falsch ist.
- Verdeckte, objektive Tests. Ohne sie hätten wir weder die Regressionen von Qwen3.6 und GLM bemerkt noch die Sitzung, die „fertig“ meldete, ohne eine Zeile zu schreiben.
- Das Modell nach der Arbeitsweise wählen, nicht nach dem Namen. Die beiden „Coder“-Modelle (Qwen3-Coder, Devstral) landeten auf den letzten Plätzen. Das allgemeine Qwen3.8 arbeitete am gezieltesten.
- Kontextgrenzen ernst nehmen. Der Server ist ehrlich (sofort Fehler 400), der Agent muss rechtzeitig kompaktieren, und der beste Schutz ist, gar nicht erst an die Grenze zu kommen.
Claude bleibt der einfache Weg: Claude Sonnet löste das minipack-Ziel in 11 Minuten, für das GSQ eine knappe Stunde braucht. Aber der Abstand ist kleiner geworden, als wir dachten, und er schrumpft vor allem durch das, was man um das Modell herum baut.
Nächste Schritte: Zeitlimit pro Modellanfrage im Testrahmen (bei entarteter Ausgabe frisch neu starten statt mit demselben Verlauf fortsetzen), TinyXML-2 als großes C++-Ziel für re-lab, und Qwen3.8-Flash-Next als GSQ-Coder-Quant testen, sobald eine kleinere Variante in 28 GB passt.