DeepSeek Harness 在 PTC 模式下,模型给出一段调用工具的 TypeScript 程序,交给运行时执行。这段程序在 dsh 自带的 Node 运行时里,每次调用要花 110 毫秒左右;换成一个用 Go 写的 JavaScript 引擎 moejs,同样的程序只要 0.42 毫秒。

差 260 倍。

这个仓库 10 月 22 日才建,MIT 协议,目前 7 颗星、1 个 fork。它做的事情很具体:把 dsh 的 PTC 运行时从 Node 换成 moejs,让 sidecar 进程常驻,程序不再每次启动一个新进程。

对使用者来说,这意味着一件事——Agent 跑得更快了,而且快在每一次循环上,不是一次性的启动开销。

一、它到底在解决什么问题

打开网易新闻 查看精彩图片

(图注1:dsh-ptc-moejs 运行时分层结构。模型写程序,sidecar 常驻执行,程序对机器的操作全部走 dsh 工具流水线。)

先把 PTC 是什么说清楚,不然后面看不明白。

传统模式下,模型不写代码,只输出文本指令,Harness 把每条指令翻译成一次工具调用。模型看到工具返回的结果,再决定下一步。这中间往返多,模型容易跑偏。

PTC 模式换了个思路:让模型直接写出一段完整的程序,一次性把「查三个文件、跑一条命令、把结果汇总成一个对象」这套逻辑表达完,Harness 执行完把结果一次性交回去。程序里可以写循环、可以 await、可以判断。

但 PTC 有个性能问题。dsh 自带的 Node 运行时,每段程序都要启动一个 Node 进程。启动进程、初始化运行时、加载模块,这一整套开销下来,一段只调用三次工具的程序要 110 毫秒。而 Agent 的工作模式恰恰是「跑一段程序 → 拿结果 → 想一想 → 再跑一段」,这段程序的执行时间直接压在 Agent 的响应延迟上。

moejs 的思路是:sidecar 进程常驻不退出,每段程序进去之后拿一个全新的 moejs 运行时,用完就扔,下一段程序再拿一个新的。全局环境每次都是干净的,但进程启动开销被摊掉了。

仓库的 README 里给了一组实测:三次工具调用的程序,dsh-ptc-moejs 在 workspace-write 策略下每次调用 0.42 毫秒,从加载到第一个结果 16.4 毫秒;Node 运行时是 110.4 毫秒和 164.4 毫秒。

二、四层架构,每一层解决一个问题

这个插件的设计可以拆成四层,每一层都在回答一个具体问题。

第一层是模型。 在 PTC 模式下,模型直接产出可执行的 TypeScript 程序。这不是插件加的功能,是 dsh 的 PTC 模式本身提供的。插件负责的是「这段程序谁来跑」。

第二层是 sidecar。 一个用 Go 写的自包含二进制,CGO_ENABLED=0 编译,通过 npm 的可选依赖分发给各个平台——darwin-arm64、darwin-x64、linux-arm64、linux-x64、win32-x64 共五个。sidecar 常驻在内存里,每段程序进来拿一个全新的 moejs 运行时。

第三层是 dsh 的工具流水线。 这是最关键的一层。程序不能自己直接读文件、跑命令,它只能调工具,dsh 按每个工具自己的策略、审批和沙箱去执行这些调用。也就是说,moejs 换掉的只是「执行引擎」,安全模型一点没动。

第四层是沙箱。 sidecar 受的限制和 Node 运行时的程序进程完全一致:Linux 上是 bubblewrap 或 Landlock,macOS 上是 Seatbelt,Windows 上是受限令牌 runner。受限模式在没法限制时直接失败,报 sandbox-unavailable。

有一个细节值得单独提:dsh 传给 sidecar 的环境变量只保留 PATH、PATHEXT、SYSTEMROOT、WINDIR、TEMP、TMP 六个,API Key 根本进不到 sidecar 里。sidecar 自己读取的文件只有程序 import 的那些,读取时同样受沙箱约束。

三、装上它,五分钟跑通

安装用 dsh 自带的插件管理器:

dsh plugin --profile web add dsh-ptc-moejs

这里的 web 换成你自己用的 profile 名。因为 sidecar 是可选依赖,安装时需要启用可选依赖。

装完之后 profile 里会多出一行处于禁用状态的 ptc-moejs。要启用它,得往 profile 的 cordis.patch.yml 里加一段补丁,路径是 ~/.dsh/profiles//cordis.patch.yml(设了 $DSH_HOME 就在它下面的同一路径):

【用 moejs 替换 Node PTC 运行时。】 - id: ptc-runtime disabled: true - id: ptc-moejs disabled: false 【打开 PTC 模式。】 - id: tools config: mode: ptc

三行干三件事:关掉原来的 Node 运行时、打开 moejs、把 PTC 模式打开。

装完可以用 dsh --profile web --dump-config 确认组合后的配置行。在 web 和 headless profile 里,也可以用环境变量 DSH_TOOLS_MODE=ptc 在单次启动时打开 PTC 模式,代替配置文件里 tools 那一项——想临时试一下不用改文件时挺方便。

想换回 Node 运行时,把前两项删掉就行。

四、程序长什么样,怎么写

程序是「一个 async 函数的函数体」,顶层可以直接用 await 和 return。类型标注会被擦掉,但 enum、带值的 namespace、参数属性都是语法错误。

最简单的一段:

const head = await tools.bash({ description: "Read the last commit", command: "git log -1 --oneline" }); return { head: head.stdout?.text };

