← 返回AI变现
🌐 其他

DeepSeeker-Code源码导读05-计划模式与子Agent

来源:掘金 · 发布于 2026-08-19 11:56:24
读懂计划模式与子 Agent:信号归模型,约束归 harness 这篇讲什么 一个成熟的 agent 要会两件事:想清楚再动手(计划模式),和派分身去干活(子 Agent)。前者让它在复杂任务前先调研

DeepSeeker-Code源码导读05-计划模式与子Agent

樊小肆 2026-08-19 0 阅读23分钟

读懂计划模式与子 Agent:信号归模型,约束归 harness

这篇讲什么

一个成熟的 agent 要会两件事:想清楚再动手(计划模式),和派分身去干活(子 Agent)。前者让它在复杂任务前先调研、出方案、过审批;后者让它把独立子任务委派给一个独立的小 agent 去跑。

这两个功能看似毫不相干——一个是"自我克制",一个是"派生分身"。但读完源码你会发现,它们背后是同一个设计原则:信号归模型,约束归 harness。这篇就拆 planMode.ts、subagent.ts 和 workflow.ts,看这个原则怎么把两个功能统一起来。

先解释两个词。信号(signal)是模型自由表达的意图——"我想先规划""我想派个分身"。约束(constraint)是 harness 物理上施加的强制——"计划模式下你根本没有写工具""子 agent 不准用 shell"。模型可以发信号,但约束不由它说了算。

一、先看全貌:两个功能,一个原则

下面这张表是读这篇的钥匙——计划模式和子 Agent 各自的"信号"和"约束"是什么:

信号(模型发出)约束(harness 强制)
计划模式enter_plan_mode(我想规划)/ exit_plan_mode(方案好了,请审批)PLAN_ALLOWED_TOOLS 执行层门禁拒绝一切写工具(工具表恒定不裁剪)
子 Agentspawn_agent(派个分身干这活)/ run_workflow(一次派多个)SUBAGENT_DENYLIST 剔除子 agent 的 shell/递归删除 + 深度熔断

注意每行的右边那一列——约束都不是靠提示词实现的。计划模式不是说"请你只读"(模型会阳奉阴违),而是在工具执行入口设一道门禁:写工具的调用到了执行层,直接被拒、返回引导文案。子 agent 不是说"你级别低,别用 run_command",而是干脆不给它这个工具。

这就是"信号 vs 约束分离"的核心:意图归模型,强制归 harness,而且强制必须是物理的、绕不过去的。 拿几个场景盘一下:

  • 场景 A:复杂任务,模型想先规划。 它调 enter_plan_mode(信号)。harness 接到信号,翻转 planMode 重跑。这一轮起,工具表纹丝不动(保前缀缓存),但执行层门禁生效——模型如果"想"直接 edit_file,调用会立刻收到"❌ 当前为只读调研阶段,禁止 edit_file"的拒绝结果,写操作根本落不到磁盘。

  • 场景 B:模型在计划模式调研完,提交方案。 它调 exit_plan_mode(信号)。harness 把方案推给用户审批。用户点了"通过",harness 才翻转回正常模式,写工具的门禁解除,进入实现阶段。

  • 场景 C:主 agent 想把"写单元测试"这活派出去。 它调 spawn_agent(信号)。harness 派生一个子 agent,但给它的工具表是收过权的(约束)——没有 run_command、没有 delete_path。子 agent 能读写代码,但不能跑 shell、不能递归删。

  • 场景 D:并行派 5 个子 agent 审查 5 个模块。 主 agent 调 run_workflow(信号)。harness 并发派生,但受并发上限节流(约束,防打爆 API),且共享一把审批锁(约束,防 5 个高危审批同时弹窗竞态)。

理解了"信号是模型的权利、约束是 harness 的责任",再读源码,那些"为什么不直接在提示词里写"的疑问就都通了。下面分别拆两个功能。

二、计划模式:模型发信号,harness 落约束

两个对偶的信号工具

planMode.ts 暴露了两个特殊的工具 schema,它们不是常规工具(没有 execute、没有 safetyLevel),而是"信号通道":

