Zurück zum Blog

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

Datum 20. Oktober 2026
Kategorie LLM & Agenten
Lesezeit 24 Minuten

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:

  1. re-lab, größeres Ziel: ein echtes Open-Source-Kompressionsprogramm (heatshrink, ~1.500 Zeilen C) statt eines Übungsprogramms mit 360 Zeilen.
  2. 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.
Die Kurzfassung für Eilige:
  • 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.

Die Ausgangslage

BausteinDetails
GPUsNVIDIA RTX 5060 Ti 16 GB + RTX 4070 Ti 12 GB = 28 GB VRAM
Inferenzllama.cpp (b10964), nativ unter Windows 11, hinter llama-swap (Modellwechsel)
AgentOpenCode 2.0.27, ohne Oberfläche (opencode run --auto --format json)
ProduktionsmodellQwen3.8 27B, Quant GSQ-RCO IQ3_S von ISTA-DASLab, -sm tensor, 64k Kontext, ~47 Token/s
Die harte Grenze: Jedes Modell muss vollständig in die 28 GB passen, samt Kontext. Sobald Teile in den Arbeitsspeicher ausweichen, bricht die Geschwindigkeit ein und Agentenläufe von Stunden werden unmöglich. Damit fallen die großen offenen Modelle (Kimi K2.x, GLM-5, DeepSeek V4) von vornherein weg. Ein neues, spezialisiertes Coding-Modell (Qwen3.8-Flash-Next als GSQ-Coder-Quant) belegt 29,6 GB und ist damit knapp zu groß. Es bleiben Modelle bis etwa 30 Milliarden Parameter.

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 65536 am 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:

ÄnderungWirkung
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 gespeichertdito, an anderer Stelle im Bitstrom
Eigenes Kommandozeilenprogramm hshrinkContainer 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:

DateiInhaltPrüft
empty.bin, one.bin, two.bin0, 1 und 2 ByteMinimalfälle, kein Rückverweis möglich
text.txt4.600 Byte deutscher/lateinischer Text, Tabs, Windows-Zeilenendentypischer Text
words.txt" eins zwei\n\ndrei\t vier \n"kurze Eingabe mit Wiederholungen
random.bin5.000 Byte Pseudozufallnicht komprimierbar, Ausgabe wird größer
runs.bin300ׄa“, 600×0x00, 256×0xFF …lange Wiederholungen, maximale Rückverweislänge
ramp.bin0,1,2,…,255,0,1,… (2.048 Byte)Muster mit Periode 256
zeros.bin10.000 Nullbytesextremer Fall für den Suchindex
abc.txt„abc“ × 1.000kurze Periode
data.json300 JSON-Zeilenstrukturierter Text
mixed.binZufall + Text + ZufallWechsel zwischen komprimierbar und nicht

Daraus entstehen sieben Gruppen:

GruppeFälleWas genau
cli16Aufruf 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)
enc60jede 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
roundtrip24mit dem eigenen Packer packen, mit dem eigenen Entpacker entpacken
dec12Dateien entpacken, die das Original gepackt hat
info12-i: Kopf eines Containers anzeigen
verbose13-v: Statistikausgabe beim Packen und Entpacken
corrupt15sieben 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
Summe152

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.

GruppeErgebnis
info12/12
cli15/16
enc21/60
dec0/12
roundtrip0/24
verbose + corrupt12/28
gesamt60/152, Struktur 6/15, Ghidra: 0 Funktionen benannt

Warum es so ausfiel

Die Protokolle zeigen ein klares Muster:

  1. Das Grundgerüst ging schnell. Hilfe, Fehlermeldungen, Container-Kopf und -i waren nach gut einer Stunde fertig.
  2. 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 enc bei wenigen Treffern.
  3. Die Rückmeldung half nicht. Bei einer falschen Ausgabedatei meldete check.ps1 nur „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 rief check.ps1 zeitweise zwei Stunden lang gar nicht auf und analysierte stattdessen weiter.
  4. 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:

ModulZiel (Gruppen)BudgetHinweis in der Anleitung
M1 Grundgerüstcli, info60 minPacken/Entpacken bleiben Stubs
M2 Entpackerdec, corrupt120 minTestcontainer stammen vom Original: Entpacker unabhängig prüfbar
M3 Packerenc, roundtrip150 minDer Entpacker zeigt, wie der Datenstrom aufgebaut ist
M4 Restalle30 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üfung60/152 nach 360 min152/152 nach 313 min
Struktur6/1513/15
Ghidra benannt / kommentiert / Prototypen0 / – / –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.

Ein Detail zeigt, wie viel am Testrahmen hängt: M2 war eigentlich nach etwa 105 Minuten fertig (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.md als Gedächtnis ging nichts verloren.
  • Rückmeldung, mit der man arbeiten kann. Eine Meldung der Art „Byte 37: erwartet 0x9c, erhalten 0x1c“ 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). fetch ist 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 --noEmit zählt TypeScript-Fehler im Code des Agenten.
  • Verdeckt: Die Tests liegen außerhalb des Arbeitsverzeichnisses. Der Agent darf nur check.ps1 aufrufen, 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

