深入浅出 DeepSeek Harness 第三部:工具执行——不是调一个函数那么简单 第 19 章

程序在子进程里,工具在宿主上

模型写一段程序、经宿主绑定调工具:PTC 运行时。run_code 折叠掉其它直接入口,程序里的 tools 是回到注册表的路,子调用按同一套并发规则调度;程序跑在全新 Node 进程里,经一条控制通道把工具调用送回宿主,外层只回 curate 过的结果。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,Web 里一个挂着 standard preset 的会话,模型想让两个 bash 命令的结果拼在一起。它没有先调 bash 再调 bash,而是写了一段程序,交给 run_code:const a = await tools.bash({ command: 'echo CODE_ONE', ... }),再等第二个,把两个输出用 + 拼起来 return。程序在子进程里跑,两次 tools.bash 的调用顺着一条控制通道回到宿主,在注册表里照常执行。第 40 秒,结果回到模型面前:先是程序 console.log 的 captured output,然后是拼好的 CODE_ONE+CODE_TWO。

第 18 章结尾说,run_code 的程序也是子进程服务起的一个 Node 子进程,比 bash -c 多要了一条控制通道。这一章讲这条通道的两边:模型写的程序怎么在子进程里跑起来,程序里 tools.xxx 的调用怎么经宿主绑定变成注册表里的真实工具调用,跑完的结果又怎么只把 curate 过的那一份交回模型。

PTC 运行时:模型面折叠成唯一入口 run_code(PTC_ONLY_INSTRUCTION,其它工具只能从程序里调);程序里的 tools 是回到注册表的路——createRunCodeTool 枚举 registry.schemas(exec.agent) 可见工具,绑定调用经 TOOL_RUNTIME_SCHEDULER 的 prepare→dispatch→finalize 当子调用执行,与原生同池同规则(提交序/head-of-line/exclusive 屏障/maxParallel);程序跑在全新 Node 子进程(stripTypeScriptTypes 剥类型、env 白名单滤 ELECTRON_RUN_AS_NODE、--max-old-space-size=512、沙箱与 bash 同一服务),控制通道是长度前缀 JSON 帧(ready→boot→call/log/done→reply,maxMessageBytes 128 MiB、maxPendingCalls 128);失败是一类结果(八类 exception/timeout/abort/worker-exit/invalid-output/output-limit/protocol/sandbox-unavailable,done 帧只认三种);结果只回 curate 过的一份,图片经 deferContext 带回。

模型面只剩一个入口

一次程序调用,模型的书写面被整个折叠了。挂上 PTC 模式以后,模型能看到、能直接调的工具只有一个 run_code:系统提示词里有一条专门的声明(PTC_ONLY_INSTRUCTION),说 run_code 是唯一能直接调的工具,调别的工具会失败,SDK 声明的每个工具都要从程序里调。这条声明和注册表的执行判定用同一个谓词生成,提示词里写的规则,就是执行时会拦的那条。

模型读到的 run_code 有两个必填参数:code,一段异步函数体(类型擦除语法,顶层 await 和 return 可用);description,一句话说明程序做什么,作为 UI 上永远可见的标签(presentCall 拿它当卡片标题)。可选的三个控制参数:timeoutMs(正整数毫秒,压到部署上限)、sandbox_permissions(给整段程序升权的请求,需要 justification 理由,走第 17 章的审批)、justification。升权的措辞刻意说明:这次升权只批准这一整段程序跑一次,程序里嵌套的工具保留各自的策略和审批;已经生效的副作用不会回滚,程序不会自动重放。

code 参数的说明和工具的 description 都按运行时语言走:TypeScript 和 Python 各有一份 flavor,模型拿到的 schema 和 SDK 段的语言永远一致。两份表(schema flavor 和 SDK 渲染器)用同一个语言联合类型钉住,加一种后端语言是三处并列的改动,漏一处就过不了类型检查。运行时没有挂载时,schema 退到 TypeScript flavor——但真正的装配路径在发射 schema 之前就要求运行时存在,所以模型永远看不到这个退路。