// enter_plan_mode:模型主动请求进入计划模式
{ name: "enter_plan_mode", parameters: { reason: "为何要规划" } }
// exit_plan_mode:模型提交实现方案,请求审批
{ name: "exit_plan_mode", parameters: { plan: "完整实现方案" } }

它们被 runAgent 按名特殊拦截,不走常规的工具执行管线。拦截后,harness 干真正的事——翻转 planMode 状态、重跑。模型只是"按了个门铃",开门的是 harness。

真正的约束:执行层门禁 + 工具表恒定

信号收到后,约束怎么落?这里有一段值得完整讲的演进史。

第一版做法是"物理裁剪工具表":计划模式下 filterToolsForPlanMode 把工具表裁成只读白名单(约 14 个只读工具 + exit_plan_mode),正常模式用 appendPlanControlTools 恢复全量。模型在计划模式下根本看不到写工具——想写无从谈起。听起来很完美?

实测打脸了。 对着 DeepSeek 的前缀缓存做 A/B 实验(同一会话两个计划轮,唯一变量是工具表策略),数据是这样的:

工具表策略计划轮首调缓存命中率
裁表(schema 档)6.4%(2.2K / 34.1K)
恒定 + 执行层门禁(runtime 档)59.7%(23.3K / 39.0K)

九倍差距。原因藏在一个容易忽略的事实里:OpenAI 协议把 tools 参数序列化在请求头部,DeepSeek 把它计入前缀缓存哈希。也就是说,message[0] 稳定还不够——工具表一变,前缀照样全废。而"进计划→出计划"一个循环,工具表要翻转两次(全量→白名单→全量),整个会话历史 re-prefill 两次。这恰好发生在"方案刚批准等开工"和"刚进计划等调研"两个用户最敏感的时刻——TTFT 直接翻倍,还掐断了"翻转→命中率掉底→提前压缩丢细节→再击穿"的复合伤害链。

所以现状是两层的(systemInjections.ts :45):

// runtime 档(默认):两态统一走 appendPlanControlTools——工具表全会话恒定,保前缀缓存
const rawToolsPreEnv = (planMode && appConfig.planEnforcement === 'schema')
    ? filterToolsForPlanMode(toolSchemas ?? [])   // schema 档:回退旧裁表路径
    : appendPlanControlTools(toolSchemas ?? []);  // 正常/计划两态同表

工具表恒定了,约束去哪了?移到执行层。toolExecution.ts :143,processToolCall 的入口处(终结类判定之后、工具匹配之前):

if (!parseFailed && planMode && appConfig.planEnforcement === 'runtime'
    && calledName !== 'enter_plan_mode' && calledName !== 'exit_plan_mode'
    && !PLAN_ALLOWED_TOOLS.has(calledName)) {
    const note = `❌ [计划模式] 当前为只读调研阶段,禁止 ${calledName}。完成方案请调 exit_plan_mode 提交,经用户审批后进入实现阶段。`;
    return { ..., resultForModel: note, ok: false };
}

注意这个拒绝的分量:不发审批、不加锁、不进 hook 流水线——写工具的调用连审批网关都到不了,直接以结果文案劝返。"计划先于执行"的语义一点没变,只是落点从"模型看不见"变成了"模型调不动"。

这个转变有个诚实的代价:计划期模型看得见写工具(表恒定的必然),偶尔可能试探一次写操作——浪费一轮推理。实测(一个完整 plan 循环)试探次数为 0:系统提示词已有"计划模式只读"的静态约束,显式诱导写盘的指令反而被模型当可疑指令拒绝了两次。静态引导 + 门禁兜底,足够。

三个配套细节:

  • 白名单补漏:PLAN_ALLOWED_TOOLS 现在包含 get_diagnostics、goto_definition——LSP 导航/诊断恰恰是调研阶段最需要的,原名单漏登记(白名单漂移 bug),无论哪个档都补上了。
  • 防翻转守卫:表恒定后 enter_plan_mode 在计划模式内也可见。模型重复调它不再触发"翻转重跑"循环,而是返回一条普通结果"(已在计划模式中,无需重复进入)"。
  • 回退开关:DEEP_SEEK_PLAN_ENFORCEMENT=schema 一行环境变量退回旧裁表路径。所有行为变更都留退路,是这个项目的惯例。

