1. Claude Code怎么知道,这回说完了
你按下回车。屏幕上字一个一个冒出来——Claude Code读文件、跑命令,最后把那段总结吐给你,然后,停了。
前面那些"写到一半跑去读文件"还好理解:模型想要工具嘛,给它就是。可最后这一次不一样——它没再要任何工具,直接收尾了。那你有没有想过:
它怎么知道,这回真的该停了?
你大概会猜:是模型自己说了句"我说完了"吧?上一回那张 8 行玩具骨架,就是这么停的。可这套让模型"动起来"的真实循环,偏不信模型这句话。
那它到底靠什么,知道自己说完了?这一回,我们钻进去。
2. 玩具的命脉,压在一个 if 上
先把上一回那 8 行玩具再请出来。它的全部"智能",压在中间这一行上:
if resp.stop_reason != "tool_use": # 模型不再要工具 → 说完了
break
模型每生成完一轮回答,服务器会在响应尾部附上一个字段 stop_reason,意思是"我这一轮为什么停下了":要去调工具就标 tool_use,话说完了就标 end_turn,字数撞到上限就标 max_tokens。说白了,这是模型对自己这一轮行为的自述——"我是为了调工具才停的"。
整个玩具的生死,就攥在这句自述上:它盯着 stop_reason——是 tool_use 就接着转(跑工具、把结果塞回去、再问一轮),不是就 break,循环结束。
干净、好懂。所以一个很自然的问题来了:
Claude Code 真的,就是这么写的吗?
3. 可真实代码,根本不信它
翻开 src/query.ts——那个真实循环 queryLoop 的老家,1700+行。我想找的,就是上面那个 if stop_reason 的真实版本。
结果翻到这么一条注释,赫然写着:
stop_reason === 'tool_use'is unreliable —— it's not always set correctly. (这个字段不可靠,不一定设得对。)
更绝的是,我在整个 1700 多行里搜了一遍 stop_reason——它就只出现了这一次,就是在这条警告里。也就是说,这个循环从头到尾,一次都没拿 stop_reason 来做判断。
那为什么不信它?注释那句"不可靠,不一定设得对",说的就是:模型有时候这一轮明明吐出了工具调用,stop_reason 却没标成 tool_use,标错了。循环要是信了这个标签,就会在该接着转的时候,误以为"模型说完了",提前退出——活儿干一半就停了。所以与其信模型的自述,不如看它的实际行动:有没有 tool_use 块,真出现在它吐出来的内容里。
真实循环就是这么干的。它用了一个布尔变量 needsFollowUp,专门记"这一轮模型有没有要过工具"。模型一边吐字,循环一边看:每冒出一个 tool_use 块,就把这个变量记成真;一整轮流完,要是它还是假,说明模型这一轮压根没要工具——那才是真说完了。
于是玩具里那个"信 stop_reason"的 if,在真实代码里变成了"看模型实际有没有要工具":
if not needs_follow_up: # 这一轮, 模型没要过工具
return "completed" # 那就真的说完了, 循环退出
注意两件事。
第一,循环是靠 return 退出的,不是靠 break 一个条件循环。真实代码的循环长这样:while True,顶上没有任何条件——它根本不是"当还要工具时就转"的 while(tool_use),而是"一直转,转到内部某次 return 把它打断"的 while(true)。
第二,人们常说的那个 while(tool_use),其实是个简写。真正的形态是:外面一个 while(true) 兜着,里面用"模型实际有没有要工具"这件事,替掉了那个不可靠的 stop_reason。行为上等价——还是要工具就转、不要就停;实现上,却把"信不信模型这句话",从模型手里夺了回来。
这,就是 8 行玩具和 1700 多行生产级代码之间,第一道裂缝。
4. 一个自己转圈的循环
把这套机制画出来,大概是这个样子:
这张图只有两条路,可它们撑起了模型全部的"动":要过工具,就去跑工具、把结果塞回去、回到循环顶再问一轮;没要过,就 return,结束。 你看到它"自己读了三个文件、改了一个",其实就是"要工具→跑工具→回顶"这条路被走了好几遍,每一遍都让模型基于新结果,再拍一次板。
把这张图写成代码,剥到最瘦,是这样:
# 真实 queryLoop 剥到最瘦: 不信 stop_reason, 自己记模型有没有要工具
needs_follow_up = False
while True:
for event in stream_model(messages, tools): # 模型边吐, 循环边收
yield event # 边收边往外喷(你看到的打字机效果)
if has_tool_use(event): # 模型吐出了一个 tool_use 块
needs_follow_up = True # 记下来: 这轮要过工具
if not needs_follow_up: # 这一轮模型没要过工具
return "completed" # 循环靠 return 退出, 不是 stop_reason
messages.append(run_tools(...)) # 跑工具, 结果作为新消息塞回去
needs_follow_up = False # 下一轮重新记
图里的每个箭头,对应这里的一行。有两个细节值得停一下:
一是流式。玩具是"调完模型、拿完整结果再往下走";真实代码是模型边吐 token、循环边收,每收到一帧就 yield 出去——你在终端看到字一个一个冒出来,就是这些 yield。流式改变的是体验(快、可中途打断),不是结构。(顺带一提:调模型那一格里,其实还套着个小循环管重试——模型偶尔抽风,重试一次再往下,细节先不管,留给后面。)
二是结果塞回去。工具的输出不是被悄悄用掉的,而是作为一条新消息塞回对话历史。对模型来说,它看到的永远是一条连续的对话——工具的"动手",被翻译成了对话里的"该你了"。
5. 值班员不再信专家那句"我说完了"
还记得上一篇# 按下回车之后,Claude Code 在 100ms 里干了什么? 那个值班室吗?值班员(queryLoop)拿到工单,打电话问专家(模型);专家说要查个文件,值班员就派助理(工具)去查、把结果带回来,再打电话给专家。如此循环。
当时我们说:直到某一次,专家说"行了,我懂了,答案是这个"——值班员才把答案整理好,从窗口递出来。
现在钻进循环,你才发现这里有个上一回没挑明的细节:值班员,其实没全信专家那句"我说完了"。
因为专家偶尔会"口误"——明明还想再查个东西,嘴上却说了"我说完了"(这就是那条注释讲的:stop_reason 不可靠,不一定设得对)。值班员要是照单全收,循环提前结束,活儿就干砸了。
所以值班员学乖了:他不看专家嘴上怎么说,只看这一通电话里,专家有没有递出新的查件单(就是那个 tool_use 块)。有新单子,说明专家还要动手,电话继续打;一通电话下来一张新单子都没递,那才是专家真的说完了。 代码里那个布尔变量 needsFollowUp,记的就是这件事:这一轮,专家有没有递过新的查件单。
一句话:智能在专家(模型)那边,可"专家到底说完没"这一道判断,Harness 不敢让专家自己说了算——它自己盯着。 这正是玩具和生产级代码的第一道分野:玩具天真地信了模型;生产级的 Harness,把判断权握在了自己手里。
6. 那它到底什么时候停
回到第 1 节那个问题:它怎么知道,这一刻该动手、那一刻该说完?
答案现在清楚了:它不"知道",它只是看着。 每一轮,它看模型有没有递出新的工具请求;有,就动手、回顶、再问一轮;没有,就 return completed,这一回合结束。所谓"行云流水的切换",不是谁提前规划出来的,而是这个"看模型要不要工具→动手→回顶"的循环,被模型一轮轮驱动出来的。
可这么一说,一个新的、更大的麻烦也跟着冒出来了:
while True,顶上没有任何条件。那万一模型一直递工具、永远不说"完了"呢?
玩具里这不算个问题——它就那么一直转下去,转到你 Ctrl+C。但 Claude Code 是要给人干活的,不能这么任性。真实代码里,这个 while(true) 的循环体,足足有 1700+ 多行。这 1700+ 行里,光"怎么退出"这一件事,就远不止 completed 一条路。
7. 十个出口,九个是自救
我把那个循环的所有退出点数了一遍——整整 10 个。
一个,是我们今天讲的 completed:模型这轮没要工具,正常说完,循环退出。干干净净。
剩下 9 个,每一个都对应一种"出事了":模型报错了、上下文太长被服务器拒了、数据流跑到一半被你打断了、工具被中止了、撞到轮数上限了、图片出错了,还有安全护栏(Hook)伸手拦下、不让它再往下走的……光是名字就够列一串:model_error、prompt_too_long、aborted_streaming、aborted_tools、max_turns、hook_stopped……
这 9 个出口,没一个是"正常说完"。它们全是自救动作——循环在出岔子的边缘,给自己留的逃生通道。
今天,我们只回答了"它怎么动起来":一个不信 stop_reason、自己记着"模型要不要工具"的 while(true),在"要工具→跑工具→回顶"里让模型一轮轮执行下去,直到某轮模型没要工具,return completed 退出。可"动起来",只是故事的一半。那 1700+ 行里更厚的另一半,是"怎么不崩"——工具失败了怎么重试、上下文快爆了怎么压缩、配额超了怎么降级、模型越界了怎么拉回来。这些,就藏在那一串异常出口,和循环里的若干条 continue 里。
那 9 个出口、那些 continue,每一种,都是一个自救的故事。我们下一篇,接着钻。