Manche Fehler kosten einen ganzen Arbeitstag, obwohl alle beteiligten Systeme Erfolg melden und sichtbar nichts geschieht.
Unser Coding-Agent antwortete mit OK. Ziemlich oft sogar. Dazu lieferte er eine ordentliche Markdown-Zusammenfassung der angeblich geänderten Zeilen, selbstverständlich mit einem Abschnitt namens „Verification“. git diff zeigte unterdessen eine unveränderte Datei.
Wir suchten fast einen Tag lang, lagen sechsmal falsch, jedes Mal auf eine andere interessante Weise, und fanden schließlich einen echten Defekt in Laguna S 2.1. Er ließ sich sogar über die von poolside selbst gehostete API reproduzieren. Einige Wochen später begegnete uns derselbe Defekt in einem völlig unabhängigen Modell eines anderen Herstellers.
Die Infrastruktur rund um Laguna haben wir in Beitrag #8 behandelt: TP4 auf vier GPUs, 262k gegenüber 1M Kontext und der PP3-Skalierungsfehler, bei dem der Server sauber startet und anschließend unauffällig Unsinn ausliefert. Hier geht es um einen einzelnen, klar begrenzten Fehler im Modell selbst.
Die Kurzfassung:
Sechs Zeichenfolgen sind einzelne Tokens in Lagunas Vokabular. Sobald eine davon als Nutzdaten im Quellcode vorkommt, kann Laguna sie weder richtig lesen noch schreiben. Gelegentlich beendet das Modell beim Versuch sogar seinen eigenen Tool-Call mitten im JSON.
Am längsten brauchten wir für die Einsicht, dass dies keine Laguna-Eigenheit ist. Qwen3.6-35B-A3B hat mit denselben Tags denselben Defekt, und unserem Workaround fehlte zunächst ein entscheidender Kanal.
Der Aufbau, an dem es scheiterte
Wir waren dabei, DeepSeek-V4-Flash als vollwertiges OpenCode-Modell in TensorSharp einzubauen: Chat-Template, Tool-Historie, Ausgabe-Parsing, Trennung des Reasonings, das volle Programm. Dafür nutzten wir ein lokales Laguna, code-laguna für die Implementierung und reviewer-laguna für Reviews. TP4, 262k Kontext, vier GPUs, auf demselben Rig neben zwei weiteren Modellen. Bis dahin ein normaler Aufbau.
Die Zielcodebasis enthielt entsprechend viele Zeilen wie diese:
int closeIdx = content.IndexOf("</think>", StringComparison.Ordinal);
int openIdx = content.LastIndexOf("<think>", closeIdx, StringComparison.Ordinal);
Im Rückblick liegt die Ursache dort offen zutage. Uns fiel sie mehrere Stunden lang nicht auf.
Die Symptome traten in dieser Reihenfolge auf:
reviewer-lagunalieferte wiederholt leere Antworten auf Code, der nichts besonders Schwieriges enthielt.code-lagunameldete erfolgreiche Änderungen, die nie stattgefunden hatten.- In einem besonders denkwürdigen Lauf beschädigte das Modell sieben String-Literale in einer Datei, vier davon in Code, der mit der Aufgabe überhaupt nichts zu tun hatte.
Der Server zeigte sich währenddessen kerngesund: Running: 1 reqs, Waiting: 0. KV-Auslastung 11,6 Prozent. Prefix-Cache-Trefferrate 94,4 Prozent. Keine Preemption. Jeder einzelne Request endete mit 200 OK.
Irrweg eins: Der Reasoning-Parser muss schuld sein
vLLM protokolliert bei jedem Start mit Laguna diese Warnung:
WARNING [vllm.py:1486] Auto-initialization of reasoning token IDs failed.
Please check whether your reasoning parser has implemented the
`reasoning_start_str` and `reasoning_end_str`.
Eine Warnung über Reasoning-Tokens, ausgerechnet bei einem Modell mit einem offensichtlichen <think>-Problem. Das sah nach einer schnellen Aufklärung aus.
Wir schickten fünf Recherche-Agenten parallel durch den vLLM-Quellcode. Unabhängig voneinander kamen sie mit derselben, sehr gut belegten Antwort zurück. Dabei fanden sie tatsächlich einen Fehler:
# vllm/reasoning/basic_parsers.py
model_output_parts = model_output.partition(self.start_token)
model_output = (
model_output_parts[2] if model_output_parts[1] else model_output_parts[0]
)
str.partition trennt am ersten Vorkommen irgendwo in der Ausgabe. parts[0], also alles vor dem Treffer, wird nirgends zugewiesen. Der Inhalt landet nicht in content, nicht in reasoning, sondern verschwindet einfach.
Außerdem läuft der Reasoning-Parser über die gesamte rohe Modellausgabe, bevor der Tool-Call-Parser überhaupt die Argumente abtrennen kann:
# vllm/parser/abstract_parser.py
reasoning, content = self.extract_reasoning(model_output, request)
tool_calls, content = self._extract_tool_calls(content=content, ...)
Ein <think> innerhalb eines Tool-Arguments wird damit von einer strukturblinden String-Suche verschluckt, bevor eine Komponente an die Reihe kommt, die Tool-Calls versteht. Ein schöner Befund, vernichtend für den Parser und in fünf unabhängigen Berichten gründlich dokumentiert.
Nur erklärte er unseren Fehler nicht. Den Parser-Defekt gibt es trotzdem; wir meldeten ihn später als vllm#50901. Am Ende des Artikels kommen wir darauf zurück.
Zwei der fünf Agenten wiesen zu ihrer Ehrenrettung selbst auf die Unstimmigkeit hin: Der vorhergesagte Schaden passte nicht zum beobachteten. Nach der Parser-Theorie müsste der gesamte Tool-Call zerstört werden. Bei uns blieb der Tool-Call intakt, während lediglich sieben Zeichen präzise herausgeschnitten und durch ein einzelnes Leerzeichen ersetzt wurden. Zudem hatte reasoning_content die Länge null, obwohl es nach dieser Theorie gefüllt sein müsste.
Diese Abweichung brachte uns schließlich auf die richtige Spur.
Die Messung, die den Fall entschied
Auf Wunsch gibt vLLM die rohen Token-IDs der Ausgabe zurück ("return_token_ids": true). Hinzu kommen die Endpunkte /tokenize und /detokenize. Damit lassen sich drei Fragen sauber trennen: Was ging hinein, was tat das Modell und was kam heraus?
Zunächst die betreffenden Tokens:
[18] -> <think> [23] -> <assistant> [25] -> <tool_call>
[19] -> </think> [24] -> </assistant> [26] -> </tool_call>
Sechs einzelne Tokens. Es handelt sich nicht um sieben Zeichen, die zufällig wie ein Tag aussehen, sondern jeweils um einen atomaren Eintrag im Vokabular.
So verarbeitet der Tokenizer gewöhnlichen Eingabetext:
'X<think>Y' -> [2, 125, 18, 126]
'grep -c "<think>" /tmp/a.cs' -> [2, 21565, 419, 136, 444, 18, 71, 778, 6684, 7935, 11905]
'X< think >Y' -> [2, 125, 97, 1981, 981, 126] <- no token 18
Ein <think>, das im Prompt als normaler Text steht, wird zu genau demselben Token, mit dem das Chat-Template einen Reasoning-Block öffnet. Auf Token-Ebene ist beides ununterscheidbar. Der Generation-Prompt endet außerdem mit <assistant><think> = [..., 23, 18]. Das Modell beginnt seine Ausgabe also immer innerhalb eines geöffneten Reasoning-Blocks.
Wir ließen Laguna daraufhin grep -c "<think>" /tmp/a.cs erzeugen und verglichen die tatsächliche Tokenfolge mit der erwarteten:
correct : ... 136(c) 444(") 18(<think>) 71(") 778(/tmp) ...
measured: ... 136(c) 444(") 444(") 778(/tmp) ...
Token 18 erscheint kein einziges Mal. Drei Läufe mit Temperatur 0 ergaben null Treffer. skip_special_tokens=false änderte daran ebenso wenig wie enable_thinking=false.
Der Parser hatte folglich nichts gelöscht. Das Modell erzeugte Token 18 schlicht nie und setzte stattdessen ein zweites Token 444. Deshalb kam der Befehl als grep -c " " /tmp/a.cs zurück. Das rätselhafte Leerzeichen, für das sich im vLLM-Quellcode keine Ursache finden ließ, war kein Löschartefakt. Laguna hatte es selbst geschrieben.
Sobald man die Tokenverwendung betrachtet, ist der Grund unspektakulär. Token 18 taucht in den Trainingsdaten im Wesentlichen nur an einer strukturellen Position auf, vom Template ausgegeben und nicht als Nutzlast innerhalb eines Strings. Für „Erzeuge Token 18 als Inhalt eines Tool-Arguments“ gibt es daher nahezu kein Signal. Das Modell greift zu etwas anderem.
Immerhin war der Fehler für einen Moment sogar komisch. Auf die Aufforderung, den literalen String X<think>Y zu wiederholen, schrieb Laguna im eigenen Reasoning:
Okay, the user wants me to return exactly the string "XY" and nothing else.
Das Modell las Token 18 und gab es als Token 26 wieder. Laguna kann diese Tokens nicht nur schlecht schreiben, es verwechselt sie auch untereinander.
Beweisstück A: Das Modell legt selbst auf
Das anschaulichste Fehlerbild entstand mit einem edit-Tool. Laguna sollte diese Zeile als Tool-Argument erzeugen:
int closeIdx = content.IndexOf("</think>", StringComparison.Ordinal);
Über die Leitung kam wortwörtlich Folgendes zurück:
{"filePath": "/tmp/x.cs", "oldString": " int closeIdx = content.IndexOf(\"</tool_call>
^^^^^^^^^^^^
Beim Versuch, </think> zu schreiben, wählte das Modell </tool_call>. Dieses Token beendet den Tool-Call. Die Generierung stoppt mitten in einem JSON-String, weil das Modell aus Sicht des Harness soeben erklärt hat, dass es fertig ist.
Das JSON bleibt unvollständig und kann nicht geparst werden. Auf unserem lokalen Server verwirft der vLLM-Parser den fehlerhaften Aufruf vollständig. Übrig bleiben finish_reason: stop, eine leere Antwort und ein Agent, der mit den Schultern zuckt und OK meldet.
Damit war das gesamte Rätsel erklärt: ein lautloser Fehlschlag auf einem Server, der vollkommen gesund aussah.
Irrweg zwei: Dann muss es unser Stack sein
Wir kannten nun einen Mechanismus, aber noch nicht den Verursacher. Wir verwendeten INT4, eine bestimmte vLLM-Version und --reasoning-parser poolside_v1. Jede dieser Komponenten kam als eigentliche Fehlerquelle infrage.
Deshalb führten wir dieselben drei Tests gegen das von poolside gehostete Modell auf OpenRouter aus, einmal im kostenlosen und einmal im kostenpflichtigen Tarif, und verglichen sie mit unserem lokalen INT4.
| Test | lokales vLLM INT4 | OpenRouter :free |
OpenRouter kostenpflichtig |
|---|---|---|---|
Echo X<think>Y |
content='' |
content='' |
content='' |
| Literal in einem Tool-Argument | grep -c " " |
grep -c " " |
grep -c " " |
| Edit mit Literal in der Nutzlast | kein Tool-Call | defektes JSON | defektes JSON |
Test 2 ist auf allen drei Endpunkten bytegenau auf dieselbe Weise falsch.
Diese eine Tabelle entlastet gleich vier Verdächtige: unseren vLLM-Server, unseren INT4-Quant (die gehosteten Endpunkte verwenden kein INT4), den poolside_v1-Parser (OpenRouter nutzt einen anderen Stack) und OpenCode.
Mit diesen drei curl-Aufrufen in der ersten statt in der sechsten Stunde hätten wir den größten Teil des Tages gespart. Seitdem gilt für uns: Bei Verdacht auf einen Modellfehler testen wir dasselbe Modell zuerst bei einem zweiten Anbieter, bevor wir den eigenen Stack zerlegen.
Die vollständige Reproduktion:
curl -s $ENDPOINT/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model":"'"$MODEL"'",
"messages":[{"role":"user","content":"Call the bash tool with exactly this command: grep -c \"<think>\" /tmp/a.cs"}],
"tools":[{"type":"function","function":{"name":"bash","description":"Run a shell command",
"parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}}],
"tool_choice":"auto","max_tokens":2000,"temperature":0}'
Erwartet: grep -c "<think>" /tmp/a.cs. Geliefert: grep -c " " /tmp/a.cs.
Was den Fehler nicht behebt
Nach den bisherigen Vermutungen maßen wir jeden Lösungsvorschlag einzeln:
| Versuch | Ergebnis |
|---|---|
enable_thinking:false pro Request |
Modell bricht nach einem Zeichen ab und erzeugt EOS |
reasoning_effort:"none" |
gleiches Ergebnis |
skip_special_tokens:false |
wirkungslos, weil Token 18 gar nicht erst erzeugt wird |
--reasoning-config mit expliziten Begrenzern |
unterdrückt die Startwarnung, ändert sonst nichts |
--reasoning-parser identity |
dieser Name ist nicht registriert; der Server startet nicht |
| Upgrade von vLLM v0.25.1 auf v0.26.0 | die relevanten Dateien sind SHA-256-identisch |
Der letzte Punkt verdient eine Erläuterung. Wir bildeten Hashes von poolside_v1_reasoning_parser.py, deepseek_v3_, deepseek_r1_ und poolside_v1_tool_parser.py in v0.25.1, v0.26.0 und main. Sämtliche Dateien waren byteidentisch. Der Poolside-Reasoning-Parser hat in seiner gesamten Historie genau einen Commit. Ein Upgrade mit dem Produktionsrisiko von 411 weiteren Commits hätte uns exakt nichts gebracht.
Auch die Startwarnung, die uns auf den ersten Irrweg geschickt hatte, erwies sich für diesen Fall als belanglos. Die daraus abgeleiteten Token-IDs werden im gesamten vLLM-Baum von genau einer Stelle gelesen: dem Sampler thinking_token_budget. Die Warnung ist kosmetisch. Sie ist zudem als Issue #49379 gemeldet, auf genau unserer Version und mit nahezu denselben Flags. Seit dem 21. Juli ist das Issue offen und hat null Kommentare. Drei zugehörige PRs sind ebenfalls offen, nicht gemergt, keinem Meilenstein zugeordnet und ohne menschliches Review. Das vermittelt einen recht nüchternen Eindruck vom Long-Tail-Modellsupport in einer schnelllebigen Inferenz-Engine.
Der Fix: Wir belügen das Modell
Der Defekt sitzt in den Gewichten und lässt sich dort von uns nicht beheben. Die praktische Anforderung ist jedoch eng und mechanisch:
Die sechs Zeichenfolgen dürfen nie als ihre strukturellen Tokens in den Kontext gelangen und das Modell darf sie nie erzeugen müssen.
Damit wird aus dem Modellfehler ein Transportkodierungsproblem, für das es bewährte Lösungen gibt. OpenCode bietet eine Plugin-API mit Hooks rund um Tool-Ausführung und Nachrichtenfluss:
| Hook | Aufgabe |
|---|---|
chat.params |
merkt sich, welche Sessions geschützt sind und welcher Markersatz gilt |
tool.execute.after |
kodiert die sechs Zeichenfolgen in Tool-Ergebnissen, bevor das Modell sie sieht |
tool.execute.before |
dekodiert sie in Tool-Argumenten, bevor etwas ausgeführt wird |
experimental.chat.messages.transform |
kodiert sie in Benutzernachrichten, bevor das Modell sie sieht |
Den vierten Hook ergänzten wir später, nach einer teuren Lehrstunde; dazu gleich mehr unter „Irrweg vier“. Das Modell begegnet keinem strukturellen Token mehr und muss auch keines ausgeben. In der Datei auf dem Datenträger stehen weiterhin die echten Bytes. Für alle anderen Modelle bleibt der Datenstrom unverändert. Das haben wir ausdrücklich mit Claude, GPT und Qwen sowie mit unbekannten Sessions geprüft, bei denen das Plugin im Zweifel nicht eingreift.
Eine wichtige Abgrenzung, weil dieser Punkt leicht missverstanden wird: Das ist kein Skill. Ein Skill besteht aus Markdown und erklärt dem Modell etwas. Unser Code läuft im Harness-Prozess und verändert Daten während des Transports. Eine Anweisung könnte Laguna keinen Ausgabepfad für ein Token verschaffen, das es als Nutzlast nicht erzeugen kann.
Die Wahl des Sentinels prüften wir direkt am Tokenizer:
<think> -> [18] structural token
%%LAGUNA_THINK_OPEN%% -> [3834, 7836, 2683, ...] ordinary BPE
Irrweg drei: Der elegante Sentinel
Unser erster Sentinel war hübsch: ein Zero-Width Space vor der schließenden spitzen Klammer, also <think\u200B>. Für Menschen unsichtbar, für das Modell weiterhin als Tag verständlich und für den Tokenizer kein Treffer auf dem atomaren Eintrag. Die Messung bestätigte, dass Token 18 nicht vorkam.
Viermal ließen wir damit ein Refactoring ausführen, das zuvor deterministisch gescheitert war. Drei Läufe waren fehlerfrei. Im vierten schrieb Laguna:
private const string ThinkOpen = "<think\u200B>";
Das Modell hatte das unsichtbare Zeichen „hilfreich“ in eine C#-Escape-Sequenz normalisiert. Der Code kompiliert, erzeugt aber einen acht Zeichen langen String, wo sieben Zeichen benötigt werden. Jeder IndexOf darauf verfehlt sein Ziel, ohne einen Fehler zu melden.
Ein stiller semantischer Fehler ist schlimmer als ein lauter Absturz. Wir verwarfen den eleganten Sentinel und wechselten zur hässlichen ASCII-Variante. Sie enthält kein unsichtbares Zeichen, das sich normalisieren ließe, und lädt das Modell auch nicht zum Aufräumen ein.
Der Decoder ist absichtlich toleranter als der Encoder, denn wir hatten gemessen, wie Laguna lange Tokens beschädigt:
%%LAGUNA_THINK_OPEN%% -> <think> canonical
%%laguna_think_open%% -> <think> lowercased
%LAGUNA_THINK_OPEN%% -> <think> dropped character
50%% off and 100% sure -> unchanged
Vier neue Läufe mit demselben Refactoring ergaben:
| ohne Plugin | ZWSP-Sentinel | ASCII-Sentinel | |
|---|---|---|---|
| strukturelle Literale korrekt | 0 von 2 | 3 von 4 | 4 von 4 |
| Sentinel in Datei gelangt | – | 1 von 4 | 0 von 4 |
Irrweg vier: Das Plugin reichte nicht aus
Einige Wochen später tauchte derselbe Defekt in einem vollständig anderen Modell auf, Qwen3.6-35B-A3B. Das Plugin um dieses Modell zu erweitern, sah nach einer Fünf-Minuten-Aufgabe aus. Es wurde keine.
Die Tokenizer-Geschichte ist dieselbe: <think> und </think> sind einzelne Vokabulareinträge, die das Modell als Nutzdaten nicht ausgeben kann. Bis wir den Versuch korrekt aufgebaut hatten, brauchten wir drei Anläufe. Wir nahmen vier Ausschnitte von jeweils ungefähr 420 Bytes aus derselben Produktionsdatei. Damit blieb die Nutzlast vergleichbar, und nur die enthaltenen Tags änderten sich:
| Ausschnitt | lokales llama.cpp Q4 | Parasail vLLM FP8 |
|---|---|---|
| kein strukturelles Tag (580 B) | 3/3 exakt | 3/3 exakt |
nur <think> |
3/3 exakt | 0/3 |
nur </think> |
0/3 | 0/3 |
| beide | 0/3 | 0/3 |
Der Kontrollausschnitt besteht den Test auf beiden Stacks und ist zugleich der größte der vier. Der Fehler hängt daher am Tag und nicht an der Länge. In zwei früheren Versuchen hatten wir fälschlich die Nutzlastgröße verantwortlich gemacht. Der kleine Ausschnitt enthielt zufällig nur das öffnende Tag, die größeren ergänzten das schließende. Größe und Tag-Inhalt waren in unserem Versuchsaufbau miteinander vermischt.
Dann aktivierten wir das Plugin, die Transkriptionstests sprangen von 0/5 auf 5/5, und wir setzten einen echten Agenten auf eine echte Datei an. Er änderte nichts. Zweimal. Im dritten Versuch erzeugte er 142 kB Selbstgespräch und fror den Editor ein.
Der naheliegende Verdacht fiel auf die Platzhalter. Der Agent sprach sogar darüber:
The terminal is rendering it as an XML tag and hiding it, which is why the text display shows
%%SHIELD_THINK_OPEN%%
Also ergänzten wir einen System-Prompt: Dies sind Platzhalter, behandle sie wie das Tag, schreibe sie unverändert zurück und untersuche sie nicht weiter. Die Begründung klang vernünftig. Die Messung widersprach ihr:
| Formulierung der Aufgabe | ohne Hinweis | mit Hinweis |
|---|---|---|
| verweist auf vorhandenen Code | 3/3 | 3/3 |
| schreibt den Marker aus | 1/3 | 0/3 plus Doom-Loop |
Der Hinweis half nie und verschlechterte eine Formulierung sogar deutlich. Wir entfernten ihn wieder.
Der fehlende Kanal war der Prompt. Unser eigener Aufgabentext enthielt das rohe </think>. Das Plugin kodierte Tool-Ergebnisse und Tool-Argumente, aber keine Benutzernachrichten. Wir hatten das Modell aufgefordert, nach einer Zeichenfolge zu suchen, die es prinzipbedingt nicht wahrnehmen kann, und beobachteten dann, wie es zwanzigtausend Tokens lang suchte.
| korrekt | Doom-Loops | Ausgabe | |
|---|---|---|---|
| roher Marker im Prompt | 1/3 | 2 | 20–25 kB |
| kodierter Marker im Prompt | 3/3 | 0 | 6215 B, deterministisch |
Ein weiterer Hook behob das Problem: Derselbe Auftrag, der zuvor zwei leere Diffs und einen eingefrorenen Editor erzeugt hatte, lieferte anschließend genau die angeforderte Änderung. experimental.chat.messages.transform erhält dabei ein leeres Eingabeobjekt, weshalb die Session aus der Nachrichten-Nutzlast rekonstruiert werden muss.
Zwei Kontrollen machten den Fehler diagnostizierbar. Dasselbe Modell bearbeitete eine Datei ohne Marker bei einer vergleichbaren Aufgabe korrekt; andernfalls hätte es einfach wie ein allgemein unfähiges Modell gewirkt. Außerdem prüften wir git diff statt des Agentenberichts. Der Bericht behauptete, %%SHIELD_THINK_CLOSE%% in die Datei geschrieben zu haben; tatsächlich enthielt sie das echte Tag.
Unsere Transparenzannahme lautete, dass kein aufrufender Agent etwas von der Kodierung wissen müsse. Für reines Durchreichen stimmte das. Sobald der Agent über die Bedeutung eines Literals nachdenken musste, brach die Annahme zusammen. Unser End-to-End-Test hatte bis dahin ausschließlich das Durchreichen geprüft.
Zwei weitere Zeichenfolgen, gefunden mit der naheliegenden Frage
Sobald der Mechanismus verstanden war, stellte sich eine einfache Frage: Welche anderen Zeichenfolgen in diesem Vokabular sind einzelne Tokens? Bei Qwen genügte ein Aufruf von /tokenize:
<|im_start|> -> [248045] single token
<|im_end|> -> [248046] single token
<|assistant|> -> [27, 91, 74455, 91, 29] ordinary text, fine
Zwei Treffer, beide genau in dem Code, auf den man einen Coding-Agenten ansetzen würde: dem Rendering des Chat-Templates. In der Liste des Plugins fehlten sie.
Wir wiederholten den vorherigen Versuch mit vier ähnlich großen Ausschnitten aus einer echten Datei. Diesmal war die Kontrolle besonders wichtig, denn sie war selbst nicht fehlerfrei:
| Ausschnitt | exakt | Marker intakt |
|---|---|---|
| kein Marker (Kontrolle) | 1/3 | 3/3 |
nur <|im_start|> |
0/3 | 0/3 |
nur <|im_end|> |
0/3 | 0/3 |
| beide | 0/3 | 0/3 |
Der Kontrollausschnitt verliert in zwei von drei Läufen ganz von allein eine Zeile. Ohne diese Kontrolle hätten wir dem Tag Schäden zugerechnet, die diese Datei ohnehin provoziert. Das Signal steckt in der letzten Spalte: 0 von 9 ohne Shield, 9 von 9 mit Shield.
Die beiden Fehlermuster unterscheiden sich, und dieser Unterschied ist wichtig:
<|im_start|> -> " :" ersetzt. Kompiliert einwandfrei. Zerstört still das
Prompt-Format, was kein Build bemerkt.
<|im_end|> -> die Ausgabe endet einfach am Marker, finish_reason=stop
Der erste Fall ist der unangenehmere. sb.Append(" :system\n...") ist gültiges C#, erzeugt aber einen falschen Prompt. Damit kehrte exakt die stille Fehlerklasse vom Beginn unserer Untersuchung durch einen anderen Eingang zurück.
Bei der Implementierung haben wir bewusst eine Grenze gezogen. Diese beiden Tokens werden nur für Qwen kodiert. Lagunas Endpunkt war zu diesem Zeitpunkt nicht erreichbar, daher fehlt eine Messung für dieses Modell. Kodierung ist außerdem nicht kostenlos: Sie verändert die Tokenisierung der Eingabe. In unseren Messungen traten dabei zwei Fehlerstellen pro zehn Läufe auf. Der Markersatz gilt deshalb pro Modellfamilie. Der Decoder akzeptiert dagegen alle Varianten, denn ein in eine Datei gelangter Sentinel muss auch dann dekodierbar bleiben, wenn später ein anderes Modell die Stelle findet.
Bonusrunde: ein zweiter Defekt und eine übereilte Widerlegung
Beim Messen des ersten Problems stießen wir auf einen zweiten, unabhängigen Fehler, den keine noch so geschickte Kodierung beheben kann. Laguna lässt bei wortgetreuer Transkription Zeichen aus, und mit wachsender Nutzlast wird der Effekt deutlich stärker. Gemessen haben wir an echtem C#, mit jeweils zwei Läufen pro Nutzlast; gezählt wurden Abweichungen auf Zeichenebene:
| Nutzlast | Fehler |
|---|---|
| 388 B | 2 |
| 600 B | 3 |
| 1003 B | 3 |
| 1656 B | 27 |
| 2402 B | 27 |
Typische Ausgaben waren IndexOf → Indexof, StringComparison → Stringcomparison, IsNullOrEmpty → IsNullorEmpty und LastIndexOf → LastIndexIf.
Zwischen 1000 und 1650 Bytes liegt ein deutlicher Sprung.
Nun zum peinlichen Teil. Unsere bisherige Faustregel lautete: „Lagunas Edit-Nutzlasten unter ungefähr 1,5 kB halten.“ Mitten in der Untersuchung erklärten wir diese Regel für widerlegt, weil ein synthetischer Testblock bei 4000 Bytes keinen einzigen Fehler zeigte.
Der synthetische Block bestand aus hundert nahezu identischen Zeilen. Für ein Sprachmodell war er trivial, und die Messung sagte praktisch nichts aus. Die alte Regel war richtig, unsere Widerlegung wertlos; wir setzten die Regel wieder in Kraft.
Eine Messung ist nur so gut wie ihr schwierigster Fall. Sind die Testdaten leichter als die Produktion, bedeutet ein grünes Ergebnis gar nichts.
Der zweite Defekt besitzt wenigstens eine angenehme Eigenschaft: LastIndexIf kompiliert nicht. Er scheitert laut. Gegenüber dem ersten Defekt, bei dem überhaupt nichts geschah und der Agent sich anschließend selbst gratulierte, ist das ein erheblicher Fortschritt.
Was das für den Betrieb von Laguna bedeutet
Das Plugin lässt den ersten Defekt aus Sicht des aufrufenden Systems verschwinden. Genau das war sein Zweck. Ein Modell, bei dem jeder aufrufende Agent drei Sonderregeln kennen muss, würde zu Recht kaum eingesetzt.
Übrig bleiben ein paar praktische Betriebsregeln:
git diffist der Schiedsrichter, niemals der Bericht des Agenten. In erfolgreichen Läufen waren Berichte teilweise leer oder bruchstückhaft, obwohl die Arbeit fehlerfrei war. Der umgekehrte Fall kommt ebenso vor. Die Selbsteinschätzung liefert in beide Richtungen keine belastbare Information.- Wir führen den Build aus. Der Transkriptionsfehler erzeugt Code, der nicht kompiliert. Sichtbar wird das aber nur, wenn jemand tatsächlich kompiliert.
- Wir halten Edit-Nutzlasten klein. Eine Methode pro Auftrag, nicht eine ganze Klasse.
- Wir schreiben rohe Marker nie selbst in einen Prompt. Innerhalb des Harness kümmert sich das Plugin darum. Außerhalb davon, etwa bei einer
curl-Reproduktion oder einer manuell geschriebenen Aufgabenbeschreibung, gibt es keinen Schutz. Dieser Fehler kostete uns mit Abstand die meiste Zeit.
Zur Einordnung der praktischen Häufigkeit: Von 57 code-laguna-Aufträgen im betroffenen Projekt erzeugten 9 überhaupt keine Änderung. Das sind sechzehn Prozent, ohne sichtbaren Fehler. Die Codebasis behandelte ausgerechnet Chat-Protokolle und war damit der ungünstigste denkbare Fall. In gewöhnlichem Anwendungscode liegt die Quote praktisch bei null, weil dort normalerweise kein </tool_call> als String-Literal vorkommt.
Aus all dem ließe sich leicht das Urteil ableiten, Laguna sei kaputt und sollte nicht eingesetzt werden. Unsere Erfahrung spricht dagegen. Das Modell ist bei schwierigen Debugging-Aufgaben ausgesprochen stark; daran hat sich seit Beitrag #8 nichts geändert. Es besitzt einen eng begrenzten, unangenehmen Defekt, der in einer bestimmten Art von Codebasis spektakulär detoniert. Wir trafen ihn so zuverlässig, weil unser Zielcode ein Chat-Template-Parser war.
Nachtrag: sechs Irrwege und eine Methode
Unsere sechs falschen Erklärungen waren:
- Der vLLM-Reasoning-Parser verursache den Fehler. Fünf Agenten belegten das gründlich und lagen trotzdem falsch.
- Unser eigener Serving-Stack sei verdächtig. Drei
curl-Aufrufe hätten ihn entlastet. - Der Zero-Width-Sentinel sei eine elegante Lösung. Das war er kurz, bis er lautlos eine Konstante beschädigte.
- Die 1,5-kB-Regel sei widerlegt. Unsere Widerlegung verwendete Daten, an denen das Modell kaum scheitern konnte.
- Die Nutzlastgröße erkläre den Fehler im zweiten Modell. In unserem Versuch waren Größe und Tag-Inhalt vermischt.
- Die Platzhalter in der Datei verwirrten den Agenten. Tatsächlich lag der rohe Marker in unserem eigenen Prompt, und der vermeintliche System-Prompt-Fix verschlimmerte einen Fall.
Jede Erklärung war plausibel und stützte sich auf Evidenz, die zu dünn war, aus einem zu leichten Test stammte oder aus der falschen Schicht kam. Gerade weil die Befunde jeweils gut aussahen, hielten sich die Erklärungen eine Weile.
Entschieden wurde der Fall jedes Mal durch eine Messung entlang des realen Pfads: /tokenize statt Quellcodeinterpretation, ein zweiter Anbieter statt weiterer lokaler Fehlersuche, echter Code statt synthetischer Daten und ein tatsächlicher Neustart statt eines Imports, der den Loader umging.
Das wussten wir vorher schon. Das ist der lästige Teil.
Beide Defekte sind inzwischen gemeldet
Keines der beiden Probleme aus diesem Artikel war zuvor irgendwo dokumentiert. Deshalb haben wir beide gemeldet.
Der Modellfehler: poolside/Laguna-S-2.1, Diskussion #35. Der curl-Aufruf aus „Irrweg zwei“ bildet den vollständigen Bugreport. Der Fehler dürfte außerdem #29 erklären. Dort stieß jemand in OpenCode auf dieselbe leere Antwort, ohne erkennen zu können, was passiert war.
Der vLLM-Parserfehler: vllm-project/vllm#50901. Das ist der reale, für unseren konkreten Fall jedoch unschuldige Defekt aus „Irrweg eins“. Für die Meldung war eine weitere Recherche nötig, die das schönste Detail der gesamten Untersuchung hervorbrachte: minimax_m3 überschreibt extract_reasoning bereits und behandelt das verworfene Präfix korrekt. Die Lösung existiert also im selben Repository. Sie wurde nur nie in die Basisklasse übernommen, von der deepseek_r1 erbt.
Unsere Erwartungen an beide Meldungen waren realistisch. poolside scheint Diskussionen auf Hugging Face nicht zu beantworten; in #15 wurde ein Mitarbeiter namentlich markiert und antwortete nie. Das unserem Fall nächstgelegene vLLM-Issue #49379 war seit dem 21. Juli ohne einen einzigen Kommentar offen. Die Meldungen lohnen sich trotzdem. Wer als Nächstes auf den Fehler stößt, kann dadurch wenigstens herausfinden, was passiert. Diesen Vorteil hatten wir nicht.
Aktualisierung. Beim vLLM-Issue kam innerhalb einer Stunde Bewegung auf. Ein weiterer Contributor bestätigte das verworfene Präfix durch Quellcodeprüfung und schlug genau den engen Umfang vor, den wir empfohlen hatten. Ein dritter eröffnete PR #50918: content_before bleibt erhalten und wird vor content gesetzt, ergänzt um zwei Tests, die auf dem alten Code fehlschlagen.
Aus diesem Austausch blieb ein Detail besonders hängen. Der Contributor, der den Fehler bestätigt hatte, wies zusätzlich auf eine nachgelagerte Wechselwirkung hin. Step3p5ReasoningParser bearbeitet den Rückgabewert der Basisklasse mit content.removeprefix("\n"). Nach dem Fix entfernt diese Zeile das wiederhergestellte Präfix statt des Zeilenumbruchs nach dem schließenden Tag. Anschließend genehmigte derselbe Contributor den PR, ohne zu prüfen, ob dieser Punkt behandelt worden war. In #50918 war er weiterhin offen. Der Verlust ist klein, betrifft aber genau die Position, die der PR wiederherstellt. Die vorhandenen Tests können ihn nicht erkennen, weil er erst sichtbar wird, sobald das Präfix erhalten bleibt.
Status vom 17. August 2026. Wir reproduzierten diese Wechselwirkung auf Codeebene und meldeten Issue #51164. Contributor khushali9 setzte sie in PR #51201 samt Regressionstests um; ein Reviewer genehmigte den PR. Später bat ein vLLM-Collaborator um eine Reproduktion auf einem laufenden Server mit dem betroffenen Step3.5-Modell. Das Problem ist auf main noch latent: Es wird erst beobachtbar, nachdem #50918 das Präfix bewahrt. Für den End-to-End-Nachweis müssen daher beide Änderungen angewendet und #50918 allein mit #50918 plus #51201 verglichen werden.
Beide Fixes sind weiterhin ungemergt. PR #50918 wartet auf ein Code-Owner-Review und ist zusätzlich durch zwei Commits ohne DCO-Sign-off blockiert. PR #51201 hat einen sauberen DCO-Status, wartet aber noch auf die Entscheidung der Maintainer zur angeforderten Live-Reproduktion. An uns ist keine Frage offen; die verbleibende Arbeit liegt bei den PR-Autoren und Maintainern.
Zum Modellfehler gab es unterdessen keine Reaktion. Das entsprach unserer Erwartung.
Nachtrag zum Nachtrag: Bevor wir den Hinweis zu removeprefix meldeten, mussten wir ihn selbst prüfen. Eine unbestätigte Vermutung anderer weiterzureichen, war schließlich die Methode hinter den ersten vier Irrwegen dieses Artikels.
Die Laguna-Werte wurden auf einem Rig mit 8× RTX 3090 gemessen: Laguna S 2.1 INT4 unter vLLM 0.25.1 mit TP4 und 262k Kontext, gegengeprüft an poolside-gehosteten Endpunkten im kostenlosen und kostenpflichtigen Tarif. Die Qwen-Werte stammen von einem Budget-Rig mit 2× RTX 3060: Qwen3.6-35B-A3B-UD-IQ4_XS unter llama.cpp b10143, gegengeprüft gegen DeepInfra und Parasail FP8.