数据截至 (上游 commit d87b272aec54)
主循环:一次输入怎么走完「模型说话 → 跑工具 → 结果回灌」
30 秒导读: 你在终端敲一句「帮我把这个函数改成异步的」,Qwen Code 要把它变成「问模型 → 模型说要读文件 → 真去读 → 把内容喂回模型 → 模型说要改 → 真去改 → …… → 模型说完了」。本章把这条端到端链路拆开:谁在转圈、圈在哪一层转、工具怎么被调度、结果怎么变回一条消息、以及模型抽风时哪四道闸拦得住它。
1. 先建直觉:「一轮」和「一次交互」不是一回事
这个项目里三个近义词各指不同的东西,先分清,后面才不绕:
| 说法 | 指什么 | 边界在哪 |
|---|---|---|
| 一次 round-trip | 一次 HTTP 请求 + 一条流式响应 | Turn.run 从开始到 Finished 事件 |
| 一次 turn(回合) | 一次 round-trip + 它引出的工具执行 | 工具跑完、结果回灌为止 |
| 一次交互 | 用户敲一句话 → agent 彻底停下 | 可能包含几十次 round-trip |
关键:模型一次只能"说"一段话。 它说不出「我读了文件,内容是这 样,所以我要这样改」——它只能说「我要读这个文件」,然后闭嘴。剩下的活(真去读、把内容拼成一条新消息、再问一次)全是 agent 框架的。
所以主循环的骨架就三步,反复转:
用户输入
│
▼
┌────────────┐ 模型只说话 ┌──────────┐
│ 问一次模型 │ ───────────────► │ 结束 │
└────────────┘ └──────────┘
│ 模型要调工具
▼
┌────────────┐ ┌──────────────────┐
│ 执行工具 │ ───► │ 结果拼成一条消息 │──┐
└────────────┘ └──────────────────┘ │
▲ │
└────────┘ 回到"问一次模型"
怎么读:从上往下是一次 round-trip;只有"模型要调工具"这条支路会绕回顶部,绕一次就是多一次 round-trip。
2. 顶层全景:谁在转这个圈
2.1 一张图看清分工
┌──── 入口层(谁在转圈)─────────────────────────────┐
│ gemini.tsx ─┬─► startInteractiveUI (ink TUI) │
│ └─► runNonInteractive (headless) │
└────────────────────┬─────────────────────────────┘
│ 每轮调一次
▼
┌──── core 主链路(一轮做完就返回)──────────────────┐
│ GeminiClient.sendMessageStream │
│ └─► Turn.run ──► GeminiChat.sendMessage │
│ Stream ──► 真 HTTP + 重试 │
└────────────────────┬─────────────────────────────┘
│ 吐出 ToolCallRequest 事件
▼
┌──── CoreToolScheduler ────┐
│ 校验 → 授权 → 执行 → 汇总 │
└───────────────────────────┘
怎么读:上到下是调用方向;箭头回不去——core 不会自己转圈,转圈的是最上面那层。这是本章最重要的一句话,下一节展开。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪 |
|---|---|---|
startInteractiveUI | 拉起 ink TUI,React 树里驱动循环 | packages/cli/src/gemini.tsx:878 |
runNonInteractive | headless -p 模式,一个 while (true) 转到底 | packages/cli/src/nonInteractiveCli.ts:293 |
useGeminiStream | UI 侧消费事件流、派工具、回灌结果 | packages/cli/src/ui/hooks/useGeminiStream.ts |
GeminiClient.sendMessageStream | 一轮的前处理/后处理 + 两处自动续写 | packages/core/src/core/client.ts:1815 |
Turn.run | 把底层 chunk 翻译成统一的语义事件 | packages/core/src/core/turn.ts:387 |
GeminiChat.sendMessageStream | 真正打 API,藏起压缩与四类重试 | packages/core/src/core/geminiChat.ts:1836 |
CoreToolScheduler | 工具调用的七态状态机 | packages/core/src/core/coreToolScheduler.ts:1080 |
2.3 最反直觉的一点:core 里没有 while
很多编码 agent 把 while (true) 写在 core 里。Qwen Code 没有。
GeminiClient.sendMessageStream 是一个异步生成器,跑完一轮就 return turn(client.ts:2830)。它内部只有两处会"再来一轮",而且都是递归调自己、都不是为了送工具结果:
| 递归点 | 触发条件 | 代码位置 |
|---|---|---|
| Stop 钩子续写 | Stop hook 返回 blocking 决策,把 hook 给的理由当新 prompt | client.ts:2707 |
| 下一发言人续写 | 模型话没说完(checkNextSpeaker 判为 model),补一句 "Please continue." | client.ts:2772、client.ts:2793 |
工具结果的回灌,一次都不在 core 里。 它由入口层完成:TUI 靠 handleCompletedTools,headless 靠 while (true)。这么切的好处很实在——TUI 需要在工具跑到一半时渲染进度、需要等用户点确认、需要允许用户在工具执行期间插话,这些都是 React 世界的事;把循环留在 UI 层,core 就不必知道有没有人在看屏幕。
递归深度靠 MAX_TURNS = 100(client.ts:144)兜底,每递归一层预算减一。
3. 三层链路,逐层拆
3.1 Turn.run —— 把 chunk 翻译成语义事件
它要解决的小问题: 上游吐的是一串 GenerateContentResponse chunk,里面文本、思考、函数调用、引用混在一起。上层不该关心这个格式。
思路: Turn.run 是一台翻译机——收 chunk,吐一个判别联合类型 ServerGeminiStreamEvent(turn.ts:353),成员由 GeminiEventType(turn.ts:53)枚举。上层只要 switch 一下。
主循环真正在意的是这七种:
| 事件 | 含义 | 上层怎么响应 |
|---|---|---|
Content | 一段可见文本 | 追加到助手气泡 |
Thought | 一段推理内容 | 渲染成折叠的"思考中" |
ToolCallRequest | 模型要调某个工具 | 收集起来,流结束后统一派给调度器 |
Finished | 本次 round-trip 收尾 | 记录 finishReason 与 token 用量 |
Error | API 报错 | 展示错误 + 提供 Ctrl+Y 重试 |
UserCancelled | 用户中断 | 丢弃本轮累积状态 |
LoopDetected | 判定模型在打转 | 立刻停,不再派工具 |
ToolCallRequest 由 handlePendingFunctionCall(turn.ts:541)产出:它给每个 functionCall 兜底生成 callId(模型没给 id 时用 name-时间戳-随机),同时把请求压进 this.pendingToolCalls,供上层判断"这轮到底有没有活要干"。
有两个事件不是 chunk 翻译来的,而是把下层信号桥接上来:
streamEvent.type === 'retry'(turn.ts:414):清空pendingToolCalls、pendingCitations、finishReason后再转发。不清空的话,重试重放的工具调用会和上一次的叠在一起,变成重复执行。streamEvent.type === 'compressed'(turn.ts:431):把GeminiChat内部触发的自动压缩,冒泡成顶层ChatCompressed事件(压缩本身见 05 上下文工程)。
还有一个细节值得记:finishReason === MAX_TOKENS 时,会给本轮所有 pending 工具调用打上 wasOutputTruncated = true(turn.ts:483)。这个标记后面在调度器里被用来拒绝执行被截断的编辑类工具——参数写了一半就落盘,比不执行更糟。
3.2 GeminiChat.sendMessageStream —— 真正打 API,把重试藏起来
它要解决的小问题: 网络会断、模型会吐坏数据、上下文会超长。 上层不该为每种失败写一遍恢复逻辑。
它对外只吐三种事件(StreamEventType,geminiChat.ts:232):
| 事件 | 含义 |
|---|---|
CHUNK | 一块正常响应 |
RETRY | 上一次尝试作废,丢掉已渲染的半截内容 |
COMPRESSED | 发送前触发了自动压缩 |
发送前做两件必须按顺序的事(geminiChat.ts:2035、:1929):先把用户内容 push 进 history,然后才跑孤儿修复。顺序反了会出错——用户这次提交的可能正好是上一轮欠的 tool_result,先 push 才能让它自然配对,否则修复过程会合成一条多余的错误响应(详见 §6.3)。
发送时是一个 for (let attempt = 0; ...) 重试循环(geminiChat.ts:2124)。妙处在于四类失败各有独立预算,互不侵占:
| 失败类型 | 判据 | 是否消耗内容重试预算 |
|---|---|---|
| 限流 | isRateLimitError | 否(attempt-- 抵消) |
| 传输层断流 | 错误码命中 RETRYABLE_STREAM_TRANSPORT_CODES | 否 |
| 上下文超长 | getContextLengthExceededInfo(geminiChat.ts:2334) | 否,且只反应式压缩一次 |
| 流内容异常 | InvalidStreamError | 是 |
失败重试有个共同前置动作 popPartialIfPushed(geminiChat.ts:2169):如果上一次尝试已经把半截助手回复写进了 history,必须先把它摘掉。不摘的话,重试的响应会变成连续第二条 model 消息,且第一条里躺着一个没人应答的 tool_use——注释里管这叫"the wedge"(楔子),是这套链路最难查的一类死锁。
3.3 GeminiClient.sendMessageStream —— 一轮的前后处理
这层不碰 HTTP,它管的是"这一轮属于什么性质、要不要放行、结束后要不要自动续"。
性质由 SendMessageType(client.ts:147)标定,不同类型走不同的前处理:
| 类型 | 什么时候用 | 特殊待遇 |
|---|---|---|
UserQuery | 用户真的敲了一句话 | 触发 UserPromptSubmit 钩子、重置循环检测器、打文件快照、注入日期提醒 |
ToolResult | 送工具结果回去 | 跳过 IDE 上下文注入(避免拆散 functionCall/functionResponse 配对) |
Retry | Ctrl+Y 重发 | 先剥掉 history 里孤立的用户条目,失败则原样放回 |
Hook | Stop 钩子或续写触发 | 不计入用户提问计数 |
Cron / Notification / Teammate | 定时、后台代理、队友消息 | 按顶层交互处理但不算用户提问 |
Retry 那条"剥了再放回"的逻辑(client.ts:1854 剥、client.ts:2832 放回)判据很讲究:不看 history 长度,看推送计数器。因为自动压缩会在推送前把 history 缩短,用长度判断会误判成"没推上去",于是把同一条提 示重复放回,用户看到自己的话出现两遍。
流跑起来后(client.ts:2393 的 turn.run),每个事件都要过两道循环检测闸(§6.4),然后才 yield 给上层。
4. 工具调度:CoreToolScheduler 的七态状态机
4.1 七个状态,各自意味着什么
ToolCall 是七个类型的判别联合(coreToolScheduler.ts:326-420):
| 状态 | 字面意思 | 携带什么关键字段 |
|---|---|---|
validating | 参数校验通过,正在过权限流程 | invocation、startTime |
awaiting_approval | 卡在用户确认框上 | confirmationDetails |
scheduled | 放行了,排队等执行 | — |
executing | 正在跑 | liveOutput、executionStartTime、pid |
success | 跑完了 | response、durationMs |
error | 失败/被拒/工具不存在 | response(含 errorType) |
cancelled | 用户中断或拒绝 | response、durationMs |
后三个是终态。setStatusInternal(coreToolScheduler.ts:1175)开头就守着这条不变式:已经是终态的调用,任何后续转移一律忽略。这让并发场景下的重复回调天然幂等。
4.2 状态怎么流转
ToolCallRequest
│
▼
┌──────────┐ 权限需要问人 ┌───────────────────┐
│validating│ ──────────────► │ awaiting_approval │
└──────────┘ └─────────┬─────────┘
│ 自动放行 批准 │ 拒绝
│ ┌────────────┘ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│scheduled │ ─────► │executing │ ───────► │ success │
└──────────┘ └──────────┘ │ /error │
│/cancelled│
└──────────┘
怎么读:左到右是正常推进;只有权限判定会把流程岔到上面那条"问人"支路。权限怎么判、AUTO 模式分类器怎么工作,是 04 安全护栏 的内容,本章只认这个岔口存在(岔口代码在
coreToolScheduler.ts:2143的evaluatePermissionFlow)。
有几类请求根本进不了 validating,_schedule(coreToolScheduler.ts:1874)会直接把它们造成 error 态:
- 权限管理器判定工具被禁用 →
EXECUTION_DENIED - 工具不在注册表里(模型幻觉出的名字)→
TOOL_NOT_REGISTERED,并附上拼写建议 wasOutputTruncated+ 编辑类工具 →OUTPUT_TRUNCATED,宁可不改也不写半截内容- 参数不符合 schema →
INVALID_TOOL_PARAMS
最后两类还会累加 (工具名, 错误消息) 维度的重试计数(recordRetryableToolError,coreToolScheduler.ts:1859)。同一个错误犯到第 3 次(VALIDATION_RETRY_LOOP_THRESHOLD,coreToolScheduler.ts:799),错误消息里会追加一句"别再试了"的指令喂给模型——用提示词而不是硬中断来打断死循环。
4.3 批与并发:安全的并行,危险的串行
工具不是一个个跑,也不是全部并行。attemptExecutionOfScheduledCalls(coreToolScheduler.ts:3051)先确认所有调用都到了 scheduled 或终态(也就是没人还卡在确认框上),再把它们切成批:
[Read, Read, Edit, Read]
│
▼ partitionToolCalls
[Read,Read] (并行) → [Edit] (串行) → [Read] (串行)
怎么读:连续的"安全"工具合并成一个并行批;每个"不安全"工具自成一批。切完按批顺序执行,批内才并发。
安全的定义在 isConcurrencySafe(coreToolScheduler.ts:1039):
Read/Search/Fetch三种 kind 无条件安全(tools/tools.ts:887)agent工具安全——子代理各跑各的,无共享状态(见 06 多智能体)- shell 命令看命令内容:同步正则检查器判定为只读(
git log、cat之类)才安全,看不懂的一律判不安全
最后这条是失败关闭(fail-closed):try/catch 里 catch 直接 return false。判断不了就串行,宁慢勿错。
并行批还有并发上限,默认 10,可用 QWEN_CODE_MAX_TOOL_CONCURRENCY 调(coreToolScheduler.ts:3093)。实现是经典的"滑动窗口":满了就 Promise.race 等一个先完成的腾位子(runConcurrently,coreToolScheduler.ts:3088)。
4.4 整批完成才回调,期间来的请求排队
checkAndNotifyCompletion(coreToolScheduler.ts:3973)在每次状态变更后被触发,但它只在所有调用都进终态时才动作:
- 清空
this.toolCalls,置isFinalizingToolCalls = true - 触发
PostToolBatch钩子(钩子可以整批叫停) - 跑批级输出预算裁剪
applyBatchOutputBudget——一批结果太大就把最长的那几个落盘换成引用 - 记录遥测、写会话记录
- 调
onAllToolCallsComplete(completedCalls)—— 这一步才把球传回上层 finally里放行队列:requestQueue里若有排队的批,取一个继续_schedule
排队机制在 schedule()(coreToolScheduler.ts:1801):调度器正忙时,新请求不是报错也不是并发插队,而是挂进队列并返回一个 Promise,同时挂一个 abort 监听器——用户中途取消时能把自己从队列里摘掉。
4.5 非交互模式:同一台状态机,只装一个工具
headless 不想要批语义,于是 executeToolCall(core/nonInteractiveToolExecutor.ts:28)包了一层薄壳:每次 new 一台全新的 CoreToolScheduler,只塞一个请求,在 onAllToolCallsComplete 里 resolve(completedToolCalls[0].response)。
// 示意,非源码:headless 侧把"批"退化成"单个"
function executeToolCall(config, request, signal) {
return new Promise((resolve, reject) => {
new CoreToolScheduler({
config,
onAllToolCallsComplete: async (done) => resolve(done[0].response), // 批里只有一个
getPreferredEditor: () => undefined, // 没有编辑器可开
onEditorClose: () => {},
}).schedule(request, signal).catch(reject);
});
}
重点看两处:getPreferredEditor 返回 undefined(headless 没有交互式 diff 可开),以及"批"被硬编码成 [0]。权限、钩子、遥测、输出裁剪全都复用同一条路径——两种模式行为一致,靠的是共用状态机而不是两套代码。