Skip to content

parseAssistantMessageV2: Text-Stream-Block-Parser des LLM

源码版本v4.0.10

Verantwortung

parseAssistantMessageV2 ist Clines Parser, der den vom LLM gestreamten rohen String in strukturierte Blöcke zerlegt, geschrieben in apps/vscode/src/core/assistant-message/parse-assistant-message.ts. Eingabe ist der kumulierte Assistant-Text, Ausgabe ist AssistantMessageContent[], wobei jedes Element eines von TextStreamContent, ToolUse oder ReasoningStreamContent ist. Cline verlässt sich nicht auf die native tool_use-API des LLM, sondern nutzt ein XML-artiges Tag-Protokoll (<read_file>...</read_file>, <write_to_file><content>...</content></write_to_file>), um das Modell Werkzeugaufrufe in normalen Text einzubetten; dieser Parser ist der Kern, der diesen halbstrukturierten Text zerlegt.

Seine Position in der Agent-Loop ist klar: recursivelyMakeClineRequests startet attemptApiRequest, um den Stream zu erhalten; bei jedem empfangenen Text-Stück im Stream-Callback wird assistantMessage += chunk.text kumuliert und dann sofort parseAssistantMessageV2(assistantMessage) aufgerufen, um die gesamte Zeichenkette neu zu parsen (parseAssistantMessageV2 call:3508). Das geparste Block-Array wird in taskState.assistantMessageContent gespeichert; wenn die Länge wächst, wird scheduleAssistantPresentation aufgerufen, damit presentAssistantMessage voranschreitet. Diese Funktion kann also dutzende Male pro Sekunde aufgerufen werden und muss ein O(n)-Einzeldurchlauf sein.

Entwurfsmotivation

  • Ganze Zeichenkette neu parsen statt inkrementell: Bei jedem neuen Text-Stück wird die gesamte assistantMessage neu geparst. Scheint verschwenderisch, aber da der LLM-Stream zwischendurch frühere Tags reparieren kann (z. B. </parameter> schließen), würde eine inkrementelle Zustandsmaschine durch solche Reparaturen durcheinander geraten. Ein kompletter Neuparse ist am robustesten; außerdem ist der Assistant-Text normalerweise einige KB groß, ein O(n)-Scan schnell genug.
  • endsWith-artige Tag-Erkennung: Kein Vorab-Tokenize, sondern ein Charakter-weises Vorgehen ab i=0; an jeder Position wird geprüft, ob der Teilstring, der an i endet, einem Öffnungs-/Schließ-Tag entspricht (close tag check:56). Diese Schreibweise ist fehlertolerant gegenüber gelegentlich zu viel oder zu wenig ausgegebenen Zeichen des LLM; solange das Tag insgesamt geschlossen werden kann, ist es in Ordnung.
  • Vorab berechnete Öffnungs-Tag-Maps: toolUseOpenTags und toolParamOpenTags sind beide Map<string, name>, die außerhalb der Schleife einmalig aufgebaut werden (precompute maps:38); in der Schleife wird nur die Map abgefragt, nicht das Array gescannt. Werkzeug- und Parameternamen stammen aus der Whitelist getToolUseNames() / toolParamNames; Nicht-Whitelist-Tags werden als Text behandelt.
  • partial-Flag durchgängig: Ein Block, der gerade gestreamt wird und dessen Schließ-Tag noch nicht gesehen wurde, wird mit partial: true markiert (partial true:178); der nachgelagerte presentAssistantMessage weiß bei einem partial-Block, dass er noch unvollständig ist, und führt nur inkrementelle UI-Updates durch, ohne das Werkzeug wirklich auszuführen. Nach Stream-Ende wird partial auf false erzwungen, damit der nachgelagerte Part finalisieren kann.
  • Sonderbehandlung von write_to_file-content: Der <content>-Parameter von write_to_file kann Code enthalten, der wie ein Schließ-Tag aussieht (z. B. wenn das Modell eine XML-Datei schreibt); indexOf + lastIndexOf nutzen first/last-Anker, um die echte Schließ-Position zu finden (content lastIndexOf:119), um zu verhindern, dass ein falsches mittleres Schließ-Tag den Inhalt abschneidet.
  • Finalize am Stream-Ende: Wenn die Schleife normal endet und noch未geschlossene tool use oder text vorhanden sind, werden sie als partial in contentBlocks gepusht (finalize partial:223). So bekommt der nachgelagerte Part selbst bei Stream-Abbruch ein Halbprodukt.

