Skip to Content

第 1 章第一部分 它为什么长这样

主循环也只是配置里的一行

前言里那个需求:Claude Code 每次改完文件,把 diff 发到群里。在 Claude Code 里它只能写成一个 PostToolUse hook,由一个子进程从 stdin 读数据、解析、再决定发不发。hook 挂在主路径旁边,主路径本身不对外开放,换压缩策略、走远程沙箱、在对话里插卡片这类需求都做不了。

DeepSeek Harness(命令行叫 dsh,下文都这么写)的做法不一样。下面把它装上,让它跑一个任务,再看它在磁盘上留下了什么。


1.1默认形态是浏览器,不是终端

npx @deepseek-ai/dsh web

终端会打印一个带 ?token= 的地址,形如 http://127.0.0.1:3080/?token=...,默认浏览器通常会自动打开它。token 是这个进程临时生成的,浏览器拿它换一个签名 cookie,之后跳回不带参数的地址。所以直接在地址栏敲 http://127.0.0.1:3080 是进不去的,要用打印出来的那个链接。

打开的是一个本地 Web 应用。Claude Code 和 Codex 都从终端起家,dsh 出厂的默认形态是浏览器。出厂的 profile 有三个:web、headless、acp。profile 是一份启动配置,决定这次启动出来的是哪种产品形态,1.6 节会把它拆开看。

装不上的话dsh 依赖 node-pty,安装时要编译。Ubuntu 20.04 默认的 g++ 是 9.4,不认 -std=gnu++20,直接报错。装个 GCC 10 再指定编译器:CC=gcc-10 CXX=g++-10 npm i @deepseek-ai/dsh。我在 2026-10-28 装 0.2.1-rc.2 用了 7 分半,大部分时间花在编译上。

版本说明:本书的源码行号基于 commit 47f94385(0.1.0-rc.5)。本章的运行结果已按 npm 上的 0.2.1-rc.2 复核,只有 1.2 节的会话数据是 2026 年 8 月用 0.1.0-rc.6 在 DeepSeek 官方端点上采的。新旧两版的数字对照见 assets/ch01/environment.md。

1.2一句话的任务,磁盘上落下 43 行日志

准备一个只有两个文件的目录:

demo-project/ math.js package.json

跑一句话(headless 这个 profile 不起 Web 服务,跑完就退):

dsh --profile headless "用 glob 工具列出当前目录的文件,然后直接回答文件名"

输出:

当前目录下的文件有: - `math.js` - `package.json`

答案对了。下面看它留下的记录。

会话写在 $DSH_HOME/sessions/($DSH_HOME 默认是 ~/.dsh)下,按 workspace 路径分目录。rc.6 时文件叫 session.jsonl.zstd,0.2.1-rc.2 改成了带格式版本号的 session.v4.jsonl.zstd,旁边多了一个 session.lock。

这个文件直接 zstd -d 只能解出几百字节。它由多个 zstd 帧首尾相接组成:日志只追加不重写,每次刷盘压一帧接在末尾,要读全得逐帧解。这次任务是 14 帧,压缩后 24,226 字节,展开 72,975 字节。

展开是 43 行 JSON,第一行是会话头,其余 42 行是事件:

seq 事件 内容 --- ------------------------ -------------------------------------- 0 permission/preset 1 sandbox/mode 2 approval/policy 4 turn/start turn=1 6 step/start turn=1 step=1 7 user/message "用 glob 工具列出当前目录的文件..." 8 user/message "Current runtime context..." 9 user/message "<system-reminder>A skill..." 11 request/header deepseek-v4-flash · 25 个工具 13 session/title-llm-request 14 session/title 列出当前目录的文件名 70 assistant/message 模型决定调 glob 71 tool/call glob 72 tool/result isError=null 73 step/end step=1 74 step/start turn=1 step=2 119 assistant/message 给出答案 120 step/end step=2 121 turn/end turn=1 completed

表里只列了骨架。序号排到 121,文件却只有 43 行,因为 dsh 把连续的流式 chunk 折叠成一行写盘:tool-call-chunks、reasoning-chunks、text-chunks 这三种行各带一个起始序号 seq0 和一串时间差 dt,一行顶原来的几十条。完整产物见 assets/ch01/session-trace.jsonl。

