跳到主要内容

Continue — Agent 主循环

本章讲整个 agent 的心脏:你一句话进来后,Continue 怎么在「调模型 ↔ 执行工具」之间循环,直到任务完成。读完你能讲清楚「一次工具调用从请求到生效」的完整路径。

1. 这一章解决的小问题

大模型本身只会出文本。让它「改文件」靠的是约定:模型在回复里输出结构化的 tool call(工具调用,如 Edit(file_path=..., old_string=..., new_string=...)),agent 程序把它执行掉,再把执行结果作为 tool_result 喂回去。问题是:模型是流式吐字的,工具调用也是一片片到的;而且一次任务往往要调好几轮工具。所以需要一个循环来驱动整件事。

2. 思路/直觉:一个会重复的「回合」

把一次 agent 任务想成下棋的多个回合:

回合 = 「让模型说话」+「执行模型要求的动作」

① 模型说话(流式)─┐
├─ 说话里有工具调用吗?
③ 执行工具 ◄───── 是
↓ │
把结果塞回历史 否 → 任务结束,返回最终文本

回到 ①(再来一回合)

只要模型还在要求调工具,就再来一回合;哪一回合模型只说话、不调工具,就说明它觉得活干完了,循环结束。

3. 主线走一遍(高层)

入口是 streamChatResponse()(extensions/cli/src/stream/streamChatResponse.ts:423)。它的 while (true) 每圈做这几件事:

  1. 刷新历史 + 取系统消息 + 取工具。注意工具是每圈重算的——这样用户在流式过程中切换权限模式(如从「问我」切到「自动」)能即时生效。见 streamChatResponse.ts:448-458
  2. 压缩前置检查:调模型前先看历史会不会超长,超了先压缩(见 04 章)。
  3. 流式调模型:processStreamingResponse() 把流拆成文本和工具调用。
  4. 处理工具调用:handleToolCalls() 执行工具、回填结果。
  5. 压缩后置检查 + 自动续跑判断
  6. 若模型这圈没要工具(shouldContinue 为 false)且不需自动续跑 → break

4. 核心机制

4.1 流式解析:边收边拆文本与工具调用

它要解决的小问题: 模型的输出是一串 chunk(数据块)流过来的,每个 chunk 可能带一点正文、也可能带一片工具调用参数(JSON 是逐字符拼出来的)。要边收边分拣。

原理演示(示意,非源码):

// 演示:从流式 chunk 里同时攒「正文」和「工具调用」
let text = "";
const toolCalls = new Map(); // id -> { name, argumentsStr }

for await (const chunk of stream) {
const delta = chunk.choices[0].delta;
if (delta.content) text += delta.content; // 正文增量,直接显示
for (const tc of delta.tool_calls ?? []) { // 工具调用增量
const entry = toolCalls.get(tc.id) ?? { argumentsStr: "" };
entry.name ??= tc.function?.name;
entry.argumentsStr += tc.function?.arguments ?? ""; // JSON 一片片拼
toolCalls.set(tc.id, entry);
}
}
// 重点看:工具调用的 arguments 是分多个 chunk 累加出来的,流结束才完整

真实实现: 单个 chunk 的处理在 processChunk()(streamChatResponse.ts:140),正文走 processChunkContent、工具调用走 processToolCallDelta(均在 streamChatResponse.helpers.ts)。整个流的消费循环在 processStreamingResponse()for await (const chunk of streamWithBackoff)(streamChatResponse.ts:294)。流结束后还会过滤掉没拼出名字的残缺工具调用(streamChatResponse.ts:396-407)。

关键细节: 工具调用增量用 index → id 映射来对齐(indexToIdMap),因为有些模型先给 index 后给 id。

4.2 把工具调用写进历史并执行

它要解决的小问题: 模型这圈给了 N 个工具调用,要(a)把「assistant 说了什么 + 要调哪些工具」记进历史,(b)逐个执行,(c)把每个结果作为 tool_result 记回去——顺序和配对不能乱,否则下一轮模型看到的对话结构是坏的。

流程:

handleToolCalls()

├─ 没有工具调用? → 只把 assistant 正文写进历史,return false(循环会停)

├─ 有工具调用:
│ 1. 把 assistant 消息(含 toolCalls)写进历史
│ 2. preprocessStreamedToolCalls() 预处理 + 权限确认
│ 3. executeStreamedToolCalls() 真正执行(可并行)
│ 4. 结果在执行内部就 addToolResult() 回填
└─ headless 模式下若有拒绝 → return true(提前结束)

