跳到主要内容

数据截至 (上游 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
runNonInteractiveheadless -p 模式,一个 while (true) 转到底packages/cli/src/nonInteractiveCli.ts:293
useGeminiStreamUI 侧消费事件流、派工具、回灌结果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 给的理由当新 promptclient.ts:2707
下一发言人续写模型话没说完(checkNextSpeaker 判为 model),补一句 "Please continue."client.ts:2772client.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 用量
ErrorAPI 报错展示错误 + 提供 Ctrl+Y 重试
UserCancelled用户中断丢弃本轮累积状态
LoopDetected判定模型在打转立刻停,不再派工具

ToolCallRequesthandlePendingFunctionCall(turn.ts:541)产出:它给每个 functionCall 兜底生成 callId(模型没给 id 时用 name-时间戳-随机),同时把请求压进 this.pendingToolCalls,供上层判断"这轮到底有没有活要干"。

有两个事件不是 chunk 翻译来的,而是把下层信号桥接上来:

  • streamEvent.type === 'retry'(turn.ts:414):清空 pendingToolCallspendingCitationsfinishReason 后再转发。不清空的话,重试重放的工具调用会和上一次的叠在一起,变成重复执行。
  • 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 配对)
RetryCtrl+Y 重发先剥掉 history 里孤立的用户条目,失败则原样放回
HookStop 钩子或续写触发不计入用户提问计数
Cron / Notification / Teammate定时、后台代理、队友消息按顶层交互处理但不算用户提问

Retry 那条"剥了再放回"的逻辑(client.ts:1854 剥、client.ts:2832 放回)判据很讲究:不看 history 长度,看推送计数器。因为自动压缩会在推送前把 history 缩短,用长度判断会误判成"没推上去",于是把同一条提示重复放回,用户看到自己的话出现两遍。

流跑起来后(client.ts:2393turn.run),每个事件都要过两道循环检测闸(§6.4),然后才 yield 给上层。


4. 工具调度:CoreToolScheduler 的七态状态机

4.1 七个状态,各自意味着什么

ToolCall 是七个类型的判别联合(coreToolScheduler.ts:326-420):

状态字面意思携带什么关键字段
validating参数校验通过,正在过权限流程invocationstartTime
awaiting_approval卡在用户确认框上confirmationDetails
scheduled放行了,排队等执行
executing正在跑liveOutputexecutionStartTimepid
success跑完了responsedurationMs
error失败/被拒/工具不存在response(含 errorType)
cancelled用户中断或拒绝responsedurationMs

后三个是终态。setStatusInternal(coreToolScheduler.ts:1175)开头就守着这条不变式:已经是终态的调用,任何后续转移一律忽略。这让并发场景下的重复回调天然幂等。

4.2 状态怎么流转

ToolCallRequest


┌──────────┐ 权限需要问人 ┌───────────────────┐
│validating│ ──────────────► │ awaiting_approval │
└──────────┘ └─────────┬─────────┘
│ 自动放行 批准 │ 拒绝
│ ┌────────────┘ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│scheduled │ ─────► │executing │ ───────► │ success │
└──────────┘ └──────────┘ │ /error │
│/cancelled│
└──────────┘

怎么读:左到右是正常推进;只有权限判定会把流程岔到上面那条"问人"支路。权限怎么判、AUTO 模式分类器怎么工作,是 04 安全护栏 的内容,本章只认这个岔口存在(岔口代码在 coreToolScheduler.ts:2143evaluatePermissionFlow)。

有几类请求根本进不了 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 logcat 之类)才安全,看不懂的一律判不安全

最后这条是失败关闭(fail-closed):try/catchcatch 直接 return false。判断不了就串行,宁慢勿错。

并行批还有并发上限,默认 10,可用 QWEN_CODE_MAX_TOOL_CONCURRENCY 调(coreToolScheduler.ts:3093)。实现是经典的"滑动窗口":满了就 Promise.race 等一个先完成的腾位子(runConcurrently,coreToolScheduler.ts:3088)。

