跳到主要内容

委派与专长:子 agent 并行研究与 Skill 工作流

30 秒导读: 一个主 agent(leader)想更强,有两条横向扩展的路。一条是委派:把一块自成一体的活丢给一个隔离的子 agent,它跑自己的循环、只把最终答案回传,leader 的上下文不被中间过程污染;多块独立的活可以同一轮一起丢出去、并行跑。另一条是专长:把「怎么做 DCF 估值」这类可复用的工作流写成一份 SKILL.md,平时只在系统提示里挂一行简介,真用到时才把完整指令按需注入。本章讲清这两套机制怎么落地。

本章属于 Dexter 系列的第 5 章。前置:核心主循环见 01-agent-loop,工具层的元工具路由 / 并发批处理 / 权限闸门见 02-tools-and-permissions,上下文工程见 03-context-engineering。本章不讲普通工具的执行与权限(→ 02),也不讲无人值守调度(→ 06-autonomy-and-delivery)。


1. 这是什么(零基础也能懂)

一个 AI agent 的本事,受限于两样东西:它的上下文窗口有多大,和它会不会某个专门活儿。本章的两套机制,正好各解一个。

1.1 子 agent:把活外包出去,只要结果

先说个痛点。假设你让 Dexter「对比 NVDA、AMD、INTC 三家的护城河」。如果它自己一家一家查——搜财报、读新闻、翻 filings——每家的中间过程(几十次工具调用的原始输出)都会堆进它的上下文。三家查完,上下文早被原始素材撑爆,真正该留着做对比的「脑容量」反而没了。

子 agent(subagent) 就是解法:leader 把「分析 NVDA」这一整块活,外包给一个全新的、隔离的 agent。这个子 agent:

  • 自己的一轮完整 agent 循环(自己搜、自己读、自己想);
  • 看不见 leader 的对话,也不能再往下委派;
  • 干完只把一段最终答案交回来——中间那几十次工具输出,leader 一个字都不用看。

一句话直觉:像项目经理把子任务派给三个互不打扰的实习生,自己只收三份结论,不看他们的草稿纸。 而且三个实习生同时开工。

1.2 Skill:把「怎么做」写成可复用的说明书

另一个痛点。「做一次 DCF 估值」有固定套路:先拉财务数据、算自由现金流增长率、估 WACC、折现、做敏感性分析……这套流程又长又固定。如果每次都把它塞进系统提示,提示会被撑得又贵又臭;如果不塞,模型又常常做得不规范。

Skill(技能) 的办法是按需披露:平时系统提示里只挂一行「有个 dcf 技能,遇到估值问题能用」;当用户真问「NVDA 值多少钱」时,模型才调 skill 工具,把那份完整的 SKILL.md 说明书取进来当作接下来的行动纲领。

一句话直觉:像一本放在架子上的《操作手册》——不用时不占桌面,要做某道复杂菜时才翻开对应那一页照着做。

1.3 两条路的共同点

子 agentSkill
扩展的是算力 / 上下文(横向并行)知识 / 流程(可复用专长)
核心动作起一个隔离子循环,回传答案注入一段工作流指令
触发工具spawn_subagentskill
省的是什么leader 的上下文不被中间过程污染系统提示不被长流程撑爆
关键约束只能委派一层、只读、可并行每个 query 每个技能只调一次

2. 顶层全景(它大概怎么转)

两套机制都挂在工具层上——它们本质就是两个特殊工具(spawn_subagentskill),由主循环像调用普通工具一样调用。区别在于它们「调用后」做的事不一样。

┌─────────────────────────────────┐
│ 主 agent (leader) │
│ 一轮里可发多个 tool_call │
└───────┬──────────────────┬──────┘
│ │
spawn_subagent ×N │ │ skill(name)
(同一轮多次 → 并行) │ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ 子 agent 子循环 │ │ Skill 加载器 │
│ · 隔离上下文 │ │ · 读 SKILL.md │
│ · 只读工具白名单 │ │ · 解析 frontmatter │
│ · memory 关掉 │ │ · 注入完整指令 │
│ · 不能再委派 │ └───────────┬──────────┘
│ 消费 event 流 │ │ 返回指令文本
│ → 只回传 answer │ ▼
└───────────┬──────────┘ ┌──────────────────────┐
│ │ leader 照指令继续做 │
▼ └──────────────────────┘
一段最终答案 + token 计

