DeepSeeker-Code源码导读02-runAgent主循环

读懂 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>。主循环不是普通函数,是一个可以随时吐出事件的迭代器 。它一边推理一边 yieldtext.delta(逐字正文)、thinking.delta(思考过程)、tool.start/tool.end,上层(CLI / VSCode 插件)消费这些事件来驱动 UI------你看到的打字机效果、工具执行动画,就是这些事件喂出来的。

这里有个关键约定:能 yield final(终结事件)的,只有主循环自己。 被它调用的 streamInferencescheduleToolCalls 这些子模块,只 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 步,轮数检查(:110round 每轮自增,超过 500 就打 stopReason='limit',yield 一段带"接续"提示的 final 收尾。温和、可恢复。

第 1 步,中止检查(:119 。检测 signal?.aborted 必须排在 round.start 之前------否则用户刚点完停止、又多发一个 round 事件,前端会多渲染一圈。细节,但正是这种顺序敏感的判断,区分了"能用"和"顺手"。

第 2 步,窗口治理(:141ensureFitsWindow 判断上下文有没有逼近窗口,是就触发压缩(往 message[1] 塞摘要)。注意它传进去的 correctionRatiolastReal/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 建议这么走:

  1. 先读文件头注释:10)。它把每轮的四步职责(窗口治理 / 流式推理 / 工具执行 / 退出判定)写得很清楚,是阶段表的出处。
  2. 跳到 while(true) 开头:108),对着第一节的阶段表,逐段标注每个阶段对应的行号。把这张表"刻"进源码。
  3. 数一遍 yield final 的出口 。从 :110:282,每一个 yield final 都对应一个"终结条件"。数清楚,你就掌握了 agent 所有结束路径。
  4. finally:284)。Stop hook + 持久化校准,这是所有终结路径的共同善后。
  5. 最后只看主循环怎么调子模块(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 是对我最大的鼓励。

总结

  1. 生命周期即阶段表 :主循环每轮按 0~8 九个阶段走,每一步要么"继续下一轮"、要么"终结整个 agent"------这是读 runAgent.ts 的地图;
  2. 四个设计决策:流式 AsyncGenerator 且终结权集中(final 只由主循环 yield)、message0/1 下标契约(保 P0-4 前缀缓存)、主循环只是薄调度层(脏活外包给子模块)、让模型自决 + 500 轮极高兜底;
  3. 逐阶段拆:轮数检查 → 中止检查 → 窗口治理 → 选 nudge → 流式推理 → 校准 → 落盘 → 退出判定 → 工具执行,代码与阶段表行行对应;
  4. 四个技术难点:终结权为何必须集中(守护 finally 不变式)、两层异常兜底防永久卡死、中止要管半截成果且压过错误文案、校准状态跨 run 持久化(避免每 run 从零重收敛);
  5. 主循环是索引:它把 streamInference / 压缩 / 工具调度 / 熔断 / 防呆全串成一条流水线,读懂它就读懂了 agent 骨架。
相关推荐
爆写加倍5 小时前
2026年3款视频转文字软件测评技术升级让转写整理更准更省心
人工智能·ai
ACP广源盛139246256736 小时前
WAIC2026 国产算力浪潮@ACP#YLB3118 在算力矩阵中的存储定位与落地场景
大数据·人工智能·分布式·单片机·嵌入式硬件
ACP广源盛139246256736 小时前
WAIC2026 国产算力浪潮下@ACP#IX8024 在算力矩阵中的定位与落地场景
大数据·数据库·人工智能·嵌入式硬件·线性代数·矩阵
3A Cloud6 小时前
从「心即理」到提示词工程:深度解析 ClawHub 上的阳明心学技能(yangming-xinxue)
人工智能·笔记
大模型探索者6 小时前
统一工作台+预测外呼:华润保险经纪如何用中关村科金呼叫中心重构保险服务全流程?
人工智能
labixiong6 小时前
DeepSeek V4 Pro 首日实测:用前端项目跑了一遍,Agent 能力暴涨8倍是真的吗?
agent·ai编程·deepseek
品牌测评6 小时前
当AI编码平台走向订阅制与多模型聚合:以Alaya Code为例的技术观察
人工智能
茶马古道的搬运工6 小时前
AI 深度技能之-解读DeepSeek Harness(一)- 初见
人工智能
oort1236 小时前
OortCodex 是奥尔特云 OortCloudSmart 推出的国产化工程化 AI 编码 Agent(奥尔特云编码智能体)
大数据·人工智能·算法