• 正文
  • 相关推荐
申请入驻 产业图谱

Claude Code主循环 Query 流程源码深度解析

5小时前
172
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

原标题:面试官皱眉:“你懂 Claude Code 主循环 Query 流程吗?” 我说:“就是一个 while 循环,调模型、跑工具、再调模型”,他:就这??

大家好,我是小林。

最近有位林友跟我说,他去面试,被问到一道很硬核的题目:「介绍一下 Claude Code 主循环 Query 的流程?

这题面试官想考的,其实不是 Claude Code 本身,而是看你对一个工业级 Agent 的循环架构有没有真正想过。

你是停在「调模型、跑工具」这种皮毛上,还是真的往下挖过:模型吐字的时候怎么控?出错了怎么兜?状态怎么跨轮不丢?这才是面试官想听你聊的东西。

很可惜,这位林友当时只答到「就是一个 while 循环,调模型、跑工具、再调模型」就接不下去了,面试官摇了摇头让他先回去等通知。

今天这篇文章,我就把 Claude Code 源码里这个主循环(叫 queryLoop)的设计,由浅入深给大家扒一遍。

整个主循环的实现核心在一个 1729 行的文件里,我会带着大家解答下面这些疑问,比如:

    敲完回车,Claude Code 内部到底发生了什么?从 ask 到 queryLoop:一句话怎么穿过四层调用?Agent 怎么知道「该用工具」还是「该停下来」?模型还在吐字、工具就开跑了?这怎么做到的?工具跑挂之后,对话凭什么还能继续?模型输出被截断了,怎么让用户根本感觉不到?

文章依旧硬核到底,发车!(比较肝,可以收藏起来慢慢看)

一、敲完回车,内部发生了什么?

我们先从一个最朴素的场景开始。

你在终端打开 Claude Code,敲一句「帮我修复这个 bug」,然后回车。30 秒后,Claude 给你返回了修复方案,并且把代码也改好了。

这 30 秒里,到底发生了什么?

很多没接触过 Agent 开发的同学,第一反应是:「这有啥,就是把你的问题发给大模型,大模型回个答案嘛。」

如果真这么简单,那 Claude 怎么知道你说的「这个 bug」具体在哪个文件?它又是怎么把代码改到磁盘上的?

多轮循环示意图

真实情况是这样的:你这句话发出去之后,大模型第一次回复你的可能不是答案,而是「我需要先看一下你提到的那个文件」。

Claude Code 听到这个,就去读那个文件,把文件内容塞回去再问一遍。大模型这时候可能又说「我得跑一下测试看看现在的报错」,于是 Claude Code 又去跑测试,把报错塞回去……

来来回回好几轮,模型才会说「我知道了,这个 bug 是因为 X,我已经把修复写好了」。

这时候这一轮对话才真正结束。

这个不停「调模型 → 看结果 → 决定下一步」的循环,就叫主循环(Query Loop),是整个 Agent 的「心脏」。

Agent 心脏示意图

理解到这一层还不够。主循环这个东西,你们听上去会觉得很简单:

while (true) {
  调模型()
  if (模型说要用工具) {
    执行工具()
  } else {
    break
  }
}

如果真这么写,能跑通一个 demo,但在真实生产环境里会被千锤百炼出无数个 bug:

    用户中途按 Ctrl+C 怎么办?工具跑到一半挂了,下一轮调模型会被 API 直接拒收,怎么救?模型输出超长被截断了,怎么续?上下文太长塞不进去了,要不要压缩?什么时候压?跑了 50 轮还没收敛,要不要强行止损?

Claude Code 的 1729 行主循环代码,80% 都在处理这些「异常路径」,真正的「正常路径」可能 200 行就写完了。这也是我说它是一份工业级教科书的原因。

主循环代码占比柱状图

接下来我们一层一层剥开。

二、从 ask 到 queryLoop

要讲清楚主循环,得先看你在终端敲的那句话,在代码里到底走了几道关。

为什么要分层?我打个生活类比。

你家水龙头流出来的水,看上去就是「打开龙头水就来」,背后其实经过了四道关:水厂净化 → 街道主管线 → 小区水箱加压 → 你家管道末端。每一道关都有自己的职责,不能直接把水厂接到你家水龙头,那样既不安全也不灵活。