怎么读这张图: 左路是委派(外包出去、拿回答案),右路是专长(取回说明书、自己照做)。两路都从 leader 的一次 tool_call 出发,但左路新起一个 agent,右路只取一段文本

各部件一句话职责:

部件干什么文件
createSpawnSubagentspawn_subagent 工具:起子循环、消费 event、回传答案src/tools/subagent/spawn-subagent.ts:78
SUBAGENT_TYPES / resolveSubagentTools不同子 agent 类型的系统提示 / 工具白名单 / 迭代预算src/tools/subagent/types.ts:55
encodeSubagentProgress把子 agent 的实时进度编码成一条字符串,越过工具↔UI 的边界src/tools/subagent/progress.ts:24
parseSkillFile / extractSkillMetadata解析 SKILL.md 的 frontmatter 与正文src/skills/loader.ts:15 / :56
discoverSkills / buildSkillMetadataSection发现所有技能、拼出注入系统提示的清单src/skills/registry.ts:62 / :107
skillToolskill 工具:按名加载技能、返回指令src/tools/skill.ts:37
hasExecutedSkill去重:同一 query 里同一技能只跑一次src/agent/scratchpad.ts:443

3. 核心机制一:子 agent 的隔离子循环

3.1 它要解决的小问题

leader 想把一块活外包,但外包出去的 agent 必须真隔离:不能看见 leader 的对话(否则等于没省上下文)、不能有副作用(否则并行时会互相踩、争抢审批弹窗)、不能再往下无限委派(否则递归失控)。createSpawnSubagent 就是把这几条约束焊死在一个工具里。

3.2 思路:一个工具 func 里,起一个完整的 mini agent

spawn_subagent 不是特殊的运行时,它就是一个普通的 DynamicStructuredTool。它的 func 做三件事:按类型配好一个隔离的子 agent → 跑它 → 把它的 event 流嚼成一段答案回传

3.3 关键细节 1:懒加载破循环依赖

子 agent 要用 Agent.create,但 Agent 又通过工具注册表引用了 spawn_subagent——直接 import 会形成 registry → spawn-subagent → agent → registry 的循环。解法是在 func 内部懒加载:

真实实现 src/tools/subagent/spawn-subagent.ts:93:

// Lazy import to break the registry → spawn-subagent → agent → registry cycle.
const { Agent } = await import('../../agent/agent.js');

到第一次真正调用工具时,所有模块早已加载完,动态 import 不再有循环问题。

3.4 关键细节 2:用四个开关把子 agent「锁」成隔离只读

起子 agent 时传的这组参数,每一个都对应一条隔离约束。真实实现 src/tools/subagent/spawn-subagent.ts:95:

const subagent = await Agent.create({
model,
maxIterations: typeCfg.maxIterations, // 按类型给的步数预算
signal, // 继承父级取消信号
memoryEnabled: false, // 关记忆:不读不写用户记忆
toolAllowlist, // 只给白名单里的只读工具
systemPromptOverride: typeCfg.systemPrompt, // 换成 worker 专用提示
agentLabel: typeKey,
});
开关作用为什么
memoryEnabled: false子 agent 不碰用户记忆隔离——它是临时工,不该读写长期记忆(记忆系统见 04-memory)
toolAllowlist只给只读工具无副作用——并行时不会争抢审批、不会互相踩写操作
systemPromptOverride换成 worker 提示让它知道「你是子 agent、看不见主对话、不能再委派」
maxIterations限定步数防止子 agent 兜圈子烧钱

注意 leader 传给子 agent 的输入:如果带了 context,就把背景拼在任务前面(spawn-subagent.ts:106),因为子 agent看不见主对话,一切所需必须显式喂进去。

3.5 关键细节 3:消费 event 流,但只回传 answer