PTC 模式有三种(mode 配置,或者按作用域用 presentAs 声明):native 是原生工具面,run_code 对模型不可见;ptc 是折叠面,模型面只剩 run_code,SDK 声明全部进系统提示词的 tools:sdk 段;both 是两者都在,模型既能直接调工具,也能写程序。tools:sdk 段按调用方的可见工具生成(TypeScript 渲染 renderToolsSdk,Python 渲染 renderToolsSdkPy),作用域声明只在 PTC 模式部署里为个别 Agent 打开,一个进程里可以同时有原生 Agent 和 PTC Agent。

本节源码依据(8 处)

程序里的 tools 是回到注册表的路

程序里 tools.bash(...) 的 tools 不是注册表本身,是一组绑定函数:createRunCodeTool 枚举发起调用的 Agent 可见的那份工具集(registry.schemas(exec.agent),作用域过滤过的、全局可见的都在),跳过 run_code 自己,给每个工具造一个 PtcBindingFunction。程序看到的名字,就是它的 SDK 段承诺过的名字——被 restrict 挡掉的工具不会出现在程序里。绑定函数用 Object.defineProperty 挂在空原型对象上,一个叫 __proto__ 或 constructor 的工具在程序里也只是普通键,不会撞原型。

一次 tools.bash({...}) 在程序眼里是 await 一个异步函数;在宿主眼里是一次真实的子调度。绑定函数先校验参数必须是无损 JSON(不可克隆的值直接拒绝,不给程序一次会悄悄丢数据的调用),然后入调度队列。这里的调度器不是绕开注册表另写一套,而是注册表自己的那条:绑定函数经 TOOL_RUNTIME_SCHEDULER 走 prepare(pre-execute/guards)→ dispatch(body)→ finalize/finish(post-execute),与原生循环共用同一套提交规则——提交序、head-of-line 光标、exclusive 全屏障、maxParallel 上限(配置项 maxParallelSubCalls)。每个子调用在开始时记一条 tool/ptc-dispatch-start,了结时记一条 tool/ptc-dispatch(参数另存一份 byte 相同的快照,工具改了自己的参数也不会让日志失真),供事后重构这一轮到底发生了什么。

程序拿到的结果是被 curate 过的:logs 数组 + 可选的 result,嵌套工具的内部错误元数据不进入程序面的失败契约。子调用失败时,程序收到一个 ToolCallError 实例——绑定命名空间声明的错误类,带 toolName 属性标出是哪个工具。子调用的结果里要是有图片,宿主不会塞进程序的返回值,而是经 deferContext 带回上下文(ptc-mode 来源),和嵌套工具的 additionalContexts 一起,程序跑完以后才轮到它们进入模型视野。

一个程序里可以嵌套调用会自己起程序的工具,也可以调用别的 Agent 专属的工具:绑定是按调用 Agent 的可见集枚举的,子调用也把 exec.agent 一路传下去,作用域解析在每个调用上都重新发生一次。

本节源码依据(10 处)

命名空间是一份可移植的契约

绑定不是宿主随手塞几个函数给程序。PtcRuntime 的接缝层先立规矩:程序能看见的每个全局名、每个错误类名,都要是可移植标识符——符合 [A-Za-z_][A-Za-z0-9_]*,并且不在任何目标语言(ECMAScript ∪ Python)的保留字清单里。这一条是全后端共享的:lambda 在 TypeScript 里合法、在 Python 里是关键字,一份名字清单要么在所有后端都合法,要么都不合法。加一种后端语言,就要扩宽这份保留字并集(对既有绑定名是一次有意的破坏性评审)。

还有一层显式的预留:console(Node 后端的日志捕获槽位)、__dsh_main__/__builtins__/__name__(Python 后端的引导包装和种子全局)、__debug__(CPython 编译期常量,注进去也够不着)——这些名字任何后端都拒绝,为的是同一个承诺:在一个后端验证过的清单,拿到别的后端不会撞上它私有的槽位。错误类的成员属性名同样共享一份排除清单(name/message/stack 与 Python 异常协议的 args/with_traceback/add_note),双下划线形式(__x__)整类拒绝。绑定校验在起进程之前就把这些不合规的声明挡回去,而不是让程序跑到一半才发现名字不可用。

