夜雨聆风学习资料网

ARTICLE · 1099322

DeepSeek-Harness 源码深读(14):工具管线只有一条,调度器却写了两个

DeepSeek-Harness 源码深读(14):工具管线只有一条,调度器却写了两个

摘要:run_code 的 body 在 agent-loop 眼里是一次普通工具调用,程序里的子调用全靠桥内手写的 driver lane 调度:提交序启动、策略段串行、exclusive 屏障压到 commit、结果按 head-of-line 游标提交、settle 即 abort 排空。两份调度器的顺序语义逐条对账,外加参数双快照、null 原型命名空间、日志背压三个边角。

教程线第七篇《没有一个权限模块,tools 插件凭什么管住所有调用》跟着一次 bash 调用,把 pre-execute、守卫、post-execute 那串关卡从头过了一遍。这一篇把问题拧个方向:Code Mode 下模型不逐个敲工具了,它写一段程序连发调用,这些调用归谁调度?

先把结论摆出来。dsh 的工具执行管线只有一条,调度器却写了两个。一个在 dsh-agent-loop 里,安排模型直接发起的调用;另一个藏在 packages/core/tools 的 run_code 桥里,code-mode.ts 手写的 driver lane。两份代码要维持同一套顺序语义,靠的是源码注释逐条互相锚定。

这块源码有点绕。我们还是拿一段具体程序,从头走。

一、body 是黑盒:主循环只认识 run_code

先上主线案例。Code Mode 下模型写了这段 TypeScript,四个子调用,read 三个并发,bash 压轴:

const [pkg, readme, cfg] = await Promise.all([  tools.read({ path: 'package.json' }),  tools.read({ path: 'README.md' }),  tools.read({ path: 'tsconfig.json' }),])const lint = await tools.bash({ command: 'npm run lint', description: 'Run lint' })return { deps: pkg.dependencies, lint }

(这段是模型写的程序,示意,非 dsh 源码。)对 agent-loop 来说,这次 run_code 就是一次普通工具调用。调度器按自己的规则把它排进队,等 body 返回,内部还有四个调用在跑这件事,它看不见。body 是个黑盒。

黑盒里的子调用不能裸奔。教程篇那套关卡(三值决策、单调守卫、post-execute 治理)对子调用原样生效,路径在 code-mode.ts:483:桥拿走注册表的分阶段调度接口,就一行 const scheduler = registry[TOOL_RUNTIME_SCHEDULER]。接口本体长这样(index.ts:451-460):

export interface ToolRuntimeScheduler {  /** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */  prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>  /** Run only the around-dispatch/body stage. */  dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>  /** Run post-execute and definition-owned content finalization, then materialize and notify. */  finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>  /** Run definition-owned content finalization, then materialize and notify without post-execute. */  finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult}

这段是注册表对调度方摊开的四个阶段。注意看 prepare 和 dispatch 是拆开的:pre-execute 加守卫在 prepare,工具体在 dispatch。拆开是给并发调度留的缝,策略段可以有序,body 段可以重叠。子调用走 scheduler.prepare 进的正是教程篇那次 bash 调用走进的同一个 prepareExecution(index.ts:1463),权限、审批、双冻结一分不少。每个子调用还领到确定性的 callId:父调用的 ID 拼上 :code: 和提交序号(code-mode.ts:471-472),程序里第几次调用,日志里就第几条,重放时对得上账。这个接口不进公共服务 API,挂在 symbol 键后面(index.ts:466),实现落在 index.ts:796;注册表私有的 requireRuntime、maxParallel 也一样,以闭包注入桥(code-mode.ts:269-283)。拿得到接口的,是拥有者,别人连键名都摸不着。

但接口只回答「一次调用怎么分段」。「什么时候启动、谁先谁后、结果按什么顺序交还程序」,它一个都答不了。主循环在等 body,指望不上。所以桥里长出了第二个调度器。

二、单车道:有序段全部串行,只放 body 并发

driver lane 只有一条循环(drive(),code-mode.ts:395),所有有序段都在这条道上排队。状态就几个变量(code-mode.ts:374-382):待启动队列 pendingQueue、飞行池 inFlight、提交队列 commitQueue、屏障标志 exclusiveActive。

