DSH 子代理与多智能体编排:从 Spawn 到 Workflow

DSH 如何编排多个 Agent:命名 Provider、可续子代理、工作流脚本、Ralph 循环和后台作业系统完整拆解。

先说结论: DSH 的多 Agent 不是一种模式——而是一个可组合的接缝,包含六种命名 Provider(spawn-in-processfork-in-processacpcodexclaude-codedsh-sdk)、两个模型工具(subagent 可续后台子代理、subagent_fork 一次性前台子代理)、在 worker_threads 中运行模型编写 JS 的工作流引擎、用于有界迭代任务的 Ralph 循环,以及统一的后台作业系统。Provider 独立替换;模型看到的接口不变。

Subagent 接缝:为什么多个 Provider 并存

与 bash(每个上下文一个执行器)不同,subagent 接缝通过 ctx.subagents 支持多个命名 Provider 同时共存。这是 LLM 适配器注册表模式在子代理上的应用:每个 Provider 按名注册,模型工具选择用哪个。

Provider功能适用场景
spawn-in-process同一 Node 进程中创建全新子代理默认本地委派
fork-in-process子代理继承父代理的对话前缀带上下文的委派
acpAgent Communication Protocol over JSON-RPC跨进程/跨机器
codex委派给 OpenAI Codex agent外部产品集成
claude-code委派给 Claude Code外部产品集成
dsh-sdk通过 SDK 调用另一个 DSH 实例分布式 DSH 部署

Service Definition(dsh-subagent)声明合约。每个 Provider 包独立实现它。模型工具(dsh-tool-subagentdsh-tool-subagent-controldsh-tool-subagent-report)只依赖合约,不关心背后是哪个 Provider。

// 注册是 effect-scoped 且 HMR 安全的
const dispose = ctx.subagents.registerProvider(myProvider)
// 移除 Provider 阻止新启动,不撤销正在运行的子代理

DeepSeek Harness GitHub 仓库

两个模型工具:subagent vs subagent_fork

模型通过两个工具与子代理系统交互,分别绑定不同的 Provider 和运行模式:

工具模式默认执行方式Provider
subagent可续(Continuable)后台spawn-in-process
subagent_fork一次性(One-shot)前台fork-in-process

subagent 创建的子代理在父代理当前 Turn 结束后继续存活。默认后台运行,拥有收件箱接收后续消息,通过 report 工具交付结果。

subagent_fork 是一次性的:子代理继承父代理的对话历史(一段平衡的已完成 Turn 前缀),在前台运行到完成,直接返回输出。没有收件箱、没有后续——携带上下文的”发射后不管”。

SubagentCapabilities:Provider 公示的能力

在一次性启动前,服务会检查 Provider 能力与请求的匹配。如果 Provider 做不到请求要求的能力,直接报错——绝不”接受后忽略”:

interface SubagentCapabilities {
  readonly outputSchema: boolean;   // 能否强制结构化输出
  readonly depthLimit: boolean;     // 能否限制递归深度
  readonly toolFilter: boolean;     // 能否限制子代理可用工具
  readonly persona: boolean;        // 能否应用不同 persona
}

这些标志只描述一次性 start() 路径。可续子代理通过 SubagentProvider.prepareContinuable 方法守门——方法存在本身就是能力声明,用 TypeScript 类型收窄作为发现机制。

可续子代理:Activation 生命周期

可续子代理是 DSH 长时间运行多 Agent 能力的核心。它们不只是”发射后不管”——会持久化,接受后续消息,还能从存储中冷恢复。

生命周期状态

可续子代理经历 Activation 生命周期:

Created → Running → Idle → (Cold Storage) → Resumed → Running → ...

关键特性:

  • FIFO 收件箱:消息按序排列;每条被接受的消息成为一个 Turn
  • 冷恢复:当前未加载的子代理可从持久化 Session 恢复
  • 不丢消息:Agent inbox 是唯一队列,每条消息有一个可观测的顺序
// 启动一个可续子代理
const { childId, messageId } = await ctx.subagents.startContinuable({
  provider: 'spawn-in-process',
  request: { description: 'Research agent', prompt: 'Find papers on...' },
  parent: currentAgent,
  signal: abortController.signal,
})

// 之后发送后续消息(不同的 Turn,甚至不同的 session resume)
await ctx.subagents.followup(
  currentAgent,
  childId,
  [{ type: 'text', text: 'Also check arxiv for...' }],
  { signal }
)