程序里实际见到的错误类是引导侧构造的真构造函数:makeBindingErrorClass 为每个声明了 errorClass 的命名空间造一个类,构造函数把成员名写进声明的属性(ToolCallError 的 toolName),instanceof 可用。命名空间对象是空原型,函数名是普通自有键。

本节源码依据(6 处)

程序跑在一个全新的 Node 进程里

run_code 的程序由 NodePtcRuntime 跑:language 是 typescript,isolation 是 process——每次调用都在一个全新的 Node 进程里。resolve 先补齐执行输入:工作目录(没给就用沙箱策略的工作区根目录,给了必须是绝对路径)、时限(缺省 120 秒、上限 600 秒,clampTimeout 补缺省、压上限,null 表示不限时)、沙箱策略(调用方给了用调用方的,否则用部署策略)。run 校验解析后的输入,然后起进程。

进程是这样起来的:先把程序源码剥掉类型——stripTypeScriptTypes 把 code 包成 async function __dsh_program__() { ... } 再剥,剥完的程序以严格模式跑。可执行文件经子进程服务的 resolveExecutable 解析,堆上限用 --max-old-space-size(缺省 512 MiB)定住;引导代码(bootstrap)按部署形态选择:装了内置引导就用内置,源码形态就经 --input-type=module --eval 拼一段 import 引导的启动器。沙箱:模式不是 danger-full-access 时,先经 sandbox.confine 把整条启动命令包一层——和 bash 用的是同一个沙箱服务,所以文件策略的语义(read-only、workspace-write、danger-full-access)两边一致,升权、denial 判定也都一样。环境变量从空开始:宿主环境的键被过滤,只留启动变量(PATH、PATHEXT、SYSTEMROOT、WINDIR、TEMP、TMP),连 ELECTRON_RUN_AS_NODE 这种会被 Electron 子进程误读的变量也在引导启动前滤掉。程序里的 process.env 不是宿主的缩小版,是一张白纸。

程序输出的兜底也在这里定:stdout/stderr 各进一个管道,宿主侧由 OutputLedger 按一个合并预算记账——maxOutputBytes 缺省 64 MiB,日志、完成值、失败诊断合在一起算,超了就明确报 output-limit(这是失败的一种,不是悄悄截断)。

本节源码依据(8 处)

控制通道上只有四类消息

子进程和宿主之间是一条专用控制管道,走的是 JsonChannel:每帧 4 字节大端长度前缀加一段 JSON。长度上限由 maxMessageBytes(缺省 128 MiB)管住,写队列也有同一个预算;解析失败、帧超限、通道提前关闭,都按协议错误处理。协议的词汇表很小:

  • 子进程启动后先发 ready;
  • 宿主回 boot,带上剥好类型的代码、命名空间声明(只有全局名和成员名,函数本体留在宿主侧)和输出上限;
  • 程序此后发三类消息:call(一次绑定调用:自增 id、全局名、成员名、参数)、log(捕获的文本,边走边发,程序中途被杀输出也不丢)、done(程序了结,带完成值或失败诊断);
  • 宿主对每个 call 回一条 reply(ok + 无损 JSON 值,或 ok:false + 消息)。

词汇表按方向看更清楚:

消息方向内容谁发起
ready子进程 → 宿主我起来了子进程(启动即发)
boot宿主 → 子进程剥好类型的代码 + 命名空间声明(只有全局名与成员名,函数本体留宿主侧)+ 输出上限宿主(收到 ready 后)
call子进程 → 宿主一次绑定调用:自增 id、全局名、成员名、参数程序(tools.xxx(...))
log子进程 → 宿主捕获的文本,边走边发程序(console shim / stdout 补丁)
done子进程 → 宿主程序了结:完成值或失败诊断程序(return / throw)
reply宿主 → 子进程ok + 无损 JSON 值,或 ok:false + 消息宿主(每个 call 一条)

为什么词汇表这么小?因为这是敌意对等体之间的最小接口(PtcRuntime 的约定原话 “treat programs as hostile peers”):程序不信宿主给的任何值,宿主不信程序发的任何帧。词汇越少,双方要防的面越少——boot 只带声明不带函数体,程序就偷不走宿主侧的实现;done 只带结果不带内部账,程序就伪造不了宿主侧才有的失败。