三条路径,都收敛到方案审批

计划模式有三条进入路径,但最终都收敛到同一个"方案审批"闸门:

  1. 用户手动开(--plan / 快捷键)→ 直接进入只读计划模式。
  2. 模型自主进入(调 enter_plan_mode)→ harness 翻转、以只读重跑计划阶段。
  3. 模型跳过 enter,自行用只读工具调研 → 直接调 exit_plan_mode 提交方案。

第三条是个容易漏的边界。看注释(planMode.ts :91):

为何正常模式也暴露 exit_plan_mode:DeepSeek 有时会跳过 enter_plan_mode、自行用只读工具先调研,随后想提交方案却找不到 exit_plan_mode → 退回纯文本方案,方案审批弹窗永不触发。

DeepSeek 的行为不完全可控——它可能不理 enter_plan_mode,自己先 read 一通。如果这时候没 exit_plan_mode 可调,它就把方案当普通文本输出,审批闸门形同虚设。所以正常模式也一并暴露 exit_plan_mode,让"自行调研后提交方案"也能正确走审批。这是个为模型实际行为打补丁的细节。

P0-4 延续:约束静态化,前缀缓存的两次升级

这里有个和前几章呼应的设计,而且它经历了两次升级,最能体现"缓存优先"这条原则是怎么一步步逼出架构演进的。

第一次升级:约束说明静态化。 计划模式的"约束说明",是静态写进系统提示词的,不随 planMode 状态动态改写 message[0](planMode.ts :27):

计划模式约束已静态化进 SYSTEM_PROMPT,不再随 planMode 状态动态改写 message[0](避免破坏 DeepSeek 隐式前缀缓存)。

如果计划模式一开一关就改写系统提示词,前缀缓存就废了。所以把"说明性文字"静态化(永远在提示词里),运行时强制交给工具机制。

第二次升级:工具表恒定(就是上一节讲的执行层门禁)。 第一次升级只保住了 message[0] 的文本,漏了一个同在请求头部的变量——tools 参数。实测证明 DeepSeek 把 tools 计入前缀哈希后,裁表翻转照样全历史击穿,于是有了 P0-A:工具表全会话恒定,约束移到执行层。

两次升级合起来的完整图景:发给模型的请求 = 头部(tools + message[0])+ 中部(历史)+ 尾部(最新内容),前缀缓存要求头部绝对稳定。 计划模式的所有动态性——模式切换、约束强制——都消化在执行层和尾部,永不回写头部。提示词负责告诉模型规矩,执行层负责物理执行规矩,两者分离,各不影响缓存。

三、子 Agent:派生内核 runSubagent

两个编排工具,一个共享内核

子 Agent 有两个工具(agent.ts 和 workflow.ts):

  • spawn_agent:串行派生单个子 agent。适合"把这块独立活派出去"。
  • run_workflow:并行(parallel)或流水线(pipeline)编排多个子 agent。适合大规模审查、迁移分析这类可分解的任务。

两者共享同一个派生内核——subagent.ts 的 runSubagent。注释说得很清楚(subagent.ts :1):

把「构造子系统词 / 工具表收权 / 初始化子会话 / 驱动 runAgent / 采集 final / 异常熔断」封装为纯函数 runSubagent,供 spawn_agent 与 run_workflow 复用,避免两处复制粘贴同一套逻辑。

runSubagent 做的事:检查深度熔断 → 加载声明式 manifest(若有)→ 构造子系统词 → 工具表收权 → 初始化子会话 → 递归调 runAgent → 采集 final → 异常兜底。它返回纯数据 { ok, output, sessionId },不做任何文案包装(汇报框、同步通知留给调用方,让 spawn_agent 和 run_workflow 各自定制呈现)。

又一个约束:子 Agent 的工具收权