Schlüsseldateien

  • parseAssistantMessageV2:28 — Funktionseinstieg, Signatur (assistantMessage: string) => AssistantMessageContent[].
  • precompute maps:38 — baut <tool_name>- und <param_name>-Tags vorab als Map auf, in der Schleife O(1)-Lookup.
  • param state:52 — im Parameterwert-Zustand wird geprüft, ob die aktuelle Position ein </param_name>-Schließ-Tag ist.
  • tool use state:78 — im tool use, aber nicht im Parameterwert-Zustand, wird geprüft, ob ein neuer Parameter beginnt oder das Werkzeug geschlossen wird.
  • tool close tag:95 — bei Treffer von </tool_name> wird tool als partial: false markiert und in contentBlocks gepusht.
  • content special:111 — write_to_file-content-Parameter nutzt lastIndexOf, um das echte Schließ-Tag zu finden und ein falsches mittleres zu verhindern.
  • text state:138 — weder in tool use noch in Parameter: im Text-Zustand scannen und prüfen, ob ein neues Werkzeug beginnt.
  • new tool use:174 — bei Treffer des Öffnungs-Tags wird zuerst der vorherige text-Block finalisiert, dann { type: "tool_use", partial: true } erstellt.
  • finalize partial:215 — nach Schleifenende werden verbleibende tool use- / text-Blöcke als partial zurückgepusht.
  • AssistantMessageContent:3 — Union-Typ TextStreamContent | ToolUse | ReasoningStreamContent.
  • toolParamNames:13 — Parameternamen-Whitelist (command, path, content, diff etc.); Nicht-Whitelist-Tags werden als Text behandelt.
  • ToolUse:63 — Werkzeugaufruf-Struktur, mit name, params, partial, call_id, isNativeToolCall, signature.
  • parseAssistantMessageV2 call:3508 — Aufrufstelle, bei jedem Text-Stück im Stream-Callback wird die gesamte Zeichenkette neu geparst.

Datenfluss

Die Hauptschleife des Parsers ist «zeichenweises Scannen: beim Öffnungs-Tag in den tool use-Zustand wechseln, beim Schließ-Tag zurück in den text-Zustand». Das folgende Stück ist der Kern, wenn man sich im tool use, aber nicht im Parameterwert-Zustand befindet, und prüft, ob ein neuer Parameter beginnt oder das Werkzeug geschlossen wird:

typescript
// apps/vscode/src/core/assistant-message/parse-assistant-message.ts
// --- State: Parsing a Tool Use (but not a specific parameter) ---
if (currentToolUse && !currentParamName) {
    // Check if starting a new parameter
    let startedNewParam = false
    for (const [tag, paramName] of toolParamOpenTags.entries()) {
        if (currentCharIndex >= tag.length - 1 && assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)) {
            currentParamName = paramName
            currentParamValueStart = currentCharIndex + 1 // Value starts after the tag
            startedNewParam = true
            break
        }
    }
    if (startedNewParam) {
        continue // Handled start of param, move to next char
    }

    // Check if closing the current tool use
    const toolCloseTag = `</${currentToolUse.name}>`
    if (
        currentCharIndex >= toolCloseTag.length - 1 &&
        assistantMessage.startsWith(toolCloseTag, currentCharIndex - toolCloseTag.length + 1)
    ) {
        // End of the tool use found
        // ... write_to_file content special handling ...
        currentToolUse.partial = false // Mark as complete
        contentBlocks.push(currentToolUse)
        currentToolUse = undefined // Reset state
        currentTextContentStart = currentCharIndex + 1 // Potential text starts after this tag
        continue
    }
    continue
}

