目录/dynamic subagents

第 16 章:Dynamic Subagents — 用代码编排多个 Agent

一个代码仓库里有 24 个路由文件。主 Agent 可以把第一个文件交给代码审查 Agent,等结果回来,再决定是否检查第二个;也可以先列出全部文件,用 JavaScript 一次组织 24 次审查,再把可疑发现交给另一个 Agent 复核。两种方式都用了子 Agent,差别在于谁控制任务的拆分、并发和下一阶段。

直接调用子 Agent 适合一次明确委派。任务覆盖许多独立对象,或者需要分类、并行、交叉验证和反复收敛时,让模型逐次选择下一次委派会带来漏项、额外模型轮次和不稳定的控制流。Dynamic Subagents(动态子 Agent)沿用上一章的 QuickJS Interpreter:模型先生成编排代码,再由代码调用内置的 task(),启动多个完整的子 Agent 循环并汇总结果。

本章先说明如何准备子 Agent 并触发 workflow,再用工单分流、代码审查和死代码排查三个例子展示主 LLM 可能生成的编排代码。除了分清 task() 和普通 task Tool,还要知道怎样定义可用角色、描述处理流程,并检查输入范围、调度约束和停止条件。

Dynamic Subagents 依赖的 Interpreter Runtime 仍处于 Beta 阶段,接口和生命周期可能继续变化。运行环境需要 Python 3.11+ 与 langchain-quickjs>=0.2.0;本章在 deepagents==0.7.8langchain-quickjs==0.3.5 中核对接口和运行时限制。

1. 从单次委派到动态编排

Deep Agents 已经可以通过普通 task Tool 把工作交给子 Agent。主 Agent 每次决定一个 description 和一个 subagent_type,等待该子 Agent 完成,再继续自己的推理循环。

这种方式没有问题,只是控制粒度不同:

任务形状优先方式原因
把一个明确问题交给一个专家普通 task Tool路径短,容易观察和逐次审批
对一批独立对象执行同类分析Dynamic SubagentsJavaScript 可以完整覆盖输入并并行扇出
输入混杂,需要先分类再分发Dynamic Subagents分类结果可以直接决定下一批 subagentType
同一发现需要独立复核Dynamic Subagents第一阶段输出可以在代码中进入第二阶段
范围未知,需要反复搜索直到没有新结果Dynamic Subagents循环和停止条件由代码明确表达
子任务要在后台持续运行并由主 Agent 随后查询Async Subagents生命周期目标不同,不应混为并行扇出

Dynamic Subagents 不是新的子 Agent 类型。它改变的是编排位置:普通委派由主模型逐次调用 task Tool;动态编排则由一次 eval 中的 JavaScript 多次调用 task()

一次明确交接由普通 task 完成;面对批量或多阶段任务,主 LLM 可以生成 JavaScript,并由 QuickJS 多次调用 task() 后汇总结果

为什么代码编排更稳定

假设要审查 24 个文件。模型逐次委派时,每次返回都可能改变后续决策;如果上下文变长,模型还可能提前总结,留下没有处理的文件。把文件列表放进 JavaScript 后,可以明确表达:

  1. 每个输入都进入一次审查。
  2. 独立任务按批次并行运行。
  3. 第一阶段的发现统一去重和筛选。
  4. 只有高风险发现进入复核。
  5. 最终只把已确认的结果交还主模型。

生成的代码通过检查后,可以把控制流和覆盖规则明确下来,但不能保证每个子任务都成功,也不能保证子 Agent 的判断正确。每次 task() 都会启动完整的 Agent 推理循环,结果仍受模型、Prompt、工具和输入影响。

2. 先看运行过程:AI 编写 JavaScript,task() 执行调度

当 Agent 同时配置了子 Agent 和 Interpreter 中间件,JavaScript 环境会出现全局函数 task()。具体的 subagents 定义放在下一节,这里先看运行链路。langchain-quickjs 负责把 Python 侧的 Interpreter 中间件、QuickJS 运行时与异步能力桥接起来:

  1. 主 LLM 读取用户请求,决定是否调用 eval,并为当前任务编写 JavaScript。
  2. 这段 JavaScript 在 QuickJS 中运行,遇到 task() 时启动指定的子 Agent。
  3. 每个子 Agent 再运行自己的完整推理循环,可以使用分配给它的工具。
  4. JavaScript 收集、筛选和合并结果,最后只把需要的信息交回主 LLM。