这条车道全貌一张图:

提交序进,提交序出

注意看 exclusive 屏障压的位置:不是 bash 的 body 落地就放行,要等 commitQueue 轮到它、post-execute 落定,exclusiveActive 才降。图上每个闸门的行号,下面三节逐个拆。

循环里的启动判决(code-mode.ts:420-431)决定队首调用能不能起跑:

// Reclassify at start time (fail-closed on registry changes).const mode = head.classify()const capacity = !exclusiveActive  && (mode === 'exclusive' ? inFlight.size === 0 : inFlight.size < maxParallel)if (capacity) {  if (mode === 'exclusive') exclusiveActive = true  head.mode = mode  pendingQueue.shift()  // Joined before start() so the commit cursor sees submission  // order; nothing commits it until `settled` flips.  commitQueue.push(head)  await head.start()

这段是启动闸门。注意看 capacity 那行:parallel 分类只要飞行池没满就能起(maxParallel 来自配置 maxParallelSubCalls,默认 10,index.ts:776);exclusive 分类要求池子是空的,还要立起 exclusiveActive 屏障。我们案例里三个 read 声明了 isConcurrencySafe,按提交序一个个起跑、同时在飞;轮到 bash,它是 exclusive,得等三个 read 的 body 全部落地。

分类每次启动前现查。判决函数在注册表侧(index.ts:1276-1285):

executionMode(exec: ToolExecutionInput): ToolExecutionMode {  const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)  if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }  try {    const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)    return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' }  } catch {    return { kind: 'exclusive' }  }}

这段是并发分类的判据。注意看失败方向:没声明 isConcurrencySafe、返回了非 true、甚至抛异常,一律按 exclusive。宁可白排队,不冒并发险。判决时机也值得看,classify 绑的是 registry.executionMode(input).kind(code-mode.ts:532),每次启动时现读,排队期间有人往注册表塞了新工具,下一轮现查就翻案。这跟原生调度器的惰性重分类同款,注释明写 matching the native scheduler's lazy reclassification(code-mode.ts:357-359)。

策略段的串行也在这条车道里保证。start() 的内部(code-mode.ts:544-554):

// Ordered prepare runs INSIDE the driver lane: the next entry's// pre-execute waits for this resolution, as under the native// scheduler. Only the launched body below overlaps.const prepared = await scheduler.prepare(input)if (prepared.kind === 'dispatch') {  this.flight = scheduler.dispatch(prepared.exec).then((dispatchOutcome) => {    parked = { kind: dispatchOutcome.kind, exec: prepared.exec, result: dispatchOutcome.result }    this.settled = true  })  return}

这段是 start 先跑策略段、再发射工具体。注意看两个动作的时序差:prepare 在车道内 await,第 N+1 个调用的 pre-execute 必须等第 N 个落定;紧跟着的 dispatch 不 await,body 甩进 flight 池就交还车道。串行的只串策略,并发的只并 body。任何时刻都不存在两个调用的审批、守卫读数交错出现。

那么结果呢?三个 read 同时在飞,README 最大最慢,tsconfig 说不定最先跑完。谁先完成谁先交吗?

三、head-of-line 游标:结果按提交序交

不交。循环另一头的 commit 判决(code-mode.ts:404-412):

