ryos

解读 DeepSeek Harness

DSH(DeepSeek Harness)不是一个写死工作流的 Agent 应用,而是一个由 Cordis 插件微内核组合出来的 Agent 运行时——连 Agent Loop 本身也是可替换的插件。本文聚焦三个核心要点:

  1. 产品是组合出来的——配置合成,不是写死入口。
  2. Session 是唯一真相——其余一切(模型历史、界面、回放)都是投影。
  3. 扩展发生在 Loop 之外——新增能力时,先找最小充分的能力缝,而不是修改核心。

0. 开场

先把定义摆正。模型本身只做一件事:读取上下文,输出文本或调用工具的意图。Agent harness 是围在模型外面、把意图变成真实动作的运行时:它执行工具、管理上下文与历史、控制权限、持久化会话并提供交互界面。Claude Code、Codex CLI 都属于 harness;DSH 则是 DeepSeek 的 harness 项目。打个比方:模型是发动机,harness 是整车。

用户输入 ──▶ 组装上下文(历史 · 工具清单 · 系统提示词)
                │ 一次模型请求

             模型(发动机):读上下文 → 文本,或「我要调工具」
                │ 工具意图

             执行真实动作(权限审批 · 沙箱 · 工具执行)
                │ 结果写回历史 → 组装下一次请求(循环)
                └─▶ 不再需要工具时:文本回复给用户

模型可以看作一个无状态函数:输入上下文,输出意图。谁来组装上下文、兑现意图、记录结果,并负责 Session 持久化、界面和会话恢复?这一整圈就是 harness。DSH 要回答的,正是这套运行时应该用什么结构组织。

一个 agent harness 要同时管理模型适配、工具执行、权限审批、沙箱、上下文窗口、持久化、界面和子 Agent。如果这些都写进一个中央 Agent 类,新增能力就要修改核心,替换实现就要整体重构。

DSH 的回答是:核心只保留组合机制,所有能力——包括 Agent Loop 自己——都做成插件。 下面先看 Cordis 解决什么问题,再看 DSH 如何用它组装 harness。

先看全景图。第 1 节讲内核,第 2 节逐块展开:

┌─ Host 平面:Node 进程 · 一棵 Cordis 插件树 ────────────┐   ┌─ Web Client 平面 ─────────┐
│ 核心服务(注册表):llm · sessions · tools             │   │ 浏览器里的第二棵 Cordis 树  │
│                    agents · systemPrompt              │◀─▶│ 界面 / 视图插件            │
│ Agent Loop(也只是插件) · 策略插件 · provider          │   │ 会话投影(事件流 / RPC)   │
│ Agent 平面:作用域 ×N(agent → preset → global)        │   └──────────────────────────┘
│ Session 日志:append-only · 唯一真相                    │
└───────────────────────────────────────────────────────┘
          两棵树都长在同一个 Cordis 微内核上(约 2700 行 · vendored)

系统里没有中央 Agent 类:五个核心服务、Loop、策略和 provider 都是同一内核上的插件;每个会话对应树上的一个作用域;浏览器界面是第二棵树;所有事实最终写入 Session 日志。

1. Cordis:为可逆性设计的插件微内核

1.1 真正要解决的问题:不是「能加载插件」,而是「能完整拿出去」

大多数插件系统只解决「往里加」。Cordis 更重视反方向:插件可以被完整移除、替换和重建,依赖它的其它部分也会恢复一致。

为什么这是 harness 的命门:

只实现注册、不实现撤销,会直接破坏这三种能力。因此,「可逆」在 Cordis 里不是清理风格,而是第一原则。

边界也要说清楚:Cordis 撤销的是进程内的注册与副作用。已经写入的文件、发出的请求和记录的持久事件不在撤销范围内;它们分别属于 Session 和沙箱要处理的问题,第 2 节会继续展开。

1.2 微内核:机制留在内核,策略放到插件

这里的「机制」包括五件事:生命周期(安装、激活、回滚、卸载)、依赖注入(能力的声明与解析)、作用域(谁能看见谁)、事件(插件间的松耦合协作)和可逆副作用(一切贡献都可撤销)。

其余都是「策略」。内核不知道什么是 LLM、工具、Session 或权限;它们都是插件,用同样的五种机制表达自己。判断一项能力该进入内核还是成为插件,只需看它是否属于「所有插件都需要的组合语义」。