4.4 整批完成才回调,期间来的请求排队

checkAndNotifyCompletion(coreToolScheduler.ts:3973)在每次状态变更后被触发,但它只在所有调用都进终态时才动作:

  1. 清空 this.toolCalls,置 isFinalizingToolCalls = true
  2. 触发 PostToolBatch 钩子(钩子可以整批叫停)
  3. 跑批级输出预算裁剪 applyBatchOutputBudget——一批结果太大就把最长的那几个落盘换成引用
  4. 记录遥测、写会话记录
  5. onAllToolCallsComplete(completedCalls) —— 这一步才把球传回上层
  6. finally 里放行队列:requestQueue 里若有排队的批,取一个继续 _schedule

排队机制在 schedule()(coreToolScheduler.ts:1801):调度器正忙时,新请求不是报错也不是并发插队,而是挂进队列并返回一个 Promise,同时挂一个 abort 监听器——用户中途取消时能把自己从队列里摘掉。

4.5 非交互模式:同一台状态机,只装一个工具

headless 不想要批语义,于是 executeToolCall(core/nonInteractiveToolExecutor.ts:28)包了一层薄壳:每次 new 一台全新的 CoreToolScheduler,只塞一个请求,在 onAllToolCallsCompleteresolve(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]权限、钩子、遥测、输出裁剪全都复用同一条路径——两种模式行为一致,靠的是共用状态机而不是两套代码。


5. 结果回灌:工具输出怎么变回一条 user 消息

5.1 统一的落点:convertToFunctionResponse

工具返回的东西五花八门:字符串、Part[]、图片 inlineData、甚至已经是 functionResponseconvertToFunctionResponse(coreToolScheduler.ts:689)负责把它们都归一成 functionResponse part。

两条值得记的规则:

  • 文本和媒体都塞进同一个 functionResponse,不外挂——外挂的 part 在多协议转换时容易掉队。
  • 拿不到任何文本时兜底成 'Tool execution succeeded.',绝不返回空。空的 tool_result 会让部分后端直接 400。

5.2 交互式:React 回调驱动

链路是这样接起来的:

processGeminiStreamEvents 收集 ToolCallRequest
│ (useGeminiStream.ts:1691)

scheduleToolCalls(...) → CoreToolScheduler 干活
│ (useGeminiStream.ts:2059)

allToolCallsCompleteHandler → handleCompletedTools
│ (useReactToolScheduler.ts:129 → useGeminiStream.ts:2526)

submitQuery(responseParts, SendMessageType.ToolResult, promptId)
(useGeminiStream.ts:3002) ── 这就是回灌,圈闭合

useReactToolScheduler(useReactToolScheduler.ts:103)在中间做的是双状态同步:core 的 ToolCall 是权威状态,React 侧再叠一层 UI-only 字段(liveOutputresponseSubmittedToGemini)。非 executing 状态时它会显式把 liveOutput / pid 清成 undefined——防止执行态的残留泄漏到完成态,表现为"卡住不动的 PID"。

5.3 headless:一个诚实的 while (true)

nonInteractiveCli.ts:1314 起就是教科书式的写法,主干只有几步:

// 示意,非源码:headless 主循环的骨架
while (true) {
turnCount++; // 会话回合计数,超限报错
const sendType = isFirstTurn ? UserQuery : ToolResult;
const stream = geminiClient.sendMessageStream(currentMessages[0].parts, signal, promptId, { type: sendType });

const toolCallRequests = [];
for await (const event of stream) { /* 收集 ToolCallRequest / 检测 LoopDetected */ }

if (toolCallRequests.length === 0) break; // 模型没活干了,收工
const parts = await processToolCallBatch(toolCallRequests);
currentMessages = [{ role: 'user', parts }]; // ★ 结果变成下一轮的输入
}

