ARTICLE · 1177134
DeepSeek-Harness 源码深读(22):模型写的脚本会死循环,宿主凭什么不陪葬
摘要:workflow 引擎把模型写的编排脚本放进每 run 一个的 worker 线程加 escapable vm:五个钩子注入冻结全局,错误二分法让 fatal 杀脚本、子失败溶 null,取消被建模成下一个钩子边界并五处复查,grace 到点 terminate 线程,返回值经 realm 物化才许离开 vm。模块头对线程的定性自己写死了:containment。
上一篇《子代理后端只要两个方法,其余全归框架》拆的是委派:模型一轮发一个 subagent 调用,等结果,再发下一个。委派到第三个、第十个还凑合,要是任务本身是「审计 234 个包的依赖」?逐轮委派要 234 轮上下文往返,模型自己的对话先被撑爆。
dsh 的答案是 workflow 工具:模型不再逐轮委派,写一段 JavaScript 编排脚本,脚本里用 agent() 连发子代理。可这段脚本是模型现场写的代码。现场写的代码会写错,会死循环,会在取消之后还想继续跑。问题就落在这句上:模型写的脚本会死循环,宿主凭什么不陪葬?
我们先上主线案例。模型写的脚本长这样(示意,非 dsh 源码):
phase('scan')const reports = await pipeline(args.modules, (m) => agent(`扫描 ${m} 里的过期 API`, { schema: REPORT }), (r, m) => r === null ? null : agent(`汇总 ${m}:${r.findings.length} 处`))log(`完成 ${reports.filter(Boolean).length}/${args.modules.length} 个模块`)return { reports: reports.filter(Boolean) }八个模块从 pipeline 连发,两段 stage,第一段带 schema 拿结构化结果。下面每一节,这段脚本都会再往前拧一格。
一、这段脚本在哪跑:一个 run 一个线程
最省事的做法是在宿主进程里 eval 这段脚本。问题是 while (true):脚本同步死循环,宿主的事件循环跟着卡死,每一步工具调用、每个插件、整个进程全部陪葬。所以引擎叫 workflow-worker-thread,每次 start() 起一个全新 worker 线程,脚本在另一个线程的 escapable vm 上下文里跑(workflow-worker-thread/src/index.ts:2-5 的模块头)。线程买到两个硬能力:同步脚本不阻塞宿主;永不结算的脚本可以强制终止。同一行注释也把定性说死了:线程提供的是隔离(containment),不是安全边界(security boundary)。
整个 run 的剖面一张图:

宿主进程与 worker 线程各管一半
注意看中缝那两行协议箭头:宿主与线程的全部往来就这十来个消息,子代理没有任何专线;底部唯一出口是 realm 物化,return 值过了那道拒绝清单才许离开线程。图上每个方括里的行号,后面五节逐个走到。
start() 头两行就把模型最常犯的错拦在宿主侧(index.ts:143-145):validateMeta 验参数块,assertBodyParses 验脚本体。后者的实现(index.ts:64-73):
function assertBodyParses(body: string, name: string): void { if (META_STATEMENT.test(body)) { throw new WorkflowError('workflow meta rides the `meta` request field, not the script: remove the `export const meta = {...}` statement from the body', 'SCRIPT_PARSE') } try { // Parse only — the script object is discarded, nothing executes. void new vm.Script(`(async () => {\n${body}\n})()`, { filename: `workflow:${name}`, lineOffset: -1 }) } catch (error: unknown) { throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error }) }这段是宿主侧的语法预检。注意看两处。第一处,worker 里自己会编译,这里再解析一遍是刻意冗余:worker 的编译发生在另一个线程、start() 返回之后,预检买回来的契约是语法错误同步抛出,模型当场拿到带行号的报错,而不是等一个异步崩溃。第二处,body 以 export const meta 开头会被点名批评,报错直接教模型怎么改(index.ts:61-62 的注释称它 the model's likeliest authoring slip,从 Claude Code 风格迁移过来的惯性笔误)。lineOffset: -1 把包装函数占的那一行从堆栈里减掉,报错行号对得上模型自己写的行数。
二、五个钩子从哪来:注入,再冻结
脚本里能用 agent、parallel、pipeline、phase、log,外加 args。没有 fetch,没有 fs,没有定时器。工具描述写得直白:the agents do the work, the script only coordinates them(tool-workflow/src/index.ts:150)。这些全局在构造执行器时注入 vm(runtime.ts:100-108):
const globals: Record<string, unknown> = { agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)), parallel: (thunks: unknown) => this.contain(this.parallel(thunks)), pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)), phase: (title: unknown) => { this.phase(title) }, log: (message: unknown) => { this.log(message) }, // workerData already performed the real cross-thread structured clone. args, }这段是 vm 全局的装配。注意看 agent 外面裹的 this.contain:它给 promise 预挂一个空操作的 rejection 消费者(runtime.ts:195-197),脚本忘了 await 也不会变成 unhandled rejection 把 worker 线程打崩。钩子本体逐个 Object.freeze(runtime.ts:112),注释给出的理由很省事:脚本覆盖自己的钩子,坑的只是自己(runtime.ts:110-111)。
agent() 的 options 还要过一道白名单(runtime.ts:368-374):
for (const key of Object.keys(record)) { if (SUPPORTED_AGENT_OPTIONS.has(key)) continue if (DEFERRED_AGENT_OPTIONS.has(key)) { throw new WorkflowError(`agent() option 「${key}」 is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, 'UNSUPPORTED_OPTION') } throw new WorkflowError(`agent() option 「${key}」 is not recognized (supported: label, phase, schema, provider, model)`, 'UNSUPPORTED_OPTION') }这段是选项闸门。注意看 effort、isolation、agentType 是被点名拒绝的(runtime.ts:41 的 DEFERRED_AGENT_OPTIONS),报错里写明 deferred,比一句含糊的 unknown option 多给模型一条信息。白名单放行的只有 label、phase、schema、provider、model 五个。
子代理本身没有任何特殊通道。引擎 inject 的是 subagents 服务(index.ts:113),每个 agent() 最终走 subagents.start(provider, ...),跟模型手写的委派同一个后端,maxDepth、观察策略、沙箱的常规约束一个不少。引擎可配置换后端(默认 spawn),靠的正是上一篇拆过的那道接缝。
三、一个子代理失败,脚本死不死:错误二分法
主线案例跑到第三个模块,那个 agent 挂了。脚本死不死?
我们先找判定点。它在 parallel 的逐项包装里(runtime.ts:413-424):
return Promise.all(thunks.map(async (thunk) => { try { return await thunk() } catch (error: unknown) { // Hook failures are WorkflowErrors built OUTSIDE the script's realm; // fatality is recognized by `instanceof` against this realm's class — // a script-built object can never pass it, so fatality cannot be // forged (nor accidentally dissolved). if (isFatalWorkflowError(error)) throw error return null } }))这段是二分法的执行点。注意看 isFatalWorkflowError 的判定方式:instanceof 引擎 realm 里的 WorkflowError 类。脚本自己 new 一个同名的 Error,永远过不了这个检查。致命与否,脚本说了不算,引擎的类身份说了算,伪造不出来,也误伤不了。
分界线画在错误来源上。钩子误用、上限触发、取消、基础设施故障,致命,直接杀脚本;子代理自身失败、stage 普通抛错,不致命,溶成 null。所以第三个模块失败,reports[2] 是 null,其余七项照常返回,脚本结尾的 .filter(Boolean) 把它滤掉。这条契约直接写进了工具描述(tool-workflow/src/index.ts:148):Misused hooks ... ALWAYS kill the script — they never dissolve into a per-item null。
那 agent() 什么时候返回 null?带 schema 的结果判定(runtime.ts:317-323):
if (result.stopReason === 'completed') { if (opts.schema !== undefined) { // The provider honored outputSchema (capability-gated at start), so // a completed run without a structured value is a child failure. if (result.structured === undefined) { this.observer.agentEnd({ ...info, outcome: 'failed' }) return null }这段是带 schema 调用的结果闸门。注意看 completed 还不够:能力门在启动时就查过 provider 支持 outputSchema,跑了、完成了、却没交出 structured 值,按子失败算,返回 null。不带 schema 的 completed 返回子代理的最终文本;子代理自身失败(stopReason 非 completed)同样返回 null。唯一致命的出口是 run.result 被拒,那是宿主中继的基础设施故障,包成 AGENT_RESULT 往外抛(runtime.ts:304-315),注释给的理由:a broken provider must not read as a failed child。基础设施坏了,不能让模型解读成「这孩子没干成活」。
四、取消发生在哪一毫秒:下一个钩子边界
用户按下取消。脚本正 await 在第三个 agent() 上。它死在哪?
引擎的答案:下一个钩子边界。cancel 的实现(runtime.ts:146-151):
cancel(reason: string): void { if (this.cancelReason !== undefined) return this.cancelReason = reason this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED') for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError()) }这段是取消的本体。注意看最后一行:正在等并发槽的 waiter 全部拒绝。而已经拿到的取消,会在每个钩子的入口处爆出来(runtime.ts:133-135 的 throwIfCancelled),注释把语义钉死(runtime.ts:127-131):取消的边界画在钩子上,agent() 只是其中之一。脚本捕获过一次取消拒绝,照样不能接着用 phase/log 发进度、不能再钻进组合子。取消杀的是脚本整体,不给它带病续跑的机会。
边界要守得住竞态。agent() 拿到并发槽之后的第一件事(runtime.ts:267-274):
await this.acquireSlot() try { // Re-check after the acquire: the await yields at least one microtask // tick even when a slot is free, and a queued waiter resumes a tick // after its release — a cancel() landing in either window must not // reach the host (which would refuse anyway, but the refusal reads as // a start failure rather than the cancellation it is). this.throwIfCancelled()这段是取槽后的复查。注意看注释算的时间账:await 至少让出一个微任务节拍,cancel 落在这个窗口里,语义必须是取消,读成「启动失败」就把一次正常取消冤枉成了故障。启动往返之后还有一次复查(runtime.ts:294-297),刚建好的子代理就地 dispose,注释直译过来:不留它活在一个已经死掉的脚本后面。
我们数下来,光主路上的取消复查就有五处:钩子入口、取槽后(:274)、启动往返后(:294),加上 drive() 结算后的两次(resolve 路径 :173、reject 路径 :179-181);旁边还守着启动失败窗口(:287)和子结果复查(:333)。结算后还要查的意思是:脚本正常 return 了,但取消已经发生,结果必须报 cancelled;completed 是谎报。
脚本要是永远不结算呢?卡在一个没人持有的 promise 上,钩子边界永远不来。宿主侧的兜底(host.ts:198-203):
this.endStrandedAgents() this.settleResult(this.cancelledResult(this.hostStarted)) void this.worker.terminate() }, this.disposeGraceMs) // unref'd: an armed grace timer must never hold the process open. this.graceTimer.unref()这段是处决流程的收尾。grace 定时器(disposeGraceMs,默认 5000ms)到点,先给所有下落不明的子代理补发 cancelled 结局,再强制结算,然后 terminate 线程。dispose 路径的注释写得更狠:the thread never outlives its run(host.ts:214-215),线程绝不比它的 run 活得久。连定时器自己都 unref 了,一句注释说明立场:已武装的 grace 绝不让进程因为它多活一秒。
五、返回值怎么出 vm:物化,逐项拒绝
脚本 return 了一个对象。这个对象生在 vm realm 里,宿主不能直接拿:原型链是那个 realm 的,getter 会执行脚本代码,还可能有环。离开 vm 的唯一通道是物化拷贝(realm.ts:66 起),后面跟着一份拒绝清单:非有限数、bigint、function、symbol、嵌套 undefined、环、稀疏数组、数组的非索引属性、symbol 键、异域原型,一律 MaterializeError(realm.ts:84-135)。工具描述对模型只说人话:the value must be JSON-serializable。
拷贝对象的那一步藏着个细节(realm.ts:140-148):
for (const key of Object.keys(value)) { // defineProperty, never assignment: a 「__proto__」 key must become an OWN // data property of the copy, not a prototype mutation. Object.defineProperty(out, key, { value: materialize((value as Record<string, unknown>)[key], `${path}.${key}`, seen), enumerable: true, writable: true, configurable: true, }) }这段是对象拷贝的实现。注意看为什么不用赋值:脚本返回 {「__proto__」: {...}},普通赋值会命中原型 setter,副本的原型被悄悄改掉;defineProperty 让它老老实实成为副本的自有数据属性,JSON 语义分毫不差。
物化为什么敢直接读 getter?读了就是执行脚本代码。模块头的信任模型写明白了:getters and proxy traps may run, and the vm is not a security boundary(realm.ts:4-5)。worker 挡住的是脚本卡死宿主;脚本要偷宿主数据,vm 这层不管,真有不信任模型的部署,换引擎,这道扩展点就是为此留的。隔离做得彻底的地方在别处:worker 线程的环境变量被擦成空对象(host.ts:45-57 的 workerSpawnEnv),宿主的凭证、loader 标志一概不进线程,Windows 上只补 TMP/TEMP 两个路径,注释解释了原因:不补的话 tsx 的转换缓存会落进一个 cwd 相关的 undefined/temp 目录。
六、模型界面:描述即规范,点名才放行
引擎之上是 tool-workflow,模型看到的 workflow 工具。分工干净:脚本解析、执行、上限、取消全在 ctx.workflowEngine 后面(tool-workflow/src/index.ts:2-10 的模块头),换一个加固引擎,模型界面纹丝不动。
工具描述本身就是规范文本(index.ts:133-150 的 DESCRIPTION 常量):meta 走参数通道、script 是纯 JS body 顶层 await 加 return 结尾、钩子的精确语义、schema 只许用 type/properties/required/additionalProperties/items/enum/const/oneOf 这八样。模型写错格式的报错,早在第一节的同步抛出里就领教过了。
这么贵的机制,什么时候许用?注册进系统提示的只有一段话(index.ts:212-216):
ctx.systemPrompt.section({ name: `tool:${toolName}`, order: 115, text: `Use the ${toolName} tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.`, })这段是使用政策。注意看 ONLY:用户点名要 workflow、或确属大规模多代理编排,才许用;一两个委派用普通 subagent。高成本机制由用户发令,模型不许自作主张升格。
取消契约在工具这层还有一座本地桥(index.ts:296-300):
// Bridge the tool's abort signal to the run: if the parent step is aborted while the // script is in flight, cancel the whole run. The signal also enters the engine directly, but // this local bridge preserves the tool contract even if an implementation ignores it. const onAbort = (): void => { run.cancel('parent step aborted') } exec.signal.addEventListener('abort', onAbort, { once: true })这段是工具层的取消桥。注意看双保险:signal 已经直接传进引擎,这里再挂一层本地桥,防的就是某个引擎实现把 signal 晾在一边,工具自己的取消契约照样成立。
七、结语
回到主线案例。八个模块从 pipeline 连发,每项独立穿两段 stage,无跨阶段屏障,快的不等慢的。第三个模块的子代理失败,那一项溶成 null,其余七项照常走完。用户中途取消:脚本死在下一个钩子边界,在飞的子代理由宿主补发 cancelled 结局;脚本要是卡死不结算,grace 定时器 5000ms 到点,void this.worker.terminate() 那一行把线程连同卡死的脚本一起送走。
账单也摆在明处。每 run 一个 worker 线程加一个 vm realm,短 run 的创建开销不小,所以使用政策才要求用户点名;结构化克隆跨线程传大输出,序列化成本按子代理输出体积走;同步切片 5 秒超时只盖住第一个切片,await 之间的同步循环靠 grace 兜底,没有精细的 CPU 配额。
这套引擎还有个复用者。ralph 工具 inject 同一个 workflowEngine 扩展点,每一圈的编排脚本跑在同一个 worker 线程引擎上(tool-ralph/src/index.ts:20、:447)。下一篇拆它:一个会自己循环的代理怎么靠这段引擎长跑,深读第 23 篇见。
你的 Agent 平台让模型写过编排脚本吗,失控那次是怎么收场的?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。















































































