本文对 DeepSeek Harness(以下简称 DSH)进行全面的技术架构剖析,全文由Claude Code + GLM-5.2撰写后小范围调整。文中将从"如何让 Agent 框架的每个部分都可替换"这个最核心的设计问题出发,逐步展开其插件系统、Agent 生命周期、工具执行管线、LLM 抽象、会话模型、双端架构和构建体系。旨在提供一份学习DSH的阅读指南,适合对 AI Agent 框架设计感兴趣的中高级开发者阅读。

DeepSeek Harness 于 昨天(2026年8月13日) 正式发布并开源。具体发布情况如下:

  • 发布版本:v0.1 开发者预览版(本文基于v0.1.0-rc.5),面向全球开发者开放测试。
  • 开源协议:采用 MIT 协议在 GitHub 平台全量开放源代码。

注:部分媒体和报道在8月14日对其进行了跟进报道或称其为“8月14日发布”,这主要是因为该框架在13日晚间发布后,于14日迅速引发了广泛关注并登顶GitHub涨星榜,但官方实际的开源发布时间为8月13日。


一、Agent框架要解决什么问题

在构建 AI Agent 应用时,开发者通常面临几个核心挑战:

  1. 模型适配:不同的 LLM 提供商(DeepSeek、OpenAI、Anthropic)有不同的 API 和流式协议,如何统一?
  2. 工具扩展:如何让 Agent 安全地调用 Bash、读写文件、搜索网页,同时允许用户自定义工具?
  3. 上下文管理:对话越来越长,token 窗口有限,如何压缩历史而不丢失关键信息?
  4. 会话持久化:如何保存和恢复 Agent 的完整状态,包括多轮对话、工具调用结果?
  5. UI 集成:如何将 Agent 能力暴露给 Web 前端,同时保持前后端解耦?

DeepSeek Harness 的答案简洁而彻底:将所有功能都设计为可替换的插件,包括 Agent 循环本身。项目基于 Cordis 插件框架构建,代码完全开源(MIT)。

技术栈速览

维度技术选型
语言TypeScript 6.0+,编译目标 ES2024
运行时Node.js ≥22.19 / ≥24.0
包管理pnpm 11.7 Workspace Monorepo
构建tsc -b(项目引用增量编译)+ tsdown(Rolldown 打包)+ vite(Web 前端)
前端React 18 + Vite 6
测试Vitest 4.x(单元 / Web / E2E / 快照 / 性能测试)
代码检查Oxlint + ESLint
文档站点VitePress
通信协议自研 Typert 类型化 RPC + JSON-RPC 2.0

二、项目结构:一个 Monorepo,两个编译面

DSH 使用 pnpm workspace 管理 50+ 个功能包,按领域分在 packages/<group>/<pkg>/ 下。整个项目在编译时被分为两个独立的 TypeScript 程序——Host(Node.js 后端)Client(浏览器前端)——它们永远不会在同一个类型系统中相遇。

deepseek-harness/
├── vendor/                  # 第三方源码(vendored),Cordis 框架核心
│   ├── cordis/              # 插件系统:Context, Service, Fiber, Events
│   ├── loader/              # 从 YAML 配置文件加载插件的运行时
│   └── ...
├── packages/
│   ├── core/                # 核心骨架——Agent、会话、工具、系统提示词
│   ├── host/                # Host 端:Web 服务器、API 代理、目录选择器
│   ├── client/              # Web UI 组件(30+ 个包)
│   ├── llm/                 # LLM 抽象层:适配器注册 + 流式协议
│   ├── sdk/                 # JSON-RPC 协议定义 + 服务端 + TypeScript 客户端
│   ├── shell/fs/terminal/   # 系统能力:Shell 执行、文件系统、终端
│   ├── skill/commands/      # 扩展机制:技能注册表、斜杠命令
│   ├── compaction/          # 上下文压缩(解决 token 窗口限制)
│   ├── subagent/workflow/   # 编排:子代理委派、工作流
│   ├── hooks/mcp/acp/       # 外部协议:Claude Code/Codex Hook、MCP、ACP
│   ├── sandbox/             # 沙箱策略(进程隔离、Windows ACL)
│   ├── typert/              # 类型化 RPC 系统(类型图生成 + 运行时注册表)
│   ├── session/             # 会话持久化(JSONL / SQLite 两种后端)
│   ├── interaction/         # 用户审批、权限、AskUserQuestion
│   ├── plan/todo/goal/      # 高级 Agent 行为:计划模式、待办、目标
│   ├── bundle/              # 分发包(base / headless / web-app)
│   ├── preset/              # Agent 预设配置
│   ├── boot/                # 应用启动引导
│   └── util/                # 零依赖工具库
├── apps/
│   ├── cli/                 # `dsh` 命令行入口
│   └── web/                 # Web 前端 Vite 入口
├── python/                  # Python SDK + 运行时
├── native/                  # C 原生模块
├── examples/                # 可运行的 Cordis 配置示例
└── docs/ + website/         # 架构文档 + VitePress 文档站点

