模型抽风、上下文爆、话说一半——Claude Code凭什么没崩?

72 阅读15分钟

1 凭什么没崩

上一篇 模型说"我说完了",Claude Code 偏不信 结尾,我们撂下一个没解的结:那个 while(true) 顶上没有任何条件,万一模型一直递工具、永远不说「完了」呢?当时还留了句话——那 1700 多行里更厚的另一半,是「怎么不崩」。今天,就钻这另一半。

先看三个画面。

画面一:模型正给你吐字——读到一半的文件、写到一半的代码,字一个一个往屏幕上冒。突然,断了。不是它说完了,是它被掐了:服务器没递回完整答案,递回的是一个 max_tokens,意思是「话太长,我给你截断了」。半截话挂在屏幕上,没头没尾。

画面二:对话来回了几十轮,每一轮模型都递工具、都跑命令、都塞回结果。历史越攒越长,终有一次,服务器连看都不想看——递回一个 413(prompt_too_long),意思是「上下文太长,整封信被退」。模型根本没机会开口。

画面三:模型正说着,突然一个过载——请求抛错了。再递一次,又抛;再递,还抛。不是字被截,不是信被退,是电话那头的人不接了

换作上一篇那个 8 行玩具,这三样全会崩——撞上限就退出、上下文爆就死循环、模型抽风就 stack trace。可你在 Claude Code 里,多半压根没察觉出过事:话被掐了它自己续上、上下文爆了它压缩了再试、模型抽风了它切备用模型。像什么都没发生过。

它是怎么做到的?

翻开 src/query.ts 那个循环,把「出事了怎么办」的路数一数——整整 16 条。可真正「说完了」的,只有 1 条(上一篇那个 completed);其余 15 条,全是出事时的退路。

这 16 条路,没有一条是模型自己想的——模型只管吐字,一出错它就什么都做不了。替它兜底的,是外面那层 Harness。这就是本篇要讲的:模型一定会出错——可靠的从来不是模型,是 Harness 给每种出错备的退路。 智能在模型,可靠在 Harness——上一篇讲了前一半,这一篇,讲后一半。

那这条「退路」,到底长什么样?

2 退路就两种

这条退路,说穿了就两种。

第一种,自救再试:出事了,可还想再试一次——不退出,回循环顶上重来一轮。代码里叫 continue。话被掐了,就提个额、接着写;上下文爆了,就压一压、再递一次。每回自救,循环都会给自己留一笔小笔记,记下「这次为什么没退出、还要再转一轮」——字段名叫 transition。这笔笔记留给下一轮的自己,让它知道上一轮是怎么救的,不至于失忆地把同一条路重做一遍。

第二种,认输退出:出事了,而且再试也没用——干脆退出循环,带一个 exit reason 抛回给你。代码里叫 return。你按了中止、活转了太多次,都走这条。

剥到最瘦,循环的骨架就两条路:

01-skeleton.png

所以每一种「出事」,循环都要先做个二选一:这条,救得回吗? 救得回,就 continue 自救;救不回,就 return 认输。接下来几节,就按「话被掐 / 上下文爆 / 模型抽风」一种一种看,看它各是怎么救、又各是怎么定这条边。

3 话说一半被掐

先看最扎心的一种——max_output_tokens,话说到一半被掐。

这正是本篇开头那个画面:模型正给你吐字,字一个一个往屏幕上冒,突然服务器递回的不是一个完整答案,是一个 max_output_tokens,意思是「话太长,我给你截了」。半截话挂在屏幕上,没头没尾。

可它不是每轮都查这条。恢复的时机,卡在一个外层条件上:模型这轮说完了、且没要工具——也就是循环里那个 !needsFollowUp 分支才进。你想啊,模型还在递工具、活还没干完时,话被掐了根本不算事——下一轮跑工具、拿结果、再问一轮自然就接上了。只有模型自己觉得「我说完了」、却被服务器截了,这才真需要 Harness 介入。

进了这个分支,先核对消息上的 apiError 标记,确认挂的彩确实是输出上限,才往下走两段提额。