Claude Code 的主循环也是这样分层的:最外面一层管「你怎么用它」(就像水龙头),往里一层管「这次对话怎么记账」(就像小区水箱缓存水量),再往里一层管「怎么把事件源源不断吐给外面」(就像主管线持续供水),最里面那一层才是「真正干活的循环本体」(就像水厂在 24 小时净化)。

这四层从外到内分别叫 askQueryEngine.submitMessagequeryqueryLoop

这四层在源码里的调用关系,简化成伪代码长这样:

// 第一层 ask:SDK 入口,一次性调用
asyncfunction* ask(params) {
const engine = new QueryEngine(config)
yield* engine.submitMessage(params.prompt)
}

// 第二层 QueryEngine:管这次对话的会话状态
class QueryEngine {
async *submitMessage(prompt) {
    // 处理 /斜杠命令、组装系统提示、注入上下文 ……
    yield* query({ messages, systemPrompt, tools, ... })
  }
}

// 第三层 query:流式包装层
asyncfunction* query(params) {
yield* queryLoop(params)
}

// 第四层 queryLoop:核心循环本体
asyncfunction* queryLoop(params) {
while (true) {
    // 准备消息 → 调模型 → 判断 → 执行工具 → 塞回结果
  }
}

注意每一层都是 yield* 接力往下传,意味着最里面 queryLoop 抛出的每一个事件,都能原封不动一路冒到最外面 ask 的调用方手里。这是「边干边吐」这条流水线能贯通的关键。

四层调用链水流类比图

我们从外往里看每一层。

**最外面那层叫 ask**,是给 SDK 一次性调用的便捷入口。你写个脚本想跑一次 Claude,最少代码量就是 await ask({ prompt, ... })。它内部其实只做一件事:新建一个 QueryEngine 实例,然后转交给它。

第二层 QueryEngine 才是真正管「会话」的家伙。它维护着这次对话的消息历史、文件缓存、权限拒绝记录这些跨轮持久化的状态。你可以把它想象成一个对话窗口的「记账本」,每说一句话、每读一个文件,都得在它这里登记。

第三层 query 长得很特殊,它是个「会一边干活一边吐结果」的函数。普通函数是干完所有事情、最后一次性 return 一个结果。但 query 不一样,它会在干活的过程中,每完成一件事就立刻把结果抛出去给外面用。这种「边干边吐」的函数,在 JavaScript 里有个专门的名字,叫异步生成器(async generator)。

最内层 queryLoop 才是 while (true) 循环本体,是真正干活的「心脏」。

讲到这里你可能会问:为啥要分这么多层?尤其是 query 这一层,它就只是个简单的包装,为啥不直接把 queryLoop 暴露出去?

这就要讲到主循环最关键的一个设计选择:为什么必须用异步生成器?

我们用一个生活类比来理解。

水龙头 vs 水桶类比图

普通的 async 函数,就像让你接一桶水。你打开水龙头之后,必须等水接满了,整桶端走,才能用。对应到 Agent 上,就是「你问一句,等几十秒模型把完整答案吐完,你才能看到结果」。

异步生成器就是水龙头本身。模型每吐出一个字符、每跑完一个工具,立刻就能流出来给你看。

这就是为什么你在 Claude Code 里敲完回车,能立刻看到模型一个字一个字地在打字,而不是干等几十秒突然蹦出一大段文字。这种「边干边吐」的能力,不仅是用户体感,更是后面第五章会讲到的「流式 + 并行工具执行」的前提条件。

事件接力流水线示意图

异步生成器的语法长什么样?我们看一眼源码(来自 query.ts),里面有两个关键标记:

export async function* query(params) {
  const terminal = yield* queryLoop(params, ...)
  return terminal
}

第一个是函数前面那个 *(写法是 async function*),它告诉 JS 这是一个异步生成器,不是普通函数。第二个是 yield* 这个表达式,它的意思是「把 queryLoop 里所有抛出来的东西,原封不动地接力往外抛」,专门用来串联两个生成器。