因此,task() 不是开发者在 Python 中手写的固定工作流函数。它是 Interpreter 注入到 JavaScript 环境里的全局函数,而调用它的 JavaScript 通常由主 LLM 根据当前请求现场生成。

这对主模型提出了更高要求。模型既要稳定调用工具,也要写出可运行的异步 JavaScript,正确使用 await、数组方法、循环、分支和 Schema,还要选中真实存在的 subagentType。模型能力不足时,编排代码容易出现语法错误、角色名漂移、遗漏输入或循环失控。

提示词里的 workflow 会提醒主 LLM 优先考虑代码编排。它不是 API 参数;真正提供能力的是 subagents 配置和 CodeInterpreterMiddleware。下一节的提示词只写业务目标与约束,不需要加入底层函数和工具名。只有一次明确委派时,普通 task Tool 往往更直接。

用户用 workflow 描述请求,主 LLM 编写 JavaScript,经 eval 进入 QuickJS;task() 使用三个可用字段启动已配置的子 Agent,结果经筛选和合并后返回

task() 只有三个字段

把三个字段放在一起看,调用契约会清楚很多:

字段是否必填作用
description发送给子 Agent 的任务说明,应包含目标、范围、约束和输出要求
subagentType选择哪个已配置的子 Agent,值必须匹配 subagents 中的 name
responseSchema用 JSON Schema 约束返回值;设置后得到的已经是 JavaScript 对象

下面的调用同时用到了三个字段:

const review = await task({
  description: "读取 src/auth/login.ts,检查认证绕过;引用行号。",
  subagentType: "reviewer",
  responseSchema: {
    type: "object",
    properties: {
      issues: {
        type: "array",
        items: {
          type: "object",
          properties: {
            line: { type: "number" },
            severity: { type: "string" },
            evidence: { type: "string" },
          },
          required: ["line", "severity", "evidence"],
        },
      },
    },
    required: ["issues"],
  },
});

const highRisk = review.issues.filter((item) => item.severity === "high");

设置 responseSchema 后,review 已经是对象,不需要再调用 JSON.parse。一次 task() 返回的是本次子 Agent 运行的结果,不是可继续对话的会话;下一次调度仍要提供自包含的 description

上面的 JavaScript 只是便于解释接口的代表性写法。实际运行时,主 LLM 可能改用 for...ofPromise.all、辅助函数或不同变量名。判断是否正确,应看输入有没有完整覆盖、角色是否选对、停止条件是否生效,以及结果是否满足 Schema,不要要求生成代码逐字一致。

3. 先准备角色,再触发 workflow

使用 Dynamic Subagents 时,开发者和用户负责不同层面的输入。开发者预先定义主 LLM 可以选择哪些子 Agent,以及每个角色的工作边界;用户在请求中写出 workflow,并说明这些角色要按什么顺序处理哪些输入。主 LLM 再根据两部分信息现场生成 JavaScript。

3.1 准备:配置角色,写清流程

name 是 JavaScript 中 subagentType 的可用值,description 帮助主 LLM 选角色,system_prompt 则约束该角色怎样完成任务。下面七个角色覆盖三个详细示例:

subagents = [
    {
        "name": "classifier",
        "description": "将工单归为 bug、feature、question 或 unknown",
        "system_prompt": "只负责分类;信息不足时返回 unknown,不处理工单。",
    },
    {
        "name": "bug-fixer",
        "description": "调查缺陷工单并给出复现步骤",
        "system_prompt": "核对现象与上下文,返回复现步骤和影响。",
    },
    {
        "name": "feature-analyst",
        "description": "评估功能请求的可行性和成本",
        "system_prompt": "说明用户价值、实现条件和主要取舍。",
    },
    {
        "name": "support-agent",
        "description": "根据已有材料回答使用问题",
        "system_prompt": "只根据可用材料回答;缺少依据时明确说明。",
    },
    {
        "name": "reviewer",
        "description": "审查代码并给出文件、行号和证据",
        "system_prompt": "只报告有代码证据的候选问题;稳定 ID 使用‘文件:行号:问题类型’。",
    },
    {
        "name": "verifier",
        "description": "独立复核候选问题并优先识别误报",
        "system_prompt": "重新读取代码,寻找反证后再确认或反驳。",
    },
    {
        "name": "analyzer",
        "description": "在给定范围内分轮查找死代码",
        "system_prompt": "用文件和符号组成稳定 ID,不要重复已发现项。",
    },
]

把角色表和 Interpreter 中间件一起交给主 Agent:

agent = create_deep_agent(
    model=model,
    subagents=subagents,
    middleware=[CodeInterpreterMiddleware(mode="turn")],
)

第 4 节会统一回顾中间件参数。完成这一步后,主 LLM 才能看到可用角色,并在 JavaScript 中选择它们。

接下来在用户请求中写明流程。一个可检查的流程提示通常包含四部分:

  1. workflow 明确提示主 LLM 采用工作流式编排。
  2. 给出明确的输入范围和可用角色。
  3. 说明先做什么、后做什么,以及哪些步骤可以并行处理。
  4. 写清去重、数量上限、停止条件和最终返回内容。

可以先用下面的骨架组织请求,再把方括号替换成当前任务的信息:

运行一个 workflow,处理以下范围:[明确的输入范围]。
先让 [角色 A] 完成 [第一步],再让 [角色 B] 根据第一步结果完成 [第二步]。
每个输入都要 [覆盖、去重或限制数量];遇到 [异常或停止条件] 时 [处理方式]。
最后返回 [结果字段和未处理信息]。

这个骨架描述的是业务流程,不规定底层函数和代码写法。主 LLM 可能选择并发、循环、分支或结构化返回;能否生成正确代码,仍取决于模型的代码生成和工具调用能力。

开发者先通过 subagents 和 CodeInterpreterMiddleware 准备可调度角色,用户再用 workflow 描述输入范围、角色顺序、结果要求和停止条件,两部分共同交给主 LLM 生成 JavaScript

3.2 三个示例:从流程提示到可能的 JavaScript

下面三个例子分别展示分类路由、并行复核和迭代收敛。它们不是内置模式,也不是必须照抄的固定脚本;重点是观察流程提示如何约束主 LLM 可能生成的代码。

示例一:分类后分流

工单类型混在一起时,先确定受控类别,再把每条交给对应角色:

运行一个 workflow,处理下面三条工单:
- T-101:点击登录后返回 500。
- T-102:希望订单页可以导出 CSV。
- T-103:如何重置密码?

先让 classifier 把每条归为 bug、feature、question 或 unknown,再分别交给 bug-fixer、feature-analyst 或 support-agent。
每条工单只能进入一个处理分支,不得遗漏或重复。无法确定时保留 unknown,不要猜。
最后按工单 ID 返回类别、处理结果和未处理原因。

主 LLM 可能先调用 classifier,再根据受控类别选择处理角色:

const tickets = [
  { id: "T-101", text: "点击登录后返回 500" },
  { id: "T-102", text: "订单页导出 CSV" },
  { id: "T-103", text: "如何重置密码" },
];
const categorySchema = {
  type: "object",
  properties: {
    category: { type: "string", enum: ["bug", "feature", "question", "unknown"] },
  },
  required: ["category"],
};
const handler = {
  bug: "bug-fixer",
  feature: "feature-analyst",
  question: "support-agent",
};
const results = await Promise.all(tickets.map(async (ticket) => {
  const { category } = await task({
    description: `只对工单分类:${ticket.id}\n${ticket.text}`,
    subagentType: "classifier",
    responseSchema: categorySchema,
  });
  if (!handler[category]) {
    return { id: ticket.id, category, unhandledReason: "分类为 unknown" };
  }
  const result = await task({
    description: `处理工单 ${ticket.id}:${ticket.text}`,
    subagentType: handler[category],
  });
  return { id: ticket.id, category, result };
}));
results;

