Skip to content

parseAssistantMessageV2:LLM 流文字切塊器

源码版本v4.0.10

職責

parseAssistantMessageV2 是 Cline 把 LLM 串流吐出的原始字串切成結構化 block 的解析器,寫在 apps/vscode/src/core/assistant-message/parse-assistant-message.ts。它的輸入是累積的 assistant 文字,輸出是 AssistantMessageContent[],每個元素是 TextStreamContentToolUseReasoningStreamContent 三種之一。Cline 不依賴 LLM 的原生 tool_use API,而是用一種 XML 風格的標籤協議 (<read_file>...</read_file><write_to_file><content>...</content></write_to_file>) 讓模型在普通文字裡嵌工具呼叫,這個解析器就是把這種半結構化文字拆開的核心。

它在 agent loop 裡的位置很明確:recursivelyMakeClineRequests 拉起 attemptApiRequest 拿到流,流回呼每收到一段 text 就 assistantMessage += chunk.text 累積,然後立刻呼叫 parseAssistantMessageV2(assistantMessage) 重解析整段 (parseAssistantMessageV2 call:3508)。解析出的 block 陣列存進 taskState.assistantMessageContent,長度變長就呼叫 scheduleAssistantPresentationpresentAssistantMessage 推進。所以這個函式每秒可能被呼叫幾十次,必須是 O(n) 單次掃一遍。

設計動機

  • 整段重解析而非增量:每收到一段新 text 就重跑整個 assistantMessage。看起來浪費,但因為 LLM 流可能在中途修復前面的標籤 (比如補 </parameter> 閉合),增量狀態機會被這種修復搞亂。整段重解析是最穩的做法,且 assistant 文字通常幾 KB,O(n) 掃一遍夠快。
  • endsWith 風格的標籤探測:不預 tokenize,而是從 i=0 開始逐字元走,在每個位置檢查「以 i 結尾的子串是否匹配某個開/閉標籤」(close tag check:56)。這種寫法對 LLM 偶發多輸出/少輸出字元的容錯好,只要標籤整體能閉合就行。
  • 預計算開標籤 Map:toolUseOpenTagstoolParamOpenTags 都是 Map<string, name>,迴圈外一次性建好 (precompute maps:38),迴圈裡只查 Map 不掃陣列。工具名和參數名都來自 getToolUseNames() / toolParamNames 的白名單,非白名單標籤當文字處理。
  • partial 標誌貫穿:正在串流寫入、還沒看到閉合標籤的 block 標 partial: true (partial true:178),下游 presentAssistantMessage 拿到 partial block 時知道它還不完整,只做增量 UI 更新不真正執行工具。流結束後 partial 強制置 false 讓下游能 finalize。
  • write_to_file 的 content 特殊處理:write_to_file<content> 參數可能包含看起來像閉合標籤的程式碼 (比如讓模型寫一個 XML 檔案),indexOf + lastIndexOf 用首尾錨點定位真實閉合位置 (content lastIndexOf:119),防止中間的假閉合標籤截斷內容。
  • 流末尾的 finalize:迴圈正常結束後,如果還殘留未閉合的 tool use 或 text,把它們當作 partial 推進 contentBlocks (finalize partial:223)。這樣流中斷時下游也能拿到半成品。

關鍵檔案

  • parseAssistantMessageV2:28 — 函式入口,簽名為 (assistantMessage: string) => AssistantMessageContent[]
  • precompute maps:38 — 把 <tool_name><param_name> 標籤預建成 Map,迴圈內 O(1) 查。
  • param state:52 — 在參數值狀態時,檢查當前位置是否是 </param_name> 閉合標籤。
  • tool use state:78 — 在 tool use 但不在參數值狀態時,檢查是否開始新參數或閉合工具。
  • tool close tag:95 — 命中 </tool_name> 時,把 tool 標記為 partial: false 推進 contentBlocks。
  • content special:111 — write_to_file 的 content 參數用 lastIndexOf 找真實閉合,防止中間假閉合標籤。
  • text state:138 — 既不在 tool use 也不在參數時,在文字狀態掃,檢查是否開始新工具。
  • new tool use:174 — 命中開標籤時,先 finalize 前面的 text block,再建立 { type: "tool_use", partial: true }
  • finalize partial:215 — 迴圈結束後,殘留的 tool use / text block 當 partial 推回。
  • AssistantMessageContent:3 — 聯合型別 TextStreamContent | ToolUse | ReasoningStreamContent
  • toolParamNames:13 — 參數名白名單 (command、path、content、diff 等),非白名單標籤當文字。
  • ToolUse:63 — 工具呼叫結構,帶 nameparamspartialcall_idisNativeToolCallsignature
  • parseAssistantMessageV2 call:3508 — 呼叫點,流回呼裡每段 text 都重解析整段。