程序侧的引导(bootstrap)把通道接好:ready 发出、等 boot,然后 runProgram 用 AsyncFunction 构造器把代码段包成严格模式的异步函数,参数依次是各命名空间对象、错误类、console shim。程序里的 console 不是 Node 全套,是五个方法(log/info/warn/error/debug)的 shim,渲染进同一个日志台账;process.stdout/process.stderr 的 write 也被补丁进台账,保证输出顺序和 console 一致。程序完成的返回值必须是无损 JSON(snapshotPtcJsonValue),不然按 invalid-output 失败;完成值占的字节也在剩余预算里扣,扣不下就报 output-limit。抛出的异常变成 done 里的 exception 诊断,栈文本受预算约束,不会把无界字符串送上通道。

宿主侧按同样的协议栈处理每一帧,并额外做一层防御:模型写的代码可以直接写继承来的描述符,所以宿主对每个 call 都重新校验——id 必须递增、全局名必须是声明过的命名空间、成员名必须是那个命名空间里的函数、参数必须是无损 JSON。未声明的绑定、错序的 id、超预算的并发(maxPendingCalls 缺省 128)都按 protocol 失败收场。done 里的错误种类宿主也校验:只认 exception、invalid-output、output-limit 三种——超时、中止、进程死亡这些账在宿主侧记,不由程序自己报。

本节源码依据(7 处)

子调用按同一套并发规则调度

程序里连续 await tools.bash(...),宿主把每个调用当一次子调度。并发规则和原生循环是同一条:每个调度按 executionMode 分类,parallel 可以重叠到 maxParallel,exclusive 要等池子排空、单独跑、并且从开始到提交(post-execute 也算)一直持着屏障。分类在每次启动前重新读——程序排队期间注册表变了,一个本来并行的调用可以变成 exclusive,和原生调度器的惰性重分类一模一样。结果按提交序提交:head-of-line 光标保证模型看到的结果顺序就是程序发起的顺序。

区别在日志和可见性。子调用的每一步都有痕迹:开始时 append tool/ptc-dispatch-start,了结时经 tools/ptc-dispatch-log 瀑布(waterfall)把结果整理成可重构的块,再 append tool/ptc-dispatch。这些事件是为重构准备的——事后可以还原这一轮程序到底调了哪些工具、各自什么结果;但模型的历史里只进外层 curate 的结果。排干逻辑也在这里:程序一了结(无论成功失败),runController 就 abort,还在飞的子调用被中止(执行器在信号上杀掉)、还没开始的被放弃,日志任务全部排干之后才关这一轮。绑定失败的 promise 各自 observable,放弃的调用拿到一条明确的错误。

两个录制正好覆盖这两种形态。mcp-empty-ptc 里程序两次 await tools.bash,串行等结果,宿主侧是两次按序执行的子调度,外层结果只有 captured output 和拼好的字符串;mcp-resources-ptc 里程序用 Promise.all 并行调四个 MCP 资源工具,宿主侧子调度重叠到并发上限,程序拿到每个工具的返回对象后 curate 成摘要再返回。读一下录制里 run_code 的调用和结果:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the pinned checkout}"
cd "$repo/snapshots/session"
for rec in mcp-empty-ptc mcp-resources-ptc; do
  echo "== $rec"
  jq -r 'select(.type == "tool/call" or .type == "tool/result")
    | if .type == "tool/call"
      then "call    \(.data.name) \(.data.arguments | fromjson | del(.description) | tojson)"
      else "result  \(.data.message.content[0].content[0].text | gsub("\n"; " "))" end' "$rec/session.v3.jsonl"