这层包装看上去什么都没做,但它的作用是把对话生命周期的收尾工作(比如最后还要通知一些清理事件),和核心循环逻辑解耦开,让 queryLoop 只管「转圈」,不操心「转完之后还要给谁打招呼」。

四层职责对照表

四层调用链解释完了,我们终于可以走进最里面那间屋子,看看主循环转一圈到底干了啥。

三、到底跑了哪些动作?

queryLoop 这个函数有近 1500 行,但骨架就是一个 while (true)。每一轮迭代,按顺序做五件事。

主循环五步流程图

这张图就是整个主循环的「素描」。如果你更喜欢从代码视角看,把这个流程翻译成伪代码就是:

async function* queryLoop(params) {
let state = { messages: [...], turnCount: 1, ... }

while (true) {
    // 1. 准备消息:必要时压缩
    const messagesForQuery = maybeCompact(state.messages)

    // 2. 流式调大模型,边收边处理
    let toolUseBlocks = []
    let needsFollowUp = false
    forawait (const chunk of callModel(messagesForQuery)) {
      yield chunk  // 文字块即时抛给用户
      if (chunk 是 tool_use 块) {
        toolUseBlocks.push(chunk)
        needsFollowUp = true
      }
    }

    // 3. 判断继续还是结束
    if (!needsFollowUp) {
      return { reason: 'completed' }
    }

    // 4. 执行所有 tool_use(可并行/串行)
    const toolResults = await runTools(toolUseBlocks)

    // 5. 塞回结果进下一轮
    state = {
      ...state,
      messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
      turnCount: state.turnCount + 1,
    }
    // continue 回到 while 头部
  }
}

伪代码里这个 state 对象就是第六章要展开讲的「跨轮状态对象」,这里先记个脸熟。

下面我把每一步拆开讲。

第一步:准备消息。

每一轮开始,主循环要拿到「截至目前的完整对话历史」,准备喂给模型。这里有个细节很容易被想当然:消息历史不是每轮开头都主动压一遍的,而是被动触发。主循环先按原样把消息发出去,只有 API 返回 prompt_too_long(413)拒收了,才补救压缩一次再重试这一轮。如果压完还是塞不下,就老老实实退出。

为什么不主动压?因为压缩本身也要烧 token 调一次小模型做摘要,能不压就不压。主循环的策略很朴素:先试试看,被拒了再补救。

State.hasAttemptedReactiveCompact 这个标志位的作用,是同一轮内防止反复压。一次压完还是塞不下,就别再压第二次了,避免陷入「压缩 → 还是塞不下 → 再压」的死循环。新一轮开始时这个标志会被重置。

Claude Code 的压缩策略我之前专门写过一篇(公众号文章《Claude Code 上下文压缩机制详解》),这里就不重复展开了。

消息历史滚雪球示意图

第二步:流式调大模型。

讲这一步之前,我得先帮大家把一个后面会反复出现的概念铺一下,否则后面看代码会很懵。

模型回话的两种姿势

大模型每次回复你的内容,并不是只有一种格式。仔细看 Claude API 的返回,你会发现模型每次回复,里面可能塞着两种东西:

第一种是「文字块」,就是普通的文字内容,比如「这个 bug 是因为变量没初始化」。这是模型在跟你说话。

第二种是「工具调用块」,长得像 { 工具名: "Read", 参数: { 文件路径: "config.ts" } } 这种结构化的请求。这是模型在告诉你「我现在想用某某工具,参数是这些,麻烦你帮我跑一下」。

这种「工具调用块」在 Anthropic API 协议里有个专门的名字,叫 tool_use 块(直译就是「使用工具」的意思)。后面的文字里我会经常用这个词,你只要记住「模型说要用工具的请求,就叫一个 tool_use 块」就够了。

文字块 vs tool_use 块对比图

铺垫完毕,回到第二步。

调模型这一步是用流式接收的,模型每吐出一小段内容(可能是几个字符),主循环立刻就能拿到。收到的可能是文字块的一部分,也可能是 tool_use 块的一部分。

主循环一边收,一边干两件事:把文字块往上抛出去(让用户能即时看到模型在打字),把 tool_use 块攒起来准备等会儿执行。

第三步:判断继续还是结束。

模型流完之后,主循环要回答一个最核心的问题:这一轮对话结束了,还是要继续?

