读懂 runAgent 主循环:一个 agent 的心跳是怎么跳的
这篇讲什么
上一篇我们读了 agentNudges.ts------agent 的"防呆层",治模型犯傻。但那只是挂在主循环上的一个零件。今天我们读主角:主循环 runAgent.ts 本身。
它是一个 while(true) 的流式 generator,把"推理、工具、压缩、兜底、中止"串成一条流水线。整个 agent 子系统的所有设计思想------前缀缓存契约、控制流与提示词解耦、让模型自决------都在这一个文件里落地。可以说,读懂主循环,就读懂了这个 agent 的骨架。
源码在 runAgent.ts,全部在 src/core/src/agent/ 下。建议对着源码读这篇。
一、先看全貌:一轮推理的生命周期
读主循环最大的障碍,是它有太多分支、太多出口。一不留神就迷路。所以我先给一张阶段表------这是读这篇、也是读源码的钥匙。
主循环每跑一圈(一次 while(true)),固定按下面的顺序走一遍。核心认知只有一条:主循环的每一步,要么让 agent「继续下一轮」,要么「终结整个 agent」。没有第三种结果。
| 阶段 | 干什么 | 代码位置 | 结果 |
|---|---|---|---|
| 0. 轮数检查 | 超过 500 轮?防失控兜底 | :110 |
超了 → 终结 |
| 1. 中止检查 | 用户点了停止? | :119 |
是 → 终结(已中止) |
| 2. 窗口治理 | 上下文快满了?触发压缩 | :141 ensureFitsWindow |
出错 → 终结;否则继续 |
| 3. 选 nudge | 这轮要不要软推一下模型 | :174 pickNudge |
继续 |
| 4. 流式推理 | 把上下文发给模型,边收边吐字 | :178 streamInference |
中止/出错 → 终结;否则继续 |
| 5. 校准 | 用真实 token 修正估算 | :197 |
继续 |
| 6. 落盘 | 把模型回复写进上下文 | :214 |
继续 |
| 7. 退出判定 | 模型没调工具?想收尾 | :223 |
收尾 → 终结;否则继续 |
| 8. 工具执行 | 执行模型调用的工具 | :259 scheduleToolCalls |
中止/致命错 → 终结 ;否则进入下一轮 |
看明白了吗?第 0~7 步,任何一步判定为"终结",就立刻 yield final + return,整个 agent 结束。 只有走到第 8 步、且工具正常执行完,才会回到 while 顶部,开始新一轮。这就是 agent 的心跳:一轮推理 → 干活 → 再一轮推理 → ...... → 直到某一步说"该结束了"。
光看表可能还抽象,拿几个真实场景盘一下:
-
场景 A:你问"这个项目用 React 还是 Vue?" ------ 第 4 步推理完,模型直接给答案、不调工具。第 7 步判定"无工具调用 = 收尾",
interceptFinal放行 →yield final。整个 agent 一轮就结束。 -
场景 B:你说"把 a.ts 里的死循环修了"。 ------ 第 4 步推理,模型决定先
read_file。跳过第 7 步,走到第 8 步执行读取 → 进入下一轮 。第二轮推理,模型edit_file→ 又一轮。第三轮推理,模型不再调工具,给出修改说明 → 第 7 步收尾 → 终结。转了 3 轮。 -
场景 C:你嫌它慢,点了停止。 ------ 不管它正卡在第几步,下一轮开头第 1 步检测到
signal.aborted→ 立刻终结。用户随时能喊停。 -
场景 D:模型卡死循环,连调 3 次一模一样的工具。 ------ 第 8 步之前有个
breaker.check(:238),连续 3 次相同签名 → 判定为病态重复 → 终结。agent 不会无限空转烧 token。
这张表 + 这四个场景,就是读 runAgent.ts 的地图。后面讲的设计决策和技术难点,你都能在表里找到对应的位置。
二、四个设计决策:主循环的灵魂
决策一:主循环是个流式 AsyncGenerator,且"终结权"集中
函数签名是这样的:
ts
export async function* runAgent(message, options): AsyncGenerator<AgentEvent>
注意那个 * 和返回类型 AsyncGenerator<AgentEvent>。主循环不是普通函数,是一个可以随时吐出事件的迭代器 。它一边推理一边 yield 出 text.delta(逐字正文)、thinking.delta(思考过程)、tool.start/tool.end,上层(CLI / VSCode 插件)消费这些事件来驱动 UI------你看到的打字机效果、工具执行动画,就是这些事件喂出来的。
这里有个关键约定:能 yield final(终结事件)的,只有主循环自己。 被它调用的 streamInference、scheduleToolCalls 这些子模块,只 yield 自己负责的局部事件(流式文本、工具事件),然后用 return 带一个判别结果回来,由主循环决定要不要 yield final。
ts
// streamInference 不 yield final,而是 return 一个判别结果
const infResult: InferenceResult = yield* streamInference({...});
if (infResult.kind === 'aborted') {
yield { type: 'final', text: infResult.partialText || ... };
return;
}
为什么不让子模块自己 yield final 收尾?因为"整个 agent 结束"是一件很重的事------它要触发 Stop hook、要持久化校准状态(见难点四)。这些收尾动作只能在主循环的 finally 块里做一次(:284)。如果终结权散落各处,收尾逻辑就得在每个子模块里重复,迟早漏。所以设计上把终结权牢牢攥在主循环手里,子模块只管"汇报情况",主循环统一"执行死刑 + 善后"。
决策二:message0 / message1 是铁打的下标契约
主循环接到的 message 数组,头两个位置是写死的:
ts
// message[0] = 系统提示词
// message[1] = 滚动摘要槽(压缩时往这里塞摘要)
这两个下标是整个 agent 子系统最硬的约定,源码注释、README、CLAUDE.md 里反复强调"勿改前两个下标"。为什么?
-
message[0]是系统提示词,它的内容必须跨轮绝对稳定 ------这正是上一篇讲过的 P0-4 前缀缓存契约:DeepSeek 有隐式前缀缓存,前缀一动,缓存全废,账单翻几倍。所有"想往系统提示词加点东西"的操作(注入记忆、注入 locale、注入 skill),都只能追加到message[0].content末尾,绝不能在前面插、绝不能重排。 -
message[1]是滚动摘要槽。上下文压缩时,被压缩掉的历史会变成一段摘要塞进这里。把摘要固定在第二个位置,意味着即便压缩了,message[0]之后的前缀也尽可能稳定,缓存损失最小化。
读主循环时,你会看到 ensureFitsWindow(压缩)、prepareToolsAndInjections(注入)全都绕着这两个下标转。记住这条契约,那些看似啰嗦的注释就都通了------它们全是在保前缀缓存。
决策三:主循环只是薄调度层,脏活全外包
你翻主循环会发现,真正"干活"的逻辑都不在这:
ts
const { rawTools, cleanedToolSchemas } = await prepareToolsAndInjections(...); // 工具表 + 注入
await ensureFitsWindow({...}); // 窗口压缩 → truncate.ts
const nudgeMsg = nudges.pickNudge(round); // 软推 → agentNudges.ts
const infResult = yield* streamInference({...}); // 流式推理 → streamInference.ts
const repeatVerdict = breaker.check(...); // 硬熔断 → repeatBreaker.ts
const scheduleResult = yield* scheduleToolCalls({...}); // 工具调度 → toolScheduling.ts
主循环自己几乎不写业务逻辑,它只是按阶段表,依次把这些模块调一遍 。每个模块都是一个"职责清晰的纯单元"。这是上一篇文章讲过的"控制流与提示词解耦"的延续------只不过那篇是讲单个文件内部的解耦,这里是讲整个 agent 子系统的模块边界。
好处很直接:你想调压缩策略,去 truncate.ts;想调防傻阈值,去 agentNudges.ts;想调工具审批管线,去 toolScheduling.ts。主循环永远不用动。 它是稳定的中轴,周围是可替换的零件。
决策四:让模型自决,硬上限只做极高兜底
看轮数治理这段注释(:74):
主机制:周期性 ephemeral nudge 由模型自决"收尾给答案"还是"继续推进";兜底:MAX_AGENT_ROUNDS 极高(500),仅防失控烧 token 的病理死循环。
这是一个很克制的设计。很多 agent 框架喜欢设个"最多 20 轮就硬停",听起来安全,但复杂任务(大型重构、多文件调研)往往就是要跑几十轮。硬停等于把没干完的活掐断。
DeepSeeker-Code 的做法是双轨:
-
软轨(常态) :靠上一篇讲的 nudge,周期性提醒模型"你是不是该收尾了"。模型自己判断任务完成没、该不该停。完成就自然收尾,没完成就继续------把"什么时候停"的决策权交给最懂任务的模型自己。
-
硬轨(兜底):500 轮的天花板。正常任务永远摸不到,只有模型真的进了死循环、又无视所有 nudge 时才会撞上。撞上了也不是报错,而是温和收尾、留一句"回复'继续'即可接续"------用户随时能把任务接下去。
克制地设上限,是对模型能力的基本信任。 读源码时,别被 500 这个数字吓到,它不是常态约束,是保险丝。
三、一轮推理逐阶段拆(带行号)
对着阶段表,把每一阶段在源码里具体怎么做的,过一遍。这是读源码时的"导游路线"。
第 0 步,轮数检查(:110) 。round 每轮自增,超过 500 就打 stopReason='limit',yield 一段带"接续"提示的 final 收尾。温和、可恢复。
第 1 步,中止检查(:119) 。检测 signal?.aborted 必须排在 round.start 之前------否则用户刚点完停止、又多发一个 round 事件,前端会多渲染一圈。细节,但正是这种顺序敏感的判断,区分了"能用"和"顺手"。
第 2 步,窗口治理(:141) 。ensureFitsWindow 判断上下文有没有逼近窗口,是就触发压缩(往 message[1] 塞摘要)。注意它传进去的 correctionRatio 和 lastReal/lastCached------这是难点四要讲的校准机制,让压缩按"真实 token 口径 + 缓存健康度"来判,而不是拍脑袋。压缩请求本身可能被用户中止打断(抛 "Request aborted"),这里有个内层 catch 优先判中止走安静收尾。
第 3 步,选 nudge(:174)。上一篇的主角。一行调用,拿到这轮要不要软推。
第 4 步,流式推理(:178) 。整条流水线的核心。yield* 委托 streamInference,它负责把上下文发给模型、逐字流式回传、拼接分片的 tool_calls、收 usage。yield* 这个写法很妙------它把子 generator 的事件原样透传给最上层(所以 CLI 能直接收到 streamInference 吐的字),同时拿到子 generator 的 return 值(判别结果)。
第 5 步,校准(:197) 。拿这轮 API 返回的真实 prompt_tokens,和本地估算比一比,更新校准系数。EMA 平滑(历史 0.6 / 新观测 0.4),过滤极小上下文的噪声。这一步的产物,是下一轮第 2 步压缩判定的输入。
第 6 步,落盘(:214) 。模型回复 push 进 message 数组 + 写进会话记录。这里有个厂商适配的细节(:206 注释):DeepSeek 思考模式下,带工具调用的轮次必须回传 reasoning_content 字段,否则 API 报 400。所以这里用 ...spread 整体透传 assistantMessage,绝不手工列举字段------免得漏挂厂商扩展字段,断了回传链。
第 7 步,退出判定(:223) 。本轮没调工具 = 模型想收尾。先过 interceptFinal(上一篇的 PHANTOM/EARLY_FINAL),命中就拦住 continue;否则 yield final 终结。
第 8 步,工具执行(:259) 。有 tool_calls = 在干活。先 noteToolCall(更新防傻计数)、再 breaker.check(查死循环)、最后 scheduleToolCalls 真正执行。执行完是 completed,回到 while 顶部,开始新一轮。
逐阶段拆完,你会发现每一步都对应阶段表里的一行,代码和表严丝合缝。带着这张表读源码,不会再迷路。
四、四个技术难点深挖
这是这篇的重头戏。这几个点,是我写主循环时反复打磨的地方。
难点一:为什么 final 只能由主循环 yield(终结权集中)
前面决策一提过,这里展开讲它为什么是个"难点"。
子模块(比如 streamInference)也会遇到"该结束了"的情况------用户中止了、API 出错了、上下文超长了。最直觉的写法,是让子模块自己 yield {type:'final'} 然后结束。但这样有个致命问题:主循环的 finally 块跑不到。
finally 块(:284)干两件善后:触发 Stop hook、持久化校准状态。如果子模块自己 yield final 提前结束,外层 generator 的 finally 虽然也会执行(generator 的 finally 在 return/throw 时照常跑),但子模块没能力在 finally 里做这些主循环级的善后------它不知道该 dispatch 哪个 hook、没有 sessionId 的完整上下文。
更危险的是,如果终结点散落,"善后逻辑"就得在每个 yield final 的地方复制一遍。复制 = 迟早漏。漏一个,就是"agent 停了但 Stop hook 没触发"或"校准状态没存下来,下一 run 又从零开始"。
所以解法是统一:所有"该结束了"的判定,都化成子模块的 return(带一个判别联合),由主循环统一 yield final + 走 finally。 看那几行重复的模式:
ts
if (infResult.kind === 'aborted') {
yield { type: 'final', text: ... }; return; // 终结点 1
}
if (infResult.kind === 'error') {
yield { type: 'final', text: ... }; return; // 终结点 2
}
// ...轮数超限、中止、收尾、重复熔断、工具致命错,都是同一个模式
看着重复,但这种"终结点收敛在主循环、统一走 finally"的重复,是刻意的。它换来了善后逻辑的单一来源。读源码时看到这些长得差不多的 yield final,别觉得啰嗦------它们是终结权集中的代价。
读源码启示:当一个 generator 里出现大量"yield final + return"的重复模式,先别急着重构去重,想想它们是不是在共同守护某个 finally 级的不变式。
难点二:埋点和工具段的异常兜底,防"永久卡死"
agent 主循环最怕的不是出错,是出错后悄悄卡死、前端永远转圈。源码里有两层防这个的兜底。
第一层,埋点安全包装(:55):
ts
const rawEvents = options.events;
const events: typeof rawEvents = async (base) => {
try { await rawEvents(base); } catch (e) { console.warn('⚠️ 埋点失败(不影响推理):', ...); }
};
埋点(trace/落盘)是旁路操作------磁盘满了、JSON 序列化失败、上报网络挂了,这些都不该拖垮主业务推理。所以包一层 try/catch,把埋点异常"吃掉"只打 warning。主循环里所有 await events(...) 都自动获得这层保护,不用每个点位单独写 catch。
第二层,工具执行段的外层 catch(:270):
ts
} catch (toolErr) {
// 落盘 / processToolCall / appendMessage 等若抛未守护异常,
// 原先会逃出 generator → 消费层无 catch → final 永不发 → 前端 busy 永不清(永久卡死)。
if (signal?.aborted) { yield { type: 'final', text: ... }; return; }
const errMsg = toolErr instanceof Error ? toolErr.message : String(toolErr);
yield { type: 'final', text: (lastContent || "") + `\n(工具执行异常:${errMsg})` };
return;
}
注释写得明白:流式推理段的异常,已被内层 catch 处理(yield final + return);但工具执行段 (落盘、processToolCall、Promise.all)的异常,原先没人兜,会一路冒泡逃出 generator,消费它的 for await 又没 catch,结果就是 final 永远不发、前端永久 busy。这个外层 catch 是兜底的兜底------无论如何,agent 必定 yield 一个 final 收尾。
读源码启示:一个长跑的 generator,"任何路径都能走到 final"是它的安全底线。读这类代码,重点看每个异常分支是不是都有兜底的 yield final。卡死往往不是逻辑错,是某条异常路径漏了出口。
难点三:中止(abort)不是简单 return,要管"半截成果"
用户点停止,理想是"立刻停"。但实现上有个难点:模型可能正好吐了一半字、或者推理到一半。这半截成果留不留?
DeepSeeker-Code 的策略是尽量落盘半截成果 。看 streamInference 返回的 aborted 分支(:184):
ts
if (infResult.kind === 'aborted') {
// partialText 由 streamInference 在仅文本无半截 tool_call 时落盘后带回
yield { type: 'final', text: infResult.partialText || lastContent || "(已中止)" };
return;
}
中止时分两种情况:如果模型已经吐完了一段文本(没有半截的工具调用),这半截文本会被落盘成 partial 带回来,作为 final 显示给用户------你按了停止,但模型刚说的那段话不会丢 。如果是异常路径中止(拿不到 partial),就回落到 lastContent(上一轮的内容)。
中止处理遍布主循环几乎每个出口------压缩被打断、推理被打断、工具执行被打断,每个 catch 里都先判一次 signal?.aborted 走安静收尾。为什么这么小心?因为中止是一种"用户意图",它应该压过任何错误信息 。如果中止时冒出来一个 "Request was aborted" 的报错文案,用户会一脸懵。所以注释专门写(:158):
用户主动中止恰好打断压缩摘要请求......此处优先判 signal.aborted 走安静收尾,避免向用户显示"(Request was aborted)"误导性文案。
读源码启示 :处理"中止"时,要把它当成一种特殊的高优先级意图,而不是普通异常。读源码看到到处都在判signal?.aborted,不是冗余,是在守护"中止体验干净"这个不变式。
难点四:跨 run 的校准状态持久化
最后一个难点,藏在一头一尾。
主循环开头(:100):
ts
const persistedCalib = await getRollingState(sessionId);
let calibRatio = persistedCalib.calibRatio ?? 1.4; // 回落 1.4(保守偏高,偏早压缩)
let lastRealPromptTokens = persistedCalib.lastRealPromptTokens;
let lastCachedTokens = persistedCalib.lastCachedTokens;
结尾 finally(:294):
ts
try {
await updateCalibration(sessionId, { calibRatio, lastRealPromptTokens, lastCachedTokens });
} catch { /* ignore */ }
问题是什么?主循环每轮用真实 token 修正 calibRatio(难点:本地估算系统性低估约 31%)。但一个会话往往跨多个 run------用户问一个问题是一个 run,追问是下一个 run,计划模式调研完进实现阶段又是一个 run。如果每个 run 都从默认值 1.4 重新开始校准,前几个 run 的压缩判定就会滞后(以为还安全、其实快满了),白白靠 API 400 兜底------每次 400 都是一次完整失败的付费请求。
解法是把校准状态持久化、按 sessionId 跨 run 复用 。开 run 时读上一次攒的真实校准,跑的过程中持续修正,结束时存回去。下一个 run 接着用。注释(:98)写得很实在:
跨 run 持久化:复用前一 run 攒的真实校准与缓存数据,避免每 run 从 1.4 重零(实测致实现 run 压缩判定滞后、更晚压缩)。
这是个典型的"状态要跨边界延续"的难点。本地估算不准是已知的,校准是解法,但校准本身需要时间收敛------而 run 是频繁重启的。把状态从 run 内提到 run 间,让校准的收敛不被重启打断。
注意它和 Stop hook 一样放在 finally、套 try/catch------旁路持久化失败绝不阻塞主流程。存不下来大不了下个 run 从 1.4 重来,但不能因为这个把 agent 搞崩。
读源码启示:有状态收敛的逻辑(校准、学习、缓存),遇到"运行单元会被频繁重启"的场景,要把状态持久化到运行单元之外。读源码看到 generator 头尾都碰同一个外部 store,通常就是在干这件事。
五、推荐的源码阅读顺序
带着这篇当导游,读 runAgent.ts 建议这么走:
- 先读文件头注释 (
:10)。它把每轮的四步职责(窗口治理 / 流式推理 / 工具执行 / 退出判定)写得很清楚,是阶段表的出处。 - 跳到
while(true)开头 (:108),对着第一节的阶段表,逐段标注每个阶段对应的行号。把这张表"刻"进源码。 - 数一遍 yield final 的出口 。从
:110到:282,每一个 yield final 都对应一个"终结条件"。数清楚,你就掌握了 agent 所有结束路径。 - 看
finally块 (:284)。Stop hook + 持久化校准,这是所有终结路径的共同善后。 - 最后只看主循环怎么调子模块(prepareToolsAndInjections / ensureFitsWindow / streamInference / breaker / scheduleToolCalls),先不进它们的实现------把主循环当成"调度中轴"理解透,子模块的实现是后面几篇的内容。
六、关联:主循环串起了哪些模块
主循环本身是薄调度层,它真正的深度,在它调用的那些模块里。这些模块后面会各自成篇,这里先点名它们和主循环的接口关系:
- streamInference(流式推理) :主循环第 4 步。
yield*委托,吐流式事件、return 判别结果。它内部还有"三道有限重试"(idle/API/context_length),是下一个值得深读的模块。 - ensureFitsWindow / truncate(上下文压缩):主循环第 2 步。缓存感知的压缩时机 + 真实 token 口径校准,是"省钱"的工程核心(缓存那篇聊过它的效果,源码导读会单独拆它的算法)。
- scheduleToolCalls / toolScheduling(工具调度):主循环第 8 步。分波调度 + 审批熔断,工具系统的执行入口。
- repeatBreaker(硬熔断):主循环第 8 步前。上一篇的搭档,nudge 治不了的死循环,它来熔断。
- agentNudges(防呆层):主循环第 3、7 步。上一篇的主角。
读主循环的价值,正在于它是一张索引------把 agent 子系统的所有模块,按"一轮推理的生命周期"编排成一个清晰的整体。先读懂主循环,再钻进任何一个子模块,都不会迷失。
最后
runAgent.ts 这个文件不长,但密度极高。一个 while(true),把"什么时候压缩、什么时候软推、什么时候硬熔断、什么时候收尾、什么时候善后"全编排进去了。它体现的设计思想------终结权集中、下标契约保缓存、薄调度层解耦、让模型自决------是整个 agent 子系统的基调。
下一篇,我们顺着主循环第 4 步往里钻,读 streamInference.ts------看流式推理本身是怎么做的,那个 yield* 委托背后,"三道有限重试"和"中止时落盘半截成果"具体是怎么实现的。
项目源码开源在 github.com/xnk/deepSee... ,文章里提到的文件都在 src/core/src/agent/ 下,欢迎对着源码读。觉得这个导读系列有点意思,点个 star 是对我最大的鼓励。
总结
- 生命周期即阶段表 :主循环每轮按 0~8 九个阶段走,每一步要么"继续下一轮"、要么"终结整个 agent"------这是读
runAgent.ts的地图; - 四个设计决策:流式 AsyncGenerator 且终结权集中(final 只由主循环 yield)、message0/1 下标契约(保 P0-4 前缀缓存)、主循环只是薄调度层(脏活外包给子模块)、让模型自决 + 500 轮极高兜底;
- 逐阶段拆:轮数检查 → 中止检查 → 窗口治理 → 选 nudge → 流式推理 → 校准 → 落盘 → 退出判定 → 工具执行,代码与阶段表行行对应;
- 四个技术难点:终结权为何必须集中(守护 finally 不变式)、两层异常兜底防永久卡死、中止要管半截成果且压过错误文案、校准状态跨 run 持久化(避免每 run 从零重收敛);
- 主循环是索引:它把 streamInference / 压缩 / 工具调度 / 熔断 / 防呆全串成一条流水线,读懂它就读懂了 agent 骨架。