子 agent 跑起来会吐出一串 event(思考、工具开始、流式进度、完成……)。leader 一样都不转发给用户——它只关心最后那段答案,顺带统计一点实时指标喂给 UI。真实实现 src/tools/subagent/spawn-subagent.ts:133:

for await (const ev of subagent.run(query)) {
switch (ev.type) {
case 'tool_start': // 计数 + 分类(搜索/读取),更新活动行
toolUseCount++; lastTool = ev.tool;
if (SEARCH_TOOLS.has(ev.tool)) searchCount++;
else if (READ_TOOLS.has(ev.tool)) readCount++;
emit();
break;
case 'stream_progress': // 累加字符数做 token 估算,但不每块都 emit(会淹没父级)
streamedChars += ev.charDelta;
break;
case 'done': // 只取这个:答案 + 精确 token 数
answer = ev.answer; usage = ev.tokenUsage;
break;
default: // thinking / compaction 等一律吞掉
break;
}
}

最后回传时,把子 agent 的 token 计附在答案末尾,让 leader(和用户)知道这块外包花了多少(spawn-subagent.ts:165):

return usage
? `${answer}\n\n_[subagent ${typeKey}: ${usage.totalTokens} tokens]_`
: answer;

3.6 关键细节 4:进度怎么越过「工具层 ↔ UI 层」的边界

有个工程小难题:子 agent 的实时进度(调了几次工具、烧了多少 token、正在干嘛)产生在工具层,但显示这行进度的 UI 组件在UI 层,两者之间唯一的活线是工具的 onProgress 字符串通道。

解法(src/tools/subagent/progress.ts):把结构化的进度对象编码成一条带前缀的字符串塞进这个字符串通道,UI 那头再解码。前缀 subagent-progress(progress.ts:22)是个哨兵,保证这条结构化载荷不会跟普通进度字符串撞车:

export function encodeSubagentProgress(progress: SubagentProgress): string {
return PREFIX + JSON.stringify(progress); // progress.ts:24
}

而那行「正在干嘛」的人话,是 activityText 把尾部若干次工具操作像人一样归纳出来的——搜了 3 次、读了 2 个来源,就显示 Searched 3×, read 2 sources(spawn-subagent.ts:18):

function activityText(searchCount, readCount, lastTool) {
if (searchCount + readCount >= 2) { // 攒够 2 次才归纳
const parts = [];
if (searchCount > 0) parts.push(`Searched ${searchCount}×`);
if (readCount > 0) parts.push(`read ${readCount} source${readCount === 1 ? '' : 's'}`);
return parts.join(', ');
}
if (lastTool) return `Ran ${lastTool}`; // 否则只报最近一个工具
return 'Initializing…';
}

4. 核心机制二:子 agent 类型(config bundle)

4.1 它要解决的小问题

不是所有外包活都一样:「查外部信息」需要搜索类工具,「算财务」需要财报类工具。与其给每个子 agent 全套工具,不如按类型预置好三样东西——系统提示 + 工具白名单 + 迭代预算。这就是「subagent type」。

4.2 三种类型

SUBAGENT_TYPES 是一张类型表(src/tools/subagent/types.ts:55),每种类型是一个小 config bundle:

类型何时用工具白名单maxIterations
general-purpose(默认)单一聚焦子任务上的多步研究/分析全套只读工具(READ_ONLY_TOOLS)8
research就一个主题从外部搜集并综述web_search / x_search / web_fetch / read_filings / get_market_data8
analysis针对具体公司的定量财务分析get_financials / get_market_data / stock_screener / read_filings8

默认类型是 general-purpose(types.ts:76)。所有类型的系统提示都以同一段 WORKER_PREAMBLE(types.ts:47)开头——正是它告诉子 agent「你在隔离中工作、看不见主对话、不能再委派、你的最终消息会原样回传,所以要直接给结论而不是描述过程」。

4.3 关键细节:白名单的「双保险」

resolveSubagentTools 解析某类型的工具白名单时,会再过一遍黑名单 SUBAGENT_DISALLOWED_TOOLS,把危险工具防御性地剔掉(src/tools/subagent/types.ts:82):