这是整个主循环的「决策点」,我下一章专门讲。

第四步:执行所有 tool_use。

如果判断要继续,就把模型这一轮要求的所有工具调用都跑掉,拿到每个工具的执行结果。Claude Code 在这一步有一个非常骚的「流式并行」设计,第五章细讲。

第五步:塞回结果,进下一轮。

所有工具结果跑完了,把它们当作「user 角色的消息」塞回消息历史里,更新一下「跨轮要带的状态对象」(这个状态对象第六章会专门讲),然后 continue 回到 while 头部。

五步骨架环形图

注意一个细节:每跑一轮,消息历史都会变长(assistant 的回复 + 工具结果)。如果主循环跑了 20 轮,那第 20 轮发给模型的消息历史里,就有前 19 轮所有的对话和工具结果。

这也是为什么压缩机制要存在:消息历史会像滚雪球一样越滚越大,不压就会爆炸。

一轮迭代消息累积示意图

主循环的骨架就这样。但「骨架」不等于「细节」,真正考验工程功力的是后面三章要讲的东西。

四、Agent 怎么判断?

承接上一章的「决策点」,主循环第三步那个判断到底怎么做的?

我跟你说,这个判断简单到出乎你的意料:就看模型返回里有没有 tool_use 块

主循环内部用一个布尔变量记录「这一轮要不要继续」,这个变量在源码里叫 needsFollowUp(直译就是「需要后续动作」)。判断逻辑就一条:

有 tool_use 块 → 把 needsFollowUp 标记为 true → 执行工具 → 继续循环没有 tool_use 块 → 模型在跟你说话 → 结束循环

退出的时候,主循环还会附带一个「退出原因码」(在源码里叫 reason),告诉外面是因为啥结束的。最常见的退出原因叫 completed,意思就是「正常完成」。代码大致长这样:

if (!needsFollowUp) {
  // 模型没要求调工具,对话正常结束
  return { reason: 'completed' }
}
// 有 tool_use,跑完工具后 continue 回循环头

就这么个判断,没有什么复杂的状态机,没有什么意图识别,没有什么投票表决。

为啥这么简单?因为「决定下一步做什么」这件事,本身就是大模型的工作。你只要看它最后吐出来的是「文字」还是「工具调用请求」,就知道它的意图了。

我打个比方。你跟工位旁边的同事说:「帮我看下这个 bug。」他要么回你:「我得先看看你那段代码。」(动手 → 用工具),要么直接告诉你:「这是因为 X 导致的,你这样改一下就好了。」(停下 → 给答复)。

Claude 主循环的判断逻辑就是这么朴素。

tool_use 决策类比图

但是!「停下来」并不只有「正常完成」这一种姿势。

这才是真正的工程深水区。

在 Claude Code 的主循环里,退出原因有十多种,每一种背后都对应着一个曾经踩过的坑:

正常完成completed):模型说「搞定了」,对话结束。这是最幸福的一种

轮数封顶max_turns):调用方传了 maxTurns 参数时生效,跑到上限就强行止损,防止模型跑飞烧钱(不传则不封顶,由其他退出条件兜底)

流式调模型时被中断aborted_streaming):模型还在吐字,用户按了 Ctrl+C

工具执行时被中断aborted_tools):工具跑到一半,用户按了 Ctrl+C

输入超长救不回来prompt_too_long):消息历史太长,连压缩都塞不进上下文窗口

输出超长救不回来max_output_tokens_recovery):模型输出被截断,尝试 3 次续写后还是不行

被用户钩子拦下stop_hook_prevented):用户配置了自定义钩子主动喊停(比如规定 git push 前必须先跑 lint,lint 没过就拦下,这个钩子机制叫 Stop Hook)

图片格式错误image_error):传图的时候格式不对

这里列的是最有代表性的 8 个。源码里实际定义了 17 种 reason,剩下的多是细分子情况,原理一致,不影响理解。

看到这十几种 reason,你应该能感受到一件事:一个工业级的 Agent 主循环,它要兜住的从来不是「正常退出」,而是十几种「异常退出」。

我们用一张图把这些退出 reason 按性质归一下类:

主循环退出原因分类图