检查时看三点:类别是否受 Schema 限制,unknown 是否没有被强行路由,三个工单 ID 是否都出现在结果中。

示例二:并行审查后复核

这个例子把“扇出并汇总”和“对抗复核”连成两个阶段。文件列表直接写进提示词,不需要让主 LLM 猜搜索范围:

运行一个 workflow,审查下面三个文件:
- src/routes/login.ts
- src/routes/session.ts
- src/routes/reset-password.ts

让 reviewer 分别检查每个文件的认证问题,候选结果必须包含稳定 ID、文件、行号和证据。
按稳定 ID 去重,再让 verifier 独立确认或反驳,最多复核 20 条。
最后只返回已确认问题,并说明被反驳和未复核的数量。

下面只展示两阶段调度。findingsSchemaverdictSchema 不是内置变量;实际生成的完整代码必须先定义它们,字段分别对应上面的候选结果和复核结论:

const files = [
  "src/routes/login.ts",
  "src/routes/session.ts",
  "src/routes/reset-password.ts",
];
const reviews = await Promise.all(files.map((file) => task({
  description: `审查 ${file} 的认证问题;每条候选返回稳定 ID、文件、行号和证据,稳定 ID 使用“文件:行号:问题类型”。`,
  subagentType: "reviewer",
  responseSchema: findingsSchema,
})));
const findings = reviews.flatMap((review) => review.findings);
const unique = [...new Map(findings.map((item) => [item.id, item])).values()];
const candidates = unique.slice(0, 20);
const verdicts = await Promise.all(candidates.map((finding) => task({
  description: `复核 ${finding.file}:${finding.line},确认或反驳:${finding.evidence}`,
  subagentType: "verifier",
  responseSchema: verdictSchema,
})));
const confirmed = candidates.filter((_, index) => verdicts[index].confirmed);
({
  confirmed,
  rejected: candidates.length - confirmed.length,
  unreviewed: unique.length - candidates.length,
});

主 LLM 可能使用循环或分批处理,不一定使用 Promise.all。验收时检查三个文件是否全部进入 reviewer、每条进入复核的候选是否都得到 verifier 的独立结论,以及超出 20 条的部分是否计入未复核数。

示例三:分轮搜索直到停止

范围事先不知道有多大时,提示词要同时给出业务停止条件和资源上限:

运行一个 workflow,在 src/legacy/ 范围内分轮查找死代码。
每轮都让 analyzer 继续查找,并把已发现项的稳定 ID 传给它,避免重复。
如果某一轮没有新增项就停止;无论如何最多运行 5 轮,每轮最多保留 20 项。
最后返回已发现并保留的去重结果、实际轮数和停止原因。

这里的 itemsSchema 同样不是内置变量;完整代码应用它约束 ID、文件、位置和证据:

const seen = new Set();
const found = [];
let rounds = 0;
let stopReason = "达到 5 轮上限";
for (let round = 0; round < 5; round += 1) {
  rounds = round + 1;
  const { items } = await task({
    description: `在 src/legacy/ 中继续查找死代码,每轮最多返回 20 项。已发现:${[...seen].join(", ") || "无"}`,
    subagentType: "analyzer",
    responseSchema: itemsSchema,
  });
  const fresh = items.filter((item) => !seen.has(item.id)).slice(0, 20);
  if (fresh.length === 0) {
    stopReason = "本轮没有新增项";
    break;
  }
  for (const item of fresh) { seen.add(item.id); found.push(item); }
}
({ rounds, stopReason, found });

这段代码里,“没有新结果”是业务停止条件,“最多 5 轮”是资源上限。两者都应能从生成的 JavaScript 中直接看到。

把三个例子放在一起,可以看到流程提示分别约束了路由、并行复核和迭代停止。三个示例的 JavaScript 都只是可能结果:变量名、循环和并发写法可以不同,但输入覆盖、角色名、结果结构和停止条件必须与提示词一致。如果需要在运行前看到代码,可以先要求只输出草案,或对 eval 本身设置审批。

