DeepSeek Harness 的代码运行时:模型写的代码在哪儿跑、能碰到什么

2026-08-17

把 agent 的工具调用换成「让模型写一段程序,程序里再调工具」,第一个让人睡不着的问题不是效果,而是:这段模型写的代码到底在谁的机器上、哪个进程里跑?它能读到我的环境变量吗?它死循环的时候谁去杀它?

DeepSeek Harness 仓库里这块能力叫 code runtime。下面这一趟是顺着仓库源码从入口走到 worker 里的,每一处都标了文件,你可以自己去翻。先说清限定:该项目 README 自述处于开发者预览阶段,并明写「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」(会有破坏兼容性的变更),下面提到的配置项名字和默认值随时可能变。我们没有安装、也没有运行过它,讲的全是源码与文档口径。

第一步:谁发起这次执行

入口在 packages/core/tools/src/code-mode.ts。当配置里写了 tools: { mode: code }(见 examples/acp-agent/code-mode.cordis.yml),工具注册表就不再把每个工具铺到线上,而是只暴露一个模型可见的工具,名字是常量 RUN_CODE_NAME,值为 run_code

模型交上来的 code 参数,会被组装成一次 runtime.run(...) 调用。这个请求里绑定了一个命名空间:

bindings: [{
  global: 'tools',
  functions,
  errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
}]

functions 是遍历 registry.schemas(exec.agent) 建起来的,会跳过 run_code 自己(否则程序里能递归再开一层)。源码里这个对象是用 Object.create(null)defineProperty 建的,注释写明理由:如果有个工具真叫 __proto__,普通赋值会打到原型 setter 上、绑定被悄悄丢掉。

errorClass 这两个字段值得记一下:程序里失败的工具调用会 reject 成一个真的 ToolCallError 实例,出错的工具名挂在 toolName 这个自有属性上。也就是说模型可以在程序里写 catch (e) { if (e instanceof ToolCallError) ... }——注入的是真构造函数,不是长得像的对象。

第二步:seam 只承诺两件事

抽象服务定义在 packages/code-runtime/code-runtime/src/index.tsCodeRuntime 除了 run(request) 只有两个只读描述符:languageisolation

docs/subsystems/code-runtime.md 里对这两个字段的措辞很值得抄一遍:language 的已知值是 'typescript''python',但只有 'typescript' 有已发布的后端isolation 的已知值是 'worker-thread''process''container',文档原文说它是诊断标签,不构成安全承诺(a diagnostic label, not a security claim)。

顺带一处口径差异:packages/code-runtime/README.md 的包表格里,后端那一行写的是 code-runtime-worker/,而磁盘上的目录与 package.json 里的包名都是 code-runtime-worker-thread。两处不一致,以实际目录为准;这里只陈述差异。

第三步:还没起 worker 就可能失败

后端实现在 packages/code-runtime/code-runtime-worker-thread/src/index.tsrun() 进来先做两件事,两件都可能在 worker 出生前就把这次执行判掉,而且处置方式不同

一是校验绑定。命名空间的 global 必须匹配 ^[A-Za-z_][A-Za-z0-9_]*$,还不能落在 PORTABLE_RESERVED_WORDS 里——那是 ECMAScript 与 Python 保留字的并集,我们在 seam 源码里数出 73 条字面量、去重后 71 个。注释自述这么做的理由:可移植性承诺是「一个后端上合法的绑定列表,在每个后端上都合法」,所以 lambda 这种名字在 TypeScript 后端也一样拒绝。另有一张 RESERVED_BINDING_GLOBALS,只有五项:console__dsh_main____builtins____name____debug__。前者是本后端的日志捕获槽位,后四个是给 Python 侧留的;__debug__ 的注释单独解释了一句——CPython 会把裸的 __debug__ 编译成常量 True 并在编译期拒绝对该名字赋值,所以注入到这个名字上的全局「校验能过、在 Python 后端上却用不了」,而共享保留集存在的意义就是不让这种分裂发生。这类失败走的是 run() 抛异常,语义是「调用方用错了服务定义契约」。

二是类型剥离。程序会被包进一层壳再剥类型,壳的字面量就写在源码常量 STRIP_WRAP 里:前缀 async function __dsh_program__() {\n、后缀 \n}。剥完再按前缀长度切回来。用的是 node:modulestripTypeScriptTypes,只支持可擦除语法,所以模型写了 enumnamespace 会直接失败。这条路径的处置和上一条相反:它是程序自己的失败,以 kind: 'exception' 出现在结果字段里,并且注释明写「no worker ever spawns」。后端 README 的「已知限制」里还挂着一句:这一步依赖的是 Node 的实验 API,若行为变化,点名的替代品是 amaro 或 sucrase。

第四步:worker 是怎么起的

execute()new Worker(...) 的选项,是这篇里最该记住的几行:

选项含义
env{}空环境
execArgv[]不继承宿主的 loader 参数
resourceLimits.maxOldGenerationSizeMb配置项,默认 512老生代堆上限
stdout / stderrtrue管道兜底捕获