这个内核约 2700 行源码,直接 vendored 进仓库,因此 harness 完全拥有自己的框架层。插件安装时产生的每一项副作用都会记在 Fiber 账本上,卸载时再逐笔撤销。1.1 的三种能力都建立在这条「可逆」语义上。

1.3 三个够用的概念

概念 直白地说
Fiber / Effect 插件安装时注册监听、提供服务或启动 watcher,Cordis 都会记下一笔 effect;这些记录挂在本次安装对应的 Fiber 上。卸载插件时逐笔撤销,系统回到安装前的状态
Service + inject 插件不直接 import 其他插件,而是声明「我需要一个名为 tools 的能力」。能力存在时激活,被卸载或替换时停用,新实现出现后自动恢复。插件只依赖契约,不关心由谁提供,因此替换实现不需要重构
Event waterfall 事件链由多层监听器包裹而成;每一层都可以调用 next 放行,或不调用来拦截。要在工具执行前加入审批,只需在链上增加一层

用一个审批插件,就能同时看到这三个概念:

// 审批插件:一段代码覆盖三个概念
export const name = 'approval'
export const inject = ['tools']            // ② 声明依赖:ctx.tools 在我才激活
                                           //    至于 tools 由谁实现,我不关心

export function apply(ctx: Context) {
  ctx.tools.register({                     // ① 这笔贡献记进 Fiber 账本
    name: 'ask_user', /* 征询用户的工具,细节略 */
  })

  ctx.on('tools/pre-execute', (exec, next) => {   // ③ 往工具执行链上包一层
    if (!approved(exec)) return            //    不调 next:拦下,执行器不会被叫到
    return next()                          //    调 next:放行,交给链上的下一层
  })                                       // ① 这个监听同样记账,卸载自动摘除

  ctx.effect(() => {                       // ① 外部资源也显式记账
    const timer = startApprovalTimeout()
    return () => timer.clear()             //    卸载 / 热重载共用这条清理路径
  })
}

这段代码里,inject 让插件只依赖 tools 契约;provider 热替换时,它会先停用,再随新实现恢复。两项注册和一个 effect 都记入 Fiber 账本,卸载时照账撤销。pre-execute 监听器则通过是否调用 next 决定放行或拦截。

2. DSH:用 Cordis 组装 Agent Harness

2.0 先看一次请求怎么流过系统

先纵向看一次请求如何流过系统,再横向拆解各个组件。

图里有两个后文会反复使用的概念,口径与官方 docs/architecture.zh.md 一致:step 是一次模型请求及其调用的全部工具。模型在一条回复里调用三次工具,仍属于同一个 step;工具执行后如果还需要再次请求模型,才进入下一个 step。turn 在领取一条外部输入时开始,在没有待处理工作时结束,可以包含零个或多个 step。若 pre-step 准入被拒绝,日志里仍会留下一个不含 step 的 turn。

用户输入 ──▶ inbox(内容以持久事件记入 Session 日志)
              │ 空闲时取件,开启一个 turn

        ┌── step ────────────────────────────────┐
        │ pre-step 准入检查 → 冻结并重建本步请求      │
        │ (system prompt + 历史投影 + 上下文材料)   │
        │         ▼                              │
        │ LLM 流式输出 ──▶ 文本 / 工具调用           │
        │         ▼                              │
        │ 工具经策略链执行(可并发,按模型顺序提交)     │
        └── 还有工具调用?是 → 下一个 step ──────────┘

      每一步事实 append 进 Session ──▶ 投影到 Web / CLI

图上的 prompt、LLM 路由、工具管线、Session,甚至驱动循环的 Loop,都是独立插件。接下来从三个要点解释这张图;第三个要点分两步展开:2.3 先说明 Loop 没有特权,2.4 再用新增能力检验这一点。

2.1 要点一:产品是组合出来的

先从三个协作平面说起。 当你在浏览器里和 agent 对话,眼前的界面属于 Web Client 平面;背后的 Node 进程负责模型适配、持久化和各类注册表,属于 Host 平面;每个会话各自拥有工具、提示词和策略作用域,构成 Agent 平面。