这张图能让你直观感受到:正常完成只是分支里最小的一支,其他三支才是大头

每一种异常退出,都不是一个简单的 throw new Error,而是需要:

第一,识别这种错误状态(每种错误的特征不一样);第二,做必要的清理(比如取消正在跑的工具、解锁资源);第三,返回结构化的 reason,让外层调用者知道是什么原因结束的,方便上报、重试或者给用户友好提示。

主循环工程量冰山图

这才是「主循环」三个字背后真正的工程量。

五、执行工具

讲完决策,我们看主循环第四步:执行工具。

这一步是 Claude Code 整个主循环里最骚的设计,我必须单独开一章来讲。

我们先想想朴素做法是什么样的。

朴素串行 vs 流式并行时间线

朴素做法是这样的:

    • 第一步,等模型完整流完,把所有

tool_use

     块都收齐。第二步,挨个执行这些工具,等所有工具跑完。第三步,把结果塞回去,进下一轮。

这个流程没毛病,能跑通。但仔细想想,模型在「吐字」期间,工具在干等。工具开始执行了,模型又在干等。两段时间完全串行,CPU 和网络都在浪费

Claude Code 的做法是这样:

模型一边流式输出,主循环就一边监听。一旦识别出一个完整的 tool_use 块,立刻把它丢给后台开始执行,不等模型流完。等模型流完,最早开始执行的那个工具,可能已经回结果了。

这个负责后台执行的对象叫 StreamingToolExecutor(直译就是「流式工具执行器」)。它的核心就两个动作:「边收边开跑」和「最后一次性收结果」。

我把源码精简到最小,就两行:

// 模型流式输出时,每识别到一个工具调用就立刻丢到后台开跑
streamingToolExecutor.addTool(toolBlock, message)

// 模型流完后,把所有已完成的工具结果一次性收回来
const toolUpdates = streamingToolExecutor.getRemainingResults()

第一行 addTool,发生在主循环监听到模型吐出一个 tool_use 块的瞬间。这个工具立刻在后台开跑,主循环不等它,继续监听模型的下一段输出。

第二行 getRemainingResults,发生在模型完整流完之后。这时候所有工具都已经在后台跑了一会儿了,这里只是把已完成的结果一次性收上来。

点菜类比示意图

用「点菜」打个比方就清楚了。

传统模式是:你完整说完「我要一个红烧肉、一个清炒空心菜、一个鱼香肉丝」,服务员拿着单子跑去厨房,厨房才开始一道一道做。

Claude Code 的模式是:你说「我要一个红烧肉」,服务员立刻冲到后厨喊「红烧肉一份!」,你接着说「再来一个清炒空心菜」,服务员又喊「空心菜一份!」。等你点完,红烧肉已经在锅里炒了一半了。

最终上菜时间,比传统模式快了一大截。

最终用时对比柱状图

但并不是所有工具都能这么并行。这里有个边界要讲清楚。

Claude Code 的工具在定义的时候,每个工具都有一个属性,标明它是「只读」还是「会改东西的」。

    只读工具(比如 Read、Grep、Glob):可以随便并行。你同时读 10 个文件,互不干扰会改状态的工具(比如 Edit、Write、Bash):必须串行。两个写文件操作如果并发跑,可能互相覆盖

这个属性是在工具定义时就声明的。

如果开发者一时忘了写,框架会 fail-closed 兜底,默认当成「会改东西的工具」(值为 false),强制串行执行。

也就是说,宁可错杀让你慢一点,也绝不会因为漏写而出现并发踩踏。

只读 vs 写操作并行性对照图

讲到这里,我想抛出一个延伸思考:Claude Code 让 Agent 跑得快的秘密,不在于模型多聪明,而在于工程上把每一段空闲时间都榨干

这种「流式 + 并行」的设计,是把模型生成时间和工具执行时间高度重叠,最大化利用每一秒。

这才是真正的工程功力。

六、还有哪些精彩设计?

前面五章把主循环的正常路径讲完了。但回到开头那个问题,你按下回车后那 30 秒里,主循环转的可不只是「调模型 + 跑工具」这么干净。

中间随便一个意外:网络抖一下、你按一次 Ctrl+C、模型一口气想说太多话,都可能让对话卡死、报错或者结果残缺。