AufgabeInhaltTestsWas genau geprüft wird
1 ChatPOST /api/chat ruft das LLM mit httpx9Antwort 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 StreamPOST /api/chat/stream (Server-Sent Events)8Stü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 UnterhaltungenSQLite, CRUD, Verlauf an das LLM10Anlegen (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 Extractstrukturierte Ausgabe8response_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-UIReact-Komponente9Textfeld, Senden-Knopf, Enter sendet, Shift+Enter macht Zeilenumbruch, Ladehinweis „Antwort wird erzeugt…“, Senden währenddessen gesperrt, Fehlermeldung als role="alert"
6 Stream-UIStreaming im Browser + Unterhaltungsliste8SSE 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):

ModellTypDatei
Qwen3.8 27B GSQ (Referenz)dicht, 27BISTA-DASLab GSQ-RCO IQ3_S, -sm tensor
Qwen3.6 35B-A3BMoE, 3B aktivbartowski Q4_K_M
Qwen3-Coder 30B-A3BMoE, Coding-SpezialistQ4_K_M
Devstral Small 2 24B (2512)dicht, Mistrals Agenten-CoderUnsloth UD-Q5_K_XL
GLM-4.7-Flash 30B-A3BMoEUnsloth UD-Q5_K_XL, Denkbudget 2048

Ergebnis Stufe 1

PlatzModellTestsMinutenSchritteTool-Fehlermax. KontextKompaktierungen
1Qwen3.652/52101111537,6k0
2Qwen3.8 GSQ52/521461921,3k0
3GLM-4.7-Flash45/52906438344,0k8
4Qwen3-Coder-30B41/52814506044,1k6
5Devstral Small 240/521061.5663129,0k3

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.

Konsequenz: GLM-4.7-Flash, Devstral Small 2 und Qwen3-Coder-30B haben wir gelöscht, ebenso zwei ältere Testmodelle (DeepSeek-Coder-V2-Lite, Qwen2.5-Coder-32B). Zusammen 78 GB.

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.

AufgabeInhaltTestsWas genau geprüft wird
7 AgentPOST /api/agent: Tool-Calling-Schleife9exakte 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 KontextbudgetVerlauf vor dem LLM-Aufruf kürzen8Token-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 VerlaufAufgaben 3 und 8 auch für den Stream8404/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 AnmeldungToken im Authorization-Header, eigene Unterhaltungen9401 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 VerbrauchAbrechnung und Tageslimit8jeder 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-ExtrasFrontend8Token-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).

Aufgabe 10 enthält eine bewusste Falle: In FastAPI prüft das Framework die Eingabedaten, bevor der eigentliche Endpunkt läuft. Eine Anmeldeprüfung im Endpunkt käme für „leere Nachricht ohne Token“ zu spät (422 statt 401). Die saubere Lösung ist eine Middleware. Aufgaben 10 und 11 zwingen außerdem dazu, jede bestehende Funktion anzufassen.

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

ModellVarianteTestsMinutenSchritteTool-Fehlermax. KontextKompaktierungen
Qwen3.8 GSQAufgaben-Sitzungen102/1023392942k1
Qwen3.8 GSQeine Sitzung56/102279017k0
Qwen3.8 GSQeine Sitzung, Wiederholung102/102851991944k7
Qwen3.8 GSQeine Sitzung, ohne Kompaktierung102/1022038159,4k–
Qwen3.6Aufgaben-Sitzungen97/102402575144k4
Qwen3.6eine Sitzung102/102502822644k8
Qwen3.6eine Sitzung, ohne Kompaktierung83/1021149564,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ößeStatusAnfang gesehen?
50 %200ja (4242)
90 %200ja
98 %200ja
102 %400 exceed_context_size_error, nach 0,2 s–
120 %400–
150 %400–
90 % Prompt, Antwort bis 30 %200, finish_reason: lengthAntwort 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.
Eine Falle im ersten Anlauf dieses Tests: Das Modell durfte nur 5 Token antworten. Mit eingeschaltetem Denkmodus gingen die alle ins Nachdenken, und jede Antwort war leer. Bei kurzen Testantworten den Denkmodus abschalten (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:

SzenarioServer -cOpenCode-GrenzeKompaktierungTestsErgebnis
normal64k60kan52/52Grenze nie erreicht (max. 42k)
knapp64k24kan52/522 Kompaktierungen, kein Schaden
Überlauf24k60kan52/52Server lehnt mit 400 ab, Sitzung bricht ab
ohne Kompaktierung64k60kaus52/52Grenze 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

  1. limit.context in OpenCode immer unter dem -c des Servers. Bei uns 60k gegen 64k. Liegt die Grenze darüber, kompaktiert OpenCode zu spät, und der Server lehnt ab.
  2. Kompaktierung eingeschaltet lassen. Ohne sie ist ein Überlauf kein Leistungsverlust, sondern das Ende der Sitzung.
  3. Eine zu knappe Grenze schadet kaum. 24k statt 60k bedeutete nur zwei Kompaktierungen mehr.
  4. 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.
  5. 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 lokal heute geht: Ein 27B-Modell auf zwei Mittelklasse-Karten erweitert eine Web-App in einer halben Stunde um Tool-Calling, Kontextbudget, Anmeldung und Verbrauchsabrechnung, besteht 102 von 102 verdeckten Tests und gewinnt ein optimiertes C-Programm ohne Symbole bitgenau aus dem Maschinencode zurück. Das hätten wir vor einem halben Jahr nicht erwartet.

Was es dafür braucht, und das ist die eigentliche Erkenntnis aus beiden Laboren:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.