三、Cordis 插件系统:“一切皆插件"的基础

Cordis 是 DSH 的插件框架,被完整 vendored 在 vendor/cordis/ 下。它解决的核心问题是:如何让几十个功能模块在不互相 import 的情况下协作,并且每个模块都能被独立替换?

3.1 服务定位:通过 Context 而不是 import

在传统架构中,模块 A 要使用模块 B 的功能,通常会直接 import B:

1
// 传统方式:硬编码依赖 import { toolRegistry } from './tools' toolRegistry.execute('bash', args)

这种方式的问题是:B 被替换时,A 的代码必须修改。Cordis 的做法是:所有模块将自身注册到一个共享的 Context 对象上,其他模块通过 Context 的属性访问它们

1
// Cordis 方式:通过 Context 查找服务 class MyPlugin { // 声明依赖:我需要 tools 和 llm 两个服务 static inject = ['tools', 'llm'] apply(ctx) { // ctx.tools 和 ctx.llm 在 inject 声明的服务就绪后自动可用 ctx.tools.execute('bash', args) } }

ctx 是一个 Proxy 对象,当你访问 ctx.tools 时,它实际上通过服务解析器查找名为 tools 的注册服务。inject 声明告诉 Cordis:“只有在这两个服务都可用时,才启动这个插件;如果任何一个服务被卸载,先停掉这个插件,等它重新可用后再启动。”

这意味着:加载顺序不需要手动编排。 你只需要声明依赖关系,Cordis 自动处理启动和卸载的时序。

3.2 插件的三种写法

Cordis 接受三种插件形态,选择哪种取决于你需要多复杂的生命周期管理:

1
// 1. 函数插件:最简单的形态,适合无状态的纯逻辑 const myPlugin = (ctx, config) => { ctx.on('some-event', handler) } // 2. 带 inject 的对象插件:需要声明服务依赖时使用 const myPlugin = { inject: ['tools', 'llm'], apply(ctx, config) { // 启动时执行 ctx.effect(() => { // 注册副作用 return () => { /* 清理 */ } }) } } // 3. 类插件(继承 Service):需要提供可被其他插件发现的服务时使用 class MyPlugin extends Service { constructor(ctx, config) { super(ctx, 'myService') // 在 ctx 上注册为 'myService' } }

3.3 事件系统:四种分发模式满足不同场景

Cordis 的事件系统不是简单的 pub/sub。它提供了四种分发模式,每种对应不同的协作场景:

模式行为典型场景
emit同步触发所有监听器,不等待,不收集返回值通知类事件:“某件事发生了”
parallel并行触发所有监听器,等待全部完成独立的后处理:“所有人都处理一下”
serial按注册顺序依次触发,返回值传递给下一个有序决策链:“依次审批”
waterfall每个监听器可调用 next() 委托给下游,不调用则短路中间件:“包装或拦截”

Waterfall 是最重要的模式,它实现了"围绕中间件"语义。来看一个具体例子——工具执行前的审批:

1
// 监听 tools/pre-execute 事件(waterfall 模式) ctx.on('tools/pre-execute', (call, next) => { if (call.name === 'bash' && isDangerous(call.args)) { // 需要用户审批,阻塞执行 const approved = await ctx.askUser('是否允许执行此命令?') if (!approved) { // 短路:不调用 next(),直接返回拒绝结果 return { allowed: false, reason: '用户拒绝' } } } // 调用 next() 将控制权传递给下一个监听器(或实际执行) return next() })

3.4 Fiber:每个插件的独立生命周期

每次调用 ctx.plugin() 都会创建一个 Fiber,它代表这个插件实例的完整生命周期。Fiber 最重要的特性是可撤销的副作用

1
ctx.effect(() => { // 启动时执行 const server = http.createServer(handler) server.listen(3000) // 返回清理函数:当插件被卸载时自动执行 return () => { server.close() console.log('服务器已关闭') } })

所有通过 ctx.effect() 注册的副作用,在插件卸载时会按注册的逆序自动清理。这保证了即使插件在运行中被热替换,也不会留下资源泄漏。

3.5 从配置文件到运行中的插件树

DSH 的插件不是硬编码的,而是通过 YAML 配置文件声明:

1
# cordis.yml plugins: - id: bash-tool name: '@deepseek-ai/dsh-tool-bash' config: timeout: 30000 - id: deepseek-adapter name: '@deepseek-ai/dsh-llm-deepseek' config: provider: deepseek-official

Loader 的加载过程:

  1. 解析 YAML 为 Entry 节点树
  2. 通过 Node.js 的动态 import() 加载每个插件的模块
  3. 用插件声明的 schema 校验用户配置
  4. 调用 ctx.plugin() 创建 Fiber 并启动
  5. 配置变更时,如果只改了 config 值,则热更新 Fiber 而不重建;如果改了模块名或依赖声明,则重建整个 Fiber

这种机制实现了配置即架构:修改一个 YAML 文件就能替换任何功能模块,无需重新编译。


四、Agent 的完整生命周期

4.1 两个核心概念:Step 和 Turn

在理解 Agent 循环之前,需要先厘清两个关键概念:

  • Step:一次 LLM 请求 + 该请求触发的所有工具调用。例如,用户说"帮我创建一个文件”,Agent 可能调用 write_file 工具——这是一次 LLM 请求 + 一个工具调用 = 一个 Step。
  • Turn:一个完整的用户交互周期。一个 Turn 包含零个或多个 Step。当模型说"还需要更多信息"并调用工具时,Step 结束,但 Turn 继续;当模型给出最终回复且没有更多待处理的工作时,Turn 结束。

4.2 一次 Turn 的完整流程

下面是从用户发送消息到 Agent 完成回复的完整事件序列。我们将事件分为两类:session/event 前缀的是持久化事件(写入会话日志),agent/*tools/* 前缀的是运行时事件(只在内存中传递,不持久化)。

用户发送消息 "帮我分析 app.ts"
        │
        ▼
  turn/start(持久化)                         ← 新 Turn 开始
        │
        ├─ 从 inbox 中认领用户的输入消息
        │
        ├─ agent/pre-step(waterfall)          ← 运行时决策点
        │    │                                    插件可以在这里:
        │    │                                    · 拒绝进入(turn 关闭,不消耗 step)
        │    │                                    · 改写消息内容
        │    │                                    · 注入额外上下文
        │    │
        │    └─ 进入 →
        │
        ├─ step/start(持久化)                 ← 新 Step 开始
        │    │
        │    ├─ user/message(持久化)           ← 记录用户消息
        │    │
        │    ├─ 从会话日志派生模型历史            ← 将持久化事件转为 LLM 消息格式
        │    │
        │    ├─ 组装系统提示词 + 工具 schema      ← 注入当前可用的工具列表
        │    │
        │    ├─ agent/request(waterfall)        ← 发送前最后修改机会
        │    │    └─ llm/stream(waterfall)      ← 流式 LLM 调用
        │    │         ├─ assistant/chunk(持久化)← 每个流式块
        │    │         ├─ assistant/chunk
        │    │         └─ assistant/message(持久化)← 完整回复
        │    │
        │    ├─ 模型决定调用工具
        │    │    │
        │    │    ├─ tool/call(持久化)          ← 记录工具调用
        │    │    ├─ tools/pre-execute(waterfall) ← 允许/拒绝/询问
        │    │    ├─ tools/execute(waterfall)    ← 实际执行(可被超时/重试包装)
        │    │    ├─ tools/post-execute(waterfall)← 检查/替换/丰富结果
        │    │    └─ tool/result(持久化)         ← 记录工具结果
        │    │
        │    └─ step/end(持久化)
        │         │
        │         ├─ 工具调用指示需要继续?→ 回到 step/start
        │         └─ 模型给出最终回复 → 进入 turn 结束流程
        │
        ├─ agent/turn-stopping(serial)          ← 最终检查点
        │
        └─ turn/end(持久化)                    ← Turn 结束

4.3 Agent 接口

ReactLoopAgent(位于 packages/core/agent-loop/src/agent.ts)是 Agent 接口的具体实现。它暴露了以下编程接口:

1
interface Agent { id: SessionId session: Session inbox: Inbox status: 'idle' | 'running' ctx: Context // 每个 Agent 有独立的子 Context followup(content) // 发送用户消息,立即唤醒 Agent steer(content) // 引导 Agent 行为 inject(content) // 注入上下文,不立即唤醒(等待下一条消息一起处理) cancel(cause) // 取消当前操作 }

几个值得注意的设计:

  • 双目标 Inbox:消息被路由到 next-turnnext-step 两个队列。next-turn 的消息在新 Turn 开始时认领,next-step 的消息在当前 Step 结束后立即认领。这允许插件在不同时机注入不同优先级的上下文。
  • 独立的子 Context:每个 Agent 拥有自己的子 Context,意味着可以为不同会话注册不同的工具集、不同的审批策略,互不干扰。
  • AbortController 三合一:取消信号来自三个可能的来源——用户主动取消、插件被卸载、Agent 工厂被销毁。Agent 实现将这三个信号融合为一个统一的 AbortController。

五、工具执行管线:每一层都可以拦截

工具系统是 DSH 最精密的设计之一。每个 工具调用 不是简单地"执行然后返回结果",而是经过一个四阶段管线,每个阶段都是一个可以被插件拦截的 waterfall 事件:

tool/call 被记录
    │
    ▼
tools/pre-execute(waterfall)
    插件可以在这里决定:允许、拒绝、或弹出用户审批对话框
    如果拒绝,整个调用在此终止,记录一个拒绝结果
    │
    ▼
tools/execute(waterfall)
    实际执行工具。插件可以在这里包装超时控制、重试逻辑、性能指标收集
    执行环境与调用者的身份和权限绑定
    │
    ▼
tools/post-execute(waterfall)
    执行完成后。插件可以检查结果、替换内容、丰富元数据、或阻止敏感信息返回
    │
    ▼
tools/result(emit)
    最终结果冻结。这是纯通知事件,监听器不能修改结果
    │
    ▼
tool/result 被持久化

并行工具调用的调度策略

当 LLM 一次返回多个工具调用时,DSH 的调度器会分析每个调用的执行模式:

  • 独占调用:形成屏障,所有其他调用必须等待。例如,write_fileread_file 对同一文件操作时必须串行。
  • 并行调用:使用有界滚动池(大小由 maxParallelToolCalls 配置控制),允许同时执行。

关键约束是:结果必须按模型返回的顺序最终化,不能因为并行执行而打乱顺序。这保证了重放时的一致性——如果因取消而跳过了某个调用,调度器会记录一个合成的错误结果,而不是留下一个空洞。

Code Mode:在沙箱中执行代码

DSH 支持一种特殊的"代码模式":当 Agent 选择使用 run_code 工具时,它会提交一段代码(TypeScript 或 Python),这段代码在沙箱中执行,并且代码内部可以访问所有其他工具。换句话说,run_code 是唯一直接暴露给模型调用的工具,文件读写、Shell 执行等能力都通过 ctx.codeRuntime 从程序内部访问。


六、LLM 抽象层:一套流式协议适配所有模型

6.1 适配器模式

DSH 将所有 LLM 提供商的差异封装在 LlmAdapter 抽象类之后:

1
abstract class LlmAdapter { // 唯一必须实现的方法:接收参数,返回异步可迭代的流式块 abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk> // 可选方法 providerInfo(): ProviderInfo // 提供商元数据 listModels(): Promise<ModelInfo[]> // 可用模型列表 resolveModel(id: string): string // 模型 ID 标准化 providerRetryPolicy(): RetryPolicy // 重试策略 }

注册适配器是全或无的操作:如果尝试注册一个已有提供商的适配器,会直接抛出错误。这种设计防止了"哪个适配器在生效"的歧义。适配器的生命周期与注册它的 Fiber 绑定,Fiber 卸载时适配器自动注销。

6.2 统一的流式协议

DSH 定义了一套与提供商无关的流式块类型系统。无论底层是 DeepSeek、 OpenAI 还是 Anthropic 的 API,最终都转换为这种统一格式:

1
type StreamChunk = | { kind: 'block-start', index: number } // 一个内容块开始 | { kind: 'text-delta', index: number, text: string } // 文本增量 | { kind: 'reasoning-delta', index: number, text: string } // 推理过程增量 | { kind: 'tool-call-delta', index: number, delta: ... } // 工具调用增量 | { kind: 'block-end', index: number } // 内容块结束 | { kind: 'usage', promptTokens: number, completionTokens: number } | { kind: 'finish', reason: 'stop' | 'tool-calls' | 'max-tokens' | 'aborted' | 'error' }

每个内容块有独立的 index,支持多模态输出(例如同时生成文本和工具调用)。reasoning-delta 类型专门用于承载模型的推理过程(如 DeepSeek-R1 的思维链)。

6.3 流式调用的拦截

每个 LLM 调用都流经 llm/stream waterfall 事件。这意味着你可以在不修改适配器代码的情况下插入中间件:

1
// 一个日志中间件:记录每次 LLM 调用的耗时 ctx.on('llm/stream', async (options, next) => { const start = Date.now() for await (const chunk of next()) { // next() 返回实际的流 console.log(`[${chunk.kind}] index=${chunk.index}`) yield chunk } console.log(`LLM call took ${Date.now() - start}ms`) })

prepareCall() 方法在适配器注册状态下"锁定"一次调用,返回 PreparedLlmCall 句柄。这防止了在热更新(HMR)期间,一次调用的不同阶段使用不同版本的适配器。


七、双端架构:Host 与 Client 的彻底分离

7.1 为什么需要分离

DSH 的 Host 端运行在 Node.js 中,拥有文件系统、子进程、网络等完整能力;Client 端运行在浏览器中,只负责渲染 UI 和转发用户输入。两者通过 WebSocket 通信。这种分离意味着:

  • Host 端可以访问敏感资源,Client 端不能
  • 可以在不重启 Host 的情况下热更新前端
  • 第三方可以通过 SDK 直接与 Host 通信,而不需要浏览器

7.2 编译时分离

两个 TypeScript 程序通过项目引用定义:

tsconfig.json           → 解决方案文件,files: [],不包含任何源码
├── tsconfig.host.json  → Host 端项目引用图
└── tsconfig.client.json → Client 端项目引用图

tsc -b tsconfig.host.jsontsc -b tsconfig.client.json 分别编译。这意味着 Host 端的类型(如 node:httpnode:fs)永远不会泄漏到 Client 端,反之亦然。

7.3 运行时通信

┌────────────────────── Host (Node.js 进程) ──────────────────────┐
│                                                                  │
│  WebServer (ctx.webServer)                                       │
│  ├─ HTTP API: /api/* 路由                                        │
│  ├─ WebSocket: 多路复用事件流 + Host 事件流                      │
│  └─ 安全策略:DNS-rebinding 防护、特权接口仅限 loopback          │
│                                                                  │
│  AgentLoop ──→ LLM ──→ Tools ──→ Session Log                    │
│       │                                                          │
│       └── session/event ──→ WebSocket ──→ 推送到浏览器           │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘
                           │ WebSocket / JSON-RPC
┌────────────────────── Client (浏览器) ───────────────────────────┐
│                                                                  │
│  ConnectionController                                            │
│  ├─ 双流连接:mux(多路复用事件)+ host(Host 事件)             │
│  ├─ 指数退避重连:500ms → 1s → 2s → ... → 10s(带随机抖动)     │
│  └─ 严格就绪握手:describe() 单向可达 + onOpen 流建立            │
│                                                                  │
│  SessionRuntime ──→ ConversationView ──→ SlotRenderer           │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

7.4 前端的两阶段启动

Web 前端启动时经历两个阶段:

  1. Module Face:解析 Host 注入的 window.__DSH_BOOT__ 清单,预加载 React、Cordis 核心和 UI 基础组件。这些是固定的、不会因配置而变化的模块。
  2. Plugin Face:挂载 Cordis Loader,为每个插件行创建 Entry 并启动。所有 Entry 启动完成后执行全量 Fiber 扫描——任何处于 PENDING 或 FAILED 状态的 Entry 都会导致启动失败,并在界面上展示详细的诊断信息(哪个插件、缺失哪个服务)。

Shell 组件(app-shell)是唯一内置的前端模块。它只做一件事:提供 React Slot 渲染器。整个 UI 布局树挂在 'root' slot 上——Shell 不参与任何 UI 组合决策,所有布局由 Host 端的插件 bundle 通过 Slot 注册来决定。


八、会话模型:事件溯源而非状态快照

8.1 核心设计

DSH 的会话不是"当前状态的快照",而是只追加的类型化事件日志。每一次用户消息、每一个 LLM 流式块、每一次工具调用和结果,都作为不可变事件追加到日志末尾。

这个设计有一个硬性约束:任何要发送给模型的内容,必须能从日志中重建。 运行时不变量会强制执行这一点——如果你试图向模型发送日志中没有记录的内容,系统会抛出错误。

1
// 会话事件类型通过声明合并扩展 // 每个领域的包都可以声明自己的事件类型 declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { 'user/message': { content: ContentBlock[] } 'assistant/message': { content: ContentBlock[], usage: Usage } 'assistant/chunk': StreamChunk 'tool/call': { name: string, args: unknown } 'tool/result': { name: string, result: unknown } 'turn/start': { turnId: string } 'turn/end': { turnId: string } 'step/start': { stepId: string } 'step/end': { stepId: string } } }

事件溯源带来的直接好处:

  • Fork 会话:从任意事件序号切出一个子会话,两个会话共享分叉前的历史
  • 断点续传:应用重启后从日志恢复完整状态
  • 审计:每个决策的前因后果都有完整记录
  • 重放:Telemetry 和调试都可以通过重放日志实现

8.2 两种持久化后端

后端格式特点
JSONL每会话一个追加文件Zstandard 压缩,每个帧可独立解码,崩溃恢复只需截断尾部
SQLite每会话的行映射WAL 日志模式,原子追加事务,可通过 SQL 查询特定事件范围

两者共享 PersistenceCoordinator,它负责写批合并(可配置延迟,减少磁盘 I/O)、会话缓存(避免重复加载)和修订追踪。


九、Typert:跨进程的类型安全 RPC

9.1 问题:前后端分离后如何保证类型安全

当 Host 和 Client 是两个独立的 TypeScript 程序时,它们无法共享类型定义。DSH 的解决方案是 Typert——一个运行时类型注册表,在构建时从 Host 端代码中提取类型信息,生成反射 schema,然后在运行时供 Client 端查询。

9.2 工作原理

1
// 第一步:Host 端包声明自己提供的类型 declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertLookupMap { // 包 'agent' 提供了一个名为 'agent' 的 lookup,类型为 Agent agent: TypertLookup<Agent, SessionId> } interface TypertContextMap { // 包 'agent' 需要 SessionId 作为上下文 agent: TypertContext<SessionId> } } // 第二步:运行时,Client 端可以查询任何已注册的类型 const descriptor = typertKey('agent', 'followup') // 获取该方法的 JSON Schema const schema = ctx.typert.toJSONSchema(descriptor) // 远程调用该方法 const result = await ctx.typert.invoke(descriptor, args)

typertKey(package, name) 生成全局唯一键(格式为 <package>#<name>),注册表负责解析和路由。构建时,tsdown 的 Typert 插件扫描 Host 编译产物,自动生成反射数据。


十、扩展机制:Skill、Command 与 Hook

10.1 Skill 技能系统

Skill 是 Agent 可动态加载的能力模块。每个 Skill 由一个 SkillProvider 提供:

1
interface SkillProvider { name: string // 列出所有可用的技能候选 list(options): Promise<SkillCandidate[]> // 加载某个技能的完整内容 get(candidate, options): Promise<Skill> }

每个 Skill 有两个可见性标志:

  • modelInvocable:是否出现在模型的工具目录中(模型可以主动调用)
  • userInvocable:是否出现在用户的命令目录中(用户可以通过 /skill-name 调用)

Skill 使用作用域分层机制:全局注册 + 每个 Agent 作用域可覆盖。同一层内,同名 Skill 先注册的生效;不同层之间,更近的作用域优先。

Skill 的内容被渲染为规范的 XML 格式,传给模型:

1
<skill_content> <skill_resources> <!-- 技能所需的资源文件 --> </skill_resources> <skill_instructions> <!-- 技能的使用说明 --> </skill_instructions> </skill_content>

10.2 Command 命令系统

Command 是不经过模型的用户直接操作,通过斜杠命令触发(如 /clear/compact):

1
interface CommandDefinition { name: string // 必须匹配 /^[a-z][a-z0-9_-]*$/u description: string execute(agent, line, signal): Promise<CommandResult> } type CommandResult = | { kind: 'success', text?: string, sourceEventSeq?: number } | { kind: 'error', text: string }

Command 的执行生命周期是:command/run(持久化,记录调用)→ handler 执行 → command/done(持久化,记录结果)。成功时可以通过 sourceEventSeq 指向一个领域事件,让前端做更丰富的展示。

10.3 Hook 系统

DSH 通过 Hook 系统与外部 Agent 工具( Claude Code、OpenAI Codex)集成。packages/hooks/ 下的包实现了与这些工具的桥接协议,允许 DSH 作为中间层统一管理多个 Agent 工具的会话、权限和资源。


十一、上下文压缩:在 Token 限制下保持对话连贯

11.1 为什么需要压缩

每次 LLM 调用都需要把历史对话作为上下文发送。随着对话增长,token 消耗线性增加,最终超出模型的上下文窗口限制。压缩的目标是:用一段简短的摘要替换早期的对话内容,在保持关键信息不丢失的前提下减少 token 消耗。

11.2 压缩策略

DSH 的 CompactionEnginectx.compaction)定义了压缩的抽象接口,BasicCompactionEngine 是默认实现。一次压缩的完整流程:

  1. 触发判断:通过 ctx.tokenMeter 测量当前上下文压力。达到阈值(thresholdRatio)时触发压缩。
  2. 范围选择selectCompactableRange() 从对话历史中选出一个"可压缩范围"。选择时会尊重工具调用配对——如果一对 tool/calltool/result 被压缩,它们必须一起被压缩,否则模型会看到一个不完整的工具调用。
  3. 摘要生成:用一个专门的 LLM 调用(可以使用不同的模型,比如用更便宜的模型做摘要)生成压缩范围的摘要。
  4. 替换事务:用一个 user/message 事件(携带摘要内容)替换选定的表面范围。压缩本身的事件(compaction/startcompaction/summarycompaction/end)只写入日志,不进入模型可见的表面。

这套机制支持两种所有权模式:

  • 'current-turn':仅压缩当前 Turn 内的内容
  • 'whole-surface':压缩整个对话表面

十二、构建体系

12.1 构建流程

pnpm build
  ├── build:lib:host   → tsc -b tsconfig.host.json        # Host 端类型检查
  │                    → tsdown --env.DSH_BUILD_FACE host  # Host 端打包 + Typert 类型生成
  │
  ├── build:lib:client → tsc -b tsconfig.client.json      # Client 端类型检查
  │                    → tsdown --env.DSH_BUILD_FACE client # Client 端打包
  │
  └── build:web        → pnpm --filter @deepseek-ai/dsh-web-frontend run build
                       → vite build                        # Web 前端 Vite 构建

tsdown 基于 Rolldown(Rust 编写的打包器,与 Rollup 兼容),通过 tsdown.config.ts 统一管理 workspace 级别的打包配置。typertPlugin 在 Host 面构建时运行,从 TypeScript 编译产物中提取类型信息并生成运行时反射 schema。

12.2 代码质量工具链

  • Oxlint + ESLint:双轨 lint。Oxlint 处理快速检查,ESLint 处理 Stylistic 和 SonarJS 等需要 AST 分析的规则
  • Lefthook:管理 Git hooks,确保提交前通过检查
  • Knip:检测未使用的文件和依赖
  • publint:在发布前验证每个包的 package.json 配置

十三、SDK:将 Agent 能力嵌入你的应用

13.1 TypeScript SDK

1
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' // 创建一个 Harness 实例(内部管理一个运行时子进程) const dsh = new DeepSeekHarness() await dsh.start() // 发送一条消息,等待 Agent 完成 const result = await dsh.run('请帮我分析这个项目的架构', { provider: 'deepseek-official', model: 'deepseek-v4-flash', }) console.log(result.finalResponse) // Agent 的最终回复 // result.events 包含完整的事件流 // result.notifications 包含子代理启动/完成等通知 await dsh.close()

DeepSeekHarness 是高级 API,在内部管理一个 DSH 运行时子进程,跨多个会话复用。底层 HarnessClient 通过子进程的 stdio 进行 JSON-RPC 2.0 通信,包含完整的错误处理:传输层错误(TransportClosedError)、超时错误(RequestTimeoutError)和协议错误(SdkProtocolError)。

13.2 扩展点速查

当你需要扩展 DSH 时,以下是各场景对应的机制:

你想做什么在哪里做
接入新的 LLM 提供商实现 LlmAdapter,注册到 ctx.llm
添加新的工具定义 ToolDefinition,注册到 ctx.tools
在工具执行前插入审批监听 tools/pre-execute waterfall 事件
添加新的 Shell 后端注册 ctx.shell 提供商
添加用户命令注册到 ctx.commands
添加文件系统策略监听 fs/* 事件
拦截每次 LLM 请求监听 agent/requestllm/stream waterfall 事件
添加前端聊天节点注册 ConversationNodeDefinition + 渲染器
扩展会话事件类型扩展 SessionEventMap 接口
添加新的持久化后端实现 SessionPersistence 抽象类

十四、设计原则总结

回顾全文,DSH 的架构体现了几个鲜明的设计原则:

1. 一切皆可替换。 没有"特权核心"。Agent 循环、工具注册表、LLM 适配器、会话持久化——全部是插件,全部可以从 YAML 配置替换。文件系统和子进程提供商共享一个执行世界,将它们指向远程沙箱即可同时移动 Bash、PTY 和 LSP 的能力。

2. 声明式依赖。 插件通过 inject 声明自己需要什么服务,框架自动处理加载顺序和卸载。不存在"确保 X 在 Y 之前初始化"的样板代码。

3. 多阶段拦截。 工具执行管线(pre-execute → execute → post-execute → result)和 LLM 调用流(request → stream)都设计为多阶段 waterfall 事件链,每个阶段都可以被插件拦截、包装或重写。

4. 事件溯源。 所有会话状态来自可重放的只追加日志。Fork 会话、断点续传、审计和调试都是这一设计的自然结果。

5. 编译时分离。 Host 和 Client 是两个独立的 TypeScript 程序,通过 Typert 生成的反射 schema 在运行时保证类型安全,而不是通过共享类型文件。


本文基于 DeepSeek Harness v0.1.0-rc.5 源码分析,架构细节可能随版本迭代变化。