表里出现了 dsh 的两个时间单位。step 是一次模型请求,加上这次响应引发的工具执行;turn 是把一次输入处理完的全过程,包含一到多个 step。 这次任务是一个 turn、两个 step:step 1 模型决定调 glob(seq 6 到 73),step 2 拿到工具结果给出答案(seq 74 到 120),整个任务用了 4.89 秒。

再看 seq 70 和 seq 119 两条 assistant/message 的 usage 字段:

  • step 1:inputTokens=11250、cacheReadTokens=0。冷启动,11,250 个 token 全部要算
  • step 2:inputTokens=85、cacheReadTokens=11264。11,264 个 token 命中缓存,新的只有 85 个

第二次请求带着第一次的全部内容(系统提示、25 个工具的 schema、消息、工具结果),几乎原样命中。前缀缓存匹配的是请求开头那段字节,没变的部分服务端不用重算,按缓存价计费。第 11 章会拆开算这笔账。

把 42 个事件按参与方摊开,就是图 1-1,括号里是每一跳落在哪个包:

图 1-1:一次 headless 任务的事件流(rc.6 官方端点实测,原始日志见 assets/ch01/session-trace.jsonl)

1.3日志里有三条我没说过的话

seq 7、8、9 是三条 user/message,可我只说了一句话。

另外两条,一条以「Current runtime context」开头,一条是 <system-reminder>,都是插件塞进去的。插件想让模型看见一段文字,没有走私有通道,而是在会话日志里落一条 user/message 事件。

模型每次看到的对话历史,都是从这份日志现算出来的。core/session 里有个 deriveMessages(),每次发请求前扫一遍日志,把该给模型看的事件折成消息数组。不是所有事件都会进去:request/header、session/title 只记账,user/message 和 assistant/message 才进模型。dsh 把进模型的那部分叫 surface。

这条规矩 dsh 写成了一个式子:model-visible ⟺ logged。模型能看见的东西,必须是日志里的一个事件。想给模型加一句话,就得新增一个事件,没有别的入口。

0.2.1-rc.2 把这条规矩执行得更彻底。我用 mock 端点在新版上重跑了同一个任务(只核日志结构,不看模型行为),日志里多了三种事件:system prompt 也落成了一条 system/message;用户输入先进 agent 的 inbox,记一条 agent/inbox/spliced,再落成 user/message;每次请求前多一条 request/context,记下 provider、模型和上下文窗口大小。

顺着这条规矩看日志开头,permission/preset、sandbox/mode、approval/policy 排在 seq 0、1、2,比我的第一句话还早。这次会话被允许做什么,先记账,再执行。

seq 13、14 是一次和主任务无关的模型调用:给会话生成标题。标题从截取首句得到的「用 glob 工具列出当前目录的文」变成了「列出当前目录的文件名」。这种边角小事也在日志里留了两条记录。两次调用发出去的东西差别很大:生成标题那次只带了 363 字节的 system prompt,一个工具都没带;主链路每次都带满 25 个工具。同一个模型服务的两种用法,记在同一份日志里。

1.4190 行配置里,主循环占一行

插件凭什么能往会话日志里写东西?在别的 agent 工具里,会话日志是产品的内部实现。

答案要从这条命令里找:

dsh --profile web --dump-config

它什么都不启动,只把这台机器上将要启动的插件树打印出来。0.2.1-rc.2 在我这里打印出 1,229 行 YAML、190 个插件行,耗时 0.6 秒(rc.6 时是 490 行、129 个插件行)。

输出是一份平铺的清单,一行一个插件。之所以还叫「树」,是因为插件之间有父子关系,第 5 章会讲这层结构。

截一段:

# == @deepseek-ai/dsh-base, patched by @deepseek-ai/dsh-web-app - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' disabled: true - id: tool-pwsh name: '@deepseek-ai/dsh-tool-pwsh' disabled: true

# == 开头的注释是 dump 自己加的,标明下面这段行来自哪个文件、被哪些层改过。这一句说明 tool-bash 是 dsh-base 放进来的,dsh-web-app 又把它改成了 disabled: true。原因写在 dsh-web-app 的 patch 文件注释里:Web 形态下每个会话各自挂载一个 agent preset,工具由 preset 带进来,宿主层的这几行就要关掉。

上层能覆盖下层的任何一行,dump 会标出是谁覆盖的。 web 这份输出里有 24 处这样的层标记。