資料流

解析器主迴圈就是「逐字元掃,看到開標籤就切到 tool use 狀態,看到閉標籤就切回 text 狀態」。下面這段是在 tool use 但不在參數值時,檢查是否開始新參數或閉合工具的核心:

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 特殊處理 ...
        currentToolUse.partial = false // Mark as complete
        contentBlocks.push(currentToolUse)
        currentToolUse = undefined // Reset state
        currentTextContentStart = currentCharIndex + 1 // Potential text starts after this tag
        continue
    }
    continue
}

這段在 tool use state:78 附近。注意檢查標籤的方式是 assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1),意思是「以當前位置為末尾的子串是否等於 tag」。這等價於 assistantMessage.slice(i - tag.length + 1, i + 1) === tag,但不用真的 slice,效能更好。呼叫方在 parse site:3506 拿到新陣列後,比較 prevLengthcontentBlocks.length,長度增加就重置 userMessageContentReady = falsepresentAssistantMessage 知道有新內容要推進。流結束後還殘留的 partial block 會強制 partial = false,讓下游能 finalize 並觸發遞迴 (force partial false:3792)。

邊界與失敗

  • 未閉合標籤:流中斷時 tool use 沒等到 </tool_name>,迴圈正常結束後 finalize 把它當 partial 推回 (finalize partial:223),下游 presentAssistantMessage 看到 partial=true 不會真執行工具,等下一輪重試。
  • write_to_file 內嵌假閉合標籤:模型讓寫 XML 檔案時,<content> 裡的 </content> 看起來是閉合標籤,實際是檔案內容。lastIndexOf 從 toolContentSlice 末尾往前找真實閉合 (lastIndexOf:119),保證拿到最外層的閉合位置。
  • 未知工具名:白名單外 (比如模型幻覺出 <analyze_file>) 的標籤當純文字處理 (toolParamNames whitelist:13),不會建立 ToolUse,只是 chat 裡多一段帶尖括號的文字。
  • 空 content 參數:write_to_file<content> 標籤可能因為參數解析漏掉沒填值,tool use 閉合時再掃一次 (content check:113),從 toolContentSlice 裡把 content 切出來補上。
  • 串流重複呼叫:每段 text 都重解析整段,這意味著前面已經 finalized 的 tool use 會被重新解析一次,但因為閉標籤還在,結果一致。call_idnanoid(8) 每次重新生成 (nanoid call_id:179),所以同一段 tool use 在多次解析裡 call_id 不一樣,但下游 presentAssistantMessagecurrentStreamingContentIndex 按 block 位置而非 call_id 追蹤,不會錯亂。
  • trailing whitespace:slice().trim() 把參數值兩端空白去掉 (trim value:68),模型多輸出的換行不會汙染 path、command 等參數。

小結

parseAssistantMessageV2 是 Cline 「XML 協議 over 純文字」方案的解析核心。它用整段重解析 + 末尾標籤探測 + 預計算 Map 把 O(n) 單次掃描跑得足夠快,partial 標誌貫穿讓下游能區分「正在流的半成品」和「已閉合的可執行 block」。要看解析出的 block 怎麼被推進 UI 和工具執行器,轉 /agent-loop/present-assistant-message;看更外層遞迴怎麼驅動這個解析迴圈,轉 /agent-loop/recursion

對照官方資料:Cline 文件 · README