env: {} 那行的注释写得很直白:模型代码拿不到任何环境变量,比该仓库对「派生命令」要求的洗环境规则更严。execArgv: [] 的理由是空环境里的裸 isolate 满足不了宿主继承来的 loader hook。

每次执行都是一个全新的 worker,没有池化。后端 README 自述的收益是:程序的世界随 worker 一起死,跨执行的状态串味在结构上不可表示。

第五步:程序在里面看得到什么

worker 侧逻辑在同包的 src/bootstrap.ts。程序不是被 import 进去的,而是通过 AsyncFunction 构造出来的函数体,参数依次是各命名空间的 global、各错误类名、然后是 console,函数体前面还拼了 'use strict';。所以顶层 awaitreturn 能用,return 的值就是这次执行的完成值。

那个 console 是个 shim,只有五个方法:loginfowarnerrordebug——README 的已知限制里明说「deliberately not Node’s full console API」。渲染参数用 util.inspect,选项写死在 INSPECT_OPTIONSdepth: 4maxArrayLength: 100maxStringLength: 10_000。你在程序里打印一个深层嵌套对象,超过四层就不是你想看的那样了。

process.stdoutprocess.stderrwrite 槽位也被 patch 进同一个日志缓冲,注释说是为了让裸写入和 console 输出按发出顺序落在一起,而不是各自赛跑。宿主侧建 worker 时开的 stdout / stderr 管道,注释自述是兜底捕获:JS 层写入既然已被 patch 进有序缓冲,这两根管道正常情况下是安静的;真从管道里冒出来的(源码点名的是原生层写入)会被追加在正常日志之后。

第六步:两个预算,各管一半

预算全在 WorkerThreadCodeRuntime.Config 里,四项都有默认值:

字段默认值说明
computeMs60_000忙时预算,读 worker.performance.eventLoopUtilization() 实际测量到的活跃时间
maxWallMs600_000墙钟上限,不为任何事情暂停
maxOutputBytes67_108_864外层输出合并上限(README 标注为 64 MiB)
maxOldGenerationSizeMb512worker 堆上限

为什么要两个而不是一个,源码注释自述了:computeMs 计的是 worker 实际测量到的忙时,所以一个在等慢工具的程序不累计预算,而一个热循环无论有没有假装派发都照样累计;maxWallMs 兜住忙时看不见的情况——比如 await 一个永远没人 resolve 的 promise。两者都通向 worker.terminate(),它能把同步死循环一并终止。

有两个细节容易踩:一是忙时的采样间隔是源码常量 ELU_POLL_INTERVAL_MS = 25,注释写明这是内部节奏、刻意不做成配置,代价是超时判定最多可能超出一个采样间隔;二是 maxWallMs 在加载时就会被范围检查,超过 MAX_TIMER_DELAY_MS2_147_483_647,源码注释自述约合 24.9 天)会直接报错——因为 setTimeout 会把更大的延迟钳到 1 毫秒,一个本想写成「一个月上限」的值反而会让每次执行第一跳就超时。堆超了不算超时,worker 被 OOM 杀掉,结果是 kind: 'worker-exit'

第七步:失败一共分六类

packages/code-runtime/code-runtime/src/types.tsCodeRunFailure.kind 是个六值联合:'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'。文档强调这些是正交的独立结果:预算耗尽不是异常,中止不是超时,基底崩溃两者都不是。

还有一条契约反直觉但很关键:错误是结果上的一个字段,不是 run() 的 reject。只有服务定义契约用错了(比如对已 dispose 的运行时调 run、绑定名非法)才会 reject。所以你在消费侧写 try/catch 抓不到模型程序的失败,得去看 result.error

输出超限的处置也值得一提:OutputLedger 会保留装得下的那一段日志前缀,再给出固定诊断 outer output exceeded <N> bytes,而不是塞一段替代文本进返回值。README 里补了一句限制:这个 64 MiB 默认值是拒绝边界,不是可回收的存储——被运行时挡下的字节根本到不了 spill 层。

边界:仓库自己承认的部分

后端模块头一句话就把话说死了:containment, not a security boundary(是「容纳」,不是安全边界),信任姿态自述与 bash 等价,只是多了一层 bash 没有的容纳手段——独立 isolate、空环境、堆上限、硬终止。所以不要因为「跑在 worker 里」就当它被隔离好了。

已知限制里最该记住的一条:程序派生出的操作系统进程会在终止后存活worker.terminate() 只结束线程,弱于 bash-local 的进程组 kill;README 原文说孤儿进程清理是部署方的事,直到有 container 后端为止。另外还有一条:中间绑定值(工具调用的参数与返回)没有字节上限,一个程序可以用一个永远不会变成外层输出的值把进程或 worker 的内存吃光。

要调这四个上限,配置写在 cordis 配置的 code-runtime 那一行下面,字段名与上表一致,格式可参照后端 README 的 Config 段落与 examples/acp-agent/code-mode.cordis.ymlname: '@deepseek-ai/dsh-code-runtime-worker-thread' 的挂载写法。README 自述除此之外没有别的可调项。再说一次:开发者预览阶段,这些名字与默认值都可能变,动手前请以仓库当下的源码为准。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。