三个平面都由 Cordis 组织。Web Client 是运行在浏览器里的第二棵 Cordis 树,并非一层只会调用 REST 的前端。Agent 平面则是 Host 树上的作用域,不是独立进程或线程。每打开一个会话,就增加一个作用域,容纳该会话的工具、提示词和策略;本层找不到的能力,会沿 agent → preset → global 的父链向上查找。会话视图则是对应作用域的投影。

产品是配置合成的结果。 系统没有写死的入口文件。启动时从空插件列表开始,依次应用四层 patch:

空插件列表
 └─ bundle patch(发布的产品组合,如 dsh-base + dsh-web-app)
     └─ profile patch(本机具名组装:$DSH_HOME/profiles/<name>)
         └─ home patch(本机全局覆盖:$DSH_HOME/cordis.patch.yml)
             └─ CLI --patch(仅本次执行的临时覆盖)
                 = 最终 Cordis 插件树

web 和 headless 两个产品的差别,只是 bundle 层引用的组合不同。想看合成结果:dsh --dump-config

226 个 package 不是目录审美,而是能力缝(capability seam)的结果。 DSH 源码的 packages/ 目录包含 226 个正式 package,分属 50 个一级分组。拆包沿着能力缝进行:系统以 ctx.<key> 暴露稳定契约,既允许整体替换背后的实现,也允许第三方在此贡献行为。一个完整能力通常拆成三种角色:

角色 ctx.subprocess(进程执行) ctx.fs(文件系统)
definition subprocess 契约包:进程怎么起、怎么管 fs 契约包:文件怎么读写、watch
provider subprocess-local / subprocess-e2b fs-local / fs-sandbox / fs-e2b
consumer bash、terminal、lsp(只认契约,不认实现) tool-fs(模型的文件工具)

以执行环境的两条能力缝为例:bash、terminal、lsp 消费 ctx.subprocess,并不知道进程实际运行在哪里;模型的文件工具则消费 ctx.fs。把这两个 provider 替换为远程沙箱实现时,所有消费方都无需修改,2.4 会继续验证这一点。LLM、权限、Session 检索等能力也采用相同拆法。包边界就是可以独立演进、独立装配、拥有独立生命周期的角色边界。

这种组装并不意味着每个会话都要重复安装一整套能力。工具、提示词和策略的公共配方称为 preset:同一个 preset 在进程中只安装一次,由所有使用它的会话共享;会话作用域只保存个性化部分。因此,多开会话的额外成本很低。

2.2 要点二:Session 是唯一真相

Session.append() 是事实提交点。 系统先提交事件,再通知观察者;观察者失败也不会回滚事实。模型看到的对话历史、Web 界面、回放和审计,都是从同一份 append-only 日志派生的投影,系统没有第二本账。

官方架构文档把它定义为一等不变量:「模型可见即已记录」——进入模型请求的一切都必须能从日志重建。 运行时断言会检查 Loop 生成的请求是否冻结、是否能从日志独立重建。本节的 compaction 和 2.3 的「每步冻结重建」,都是这条不变量的推论。

执行路径 ──append()──▶ Session 日志(append-only,唯一真相)
                          │ 提交之后才通知
               ┌──────────┼──────────┐
            模型历史投影   Web 界面    replay / 审计
           (派生视图,谁也不是第二本真相)

下面用一组简化的事件名说明「日志」与「投影」:

Session 日志(append-only)
 #1 user/message       「帮我把测试修绿」
 #2 step/start
 #3 tool/call·result    bash: npm test → 2 failed
 #4 assistant/message  「定位到两处断言……」
 #5 tool/call·result    edit: 修复断言
 #6 compaction          历史过长时写入显式替换记录(不是删除)

同一份日志派生三种视图:
 模型历史  → 折叠成下一步请求的 messages 数组
 Web 界面  → 渲染成气泡、工具卡片和进度
 replay   → 逐事件重放,重建每一步请求,用于测试与审计

工具结果是事实,compaction 也同样是一条事实。投影可以随时重算,事实不会被改写。

三个推论,各一句话:

Session 恢复的是可观察事实,无法回滚已经发生的文件写入、进程操作和外部 API 副作用。真相日志负责「记得住」,不负责「收得回」。