子 agent 的工具表,是被收过权的(subagent.ts :29):

// 嵌套深度 ≥ 1 的子 agent 不再拥有 shell 执行与递归删除
export const SUBAGENT_DENYLIST = new Set(["run_command", "delete_path"]);

注释点明了收权的理由——"这两者是爆炸半径最大的高危原语"。子 agent 能读写代码(委派代码工作合理),但不能跑 shell、不能递归删。而且 MUTATION/DANGER 工具仍各自走审批网关作为后盾。

这里又是一个"信号 vs 约束"的体现:spawn_agent 是模型派分身的信号,但派出去的分身天生是收权的——不是靠提示词告诉子 agent"你别跑 shell",而是它的工具表里压根没有 run_command。

声明式子 Agent 还更进一步——显式白名单替代黑名单(subagent.ts :113):

const toolSchemas = manifest && manifest.tools.length > 0
    ? allTools.filter(t => manifest.tools.includes(t.function.name))  // 显式授权
    : allTools.filter(t => !SUBAGENT_DENYLIST.has(t.function.name));   // 否则回退黑名单

声明式子 Agent(.deepseeker-code/agents/<name>.agent.md 里声明了 tools)走"只给清单里的工具"(对标 Claude Code 的有意授权);没声明的回退黑名单。显式授权比默认收权更可控——你明确知道这个分身有哪些能力,而不是"除了黑名单啥都能干"。

白名单还有个 MCP 时代的兼容细节(subagent.ts :114):manifest 里声明的可能是旧的逐工具名(mcp__playwright__browser_navigate),但 MCP 分发器模式下全局表已经没有这些独立工具名了(第 9 篇会讲,默认只有 mcp_list_tools + mcp_call 两个恒定 schema)。所以收权这里做了一次运行期翻译:

// manifest 声明 mcp__*(旧逐工具名)、mcp_call 或 mcp_list_tools 时,
// 一并授予两个恒定分发器工具(dispatcher 模式下全局表不再有 mcp__server__tool 独立名)
const wantsMcp = manifest?.tools.some(t => t === 'mcp_call' || t === 'mcp_list_tools' || t.startsWith('mcp__')) ?? false;
const toolSchemas = manifest && manifest.tools.length > 0
    ? allTools.filter(t => manifest.tools.includes(t.function.name)
        || (wantsMcp && (t.function.name === 'mcp_call' || t.function.name === 'mcp_list_tools')))
    : allTools.filter(t => !SUBAGENT_DENYLIST.has(t.function.name));

manifest 写 mcp__* 条目即视为"授权 MCP",翻译成分发器双工具授予。安全网没有松:每次 mcp_call 仍按合成名走 permissions 规则 + auto 分类器 + DANGER 审批网关——最坏情况是子 agent 试探未授权 server 时弹一次审批,不存在静默越权。这和 P1-C 的委派策略是一体的:重型/专用工具集的场景,推荐用声明式子 agent 承接(frontmatter 声明 tools 白名单 + model),父会话只见 spawn_agent——一大坨专用 schema 不塞回主会话头部,正是"新工具不进 L1"原则的落地。

几道工程护城河

并行编排(run_workflow)还有几道防止"派分身派出事"的护城河:

深度熔断(防无限嵌套):子 agent 还能再生子 agent,但不能无限套娃。MAX_AGENT_DEPTH 一到就拒绝派生(subagent.ts :69)。和主循环的 500 轮兜底一个思路——极端情况才触发的保险丝。

并发节流(防打爆 API):parallel 模式用信号量限并发(workflow.ts :206),模型说"派 10 个"但实际同时跑的受 workflowConcurrency 上限节流。

审批串行化(防弹窗竞态):这是最巧妙的一道。并行子 agent 共享一把互斥锁包裹审批(workflow.ts :131):

const approvalMutex = createSemaphore(1);
const serializedApproval = ctx.requestApproval
    ? async (...raArgs) => {
        await approvalMutex.acquire();
        try { return await ctx.requestApproval!(...raArgs); }
        finally { approvalMutex.release(); }
    } : undefined;