export function resolveSubagentTools(typeKey: string): string[] {
const cfg = SUBAGENT_TYPES[typeKey] ?? SUBAGENT_TYPES[DEFAULT_SUBAGENT_TYPE];
return cfg.tools.filter(t => !SUBAGENT_DISALLOWED_TOOLS.has(t));
}

黑名单里(types.ts:27)最关键的是 spawn_subagent 自己——这保证子 agent 永远拿不到委派工具,委派因此只有一层深,不会递归失控。另外 ask_user_question(子 agent 无 UI 可问)和 bash(有副作用)也被排除。这是「双保险」:类型表里本来就没给这些工具,这里再兜底一次。


5. 核心机制三:并行是怎么真正落地的

5.1 契约:描述里写死的「同一轮多次 emit → 并行」

子 agent 能并行,靠的是 leader 遵守一条写在工具描述里的契约。这段 SPAWN_SUBAGENT_DESCRIPTION(src/tools/subagent/spawn-subagent.ts:33)会被注入 leader 的系统提示,原文关键几句:

  • 「你有多个互相独立的子任务:在同一轮里发出好几个 spawn_subagent 调用,它们就会并行跑。」
  • 互相依赖输出的子任务不要这么做(自己顺序做,或跨轮串)。」
  • 「子 agent 在隔离中运行——它看不见这段对话,也不能再委派。」

也就是说,并行的「意图」由 leader 在一轮里同时发出多个调用来表达;是否真并行,则由下一层的执行器决定。

5.2 落地:concurrencySafe 标记让并发批处理接手

在工具注册表里,spawn_subagent 被标成 concurrencySafe: true(src/tools/registry.ts:83):

{
name: 'spawn_subagent',
tool: createSpawnSubagent(model),
description: SPAWN_SUBAGENT_DESCRIPTION,
compactDescription: 'Delegate a focused sub-task to an isolated subagent. Emit multiple calls in one turn to run independent sub-tasks in parallel.',
concurrencySafe: true,
},

这个标记正是 02 章讲的并发批处理器的开关。工具执行器 partitionToolCalls 会把连续的 concurrency-safe 调用归到同一批(src/agent/tool-executor.ts:94),然后 executeBatchConcurrentlyall(...) 并行跑,并发上限 DEFAULT_MAX_CONCURRENCY = 10(tool-executor.ts:31):

private async *executeBatchConcurrently(calls, ctx) {
const generators = calls.map(call => this.executeSingleWithId(call, ctx));
yield* all(generators, this.maxConcurrency); // tool-executor.ts:120
}

两层配合才有真并行:上层(工具描述里的契约)引导 leader 在一轮里发多个调用;下层(concurrencySafe 标记 + 并发批处理)让这些调用真的同时跑。而子 agent 之所以敢并行,又回到 §3.4——它们全是只读、无副作用、无审批的,不会互相踩。


6. 核心机制四:Skill —— 可复用工作流的按需注入

6.1 它要解决的小问题

有些活是固定套路(DCF 估值、写投资备忘录、X 舆情研究),套路本身又长又不该占系统提示。Skill 的目标:平时只挂简介,用时才注入全文——两级披露。

6.2 SKILL.md 是什么

一个技能就是一个含 SKILL.md 的目录。SKILL.md 用 YAML frontmatter 声明 namedescription,正文是完整的工作流指令。看三个真实样例的 frontmatter:

技能namedescription(触发时机)节选文件
DCF 估值dcf-valuation「用户问 fair value / 内在价值 / DCF / 估值 / 值多少钱 / 高估低估时触发」src/skills/dcf/SKILL.md
写备忘录write-memo「用户说 write a memo / 写多空 thesis / pitch 某股时触发,产出 .dexter/memos/ 下一页 HTML」src/skills/write-memo/SKILL.md
X 舆情x-research「用户问『大家怎么看』/『X/Twitter 情绪』/ 搜推特时触发」src/skills/x-research/SKILL.md