const commitHead = commitQueue[0]if (commitHead !== undefined && commitHead.settled) {  commitQueue.shift()  await commitHead.commit()  // The barrier covers post-execute: later starts wait for the  // exclusive call's full pipeline, as under the native loop.  if (commitHead.mode === 'exclusive') exclusiveActive = false  continue}

这段是结果闸门,每圈循环先看提交队列队头,settle 了才 commit。注意看第二行:队头是 package.json(提交序 1),它没 settle,先跑完的 tsconfig 就得压着,谁也不能插队。程序拿值的顺序因此恒等于提交序,跟 Promise.all 参数里的书写顺序一致。顺序钉死的不止程序侧:start 事件在 start() 里追加(code-mode.ts:537-543),settle 事件在 commit 里追加,两者都在车道内按提交序跑,事件日志里的子调用顺序因此也是确定的,回放重建不用猜。

游标顺序在启动那一刻就钉死了。第二节代码块里 commitQueue.push(head) 发生在 await head.start() 之前,注释明说 Joined before start() so the commit cursor sees submission order(code-mode.ts:429-430)。顺序定了,再看「何时算完」:最后一行里 exclusiveActive 要等 bash 的 commit 跑完才降下来,而 post-execute 就装在 commit 里(code-mode.ts:561-563:post-result 走 finalize,含 post-execute;final-result 走 finish,跳过)。所以 bash 的 body 跑完还不够,结果治理没落定,屏障就压着。注释给的道理就一句:The barrier covers post-execute。屏障压到的位置比 body 完成更深一层,post-execute 没跑完就不算做完。

四、settle 即排空:不留孤儿调用

样例程序通常会正常 return。可要是三个 read 还在飞,程序先抛异常了呢?预算耗尽、用户取消呢?正在飞的子调用会落个什么下场,答案是一段挂在 runtime.run 外面的 finally,程序以任何方式 settle(返回、异常、预算到期、取消)都会走进它(code-mode.ts:631-637):

} finally {  // Abort sub-dispatches and drain every in-flight dispatch before  // closing the turn (queued-unstarted ones are abandoned unlogged).  // Binding failures remain observable through their individual promises.  runController.abort('run_code settled')  await drainDispatches()}

这段是排空本体。注意看两步:先 abort run 级控制器,再 drain。飞行中的子调用握着这个信号(绑定输入里 signal: runController.signal,code-mode.ts:480),executor 会杀掉;排队没启动的直接 abandon(code-mode.ts:415-418),绑定的 promise 被 reject,程序侧拿到明确报错。控制器在 execute 开头就建(code-mode.ts:340-342),同时跟随外层取消。

飞行完的结果也不交给程序。绑定返回前有一次 runOver() 复查(code-mode.ts:594-596),命中就抛 run_code run is over,结果作废。程序侧自始至终拿不到任何一个来自已死亡 run 的值,runOver 复查护住的就是这条底线。

排空还包括日志。drainDispatches(code-mode.ts:451-459)在 drive() 之后还要 await 所有 logWork,保证每个 settle 事件都落进还开着的 turn。

五、三个边角:双快照、null 原型、背压

主线走完,还有三处边角,是我核稿时多看两眼才咂摸出来的。

先看参数快照。绑定入口的规范化函数(code-mode.ts:163-168):

const logged = snapshotJsonValue(snapshot)/* v8 ignore next -- snapshot is already a detached lossless JSON value. */if (logged === undefined) {  throw new Error('tool arguments could not be detached for durable logging')}return { dispatched: snapshot, logged }

这段是参数的两次快照,dispatched 给执行,logged 给日志。注意看两份是字节相同的不同对象。只快照一份的话,某个工具在 body 里改写了自己的 args,日志记录的就不再是它实际收到的东西。settle 里追加事件用的正是 normalized.logged,注释写明 a tool mutating its args cannot desync this record from what it actually received(code-mode.ts:517-519)。

第二个边角,绑定命名空间的装配(code-mode.ts:609-617):

const functions: Record<string, CodeBindingFunction> = Object.create(null) as Record<string, CodeBindingFunction>// Enumerate the CALLING AGENT's visible set (scoped tools join,// restricted globals vanish) — the same view the SDK section declared,// so a program can bind exactly what its prompt promised; sub-dispatch// re-resolves per call through the same view (exec.agent threads down).for (const schema of registry.schemas(exec.agent)) {  if (schema.name === RUN_CODE_NAME) continue  Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(schema.name) })}

这段是 tools 对象的构建。注意看 Object.create(null):普通对象赋值 __proto__ 会命中原型链上的 setter,绑定被静默吞掉;null 原型加 defineProperty,任何名字都装成普通自有键。真有个工具注册名就叫 __proto__,程序照样调得动。枚举范围也有讲究:枚举的是调用方 agent 的可见集,受限全局消失、scoped 工具加入,跟系统提示里 SDK section 声明的是同一个 view() 解析结果。提示里答应过的,程序里才绑得上。

第三个边角是背压。commit 的收尾在 code-mode.ts:578-585,这段是日志侧工作的限流:

if (result.concludesTurn) exec.concludeTurn()settle(result)// Backpressure on pending event-append tasks: each task retains// a full result while a slow backend stores it, so the pool cap// bounds their count. Beyond the cap, the// ordered lane waits, so later sub-calls cannot start and// pending I/O/memory cannot grow without bound.while (logWork.size > maxParallel) await Promise.race(logWork)

这段是日志侧工作的限流。settle 事件落盘是侧工作,每个任务都攥着一份完整结果等慢后端。注意看最后一行:积压超过并行上限,车道原地等待,新调用不许启动,I/O 和内存有界。而程序拿值是立即的,settle 里 resolve 排在日志任务前头(code-mode.ts:496-498),慢后端拖不慢绑定。

六、这笔交易的账单

桥省下了什么?执行语义。子调用的 prepare 进的是注册表同一个 prepareExecution(index.ts:1463),三值决策、守卫、双冻结、post-execute,一行没重写,教程篇的关卡对子调用原样生效。

桥自己写了什么?顺序语义。提交序启动钉在 code-mode.ts:427-431,策略段串行钉在 547,exclusive 屏障钉在 410,head-of-line 提交钉在 405-407,惰性重分类钉在 421 与 532。五条规则在 driver lane 里各有一处实现,逐条对齐原生调度器。

对齐靠什么保证?注释。code-mode.ts:346-359 那段长注释,每条顺序规则后面都缀一句 as under the native scheduler 或 exactly like a native exclusive group。没有类型系统能检查「两个调度器语义相等」,将来调度规则再演化,这里是两处并行编辑。教程篇《不封装任何 SDK,llm 插件凭什么接住所有模型调用》见过另一种缝合思路,所有供应商挤进 LlmAdapter 这个窄缝,两边自由度被接口钉死;这里取舍相反,宁可养两份,把桥内编排留在自己手里。

好在第二调度器的全部活动都落在事件里。每次子派发追加 tool/code-dispatch-start 与 tool/code-dispatch 两个事件,但两者都不产消息:消息投影函数按事件类型白名单折叠(session 包 surface.ts:83 的 deriveEventMessage,109 行的 default 分支注释写明 log-only record projects to no message),子调用永不重入模型上下文。模型看到的是外层 run_code 筛选后的结果,账本上却记着每一次调用。溢出场景还留了口子:tools/code-dispatch-log waterfall 允许监听器把持久副本换成预览加定位符,spill-policy 就挂在这(spill-policy/src/index.ts:217)。这套账还有人查。tools 包自带一个 invariant 伴随插件,跑时断言 code-dispatch 事件的 rootCallId 逐调用一致、父调用属于同一棵调用树(invariant.ts:37-53),还断言事件落在开启的 turn 之内,否则报 appended outside any open turn(invariant.ts:68)。第四节的 drainDispatches 为什么要等 logWork 清空才收尾,这条断言就是验收方。

七、结语

回到样例程序。三个 read 按提交序 1、2、3 起跑,bash 排 4。tsconfig 最先跑完,结果压在游标后面;package.json settle,游标推进,README 跟上;bash 等池空独占,post-execute 落定那一刻 exclusiveActive 才降。程序拿值的顺序,跟代码里书写的顺序一字不差,靠的是 commitQueue.push(head) 早于 await head.start() 那两行。

两份调度器同语义不同实现,是这个包里少数没用窄缝接口缝合的重复,0.1.1-rc.1 还维持着。值得留意后续版本会不会合并。

子调用的编排到这里清楚了。可 run_code 自己在主循环眼里仍是一次调用:它和普通调用怎么交错、active-batch 何时换届、turn 由谁终止?这些在 agent-loop 的主循环里,下一篇拆它。

你的 Agent 也有「程序里调工具」的模式吗,调度权握在谁手里?评论区聊聊。


本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。

相关学习资料