重点看带 ★ 的那行(真实代码在 nonInteractiveCli.ts:1480):工具结果被包成一条 role: 'user' 的消息。这就是整个 agent loop 的物理本质——所谓"回灌",就是把工具输出伪装成用户又说了一句话。

出口条件也就一个:toolCallRequests.length === 0

5.4 一个真实的坑:重复的 provider tool-call id

某些后端会把同一个 tool-call id 重放两次。TUI 侧为此在 processGeminiStreamEvents 里做了两道拦截(useGeminiStream.ts:2146-2222):

  1. 查 history 里已经有 functionResponse 的 callId 集合,命中就不执行,直接合成一个"重复调用"响应发回去——必须发,否则那个 tool_use 永远悬着。
  2. 同一批里若反复出现同一个重复 id,判定为循环,整批丢弃。

6. 四道健壮性护栏

6.1 打断与续跑:turn-interruption.ts

问题: 进程崩了 / 用户 Ctrl+C 了,history 停在一个不合法的形状上。下次启动怎么接?

detectTurnInterruption(core/turn-interruption.ts:61)是个纯读函数,只看 history 尾巴就能分出三类:

尾巴形状分类怎么续
尾部是若干条非结构性 user 条目interrupted_promptRetry 语义重发这些 parts,转录里不会多出一条用户消息
尾部是带未应答 functionCallmodel 条目interrupted_turn给每个悬空调用合成一条 error functionResponse,以 ToolResult 提交
其它(干净的文本结尾、纯 system-reminder 尾、空 history)none没得续

调用点在 nonInteractiveCli.ts:614(--continue 路径)。注释里明说了一个已知盲区:流到一半被截断的文本尾巴,和正常说完是分不出来的——没有持久化的 stop_reason 元数据就判不了,所以归入 none。这种"看不出来就说看不出来"的诚实,比硬猜更值得学。

第二类走的是"合成错误响应"而不是"造一句假的用户提示",理由是 functionResponse 本身就是合法的续跑信号,不必往对话里塞人造文本。

6.2 流传输重试:stream-transport-retry.ts

整个文件只有一个白名单常量 RETRYABLE_STREAM_TRANSPORT_CODES(core/stream-transport-retry.ts:10),六个错误码:ECONNRESETETIMEDOUTUND_ERR_BODY_TIMEOUTUND_ERR_CONNECT_TIMEOUTUND_ERR_HEADERS_TIMEOUTUND_ERR_SOCKET

单独拆文件的理由写在注释里:geminiChat.ts 会从包的 barrel 导出,而这条重试策略不该成为公开 API

判定条件比白名单更严(geminiChat.ts:2284):必须一个 chunk 都还没吐给调用方(!streamYieldedChunk)才重放。吐过了再重放,用户会看到同一段话出现两遍。

6.3 孤儿 tool_use 修复:repairOrphanedToolUseTurns

问题: history 里有 functionCall 却没有对应的 functionResponse,很多后端会直接拒绝整段对话。

repairOrphanedToolUseTurns(geminiChat.ts:1387)正向遍历 history,对每个 model 轮做三步,且三步拆成三个函数:

scanModelTurn → 找出这一轮期望被应答的 callId


planRepair → 算出:哪些要合成、哪些要搬位置、哪些是重复要删


applyRepair → 唯一会改 history 的函数

怎么读:从上到下是三个阶段;只有最后一格碰 history。这种切法的收益写在源码注释里——索引漂移的 bug 只可能出在最后一格,审计范围直接缩到一个函数。

applyRepair 里的动作顺序也是有讲究的:先按降序删除(保证索引不失效),再清掉空掉的 user 轮,最后把合成的 functionResponse 插到相邻 user 轮的最前面(在第一个非 functionResponse part 之前)。最后这条是为了 Anthropic 兼容后端——它们要求 tool_result 块排在文本之前(见 02 多协议模型层)。