Dieses Stück liegt nahe tool use state:78. Beachte, dass die Tag-Prüfung über assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1) erfolgt, was bedeutet: «Entspricht der Teilstring, der an der aktuellen Position endet, dem Tag». Das ist äquivalent zu assistantMessage.slice(i - tag.length + 1, i + 1) === tag, aber ohne echtes slice, mit besserer Performance. Der Aufrufer erhält nach parse site:3506 das neue Array, vergleicht prevLength und contentBlocks.length; bei Längenzunahme wird userMessageContentReady = false zurückgesetzt, damit presentAssistantMessage weiß, dass neuer Inhalt voranzutreiben ist. Nach Stream-Ende werden verbleibende partial-Blöcke per partial = false erzwungen finalisiert, damit der nachgelagerte Part finalisieren und die Rekursion auslösen kann (force partial false:3792).

Grenzen und Fehler

  • 未geschlossene Tags: Beim Stream-Abbruch hat der tool use kein </tool_name> erreicht; nach normalem Schleifenende wird er durch finalize als partial zurückgepusht (finalize partial:223); der nachgelagerte presentAssistantMessage sieht partial=true und führt das Werkzeug nicht wirklich aus, sondern wartet auf die nächste Runde.
  • write_to_file mit eingebettetem falschem Schließ-Tag: Wenn das Modell eine XML-Datei schreibt, sieht </content> innerhalb von <content> wie ein Schließ-Tag aus, ist aber Dateiinhalt. lastIndexOf sucht vom Ende des toolContentSlice rückwärts nach dem echten Schließ-Tag (lastIndexOf:119), um die äußerste Schließ-Position zu finden.
  • Unbekannter Werkzeugname: Tags außerhalb der Whitelist (z. B. ein halluziniertes <analyze_file>) werden als Plain-Text behandelt (toolParamNames whitelist:13); es wird kein ToolUse erstellt, sondern der Chat erhält nur einen Text mit spitzen Klammern.
  • Leerer content-Parameter: Das <content>-Tag von write_to_file kann aufgrund eines Parameter-Parse-Fehlers leer bleiben; beim Schließen des tool use wird noch einmal gescannt (content check:113), um content aus dem toolContentSlice herauszuschneiden und nachzufüllen.
  • Wiederholte Streaming-Aufrufe: Jedes Text-Stück parst die gesamte Zeichenkette neu; das bedeutet, dass bereits finalisierte tool use erneut geparst werden. Da das Schließ-Tag aber noch da ist, ist das Ergebnis konsistent. call_id wird mit nanoid(8) jedes Mal neu generiert (nanoid call_id:179), sodass derselbe tool use in mehreren Parses unterschiedliche call_id hat; der nachgelagerte presentAssistantMessage verfolgt aber über currentStreamingContentIndex nach Block-Position, nicht nach call_id, sodass es nicht zu Verwirrung kommt.
  • trailing whitespace: slice().trim() entfernt Whitespace an beiden Enden des Parameterwerts (trim value:68), sodass zusätzlich ausgegebene Zeilenumbrüche des Modells nicht path, command und andere Parameter verschmutzen.

Zusammenfassung

parseAssistantMessageV2 ist der Parsing-Kern von Clines «XML-Protokoll über Plain-Text»-Ansatz. Es nutzt einen ganzen Neuparse + End-Tag-Erkennung + vorab berechnete Map, um den O(n)-Einzelscan schnell genug zu machen; das partial-Flag durchgängig, sodass der nachgelagerte Part zwischen «gerade streamendem Halbprodukt» und «geschlossenem, ausführbarem Block» unterscheiden kann. Um zu sehen, wie die geparsten Blöcke in UI und Werkzeug-Executor gepusht werden, gehe zu /agent-loop/present-assistant-message; um zu sehen, wie die äußere Rekursion diese Parse-Schleife antreibt, gehe zu /agent-loop/recursion.

Siehe offizielle Dokumentation: Cline-Dokumentation · README