Session 日志会持续增长,但模型的上下文窗口有限,token 也有成本;而模型在当前 step 看到什么,会直接影响它的行为。因此,上下文管理本身就是 harness 的一等问题。DSH 将它拆成五类协作能力,而不是一条所有请求都必须经过的中央流水线:

能力 做法
token meter 观测:估算当前请求与会话的 token 消耗,只测量,不决定删什么
compaction 压缩:历史过长时把旧事件折叠成一条显式的替换记录,模型历史变短,日志一条不删
spill 外溢:把过大的工具输出存入外部存储,内联只保留有界预览和取回定位符,模型需要时再取
attachment 附件:图片等二进制走内容寻址的持久存储,并按模型能力执行图片预算
context 插件 注入:@file 文件引用、时间、跨会话引用等材料,在请求装配时补进模型可见上下文

为了把不断增长的日志装进有限窗口,折叠、外溢、预算和注入四条路径分别处理不同材料,近期事件则原样进入;token meter 只负责观测。五类能力都是可按需装配的独立插件。

一个反例更能说明边界:消息上的点赞、评价等可编辑反馈被刻意放在 Session 之外,存入旁路存储。因为它们可变、可编辑,不适合进入不可变的真相日志。

2.3 要点三·前提:Agent Loop 是普通插件,不特权

Loop 是一个声明注入五个服务的普通插件,其他插件也可以做同样的事。agents 注册表本身不包含循环;Loop 启动时,才把自己注册为 agent 工厂。没有安装 Loop 插件时,创建 agent 会直接报错 no agent factory registered (load an agent-loop plugin)。因此,「连 Loop 都可替换」不是修辞,而是这个注册缝的字面事实。

这五个服务就是 harness 的核心组件清单,各管一摊:

Loop 注入的服务 它管什么
agents 活跃 Agent 的注册、查找与生命周期。inbox 挂在这里,三个投递动作落在 next-turn / next-step 两条队列上:followup 排进下一轮;steer 插入当前轮的下一步并唤醒;inject 放进下一步但不唤醒。whenIdle 只等待 agent 空闲,不投递内容。Loop 从这里领取下一项工作
sessions Session 日志的打开、追加与读取:append() 是事实提交点(2.2 的主角),投影、fork、崩溃后恢复都从这份日志派生
llm 模型 adapter 的注册与路由。各家模型并列注册并声明自身能力,如多模态和推理档位;请求会绑定到具体 adapter,流式输出、重试与中断恢复都在这一层收敛
tools 工具定义注册表与执行策略链。它提供 pre-execute / execute / post-execute 三个 waterfall 扩展点,以及一道只能说「不」的 guard 闸;审批、权限、超时和输出外溢都是链上的策略。工具可以并发执行,但结果按模型顺序提交
systemPrompt 提示词片段的注册与装配:各插件贡献自己的片段,可排序、可按作用域覆盖,每一步请求装配时拼成最终 system prompt——提示词也不是写死的一整块

Loop 只负责推进 turn 和 step。每个 step 开始前,pre-step 会执行准入检查;外部策略可以在这里拒绝继续,日志则保留一个不含 step 的 turn。每次调用模型前,请求都会冻结并重建,这是 2.2 中「模型可见即已记录」不变量的执行侧。

工具执行是一条策略链,而非单次函数调用。tools/pre-executetools/executetools/post-execute 三个 waterfall 之间,还有一道 guard 闸。guard 是签名为 (exec) => string | undefined 的同步函数,只能返回拒绝理由;类型本身就限定了「策略可以把允许变为拒绝,却不能反向放行」。工具可以并发执行,但结果仍按模型请求顺序提交,从而保持事实顺序稳定。

用一条危险命令的旅程把这条链走一遍(安全与人机协作平面全在这里,都是链上的策略插件):

模型发起 bash: rm -rf build/(tool/call 事实先落日志)
 → permission preset:用户面的一档选择,捆绑「沙箱模式 × 审批政策」两个旋钮
   (如 workspace-write = 工作区内可写 + 越界要问)
 → tools/pre-execute 按政策裁决:allow / deny / ask
 → ask ⇒ 弹给用户一次性审批(词汇表里只有 allowed-once,没有「记住这类命令」;
   拒绝、取消同样作为事实 append)
 → 沙箱强制执行:宿主机没有可用的沙箱后端,就拒绝以非受限方式运行(fail-closed)
 → 执行,结果 append 进 Session