外层循环用 i += insertedBefore 跳过刚插入的轮,保持线性时间。

这个修复在三个地方跑:会话加载时、每次 sendMessageStream 推送用户内容之后(geminiChat.ts:2047)、以及 UI 侧 handleCompletedTools 的去重前置(useGeminiStream.ts:2744 一带)。第三处的存在是为了堵一个竞态:调度器结果还在飞的时候,history 里已经被别处种了合成响应——此时必须把真结果丢掉,否则那个工具会永远停在"完成但没提交"的状态。

6.4 循环检测:两档,一档关不掉

loopDetectionService.ts 把检测切成两档,client.ts 里按顺序过(client.ts:2406client.ts:2442):

每个事件

├─► checkAlwaysOnSafeties ← 永远跑,配置关不掉

└─► addAndCheckHeuristicLoops ← 被 model.skipLoopDetection 挡着(默认 true = 不跑)

永远开的三条:

守卫阈值常量
连续相同工具调用(名字 + 参数全同)5TOOL_CALL_LOOP_THRESHOLD(loopDetectionService.ts:34)
shell 巡检命令停滞8SHELL_COMMAND_STAGNATION_THRESHOLD(:57)
单回合工具调用硬上限100TURN_TOOL_CALL_CAP(:71)

第一条的注释说明了为什么它必须"永远开":DashScope 服务端自己也有重复工具调用检测,会用 400 拒掉整段对话(issue #5019)。客户端阈值故意压在服务端之下,让本地先断,用户至少还能救回会话。

默认关掉的启发式那档,包括内容重复(CONTENT_LOOP_THRESHOLD = 10,:29)、思考重复(3 次,:34)、读文件 churn(15 次窗口里 8 次,:47)、全局重复(6 次,:62)、AB 交替模式(3 个周期)。默认关的理由也写得很直白:这几条历史上误报太多。

读文件那档的阈值从 5/10 提到 8/15,注释给了具体场景:「summarize this project」这类提问,开头合法地就是一次 list_directory 加好几个并行 read_file——旧阈值会在 agent 第一个有效动作上就误判。再叠一层冷启动豁免:一个回合里如果从没出现过非读取类工具,就当探索期,检测器不激活。

还有一处细节:Retry 事件到来时,两档都要回滚计数(:231:279)。重试会重放上次的工具调用,不回滚就会 3 次 + 重试 + 3 次 = 触发阈值 6,凭空冤枉模型。


7. 巧妙之处(可以直接借鉴的)

其一,循环放在入口层,core 只跑一轮。 TUI 需要在工具执行途中渲染、等确认、允许插话;headless 只想要一个 while。把转圈职责上提,两种形态各写各的循环,共享同一套 core 而不必互相迁就(client.ts:1805nonInteractiveCli.ts:1314)。

其二,重试预算按失败类型分账。 限流、传输断流、上下文超长各有独立计数器,且用 attempt-- 抵消,都不吃"内容异常"的预算(geminiChat.ts:2124 起)。一次网络抖动不该消耗掉容忍模型吐坏数据的额度。

其三,重试前必须摘掉半截助手回复。 popPartialIfPushed(geminiChat.ts:2169)连"标记指向的位置已经不是 model 轮"这种理论上不可能的情况都留了 warn 日志——把不变式做成可观测的,而不是靠注释声明。

其四,并发判定失败关闭。 isConcurrencySafecatch 直接 return false(coreToolScheduler.ts:1052-1053)。判不了就串行,代价是慢一点;判错了并行,代价是数据损坏。

其五,扫描 / 决策 / 变更三段式。 孤儿修复拆成 scanModelTurnplanRepairapplyRepair,只有第三段碰 history。历史索引 bug 的排查范围直接收敛到一个函数。

其六,用提示词打断死循环,而不是硬中断。 同一个参数错误犯到第 3 次,错误消息里追加一句"停止重试"的指令(coreToolScheduler.ts:1859)——让模型自己改主意,比框架强行掐断更不容易破坏会话。


8. 边界与本章不讲的

这条链路的已知边界:

  • 流到一半被截断的文本尾巴无法与正常结束区分,所以续跑功能救不了它(turn-interruption.ts:32-35 的注释明说需要 provider 的 prefill 支持)。
  • --max-tool-calls 兜不住 structured_output 的校验重试循环:--json-schema 模式下这个工具被豁免计数,Ajv 校验失败的重试也跟着不计——源码注释建议叠加 --max-session-turns--max-wall-time(nonInteractiveCli.ts:1182-1186)。
  • 启发式循环检测默认是关的(model.skipLoopDetection 默认 true)。也就是说内容重复、读文件 churn 这类打转,开箱状态下不会被拦。
  • 重复 provider tool-call id 的第一次是"丢结果保配对":合成一个重复响应发回去,真实执行结果被丢弃。这是明确的权衡,不是 bug。

明确交给别的章节:

话题去哪
权限怎么判、AUTO 模式分类器、沙箱04 安全护栏
自动压缩、微压缩、系统提示与记忆05 上下文工程
OpenAI / Anthropic / Gemini 协议转换02 多协议模型层
工具怎么声明、按需披露03 工具层
子代理与工作流编排06 多智能体

9. 代码地图(导航索引)

主题文件路径符号名
交互式入口装配packages/cli/src/gemini.tsxmainstartInteractiveUI
headless 入口与主 whilepackages/cli/src/nonInteractiveCli.tsrunNonInteractiveprocessToolCallBatch
UI 侧事件消费packages/cli/src/ui/hooks/useGeminiStream.tsprocessGeminiStreamEventssubmitQueryhandleCompletedTools
UI ↔ core 调度器桥packages/cli/src/ui/hooks/useReactToolScheduler.tsuseReactToolSchedulerallToolCallsCompleteHandlermarkToolsAsSubmitted
一轮的前后处理与续写packages/core/src/core/client.tsGeminiClient.sendMessageStreamSendMessageTypeMAX_TURNS
chunk → 语义事件翻译packages/core/src/core/turn.tsTurn.runGeminiEventTypeServerGeminiStreamEventhandlePendingFunctionCall
真 HTTP + 四类重试packages/core/src/core/geminiChat.tsGeminiChat.sendMessageStreamStreamEventTypemakeApiCallAndProcessStream
孤儿 tool_use 修复packages/core/src/core/geminiChat.tsrepairOrphanedToolUseTurnsORPHAN_TOOL_USE_REPAIR_REASON
工具状态机packages/core/src/core/coreToolScheduler.tsCoreToolSchedulerToolCallsetStatusInternal_schedule
执行与并发分批packages/core/src/core/coreToolScheduler.tsattemptExecutionOfScheduledCallspartitionToolCallsisConcurrencySaferunConcurrentlyexecuteSingleToolCall
批完成回调与排队packages/core/src/core/coreToolScheduler.tscheckAndNotifyCompletionschedule
工具结果归一packages/core/src/core/coreToolScheduler.tsconvertToFunctionResponsecreateFunctionResponsePart
headless 单工具执行packages/core/src/core/nonInteractiveToolExecutor.tsexecuteToolCall
打断分类与续跑packages/core/src/core/turn-interruption.tsdetectTurnInterruptionbuildSyntheticToolResponseParts
传输重试白名单packages/core/src/core/stream-transport-retry.tsRETRYABLE_STREAM_TRANSPORT_CODES
循环检测packages/core/src/services/loopDetectionService.tscheckAlwaysOnSafetiesaddAndCheckHeuristicLoopsTOOL_CALL_LOOP_THRESHOLDTURN_TOOL_CALL_CAP
下一发言人判定packages/core/src/utils/nextSpeakerChecker.tscheckNextSpeaker