第一段叫 escalate。默认这轮调用顶着 8k 的输出上限;撞墙了,就原样重跑一次,只把上限提到 64k——常量名 ESCALATED_MAX_TOKENS。源码注释一句话把意图说尽:

retry the SAME request at 64k — no meta message, no multi-turn dance. (在 64k 上把同一个请求重跑——不塞元消息,不搞多轮拉扯。)

每轮只准 escalate 一次,守门的主要靠 maxOutputTokensOverride === undefined:这个字段一被设成 64k,本轮就再也吃不到这条路径。重跑前,循环往 transition 里记一笔 max_output_tokens_escalate——上一节说的「每一次 continue 都不是白跳」,就是记在这儿。

第二段叫 recovery。提额也救不回,说明 64k 都不够——这时循环不再重跑,而是往对话里注入一句话让模型接着说:

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.

这句话写得极克制:别道歉、别复述、断在哪接哪。每注入一次,maxOutputTokensRecoveryCount 加一,上限写死在 MAX_OUTPUT_TOKENS_RECOVERY_LIMIT:3 次。三次还接不上,循环就把这半截话交给你、本轮收尾。transition 里记的,是 max_output_tokens_recovery

02-escalate.png

到这儿,得停下来问几个「为什么」。

为什么不直接认输? 玩具就会。那个 8 行循环,撞上限就直接退出——半截话丢给你,循环结束。可 Claude Code 不肯:模型很可能只是被上限掐了,不是真没词儿;续写大概率接得上,而半截答案对你也是有价值的——读到一半的文件、改到一半的代码,能续上就是完整工作。值得再给几轮。

续写凭什么接得上? 模型每次调用都是无状态的,它「知道」前半截,是因为那半截就留在对话历史里——注入「接着说」后重新调一次模型,输入带着它刚说的半截,它读着往下接。这次新调用又有自己的全新输出预算;注入词还叮嘱「别复述、断在哪接哪、把剩下的拆小」,于是它只生成后半截——短,装得下,不会再撞墙。模型没有记忆,是 Harness 用对话历史,把两次独立调用缝成了一条连贯的回复:可靠的,从来不是模型,是 Harness

那为什么又封顶 3 次? 防的是另一头——一个死循环模型:你注入「接着说」,它从头复述一遍又撞墙;你再注入,它再复述再撞墙……没这道 3 次封顶,循环能被一个抽风模型拖进无限续写,token 烧穿地板。3 次,是 Harness 给「自救」画的硬边:救是有次数的,救不回就认。

几个权衡答过,这套恢复才算看清:不是「无脑续」,是先提额、再续写、有封顶,一档一档往上探。

可话被掐好歹还有「接着说」这条路。要是被截的不是字数,是整个上下文太长,服务器直接拒了呢?那可就不是一句话能续回来的。

4 上下文爆了,服务器拒了

话被掐好歹还是「字被截了」——请求被受理了,只是答案没给全。可要是这一轮递过去的对话历史本身就太长,长到服务器连看都不想看,直接递回一个 413(prompt_too_long)——那不是字被截,是整封信被退

模型根本没机会开口,错误信息被流式层暂时压住,怎么办? 在 !needsFollowUp 分支里,有段注释把策略写得极直白:先折叠、再压缩

Try collapse drain first (cheap, keeps granular context), then reactive compact (full summary). Single-shot on each — if a retry still 413's, the next stage handles it or the error surfaces. (先折叠、再压缩:折叠便宜、保细节,压缩是整体摘要;各只跑一次——还 413 就落下一级或认输。)

第一级 先折叠: 平时循环会把该折叠的先暂存着(能不折叠就不折叠);爆了,就把这些暂存的一次性折叠,减小上下文再试。便宜,而且保住了细节(摘要比整体压缩细)。

「折叠」是什么?对话里那些又长又旧的消息(例如老工具的大段输出),模型其实早不需要逐字看了。折叠就是把它换成一段短摘要,给模型看;原文没删,还存档在完整历史里——只是这一轮不再塞给模型。所以折叠之后,模型看到的那份上下文变小了;细节没丢,是被「折叠」藏起来了(模型能看摘要,看不到原文逐字)。一句话:这是「换一份给模型看」,不是「删原文」。