为什么需要它?因为 CLI 是单弹窗模型——同一时刻只能显示一个审批框。5 个子 agent 同时要审批高危操作,如果不串行化,5 个弹窗竞态、用户点哪个都不对。互斥锁让并发的高危请求排队审批,一个一个来。

worktree 隔离(防并行写冲突):parallel 默认共享工作区(建议只用于读/分析)。但要做并行迁移/改造时,设 isolation="worktree",每个子 agent 在独立的 git worktree 里跑,互不污染,结束后收割各自的 diff 供合并(workflow.ts :166)。这是"并行写"能安全做的关键。

四、四个设计决策

决策一:信号 vs 约束分离(全篇主线)

这是计划模式和子 Agent 共同的灵魂。模型的意图通过信号工具表达(enter_plan_mode / spawn_agent),但真正的限制由 harness 物理施加(执行层门禁 / 收权 / 深度熔断)。 两者严格分离,信号不能自带约束。

为什么这么分?因为模型不可信。让模型"承诺只读"或"承诺不嵌套",它会阳奉阴违——嘴上答应,手上照写不误。所以意图归模型(它最懂任务要不要规划、要不要分身),强制归 harness(只有 harness 能保证强制真的被执行)。

这个思想和第 1 篇的 nudge(软引导)vs repeatBreaker(硬熔断)一脉相承:软的归模型自决,硬的归 harness 强制。

决策二:约束靠执行层强制,不靠提示词也不靠裁表

这是决策一的直接推论,但它自己也演进过一轮。第一版"裁掉写工具"当然有效,可它撞上了前缀缓存这堵墙——tools 在请求头部,裁表就是动头部。最终的形态是:计划模式不写"请只读"(提示词只是引导),而是工具表恒定 + 执行层按白名单拒绝;子 agent 不写"别用 shell",而是不给 shell 工具。提示词是建议,执行结果是事实。 模型可以无视建议,但它调一次写工具就吃一次拒绝——绕不过去。

值得品的是这两种"物理约束"的取舍:裁表是信息层的约束(模型看不见,零浪费),门禁是执行层的约束(模型看得见,偶尔浪费一轮试探)。信息层约束更强,但代价是头部不稳定;执行层约束稍弱,但头部恒定。DeepSeeker-Code 的选择是——约束强度够用就好(实测零试探),缓存稳定必须满分。 这是个很典型的工程 trade-off:不是选"最强的约束",而是选"约束够用前提下代价最小的那个"。

读源码时,凡看到"约束",先问自己"这是提示词层面的(可绕过),还是执行层/工具表/熔断层面的(绕不过)"。DeepSeeker-Code 的所有关键约束,都是后者。

决策三:声明式优于隐式默认

声明式子 Agent 用显式白名单(只给声明的工具),而不是默认黑名单(除了危险的全给)。这是个"白名单优先于黑名单"的安全范式——默认不给,显式要才给,比"默认全给,显式禁才禁"安全得多。读 agents/loader.ts 时,你会发现声明式 agent 的 frontmatter(system/tools/model)就是这套显式授权的配置面。

决策四:共享内核,文案归调用方

spawn_agent 和 run_workflow 共享 runSubagent 内核,但各自的"呈现"不同——spawn_agent 包汇报框 + 同步通知,run_workflow 做并行聚合。这是个干净的职责切分:派生/执行/收权/熔断是共享逻辑,文案包装是各自定制。runSubagent 返回纯数据,调用方决定怎么包装。这种"内核共享、外壳定制"的模式,让两个工具的逻辑零分叉。

五、五个技术难点

难点一:模型不按套路出牌怎么办

计划模式设计了一套"进→调研→出"的流程,但 DeepSeek 不一定照着走——它可能跳过 enter_plan_mode 自己调研(难点见第二节第三条路径),也可能压根不进计划模式直接动手。

