第 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 章拿启动时间和内存算多租户部署的账。