读懂 agentNudges:给一个会犯傻的模型兜底,设计上难在哪
这篇讲什么
DeepSeeker-Code 是个自研的 coding agent。这个系列不聊它怎么用,只带你读源码------每篇拆一个核心模块,讲清楚设计思想在哪、技术难点在哪,让你翻开源码时一眼能看懂那些"为什么这么写"。
第一篇从 agentNudges.ts 开始。它是 agent 主循环的"防呆层",专门治模型犯傻。挑它打头阵,是因为它小而完整,能把 agent 子系统的几个核心设计思想一次讲透。源码都在 src/core/src/agent/ 下。
一、它治什么、什么时候冒出来:六个 nudge 的触发场景
agent 主循环(runAgent.ts)是个 while(true),一轮轮"模型推理 → 调工具 → 拿结果 → 再推理"。理想情况模型自己好好干活、干完收尾。现实是,DeepSeek 在没有强约束时会犯六类典型的傻,agentNudges.ts 给每类傻配了一个 nudge。
下面这张表是读这篇的钥匙 ------它说清每个 nudge 在什么场景 下、满足什么条件 会被触发、多久推一次。实际用的时候,你观察到的现象就对应这里:
| nudge | 什么场景下模型会犯这个傻 | 触发条件 | 频率上限 |
|---|---|---|---|
| PLAN_FIRST | 复杂任务闷头就莽,不先规划 | 首轮 + 首条 prompt 疑似非平凡任务(多文件/重构/架构) | 仅首轮 1 次 |
| REPEAT_RETRIEVAL | 反复读同一个文件、反复跑同一个搜索 | read 同一路径第 3 次 / grep 同一检索第 2 次 | 整个 run 最多 3 次 |
| TOOL_DIGEST | 刚拿到工具结果,几乎不写总结就收尾 | 距上次工具 ≤ 2 轮 + 总结 < 30 字 | 整个 run 最多 2 次 |
| EARLY_FINAL | 才跑两轮就乐观宣布"完成了" | 有内容 + 轮次 ≤ 2 + 无完成声明 | 整个 run 最多 1 次 |
| PHANTOM | 返回完全空的内容(空包/崩溃) | 回复内容完全为空 | 最多重试 2 次 |
| NUDGE | 长任务跑很多轮,忘了目标/不知该不该停 | 每隔 40 轮 | 周期性,每 40 轮 |
举几个你实际会碰到的场景:
- 你让它"重构这个模块",它没规划直接动手------第一轮 就会被 PLAN_FIRST 推一下:"建议先调
enter_plan_mode把方案想清楚"。 - 你发现它对一个文件读到第三遍------REPEAT_RETRIEVAL 生效:"这文件你早看过了,别整文件重读"。但如果你刚让它改过这个文件,它重读核验是允许的(计数被清零,不会被拦)。
- 它跑完一条命令、拿到输出,却只回了一句"好的"就想结束------TOOL_DIGEST 把它拦住,让它基于命令结果继续。
- 任务才两轮它就说"完成了"------EARLY_FINAL 推它再自检一遍每个子目标到底落地没有。
- 一个长任务跑到第 41 轮,NUDGE 周期性出现:"你已跑约 41 轮,自评一下是不是该收尾了"。
注意表里每个 nudge 都有"频率上限"------nudge 不是无穷尽的,推够了就停,剩下的交给后面的硬刹车(repeatBreaker)。这是个贯穿全局的原则:nudge 是尽力而为的引导,不是必须成功的强制。 模型要是铁了心要犯傻,推几次没用就放弃。
理解了这六个 nudge 各自什么时候用,再去看源码,就能把那些抽象的常量和判定条件,对应到具体的触发场景上。接下来讲它背后的三个设计决策------这三个决策,比那六类傻本身更值得读源码时注意。
二、三个设计决策:这个文件的灵魂
决策一:nudge 是"用完即弃"的,绝不落盘
打开 agentNudges.ts,你会看到每个 nudge 消息长这样:
ts
export type NudgeMsg = { role: 'system'; content: string };
// 注入时:
return { role: 'system', content: `${NUDGE_FENCE}\n${NUDGE_TEXT(round)}` };
注意------它返回的这个消息,只是临时拼出来、附加到这一轮推理请求的末尾,用完就丢 。源码注释叫它 "ephemeral 尾部副本":不进 message 数组、不写进会话记录(transcript)、不进上下文压缩。
为什么这么费劲?因为 DeepSeek 有个隐式前缀缓存:只要发给它的上下文前缀和上一轮一模一样,命中缓存,费用降到约十分之一。而 nudge 是动态的(这轮要推、下轮可能不推),一旦它写进对话历史,前缀每轮都在变,缓存全废。
所以设计上宁可增加复杂度(搞一套"临时副本"机制),也要保证对话历史的前缀纹丝不动。这是整个 agent 子系统最硬的约束之一(源码里标为 P0-4)。读 agentNudges 时记住一句话:它所有的"推",都是为了不留痕。
决策二:用"有状态调度器工厂",而非模块级全局变量
你会注意到整个文件导出的不是一个对象,而是一个工厂函数:
ts
export const createNudgeScheduler = (opts) => {
// 一堆跨轮计数状态(per-run)
const readCounts = new Map<string, number>();
let phantomRetries = 0;
// ...
return { pickNudge, interceptFinal, noteToolCall };
};
主循环每次 run 创建一个实例(runAgent.ts:87):
ts
const nudges = createNudgeScheduler({ firstPrompt, planMode: !!options.planMode });
为什么不用模块顶层的全局变量存这些计数?因为一个进程里可能同时跑多个 agent------主 agent 和它派生的子 agent(subagent)。用全局变量,它们的计数会串味:主 agent 读过的文件,子 agent 也算进去。
工厂模式让每个 agent run 拥有独立的状态副本,互不干扰。这是 agent 这种"可嵌套、可并发"场景下,状态管理的标配写法。读这个文件,先认出这个工厂结构,后面那些状态变量就好理解了------它们都是 per-run 的。
决策三:文案、阈值、触发逻辑全集中在这一个文件
翻到文件顶部,一大片常量(节选):
ts
const NUDGE_EVERY = 40; // 每 N 轮注入一次周期自评
const PHANTOM_RETRY_MAX = 2; // 空响应最多重试次数
const EARLY_FINAL_MAX = 1; // 整个 run 最多推 1 次
const READ_REPEAT_THRESHOLD = 3; // 同一路径第 3 次读取 → 推
// ...文案常量...
const PHANTOM_TEXT = "你的上一条回复没有任何内容......";
文案和阈值全堆在这里,是有意的。文件头注释一句话点明设计哲学:
控制流与提示词解耦------runAgent 主循环只保留薄调用,文案/阈值/触发/预算集中于此。
意思是:主循环只管"调一下 nudge 调度器",不关心推什么文案、用什么阈值。这些"提示词层面的细节"全收拢在 agentNudges.ts。好处是------你想调 nudge 的措辞或触发条件,只改这一个文件,绝不会动到主循环的控制流。
这个"控制流与提示词解耦"是整个 agent 子系统的设计基调。你会看到 streamInference、repeatBreaker、toolScheduling 都是从主循环抽出来的独立模块。读源码时,把每个模块当成"一个职责清晰的纯单元",主循环只是把它们串起来的薄调度层。
三、三个方法 ↔ 主循环的三个时机
工厂返回三个方法,分别在主循环的三个时机被调用。记住这张对照表,读源码就能在两个文件间来回跳:
| 方法 | 作用 | 主循环调用点 |
|---|---|---|
pickNudge(round) |
推理前:这轮要不要推一下 | runAgent.ts:174 |
interceptFinal(text, round) |
收尾时:拦不拦 | runAgent.ts:227 |
noteToolCall(round, toolCalls) |
调了工具:更新计数、查重复 | runAgent.ts:235 |
具体说:
- 每轮推理前 ,调
pickNudge拿一个 nudge 消息,拼到这轮请求末尾(就是决策一说的 ephemeral 副本)。 - 推理完,如果模型没调工具 (想收尾),调
interceptFinal------返回true就拦住让它继续,false就放行真正结束。 - 如果模型调了工具 (在干活),调
noteToolCall------一方面重置"空回复"计数(在干活就不是空转),一方面检测有没有重复检索。
三个方法对应主循环三个分支,分工干净。读 runAgent.ts 时,搜 nudges. 就能定位到这三处。
四、三个技术难点深挖
这是这篇的重头戏。这三个点,是我写这块代码时真正卡过、改过的地方。
难点一:怎么检测"重复"又不误杀正常的长任务
治"读十遍"的那个 nudge,难点不在检测,在不误杀。
模型正常干活时,连续调很多次同名工具是家常便饭------连读 5 个文件、连改 3 个位置。你要是简单地"同名工具调多了就拦",正常长任务全被掐了。
我的解法是分对象、配阈值、看上下文 。看 noteToolCall 里对读取的计数:
ts
const READ_REPEAT_THRESHOLD = 3; // 同一路径第 3 次读取 → 推
const GREP_REPEAT_THRESHOLD = 2; // 同一检索第 2 次即推
if (name === "read_file") {
const p = normPath(args?.path);
const n = (readCounts.get(p) ?? 0) + 1;
readCounts.set(p, n);
if (n >= READ_REPEAT_THRESHOLD && !nudgedReads.has(p) && repeatNudges < REPEAT_NUDGE_MAX) {
nudgedReads.add(p);
repeatNudges++;
repeatRetrievalPending = { role: "system", content: `你已第 ${n} 次读取「${p}」......` };
}
}
关键在计数的粒度------按"具体路径"计,不是按"工具名"计。读 5 个不同的文件,是 5 个路径各算 1 次,谁都没超阈值;只有同一个文件读到第 3 次,才算重复。这就把"正常的多步操作"和"病态的反复看"区分开了。
阈值也有讲究:读要 3 遍才推(前两次可能是先浏览后细读,正常),搜索只要 2 遍就推(同一个 grep 跑第二遍几乎一定浪费)。
但光这样还不够。还有个边界:改过的文件,应该允许重读核验。所以每次 edit/write/create 之后,把这个路径的计数清零:
ts
if (name === "edit_file" || name === "write_file" || name === "create_file") {
const p = normPath(args?.path);
if (p) { editedPaths.add(p); readCounts.set(p, 0); nudgedReads.delete(p); }
}
改完读一遍确认,是合理的,不该被当成"重复"。这一行清零,是这套检测能不能用的关键------少了它,模型改完文件想核验一下就被拦,反而妨碍正常工作。
读源码启示:判断"重复"这类问题,核心不是"调了几次",而是"在重复什么、是不是有合理理由"。这套计数 + 清零的设计,就是在反复拿捏这个度。
难点二:怎么防止"防傻机制"自己造出新傻
这是最反直觉的一个坑。防空手收尾(TOOL_DIGEST)那个 nudge,源码注释里藏着一段血泪:
ts
// ★ 必须限长(< TOOL_DIGEST_TEXT_MAX_LEN):只拦"空手收尾",
// 绝不能拦"实质总结"。否则总结轮被 continue,每轮正文各落一条 assistant 消息 →
// 出现"连续多个 final、中间无 user"的没有结尾症状。
if (lastToolCallRound > 0 && round - lastToolCallRound <= 2
&& text.length < 30 && !looksComplete(finalText)) {
// ...注入 nudge,拦住让它继续
return true;
}
最早的版本,这个 nudge 只看"刚执行完工具就想收尾",没限制总结长度。结果------模型每次好好写总结,都被判定成"空手收尾"给拦回去重来。于是出现极其诡异的症状:对话里连续好几个"完成了",中间没有任何实质内容,因为每轮总结都被掐了重跑。
修复的关键是那个 text.length < 30:只拦"总结短于 30 字"的真·空手收尾,绝不拦有实质内容的总结。 而且最终选择用"长度"判定,而不是关键词------注释里也承认,靠"已完成/已修改"这种关键词判断模型是不是真完成了,太不可靠(模型会说"已简化完成""都已就位"这种漏网词)。
读源码启示:一个兜底机制,设不好会比没有更糟------它能把一种傻,变成另一种更隐蔽的傻。看到带"★"和长注释的限制条件,要特别注意,那通常是踩过坑才加上去的。
难点三:跨轮状态怎么管,又不让它无限膨胀
nudge 调度器是 per-run 有状态的,跑几十上百轮,那些 Map 和计数会不会越积越大、吃内存?
解法是预算上限 + 定长裁剪。每个 nudge 都有"整个 run 最多推几次"的预算:
ts
const REPEAT_NUDGE_MAX = 3; // 重复检索 nudge 整个 run 最多 3 次
const TOOL_DIGEST_MAX = 2; // 空手收尾最多 2 次
const EARLY_FINAL_MAX = 1; // 早收尾最多 1 次
推满预算就不再推------避免模型死活不听、nudge 还在无限堆积。这是个关键安全阀:nudge 是"尽力而为"的引导,不是"必须成功"的强制。 模型要是铁了心要犯傻,推几次没用就放弃,交给后面的硬刹车。
(定长裁剪在 repeatBreaker.ts 里:recentSignatures 超过 6 个就 shift 掉最早的,防长会话内存膨胀。)
读源码启示:有状态设计的边界控制,和它的核心逻辑一样重要。读这类调度器,除了看它"怎么触发",一定要看它"什么时候停"。
五、推荐的源码阅读顺序
把这篇当地图,去读 agentNudges.ts 时建议这么走:
- 先扫顶部常量区(阈值 + fence + 文案)。这里是"调参面板",所有触发条件和措辞都在这,先建立全局印象。
- 看
createNudgeScheduler工厂 。理解 per-run 状态有哪些(readCounts、各种预算计数)。 - 看三个方法 :
pickNudge(优先级排队)、interceptFinal(收尾判定)、noteToolCall(工具计数 + 重复检测)。重点看每个判定条件背后的注释。 - 回到
runAgent.ts,搜nudges.,看这三处调用,理解触发时机。 - 最后看
repeatBreaker.ts,它是这套防傻体系的"硬刹车"补充。
六、关联:软推之外的硬刹车
nudge 是软的,靠提示引导,模型可以无视。所以还得有硬的------repeatBreaker.ts,直接熔断。两道检测:
- 完整签名(工具名 + 参数)连续 3 轮一模一样 → 精确重复,立即停。
- 轮询同一个后台任务连续 4 轮 → 死循环,立即停。
第二道有个和难点一相通的设计:它按"目标"(task_id)判等,而不是按"工具名"。源码注释说得很直白:按工具名判等"注定误杀长任务(连读 N 个文件、连改 N 处都是同名不同目标)"。这跟难点一里"按路径计而非按工具名计"是同一个思想。
nudge 负责软引导、repeatBreaker 负责硬兜底,两者互补,一起构成 agent 的鲁棒性防线。
最后
agentNudges.ts 这一个文件,麻雀虽小,却把 agent 子系统的几个核心设计思想都体现了:ephemeral 机制保前缀缓存、工厂模式做状态隔离、控制流与提示词解耦。读透它,再去看 streamInference、repeatBreaker 这些同结构的模块,会顺畅很多。
下一篇,我带大家读主循环本身------runAgent.ts,看这个 while(true) 是怎么把推理、工具、压缩、兜底串成一条完整流水线的。
项目源码开源在 github.com/xnk/deepSee... ,文章里提到的文件都在 src/core/src/agent/ 下,欢迎对着源码读。觉得这个导读系列有点意思,点个 star 是对我最大的鼓励。
总结
- 定位 :
agentNudges.ts是主循环的"防呆层",治模型六类典型犯傻; - 三个设计决策:nudge 用完即弃(保前缀缓存)、工厂模式做状态隔离(per-run 互不串味)、文案阈值全集中(控制流与提示词解耦);
- 三个方法 ↔ 主循环三时机 :
pickNudge(推理前)/interceptFinal(收尾时)/noteToolCall(调工具后),对应runAgent.ts:174/227/235; - 三个技术难点:按"对象"而非"工具名"检测重复(避免误杀长任务)、用长度而非关键词判定空手收尾(防"治过头"造新傻)、预算上限控制有状态膨胀;
- 软硬互补:nudge 软引导 + repeatBreaker 硬熔断,共同构成鲁棒性防线。