中断但不销毁

可以中断子代理的当前 Turn 而不销毁它:

// Fire-and-return:取消信号立即发出
// 但目标可能短暂继续运行直到观察到信号
await ctx.subagents.interrupt(targetSessionId, { signal })
// 未被认领的 inbox 工作保留;唤醒发送恢复 FIFO 队列

DSH Packages 目录结构

Report 工具:子到父通信

后台子代理如何告诉父代理有结果了?通过 report 工具——一个有特殊属性的专用通信通道:

属性行为
Per-child scoped按可续子代理注册,非全局
穿透 toolFilter即使父代理过滤了子代理的工具,report 仍然保留
投递模式wakeup(创建父代理 Turn)或 quiet(添加上下文不唤醒)
// Report 投递行为配置
interface Config {
  reportDelivery?: 'wakeup' | 'quiet'
}
  • wakeup(默认):report 创建一个普通的父代理 Turn——父代理立即被通知。
  • quiet:向父代理添加上下文但不唤醒它。父代理只在其他事情触发 Turn 时才看到 report。

这就是后台 Agent 报告完成而不持续打断前台工作的方式。

控制工具:全局 Agent 管理

三个工具提供运行时对 Agent 群的控制:

send_message

向后台子代理发送后续消息,继续其对话。如果子代理正在工作,消息会等到当前 Turn 结束——无法重定向已在进行的工作。

interrupt_agent

请求取消子代理的当前 Turn。发射即返回:停止信号在返回前发出,但目标可能短暂继续运行。Agent 本身保持可用,可以接受后续消息。

list_agents

列出可续后台子代理及其状态:

状态含义
running正在工作
idle已加载但在 Turn 之间(可能在等自己启动的子代理)
ready仅存于存储中,可恢复
{
  "scope": "children"  // 或 "descendants" 遍历完整树
}

descendants 以稳定的前序遍历完整树,每个条目标注父 session ID 和深度。只能对 depth-1 条目用 send_message;更深的只能用 interrupt_agent

工作流引擎:模型编写的编排脚本

对于超出简单委派的复杂多 Agent 协调,DSH 提供一个工作流引擎,在 worker_threads VM 中运行模型编写的 JavaScript 脚本:

interface WorkflowStartRequest {
  script: string;           // 纯 JS 体(允许顶层 await)
  meta: WorkflowMeta;      // 身份块(name、description、phases)
  args?: unknown;           // 作为 `args` 全局变量暴露给脚本
  subagentProvider?: string; // 覆盖本次运行的子代理 Provider
  maxTotalAgents?: number;  // 本次运行的子代理上限
  parent: Agent;            // 脚本启动的每个子代理归属此 Agent
  signal?: AbortSignal;     // 取消运行
}

脚本内部用 agent() 启动子代理:

// 一个工作流脚本(模型生成,执行前验证)
const researcher = await agent({
  description: 'Research papers on topic X',
  prompt: 'Find the top 5 papers...',
})

const writer = await agent({
  description: 'Write summary from research',
  prompt: `Summarize these findings: ${researcher.result}`,
})

return { summary: writer.result }

关键约束:

  • 每个运行一个 worker:通过 worker_threads 隔离
  • meta 执行前验证:引擎绝不通过执行脚本文本来提取元数据
  • parent 必填:每个子代理都归属到一个存活的 Agent
  • 结果是纯 JSON:脚本返回值作为宿主域数据物化

WorkflowResult:脚本如何结算

interface WorkflowResult {
  value: unknown;               // 脚本返回值(undefined 时为 null)
  stopReason: 'completed' | 'cancelled' | 'error';
  error?: string;               // 仅 stopReason 非 completed 时存在
  agentsSpawned: number;        // 总共接受的 agent() 调用数
}

completed 结果映射为 isError 工具返回——部分输出绝不当成功上报。

Cordis 论文 GitHub

Ralph 循环:有界迭代执行

Ralph 循环是由 workflow 和 subagent 原语构建的特定编排模式。它不是通用的 agent-loop 模式——而是一个固定的前台工作流,用于有界迭代任务:

属性Ralph 循环行为
结构固定前台工作流
子代理每轮一个全新子代理(无对话延续)
状态传递轮间有界结构化交接
不是同会话目标、调度器或通用工作流功能

Ralph Round 与 Handoff

