DeepSeek Harness: 实战:写一个可替换的能力
最后更新:2026-08-31
理论理解了三角色,但"纸上得来终觉浅"。本课通过一个完整的实战项目,从零实现一个可替换的文件统计能力——定义接口、写本地实现、写沙箱实现、消费能力、切换 Provider,全流程走一遍。
💡 提示:这个实战项目的重点是"切换 Provider 前后 Consumer 代码不变"——如果 Consumer 需要改代码,说明你的 Definition 抽象不够好。
📋 前置知识:已完成 22-capability.md,理解能力三角色
1. 你将学到
- 完整示例:从 Definition 到 Provider 到 Consumer
- 定义一个文件统计能力
- 实现本地 Provider
- 实现远程沙箱 Provider
- 在工具中消费能力
- 切换 Provider 的效果
2. 项目结构
(1) ▶ 示例 1
TEXT
📖 仅展示
file-stats-capability/
├── definition.ts ← Definition
├── providers/
│ ├── local.ts ← 本地 Provider
│ └── sandbox.ts ← 远程沙箱 Provider
├── consumer/
│ └── file-info-tool.ts ← Consumer 插件
├── types.ts ← 类型声明
└── index.ts ← 导出
(2) ▶ 示例 2
graph TB
DEF[definition.ts] --> LOCAL[providers/local.ts]
DEF --> SANDBOX[providers/sandbox.ts]
DEF --> TOOL[consumer/file-info-tool.ts]
3. 定义文件统计能力
(1) 需求分析
文件统计能力需要提供:
- 统计文件数量
- 计算目录大小
- 按扩展名分类
- 检查路径是否存在
(2) ▶ 示例 2
TYPESCRIPT
// definition.ts
import { defineCapability } from '@deepseek-ai/cordis'
export interface FileStatsResult {
totalFiles: number
totalDirs: number
totalSize: number
byExtension: Record<string, number>
}
export interface FileStatsCapability {
countFiles(dir: string, recursive?: boolean): Promise<number>
calcSize(dir: string): Promise<number>
analyze(dir: string): Promise<FileStatsResult>
exists(path: string): Promise<boolean>
}
export const FileStats = defineCapability({
name: 'file-stats',
description: 'File statistics and analysis capability',
interface: {} as FileStatsCapability
})
(3) 类型声明
TYPESCRIPT
// types.ts
import { FileStatsCapability } from './definition'
declare module '@deepseek-ai/cordis' {
interface Context {
'file-stats': FileStatsCapability
}
}
4. 实现本地 Provider
(1) 代码
TYPESCRIPT
// providers/local.ts
import { Service, Context } from '@deepseek-ai/cordis'
import { FileStats, FileStatsResult } from '../definition'
import { readdir, stat } from 'fs/promises'
import { join } from 'path'
export default class LocalFileStatsProvider extends Service {
static inject = ['fs']
constructor(ctx: Context) {
super(ctx, 'file-stats')
ctx.implement(FileStats, {
async countFiles(dir: string, recursive = false): Promise<number> {
const result = await this._walk(dir, recursive)
return result.totalFiles
},
async calcSize(dir: string): Promise<number> {
const result = await this._walk(dir, true)
return result.totalSize
},
async analyze(dir: string): Promise<FileStatsResult> {
return await this._walk(dir, true)
},
async exists(path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}
})
}
private async _walk(dir: string, recursive: boolean): Promise<FileStatsResult> {
let totalFiles = 0
let totalDirs = 0
let totalSize = 0
const byExtension: Record<string, number> = {}
await this._walkInner(dir, recursive, (fileStat) => {
totalFiles++
totalSize += fileStat.size
const ext = fileStat.name.includes('.')
? '.' + fileStat.name.split('.').pop()!.toLowerCase()
: '(no extension)'
byExtension[ext] = (byExtension[ext] || 0) + 1
}, () => {
totalDirs++
})
return { totalFiles, totalDirs, totalSize, byExtension }
}
private async _walkInner(
dir: string,
recursive: boolean,
onFile: (f: { name: string; size: number }) => void,
onDir: () => void
): Promise<void> {
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile()) {
const s = await stat(join(dir, entry.name))
onFile({ name: entry.name, size: s.size })
} else if (entry.isDirectory()) {
onDir()
if (recursive) {
await this._walkInner(join(dir, entry.name), recursive, onFile, onDir)
}
}
}
}
}
(2) 注册到 cordis.yml
YAML
plugins:
file-stats:
$insert: /home/alice/dev/file-stats-capability/providers/local
5. 实现远程沙箱 Provider
(1) 设计
沙箱 Provider 将文件操作委托给远程服务:
graph LR
TOOL[Consumer] -->|调用| CAP[file-stats 能力]
CAP -->|HTTP| SANDBOX[沙箱服务<br/>sandbox:8080]
SANDBOX -->|操作| FS[隔离文件系统]
(2) 代码
TYPESCRIPT
// providers/sandbox.ts
import { Service, Context } from '@deepseek-ai/cordis'
import { FileStats, FileStatsResult } from '../definition'
export const Config = Schema.object({
endpoint: Schema.string().default('http://sandbox:8080').description('Sandbox API endpoint'),
timeout: Schema.number().default(30000).description('Request timeout in ms')
})
export default class SandboxFileStatsProvider extends Service {
static inject = []
private endpoint: string
private timeout: number
constructor(ctx: Context) {
super(ctx, 'file-stats')
this.endpoint = ctx.config.endpoint
this.timeout = ctx.config.timeout
ctx.implement(FileStats, {
countFiles: (dir, recursive) =>
this._call('count-files', { dir, recursive }).then(r => r.count),
calcSize: (dir) =>
this._call('calc-size', { dir }).then(r => r.size),
analyze: (dir) =>
this._call<FileStatsResult>('analyze', { dir }),
exists: (path) =>
this._call('exists', { path }).then(r => r.exists)
})
}
private async _call<T = any>(action: string, params: Record<string, any>): Promise<T> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeout)
try {
const response = await fetch(`${this.endpoint}/file-stats/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
signal: controller.signal
})
if (!response.ok) {
throw new Error(`sandbox error: ${response.status} ${response.statusText}`)
}
return await response.json() as T
} finally {
clearTimeout(timer)
}
}
}
(3) 注册到 cordis.yml
YAML
plugins:
file-stats:
$replace: /home/alice/dev/file-stats-capability/providers/sandbox
config:
endpoint: http://sandbox:8080
timeout: 15000
6. 在工具中消费能力
(1) Consumer 插件代码
TYPESCRIPT
// consumer/file-info-tool.ts
import { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh'
export const name = 'tool-file-info'
export const inject = ['tools', 'file-stats']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'file_info',
description: 'Get detailed file statistics for a directory. Returns file count, total size, and breakdown by extension.',
parameters: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Directory path to analyze'
},
recursive: {
type: 'boolean',
description: 'Include subdirectories in analysis',
default: true
}
},
required: ['path']
},
async execute({ path, recursive }, ctx) {
try {
const exists = await ctx['file-stats'].exists(path)
if (!exists) {
return { error: true, message: `Path does not exist: ${path}` }
}
const stats = await ctx['file-stats'].analyze(path)
return {
path,
recursive,
totalFiles: stats.totalFiles,
totalDirs: stats.totalDirs,
totalSize: stats.totalSize,
totalSizeHuman: formatSize(stats.totalSize),
byExtension: stats.byExtension
}
} catch (error: any) {
return {
error: true,
message: `Failed to analyze directory: ${error.message}`
}
}
}
}))
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
}
(2) 关键观察
Consumer 代码中没有任何 local 或 sandbox 的字样——它只依赖 file-stats 能力的接口。这正是能力系统的价值。
7. 切换 Provider 的效果
(1) 使用本地 Provider
YAML
# cordis.yml — 本地开发
plugins:
file-stats:
$insert: /home/alice/dev/file-stats-capability/providers/local
Agent 调用 file_info 工具:
TEXT
📖 仅展示
👤 Alice: 分析 /home/alice/project 目录
🤖 Agent:
🔧 Using tool: file_info
→ path: /home/alice/project
Result: {
path: "/home/alice/project",
totalFiles: 42,
totalDirs: 5,
totalSize: 245760,
totalSizeHuman: "240.0 KB",
byExtension: { ".ts": 28, ".js": 10, ".json": 4 }
}
(2) 切换到沙箱 Provider
YAML
# cordis.yml — 沙箱环境
plugins:
file-stats:
$replace: /home/alice/dev/file-stats-capability/providers/sandbox
config:
endpoint: http://sandbox:8080
重启后,Agent 调用同一个工具:
TEXT
📖 仅展示
👤 Alice: 分析 /workspace/project 目录
🤖 Agent:
🔧 Using tool: file_info
→ path: /workspace/project
Result: {
path: "/workspace/project",
totalFiles: 42,
totalDirs: 5,
totalSize: 245760,
totalSizeHuman: "240.0 KB",
byExtension: { ".ts": 28, ".js": 10, ".json": 4 }
}
Consumer 代码完全不变,结果格式一致——只是底层从本地文件系统变成了远程沙箱 API。
(3) 对比
| 维度 | 本地 Provider | 沙箱 Provider |
|---|---|---|
| 实现方式 | 直接 fs 调用 | HTTP API 调用 |
| 文件系统 | 本地磁盘 | 沙箱隔离环境 |
| 延迟 | < 1ms | 50-200ms |
| 安全性 | 直接访问 | 沙箱隔离 |
| Consumer 代码 | 相同 | 相同 |
| 返回格式 | 相同 | 相同 |
❓ 常见问题
Q 两个 Provider 的返回格式必须完全一致吗?
A 是的。这是 Definition 的核心约束——所有 Provider 必须遵循同一个接口。返回格式不同会导致 Consumer 解析错误。
Q 沙箱 Provider 的延迟更高,Consumer 需要处理吗?
A 不需要。延迟对 Consumer 透明。如果需要优化,可以在 Consumer 层加缓存,或用异步提示用户等待。
Q 如何保证 Provider 实现了所有方法?
A TypeScript 编译时检查。如果 Provider 缺少 Definition 中的方法,编译报错。
Q 能力系统可以用在插件配置中吗?
A 可以。不同的 Provider 可以有不同的 Config:
yaml file-stats: $insert: ./providers/sandbox config: endpoint: http://sandbox:8080 # sandbox Provider 专属配置 Q Consumer 如何知道当前使用的是哪个 Provider?
A 通常不需要知道。如果确实需要,可以在 Definition 中加
providerInfo() 方法,各 Provider 返回自身信息。 ---📖 小节
- 完整流程:Definition → Provider → Consumer → 切换验证
- Definition 声明
FileStatsCapability接口,包含 countFiles/calcSize/analyze/exists - 本地 Provider 用 fs API 直接操作,沙箱 Provider 用 HTTP 委托远程服务
- Consumer 只依赖接口,不知道也不关心具体 Provider
- 切换 Provider 只需改 cordis.yml 配置,Consumer 代码零修改
- 所有 Provider 必须返回相同格式的结果,这是 Definition 的核心约束
📝 作业
1. ⭐ 基础题:按照本课步骤,创建 FileStats 能力的 Definition 和本地 Provider,注册后通过 Consumer 工具调用验证。
2. ⭐⭐ 进阶题:实现一个 InMemoryProvider——文件数据预存在内存 Map 中(不访问真实文件系统),适合单元测试。验证 Consumer 工具在 InMemoryProvider 下也能正常工作。
3. ⭐⭐⭐ 挑战题:实现一个 CachedProvider——装饰器模式,在另一个 Provider 前加缓存层。缓存最近 N 次的分析结果,相同路径直接返回缓存。注意:CachedProvider 本身也是一个 Provider,它内部委托给另一个 Provider。