另一个后面会反复出现的名字:cordis- 开头的包(比如 @deepseek-ai/cordis-plugin-timer)不是 dsh 的业务插件,它们来自 dsh 底下的插件框架 Cordis,第 3 章 3.6 节交代它的来历和代价。后面要改的 cordis.patch.yml,文件名也是从它来的。

190 行看着多,按职责归类:

# 内核与运行时 - id: timer # cordis-plugin-timer - id: hmr # 热重载 # 模型 - id: llm # 适配器注册表 - id: llm-pi-ai # 多 provider 适配器 - id: llm-retry # 重试策略 - id: agent-default-model config: provider: deepseek-official model: deepseek-flash # 会话 - id: session # 事件日志本体 - id: session-persistence-jsonl - id: session-title - id: session-projection ... # agent - id: agent # Agent 接口与注册表 - id: agent-loop # 主循环 - id: preset-standard ... # 工具 - id: tool-bash - id: tool-fs - id: tool-fs-search ... # 前端 - id: modules # 客户端插件加载器 - id: ui-conversation - id: ui-tool ...

llm 是一行,session 是一行,agent 是一行,主循环 agent-loop 也是一行。

「一切皆插件」说的就是这个:dsh 没有一个装着主循环的内核。主循环和文件搜索工具在这棵树上地位相同,都是可以换掉的一行。插件能往日志里写东西,是因为日志本身也是一个插件,谁都能用。

1.5四行 YAML 换掉整个模型接入

patch 在 dsh 里的意思是按 id 覆盖某一行,和 diff 补丁无关:不用管上下文,写清楚要覆盖哪一行、覆盖成什么样就行。

在 $DSH_HOME/cordis.patch.yml 里写四行:

- id: agent-default-model config: provider: mock model: mock-sonnet

再 dump:

- id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' config: provider: mock model: mock-sonnet

改掉了。没有编译,没有改源码,也没有 fork 任何包。

有个坑现在就要知道:patch 命中一行之后,整体替换它的 config,不做深合并。原来那行有三个字段,你的 patch 只写一个,另外两个会消失。我在 0.2.1-rc.2 上试过:给 session-title 的 patch 只写 maxTitleBytes,dump 出来原有的 fallbackMaxWords、fallbackMaxBytes 都没了。想改一个字段,要把想保留的字段一起写上。

配置里还能写表达式,dump 里有这样的行:

- id: session-persistence-jsonl config: root: !!js dshHomePath('sessions') - id: session-telemetry-otel config: mode: !!js process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'

!!js 标签的值在插件挂载时才求值,所以路径和开关能跟着环境走。它只在插件的 config 和 disabled 两个位置生效,别处一律当字面量。

第二行的默认值变过。rc.6 是 'DISABLED',0.2.1-rc.2 改成了 'FEEDBACK_ONLY':你在界面上提交反馈(文字反馈、评分、备注)时,截至那条反馈的会话日志会通过 OpenTelemetry 上传,默认目标是 https://dsh-otel-collector.deepseeksvc.com/v1/logs。在公司内部用,如果会话内容不能离开本机,启动前设 DSH_TELEMETRY_MODE=DISABLED,或者用 patch 把 mode 写死。

手上没有 DeepSeek 的 key,可以用配套仓库里的 examples/mock-llm-server,它把本机的 claude CLI 包装成 OpenAI 兼容接口。接上去要两条 patch,一条是上面那四行,另一条声明这个端点:

- id: llm-pi-ai config: providers: mock: api: openai-completions baseURL: http://127.0.0.1:3030/v1 apiKeyEnv: MOCK_API_KEY models: - id: mock-sonnet contextWindow: 200000

接一个新的模型端点,只改配置,不写代码。

用官方端点更简单。dsh-base 出厂就带 llm-deepseek 这一行,默认模型是 deepseek-flash(rc.6 时叫 deepseek-v4-flash),什么 patch 都不用写,把 DEEPSEEK_API_KEY 放进环境变量就行。1.2 节那次任务走的就是这条路。

本书的运行数据分两类:标了 via mock 的来自那个假端点(后端是 Claude,模型行为不代表 DeepSeek);没标的来自 DeepSeek 官方端点,包括 1.2 节这次任务和第 11 章的全部缓存实验。涉及缓存命中的数字,一律只用官方端点的。