description 写得很「口语化触发词」,正是因为它要进系统提示、供模型按用户话术匹配该不该调这个技能。正文则是照着做的 checklist——dcf 的正文一上来就给一份「Step 1 拉数据 … Step 8 带 caveat 呈现」的进度清单。这就是「专长 = 可复用的工作流指令」的实体。

6.3 解析:frontmatter 与正文分离

parseSkillFilegray-matter 把 frontmatter(data)和正文(instructions)切开,并校验 name/description 必填(src/skills/loader.ts:15):

export function parseSkillFile(content, path, source): Skill {
const { data, content: instructions } = matter(content);
if (!data.name || typeof data.name !== 'string') { throw ... }
if (!data.description || typeof data.description !== 'string') { throw ... }
return { name: data.name, description: data.description, path, source, instructions: instructions.trim() };
}

启动时不需要读全文,只要元数据。extractSkillMetadata(loader.ts:56)就是「轻量版」:只 matter 出 frontmatter,不读正文——这正是两级披露的第一级。

6.4 发现与注入系统提示

discoverSkills(src/skills/registry.ts:62)扫描技能目录(内置 src/skills/ + 项目级 .dexter/skills/,后者覆盖前者,见 registry.ts:15),缓存只含元数据的清单。buildSkillMetadataSection(registry.ts:107)把它拼成注入系统提示的一行行 - **name**: description

在系统提示里,这些技能挂在 buildSkillsSection(src/agent/prompts.ts:67)下,并跟着一段 Skill Usage Policy(prompts.ts:80):

  • 「相关就第一时间调、作为你的首个动作」;
  • 已经为当前 query 调过的技能,不要再调」。

6.5 调用:skill 工具把正文加载回来

真到用时,模型调 skill 工具(src/tools/skill.ts:37)。它用 getSkill(src/skills/registry.ts:86)按名加载完整技能(这才读正文,第二级披露),返回指令文本。有个贴心处理:把正文里的相对 .md 链接改写成绝对路径,这样模型后续用 read_file 才找得到被引用的附件(如 sector-wacc.md)(skill.ts:62):

const skillDir = dirname(skillDef.path);
const resolved = skillDef.instructions.replace(
/\[([^\]]+)\]\(([^)]+\.md)\)/g,
(_match, label, relPath) => {
if (relPath.startsWith('/') || relPath.startsWith('http')) return _match;
return `[${label}](${resolve(skillDir, relPath)})`; // 相对→绝对
},
);

skill 工具本身在注册表里被标成 concurrencySafe: false(src/tools/registry.ts:215),因为注入指令后模型该顺序照做,不宜跟别的调用挤在一批。

6.6 去重:每个 query 每个技能只跑一次

「不要重复调同一技能」不只写在 policy 里,还在执行层强制。工具执行器分批前会先查 scratchpad:如果这个技能这轮已经执行过,直接跳过(src/agent/tool-executor.ts:98):

if (call.name === 'skill') {
const skillName = (call.args as Record<string, unknown>).skill as string;
if (ctx.scratchpad.hasExecutedSkill(skillName)) continue; // 跳过重复
}

hasExecutedSkill 的判断很直接:翻 scratchpad 里有没有一条 tool_resultskill 工具、且 args.skill 同名(src/agent/scratchpad.ts:443)。这是「policy 靠模型自觉、执行层再兜底」的双保险——跟 §4.3 子 agent 白名单的双保险是同一种思路。