检查主 LLM 可能生成的 JavaScript:subagentType 应来自已配置的 name,task() 字段应正确,输入覆盖和阶段顺序应符合流程,数量上限和停止条件应实际生效

4. 回到 Interpreter:同一组中间件参数继续生效

Dynamic Subagents 没有另起一套执行环境。主 LLM 生成的 JavaScript 仍由 CodeInterpreterMiddleware 提供的 eval 执行,所以第 15 章介绍的内存、超时、输出、状态、PTC 和子 Agent 开关都会继续约束本章的动态编排。

下面是一组偏保守的起点。数值要根据模型延迟、文件规模和预算调整:

middleware = CodeInterpreterMiddleware(
    mode="turn",
    subagents=True,
    memory_limit=64 * 1024 * 1024,
    timeout=30.0,
    capture_console=True,
    max_result_chars=8000,
    ptc=["glob"],
    max_ptc_calls=64,
    max_snapshot_bytes=64 * 1024 * 1024,
)
参数默认值对 Dynamic Subagents 的影响
memory_limit64 MB限制每个 Interpreter thread 的 QuickJS 堆内存;文件列表、候选结果和汇总变量都在这里占用空间
timeout5 秒限制每次 eval;动态调度、等待结果和合并逻辑都发生在这次调用里
tool_name"eval"决定主 LLM 看到的 Interpreter Tool 名称;修改后,提示词和审批配置也要使用新名称
capture_consoleTrue决定是否把 console.logwarnerror 返回给主 LLM;调试调度代码时很有用
max_result_chars4000截断返回给主 LLM 的结果、错误和控制台文本;汇总过长时会直接影响后续判断
ptcNone控制哪些工具能以 tools.* 进入 JavaScript;省略时不能从代码调用工具
max_ptc_calls256限制一次 eval 中的 tools.* 调用数,只约束 PTC,不是 task() 调度上限
subagentsTrue有子 Agent 时暴露 task();设为 False 后只能走普通 task Tool
mode"thread"决定 JavaScript 状态保留到 call、turn 还是 thread;不改变子 Agent 本身的会话语义
max_snapshot_bytesNone限制快照大小;未设置时使用 memory_limit,超限快照不会继续保留

这里最容易混淆的是 max_ptc_calls。它限制 tools.glob(...)tools.webSearch(...) 这类 PTC 调用,不限制 task() 数量。当前配置表里没有单独的 max_task_calls 参数;动态调度数量需要通过输入批次、循环上限、Prompt 约束和业务预算控制。

responseSchema 也不是中间件参数,它属于每次 task() 的调用契约。Schema 只保证返回形状可组合,不保证结论正确。汇总时仍要保留来源文件、行号、证据、复核状态和稳定 ID,方便去重与回查。

本章小结

  • Dynamic Subagents 使用 Interpreter 中的 JavaScript 编排已配置子 Agent。普通 task Tool 适合单次委派,task() 适合批量路由和多阶段组合。
  • task() 只有 descriptionsubagentType 和可选的 responseSchema 三个字段;设置 Schema 后直接读取 JavaScript 对象。
  • subagents 中的 namedescriptionsystem_prompt 分别定义角色标识、选择依据和工作边界;它们决定生成代码可以调度谁。
  • 每次 task() 都运行完整的子 Agent 循环。description 要自包含,并优先传文件路径等定位信息。
  • workflow 是帮助模型选择代码编排的提示信号,不是 API 开关。请求还要写清输入范围、角色分工、处理顺序、结果要求和停止条件。
  • 编排用的 JavaScript 由主 LLM 按请求生成,模型需要同时具备稳定的工具调用和代码生成能力。
  • CodeInterpreterMiddleware 的内存、超时、输出、PTC、状态和 subagents 配置会继续约束动态编排。
  • 面向用户的提示词写清输入范围、角色分工、返回结果和停止条件即可,不需要指定 evaltask() 等底层实现。运行前仍要检查模型生成的 JavaScript。

参考资料

《Deep Agents 实战》」· 第 16/19 章 · 内容开源自 datawhalechina/deepagents-in-action CC BY-NC-SA),版权归原作者