文章比较长,电脑端也可以直接打开 cc.windliang.wang 查看
上一篇读到 queryLoop() 时,停在了这段代码:
// src/query.tsasyncfunction* queryLoop(/* ... */) {// ...forawait (const message of deps.callModel(/* ... */) ) {yield messageif (message.type === 'assistant') { assistantMessages.push(message) } }// ...}当时只需要知道 callModel() 会不断返回消息,没有继续追消息是怎样产生的。
现在沿着 callModel() 往下走。前半篇使用Grep({ pattern: 'login|auth', path: 'src' }) 这个例子,跟踪一次正常的模型请求:API 返回的网络碎片怎样变成终端上的实时内容,以及 Agent 可以使用的完整消息。
正常链路走完后,后半篇继续看三种异常处理:流式请求失败后改用非流式请求、模型持续过载后切换备用模型,以及用户主动中断。
途中会碰到两类输出:
StreamEvent:尚未完整也可以发出,供终端实时显示;AssistantMessage:一个内容块组装完成后才发出,供queryLoop()判断是否调用 Tool。
如果连接中途断开,已经发出的 AssistantMessage 还涉及撤销和重试。这是正常路径走完以后再处理的问题。
调用链

主线只有三步:queryLoop() 发起调用,queryModel() 组装 API 碎片,结果分别交给 REPL 和 queryLoop()。下面直接进入这三个位置。
deps.callModel() 是谁
源码位置:src/query/deps.ts:7-39、src/query.ts:198、src/query.ts:263
// QueryParams 允许传入 deps,供测试直接注入 fake,// 避免反复对各个模块使用 spyOn。export type QueryDeps = {callModel: typeof queryModelWithStreaming// ...}exportfunctionproductionDeps() {return {callModel: queryModelWithStreaming,// => 正常运行时,deps.callModel// 就是 queryModelWithStreaming// ... }}asyncfunction* queryLoop(params, /* ... */) {const deps = params.deps ?? productionDeps()// 正常运行:使用 productionDeps()// 测试:可以通过 params.deps 替换 callModel// ...}正常运行时,deps.callModel 是 queryModelWithStreaming;测试传入 params.deps 后,可以换成假的模型函数,不必访问 API。
queryModelWithStreaming() 继续把工作交给内部的 queryModel()。
源码位置:src/services/api/claude.ts:752-780
exportasyncfunction* queryModelWithStreaming({ messages, systemPrompt, thinkingConfig, tools, signal, options,}) {returnyield* withStreamingVCR( messages,asyncfunction* () {yield* queryModel( messages, systemPrompt, thinkingConfig, tools, signal, options, ) }, )}withStreamingVCR() 用于录制和回放模型流。这里暂时不继续追它,因为真正请求 API、处理流事件的是 queryModel()。
进入 queryModel(),模型请求是怎样发出的
源码位置:src/services/api/claude.ts:1776-1857
// src/services/api/claude.tsasyncfunction* queryModel( messages, systemPrompt, thinkingConfig, tools, signal, options,) {let stream// 前面先把 messages、systemPrompt 和 tools// 整理成 API 需要的 params// ...const generator = withRetry(() => getAnthropicClient(/* ... */),async anthropic => {const result = await anthropic.beta.messages .create( { ...params,stream: true,// => 开启流式返回 }, { signal,// => 用户中断时可以终止请求 }, ) .withResponse() streamRequestId = result.request_id// => 'req_01ABC...'return result.data// => Anthropic SDK 创建的原始事件流 },// 重试配置…… )let stepdo { step = await generator.next()// withRetry() 中途 yield 消息时:// step => { done: false, value: API 错误消息 }//// withRetry() 最后 return 原始流时:// step => { done: true, value: result.data }if (!('controller'in step.value)) {yield step.value// => withRetry() 途中产生的 API 错误消息 } } while (!step.done) stream = step.value// => 循环结束说明 step.done === true// => 此时 step.value 才是 result.data// 后面通过 for await 逐个读取 stream 中的事件}设置 stream: true 后,API 不会一次返回完整回答,而是持续返回事件。
对于 Tool Use,content_block_start 先给出 Tool 名称和 ID,参数随后通过多个 input_json_delta 分段到达。只有参数拼接成完整 JSON 后,才能构造可执行的 Grep 调用。
顺着 for await 看它怎样拼回完整消息
上边代码最终得到的 stream 的类型是 Stream<BetaRawMessageStreamEvent>。它不是一个已经装好所有数据的数组,而是一串陆续到达的事件。for await 每次取出的 part 只代表其中一个事件。
一次回答通常按下面的顺序到达:
asyncfunction* queryModel(/* ... */) {forawait (const part of stream) {// part 依次可能是://// { type: 'message_start', message: { ... } }//// 文字内容块开始时:// {// type: 'content_block_start',// index: 0,// content_block: {// type: 'text',// text: '',// },// }//// Grep 内容块开始时:// {// type: 'content_block_start',// index: 1,// content_block: {// type: 'tool_use',// id: 'toolu_01',// name: 'Grep',// input: {},// },// }//// Grep 参数到达时:// {// type: 'content_block_delta',// index: 1,// delta: {// type: 'input_json_delta',// partial_json: '{"pattern":"login',// },// }//// { type: 'content_block_stop', index: 1 }//// 此处省略了 index: 0 的文字块事件;// 不同内容块通过 index 区分。//// { type: 'message_stop' } }}message_start 和 message_stop 管整条回答;中间三个 content_block_* 事件负责一块具体内容。index 表示事件属于哪一块内容。
这三个事件携带的字段并不相同:
// 内容块刚开始,带 content_block{type: 'content_block_start',index: 1,content_block: { /* ... */ },}// 内容增量到达,带 delta{type: 'content_block_delta',index: 1,delta: { /* ... */ },}// 内容块结束,只需要指出哪个 index 结束{type: 'content_block_stop',index: 1,}本文假设模型先输出一段文字,再调用 Grep,因此文字块的 index 是 0,Grep 块的 index 是 1。contentBlocks 用来暂存这些事件携带的碎片,下面依次看开始、增量和结束三个分支。
content_block_start:先创建一个空壳
源码位置:src/services/api/claude.ts:1995-2052
// src/services/api/claude.tsasyncfunction* queryModel(/* ... */) {const contentBlocks = []// 上一节中:// stream = step.value// = result.dataforawait (const part of stream) {switch (part.type) {case'content_block_start': {switch (part.content_block.type) {case'tool_use': contentBlocks[part.index] = { ...part.content_block,// 这次运行此时的 part.content_block:// {// type: 'tool_use',// id: 'toolu_01',// name: 'Grep',// input: {},// }input: '',// => 参数还没收完,// 改用字符串累积 }breakcase'text': contentBlocks[part.index] = { ...part.content_block,text: '', }break }break }// 其他事件由后面的 case 处理// ... } }}执行完这个分支后,当前 queryModel() 中的 contentBlocks[1] 变成 { type: 'tool_use', id: 'toolu_01', name: 'Grep', input: '' }。
这时只能知道模型准备调用 Grep,参数仍然是空的,它还不是一个可以执行的 Tool Use。
content_block_delta:把参数一段段接起来
源码位置:src/services/api/claude.ts:2053-2169
// src/services/api/claude.tsasyncfunction* queryModel(/* ... */) {const contentBlocks = []// content_block_start 已经创建内容块// ...forawait (const part of stream) {switch (part.type) {// ...case'content_block_delta': {const contentBlock = contentBlocks[part.index]if (part.delta.type === 'text_delta') { contentBlock.text += part.delta.text }if ( part.delta.type ==='input_json_delta' ) { contentBlock.input += part.delta.partial_json// 这次运行三次执行到这里以后:// 第 1 次// => '{"pattern":"login'//// 第 2 次// => '{"pattern":"login|auth","path":"'//// 第 3 次// => '{"pattern":"login|auth","path":"src"}' }break }// ... } }}到第三段为止,input 才成为完整 JSON。
文本块也是同样的过程:contentBlocks[0].text 会从「我先」变成「我先搜索登录」,最后变成「我先搜索登录相关代码。」。
content_block_stop:这时才生成正式消息
源码位置:src/services/api/claude.ts:2171-2211
// src/services/api/claude.tsasyncfunction* queryModel(/* ... */) {const newMessages = []const contentBlocks = []// 前面已经处理 start 和 delta// ...forawait (const part of stream) {switch (part.type) {// ...case'content_block_stop': {const contentBlock = contentBlocks[part.index]const message = {message: { ...partialMessage,content: normalizeContentFromAPI( [contentBlock], tools, options.agentId, ), },type: 'assistant',uuid: randomUUID(),timestamp: newDate().toISOString(), } newMessages.push(message)yield message// => 内容块完整以后,// 才产出 AssistantMessagebreak }// ... } }}把运行值代进去,文本块结束时第一次产出:
{type: 'assistant',uuid: 'assistant_text_01',message: {role: 'assistant',content: [ {type: 'text',text: '我先搜索登录相关代码。', }, ], },}Tool 块结束时第二次产出:
{type: 'assistant',uuid: 'assistant_tool_01',message: {role: 'assistant',content: [ {type: 'tool_use',id: 'toolu_01',name: 'Grep',input: {pattern: 'login|auth',path: 'src', }, }, ], },}一次模型回答可以包含多个内容块,Claude Code 会在每个内容块完成时分别产出一条 AssistantMessage。因此 queryLoop() 使用 assistantMessages 数组保存 deps.callModel() 返回的所有 assistant 消息。
asyncfunction* queryLoop(/* ... */) {const assistantMessages = []// 中间省略请求参数的准备// ...forawait (const message of deps.callModel(/* ... */) ) {if (message.type === 'assistant') { assistantMessages.push(message)// 文本块结束后第一次执行:// assistantMessages// => [文本 AssistantMessage]//// Grep 块结束后第二次执行:// assistantMessages// => [// 文本 AssistantMessage,// Grep AssistantMessage,// ] } }}终端为什么在内容块结束前就有输出
至此,完整 AssistantMessage 已经找到了。但这条路径必须等到 content_block_stop,仍然解释不了终端为什么可以逐字输出。
继续看 for await 的末尾,会发现每处理完一个原始 API 事件,queryModel() 还会原样转发一次。
源码位置:src/services/api/claude.ts:2295-2303
// src/services/api/claude.tsasyncfunction* queryModel(/* ... */) {// ...forawait (const part of stream) {switch (part.type) {// 先在这里更新 contentBlocks,// 完整时还会 yield AssistantMessage// ... }// 无论是哪一种原始事件,// 最后都会再包装成 StreamEvent 向上转发yield {type: 'stream_event',event: part, ...(part.type === 'message_start' ? { ttftMs } : undefined), } }}以第一段文本为例,向上返回的是:
{type: 'stream_event',event: {type: 'content_block_delta',index: 0,delta: {type: 'text_delta',text: '我先', }, },}以第一段 Tool 参数为例:
{type: 'stream_event',event: {type: 'content_block_delta',index: 1,delta: {type: 'input_json_delta',partial_json: '{"pattern":"login', }, },}到这里,同一个 input_json_delta 的两条去路就对上了:它先追加到 contentBlocks[1].input,然后又被包装成 StreamEvent 向上转发。
所谓「一条 API 流,两种用途」,就是在这个位置分开的。
两种结果先回到 queryLoop()
queryModel() 产出的结果经过 queryModelWithStreaming(),回到 queryLoop():
// src/query.ts:659、817-828asyncfunction* queryLoop(/* ... */) {forawait (const message of deps.callModel(/* ... */) ) {yield message// => StreamEvent 和 AssistantMessage// 都继续向 query() 的调用方转发if (message.type === 'assistant') { assistantMessages.push(message)// => queryLoop() 只把完整消息// 保存到本轮正式状态 } }}query() 使用 yield* 原样转发 queryLoop() 的输出。交互模式下,REPL 才是 query() 的调用方:
// src/query.ts:219-232exportasyncfunction* query(params) {const consumedCommandUuids = []const terminal = yield* queryLoop( params, consumedCommandUuids, )// 中间处理 Command 生命周期// ...return terminal}// src/screens/REPL.tsx:2584-2585、2793-2802exportfunctionREPL(/* ... */) {const onQueryEvent = useCallback(event => { handleMessageFromStream( event,/* ... */ ) }, [/* ... */])const onQueryImpl = useCallback(async (/* ... */ ) => {forawait (const event of query(/* ... */) ) { onQueryEvent(event) } }, [/* ... */])}因此完整路径是:queryModel() 产出事件,queryLoop() 接收并向上转发,REPL 从 query() 读到事件后,才调用 handleMessageFromStream()。
REPL 拿走 StreamEvent
源码位置:src/screens/REPL.tsx:2584-2585、2793-2802、src/utils/messages.ts:3001-3085
现在才进入 handleMessageFromStream()。下面只保留这次运行会经过的 text_delta 和 input_json_delta 两个分支:
// src/utils/messages.tsexportfunctionhandleMessageFromStream( message, onMessage, onUpdateLength, onSetStreamMode, onStreamingToolUses, onTombstone, onStreamingThinking, onApiMetrics, onStreamingText,) {// 完整 AssistantMessage 在前面的分支中// 直接交给 onMessage()// ...switch (message.event.type) {// ...case'content_block_delta': {if ( message.event.delta.type ==='text_delta' ) {const deltaText = message.event.delta.text// => '我先' onStreamingText(text => (text ?? '') + deltaText, )// => 终端立刻多显示两个字 }if ( message.event.delta.type ==='input_json_delta' ) {const delta = message.event.delta.partial_json// => '{"pattern":"login'const index = message.event.index onStreamingToolUses(items => {const current = items.find(item => item.index === index, )if (!current) {return items }return [ ...items.filter(item => item !== current, ), { ...current,unparsedToolInput: current.unparsedToolInput + delta,// 第 1 次// => '{"pattern":"login'//// 第 3 次// => '{"pattern":"login|auth","path":"src"}' }, ] }) }return }// ... }}三个增量都处理完后,REPL 中的 streamingText 是「我先搜索登录相关代码。」,streamingToolUses[0].unparsedToolInput 是 {"pattern":"login|auth","path":"src"}。
这两个值只服务于终端显示,并不会被当成下一步模型消息。
另一边,前面已经看到 queryLoop() 只把 AssistantMessage 加入 assistantMessages,后面再从中找出 Tool Use。两类数据的边界如下:
StreamEvent | |||
AssistantMessage | assistantMessages |
把刚才追过的路径串起来
把刚才分散在三个函数中的代码画到一张时序图里。为了突出 Tool Use 的组装过程,图中省略了前面的文本内容块,只跟踪 Grep 从 content_block_start 到完整 AssistantMessage 的过程。

现在回看正常路径,实际发生的是:
API 返回一个增量; queryModel()把增量累积到contentBlocks;同一个增量被包装成 StreamEvent,由queryLoop()转发给 REPL;内容块结束后, queryModel()产出完整AssistantMessage;queryLoop()从完整消息中找出 Tool Use。
正常流式路径到这里已经走完。
流式连接中断后的回滚
正常路径对上以后,继续看异常分支,仍然使用同一个例子。
假设 API 已经完成了两个内容块:
assistantMessages// => [// {// type: 'assistant',// uuid: 'assistant_text_01',// message: {// role: 'assistant',// content: [// {// type: 'text',// text: '我先搜索登录相关代码。',// },// ],// },// },// {// type: 'assistant',// uuid: 'assistant_tool_01',// message: {// role: 'assistant',// content: [// {// type: 'tool_use',// id: 'toolu_01',// name: 'Grep',// input: {// pattern: 'login|auth',// path: 'src',// },// },// ],// },// },// ]REPL 已经显示了它们,queryLoop() 也已经把它们放进 assistantMessages。
但在 message_stop 到达之前,连接断开了。
Claude Code 改用非流式方式重新请求以后,会一次得到包含两个内容块的完整消息:
{type: 'assistant',uuid: 'assistant_retry_01',message: {role: 'assistant',content: [ {type: 'text',text: '我先搜索登录相关代码。', }, {type: 'tool_use',id: 'toolu_retry_01',name: 'Grep',input: {pattern: 'login|auth',path: 'src', }, }, ], },}如果只是把新结果追加进去,会变成:
assistantMessages// => [// {// type: 'assistant',// uuid: 'assistant_text_01',// message: {// role: 'assistant',// content: [// {// type: 'text',// text: '我先搜索登录相关代码。',// },// ],// },// },// {// type: 'assistant',// uuid: 'assistant_tool_01',// message: {// role: 'assistant',// content: [// {// type: 'tool_use',// id: 'toolu_01',// name: 'Grep',// input: {// pattern: 'login|auth',// path: 'src',// },// },// ],// },// },// {// type: 'assistant',// uuid: 'assistant_retry_01',// message: {// role: 'assistant',// content: [// {// type: 'text',// text: '我先搜索登录相关代码。',// },// {// type: 'tool_use',// id: 'toolu_retry_01',// name: 'Grep',// input: {// pattern: 'login|auth',// path: 'src',// },// },// ],// },// },// ]把两次结果放在一起后,问题马上出现了:
用户看到两遍相同内容; Grep可能被执行两次;两次 Tool Use 的 ID 不同,后续 Tool Result 可能和错误的调用配对。
所以这里的失败恢复不能只是捕获错误后再请求一次,它还必须撤销第一次请求已经交付的结果。
源码先尝试同模型的非流式请求
其他流式错误进入 Streaming Fallback 后,模型保持不变,只把传输方式从 streaming 改成 non-streaming。
把这个分支放回 queryModel() 后,主干如下。
源码位置:src/services/api/claude.ts:2320-2584
// src/services/api/claude.tsasyncfunction* queryModel( messages, systemPrompt, thinkingConfig, tools, signal, options,) {// 前面创建 stream// ...try {forawait (const part of stream) {// 正常处理流事件…… } } catch (streamingError) {if (streamingError instanceof APIUserAbortError) {if (signal.aborted) {throw streamingError// => 调用方主动中断,不自动恢复 }thrownew APIConnectionTimeoutError({message: 'Request timed out', })// => SDK 自身超时,不进入下面的非流式 Fallback }// When the flag is enabled, skip the non-streaming fallback and let the// error propagate to withRetry. The mid-stream fallback causes double tool// execution when streaming tool execution is active: the partial stream// starts a tool, then the non-streaming retry produces the same tool_use// and runs it again. See inc-4258.const disableFallback = isEnvTruthy( process.env .CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK, ) || getFeatureValue_CACHED_MAY_BE_STALE('tengu_disable_streaming_to_non_streaming_fallback',false, )// 例如:// CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK// => undefined//// Feature Flag// => false//// disableFallback// => false,继续执行非流式 Fallbackif (disableFallback) {throw streamingError// => 不执行 executeNonStreamingRequest() } didFallBackToNonStreaming = true options.onStreamingFallback?.()// => 回调 queryLoop():// 前一次流式结果需要撤销const result =yield* executeNonStreamingRequest( {model: options.model,// => 仍是 'claude-sonnet-4-6' },// ... )const message = {type: 'assistant',message: { ...result,content: normalizeContentFromAPI( result.content, tools, options.agentId, ), },uuid: randomUUID(), }yield message// => 非流式请求一次返回完整结果 }// 后面记录用量并释放资源// ...}disableFallback 控制的是其他流式错误能否改用非流式请求。开启后,错误会直接向上抛出,不再发起非流式请求。
queryLoop() 发出 Tombstone(墓碑标记)
前面的流式消息同时进入了两个地方:一份已经 yield 给 REPL,用于界面显示和会话记录;另一份保存在 queryLoop() 的 assistantMessages、toolResults 和 toolUseBlocks 中。切换到非流式请求时,这两个地方都要清理:
对外发出 Tombstone,让 REPL 删除已经显示和记录的旧消息; 在 queryLoop()内清空失败流式路径留下的消息和 Tool 状态。
看上边的代码,queryModel() 失败时会调用 options.onStreamingFallback?.(),再看下边的代码,其实就是将 streamingFallbackOccured 标记为 true。
源码位置:src/query.ts:657-728
// src/query.tsasyncfunction* queryLoop(/* ... */) {const assistantMessages = []// 保存本次模型请求产生的 AssistantMessageconst toolResults = []// 保存已经返回的 Tool Resultconst toolUseBlocks = []// 保存从 AssistantMessage 中提取出的 tool_uselet needsFollowUp = false// 是否需要带着 Tool Result 再请求一次模型let streamingFallbackOccured = false// ...forawait (const message of deps.callModel({// ...options: {model: currentModel,// => 'claude-sonnet-4-6'onStreamingFallback: () => { streamingFallbackOccured = true }, }, })) {if (streamingFallbackOccured) {for (const oldMessage of assistantMessages) {yield {type: 'tombstone',message: oldMessage, }// => 通知上层删除旧消息 } assistantMessages.length = 0// => 删除失败流式路径的模型消息 toolResults.length = 0// => 删除这条路径的 Tool Result toolUseBlocks.length = 0// => 删除这条路径请求执行的 Tool needsFollowUp = false// => 不再拿这条路径的 Tool Result 请求模型 }// 接下来再处理非流式请求的新 message// ... }}这里同时存在「失败的流式结果」和「随后返回的非流式结果」两条路径。
这几行把前一条路径留下的状态原地清空,接下来循环只处理新的非流式 message。数组使用 .length = 0,是为了清空原数组,而不是创建一个新数组。
streamingFallbackOccured 为 true 后会依次产出两条 Tombstone:
[ {type: 'tombstone',message: {type: 'assistant',uuid: 'assistant_text_01',message: {role: 'assistant',content: [ {type: 'text',text: '我先搜索登录相关代码。', }, ], }, }, }, {type: 'tombstone',message: {type: 'assistant',uuid: 'assistant_tool_01',message: {role: 'assistant',content: [ {type: 'tool_use',id: 'toolu_01',name: 'Grep',input: {pattern: 'login|auth',path: 'src', }, }, ], }, }, },]每条 Tombstone 都携带一条需要作废的原消息。REPL 收到后删除对应消息。
// src/utils/messages.ts:2930-2958exportfunctionhandleMessageFromStream( message,// ... onTombstone,) {if (message.type === 'tombstone') { onTombstone?.(message.message)return }// 继续处理 StreamEvent 和普通消息// ...}// src/screens/REPL.tsx:2584-2652const onQueryEvent = useCallback(event => { handleMessageFromStream( event, newMessage => {// 处理普通消息 setMessages(oldMessages => [...oldMessages, newMessage]) }, newContent => {// 处理流式文本长度 setResponseLength(length => length + newContent.length) }, setStreamMode, setStreamingToolUses, tombstonedMessage => { setMessages(oldMessages => oldMessages.filter(message => message !== tombstonedMessage), )void removeTranscriptMessage(tombstonedMessage.uuid) }, )}, [])onTombstone 是 REPL 传给 handleMessageFromStream() 的回调函数,不是一个全局函数。
收到 Tombstone 后,REPL 会通过 uuid 把原消息从界面状态和会话记录中移除。
Fallback 的完整流程是:重新请求、撤销旧结果、清空旧的 Tool 状态,最后接收新结果。

模型 Fallback 是另一件事
如果同一个模型持续返回 529 过载错误,改变流式传输也解决不了问题。满足模型 Fallback 的启用条件,并且已经配置 fallbackModel 时,Claude Code 才会切换备用模型。
源码位置:src/services/api/withRetry.ts:170-178、318-350
// src/services/api/withRetry.tsexportasyncfunction* withRetry( getClient, operation, options,) {const maxRetries = getMaxRetries(options)const retryContext = {model: options.model,thinkingConfig: options.thinkingConfig, }let client = nulllet consecutive529Errors = 0for (let attempt = 1; attempt <= maxRetries + 1; attempt++ ) {try { client ??= await getClient()returnawait operation( client, attempt, retryContext, ) } catch (error) {// 其他错误和重试规则// ...if ( is529Error(error) && !shouldRetry529(options.querySource) ) {thrownew CannotRetryError( error, retryContext, )// => 非前台调用不在这里继续重试 }if ( is529Error(error) && ( process.env .FALLBACK_FOR_ALL_PRIMARY_MODELS || ( !isClaudeAISubscriber() && isNonCustomOpusModel(options.model) ) ) ) { consecutive529Errors++// => 1、2、3if ( consecutive529Errors >= MAX_529_RETRIES && options.fallbackModel ) {thrownew FallbackTriggeredError( options.model,// => 'claude-opus-4-6' options.fallbackModel,// => 'claude-sonnet-4-6' ) } } } }}因此,不是所有 529 都会触发模型 Fallback:非前台调用的这次请求会直接结束;前台调用还要通过模型范围判断,连续达到三次,并且配置了 fallbackModel。
queryLoop() 捕获这个专用错误后修改 currentModel,清空失败尝试的本轮结果,再重做当前请求。
源码位置:src/query.ts:650-708、893-950
// src/query.tsasyncfunction* queryLoop(/* ... */) {let attemptWithFallback = truewhile (attemptWithFallback) { attemptWithFallback = falsetry {forawait (const message of deps.callModel(/* ... */)) {// 正常处理模型消息// ... } } catch (innerError) {if ( innerError instanceof FallbackTriggeredError && fallbackModel ) { currentModel = fallbackModel// => 'claude-sonnet-4-6' attemptWithFallback = trueyield* yieldMissingToolResultBlocks( assistantMessages,'Model fallback triggered', )// => 补齐可能缺失的 Tool Result assistantMessages.length = 0 toolResults.length = 0 toolUseBlocks.length = 0 needsFollowUp = false// => 清空当前失败尝试的临时状态if (streamingToolExecutor) { streamingToolExecutor.discard() streamingToolExecutor = new StreamingToolExecutor( toolUseContext.options.tools, canUseTool, toolUseContext, ) }// => 丢弃失败尝试的 Tool 队列continue// => 回到当前 while 顶部,// 用备用模型重做当前请求 }throw innerError } }// 后面才决定是否执行 Tool// ...}模型 Fallback 也会清理数据,但只清理当前失败尝试产生的 assistantMessages、Tool 状态和执行队列。原始会话历史 messagesForQuery 会保留,备用模型仍要根据同一段上下文重新回答。
这个分支不需要发 Tombstone。它由 withRetry() 连续收到 529 后触发,此时请求还没有建立出可消费的模型结果,REPL 中通常没有需要撤回的内容;Streaming Fallback 则可能已经向 REPL 产出消息,所以才需要 Tombstone。
两种 Fallback 处理的问题不同:
这两种情况都在重做当前轮,因此不会增加 turnCount。
turnCount// Fallback 前 => 1// Fallback 后 => 1只有模型成功调用 Tool、Claude Code 得到 Tool Result 并进入下一轮时,turnCount 才会增加。
用户中断
一次查询只共享一个 AbortSignal,但触发它的不只 Esc。REPL 中可以直接看到三种主要的 reason:
user-cancel:用户按 Esc 或取消当前任务;interrupt:新输入需要立即接管,先结束正在运行的这一轮;background:按 Ctrl+B,把前台任务交给后台会话继续。
本节只展开最常见的 user-cancel。另外,StreamingToolExecutor 内部还有 sibling_error 和 streaming_fallback 等 Tool 取消原因,它们不等于用户中断,会在工具系统中解释。
下面以「模型正在输出时按下 Esc」为例。这个信号从 REPL 一直传到模型请求和 Tool 执行器。
1. 请求开始时创建中断信号
REPL 创建一个 AbortController,并把同一个 Controller 交给 onQuery()。queryLoop() 再把它的 signal 传给 queryModel(),最终交给 Anthropic SDK:
// src/screens/REPL.tsxconst controller = createAbortController()setAbortController(controller)void onQuery( [initialMsg.message], controller,true, // shouldQuery [], // additionalAllowedTools mainLoopModel,)// src/query.tsforawait (const message of deps.callModel({signal: toolUseContext.abortController.signal,// 其他参数……})) {// 消费模型消息}// src/services/api/claude.tsconst result = await anthropic.beta.messages .create( {// ...stream: true, }, { signal }, ) .withResponse()const stream = result.datacontroller.signal.aborted// => falseAbortController 可以理解成开关,signal 是这个开关的只读状态。各层拿到的是同一个 signal。
2. 用户按 Esc,REPL 改变信号
// src/screens/REPL.tsx:2106-2150functiononCancel() {// ... abortController?.abort('user-cancel')}abortController.signal.aborted// => trueabortController.signal.reason// => 'user-cancel'signal.aborted 不是网络错误,而是 REPL 明确发出的停止命令。
3. 模型流停止,但不执行 Fallback
SDK 正在通过 for await 返回流事件。abort() 以后,SDK 会终止流并抛出 APIUserAbortError:
// src/services/api/claude.ts:2434-2451asyncfunction* queryModel(/* ..., signal, ... */) {try {forawait (const part of stream) {// 处理流事件// ... } } catch (streamingError) {if (streamingError instanceof APIUserAbortError) {if (signal.aborted) {throw streamingError// => 调用方主动中断,不执行 Fallback }thrownew APIConnectionTimeoutError({message: 'Request timed out', })// => SDK 自身超时,转换错误类型后向上抛出 }// 其他流式错误才会走到这里,// 再根据 disableFallback 决定是否改用非流式请求// ... }}这里不能只根据 APIUserAbortError 判断用户是否主动中断,还要检查传入的 signal:
signal.aborted === true:调用方主动结束当前请求,直接向上抛出;signal.aborted === false:源码将它视为 SDK 自身的超时,转换成APIConnectionTimeoutError后向上抛出;其他流式错误:再根据 disableFallback决定是否改用非流式请求。
4. 为被中断的 Tool Use 补一条失败结果
假设按下 Esc 前,模型已经完整返回了下面这个 Grep Tool Use:
{type: 'tool_use',id: 'toolu_01',name: 'Grep',input: {pattern: 'login|auth',path: 'src', },}此时不能只结束循环,否则会留下一个「模型要求执行 Tool,却永远没有结果」的半截记录。在未启用流式 Tool 执行器的分支中,queryLoop() 会补出下面这条失败的 Tool Result:
{type: 'tool_result',tool_use_id: 'toolu_01',is_error: true,content: 'Interrupted by user',}tool_use.id 和 tool_result.tool_use_id 相同,表示这条结果对应上面的 Grep 请求。这里不是继续执行 Grep,只是记录它因为用户中断而没有完成。
源码根据是否启用了流式 Tool 执行器选择结果来源:
// src/query.ts:1011-1051asyncfunction* queryLoop(/* ... */) {if (toolUseContext.abortController.signal.aborted) {if (streamingToolExecutor) {forawait (const update of streamingToolExecutor.getRemainingResults()) {if (update.message) {yield update.message// => 收集已经完成的结果,// 或执行器为中断 Tool 生成的失败结果 } } } else {yield* yieldMissingToolResultBlocks( assistantMessages,'Interrupted by user', )// => Tool 尚未交给流式执行器,// 直接扫描消息并补失败结果 }return {reason: 'aborted_streaming', } }// 未中断时继续处理 Tool// ...}getRemainingResults() 不是重新执行 Tool。它只是把执行器队列中已经完成的结果取出来,并为排队中或执行到一半的 Tool 生成失败结果。如果中断前还没有出现完整的 tool_use,这里就没有内容需要补。
这里先理解它们共同解决的问题:即使执行被中断,已经产生的 tool_use 也要有对应的 tool_result。yieldMissingToolResultBlocks() 怎样查找缺失结果,StreamingToolExecutor 怎样管理排队、执行与中断,以及 getRemainingResults() 怎样收尾,会放到后边的文章展开。
最终结果取决于按下中断键的时机:
模型仍在流式输出:收尾后返回 aborted_streaming;模型已经输出完,正在执行 Tool:停止 Tool 后返回 aborted_tools。
两种情况都不会 Fallback,因为 signal.aborted 表示用户已经明确要求停止任务。
中断沿调用链返回时,queryModel() 的 finally 还会停止 api_call 活跃标记,并调用 releaseStreamResources() 终止 SDK Stream、取消 HTTP Response body。这只是模型请求层的收尾,不是 Claude Code 的完整资源清理清单。
小结
沿着源码走完以后,最初的黑盒终于展开了:API 增量被包装成 StreamEvent 供 REPL 临时显示;同一批增量也会累积到 contentBlocks,形成 AssistantMessage,再由 queryLoop() 识别 Tool Use。
如果其他流式错误进入 Streaming Fallback,Claude Code 会改用非流式请求,使用 Tombstone 撤销旧消息,再接收新的完整消息;用户主动中断则直接结束。
再回看上一篇的代码:
// src/query.tsasyncfunction* queryLoop(/* ... */) {// ...forawait (const message of deps.callModel(/* ... */) ) {yield message// => 把实时事件和完整消息都交给上层if (message.type === 'assistant') { assistantMessages.push(message)// => 只有完整消息进入 Agent 正式状态 } }// ...}到这里,callModel() 在 Agent 主循环中的作用也就清楚了。
Claude Code 处理的仍然是熟悉的工程问题。流式数据怎么拼装,失败后怎么重试或回滚,用户中断后怎么收尾,资源什么时候释放——这些在普通业务开发中也会遇到。不同之处只是 Agent 的一次任务链路更长,还会调用 Tool修改文件、执行命令,因此发生异常时,需要处理的中间状态更多。
夜雨聆风