折叠也救不回,说明摘要都不够省,得继续动刀...

第二级 reactive compact,整体压缩:把整段历史压成一个摘要,塞回去。压缩是重活,所以这一级用一个布尔变量 hasAttemptedReactiveCompact 守着:默认 false,压缩过一次就置 true之后整个循环不再变回 false——也就是说,这一级整轮只跑一次。(transition 里记的是 reactive_compact_retry,跟 §3 那两个一样,跳之前先在状态里留一笔。)

03-collapse.png

两级都救不回,循环就在这儿 return prompt_too_long——彻底退出,把球抛给你。

和 §3 一样,停下来问个「为什么」。

为什么每级都只准跑一次?

因为自救最怕的不是救不回,是救成死循环。压缩一遍还太长,那就再压?还太长,再压?——只要服务器还在拒,循环就会把同一个动作重做一遍又一遍,token 一笔笔烧穿。源码注释就记着这个代价:

Resetting to false here caused an infinite loop: compact → still too long → error → stop hook blocking → compact → … burning thousands of API calls. (在这儿把闸拆了,曾经踩出死循环,烧掉成千上万次 API 调用。)

真实烧过钱,才补上的闸——每一级都得有一道:第一级看上一轮的 transition(就是 §2 那笔小笔记,折过就不再折);第二级用布尔变量 hasAttemptedReactiveCompact(压过就不再压)。防的不是「这次救不回」,是「自救自己变成灾难」。

顺带埋个钩:reactive compact 看上去就两行字带过,其实是 Claude Code 整套「四级压缩流水线」的冰山一角,后面再聊。

上下文压缩回去,循环就还有命。

5 模型本身,坏了

前面两种能救回来,前提都是模型还在好好说话。可要是模型本身抽风了呢?请求递过去,服务器抛回一个过载;再递,又抛;再递,还抛。不是字被截,也不是信被退,是模型不响应了。

模型坏了,和前两节一样有个套路,分两层:能切备用模型,就切过去重试(内层);切不了、或用备用模型也还坏,就认输退出(外层)

04-fallback.png

内层:切备用模型重试。 调模型那段代码,被一层小循环包着。模型要是抛回一个 FallbackTriggeredError——意思是「服务器过载了,但你配了备用模型」——循环就动手自救:把当前模型切到 fallbackModel,清掉这轮攒的半截产物,换个干净的工具执行器(防上一轮的旧 tool_use_id 漏进重试,卡成孤儿),然后 continue,用新模型把这轮重跑一遍。用户会看到一句提示:

Switched to {备用模型} due to high demand for {原模型}

注意分寸:内层只救这一种——「过载 + 配了备用模型」。抛回来的要是别的错,或根本没配备用模型,内层不救,直接 throw innerError 把错往上抛。

注意:内层看着是 while,其实最多跑两次——循环顶先把 flag 置 false,只有抓到特定错才置回 true。行为就是「主模型试一次,不行换备用再试一次」。和 §3/§4 一脉相承:自救是有次数的

外层:认输退出。 往上抛的错,被外层接住。外层不挑——它接住一切没被救下的抛错,递一句对用户友好的错误消息,然后 return model_error彻底退出循环

内层救的是**「瞬时抽风」:服务器忙一下、模型被踢一下,换个备用模型重跑大概率就好,值得救,所以 continue;外层兜的是「真坏了」**:不是忙,是真的抛了,再试多少次都一样,再 continue 就是烧 token,所以 return

回头看 §2 中两个退路:「continue 自救、return 认输」,这一节把它说到底:同一个错,救得回就 continue,救不回就 return continue 的潜台词是「我还想再试试」,return 的潜台词是「我尽力了」——内层替「救得回」兜底,外层替「救不回」收尾。

6 其余退路

到这儿,前面三种出错都看完了:话被掐max_output_tokens)、信息被退prompt_too_long)、模型坏了model_error)。可循环里那一堆出口,还有不少没这么戏剧性:你按了中止、转了太多次、Hook 把它拦下……它们各自是什么?为什么也得单列一条 exit?挨个看。这三类出口救的不是故障,是「再转没意义了」。