1.6web 和 headless 只差第二层 bundle

前面用了两个 profile:web 和 headless。第三个是 acp(Agent Client Protocol,给编辑器集成用)。

它们不是三个程序。profile 就是 $DSH_HOME/profiles/<名字>/ 下的一个目录。0.2.1-rc.2 里这个目录有四个文件:package.json、cordis.yml、cordis.patch.yml、pnpm-workspace.yaml。web 的 package.json 全文:

{ "name": "dsh-profile-web", "private": true, "dependencies": {}, "dsh": { "profile": { "bundles": [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app" ] } } }

两层 bundle。bundle 是一个 npm 包,里面装的是一份 cordis.patch.yml,也就是若干个插件行,没有可执行代码。 profile 的 bundles 数组按声明顺序把它们逐层叠起来,叠出来的就是那棵树的地基。

dsh-base 是所有 profile 的第一层,模型、工具、持久化、沙箱、审批策略都在里面,patch 文件 529 行,贡献 96 个插件行。dsh-web-app 在上面加浏览器应用,patch 文件 589 行,新增 94 个插件行,另外改了 base 的 24 行。

headless 的第二层换成 dsh-headless,patch 文件只有 43 行,叠出来是 98 个插件行,一个没有服务器的一次性执行器。

另外两个文件:cordis.yml 是空列表,是合成的起点;cordis.patch.yml 是这个 profile 自己的覆盖层。pnpm-workspace.yaml 说明 profile 目录本身是一个 pnpm 工作区,第三方插件用 dsh plugin --profile web add <包名> 装进这里。

图 1-2:四层合成(bundle 层可以有多个;1.5 节改的是橙色那一层)

如图 1-2 所示,把 dsh-web-app 从 bundles 数组里删掉,Web UI 这一层就不会被叠上去。产品形态由这个数组决定。

--dump-config 和真正启动用的是同一个合成函数,打印出来的树就是实际会启动的树。想只看自己的覆盖层改了什么,可以把它和 --dump-default-config(不带用户层和 --patch 的版本)的输出做个 diff。

1.7浏览器里跑着 69 个插件

回到浏览器,看网页源码,搜 __DSH_BOOT__:

globalThis["__DSH_BOOT__"] = { "rev": "351d62836f4c", "release": { "version": "0.2.1-rc.2", "commit": "332895e" }, "entries": [ { "id": "@deepseek-ai/dsh-api-gateway", "url": "plugins/??@deepseek-ai/dsh-api-gateway/client.js&rev=a19dcc08b3fd", "rev": "a19dcc08b3fd", "inject": ["@deepseek-ai/dsh-typert-registry", "@deepseek-ai/dsh-client-connection"], "immediately": true }, ... ], ... }

entries 里有 69 个前端插件(rc.6 时是 38 个),每个都有自己的 URL、内容哈希 rev 和 inject 依赖声明。改一行前端代码,哈希就变,浏览器自然会拿到新的那份。

inject 这个词在 1.4 节那棵后端树里也有,它们是同一个机制。一个 npm 包可以有服务端和浏览器两半:服务端那半扫描已加载的插件,组出这张依赖图注入页面;浏览器那半按依赖顺序激活。

回到那 190 行:dsh-web-app 加的 94 行里,ui- 开头的就有 51 行。「一切皆插件」的「一切」,包括浏览器这一侧。

1.8hook 改不动的东西,在这里是一行配置

回到开头的问题。给 Claude Code 加东西只能挂 hook,是因为它的主路径不对外开放。dsh 没有内核:模型适配器、会话日志、主循环都是配置树上的一行,谁都能被替换,谁都能往日志里写事件。

开头那个「工具执行完之后把 diff 发到群里」的需求,在 dsh 里不用另起进程:往 tools/post-execute 上注册一个监听器,类型是通的,拿得到完整的执行结果,还能修改它。第 14 章会写一个真插件。

代价也看得到。我这台机器上,0.2.1-rc.2 的 web 从启动到打印出访问地址要 4 秒左右,进程常驻约 418 MB(rc.6 时是 1.34 秒、196 MB,插件行从 129 涨到了 190);改一个配置字段,要把整段 config 重写一遍。第 3 章讲这些代价从哪里来,第 18 章拿启动时间和内存算多租户部署的账。