应对是多层兜底:系统提示词里写强引导(PLAN_MODE_AUTO_ENTER_HINT,给出非平凡判据,扭转"从不进计划模式"的倾向);正常模式也暴露 exit_plan_mode(让自行调研也能走审批);首轮还有 PLAN_FIRST nudge(第 1 篇讲过)推它进计划模式;进了计划模式后又重复调 enter_plan_mode?防翻转守卫以普通结果劝返。不假设模型守规矩,而是无论它怎么走,都能被收束到正确的闸门。

工具表恒定还引入了新变体:"守规矩"多了一层含义——计划期模型看得见写工具,会不会手痒?实测零试探,但设计上不能赌:执行层门禁就是那道兜底,真试探了也是一次带引导文案的拒绝(不弹审批、不加锁),下一轮模型自然转只读。引导(提示词)+ 兜底(门禁)双层,比"物理看不见"少一分优雅,多一分缓存。

难点二:并行子 agent 的审批竞态

第二节讲过 approvalMutex。补充它为什么难——并发编程里"共享资源"的访问要串行化是常识,但这里的共享资源是"用户注意力"(一次只能看一个审批框)。把审批当共享资源上锁,是非典型但正确的抽象。漏了这把锁,并行高危操作会让用户面对一堆乱跳的弹窗。

难点三:并行写同一批文件

parallel 模式默认共享工作区,多个子 agent 同时改同一批文件必然冲突。注释反复强调"parallel 强烈建议用于读/分析任务"。真要并行写,靠 worktree 隔离——每个子 agent 一个独立 git worktree,结束后收割 diff。这是个完整的"并行写安全"方案:隔离执行 + 事后合并,而不是"并行改同一个工作区然后祈祷不冲突"。

难点四:父子 agent 的状态同步

子 agent 会改文件,但父 agent 不知道改了哪些。spawn_agent 的返回值里塞了一段刺眼的同步通知(agent.ts :60):

🚨【重要同步通知】: 该子 Agent 刚才极有可能已经高频修改、创建或删除了
你管辖区内的本地源码文件。如果你接下来要对文件系统继续实施 edit_file、
write_file 或验证,【你必须】首先调用 read_file 重新读取最新磁盘状态,
禁止盲目依赖你之前的记忆缓存,否则必然会引发唯一性冲突匹配失败!

这是给模型看的提醒——父子 agent 共享文件系统,子 agent 改了文件,父 agent 的"记忆"就过期了。如果父 agent 凭记忆去 edit,old_str 匹配不上(因为子 agent 改过了)。这段通知强迫父 agent 先 read_file 刷新"视网膜缓存"。注释里"视网膜缓存"这个比喻很形象——agent 没有真正的文件系统感知,它的"记忆"全靠之前 read 到的内容,过期了就全错。

难点五:深度熔断防无限套娃

子 agent 能再生子 agent,理论上能无限嵌套。MAX_AGENT_DEPTH 一到就拒绝(subagent.ts :69)。这和主循环 500 轮、压缩连续失败 3 次熔断是同一类设计——给递归/循环一个物理上限,防失控。而且报错是建设性的("请在本层级自行消化任务"),不是直接崩。

六、推荐的源码阅读顺序

  1. 先读 planMode.ts:从 PLAN_ALLOWED_TOOLS(只读白名单)开始,看 filterToolsForPlanMode(schema 回退档的裁表约束)和两个信号 schema(enter/exit_plan_mode)。这是"信号 vs 约束"的最小完整样本。
  2. 读 toolExecution.ts 的计划模式门禁(:143):看 runtime 档(默认)怎么在执行层拒绝写工具——不弹审批、不加锁、直接以结果文案劝返。这是现状约束的真正落点。
  3. 读 systemInjections.ts 的 prepareToolsAndInjections:看 planMode 怎么和主循环接上(两态统一 appendPlanControlTools 的恒定工具表),以及 P0-4 静态化约束的注释。
  4. 读 subagent.ts 的 runSubagent:这是子 agent 的派生内核,重点看深度熔断、工具收权(黑名单 + 白名单)、审批透传、异常兜底。
  5. 读 agent.ts(spawn_agent):看它怎么调 runSubagent + 包文案(汇报框 + 同步通知)。
  6. 读 workflow.ts(run_workflow):重点看 parallel/pipeline 两种模式、并发节流、审批串行化、worktree 隔离。

