第15章 工作流与子代理:从单 Agent 到协同执行
理解 workflow、subagent 与后台协作能力如何从领域端口生长出来
15.1 本章导读
前面的章节已经解释了单个 ReactLoopAgent 如何接收消息、调用工具并通过事件流返回结果。本章关注更高一层的协作能力:工作流让用户用脚本描述一个可取消的执行过程,子代理让模型把复杂任务委托给一个新的 Agent 会话。它们都不是另起炉灶,而是继续沿用领域端口、运行句柄、工具定义和会话日志。
本章会把源码中的 workflow 与 agent/subagent 两组包串起来:先看 HTTP 如何启动工作流,再看领域服务如何管理运行句柄,最后看 SubagentTool 如何把“请另一个 Agent 做子任务”包装成模型可调用的工具。
15.2 工作流的三层结构
工作流代码分成三层:WorkflowCommandController 负责 HTTP/SSE;WorkflowService 管理运行中的 WorkflowRun;IWorkflowEnginePort 把真正执行脚本的能力交给基础设施适配器。当前实现使用 LocalWorkflowEnginePort,通过系统 node -e 执行传入脚本,并以 CompletableFuture<WorkflowResult> 收敛结果。
| 层 | 关键类 | 职责 |
|---|---|---|
| Trigger | WorkflowCommandController | /api/workflow/start、/{runId}/cancel、/stream 三个入口 |
| Domain | WorkflowService | 启动后登记 liveRuns,运行结束自动清理,取消时调用 WorkflowRun.cancel() |
| Domain Model | WorkflowStartRequest、WorkflowRun、WorkflowResult | 描述脚本、元数据、父会话、工作目录、结果和停止原因 |
| Infrastructure | LocalWorkflowEnginePort | 本地执行 Node 脚本,捕获输出、超时、退出码和异常 |
15.3 WorkflowRun 的生命周期
WorkflowRun 是工作流的运行句柄,核心字段只有四个:id、meta、result 和 cancelled。启动后,调用方不用阻塞等待结果,而是持有一个 Future;取消时如果结果尚未完成,就补一个 WorkflowResult.cancelled(0),这样等待方可以统一通过 Future 收敛。
| 状态 | 来源 | 语义 |
|---|---|---|
COMPLETED | Node 进程退出码为 0 | 脚本输出被作为 value 返回 |
ERROR | 异常、非零退出码或执行失败 | error 字段携带失败信息 |
CANCELLED | 调用 cancel(runId, reason) | 主动完成 Future,通知调用方停止等待 |
实现边界:当前本地引擎直接拼接 node -e 运行脚本,超时固定 60 秒,注释中也说明完整实现应提供 agent()、log()、phase() 等绑定。生产环境需要把脚本来源、沙箱、超时、资源限制和审计补齐。
15.4 HTTP 契约:启动、取消与等待终态
WorkflowCommandController 暴露三个入口:POST /api/workflow/start 用于异步启动,POST /api/workflow/{runId}/cancel 用于取消,POST /api/workflow/stream 用于启动后通过 SSE 等待结果。它没有复用普通对话的 /api/agent 流,而是把长任务作为独立资源管理,这样前端可以把“聊天消息”和“后台运行”分开渲染、取消和追踪。
curl -X POST http://localhost:8090/api/workflow/start \
-H 'Content-Type: application/json' \
-d '{
"name": "repo-summary",
"parentCwd": "/path/to/deepseek-harness-java",
"script": "return { ok: true, cwd: process.cwd() }",
"args": { "scope": "domain" }
}'
/stream 的实现同样会先调用 workflowService.start(),但它不会持续推送中间日志;当前只在 CompletableFuture 完成后发送 done,异常时发送 error。因此它更像“等待终态的 SSE 包装”,还不是第7章那种细颗粒度过程流。
15.5 本地执行引擎:用 Node 验证编排抽象
LocalWorkflowEnginePort 选择系统 node 作为执行器,是为了快速验证“工作流脚本”这个抽象:脚本可以接收参数、返回 JSON、在指定 parentCwd 下执行。领域层只依赖 IWorkflowEnginePort,因此未来换成 Docker 沙箱、远程 Worker、Temporal、Quartz 或 Kubernetes Job 时,不需要改 Controller 与领域服务的运行句柄语义。
| 维度 | 当前实现 | 生产化方向 |
|---|---|---|
| 执行环境 | 本机 node -e | 容器/远程沙箱/Worker 池 |
| 运行记录 | ConcurrentHashMap liveRuns | 数据库运行表 + 状态机 + 分布式锁 |
| 输出事件 | 终态 done/error | started/phase/log/subagent/done 过程事件 |
| 资源限制 | 60 秒等待 + 强杀 | CPU、内存、网络、文件系统与超时配额 |
| 安全审计 | 依赖外部调用方约束 | 记录脚本摘要、参数摘要、工作目录、执行人和输出摘要 |
15.6 子代理端口:SubagentProvider
子代理不是线程池任务,而是一类领域端口。SubagentProvider 抽象“如何启动一个子 Agent”,输入是 SubagentStartRequest,输出是 CompletableFuture<SubagentResult>。请求里包含 prompt、选项、父会话、父工作目录、委托深度、启用工具和允许工具名,给后续治理预留了足够的参数位。
| 类型 | 代码 | 作用 |
|---|---|---|
| 注册表 | SubagentRegistry | 按 providerName 注册和解析 provider,并生成 runId |
| Fresh 子会话 | SpawnInProcessProvider | 创建独立 child session,不继承父上下文 |
| Fork 子会话 | ForkInProcessProvider | 提供 completedTurnPrefix(),理论上继承父会话已完成回合前缀 |
| 模型工具 | SubagentTool | 把委托能力暴露为名为 subagent 的工具 |
15.7 SubagentTool 如何被模型调用
SubagentTool 的参数 schema 只有两个主要字段:必填的 prompt 和可选的 run_in_background。当前执行逻辑会校验参数、从 SubagentRegistry 解析 provider、构造 SubagentStartRequest,然后等待 provider 返回结果,并把子代理最后一条 assistant 文本转换为工具结果。
{
"prompt": "请独立阅读 workflow 包并总结边界",
"run_in_background": false
}
需要注意:run_in_background 已出现在 schema 中,但当前 SubagentTool.execute() 没有根据它改变等待策略;两个 in-process provider 都通过 CompletableFuture.supplyAsync() 创建子 Agent 并等待 childAgent.whenIdle().join()。因此,本章把它视为预留契约,不是已经完成的后台子代理能力。
15.8 Spawn 与 Fork 的区别
SpawnInProcessProvider 会创建一个全新的 child session,适合隔离明确、只需要 prompt 的子任务。ForkInProcessProvider 从语义上用于继承父上下文,它提供 completedTurnPrefix(parentSession),只截取父会话到最后一个 turn/end 为止,避免把进行中的不完整回合带给子 Agent。不过当前启动子 Agent 的主体流程仍与 Spawn 非常接近,继承事件前缀还没有完整注入。
| 模式 | 适合场景 | 当前状态 |
|---|---|---|
| Spawn | 独立研究、局部代码审计、可复用小任务 | 可创建新 child session 并收集最后输出 |
| Fork | 需要父会话上下文的长任务分支 | 已有前缀截取方法,但继承注入仍需完善 |
| Background | 耗时子任务、并行分工 | 契约预留,执行器尚未实现非阻塞回收 |
15.9 进程外子代理:stdin/stdout 协议边界
基础设施层的 OutOfProcessSubagentProvider 是所有进程外子代理的基类。它用 ProcessBuilder 启动外部命令,把编码后的 prompt 写入 stdin,再读取 stdout/stderr。CodexSubagentProvider、ClaudeCodeSubagentProvider、AcpSubagentProvider 都可以沿这个模板接入不同外部代理。
SubagentTool.execute(args)
→ SubagentRegistry 解析 provider
→ provider.start(SubagentStartRequest)
→ ProcessBuilder(command + args)
→ encodePrompt(request) 写 stdin
→ read stdout/stderr
→ decodeOutput(output)
→ SubagentResult(completed/error/aborted)
这种协议边界的好处是 Harness 不需要绑定外部代理 SDK;坏处是可用性依赖本机命令、参数和输出格式。生产环境应在配置页或启动检查阶段标注 provider 是否可用,而不是等模型调用时才失败。
15.10 协同执行的治理点
一旦系统从单 Agent 变成多 Agent 协作,风险不只是“多跑几个线程”。真正的治理点包括:委托深度上限、防止子代理无限递归;工具白名单传递,防止子代理越权;父子会话关联,保证审计可追溯;取消传播,父任务取消时子任务也要停止;资源预算,避免多个 child agent 同时消耗模型与 Shell 资源。
| 治理点 | 源码已有基础 | 建议补强 |
|---|---|---|
| 委托深度 | delegationDepth 字段 | 在 provider 和工具执行器中强制校验 |
| 工具权限 | enabledTools、allowedToolNames | 从父上下文传入,并与审批策略合并 |
| 父子关系 | parentSessionId、parentCwd | 持久化到 session header 和事件日志 |
| 取消传播 | WorkflowRun.cancel()、ReactLoopAgent.cancel() | 建立父 run 到 child run 的引用表 |
| 资源预算 | maxTotalAgents 预留字段 | 接入全局并发限制与模型 token 预算 |
15.11 小结与下一章预告
本章要点:工作流通过 WorkflowRun 和 Future 管理可取消执行;当前本地引擎能跑 Node 脚本但还不是完整安全沙箱;子代理通过 SubagentProvider 与 SubagentTool 暴露给模型;Spawn、Fork、Background 的契约已经出现,但治理、继承和后台化还需要继续补强。
下一章:我们继续看长期任务能力——目标、计划、技能与定时任务。它们不直接改变 ReAct 循环,却决定 Agent 如何在长程上下文里保持目标、保存计划、复用技能并等待未来时刻触发。