你主动打断,是头一类。

  • aborted_streaming——模型正吐字,你按了 Esc,流式当场中止。
  • aborted_tools——模型正跑工具,你点了取消,工具里掐掉。

两条都是 return,循环把「用户不想要了」翻译成 exit reason,抛回给你——再转也没意义,因为是你不要了。

安全护栏拦下,是第二类。

「安全护栏」靠的是 Hook——用户自己写的小脚本,挂在循环的几个关键节点:工具执行前(PreToolUse)、工具执行后(PostToolUse)、模型说「完了」时(Stop Hook)。模型每走到这些节点,Harness 先问 Hook 有没有意见——可以说「继续」「停下」「重做」。

Stop Hook 挂在「模型说完了」这一步,有两条出口:

  • stop_hook_prevented——Stop Hook 说「彻底停,别再转了」,return
  • stop_hook_blocking(continue)——Stop Hook 说「这次不算,带着反馈重来」,循环不退,把拦截意见塞回对话让模型再转一轮,transition 里记一笔。

PreToolUse / PostToolUse 挂在工具执行前后,有一条:

  • hook_stopped——工具 Hook 说「停」,return

自救未必是修故障,也可以是「听反馈重来」——这条和 §3/§4 一脉相承。

撞上限,是第三类。

  • max_turns——循环转太多次(撞默认上限),停。
  • blocking_limit——账号级限流,服务器连请求都不收,停。
  • token_budget_continuation——如果给任务设了 token 预算(「这活最多花 N token」),模型说完了但预算没花完,循环会让它接着干,而不是停下(希望模型尽量探索的场景)。这条目前没启用(feature flag 关闭),但是展示出来说明continue 家族的完整图。

你打断 / Hook 硬停 / 撞上限:救不回,return;Hook 有意见 / 预算没用完:救得回,continue。和 §5 同一个道理——都是循环在那个岔口,自己挑的。

这些不是「失败」——是循环主动选的逃生通道。每一条都对应一种「这时候再硬转没意义了」。

7 全景图

现在我们把前六节讲过、没讲过的全部出口和 recovery 摊开,长这样:

05-overview.png

图上有两条暗线,得点出来。

第一条:每一条「救回」的箭头都回到 TOP,而且带着一个 transition——上一轮怎么救的、为什么接着转,全在新一轮的 transition 里记着。循环不是失忆地转圈,是带着记忆转。

第二条:救回的箭头里,藏着两道防死循环的闸——prompt_too_long 那条路上,折叠 collapse 看上一轮的 transition.reason(已经折叠过就别再折),压缩 compact 用 hasAttemptedReactiveCompact 守着(压缩过就不再压)。两道闸,一道看上一轮状态,一道记本轮历史——自救和兜底之间,处处设防。

这就是入门篇「9 出口 + continue = 自救故事」的全景。16 条「出事了怎么办」,一条不少。

8 值班员的 16 条退路

入门篇那个值班员,当时只露了一手——不信专家那句「我说完了」,自己盯着有没有新查件单。这一篇钻完才看清,他真正在扛的活儿多得多:专家话说到一半被掐、递的资料太多整封信被退、专家直接抽风……每一种,值班员都备了一条自救的路,或一条认输的门。还处处设着闸——压缩过就不再压、续写最多 3 次——防着「自救」滑成死亡螺旋。

16 条退路看完,答案其实就一句:模型一定会出错——可靠的从来不是模型,是 Harness 给每种错备的退路。

模型负责「智能」——吐字、决策、递工具;Harness 负责「可靠」——出事了兜底、救不回认输、处处设防。这才是玩具和生产级真正的鸿沟——不是代码行数从 8 涨到 1700+,是那 1700+ 行里,全写着「出事了,怎么办」。

9 后续

可这一路里,还有个细节从头到尾被我们略过——

专家还在电话里吐字呢,助理(工具)就已经动身去查了。模型一个字一个字往外冒,工具一边已经在跑——你看到的不是「模型说完,轮到工具」,而是两者重叠在一起。模型还没吐完,Harness 凭什么知道该派哪个助理?又凭什么让助理提前动身?

我们下一篇,开启流式工具执行器。