七、关联:信号与约束的辐射

"信号 vs 约束分离"不只是这两个功能的原则,它辐射到整个 agent:

  • 计划模式的约束(执行层门禁)→ 复用第 6 篇要讲的工具协议(CustomTool 的 safetyLevel / 工具表本身就是约束的载体),且门禁落在第 7 篇 processToolCall 拦截链的最前段。
  • 子 Agent 的审批透传 → 第 7 篇的安全防线(processToolCall 审批管线,子 agent 必须透传 requestApproval 否则死锁)。
  • 深度熔断 / 并发节流 → 和第 1 篇的 nudge 预算上限、repeatBreaker 一脉相承,都是"给软机制配硬上限"。
  • worktree 隔离 → 复用 git worktree 工具,是"并行写安全"的工程方案。

你会发现,"信号归模型、约束归 harness"是 DeepSeeker-Code 处理"模型自主性 vs 可控性"的核心方法论。计划模式和子 Agent 只是它最集中的体现。

最后

让 agent 又自主又可控,是所有 agentic 系统的核心难题。给模型太大自由,它乱来;管太死,它没用。DeepSeeker-Code 的答案是信号 vs 约束分离——把"想做什么"的权利交给模型(信号工具),把"能不能做"的强制握在 harness 手里(执行层门禁 / 收权 / 熔断),而且强制必须是物理的、绕不过去的。

计划模式让 agent "想清楚再动手",子 Agent 让它"派分身去干活",两个功能表面不同,底层都是这套原则。而这篇还有一条暗线值得带走:约束的实现方式本身也在被缓存契约重塑——从"裁掉工具"到"门禁拒绝",约束强度让了一步(模型看得见写工具了),换来工具表全会话恒定(首调命中率 6.4% → 59.7%)。当你系统的头部字节值钱时,"物理看不见"这种约束就不是免费的。读这段源码,最值得带走的是那个判断:看到一个约束,先问它是提示词层面的(可绕过),还是执行层/熔断层面的(绕不过);再问一句,它动没动请求头部。

下一篇,我们读工具协议本身——CustomTool 的字段设计,看一个工具是怎么用 safetyLevel、审批、锁、环境断言这些字段,把"约束"结构化的。

项目源码开源在 github.com/xnk/deepSee…,文章里提到的文件都在 src/core/src/agent/ 和 src/core/src/tool/ 下,欢迎对着源码读。觉得这个导读系列有点意思,点个 star 是对我最大的鼓励。

总结

  1. 核心原则:信号归模型,约束归 harness。意图(enter_plan_mode / spawn_agent)是模型的权利,强制(执行层门禁 / 收权 / 熔断)是 harness 的责任,两者严格分离;
  2. 计划模式:enter/exit_plan_mode 是信号(按名拦截、不走常规执行),约束是执行层门禁——PLAN_ALLOWED_TOOLS 白名单外的工具在 processToolCall 直接拒绝(不弹审批不加锁);工具表全会话恒定(P0-A:tools 计入 DeepSeek 前缀哈希,裁表翻转实测 6.4% vs 恒定 59.7% 首调命中);schema 档可回退;三条进入路径都收敛到方案审批;
  3. 子 Agent:spawn_agent(串行)/ run_workflow(并行+流水线)共享 runSubagent 内核;约束是 SUBAGENT_DENYLIST 收权(无 shell/递归删除)+ 声明式白名单(mcp__* 条目运行期翻译为分发器双工具)+ 深度熔断;
  4. 四个设计决策:信号约束分离、约束靠执行层强制不靠提示词(约束强度让一步、缓存稳定性拉满的 trade-off)、声明式白名单优于黑名单、共享内核文案归调用方;
  5. 五个技术难点:模型不守规矩的多层兜底(含防翻转守卫与计划期试探治理)、并行审批竞态(approvalMutex 串行化)、并行写冲突(worktree 隔离)、父子状态同步(🚨 重读通知)、深度熔断防套娃。