DeepSeek Harness 完全教程:从零上手、架构解析与横向对比

Cosolar 30 阅读 AI Agent架构与设计

DeepSeek Harness 完全教程:从零上手、架构解析与横向对比

本文结合 deepseek-harness 仓库源码与官方 docs/cordis-tutorial/ 教程编写,并在最后一章与 Claude Code、Codex 以及 AgentScope(阿里) 做深度对比。读完你应能:理解 Harness 的设计哲学、跑通从“Hello 插件”到“真实编码 Agent”的完整链路、看懂 cordis.yml 组合与 HMR,并清楚它与其他主流框架的区别。

GitHub: https://deepseek-harness.github.io/deepseek-harness

一、DeepSeek Harness 是什么

1.1 一句话定义

DeepSeek Harness 是 DeepSeek 官方出品的 Agent(智能体)运行框架。它不是一个“写死的助手”,而是一个可组合的插件运行时:你用一份 YAML 配置文件(cordis.yml)把“会话管理、系统提示词、工具、LLM 适配器、文件访问、子进程、沙箱、乃至 agent 主循环本身”等全部能力像搭积木一样拼起来。

它的底层是一个名为 Cordis 的微型插件框架(源码 vendored 在 vendor/cordis/)。Cordis 提供一个共享的 Context(上下文),每个能力都是一个挂载到 ctx 上的插件。

一句话定位:

Claude Code 是“产品”,DeepSeek Harness 是“框架”,AgentScope 是“开发库”。 前者给你一个开箱即用的编码助手;Harness 给你一套可以自由拼装、自托管、可嵌入自己产品的 Agent 引擎;而 AgentScope 给你一套以 Python 为主的“构建智能体应用的库”,侧重多智能体与工程化工具链。

1.2 多维度拆解:它到底是什么

要真正理解 Harness,需要从五个维度同时看它:

维度它是什么反例(它不是什么)
交付形态一套框架 + 一组官方插件 + 一个 CLI/ACP 入口一个封闭、开箱即用的产品
内核范式基于 Cordis 的插件运行时,一切皆插件一个硬编码逻辑的单体程序
组合方式声明式 cordis.yml 配置驱动、按 id 装配命令式代码里 new 实例
能力边界抽象服务(Service Definition)+ 可替换 Provider + 消费者把实现细节写死在调用方
适用范围可自托管、可嵌入自有产品、可长期会话仅终端交互的助手

1.3 设计思路:为什么这么设计(核心分析)

Harness 的设计不是“为了可插拔而可插拔”,而是围绕几个明确的工程目标与约束做出的系统性取舍。下面逐条分析。

思路一:用“一切皆插件”消除硬编码的循环逻辑

绝大多数编码助手的 agent 主循环(call model → run tools → repeat)是写死在核心里的。Harness 的反直觉决定是:整个仓库只有 dsh-agent-loop 一个包包含具体循环逻辑packages/core/agent-loop/README.md 明确如此),其余全是抽象服务或扩展点插件。

设计动机:

  • 循环只描述“驱动协议”,不掺带具体能力。hooks、sandbox、plan mode、retry、subagent、compaction 等行为全部通过监听 agent/*tools/*session/* 事件实现,而非改循环代码。
  • 这带来“行为在扩展点上、不在循环里”的硬约束——AGENTS.md 原话:“Plugins, not loop changes: new behavior goes on documented extension points; changing agent-loop requires updating docs/architecture.md.”
  • 底层原理:当循环成为唯一且稳定的驱动者,所有可变行为都被推到事件订阅侧,于是能力的增删=插件的挂载/卸载,与主循环彻底解耦。

思路二:能力分层的“三角色”模型(capability-seam)

每个能力被刻意拆成三个相互独立演化的角色(见 docs/glossary.md#capability-seam):

  • Service Definition(服务定义):拥有 ctx.<key> 与词汇表类型的 Cordis Service,是抽象类或具体注册表(如 ShellExecutorWebRuntime),绝不是 TypeScript interface——因为它要作为真实服务被挂载。
  • Service Provider(服务提供者):一种或多种实现,如 dsh-shell-local / dsh-shell-pwsh
  • Consumer(消费者):注入该服务、面向模型暴露工具的插件,如 dsh-tool-bash

设计动机:

  • 角色独立演化:当只有 provider 需要换(本地→沙箱→E2B)时,定义和消费者代码一行都不用动。这把“变化”限制在最窄的边界内。
  • 以 shell 为例:dsh-shell(定义)→ dsh-shell-local / dsh-shell-pwsh(provider,按平台 disabled)+ dsh-bash-sandbox(沙箱 policy)。LLM 同理:dsh-llm(定义)→ dsh-llm-deepseek(原生)/ dsh-llm-pi-ai(多 provider 孪生)。
  • Swappable capability:seam 是“完整能力”,不是单个角色——文档特别强调“reserve the term for that meaning”,因为误把某一角色当能力,会导致消费者直接依赖实现而破坏可替换性。

思路三:注册即副作用(effect),把生命周期交给框架

AGENTS.md 铁律:“Registrations are effects: every contribution goes through ctx.effect() / ctx.on(); a registry's register() returns the disposer.”

设计动机:

  • 插件不持有自己资源的“拆除责任”。任何注册(ctx.tools.registerctx.on、子插件、服务实例)都附着在调用它的插件上,插件卸载时自动撤销。
  • ctx.plugin(child) 让一个插件把另一个插件挂为“子”,父子一起 dispose,递归卸载。
  • 底层原理:资源所有权 = 插件生命周期,而非手动 if 分支。这从机制上消灭了“忘记移除监听器/关闭定时器”这类资源泄漏——docs/defensive-patterns.md 把“Dispose must reach quiescence”列为头号缺陷类规则:拆除要异步 await 到真正静止,而不是只发一个 kill。

思路四:依赖注入是“持续跟踪”,而非一次性检查

消费方写 inject: ['tools'],Cordis 会让插件保持 PENDING 直到 ctx.tools 存在,且运行期若服务消失(provider 被卸载/热替换),依赖方随之卸载,服务恢复后再加载。

设计动机:

  • 配置可替换服务:卸载 dsh-shell-local、挂载另一个 shell provider,所有 inject: ['shell'] 的插件自动重启用新实现——这就是“框架级热替换”的物理基础。
  • 顺序无关cordis.yml 里插件行序不影响正确性,只影响就绪先后。彻底移除某服务后,依赖方保持 PENDING,既不崩溃也不会半运行。
  • 底层原理:依赖图是运行时动态满足的,而非构建期静态绑定,因此组合(composition)本身是数据(YAML),不是代码。

思路五:“模型可见 ⟺ 已记录”的审计约束

AGENTS.md 硬约束:“Model-visible ⟺ logged: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.”

设计动机:

  • 任何送达模型的输入(工具结果、系统提示词切片、变量)都必须对应一条会话事件,使得会话日志即真相(source of truth)——可以回放、审计、fork、resume。
  • 会话是一等公民:session(JSONL/SQLite 持久化、投影、血缘)、session-query(SQLite 全文检索)、compaction(压缩+工具结果裁剪)共同支撑长期记忆与合规。
  • 底层原理:把“可重建性”上升为架构不变量,而非依赖开发者自觉。这使得调试一个错误回答 = 重放那条会话事件流,而不是猜测模型当时“看到了什么”。

思路六:显式优于隐式,错则明报,绝不静默

AGENTS.md 多项规则:

  • “Misconfiguration fails loud at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent.”
  • “No hardcoded tunables in plugins: deployment-varying choices are validated Config fields changeable from cordis.yml.”
  • 跨边界不透明 id 用 Branded<B> 品牌类型,非裸 string;只在校验边界(config、模型/工具 JSON、文件、worker、进程、线)做运行时校验,同进程类型边界信任 TypeScript

设计动机:

  • 可部署性来自“可变项都是可校验的 Config”,而非代码里 ?? default 的隐藏默认值(那是协议常量/安全不变量才固定的)。
  • 可诊断性来自“缺引用就明报”,避免“插件没反应却不知道为什么”(见教程第九章诊断器)。
  • 底层原理:把“部署差异”与“安全不变量”两类变化分离——前者进 Config 受 schema 校验,后者写死且不可被配置绕过。

思路七:安全是分层的,而非单点

docs/defensive-patterns.md 给出具体规则:

  • 生成命令拿到的是清洗过的环境(丢弃 *KEY*/*SECRET*/*TOKEN*/*PASSWORD*),防止 harness 凭据泄漏进输出或 spill 文件。
  • 临时/spill 文件用私有(0700)目录、随机名、独占 owner-only 打开('wx'0o600),避免可预测路径导致的 symlink 竞争与泄露。
  • 沙箱是一等能力:sandbox(bwrap/Landlock/Seatbelt),执行与文件系统访问都可套沙箱 policy。