每一层都是策略链上的插件,Loop 对此全程无感。凭据也遵循同样的边界:配置只保存引用,不保存机密本身。

Code Mode,也就是 PTC(Programmatic Tool Calling,程序化工具调用),允许模型编写一段程序来批量调用工具,而不是逐个发起调用。比如统计 300 个文件的词数:逐个调用需要 300 次模型往返,每份文件全文都进入上下文,再由模型汇总;PTC 则让模型写一个循环交给 harness 执行,一次往返只取回汇总表,中间结果留在程序中。关键在于,程序内部的每次工具调用仍会经过完整策略链,没有旁路。

逐个工具调用                            PTC / Code Mode(程序化工具调用)
模型 ⇄ 工具链:① read(a) → a 全文        模型 ──一段程序(唯一一次往返)──▶ 程序执行器
             ② read(b) → b 全文           for f in [a, b, c]:
             ③ read(c) → c 全文             read(f)   // 每次照走策略链
3 次模型往返 · 中间结果全进上下文        ◀── 只回汇总结果:1 次往返 · 中间结果留在程序里

2.4 要点三·检验:加能力,不改 Loop

2.3 已经说明,Loop 只是注入五个服务的普通插件。下面用两个代表性案例检验这个结论:DSH 后来新增的能力都挂在这五个服务的扩展点上,无需修改 Agent Loop。

E2B 远程沙箱:兑现 2.1 的能力缝。 要让所有可变操作进入远程沙箱,DSH 只需替换 ctx.fsctx.subprocess 两条能力缝的 provider,并增加一个 ctx.e2b 管理沙箱生命周期:

换掉 ctx.fs、ctx.subprocess 两条缝的 provider(fs-local → fs-e2b,subprocess-local → subprocess-e2b)
  ⇒ Bash、Terminal、LSP 全部工具的执行世界整体搬进远程沙箱
  ⇒ 零 fork:工具与契约一行不改,没有任何工具需要「远程版」

安装 E2B provider 后,agent 的每条 bash 命令和每次文件读写都发生在云端沙箱,本机文件系统不再属于它的执行环境。这就是 2.1 中 definition / provider / consumer 拆分带来的收益。

Goal:把长任务放在 Loop 外推进。 任务编排能力(Goal / Jobs / Workflow / Schedule)可以按时间语义分为三类,而且全部位于 Loop 之外:

同 Session 内推进 派生工作单元 到点再醒
goal / todo / plan jobs / workflow / subagent schedule

以 Goal 为例:用户输入 /goal 把测试全修绿 后,如果模型一轮没有完成任务,agent 一空闲,驱动器就向 inbox 排入一条「继续推进目标」的 follow-up;pre-step 再校验目标是否仍然有效。这个过程持续到目标 complete 或用户叫停,而 Loop 对「目标」一无所知。

Goal 驱动器(普通插件)──排 follow-up:继续推进目标──▶ inbox
      ▲                                              │ 空闲时取件,开新 turn
「空闲」事件唤醒                                        ▼
      └──────── agent 空闲 ◀── Agent Loop 跑一轮 turn
                               (pre-step 先校验:目标还有效吗?无效 → 拒绝,留下没有 step 的 turn)
complete 或用户喊停 → 不再排件,循环自然停;/goal 创建、每轮推进、complete——每个 phase 都 append 进 Session

Goal 驱动器只做三件事:监听「空闲」、向 inbox 排入 follow-up、在 pre-step 增加有效性校验。它使用的都是既有扩展点;Loop 仍然照常领取输入并运行 turn。Jobs、Workflow、Schedule 也通过同一条 inbox 路径排入工作,无需修改 Loop。

Goal 还守住了一条重要边界:持久 phase 记录目标发生过什么,写入 Session 日志,因此可以回放和 fork;进程内 activation 则表示当前是否允许自动续跑。恢复或 fork 一个带活跃目标的会话时,activation 一律从 disarmed 开始,不会自行开工。其他任务编排能力同理:派生工作完成后把结果作为事实交回 owner 会话,并在空闲时唤醒;schedule 也不是新的执行形态,只是一条发生在未来的 follow-up。