每个 Ralph round 是一个全新的子代理会话。子代理不接收父对话或前一轮子代理的对话种子。跨轮状态通过两个通道流动:

  1. 共享工作区:所有轮次可见的文件系统状态
  2. Ralph handoff:有界结构化报告,包含状态、摘要、证据、下一步和阻塞文本
// Handoff 补充工作区——不替代工作区作为权威来源
interface RalphHandoff {
  status: string;
  summary: string;
  evidence: string;
  nextSteps: string;
  blockerText?: string;
}

循环持续启动新子代理直到目标达成或达到配置上限。每个子代理只看到自己的 handoff 和工作区——不积累对话历史。

后台作业:统一的异步工作

ctx.jobs 把所有后台工作统一到一个接口:

作业类型来源
后台 bash 命令Shell 执行
PTY 终端发送终端会话
后台子代理Subagent 委派

三个模型工具管理它们:

job_list   — 列举运行中/已完成的后台工作
job_output — 读取特定作业的输出
job_kill   — 取消运行中的作业

模型不需要知道后台作业是 bash 命令、终端会话还是子代理——都通过同一个 job_* 接口呈现。这是 Capability Seams 模式 的实践:一个消费者,多种 Provider。

Provider 如何组合

出厂组合(packages/bundle/base/cordis.patch.yml)把 dsh-tool-subagent 加载了两次——每个后端一次:

  • 一个实例:toolName: 'subagent'backgroundMode: 'continuable'、绑定 spawn-in-process
  • 一个实例:toolName: 'subagent_fork'backgroundMode: 'one-shot'、绑定 fork-in-process

每个实例有自己的描述和 run_in_background 行为。控制工具(send_messageinterrupt_agentlist_agents)全局注册一次。Report 工具按子代理注册,作用域限于该子代理的上下文。

这只是插件配置——换掉 Provider 名称就能把委派重定向到外部产品,模型工具不变。

实战:研究 + 综合管线

一个用可续子代理的实际多 Agent 模式:

export function apply(ctx: Context) {
  ctx.on('agent/pre-step', async (payload, next) => {
    // 自定义编排逻辑
    return next()
  })
}

// 模型有机地驱动这个流程:
// 1. 模型调用 subagent "研究主题 A" → 后台子代理 A
// 2. 模型调用 subagent "研究主题 B" → 后台子代理 B
// 3. 两个子代理并行工作,通过 report 工具回报
// 4. 父代理被唤醒,接收两份报告,进行综合
// 5. 模型调用 subagent_fork 以完整上下文起草最终输出

不需要编排框架——模型自己决定何时委派、等待还是综合。工具和生命周期管理机制。

架构关联

常见问题

Q:子代理能启动自己的子代理吗? A:能。深度通过谱系追踪,depthLimitSubagentCapabilities 中)可以封顶递归。每个子代理运行完整的 agent loop——拥有同样的工具访问(减去 toolFilter 限制),可以自己调用 subagentlist_agents 工具用 scope: 'descendants' 能看到完整树。

Q:可续子代理的存储丢了会怎样? A:变得不可恢复。list_agents 不会显示它(只呈现有 session 支撑的条目)。恢复路径是启动一个新的子代理做同样的任务——没有自动重试。持久化是可选的;没有持久化时,枚举仅限存活的。

Q:工作流引擎怎么防止脚本失控? A:三个机制:maxTotalAgents 封顶 agent() 调用数;signal(AbortSignal)取消运行;引擎在执行前验证 meta,绝不通过执行脚本文本提取元数据。每次运行一个 worker 的隔离意味着崩溃的脚本不影响其他运行。

Q:一个 workflow 运行里能混用 Provider 吗? A:一个 workflow 运行可以全局覆盖 subagentProvider,但脚本内单个 agent() 调用不能选 Provider——全部用同一个。想混用 Provider,直接从父代理用不同的 subagent 工具调用,而不是写在 workflow 脚本里。

Q:Ralph 循环和 workflow 脚本有什么区别? A:workflow 脚本是通用的:模型写任意 JS 调用 agent()。Ralph 循环是固定策略——一个前台工作流,每轮启动全新子代理,轮间有界交接。它由 workflow 和 subagent 原语构建而成,但结构特定:轮间无对话延续,跨轮状态仅通过工作区 + handoff。通用编排用 workflow;有界迭代任务用 Ralph。