done
== mcp-empty-ptc
call    run_code {"code":"const a = await tools.bash({ command: 'echo CODE_ONE', description: 'Echo CODE_ONE' });\nconst b = await tools.bash({ command: 'echo CODE_TWO', description: 'Echo CODE_TWO' });\n…","timeoutMs":60000}
result  captured output\nCODE_ONE+CODE_TWO
== mcp-resources-ptc
call    run_code {"code":"const [resources, templates] = await Promise.all([\n  tools.list_mcp_resources({ server: \"catalog\" }),\n  …"
result  { "resources": { "_meta": { "io.modelcontextprotocol/serverInfo": … } } }

第一段的程序把两次 bash 的输出拼起来 return,模型只看到 captured output 和 CODE_ONE+CODE_TWO——两次 tools.bash 各自的细节没有进外层结果。第二段的程序把四个 MCP 调用放进 Promise.all,并行执行,返回的是一个 curate 过的对象:资源和模板的完整列表在,二进制 blob 只留摘要(binarySummary),不把 blob 数据原样带回来。这正是一章开头那句”curate it”的意思:程序怎么把结果收窄,决定模型看到什么。

本节源码依据(5 处)

失败是一类结果,不是一个异常

程序的成败不是以 run() 抛不抛异常来分:一次运行的结果是 PtcRunResult,失败是结果上的 error 字段。八类失败各归各的账:

kind谁记的账含义
exception程序(done)程序抛了,或解析/转换失败
timeout宿主实现拥有的预算耗尽,消息里写明是谁的时限
abort宿主外层 PtcRunRequest.signal 响了
worker-exit宿主执行底座没交代就死了(如 OOM)
invalid-output程序(done)完成值不是无损 JSON
output-limit宿主+程序序列化的日志/完成值/诊断超合并预算
protocol宿主程序发了非法或超预算的控制流量
sandbox-unavailable宿主要的沙箱给不出来

run_code 主体把带 error 的结果转成一条 CodeRunFailedError(CODE_RUN_FAILED)文本,附上捕获的输出、沙箱模式和拒绝标记,让模型能据以自纠——这是给模型看的整段话,不是悄悄吞掉的异常。

为什么失败是结果而不是异常?因为失败的消费方有两个(宿主代码和模型),它们要的不是同一个东西:宿主要结构化记账(哪种失败、谁的预算),模型要一整段可自纠的文本。run() 抛异常只能服务一个消费方;失败进 error 字段,两边各取所需。这也是为什么 done 帧只认三种错误(exception/invalid-output/output-limit):这三类程序自己知道,其余五类是宿主侧观测的账(超时、中止、进程死亡、协议、沙箱),不让程序自己报——模型代码可以直接写继承来的描述符,伪造一份失败是防得住的。

两个容易混淆的边界在类型定义里写得很清楚:超时不是异常,中止不是超时,进程死亡两者都不是——它们彼此正交,各记各的。沙箱的账也独立:PtcRunSandbox 带 mode、denied(程序失败文本是否命中后端诊断签名)和可选的 enforcement,跟终端结果无关——程序自己跑挂了,沙箱的观测照样带回来。宿主对 done 帧里出现的错误种类有限制(只认三种),超时、中止、进程死亡这些宿主侧观测到的账,不让程序自己报,防止模型代码直接写描述符伪造一份失败。

本节源码依据(5 处)

谁在管这条通道的两端

把这一章的三层叠起来看:模型面是一个被折叠成 run_code 的入口,提示词声明清楚规则,schema 按语言发射;接缝是绑定——程序里的 tools 是把可见工具变成子调用的桥,命名空间名受一份跨后端可移植契约约束;执行是宿主侧的事——程序在一个全新 Node 进程里跑,沙箱和 bash 同一套,控制通道上的每一帧都被校验,子调用按原生规则调度,结果只把 curate 过的一份交回。

这条通道的两端各防各的:程序侧不信任宿主给的任何值(完成值必须无损 JSON,绑定失败只带工具名),宿主侧不信任程序发的任何帧(id 必须递增、命名空间必须声明过、并发必须守限额)。run_code 的程序进程归子进程服务管(第 18 章),停法、宽限、范围都和普通命令一样;控制通道是它多出来的那一条路。程序里调的每个工具,又各自带着自己的沙箱、审批和输出上限——程序只是把它们串起来的另一双手。

下一章把执行世界从本机挪到远端:第 20 章讲文件系统、子进程和沙箱整体搬到 SSH 主机上,Harness 与会话留在本机。

本节源码依据(2 处)