文章比较长,电脑端也可以直接打开 cc.windliang.wang 查看
正常情况下,模型返回 Tool Use,Tool 执行后生成 Tool Result,结果写回 messages,下一轮模型请求再带上这些结果继续。
这条路径一旦出错,不能只用一个 catch 把所有情况重试。请求还没到模型、模型回答被截断、输入太长、用户中断,代码能看见的信息都不同。
这一篇沿着一次模型请求走一遍,先确定错误发生在哪个阶段,再看对应的恢复动作。
1. 先看全貌:错误在哪个阶段出现
queryLoop() 的返回值中有多种 reason,其中既有错误,也有正常完成和 maxTurns 这类停止边界。按调用阶段整理后,主线更清楚:
withRetry() | |||
max_output_tokens | |||
prompt_too_longimage_error | |||
is_error 的 Tool Result | |||
abortController.signal.aborted | aborted_streamingaborted_tools | ||
model_error |
其中前 3 行是「自动恢复」的主体。后 3 行分别属于 Tool 协议、用户意图和程序错误,不能把它们当成网络问题偷偷再跑一次。
下面使用同一个任务作为运行示例:
// 运行时值,不属于函数体:src/query.ts::queryLoop() 首轮请求的 messages。[ {type: 'user',uuid: 'user_01',message: {role: 'user',content: '检查 src/auth/login.ts 的登录逻辑', }, },]2. 第一层:请求没有拿到回答,withRetry() 重发 API 请求
queryLoop() 不直接创建 API 客户端。它通过 deps.callModel() 调用生产实现 queryModelWithStreaming();后者把创建流的动作交给 withRetry()。
源码位置:src/query.ts:659-707、src/services/api/claude.ts:1778-1860、src/services/api/withRetry.ts:170-262
// 函数体:src/services/api/withRetry.ts::withRetry()exportasyncfunction* withRetry(getClient, operation, options) {const maxRetries = getMaxRetries(options) // => 10const retryContext = {model: options.model, // => 'claude-sonnet-4-6'thinkingConfig: options.thinkingConfig, }let client = nullfor (let attempt = 1; attempt <= maxRetries + 1; attempt++) {if (options.signal?.aborted) {thrownew APIUserAbortError() }try {if (client === null) { client = await getClient() }returnawait operation(client, attempt, retryContext)// => 成功时返回 Anthropic SDK 的模型事件流。 } catch (error) {// ... 源码在这里排除不可重试错误,并处理 529 的备用模型分支。const retryAfter = getRetryAfter(error)const delayMs = getRetryDelay(attempt, retryAfter)// attempt => 1;基础等待从 500ms 开始,服务端给出 Retry-After 时优先使用它。 logEvent('tengu_api_retry', { attempt, delayMs,status: error.status, // => 429 或 529 })yield createSystemAPIErrorMessage(error, delayMs, attempt, maxRetries)await sleep(delayMs, options.signal, { abortError }) } }}这里的「重发」不会改变 messages。请求失败时,模型还没有返回一条有效 Assistant Message,主循环没有新会话内容可以保存;它只需要等待,再把同样的请求发出去。
withRetry() 成功后才返回 stream。queryModelWithStreaming() 消费这个流,逐步产出文本和 Tool Use;之后发生的错误才进入 queryLoop() 的第二层处理。
3. 第二层:模型调用已产生消息,但这条消息先不交给界面
模型流结束后,queryLoop() 已经收集到了本轮的 Assistant Message。对于 max_output_tokens、上下文过长和媒体过大,源码先把消息放进 assistantMessages,同时暂不 yield 给 REPL。
源码位置:src/query.ts:800-834、src/query.ts:1063-1085
// 函数体:src/query.ts::queryLoop()asyncfunction* queryLoop(params, consumedCommandUuids) {while (true) {// ...forawait (const message of deps.callModel(/* ... */)) {let withheld = falseif (isWithheldMaxOutputTokens(message)) { withheld = true }// ... 上下文过长和媒体过大也会把 withheld 设为 true。if (!withheld) {yield message }if (message.type === 'assistant') { assistantMessages.push(message)// => [{ type: 'assistant', apiError: 'max_output_tokens', message: { role: 'assistant', content: [...] } }] } }const lastMessage = assistantMessages.at(-1)// => 本轮最后一条 Assistant Message;后面的恢复分支检查它。// ... }}withheld 的作用不是丢弃消息,而是给恢复分支一个机会。恢复成功后,新的消息或新的上下文会被重新 yield;恢复耗尽时,原错误才会显示并结束。
接下来的第 4 节和第 5 节都从 lastMessage 开始判断:第 4 节处理 max_output_tokens;第 5 节处理 prompt_too_long 与媒体大小错误。
4. 输出截断:一条路径里的三个出口
输出截断不代表模型完全没有结果。当前源码把它分成三种处理结果:
输出上限不够且升级条件满足时,先把上限提高到 64000,争取一次生成完整回答; 仍然截断时,保留已经生成的内容,让模型从断点继续,避免从头重复; 续写次数耗尽时,交回截断错误,不再假装能够自动恢复。
源码位置:src/query.ts:1188-1258、src/utils/context.ts:148-151
// 函数体:src/query.ts::queryLoop()asyncfunction* queryLoop(params, consumedCommandUuids) {while (true) {// ... lastMessage.apiError => 'max_output_tokens'if (isWithheldMaxOutputTokens(lastMessage)) {const capEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_otk_slot_v1',false, ) // => trueif ( capEnabled && maxOutputTokensOverride === undefined && !process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS ) { state = {messages: messagesForQuery,// => 仍是 ['检查 src/auth/login.ts 的登录逻辑'];不带半截回答 toolUseContext,maxOutputTokensOverride: ESCALATED_MAX_TOKENS, // => 64000 maxOutputTokensRecoveryCount, // => 0 hasAttemptedReactiveCompact, turnCount,transition: { reason: 'max_output_tokens_escalate' },// ... }continue }if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {const recoveryMessage = createUserMessage({content:'Output token limit hit. Resume directly — no apology, no recap of what you were doing. ' +'Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.',isMeta: true, }) state = {messages: [ ...messagesForQuery, ...assistantMessages,// => 半截回答:'登录校验入口位于 src/auth/login.ts,失败分支目前……' recoveryMessage,// => 隐藏 User Message:要求模型从中断处继续。 ], toolUseContext,maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1, // => 1maxOutputTokensOverride: undefined, hasAttemptedReactiveCompact, turnCount,transition: {reason: 'max_output_tokens_recovery',attempt: maxOutputTokensRecoveryCount + 1, // => 1 },// ... }continue }yield lastMessage }if (lastMessage?.isApiErrorMessage) {return { reason: 'completed' } }// ... }}第一种状态只会在 tengu_otk_slot_v1 开关开启、尚未设置 maxOutputTokensOverride、且未设置 CLAUDE_CODE_MAX_OUTPUT_TOKENS 时发生。它只修改 maxOutputTokensOverride,messages 仍是原请求;因此它的作用是重新生成,而不是接着半截文本说下去。
第二种状态把 assistantMessages 与 recoveryMessage 一起加入 messages。它的作用是保存已花掉的输出:模型能看到「已经说到哪里」,只补剩余内容。maxOutputTokensRecoveryCount 从 0 变成 1,最多允许 3 次续写。
最后一种状态不再更新 state。yield lastMessage 把暂存的截断错误交给 REPL,随后结束查询。自动恢复的边界在这里,而不是无限要求模型继续。
5. 输入太长或媒体太大:替换输入后再请求
这一节只追一次被服务端拒绝的请求。假设本轮准备发送的 messagesForQuery 中既有当前问题,也有很多历史 Tool Result;服务端可能返回两种错误:整段消息过长,或其中一张图片、PDF 等媒体过大。
主循环不会立刻把这条错误显示给 REPL。它先留住错误,检查能否构造一份更小的输入;能构造就重发,不能构造才结束。
整条路径可以先压成四步:
模型流返回输入错误; 错误先进入 assistantMessages,但不yield;文本过长优先采用已有的局部替换,否则文本和媒体都交给 tryReactiveCompact()产生新的消息数组;新数组写入新建 state对象的messages字段后continue重发;没有新数组才把原错误交给 REPL。
1. 先留下错误,不立即显示
源码位置:src/query.ts:800-834
// 函数体:src/query.ts::queryLoop()asyncfunction* queryLoop( params: QueryParams, consumedCommandUuids: string[],) {// ...while (true) {// ... 本轮通过 deps.callModel() 请求模型。forawait (const message of deps.callModel(/* ... */)) {let withheld = falseif (reactiveCompact?.isWithheldPromptTooLong(message)) { withheld = true }if ( mediaRecoveryEnabled && reactiveCompact?.isWithheldMediaSizeError(message) ) { withheld = true }if (!withheld) {yield message }if (message.type === 'assistant') { assistantMessages.push(message) } } }// ...}以「输入过长」为例,message 的运行值是一条 API 错误消息;withheld 最终为 true,所以它不会先显示在界面上,但仍留在 assistantMessages 里供后面的恢复代码判断。
// 运行时值,不属于函数体:src/query.ts::queryLoop() 中的模型流消息。const message = {type: 'assistant',isApiErrorMessage: true,message: {role: 'assistant',content: 'Prompt is too long', },}2. 根据错误类型选择替换来源
模型流结束后,lastMessage 就是刚才暂存的错误。这里先区分「文本过长」和「媒体过大」,但两者最终都需要得到新的 messages。
源码位置:src/query.ts:1063-1182
// 函数体:src/query.ts::queryLoop()asyncfunction* queryLoop( params: QueryParams, consumedCommandUuids: string[],) {while (true) {// ... 已消费本轮模型流。const lastMessage = assistantMessages.at(-1)// => {// type: 'assistant',// isApiErrorMessage: true,// message: { role: 'assistant', content: 'Prompt is too long' },// }const isWithheld413 = lastMessage?.type === 'assistant' && lastMessage.isApiErrorMessage && isPromptTooLongMessage(lastMessage)// => trueconst isWithheldMedia = mediaRecoveryEnabled && reactiveCompact?.isWithheldMediaSizeError(lastMessage)// => falseif ( isWithheld413 && feature('CONTEXT_COLLAPSE') && contextCollapse && state.transition?.reason !== 'collapse_drain_retry' ) {const drained = contextCollapse.recoverFromOverflow( messagesForQuery, querySource, )// => { committed: 0, messages: messagesForQuery }if (drained.committed > 0) { state = {messages: drained.messages, toolUseContext, turnCount,transition: {reason: 'collapse_drain_retry',committed: drained.committed, },// ... }continue } }if ((isWithheld413 || isWithheldMedia) && reactiveCompact) {const compacted = await reactiveCompact.tryReactiveCompact({hasAttempted: hasAttemptedReactiveCompact, // => falsemessages: messagesForQuery,// => [// { type: 'user', message: { role: 'user', content: '检查 src/auth/login.ts' } },// { type: 'user', message: { role: 'user', content: '此前 125 条历史消息和 Tool Result' } },// ] querySource, // => 'repl_main_thread'aborted: toolUseContext.abortController.signal.aborted, // => falsecacheSafeParams: { systemPrompt, userContext, systemContext, toolUseContext,forkContextMessages: messagesForQuery, }, })if (compacted) {const postCompactMessages = buildPostCompactMessages(compacted)// => [// { type: 'user', message: { role: 'user', content: '<summary>此前历史的摘要</summary>' } },// { type: 'user', message: { role: 'user', content: '检查 src/auth/login.ts' } },// ]for (const message of postCompactMessages) {yield message } state = {messages: postCompactMessages, toolUseContext,hasAttemptedReactiveCompact: true, turnCount,transition: { reason: 'reactive_compact_retry' },// ... }continue }yield lastMessagereturn {reason: isWithheldMedia ? 'image_error' : 'prompt_too_long', } }// ... }}文本过长时,recoverFromOverflow() 有机会返回已经准备好的局部替换;上面的运行值 committed: 0 表示本次没有可直接采用的结果,于是继续调用 tryReactiveCompact()。
媒体过大时,运行值则是 isWithheld413 === false、isWithheldMedia === true。它跳过 recoverFromOverflow(),直接进入同一个 tryReactiveCompact()。src/query.ts 的源码注释把这条媒体恢复称为「strip-retry」:尝试去除超限的图片、PDF 或其他媒体,再形成新的输入重发。
tryReactiveCompact() 的内部模块由 Feature Flag 动态加载,当前源码包没有展开。当前调用点能够确认的输入输出只有两种:成功时交回可重发的 compacted,失败时交回空值。主循环并不关心它如何缩短文本或移除媒体,只根据是否拿到新的 messages 决定重发还是报错。
3. 新输入回到循环,原错误只在恢复失败后出现
恢复的落点是新建一个 state 对象,其中 messages 字段取 postCompactMessages,再通过 state = { ... } 整体替换旧状态。下一次 while 从这份较小的数组重新请求模型;hasAttemptedReactiveCompact 随之设为 true,避免同一种恢复反复执行。
这里的 while (true) 是主循环,不表示这条恢复会无限重试。文本分支第一次采用 recoverFromOverflow() 后,transition.reason 会变成 collapse_drain_retry;下一次仍然收到同样的输入过长错误时,条件 state.transition?.reason !== 'collapse_drain_retry' 不再成立,不会再走这次局部替换。
响应式分支则把 hasAttemptedReactiveCompact 从 false 改为 true 传回 tryReactiveCompact();src/query.ts 的源码注释说明它用于阻止再次压缩后仍超限时形成循环。恢复函数无法给出新消息数组时,下面的 yield lastMessage 与 return 会直接结束本次查询。
如果没有得到 compacted,yield lastMessage 才把原始错误显示出来:文本错误对应 prompt_too_long,媒体错误对应 image_error。请求前主动缩短历史是另一条路径;这里专门处理服务端已经明确拒绝输入后的补救。
6. 恢复路径怎样留下可验证的痕迹
第 4 节和第 5 节里都出现了 continue。单看这一句,只能知道循环又开始了,却看不出它是「提高输出上限」「追加续写指令」,还是「替换过长上下文」后重新开始。
queryLoop() 把每次继续之前的完整状态放进 state,并在其中记录 transition。下一轮循环从这个 state 读取输入和恢复标记,因此恢复不是散落在局部变量里的临时行为。
源码位置:src/query.ts:241-310
// 函数体:src/query.ts::queryLoop()asyncfunction* queryLoop( params: QueryParams, consumedCommandUuids: string[],) {let state: State = {messages: params.messages,// => [{ type: 'user', message: { role: 'user', content: '检查 src/auth/login.ts 的登录逻辑' } }]toolUseContext: params.toolUseContext,maxOutputTokensOverride: undefined,maxOutputTokensRecoveryCount: 0,hasAttemptedReactiveCompact: false,turnCount: 1,transition: undefined,// ... }while (true) {const { messages, maxOutputTokensRecoveryCount, hasAttemptedReactiveCompact, maxOutputTokensOverride, turnCount, } = state// ... 使用这份状态发起本轮模型请求。 }}以「输出截断后续写」为例,恢复分支写回的状态与首轮相比,只有本轮需要的字段发生变化:
messages | |||
maxOutputTokensRecoveryCount | 0 | 1 | |
maxOutputTokensOverride | undefined | undefined | |
transition.reason | undefined | max_output_tokens_recovery |
transition 不只是日志标签。第 5 节的 Context Collapse 已经直接读取前一轮的值,避免同一份局部替换结果被重复提交:
源码位置:src/query.ts:1087-1095
// 函数体:src/query.ts::queryLoop()asyncfunction* queryLoop( params: QueryParams, consumedCommandUuids: string[],) {// ...if (isWithheld413) {// isWithheld413 => true,表示本轮收到 prompt_too_long。if ( feature('CONTEXT_COLLAPSE') && contextCollapse && state.transition?.reason !== 'collapse_drain_retry'// => 首次处理时 transition 是 undefined,可以进入。// => 重试后 transition.reason 是 'collapse_drain_retry',跳过这条分支。 ) {// ... } }}源码中 State 的注释也明确说明:transition 描述上一轮为什么继续,测试可以据此断言恢复路径是否触发。这里看到的实际作用是,状态既保存下一轮要带的消息,也保存这份消息为什么会变成现在这样。
不访问真实 API,也能复现恢复分支
恢复路径最难直接测试的部分是模型请求:真实 API 会带来网络、等待时间和不稳定结果。queryLoop() 没有在函数内部写死模型实现,而是优先读取 params.deps;生产环境未传入时,才使用 productionDeps()。
源码位置:src/query.ts:263、src/query.ts:659-707、src/query/deps.ts:16-40
// 函数体:src/query.ts::queryLoop()asyncfunction* queryLoop( params: QueryParams, consumedCommandUuids: string[],) {const deps = params.deps ?? productionDeps()// ...forawait (const message of deps.callModel({messages: prependUserContext(messagesForQuery, userContext),systemPrompt: fullSystemPrompt,tools: toolUseContext.options.tools,// ... })) {yield message }// ...}// 函数体:src/query/deps.ts::productionDeps()exportfunctionproductionDeps(): QueryDeps{return {callModel: queryModelWithStreaming,microcompact: microcompactMessages,autocompact: autoCompactIfNeeded,uuid: randomUUID, }}生产运行时,deps.callModel 是 queryModelWithStreaming;测试传入自己的 deps.callModel,就可以稳定产出「输出截断」或「输入过长」这类 Assistant Message。随后检查下一次调用拿到的 messages、恢复计数和 transition.reason,即可验证恢复是否按预期重建状态,而不必真的等待 API 返回或制造超长上下文。
小结
这条源码把「失败后怎么办」拆成了四种不同的状态:
withRetry() | ||
max_output_tokens_escalate | ||
max_output_tokens_recovery | ||
prompt_too_long |
因此恢复动作不是由「发生了错误」统一决定的,而是由当前保留的数据决定的:没有可用回答时可以原样重发;回答只是碰到默认输出上限且升级条件满足时可以先放宽上限;已经生成的内容不能丢;输入本身装不下时只能换输入。每次恢复都通过新的 state 进入下一轮,并用计数或 transition 留下停止条件和路径标记。
夜雨聆风