🧰 工程全景

第15章 工作流与子代理:从单 Agent 到协同执行

理解 workflow、subagent 与后台协作能力如何从领域端口生长出来

15.1 本章导读

前面的章节已经解释了单个 ReactLoopAgent 如何接收消息、调用工具并通过事件流返回结果。本章关注更高一层的协作能力:工作流让用户用脚本描述一个可取消的执行过程,子代理让模型把复杂任务委托给一个新的 Agent 会话。它们都不是另起炉灶,而是继续沿用领域端口、运行句柄、工具定义和会话日志。

本章会把源码中的 workflowagent/subagent 两组包串起来:先看 HTTP 如何启动工作流,再看领域服务如何管理运行句柄,最后看 SubagentTool 如何把“请另一个 Agent 做子任务”包装成模型可调用的工具。

15.2 工作流的三层结构

工作流代码分成三层:WorkflowCommandController 负责 HTTP/SSE;WorkflowService 管理运行中的 WorkflowRunIWorkflowEnginePort 把真正执行脚本的能力交给基础设施适配器。当前实现使用 LocalWorkflowEnginePort,通过系统 node -e 执行传入脚本,并以 CompletableFuture<WorkflowResult> 收敛结果。

关键类职责
TriggerWorkflowCommandController/api/workflow/start/{runId}/cancel/stream 三个入口
DomainWorkflowService启动后登记 liveRuns,运行结束自动清理,取消时调用 WorkflowRun.cancel()
Domain ModelWorkflowStartRequestWorkflowRunWorkflowResult描述脚本、元数据、父会话、工作目录、结果和停止原因
InfrastructureLocalWorkflowEnginePort本地执行 Node 脚本,捕获输出、超时、退出码和异常

15.3 WorkflowRun 的生命周期

WorkflowRun 是工作流的运行句柄,核心字段只有四个:idmetaresultcancelled。启动后,调用方不用阻塞等待结果,而是持有一个 Future;取消时如果结果尚未完成,就补一个 WorkflowResult.cancelled(0),这样等待方可以统一通过 Future 收敛。

状态来源语义
COMPLETEDNode 进程退出码为 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/errorstarted/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。CodexSubagentProviderClaudeCodeSubagentProviderAcpSubagentProvider 都可以沿这个模板接入不同外部代理。

进程外子代理通用流程
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 和工具执行器中强制校验
工具权限enabledToolsallowedToolNames从父上下文传入,并与审批策略合并
父子关系parentSessionIdparentCwd持久化到 session header 和事件日志
取消传播WorkflowRun.cancel()ReactLoopAgent.cancel()建立父 run 到 child run 的引用表
资源预算maxTotalAgents 预留字段接入全局并发限制与模型 token 预算

15.11 小结与下一章预告

本章要点:工作流通过 WorkflowRun 和 Future 管理可取消执行;当前本地引擎能跑 Node 脚本但还不是完整安全沙箱;子代理通过 SubagentProviderSubagentTool 暴露给模型;Spawn、Fork、Background 的契约已经出现,但治理、继承和后台化还需要继续补强。

下一章:我们继续看长期任务能力——目标、计划、技能与定时任务。它们不直接改变 ReAct 循环,却决定 Agent 如何在长程上下文里保持目标、保存计划、复用技能并等待未来时刻触发。