真实实现: handleToolCalls()(extensions/cli/src/stream/handleToolCalls.ts:36)。它把内部 ToolCall[] 转成 ChatCompletion 风格的 toolCalls 再写进 ChatHistoryService(见 handleToolCalls.ts:80-107)。一个值得注意的注释:工具结果只在 executeStreamedToolCalls 内部通过 addToolResult() 写一次,外面不能再写,否则会产生重复的 tool_result 消息(handleToolCalls.ts:165-168)。

4.3 工具的并行执行

它要解决的小问题: 模型一圈可能同时要「读 3 个文件」。串行读慢,能并行就并行。

真实实现: executeStreamedToolCalls()(streamChatResponse.helpers.ts:469)把预处理过的调用包成一组 promise,最后 await Promise.all(execPromises)(streamChatResponse.helpers.ts:638)。它还把 parallelToolCallCount 传给每个工具——比如 Bash 工具会据此按比例缩小自己的输出截断上限,避免几个并行命令的输出加起来撑爆上下文(见 runTerminalCommand.ts:180-185)。

4.4 工具清单怎么来、怎么按权限过滤

它要解决的小问题: 不是所有工具任何时候都该给模型。比如某工具被用户设成「禁用」,就不该出现在发给模型的工具清单里。

真实实现: getRequestTools()(handleToolCalls.ts:172)先 getAllAvailableTools() 拿到全部可用工具,再逐个过 checkToolPermission():只有权限是 allow,或权限是 ask非 headless(非无人值守,即有人能现场点同意)的工具,才进清单(handleToolCalls.ts:186-192)。全部内置工具列在 ALL_BUILT_IN_TOOLS(extensions/cli/src/tools/allBuiltIns.ts:21):EditMultiEditReadWriteBashListSearch、子 agent、技能等。

5. 巧妙之处

  • 工具每圈重算,支持流式中途切模式。 大多数 agent 在一轮开始时锁死工具集;Continue 在 while 每圈都重新取 systemMessagetools(streamChatResponse.ts:448-458),所以用户在 agent 跑的过程中切换权限模式能即时影响下一圈能调哪些工具。

  • 压缩后自动注入「continue」续跑。handleAutoContinuation()(streamChatResponse.ts:95):如果这一圈发生了压缩、而模型恰好这圈不打算继续了,Continue 会自动追加一条 user 消息「continue」,让 agent 别因为压缩这个内部动作而误停。这是个很实际的体验补丁。

  • 残缺工具调用直接丢弃而非报错。 流断在工具调用名字还没拼出来时,processStreamingResponse 直接过滤掉它(streamChatResponse.ts:396-407),宁可少执行一个,也不把坏掉的调用塞给执行器。

6. 边界与局限

  • agent 主循环逻辑绑在 CLI 里(extensions/cli/src/stream/),不是 core/ 的共享件;VS Code/JetBrains 的对话循环走各自的壳。
  • 循环没有显式「最大轮数」硬上限——靠「模型不再调工具」自然收敛,加上上下文压缩兜底;理论上模型若一直要工具就会一直转。
  • headless(无人值守)模式下,凡是需要「问用户」的工具调用会被提前处理为拒绝/早返回(handleToolCalls.ts:158-163),因为没人能现场点同意。

7. 横向对比

几乎所有编码 agent 都是这套「LLM ↔ tool」循环;差异在细节:Continue 的特点是工具清单每圈重算 + 压缩后自动续跑。子 agent 隔离执行的取舍见 03 章末与 executeSubAgent

8. 代码地图

主题文件符号名
主循环extensions/cli/src/stream/streamChatResponse.tsstreamChatResponse
单次流式响应extensions/cli/src/stream/streamChatResponse.tsprocessStreamingResponse
chunk 处理extensions/cli/src/stream/streamChatResponse.tsprocessChunk
文本/工具增量extensions/cli/src/stream/streamChatResponse.helpers.tsprocessChunkContent / processToolCallDelta
工具执行(并行)extensions/cli/src/stream/streamChatResponse.helpers.tsexecuteStreamedToolCalls
工具调用入历史extensions/cli/src/stream/handleToolCalls.tshandleToolCalls
工具按权限过滤extensions/cli/src/stream/handleToolCalls.tsgetRequestTools
自动续跑extensions/cli/src/stream/streamChatResponse.tshandleAutoContinuation