7. 巧妙之处(可借鉴的技术)

  • 懒加载破循环依赖。 工具要用 AgentAgent 又要用工具,直接 import 成环。在工具 func 里 await import(...),把解析推迟到「所有模块都已加载」的第一次调用时(spawn-subagent.ts:93)。
  • 委派天然一层深。 不靠运行时计数器,而是把 spawn_subagent 直接列进子 agent 的工具黑名单(types.ts:27)——子 agent 根本拿不到委派工具,递归无从谈起。
  • 并行 = 上层契约 + 下层标记。 工具描述教 leader「独立子任务同一轮多发」,concurrencySafe: true 让并发批处理真把它们并起来(spawn-subagent.ts:39 + registry.ts:83);两层解耦,各管一段。
  • 进度跨层用「前缀哨兵字符串」偷渡。 工具层→UI 层只有一根 onProgress 字符串水管,就用带 subagent-progress 前缀的 JSON 串走这根管,解码端认前缀(progress.ts:22),既不新增通道也不误伤普通进度串。
  • 活动行「像人一样归纳」。 不逐条报工具,而是攒够 2 次就滚成 Searched 3×, read 2 sources(spawn-subagent.ts:18),UI 更像人话。
  • 两级披露省 token。 启动只 extractSkillMetadata 挂简介,用时才 getSkill 读全文(loader.ts:56 / registry.ts:86);系统提示不被长工作流撑爆。
  • policy + 执行层双保险去重。 「每技能只调一次」既写进 Skill Usage Policy 引导模型,又在 partitionToolCallshasExecutedSkill 强制跳过(tool-executor.ts:98)。

8. 边界与局限

  • 委派只有一层。 子 agent 不能再 spawn 子 agent(黑名单强制)。需要更深的分解只能靠 leader 自己跨轮串。
  • 子 agent 只读、无副作用。 白名单里没有 write/edit/memory 变更类工具(types.ts:34 的注释明说是为了并行时不争抢审批、不产生副作用),所以子 agent 不能写文件、改记忆。
  • 子 agent 看不见主对话。 一切所需都得 leader 塞进 task/context;漏了就查不到(spawn-subagent.ts:106)。
  • v1 无审批链路。 代码注释明说「Read-only subagents: no approval plumbing needed in v1」(spawn-subagent.ts:103)——子 agent 只读,所以暂时不接权限闸门。
  • 子 agent 无产出时的兜底。 若子循环跑完没吐出 answer,回传一句「finished without producing an answer」而非报错(spawn-subagent.ts:162)。
  • 技能去重按名去重。 同名技能一轮只跑一次;若确实想用不同 args 再跑一次,会被 hasExecutedSkill 拦掉(scratchpad.ts:443)。

9. 横向对比(系列内)

关切本章(05)相邻章
普通工具的路由 / 并发批处理 / 权限闸门只借用 concurrencySafe 与并发批处理机制本身见 02
上下文如何有界又不丢信息子 agent「只回传答案」是隔离手段之一系统性讲法见 03
记忆读写子 agent memoryEnabled: false 关掉记忆记忆系统见 04
无人值守调度触发这些机制不涉及06

Dexter 全景见 index,主循环端到端见 01


10. 代码地图(导航索引)

主题文件路径符号名
spawn_subagent 工具工厂src/tools/subagent/spawn-subagent.tscreateSpawnSubagent
委派契约(注入 leader 提示)src/tools/subagent/spawn-subagent.tsSPAWN_SUBAGENT_DESCRIPTION
活动行归纳src/tools/subagent/spawn-subagent.tsactivityText
子 agent 类型表src/tools/subagent/types.tsSUBAGENT_TYPES / DEFAULT_SUBAGENT_TYPE
worker 系统提示前言src/tools/subagent/types.tsWORKER_PREAMBLE
白名单解析 + 黑名单双保险src/tools/subagent/types.tsresolveSubagentTools / SUBAGENT_DISALLOWED_TOOLS
进度跨层编解码src/tools/subagent/progress.tsencodeSubagentProgress / decodeSubagentProgress
并发标记src/tools/registry.tsspawn_subagentconcurrencySafe: true
并发批处理src/agent/tool-executor.tspartitionToolCalls / executeBatchConcurrently
SKILL.md 解析src/skills/loader.tsparseSkillFile / extractSkillMetadata
技能发现与注入src/skills/registry.tsdiscoverSkills / buildSkillMetadataSection / getSkill
skill 工具src/tools/skill.tsskillTool / SKILL_TOOL_DESCRIPTION
Skill Usage Policysrc/agent/prompts.tsbuildSkillsSection
技能去重src/agent/scratchpad.tshasExecutedSkill
技能样例src/skills/dcf/SKILL.md · src/skills/write-memo/SKILL.md · src/skills/x-research/SKILL.md