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