parseAssistantMessageV2:LLM 流文字切塊器
職責
parseAssistantMessageV2 是 Cline 把 LLM 串流吐出的原始字串切成結構化 block 的解析器,寫在 apps/vscode/src/core/assistant-message/parse-assistant-message.ts。它的輸入是累積的 assistant 文字,輸出是 AssistantMessageContent[],每個元素是 TextStreamContent、ToolUse 或 ReasoningStreamContent 三種之一。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,長度變長就呼叫 scheduleAssistantPresentation 讓 presentAssistantMessage 推進。所以這個函式每秒可能被呼叫幾十次,必須是 O(n) 單次掃一遍。
設計動機
- 整段重解析而非增量:每收到一段新 text 就重跑整個
assistantMessage。看起來浪費,但因為 LLM 流可能在中途修復前面的標籤 (比如補</parameter>閉合),增量狀態機會被這種修復搞亂。整段重解析是最穩的做法,且 assistant 文字通常幾 KB,O(n) 掃一遍夠快。 endsWith風格的標籤探測:不預 tokenize,而是從 i=0 開始逐字元走,在每個位置檢查「以 i 結尾的子串是否匹配某個開/閉標籤」(close tag check:56)。這種寫法對 LLM 偶發多輸出/少輸出字元的容錯好,只要標籤整體能閉合就行。- 預計算開標籤 Map:
toolUseOpenTags和toolParamOpenTags都是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— 工具呼叫結構,帶name、params、partial、call_id、isNativeToolCall、signature。parseAssistantMessageV2 call:3508— 呼叫點,流回呼裡每段 text 都重解析整段。
資料流
解析器主迴圈就是「逐字元掃,看到開標籤就切到 tool use 狀態,看到閉標籤就切回 text 狀態」。下面這段是在 tool use 但不在參數值時,檢查是否開始新參數或閉合工具的核心:
// 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 拿到新陣列後,比較 prevLength 和 contentBlocks.length,長度增加就重置 userMessageContentReady = false 讓 presentAssistantMessage 知道有新內容要推進。流結束後還殘留的 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_id用nanoid(8)每次重新生成 (nanoid call_id:179),所以同一段 tool use 在多次解析裡call_id不一樣,但下游presentAssistantMessage用currentStreamingContentIndex按 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。