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。