设计动机:把“执行不可信输出”当头等威胁,从环境、文件、进程三层同时设防,而非依赖“用户别跑奇怪命令”的约定。

1.3.1 七条设计思路 · 思维导图

DeepSeekHarness设计思路一切皆插件消除硬编码循环
dsh-agent-loop含具体循环行为全靠事件订阅agent/* tools/* session/*扩展点而非改循环代码能力分层三角色capability-seam
Service Definition 抽象Service Provider 可替换Consumer 面向模型变化隔离在窄边界注册即副作用effect 管生命周期
ctx.effect / ctx.on自动撤销资源所有权=插件生命周期dispose await 到静止注入持续跟踪组合即数据
inject 动态满足依赖图PENDING 直到服务就绪行序无关 支持热替换模型可见即已记录日志即真相
送达模型必对应会话事件可回放 审计 fork resumesession 一等公民显式优于隐式错则明报
可变项=可校验 Config缺引用明报 不静默跳过Branded id 信任 TS 边界安全分层非单点
清洗 env 去凭据私有 spill 文件 0700沙箱bwrap/Landlock/Seatbelt

1.3.2 思路一 / 思路五 具象化:一次 Agent 循环的时序(后移)

这两条思路最直观的落点是一次真实 agent 循环的时序图。为让你先理解 tools/resultagent/*session/event 等事件,我把这张图移到 第十章 10.4 节(跑通真实工具管线之后)再展开。届时你会看到:循环只负责“走流程”,插件负责“加行为”,会话日志负责“留真相”。

1.4 这七个思路如何收敛为一个系统

把以上七点串起来,Harness 的设计主线是:

用插件运行时(Cordis)承载一切能力,用“抽象服务 + 可替换 provider”隔离变化,用 effect 把生命周期交给框架,用注入的动态满足实现配置驱动的组合,用事件把行为推到扩展点,用会话日志作为可重建的真相,用显式校验与安全分层守住部署与执行边界。

它因此呈现出与 Claude Code / Codex(产品)、AgentScope(Python 开发库)截然不同的取向:前者关心“用户开箱即用”,后者关心“研究者快速搭多智能体”,而 Harness 关心的是**“平台/产品工程师如何可靠地自托管并长期演化一个 Agent 底座”**。这也是为什么它的工程纪律极严(100% 覆盖率门禁、type-equiv 文档同步、品牌类型、声明式 surface),因为底座的可靠性是上层一切的前提。

二、核心心智模型:一切皆插件

第一章回答了“为什么这么设计”(七条思路 + 设计动机);本章回答“它实际怎么跑起来”,并用一张整体架构图把这七条思路落到物理结构上。如果你跳过了第一章,只需记住一句话:Harness 里没有“写死的助手”,所有能力都是挂在共享 ctx 上的插件。

整个仓库遵循一条铁律(见 AGENTS.md):

Everything is a plugin. 一切皆是插件。

这意味着:

  • 工具是插件(dsh-tools
  • 大语言模型适配器是插件(dsh-llm + DeepSeek provider)
  • 文件系统访问是插件(dsh-fs
  • shell / 子进程 / 终端是插件(dsh-shell / dsh-subprocess / dsh-terminal
  • 连 agent 主循环(agent-loop)本身都是可替换的插件dsh-agent-loop

所有插件共享同一个 ctx,通过三种机制协作:

机制关键字作用
依赖注入inject: ['tools']插件声明它依赖某服务,Cordis 在该服务就绪后再启动它
注册 / effectctx.effect() / ctx.on()插件贡献能力(注册工具、监听事件);卸载时自动撤销
事件ctx.on(event, cb) / ctx.waterfall()解耦的插件间通信

关键设计:注册是“副作用”(effect)。每个贡献都通过 ctx.effect() 完成,插件卸载时贡献自动撤销。这是 Cordis 生命周期管理的核心,也是它区别于“手动管理全局单例”类框架(如很多 Python agent 库)的根本点。

2.1 整体架构图(基于源码)

下图依据 packages/bundle/base/cordis.patch.yml(base bundle 的 45+ 个 id 行)与 packages/core/agent-loop/README.md(agent-loop 注入的 5 个服务)绘制,反映真实组合关系,而非示意。

扩展 / 互操作
会话 / 人机协作
编排 / 子任务能力
模型 / 检索能力
执行 / 沙箱能力 (Provider 插件)
核心脊梁 (packages/core + base bundle)
Cordis 运行时 (vendor/cordis)
注入
注入
注入
注入
注入
按 id 装配
服务就绪即激活
dsh-skill + tool-skill
hooks-claude-code / hooks-codex
(Hook 互操作桥接)
dsh-acp
(自动化协议服务器)
extensions
(agent 自修改插件)
dsh-bundle
(--profile 补丁层)
session-persistence-jsonl
session-query-sqlite
(全文检索, 可选)
session-projection
compaction-basic
+ tool-result-pruner
user-approval + permission-presets
interaction / commands / plan-mode
dsh-subagent
(spawn / fork provider)
dsh-workflow
(worker-thread)
dsh-jobs-local
dsh-tool-todo
dsh-goal / command-goal
dsh-web (web_search)
dsh-llm-deepseek
(原生适配器)
dsh-llm-pi-ai
(多 provider 孪生)
dsh-llm-retry
dsh-shell-local / dsh-shell-pwsh
dsh-subprocess-local
dsh-sandbox-local
+ sandbox-policy
dsh-tool-fs / fs-search
(dsh-fs-sandbox)
terminal / code-runtime
dsh-agent-loop
(ctx.agentLoop)
唯一具体循环驱动
dsh-agent
(ctx.agents 工厂)
dsh-tools
(ctx.tools 注册表)
dsh-llm
(ctx.llm 抽象 + DeepSeek provider)
dsh-system-prompt
(ctx.systemPrompt)
dsh-session
(ctx.session 持久化/投影)
根 Context
(共享 ctx)
Loader 插件
读 cordis.yml / --profile 补丁层
@cordis-plugin-hmr
文件变更热重载
@cordis-plugin-timer
事件总线
agent/* · tools/result · agent/request
approval/request · session/event
CLI (pnpm dsh)
/ ACP / JSON-RPC 入口
agent-loop 注入的 5 个接口服务(来自 README)

2.2 图中关系对照源码说明

1. “一切皆插件”的物理形态

  • 启动器 = node --import tsx ../../vendor/cordis/bin.js,它只创建 root Context 并挂载 Loader。
  • Loader 读取 cordis.yml--profile 补丁层(dsh-bundle)。base bundle 在 packages/bundle/base/cordis.patch.yml 里用 45+ 个 id声明了全部默认插件,行序无关(激活由“服务可用性”驱动)。
  • 每一行就是一个插件;id(如 agent-looptoolsllm-deepseek)是 stable 标识,后续补丁层按 id 覆盖它。

2. agent-loop 是唯一的“具体循环”

  • dsh-agent-loop/README.md整个 harness 只有这一个包包含具体循环逻辑,其他全是抽象服务或扩展点插件。
  • 它注入并依赖 5 个接口服务:agentssessionsllmtoolssystemPrompt(图中虚线)。这 5 个都是 ctx 上的服务,具体 provider 可热替换。
  • “call model → run tools → repeat”之外的所有行为(hooks、sandbox、plan、retry、subagent、compaction)都通过监听 agent/*tools/*session/* 事件实现——这就是图中的事件总线

3. 工具是“注册”而非“硬编码”

  • bashfswebsubagentworkflowtodoskill 等执行/编排插件,通过 ctx.tools.register(...)(effect)把工具挂进 dsh-tools 注册表,再由 agent-loop 在 tools/result 等事件里消费。两插件互不知对方存在。

4. Provider 可替换三角色

  • 以 shell 为例:dsh-shell(定义)→ dsh-shell-local / dsh-shell-pwsh(provider,按平台 disabled)+ dsh-bash-sandbox(沙箱 policy)。LLM 同理:dsh-llm(定义)→ dsh-llm-deepseek(原生)/ dsh-llm-pi-ai(多 provider 孪生)。

5. 自修改与互操作

  • extensions 让 agent 运行时装载/卸载插件;hooks-claude-code/hooks-codex 桥接外部 Hook;acp 暴露自动化协议服务器。这些都在 base bundle 之外,按需叠加。

图中 dsh-agent-loop 的 5 条虚线注入、dsh-tools 的 7+ 条工具注册、--profile 补丁层装配,均直接来自 packages/bundle/base/cordis.patch.ymlpackages/core/agent-loop/README.md 的源码事实。

三、环境准备(5 分钟)

3.1 你需要先知道的背景(新手必读)

本文示例用 TypeScript 写插件,但不需要你精通 TS。只要理解下面四点即可:

  • ESM 与 import:代码用 import { x } from 'pkg' 引入依赖;本文所有相对导入都带 .ts 后缀(如 './hello.ts'),这是 Cordis loader 的约定。
  • workspace 包名@deepseek-ai/cordis@deepseek-ai/dsh-tools 等是仓库内部的 npm 包名(pnpm workspace 解析),不是从网络下载的。import type { Context } from '@deepseek-ai/cordis' 就是从 Cordis 取类型。
  • cordis.yml 是 YAML 列表:每个 - name: ... 是一项插件;缩进用两个空格,不要混用 Tab。
  • ctx 是什么:贯穿全文的 ctx 是 Cordis 的共享上下文,所有插件通过它注册能力与监听事件。你可以把它当成“整个运行时的总接线板”。

3.2 环境前置条件

前置条件(详见 docs/development.md):

  • Node.js 22.19+ 或 24+(CI 覆盖 22.19 / 24 / 26)
  • pnpm(启用 Corepack:corepack enable),仓库锁定 pnpm@11.7.0
  • Git 2.26+
  • 可选:DeepSeek API Key(DEEPSEEK_API_KEY),仅真实跑模型时需要;本教程第三至第十章(含 HMR 与工具管线)完全无密钥可运行
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run typecheck   # 验证环境就绪

创建教程临时目录(tmp/ 已被 git 忽略,不会被提交):

mkdir -p tmp/cordis-tutorial
cd tmp/cordis-tutorial

后续所有示例都从这同一个目录运行:

node --import tsx ../../vendor/cordis/bin.js

这个单文件启动器会:① 创建根 Context;② 挂载 Loader 插件;③ 从当前目录读取 ./cordis.yml 并加载里面列出的每个插件。无需任何构建步骤(--import tsx 让 Node 直接跑 TS)。

四、动手:你的第一个插件

4.1 写插件

tmp/cordis-tutorial 下创建 hello.ts

import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello'

export function apply(ctx: Context) {
  console.log('hello from my first plugin')
}
  • 插件通过命名导出 apply 函数被 loader 挂载。
  • ctx 是 Cordis 上下文,插件通过它注册所有贡献。
  • name 是可选显示名,用于诊断信息。

4.2 组合应用

创建 cordis.yml

- name: './hello.ts'

这是一个配置项列表name 是模块指定符(相对路径或 npm 包名)。

4.3 运行

node --import tsx ../../vendor/cordis/bin.js

输出:

hello from my first plugin

4.4 三种插件形态

import { Service, type Context } from '@deepseek-ai/cordis'

// 1. 函数插件(最常用)
export function apply(ctx: Context) {}

// 2. 对象插件:带 apply 方法的对象
export const objectPlugin = { name: 'obj', apply(ctx: Context) {} }

// 3. 类插件:Service 子类(需要公开服务时用,见第六章)
export class MyService extends Service {
  constructor(ctx: Context) { super(ctx, 'myService') }
}

新手建议:在需要公开服务之前,一律使用函数形态。

4.5 容错行为(新手必知)

  • 若插件 apply 抛错 → 进程直接崩溃并报错(不会静默跳过)。
  • cordis.yml 里的模块路径/包名拼错(解析失败)→ Cordis 只通过 logger 报告,不会崩溃。新插件“没反应”时,先检查拼写。

五、生命周期与 effect(资源自动回收)

Cordis 插件可能因修改配置、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时撤销;在这些 API 之外管理的资源必须包装在 ctx.effect()

创建 lifecycle.ts

import type { Context } from '@deepseek-ai/cordis'

export const name = 'lifecycle-demo'

function heartbeat(ctx: Context) {
  console.log('heartbeat plugin loading')
  ctx.effect(() => {
    const timer = setInterval(() => console.log('tick'), 200)
    return () => {
      clearInterval(timer)
      console.log('heartbeat cleaned up')
    }
  })
}

export function apply(ctx: Context) {
  const fiber = ctx.plugin(heartbeat)
  ctx.effect(() => {
    const timer = setTimeout(async () => {
      await fiber.dispose()
      console.log('disposed')
      process.exit(0)
    }, 700)
    return () => clearTimeout(timer)
  })
}

运行后输出:

heartbeat plugin loading
tick / tick / tick
heartbeat cleaned up
disposed

三点关键:

  • ctx.plugin(heartbeat) 把一个来自代码的函数挂载为插件,与 YAML loader 为每个配置项做的完全一致。调用返回 fiber——已加载插件实例的运行时句柄。
  • effect 主体在加载期间运行,返回的 disposer 在卸载期间运行。生命周期与插件一致的资源,你绝不需要手动调用 disposer。
  • fiber.dispose() 会等该插件所有清理(含异步 disposer)完成后才结束,并递归卸载它挂载的子插件。

Fiber 状态机

每个已加载插件实例都有 fiber,在以下状态间转换:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                 ↘ FAILED
  • PENDING:已声明,但所需服务尚不可用(见第六章)。
  • LOADING / ACTIVEapply 正在运行/已完成。
  • FAILEDapply 或配置校验抛异常。
  • UNLOADING / DISPOSED:disposer 正在运行/已拆除。

已经是 effect 的操作(你很少需要手写 ctx.effect()

  • ctx.on(event, listener):监听器随插件卸载自动移除。
  • ctx.plugin(child):子插件随父插件一同 dispose。
  • 服务注册、harness 注册表(如 ctx.tools.register(...))的返回 disposer 都附着在调用插件上,自动撤销。

顺序注意:disposer 按注册逆序启动,但多个异步 disposer 并发运行;若拆除必须按顺序,请把步骤放进同一个 disposer 内依次 awaits。

六、服务(Service):能力的注册与消费

服务是插件提供、其他插件通过 ctx 消费的具名能力。在 harness 中,ctx.toolsctx.llmctx.agents 都是服务。消费方只指定 'tools' 这样的能力名,而不导入提供方——因此配置可以选择提供方,无需改动消费方代码

6.1 提供服务

greeter.ts

import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    greeter: GreeterService
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'greeter')
  }
  greet(who: string) {
    return `Hello, ${who}!`
  }
}

export const name = 'greeter'
export function apply(ctx: Context) {
  ctx.plugin(GreeterService)
}

两部分协同:

  • 运行时super(ctx, 'greeter') 以名称 greeter 注册实例,ctx.greeter 随处可访问;注册属于 effect,卸载时移除。
  • 编译时declare module 用 TS 声明合并把 greeter 加入 Context 接口,使消费方获得类型安全(无此声明运行时仍工作,但失去类型)。

6.2 消费服务(inject)

consumer.ts

import type { Context } from '@deepseek-ai/cordis'

export const name = 'consumer'
export const inject = ['greeter']

export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))
}

inject 列出该插件需要的服务。Cordis 会让插件保持 PENDING 直到每项服务都存在,因此在 apply 内可保证 ctx.greeter 已就绪——加载顺序无关紧要。

- name: './greeter.ts'
- name: './consumer.ts'

输出 Hello, world!交换两行顺序输出不变;若彻底移除 greeter.ts(或拼错包名),消费方会保持 PENDING / 启动失败——它既不崩溃、也不会在依赖缺失时半运行,而是明确停在等待状态(诊断器见第九章)。

术语区分:本例 export const name = 'consumer'插件显示名inject: ['greeter'] 里的 greeter服务名(由 super(ctx, 'greeter') 注册)。二者命名空间不同——插件可任意取名,但注入必须精确匹配服务名,否则永远 PENDING。

6.3 inject 是持续跟踪,而非一次性检查

若运行期间所需服务消失(如提供方被卸载、热替换),每个依赖插件会随之卸载,服务恢复后再加载。结合 effect,这防止消费方保留对不可用服务的引用。也正是配置能替换服务的原因:卸载 dsh-shell-local、挂载另一个 shell 提供方,所有 inject: ['shell'] 的插件会重启并用新实现。

6.4 可选依赖

inject 是硬性依赖。缺失仍可工作时跳过 inject 并探测:

export function apply(ctx: Context) {
  const greeter = ctx.get('greeter')
  console.log(greeter?.greet('maybe') ?? 'no greeter available')
}

原则:扩展插件依赖 **Service Definition(抽象服务)**而非具体 provider。这样 LLM 适配器、执行器等都能热替换、互不影响。服务名共用扁平命名空间,自有服务请加前缀(harness 已占用 toolsllm 等)。

七、事件系统:解耦通信与拦截

服务支持直接调用;事件让插件无需知道谁在监听就能广播。harness 用事件处理工具结果、模型请求、审批决定等交互。

7.1 声明、发出、监听

stats.ts(计数服务):

import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context { stats: StatsService }
  interface Events {
    'stats/report'(name: string, count: number): void
  }
}

export class StatsService extends Service {
  private counts = new Map<string, number>()
  constructor(ctx: Context) { super(ctx, 'stats') }
  bump(name: string) {
    const next = (this.counts.get(name) ?? 0) + 1
    this.counts.set(name, next)
    this.ctx.emit('stats/report', name, next)
  }
}

reporter.ts

import type { Context } from '@deepseek-ai/cordis'
import type {} from './stats.ts'

export const name = 'reporter'
export const inject = ['stats']

export function apply(ctx: Context) {
  ctx.on('stats/report', (name, count) => {
    console.log(`[stats] ${name} -> ${count}`)
  })
  ctx.stats.bump('tool_call'); ctx.stats.bump('tool_call'); ctx.stats.bump('prompt')
}

import type {} from './stats.ts' 让 TS 看到声明合并(运行时无副作用)。输出:

[stats] tool_call -> 1
[stats] tool_call -> 2
[stats] prompt -> 1

ctx.on() 属于 effect,监听器随插件消失,绝不需手动 removeListener

注意 declare module '@deepseek-ai/cordis' { interface Events { ... } } 这处声明合并:它把 'stats/report' 及其签名写入 Cordis 的全局事件表,于是 ctx.emit / ctx.on 在编译期就检查事件名与参数类型。漏写声明合并,事件仍是合法的 string 事件,但失去类型保护——这是基于 Cordis 开发时最常踩的坑。

7.2 五种分发模式

模式调用语义
emitctx.emit(name, ...)同步广播;不等待/不收集返回值
parallelawait ctx.parallel(...)全部并发并一同等待
serialawait ctx.serial(...)顺序等待;首个非 null/false/undefined 胜出并停止
bailctx.bail(...)serial 的同步版
waterfallctx.waterfall(name, ...args, next)环绕中间件,可转换或短路

7.3 waterfall:转换或短路

declare module '@deepseek-ai/cordis' {
  interface Events {
    'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
  }
}
// 监听器 1:包裹下游结果
ctx.on('demo/transform', async (input, next) => {
  const downstream = await next()
  return downstream.toUpperCase()
})
// 监听器 2:拥有决策时短路
ctx.on('demo/transform', async (input, next) => {
  if (input.includes('blocked')) return '** blocked **'
  return next()
})

await ctx.waterfall('demo/transform', 'hello', async () => 'hello')        // HELLO
await ctx.waterfall('demo/transform', 'blocked words', async () => '...') // ** BLOCKED **

纪律:只观察/标注的 waterfall 监听器必须调用 next();不调用代表有意短路。日志监听器若忘记 next() 会静默吞掉所有下游默认行为——这是本仓库常设规则。Harness 用 waterfall 处理协作决策:agent/request 允许插件替换模型调用配置,approval/request 允许策略代替用户作答。

八、配置:声明式与明确报错

cordis.yml 每个配置项都可带 config 块,插件导出 schema 在 apply 前校验。错误配置导致加载失败并给出准确错误:插件绝不会在配置不完整时启动

import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'

export const name = 'config-demo'
export interface Config { greeting: string; targets: string[] }
export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hello'),
  targets: Schema.array(String).default(['world']),
})
export function apply(ctx: Context, config: Config) {
  for (const target of config.targets) console.log(`${config.greeting}, ${target}!`)
}
- name: './config-demo.ts'
  config:
    targets: ['alpha', 'beta']

输出 Hello, alpha! / Hello, beta!(未给 greeting 时用 schema 默认值补齐,apply 永远收到完整且已验证的配置)。

传入无效值:

config: { targets: 'not-an-array' }
ValidationError: invalid config:
  - $.targets expected array but got not-an-array (at targets)

fiber 进入 FAILED,启动器打印错误后退出码 1。明确报错优于静默跳过是本仓库的一贯约定。

loader 还支持 !!js 标签用于加载时计算值:

config:
  greeting: !!js process.env.DEMO_GREETING ?? 'Hello'

!!js 仅在 config 与条目 disabled 内有效;disabled: !!js ... 可按平台/环境门控一行(本仓库扩展)。

九、组合、HMR 与诊断

cordis.yml 选择应用的插件树。配置项还能携带 iddisabled、嵌套 groupisolate 等元数据:

- id: greeter
  name: './greeter.ts'
- id: consumer
  name: './consumer.ts'
  disabled: true     # 保留条目但跳过挂载

id 提供稳定标识,使 loader 区分“修改现有项”与“先删后加”。disabled: true 卸载插件而不删条目;改回即连同 PENDING 依赖一起重载。group 可把子列表作为单元加载/卸载;isolate 为组提供某服务名的独立实例(两组各自看到不同配置的 shell 提供方,互不影响)。

9.1 热模块替换(HMR)

卸载释放 effect,加载遵循依赖,因此 HMR 可先卸载再加载以替换运行中的插件。@deepseek-ai/cordis-plugin-hmr 监视文件,保存时执行该过程:

- id: logger
  name: '@deepseek-ai/cordis-plugin-logger-console'
- id: timer
  name: '@deepseek-ai/cordis-plugin-timer'
- id: hmr
  name: '@deepseek-ai/cordis-plugin-hmr'
  config: { root: ['.'] }
- id: hello
  name: './hello.ts'

编辑 hello.ts 保存后:

hello from my first plugin
2026-07-22 15:44:36 [I] hmr watching [ '.' ]
2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts
hello from my EDITED plugin

旧实例先卸载(effect 回卷),新代码后加载。编辑 cordis.yml 本身也会触发更新:loader 按 id 比较,只改动变化部分。不带 id 的条目每次读取都获新 id,会被当作先删后加重新挂载——这就是显式 id 的意义。

9.2 诊断始终不加载的插件

依赖驱动加载的另一面:若 inject 指定了无人提供的服务,它会一直 PENDING、不输出。这不是错误(PENDING 是合法态)。可直接枚举状态:

import { FiberState, type Context } from '@deepseek-ai/cordis'
export const name = 'diagnose'
export function apply(ctx: Context) {
  setTimeout(() => {
    for (const runtime of ctx.registry.values())
      for (const fiber of runtime.fibers)
        if (fiber.state === FiberState.PENDING)
          console.log(`${fiber.name} is PENDING — a required service is missing`)
  }, 500)
}

inject: ['timer'] 无提供方时,诊断器会打印 needs-timer is PENDING — a required service is missing“插件没反应”时,先看 fiber 状态。

十、把工具接进真实 Agent

继续用第三章创建的 tmp/cordis-tutorial 目录,所有文件都放在这里,运行命令仍是 node --import tsx ../../vendor/cordis/bin.js

这是理解 Harness 的“啊哈时刻”:写一个可被模型调用的工具,穿过真实执行管线。无需密钥、不调模型。

10.1 工具插件 greet-tool.ts

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet the named person.',
    parameters: {
      name: { type: 'string', required: true, description: 'Who to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))

  void (async () => {
    const result = await ctx.tools.execute({
      callId: CallId('demo-1'),
      name: 'greet',
      arguments: { name: 'Cordis' },
      signal: new AbortController().signal,
    })
    console.log('tool replied:', JSON.stringify(result.content))
  })()
}

10.2 观察插件 tool-logger.ts

import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-tools'

export const name = 'tool-logger'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.on('tools/result', (exec, result) => {
    const text = result.content.map(b => (b.type === 'text' ? b.text : '')).join('')
    console.log(`[tool-logger] ${exec.name} -> ${text}`)
  })
}

10.3 组合运行

- name: '@deepseek-ai/dsh-system-prompt'
- name: '@deepseek-ai/dsh-tools'
- name: './tool-logger.ts'
- name: './greet-tool.ts'
node --import tsx ../../vendor/cordis/bin.js
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]

要点

  1. defineToolparameters 转成给模型的 JSON Schema,并execute 前校验参数
  2. 日志插件先触发:tools/result 在结果物化过程中发出,早于 execute 的 promise 兑现。两插件互不知对方——它们被注册表服务与事件连接。
  3. 工具插件若在组合里缺 systemPrompt 提供方,会保持 PENDING(缺依赖),正是 inject 机制的体现。

此刻你已能读懂 examples/headless-agent/cordis.yml 的每一项。真实 agent = 这套组合 + LLM 适配器 + agent-loop + 持久化 + 入口。

10.4 具象化:一次 Agent 循环的时序(思路一 / 思路五)

下面这张时序图直接来自仓库生成的权威生命周期图(docs/agent-lifecycle.md,由 scripts/gen-doc-graphs.ts 产出)。此时你已理解 tools/resultagent/*session/event 等事件,正好用它把第一章的思路一(只有 agent-loop 是具体循环、其余行为靠事件订阅)与思路五(任何送达模型的东西都写进会话日志、日志即真相)落到一次真实回合(turn/step)的每一步。

UI/SDK listenerSessionctx.toolsctx.llmctx.systemPrompthook listenersdsh-agent-loopAgentUserUI/SDK listenerSessionctx.toolsctx.llmctx.systemPrompthook listenersdsh-agent-loopAgentUserclaim pending next-step input + one queued promptopt[call starts]opt[next model-order result ready]alt[adapter/terminal request failure][model request succeeded]opt[natural stop + inbox empty]alt[pre-step rejected / failed][enter proposed step]followup(content)agent/inbox/spliced / agent/inbox/insertedqueued work wakes driveragent/status runningturn/startagent/pre-step waterfallauthoritative reject or enter(messages)claimed batch stays removed, turn spends no stepstep/startuser/message per entered messagesystem-prompt/assemble waterfallagent/request waterfall → llm/stream waterfallStreamChunk*assistant/chunk*session/event assistant/chunk*step/endagent/request-error waterfallretry action or keep original errorassistant/messageclassify pending call by executionModetool/callordered pre, concurrent executetool-owned events when applicableordered posttool/resultstep/endagent/turn-stopping serial checkpointturn/endagent/status idle

怎么读这张图(对应两条思路)

  • 思路一(一切皆插件,循环只驱动、不实现)

    • Driverdsh-agent-loop)只做“取输入 → 发事件 → 等结果”的骨架。hooks、sandbox、plan、retry、subagent、compaction 没有出现在循环体里,而是作为 agent/pre-stepagent/requestagent/request-erroragent/turn-stoppingwaterfall / serial 事件被外部插件订阅。
    • 例如 dsh-compaction-basicagent/pre-step 在请求构造前做压力检查、agent/request-error 只在上下文溢出时触发裁剪——这些都是“挂”在循环事件上的行为,而非循环内部的分支。这正是“Plugins, not loop changes”。
  • 思路五(模型可见 ⟺ 已记录,日志即真相)

    • 每一个送达模型的东西都被记成会话事件:system-prompt/assemble(提示词切片)、agent/request(模型请求)、llm/streamassistant/chunk*(流式输出)、assistant/message(一次成功调用)、tool/call / tool/result(工具调用与结果)。
    • 这些事件的耐久副本全在 session/event 上(Session-->>SDK: session/event ...),而 agent/* 只是“活的协调 API”(队列/状态/拦截/转向/续跑/错误)。
    • 因此一个错误回答可被完整重放:从 session/event 流重建出模型当时看到的提示词、调用了哪些工具、拿到了什么结果——无需猜测。这就是“日志即真相”的物理落点。

一句话:循环负责“走流程”,插件负责“加行为”,会话日志负责“留真相”。三者靠事件总线连接,互不硬编码对方。

十一、能力分层与实战运行

11.1 能力分层(Capability Seams)

Harness 把每个能力拆成三角色,各自独立演化

角色职责
Service Definition抽象接口(能力“是什么”)
Service Provider具体实现(如本地 / 云端 / E2B)
Consumer面向模型的工具或调用方

以 shell 为例:dsh-shell 定义能力,dsh-shell-local / dsh-shell-pwsh 是 provider,dsh-shell 的模型工具是 Consumer。

完整能力清单(packages/README.md)节选:

  • 核心core(session、prompt、tools、agent、agent-loop)、apitypertsdk
  • LLMllm(抽象 + DeepSeek provider)
  • 执行shellsubprocessterminalcode-runtimesandbox(bwrap/Landlock/Seatbelt)
  • 工具fslspwebskillsubagentworkflowtodoplan
  • 会话/持久化sessionsession-querycompactionstorageattachment
  • 人机协作interaction(审批/权限/ask-user)、hooksacp
  • 自修改extensions(agent 可运行时检视/挂载自己的插件)
  • 组合分发bundledsh --profile 补丁层)、preset

设计哲学:可维护依赖优于手写;跨边界 id 用 Branded<B> 品牌类型(非裸 string);运行时只在校验边界(config、模型/工具 JSON、文件、worker、进程、线)做校验,同进程类型边界信任 TypeScript

11.2 实战:跑真实编码 Agent

需先构建(详见 docs/development.md)并设置 Key:

pnpm run build
# 仓库根 .env 或环境变量
DEEPSEEK_API_KEY=sk-...
DEEPSEEK_BASE_URL=https://...   # 可选
pnpm dsh --profile headless "summarize this workspace"

其他演示:

pnpm run demo:cordis   # Agent 检视并修改自己的实时插件运行时
pnpm run demo:acp      # 用 JSON-RPC stdio 暴露自动化 Agent 会话(ACP 协议)

dsh --profile 背后是 dsh-bundle 补丁层:base 组合被部署 overlay 修补。

11.3 常用命令速查

pnpm install              # 安装 + lefthook 钩子
pnpm run typecheck        # 类型检查(pre-push 钩子)
pnpm run test             # vitest 单测
pnpm run test:coverage    # CI 覆盖率门禁(per-file 100%)
pnpm run lint
pnpm run build            # tsc 发射 lib/types + tsdown 打包
pnpm run hygiene          # knip + publint + 约束 + NodeNext 检查
pnpm run doc-sync         # 文档门禁(含 type-equiv 校验)

pnpm dsh --profile headless "task"   # 源码跑任务(需 Key)
pnpm run demo:cordis                 # 自引用 Cordis 演示(需 Key)
pnpm run demo:acp                    # ACP 自动化服务器(需 Key)

提交/推送前按 AGENTS.md 的“relevant checks”原则,只跑覆盖你所改面的检查,不必无脑跑全量——CI 才负责穷尽覆盖。

十二、优势分析 & 与 Claude Code / Codex / AgentScope 的对比

12.1 三方 + 一框架横向对比

维度DeepSeek HarnessClaude CodeCodex (CLI)AgentScope(阿里)
本质Agent 框架/运行时闭源产品(编码助手)闭源产品(编码 Agent)开源开发库(Python 为主)
内核Cordis 插件运行时,一切皆插件单体应用单体应用类 + 管道 DSL,ReAct 范式
语言TypeScript(Node)未公开未公开主要是 Python
可组合性极高:cordis.yml 拼装,含可换 agent-loop低(settings/hooks)低(settings/hooks)中(组件可换,但靠代码组装而非配置声明)
模型绑定LLM 层可替换,默认 DeepSeek锁定 Claude锁定 OpenAI多模型(含通义/OpenAI/本地),模型无关
自托管/嵌入✅ 完全可自托管、可嵌入产品❌ SaaS❌ SaaS✅ 开源可自部署
Hook 互操作内置 Claude Code/Codex 桥接无(独立生态)
会话持久化一等公民(JSONL/SQLite/血缘/全文检索)有(对话历史)有(较弱)有(Memory/长期记忆模块)
多 Agent一等:subagentworkflowjobs有限有限强项:内置 Debate、Concurrent、Handoffs 等工作流
可视化/工程化acp + 文档化子系统终端 UI终端 UIStudio + Tracing + OpenJudge 评测 + RAG + TTS
沙箱一等:sandbox(bwrap/Landlock/Seatbelt)依赖 shell 限制依赖沙箱环境运行时沙箱(runtime sandbox)
自动化协议ACP 服务器内置无(靠 CLI/钩子)A2A(Agent-to-Agent)智能体
源码开放✅ 全仓库可读可改可贡献✅(Apache-2.0 类开源)
适用对象平台/产品工程师、自研 Agent 团队终端开发者终端开发者算法/应用开发者、多智能体研究者

12.2 DeepSeek Harness 的核心优势

  1. 真正的“可组合”而非“可配置”
    Claude Code / Codex 让你配置已有行为;Harness 让你重写行为——连 agent 主循环、文件访问策略、权限模型都能换成自己的插件。这是“框架 vs 产品”的本质差别。

  2. 模型无关的能力层
    dsh-llm 把 LLM 抽象成 Service,DeepSeek 只是其中一个 provider。理论上换 provider 不改上层工具与循环。这点与 AgentScope 的“多模型无关”理念一致,但 Harness 通过 Cordis 的 inject/effect 把这种替换做成声明式、配置驱动、可热替换,比 AgentScope 在代码里换类实例更彻底。

  3. Hook 互操作
    通过 hooks-claude-code / hooks-codex 桥接包,你现有的 Claude Code / Codex hooks.json 能直接在 Harness 上跑。迁移成本极低,且原生扩展点是“类型化拦截点”,比 shell hook 更强。这是 AgentScope 完全不具备的跨生态兼容。

  4. 会话是一等公民
    session + session-query 提供持久化、投影、血缘、语义过滤、SQLite 全文检索——适合长期记忆与知识库型应用。AgentScope 也有 Memory/长期记忆模块,但 Harness 把“会话日志即真相”(model-visible ⟺ logged)上升到架构约束,保证任何送达模型的输入都能从会话日志重建——这对审计、回放、合规极有价值。

  5. 工程纪律极严
    100% 覆盖率门禁、声明式 cordis-surface、type-equiv 文档同步、品牌类型、显式边界校验——使它适合作为生产级产品的底座,而非玩具。AgentScope 的工程化(Studio/评测/Tracing)更偏“应用开发体验”,Harness 的工程化更偏“框架本身的可靠性与可维护性”。

  6. 自修改能力
    extensions 包让 agent 运行时检视/挂载/卸载自己的插件(即 demo:cordis)。这是 框架级 的插件自装载能力——区别于业务层的动态切换(如 AgentScope 的运行时换 agent),Harness 能在不重启进程的情况下增删真实 Cordis 插件并回卷其 effect。Claude Code / Codex 不提供此类机制。

12.3 与 AgentScope 的关键差异(重点)

AgentScope 是阿里开源的智能体应用开发库(Python 为主,1.0 论文见 arXiv:2508.16279),定位是“以开发者为中心构建 agentic 应用”。两者常被拿来比较,但取向不同:

取向DeepSeek HarnessAgentScope
范式插件运行时(Cordis),一切皆插件、配置驱动组件库 + 管道 DSL,ReAct 范式,代码驱动
语言生态TypeScript / Node,天然适合前端/工具/IDE 集成Python,天然适合算法/数据/ML 研究者
组合方式声明式 cordis.yml,依赖图自动排序、HMR、服务隔离命令式代码组装 Agent/Pipeline/Workflow
多智能体一等公民 subagent/workflow/jobs,基于插件协作强项:内置 Debate、Concurrent、Routing、Handoffs 等开箱即用工作流
可观测/评测会话日志 + ACP 协议 + 子系统文档化强项:Studio 可视化、Tracing、OpenJudge 评估器、RAG、TTS、Tuner
生产落地框架级可靠性(100% 覆盖、类型边界、沙箱)应用级工程化(沙箱、评测、可视化)齐全
模型默认 DeepSeek,LLM 层可替换多模型(通义/OpenAI/本地)开箱支持
runtime sandboxbwrap/Landlock/Seatbelt 一等支持runtime sandbox 支持

一句话总结差异

  • 自建 Agent 平台/产品底座、需要可替换模型与循环、要长期会话与审计、要平滑迁移 Claude Code/Codex 钩子 → 选 DeepSeek Harness(TypeScript 生态、插件化、配置驱动)。
  • 用 Python 快速搭多智能体应用、要现成的辩论/并发/路由工作流、要 Studio 可视化与评测体系 → 选 AgentScope(应用开发体验、ML 生态)。
  • 给终端用户一个开箱即用的编码助手 → 选 Claude Code / Codex(产品形态)。

12.4 何时选谁(决策树)

  1. 终端开发者、要开箱即用编码助手 → Claude Code / Codex
  2. 平台/产品工程师、自托管、嵌入自家产品、可替换模型/循环/工具、长期会话与记忆、复用 Claude Code/Codex 钩子 → DeepSeek Harness
  3. 算法/应用开发者、Python 生态、多智能体编排、可视化与评测体系 → AgentScope

十三、学习路线图与下一步

新手 30 分钟路径

  1. 跑通第四章“第一个插件”(5 分钟,无密钥)
  2. 跑通第十章“把工具接进真实 Agent”(10 分钟,无密钥)
  3. examples/headless-agent/cordis.yml,逐行对照本文(10 分钟)
  4. 设置 DEEPSEEK_API_KEY,跑 pnpm dsh --profile headless "..."(5 分钟)

深入阅读(按 docs/

  • docs/cordis-tutorial/:7 章完整 Cordis 动手教程(本文是其浓缩与扩展)
  • docs/cordis-primer.md:概念速查
  • docs/architecture.md:系统地图(改 packages/ 前必读)
  • docs/capability-seams.md:能力三层设计
  • docs/user/:面向 Harness 插件开发(develop/basic/tool.md 等)
  • docs/cookbook/adding-a-tool.md:工具 UI 呈现设计
  • packages/*/README.md:每个包的目的、API、扩展点

进阶方向

  • 写一个自定义 Service Definition + Provider(参考 dsh-shell
  • dsh-bundle 做自己的 --profile 补丁层
  • hooks-claude-code 桥接现有 hook
  • extensions 实现 agent 自修改
  • 运行 pnpm run doc-sync 重新生成所有架构/生命周期图(含本文引用的 docs/agent-lifecycle.md,由 scripts/gen-doc-graphs.ts 产出)
  • 对照 AgentScope 论文,体会“配置驱动插件运行时”vs“命令式组件库”两种架构取舍