Claude Code 之所以让你感觉不到这些意外,是因为主循环里藏了一堆兜底机制。这一章就挑三个最关键的细节讲讲:你看不见的部分,主循环都帮你处理了什么。

6.1 跨轮状态对象:为什么不能用「全局变量」糊弄?

我们先想一个朴素问题:主循环是个 while (true),那「这一轮发生了什么」要怎么传给下一轮?

最朴素的做法是用闭包变量或者全局变量,比如「重试次数」就放一个外部变量,每轮 +1。能跑,但调试起来是噩梦:你看到代码继续循环了,根本不知道是因为哪个标志位被改了。

Claude Code 的做法是把所有跨轮要传的字段,全部打包到一个状态对象里,每一轮迭代开头读出来,结尾构造一个新的写回去。这个状态对象在源码里叫 State

State 的关键字段长这样(我精简了一下,只保留最有代表性的几个):

type State = {
  messages: Message[]                    // 累积的对话消息历史
  turnCount: number                      // 当前是第几轮
  maxOutputTokensRecoveryCount: number   // 输出截断已经恢复了几次
  hasAttemptedReactiveCompact: boolean   // 本轮是不是已经触发过压缩了
}

状态对象跨轮传递示意图

注意看后面两个字段,一个是「已经恢复了几次」,一个是「本轮是不是已经压缩过了」。它们的本质都是「计数器」或「防重复标志」(同一轮内只让某件事发生一次),目的就一个:避免无限循环

举个例子:模型输出超长被截断了,主循环尝试恢复一下。如果恢复又失败,再来一次。但你不能无限重试,否则就死循环了。所以要计数:最多 3 次,超过就老老实实退出

再比如:上下文塞不下了,主循环触发一次压缩。但同一轮内只能压一次,否则连续压缩没意义还浪费 token。所以要标志位:本轮已经压过了,就别再压了。

这些状态如果全藏在闭包变量里,调试的时候你根本不知道循环是因为什么继续的。摊在一个状态对象里,每一轮的「现场」都一目了然。

闭包变量 vs 状态对象对照图

这就是为什么你用 Claude Code 跑几十轮工具也不会陷进死循环,所有「还要不要继续」的标志都摊在明处,每一轮的「现场」都能干净结束,不会因为某个藏起来的变量没复位就鬼打墙。

6.2 工具跑挂之后,对话凭什么还能继续?

这是一个 Anthropic API 的「冷知识」,没踩过坑的人根本想不到。

我先把这条 API 协议规矩说清楚:

第三章我们讲过,模型回话时会吐出 tool_use 块(工具调用请求)。工具跑完之后,要把执行结果以另一种格式塞回消息历史,这种「工具执行结果」叫 tool_result 块。

API 协议有一条死规定:每个 tool_use 块,必须有一个对应的 tool_result 块跟它配对。如果消息历史里有个孤零零的 tool_use 没人接,下一轮请求直接被 API 拒收,报错说「工具调用 ID 对不上」。

正常情况下没问题,工具跑完就有结果,配对很自然。但出错的时候呢?

比如:模型刚吐完 tool_use 块,网络突然断了,工具根本没开始跑;或者工具跑到一半,用户按了 Ctrl+C;或者模型出了问题,主循环临时切换到备用模型(这种切换机制叫「模型降级」),前一个模型吐出的 tool_use 就没机会执行了。

这些情况下,消息历史里就有了「孤立的 tool_use 块」。你不处理,下一轮想再调模型,API 直接拒收,会返回一个「请求格式不对」的错误。

孤立 tool_use 块三种场景图

Claude Code 的方案听起来有点奇葩:合成一个假的工具结果塞回去,内容写「这个工具因为 XX 错误没执行」。主循环里专门有段代码干这事,函数名也很直白,直译过来就是「把缺失的工具结果块补上」(源码里叫 yieldMissingToolResultBlocks)。

这段补救代码就三个关键:标明类型是 tool_result、内容是错误消息、附上对应的工具调用 ID 让 API 能配对上。简化后长这样:

yield 一条用户角色消息({
  类型: 'tool_result',           // 标明这是个工具结果
  内容: '这个工具没执行成功',     // 解释为啥失败
  is_error: true,                // 打个错误标记
  tool_use_id: 对应的工具调用ID, // 让 API 能配对上
})

孤立 tool_use 补救方案图

这种设计可以叫「先认错,再继续」。API 协议是死的,但通过合成一个明确标了「这是错误」的假结果,主循环至少不会因为一个错误就彻底卡死,还能让模型知道「上个工具失败了」,从而做出合理的下一步决策。

这就是为什么你用 Claude Code 按 Ctrl+C 打断之后,下一句话依然能接得上,主循环替你把那个「没人接的工具调用」伪造了一个错误结果,让 API 协议这个死规矩不会因为一次中断就把整个对话废掉。

6.3 模型输出被截断了,怎么让用户根本感觉不到?

最后一个细节,关于「模型输出超长被截断」这个常见问题。

模型每一次返回都有个最大 token 数限制。Claude Code 常规默认是 32k,但在某些灰度场景下(背后是个可以开关的「特性开关」控制位)会被压到 8k 以控费。一旦模型一口气想吐 12k 的内容,到 8k 就被强行截断,剩下的 4k 没出来。这种状态在 API 协议里叫「输出 token 上限错误」。

换作一个简单点的实现可能就直接弹个错误框:「输出过长了,请重试」。或者更糟,干脆忽略截断,让你看到一段戛然而止的回答。

Claude Code 的处理是两段式的,非常有意思。

第一段:静默升档。

模型第一次触发 8k 截断时,主循环不报错,悄悄把上限调到 64k 然后重试这一轮。用户完全感知不到出过错,只是觉得「这次模型答得稍微慢了点」。

8k 到 64k 静默升档示意图

第二段:在下一轮消息里轻轻推一把,让模型自己续写。

如果调到 64k 还截断,那是真的输出太长了。

这时候主循环会在下一轮的消息里,塞一段话给模型:「上次输出超长被截断了,请直接从断点续写,不要道歉、不要回顾你刚才在干什么、把剩余工作拆成更小的块」(英文原文是 Output token limit hit. Resume directly — no apology, no recap...,后面还会指示模型「从断点续写、把剩余工作拆小」)。

让模型从断点继续往下写。

这种「在消息里轻推一把提示模型续写」的动作,英文里叫 nudge(轻推)。但 nudge 不能无限循环,会陷入死锁。

所以源码里写死了 3 次上限:续写 3 次还不行,主循环就老老实实退出,返回原因「输出超长救不回来」。

max_output_tokens 两段式恢复流程图

我想在这里抛出一个延伸思考:工业级的容错,不是「报错弹窗」,而是用户根本感觉不到出过错

你用 Claude Code 这么多天,可能从来没注意过模型输出被截断这件事,因为它在背后默默地帮你处理了。这就是好工程的样子。

七、写在最后

从整篇文章扒下来,我觉得 Claude Code 主循环背后有四个设计哲学,值得反复琢磨:

一、边干边吐,让感知前置。 用异步生成器把每个中间事件即时抛出去,用户体感「响应快」就是这么来的。

二、状态显式管理。 该计数的字段全部摊在一个状态对象里,不藏在闭包变量里。能调试的状态才是可靠的状态。

三、引擎层不掺业务。 主循环不知道工具具体在干嘛,新增能力零侵入,只需要往工具池里塞一个新工具就完事。

四、错误恢复优先于优雅。 每个失败路径都有兜底。宁可丑陋地合成一个假的工具结果塞回去,也不能优雅地崩溃。

回到开篇那个面试场景。

再被问到「Claude Code 主循环 Query 是怎么跑的?」.

你能从「为什么主循环不能简单写成 while true」一路讲到「工具跑挂之后怎么让对话不卡死」「模型输出截断了怎么悄悄帮用户续上」,再补一句「主循环用流式监听 + 后台并行,把模型生成时间和工具执行时间叠在一起」,面试官就知道你不是在背概念,而是真的拆过源码、想过工程问题。

这才是这道面试题真正想考的东西。

如果你觉得这篇文章对你有启发,欢迎点个「在看」和「赞」,这是对小林最大的肯定和帮助。

我们下一篇见啦!

相关推荐