程序能看到的绑定有四样:tools(工具命名空间)、ToolCallError(错误类,带 toolName 属性)、console(带 log、info、warn、error、debug)、以及 setTimeout、setInterval、setImmediate 这几个全局定时器。

除了标准库,程序还能用五个 Node 模块:node:path、node:util、node:timers、node:timers/promises、node:vm。不写 node: 前缀也行。

导入工作目录下的模块:

const { summarize } = await import("./scripts/summarize.ts"); return await summarize(tools, "docs/");

被导入的文件由 sidecar 自己读取,读取时受沙箱限制。同一份内容只编译一次,之后再导入不会重新编译。

定时器这块用起来和 Node 几乎一样,可以做轮询和退避重试:

const { setTimeout: sleep } = await import("node:timers/promises"); for (let attempt = 1; attempt <= 10; attempt++) { const status = await tools.bash({ description: "Check the build", command: "cat build/status" }); if (status.stdout?.text.trim() === "done") return "built"; await sleep(2000); } return "still building";

定时器回调在程序自己的运行时里执行,所以算进内存预算和截止时间。程序返回时还没触发的定时器不会再执行。

五、值跑起来是什么速度

打开网易新闻 查看精彩图片

(图注2:每次程序调用的耗时对比。moejs 0.42 毫秒对 Node 运行时的 110 毫秒,长对话里进程数从 120 降到 3。)

README 里给了一组 40 次 run_code 的长对话实测(headless dsh,脚本应答的模型,程序一共调用 90 次工具):

指标

Node 运行时

moejs

run_code 耗时

8.6 秒

3.6 秒

整段对话墙钟时间

10.1 秒

5.0 秒

PTC 运行时进程数(含 bubblewrap)

120

3

PTC 运行时内存峰值

34.0 MiB

18.5 MiB

进程数从 120 降到 3,这个变化比时间数字更直观——Node 运行时每段程序一个进程,程序结束就退出;moejs 的 sidecar 一直活着,整段对话共享。

仓库还按「一天的程序数」算了笔账,假设每 10 秒跑一段程序:一天跑 200 段,能少等 25 秒;跑 1000 段(约 3 小时工作),少等 2.1 分钟;一个 Agent 连续工作 8 小时(2880 段),少等 6.0 分钟,CPU 时间少省 3.9 分钟。

要估自己的情况,把一天的程序数乘以 125 毫秒就行。

六、什么时候该用,什么时候别用

适合的场景: 你的 Agent 大量使用 PTC 模式,程序短小、执行频繁——比如批量读文件做内容分析、循环调工具收集数据、反复迭代跑同一套流程。这类场景下每次省下的 100 多毫秒会累积成体感上的差别。

不适合的场景: 程序要用 require、process、Buffer、fs、npm 包,或者其他 Node API。moejs 的标准库小得多,这些都拿不到。程序要读文件内容,得调用 read 工具;这个 profile 就继续用 dsh 的 Node 运行时。

还有一条要注意:每次调用都从新的运行时开始。想在多次调用之间保留变量的模式(比如 REPL 式的持续状态),README 说计划在以后的版本加入,目前没有。

七、几个必须知道的限制

沙箱在容器里可能不可用。 受限模式需要本机有 runner 能限制 sidecar。在 bubblewrap 和 Landlock 都用不了的容器里,运行会以 sandbox-unavailable 失败,这时得用 danger-full-access。同理,如果 dsh 是通过 SSH 连到另一台机器执行任务的,受限模式也会失败——sidecar 总是运行在跑 dsh 的那台机器上。

内存预算算的是分配量,垃圾也算。 默认 512 MiB。反复创建又丢弃大量数据的程序,可能在存活数据还没到预算前就被停掉。

导入限制不少。 工作目录之外的路径、指向工作目录之外的符号链接、包名、JSON 文件都不行。一次运行最多导入 256 个文件,每个最大 1 MiB,合计最大 8 MiB。moejs 没有 import attributes,所以没有 JSON 模块。

返回值必须是「无损 JSON」。 普通对象、数组、字符串、有限数字、布尔值、null 都可以;出现 undefined、函数、BigInt、循环引用、NaN、Infinity、-0、Date、Map 或类实例,运行会以 invalid-output 失败,消息里会指出第一个这样的成员路径,比如 a BigInt at $.items[3].size。

中断不能打断编译。 moejs 的中断作用于正在运行的 JavaScript,编译一旦开始会一直进行到结束。解析很慢的程序到截止时间时,dsh 一侧照常结束这次运行,sidecar 等解析返回后把它丢弃。

平台覆盖不全。 CI 每次推送都在 Linux x64、Linux arm64、macOS arm64 和 Windows x64 上跑测试。macOS x64 还没测过,虽然它的程序由同一份源码交叉编译。

八、许可证

MIT。

各平台包里的常驻进程二进制还包含 moejs 本身(Apache License 2.0)、Go 标准库(BSD 3-Clause),以及从 Node.js 移植来的 path 模块和 util.inspect(MIT License)。这些许可证的原文在仓库的 THIRD_PARTY_NOTICES.md 里,随每个平台包一起发布。

九、适合谁

如果你正在用 DeepSeek Harness 并且跑在 PTC 模式,这个插件值得试——7 颗星的新仓,但代码里带着完整的 benchmark、e2e 对比脚本和 CI 矩阵,不是玩具。

反过来,如果你只在 TUI 里做简单问答,或者程序重度依赖 npm 包生态,换过去反而会撞墙。

仓库地址是 github.com/Calcium-Ion/dsh-ptc-moejs,npm 包名 dsh-ptc-moejs。