DeepSeek Harness 的 workflow 包:它跟 Agent 自己规划有什么不一样
先说清楚一件事:本文写的全部是 deepseek-harness 仓库里的源码与文档口径。该仓库 README 自述处于开发者预览阶段,并明写「未来将出现破坏兼容性的变更」,所以下面出现的每一个字段名、默认值、错误文案,都可能在下个版本被改掉。我们没有安装、也没有运行过这个项目,本文不涉及任何界面、速度和效果的描述。
一个具体的场景
假设你要让 agent 把一个仓库里两百个文件挨个审一遍,每个文件出一份结构化结论,最后汇总。
在只有 subagent 能力的世界里,做法是模型自己规划:它在 agent loop 里一轮一轮地调委派工具,起一个子 agent、拿回文本、决定下一步再起一个。两百次意味着两百轮往返,每一轮的调度决策都要消耗父级上下文,而且「起多少个、并发多少」这件事完全由模型当时的判断决定。
workflow 工具走的是另一条路。它在工具目录里的描述原文写得很直白:用于「fans out across many independent pieces」的活,「where you write the orchestration as a script instead of delegating turn by turn」(见 docs/tool-catalog.md 中 @deepseek-ai/dsh-tool-workflow 一节)。模型不再一轮一轮下命令,而是一次性交出一段 JavaScript 编排脚本,调度逻辑写在脚本里,harness 负责执行它。
这条 seam 被划在 agent loop 外面
docs/subsystems/workflow.md 开篇就把定位钉死了:工作流 seam 是一项可选能力,不属于 agent loop,所以它的类型和操作记录在这一页而不是 core.md 里。文档同时写明,每个上下文只允许一个引擎实现提供 ctx.workflowEngine,没有命名提供方注册表——第二个引擎是通过插件配置替换第一个,而不是与它并存。
这跟隔壁的 subagent seam 正好相反。docs/subsystems/subagent.md 写明同一上下文中可共存多个提供方实现,并按名称注册在 ctx.subagents 上。也就是说,“用哪个后端起子 agent” 可以有多个选项并存,而”用哪个引擎执行编排脚本”在同一时刻只有一个。这两句话摆在一起看,就能明白为什么脚本里没有选择引擎的钩子。
packages/workflow 下有四个包(不是四个目录层级里的随便什么东西,是四个各带 package.json 的包):seam 定义在 packages/workflow/workflow,当前引擎实现是 packages/workflow/workflow-worker-thread,面向模型的消费方是 packages/workflow/tool-workflow,还有一个 packages/workflow/tool-ralph —— 它的 README 自述是「展示如何把专用编排策略实现为普通插件」的例子,不在本文展开。
从一次调用走到脚本开跑
模型看到的 workflow 工具只有三个参数:meta、script、args。这里第一个反直觉的设计是 meta 的位置。
meta 是身份数据块(必填 name、description,可选 whenToUse 和 phases),它作为普通 JSON 数据跟着请求走,而不是写在脚本正文里。引擎会先用 schema 校验它,再看脚本能不能解析。packages/workflow/workflow-worker-thread/src/index.ts 里为此专门留了一条正则:
const META_STATEMENT = /^\s*export\s+const\s+meta\b/
正文一旦以 export const meta 开头,就会被换成一条指名道姓的报错——源码注释里把这称作「模型最可能犯的写法失误」。这条特判是给模型看的,不是给人看的,但它顺手告诉你一件事:这套 meta 字段词汇在源码注释里被标注为与 Claude Code 动态工作流的 meta 块一致。
start() 的顺序在 index.ts 的 start 方法里是连续四步,全部同步完成:validateMeta 校验 meta → assertBodyParses 用与 worker 内完全相同的包装体((async () => {\n...\n})())预解析一遍脚本 → 解析 subagent 提供方路由 → 解析这次运行的子 agent 总数上限。四步任何一步失败都是同步抛 WorkflowError,运行根本不会被创建。这一点值得记住:脚本写错了不会变成”跑了一半失败”,而是压根没起来,按 seam 文档的口径,消费方会把这类失败映射成 isError 的工具结果,而不是把半截产出当成成功报上去。
四步都过了才创建 worker、才发 workflow/start 事件。
脚本里只有五个钩子,且没有定时器
worker 里注入给脚本的全局只有 args 和五个钩子(packages/workflow/workflow-worker-thread/src/runtime.ts 的构造函数里逐个挂上去):
| 钩子 | 语义要点 |
|---|---|
agent(prompt, opts?) | 起一个宿主侧 subagent;带 schema 返回结构化值,不带则返回最终文本 |
parallel(thunks) | 并发跑一组零参函数,等全部结束(是个屏障) |
pipeline(items, ...stages) | 每个条目独立走完各阶段,阶段之间没有屏障 |
phase(title) | 设置后续 agent() 的进度分组,并发出观察事件 |
log(message) | 只发观察事件的叙述行 |
工具描述里明写:不提供文件系统、网络、定时器和 Node.js API ——「the agents do the work, the script only coordinates them」。脚本只是调度器,干活的还是子 agent。
失败语义是这里最容易踩的地方,也是它跟”模型自己规划”差别最大的地方。子 agent 正常失败时,agent() 返回 null,脚本得自己 .filter(Boolean)(工具描述原文就是这么写的);而钩子用错了——参数不对、选项不认识、schema 超出支持子集、撞上限——抛的是 fatal: true 的 WorkflowError,它会直接穿透 parallel() 和 pipeline() 杀掉整个脚本。runtime.ts 里的判定用的是跨 realm 的 isFatalWorkflowError,注释写明脚本自己造的对象无法伪造这个身份。
换句话说:模型自己一轮轮委派时,某个子任务失败了它下一轮还能自己找补;写成脚本以后,失败要么静悄悄变成一个 null 等你处理,要么直接把整段编排掀了,中间没有”模型临时改主意”这一档。
那些只是默认值的数值
packages/workflow/workflow-worker-thread/README.md 的配置表列了六个键。挑几个跟本文直接相关的说:
maxTotalAgents默认1000。撞上去的报错原文把它叫做 runaway-loop backstop(失控循环兜底)。maxItemsPerCall默认4096,限制单次parallel()或pipeline()接受的条目数。syncTimeoutMs默认5000,是脚本最初那段同步片段的 vm 超时,不是整个运行的超时。disposeGraceMs默认5000,是取消之后强制结算并终止 worker 的期限,也是公开dispose()的期限。maxConcurrentAgents默认0,README 只说0会「根据可用 CPU 并行度解析」。具体怎么解析写在index.ts里:
maxConcurrentAgents: this.config.maxConcurrentAgents === 0
? Math.min(16, Math.max(1, availableParallelism() - 2))
: this.config.maxConcurrentAgents,
这几个都是配置默认值,不是对运行表现的承诺——具体一次运行会起多少并发、多久结算,取决于部署配置和运行环境,我们没有运行过这个项目,也不由这几个数字推算任何性能或成本。
还有一个不在配置表里但会影响你读日志的细节:脚本没传 label 时,runtime.ts 的 defaultLabel 会取提示词第一行,长度超过 48 就截到 47 个字符再补一个省略号。观察端上看到的标签是这么来的。
取消之后谁负责收尸
这是 workflow 与”让模型自己规划”在工程上差别最实在的一段。模型自己委派时,中断就是中断在某一轮;而一段脚本可能正卡在一个谁都不拥有的 promise 上。
按 README.md 与 seam 文档的口径:cancel() 会记下第一个原因、通知 worker、中止所有待处理与已发布子 agent 共用的那一个信号,并启动 disposeGraceMs 定时器。worker 侧的钩子会在下一个钩子边界抛 CANCELLED —— runtime.ts 的注释特意强调这不只是下一次 agent(),phase/log 同样会抛,所以一个吞掉了取消异常的脚本没法继续往外发进度。
如果到期还没结算,宿主会把运行以 cancelled 结算、给悬空的子 agent 生命周期事件补上配对的结束事件,然后终止 worker。因此 seam 契约里那句「result 绝不拒绝」才站得住:等 result 的消费方不会在取消后无限期挂着。
代价写在结果字段上。WorkflowResult.agentsStarted 的注释说明得很清楚:优雅结算时它是脚本侧计数(含还在排队等并发槽位的调用),走终止路径时它退化为宿主观测到的计数——被终止的脚本里还排着队的调用,那时已经无从得知。README 的已知限制里也把这条单列了。
一处口径不一致
packages/workflow/workflow-worker-thread/README.md 的「Script contract」一节把 agent() 的选项写成 { label, phase, schema, model },四个。
而 runtime.ts 里的白名单是五个:
const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'provider', 'model'])
docs/tool-catalog.md 中该工具的描述也写了「independent provider/model LLM target overrides」,同样是五个。两处不一致,以我们实读的源码为准。至于为什么不一致,我们不猜。
顺带说一句同名不同物:脚本的 provider 选项进的是子 agent 的 agentOptions.provider,那是 LLM 提供方路由(packages/core/agent/src/runtime-types.ts 里 AgentOptions.provider 的注释是 “Provider route”);而 WorkflowStartRequest.subagentProvider 定的是这次运行走哪个 subagent 提供方,seam 文档写明它对脚本不可见、普通 workflow 工具不会设置它。名字撞车,层级不同。
什么时候别用它
这套东西的官方使用策略不在文档里,而是随插件发的一段系统提示词,packages/workflow/tool-workflow/README.md 里贴了原文:只有用户明确要求工作流或大型多 agent 编排时才用这个工具;一两项委派优先用普通 subagent 调用。
配合几条已知限制一起读会更清楚:父级轮次会阻塞到整个工作流结算,没有后台启动/轮询接口;脚本、子 agent 进度和中间值都不做检查点,进程重启后无法续跑;这条 seam 只启动调用方给的脚本,脚本拿不到 workflow() 钩子,也就没有嵌套编排;引擎限制并发、条目和子 agent 数量,但请求与结果里都不统计跨子 agent 的模型 token。
最后一条必须单独说:packages/workflow/workflow-worker-thread/README.md 自己写明,worker 内的 node:vm 是塑造 API 的机制,不是安全边界,逃逸的脚本可以用宿主进程权限重新取得 Node 能力;worker 的作用是不让同步脚本阻塞宿主事件循环,以及让 terminate() 成为真正的最终手段。文档明说「真正的不可信脚本沙箱需要在同一工作流 seam 背后采用不同引擎」。所以不要把「跑在 worker 里」当成隔离保证——这段脚本是模型写的,信任前提与模型已有的 bash 访问相同,这也是它自己写的。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。