2.5 第二部分小结

第二部分可以收拢成一句话:回到 2.0 的流程图,每个环节都能回答三个问题——它是什么插件,归哪个核心服务或扩展点管理,以及如何在不修改消费方的前提下替换 provider。 这就是「用 Cordis 组装 harness」的含义。

三个要点分别守住一条边界:组合发生在配置层,产品由 patch 合成(2.1);事实只有一份,历史、界面和回放都是 Session 的投影(2.2);扩展发生在 Loop 之外,新增能力挂到既有扩展点(2.3–2.4)。因此,修改系统时首先要选对事件域:持久事实进入 Session 事件,实时协调使用 Agent 事件,策略决策挂到对应的能力事件。

3. 自定义插件

两个可带走的判断工具:

① 写插件前先做五个判断所有权(能力属于 Host、preset 还是单个 Agent)、时间语义(产出的是持久事实、实时协调还是策略决策)、能力缝(接入哪个 ctx.<key>,还是新开一条)、生命周期(与谁一起装卸,热重载半径多大)、交付方式(本地 --patch 是否足够,还是需要打成 Bundle)。这些判断比搭目录更重要;判断正确后,插件入口通常只是一小段 apply(ctx, config)

② 沿扩展阶梯,从最轻的方式开始。 DSH 将新增能力的方式从轻到重排成一条阶梯:越靠左,改动越小;只有当前一级无法满足需求时,才向上走一级。前文的案例覆盖了前三个层级:

① 配置 / 普通插件          ② 策略钩子              ③ 换 provider / 新开缝     ④ 更重的形态
   只用现成扩展点             往既有的链上加一层        整体替换一层实现            桥接外部生态(MCP·skills)
   例:word-count 工具        例:2.3 的权限与审批      例:2.4 的 E2B 沙箱        动态插件(agent 改自己)
       Goal 驱动器                                                              浏览器端插件 · 外部协议 · 子 Agent
────────────────────────── 改动越来越大 · 需要的场合越来越少 ──────────────────────────▶
永远先选能满足需求的最低一级;不够用了,才上一级

(官方完整分 11 级 L0–L10;日常扩展绝大多数停在第 ① 级。)

第 ④ 级有两种形态值得单独说明。桥接外部生态:MCP 工具、Claude Code / Codex 钩子和 skills 会在系统边界被翻译成 DSH 内部的标准能力,进入系统后与本地插件共用执行管线,不另开旁路。动态插件:agent 可以在运行时定义、运行和撤销自己的插件,这是对第 1 节「可逆性」的极端检验;安装出错时,系统可以按 Fiber 账本整体撤销。

③ 一个可运行的最小插件

// dsh-word-count/lib/index.ts(导入行省略)
export const name = 'word-count'
export const inject = ['tools']              // ctx.tools 存在才激活

export interface Config { maxBytes: number }
export const Config: Schema<Config> = Schema.object({
  maxBytes: Schema.number().default(1_000_000),  // 配置在加载阶段校验并填默认值
})

export function apply(ctx: Context, config: Config) {
  ctx.tools.register({
    name: 'word_count',
    description: '统计一个文件的词数',
    /* 输入 schema、execute 返回 canonical value(略) */
  })                                          // 归属当前 Fiber,卸载时自动撤销

  ctx.effect(() => {                          // 外部资源显式包装为 effect
    const watcher = startWatcher()
    return () => watcher.close()              // 热更新 / 卸载 / 回滚共用这条清理路径
  })
}

交付时,可以包一层 cordis.patch.yml 变成可安装 Bundle,也可以在本地用 --patch 挂进任意 profile;两种方式都无需修改 DSH 源码。

4. 收尾

最后回到三个要点:产品是组合出来的;Session 是唯一真相;扩展发生在 Loop 之外。

这就是 DSH 对开场问题的回答:面对一个 harness 需要管理的众多能力,它没有继续扩大中央 Agent 类,而是把「组合」本身做成内核,让包括 Loop 在内的每种能力都以插件的身份长在树上。


本文研究对象为 deepseek-ai/deepseek-harness,机制描述基于 dsh-v0.1.0-rc.8(部分章节按 v0.1.1-rc.2 核对)。

所有文章