
从20行到20万行:DeepSeek Harness如何解决Agent工程的两大死穴
你写了一个能跑的Agent,感觉没什么难的。直到你要换模型厂商、加监控、做审批、处理并发冲突------那20行循环开始向你索命。这篇文章拆解DeepSeek Harness的设计,看它如何用20万行代码回答两个最致命的问题。
写在前面:一个诚实的问题
如果你只在自己笔记本上跑Agent,出错了手动重来一遍,20行代码确实够了。DeepSeek Harness的20万行对你来说是纯粹的负担------这不是羞辱,是事实。
但如果你想让它被多人用、能被打断、能恢复、要审批、要换模型、要在别人机器上跑、要让第三方加功能------那20行会在九个不同的地方死给你看。这九个死法里,没有一种是"模型不够聪明",全都是普通软件工程问题。
而两个最核心的工程问题,可以浓缩成一句话:
你的业务逻辑(Agent循环)和基础设施(模型API、执行环境、扩展功能)缠在一起了。
这篇文章从这两个问题出发,看DeepSeek Harness怎么解的。
问题一:换一家模型厂商,为什么这么疼?
你的代码长什么样
typescript
// 你的 20 行版本里,callModel 大概长这样
async function callModel(messages: Message[]) {
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${DEEPSEEK_API_KEY}`, // ← 特定鉴权
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'deepseek-v4',
messages: messages, // ← 特定字段名
tools: toolSchemas, // ← 特定字段名
tool_choice: 'auto'
})
})
return response.json()
}
看着没毛病。直到产品说:"我们切到某友商试试,便宜一半。"
打开友商文档一看:
- URL 不一样
- 鉴权方式不一样(某友商用
Bearer还是x-api-key?) - 消息字段叫
messages还是prompt? - 工具调用字段叫
tool_calls还是function_call? - 返回结构不一样
- 流式格式不一样
你的选择:
- 改代码,搜索替换。改完友商能跑了,原来那家跑不了了。
- 加分支:
typescript
if (provider === 'deepseek') { /* 一套 */ }
else if (provider === 'qwen') { /* 另一套 */ }
else if (provider === 'openai') { /* 第三套 */ }
// ... 五家之后,callModel 300 行
- 加一个"超时自动切备用厂商" ------上述分支再乘二。
核心问题:你的 Agent 循环(业务逻辑)跟模型 API(基础设施)绑死了。
问题二:加新功能为什么要改核心循环?
你的代码长什么样
typescript
while (true) {
const reply = await callModel(messages)
if (reply.tool_calls) {
const results = await Promise.all(
reply.tool_calls.map(tc => executeTool(tc))
)
messages.push(...results)
continue
}
return reply.content
}
很干净。然后需求来了:
- 运营说:"每次调用前打一条日志到监控系统。"
- 安全说:"执行
rm -rf之前要人工确认。" - 产品说:"工具超过 30 秒就中断。"
- 老板说:"切到友商试试,但失败了要自动重试。"
每个人都让你"改一下循环"。三个月后:
typescript
while (true) {
// 监控埋点(运营的)
await emitMetric('agent.step.start', { messages: messages.length })
// 重试逻辑(老板的)
let retries = 0
let reply = null
while (retries < 3) {
try {
// 超时控制(产品的)
reply = await withTimeout(callModel(messages), 30000)
break
} catch (e) {
retries++
if (retries >= 3) throw e
}
}
if (reply.tool_calls) {
for (const tc of reply.tool_calls) {
// 人工审批(安全的)
if (isDangerousCommand(tc)) {
const approved = await askHuman(`允许执行 ${tc.name} 吗?`)
if (!approved) continue
}
// 执行工具...
}
}
// 监控埋点(运营的)
await emitMetric('agent.step.end', { toolCount: reply.tool_calls?.length })
return reply.content
}
300 行。没人敢动。改一处会影响另一处------监控和超时谁先谁后?超时了还要不要打监控?代码里没有答案,只有隐含顺序。
核心问题:你的循环没有"切入点"。新功能只能"寄生"在主流程里。
一、核心设计哲学:一切皆插件
最根本的设计原则 (来自 README.zh.md 和 AGENTS.md):
产品的每一部分都是插件,包括模型适配器、工具注册表、会话日志,以及 agent loop 本身,因此每一部分都可以从配置替换。不存在需要打补丁的特权内核。
翻译成人话:这个系统没有主程序 。没有 main() 入口,启动时是按配置把一堆插件挂到一棵树上,各自声明需要什么、提供什么,框架算出顺序后一切自己长起来。
连 agent-loop 本身也只是 Agent 接口的一个默认实现,可以整个换掉。
代码里长这样: 一个最简插件就是一个导出 apply 函数的模块:
typescript
// packages/plugins/my-plugin/src/index.ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-plugin' // 插件的唯一标识
export function apply(ctx: Context) {
// 插件加载时执行
console.log('[my-plugin] loaded!')
}
注意:没有 main(),没有任何人"调用"这个文件------是 Cordis 框架在启动时扫描配置,发现需要 my-plugin,然后调用它的 apply。
二、组装机制
- Profile(配置方案) :一份具名的组装方案,列出叠放哪些组合包(bundle)。仓库交付两个模板:
web(带界面)和headless(无服务器的一次性运行器)。 - 组合包(Bundle) :Cordis 配置项及其挂载代码的分发格式。
dsh-base是每个 profile 的第一层。 - 叠加顺序 :profile 列出的组合包 →
cordis.patch.yml→ home 级 patch → 命令行--patch。Patch 按 id 定位条目,替换整个 config 或插入新条目。 - 想知道实际启动了啥:
dsh --profile web --dump-config,打印出的任何条目都可被自己的 patch 替换。
代码里长这样: 一个 bundle 的入口文件会批量注册插件:
typescript
// packages/bundles/dsh-base/src/index.ts
import { Context } from '@deepseek-ai/cordis'
export function apply(ctx: Context) {
// 批量加载基础插件
ctx.plugin(import('@deepseek-ai/dsh-core'))
ctx.plugin(import('@deepseek-ai/dsh-tools'))
ctx.plugin(import('@deepseek-ai/dsh-llm-deepseek'))
ctx.plugin(import('@deepseek-ai/dsh-session-jsonl'))
// ... 更多基础服务
}
而用户自己的 patch 配置长这样(YAML):
yaml
# ~/.dsh/cordis.patch.yml
patch:
# 替换默认的 llm 提供方
- id: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: 'sk-xxx'
model: 'deepseek-v4-flash'
# 插入一个新工具
- id: 'tools.register'
insert:
- '@my-company/dsh-tool-greet'
三、能力接缝(Seam)模型
把「可替换能力」形式化为 seam(能力接缝),必须有三种角色:
| 角色 | 说明 | 范例(shell 组) |
|---|---|---|
| Service Definition | 声明接口 | dsh-shell |
| Service Provider | 实现接口 | dsh-bash-local / dsh-bash-sandbox |
| Consumer | 使用该能力 | dsh-tool-bash |
代码里长这样:
第一步:定义接口(Service Definition)
typescript
// packages/shell/dsh-shell/src/index.ts
import type { Context } from '@deepseek-ai/cordis'
// 声明一个服务键(Service Key)
export interface Shell {
exec(command: string, options?: { cwd?: string }): Promise<{ stdout: string; stderr: string; code: number }>
}
// 在 Context 上挂载这个服务
declare module '@deepseek-ai/cordis' {
interface Services {
shell: Shell
}
}
// 插件本身不实现 shell,只声明"我需要别人提供 shell 才能工作"
export const inject = ['shell'] // ← 声明依赖
第二步:实现接口(Service Provider)
typescript
// packages/shell/dsh-bash-local/src/index.ts
import { exec } from 'child_process'
import type { Shell } from '@deepseek-ai/dsh-shell'
export const name = 'dsh-bash-local'
export const provides = ['shell'] // ← 声明我提供 shell 服务
export function apply(ctx: Context) {
// 实现 Shell 接口
const shell: Shell = {
async exec(command, options) {
return new Promise((resolve, reject) => {
exec(command, { cwd: options?.cwd }, (error, stdout, stderr) => {
resolve({ stdout, stderr, code: error?.code ?? 0 })
})
})
}
}
// 把实现注册到上下文中
ctx.provide('shell', shell)
}
第三步:消费方(Consumer)
typescript
// packages/tools/dsh-tool-bash/src/index.ts
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'dsh-tool-bash'
export const inject = ['shell'] // ← 我需要 shell 才能工作
export function apply(ctx: Context) {
// 注册一个叫 bash 的工具,它内部使用 shell 服务
ctx.tools.register(defineTool({
name: 'bash',
description: 'Execute a bash command',
parameters: {
command: { type: 'string', required: true },
cwd: { type: 'string' }
},
async execute(args) {
// ctx.shell 就是上面那个 Provider 提供的实现
return await ctx.shell.exec(args.command, { cwd: args.cwd })
}
}))
}
关键点:ctx.shell 到底是本地 bash 还是远程沙箱,dsh-tool-bash 完全不知道,也不关心。把 dsh-bash-local 换成 dsh-bash-e2b,整个产品的执行环境就从本地换到了云端沙箱------改配置,不改代码。
四、会话与恢复机制
- 对话被设计为仅追加的事件日志 (
SessionEvent),模型历史是从日志投影出来的。 - 有了日志,恢复、fork、回放、遥测都是同一份数据的不同读法。
- 两种持久化后端:JSONL 和 SQLite。
代码里长这样:
typescript
// packages/core/dsh-session/src/index.ts
export interface SessionEvent {
id: string
sessionId: string
timestamp: number
type: 'user_message' | 'assistant_message' | 'tool_call' | 'tool_result' | 'step_start' | 'step_end' | 'turn_start' | 'turn_end'
data: unknown
}
// 会话日志:只追加,不修改
export class SessionLog {
private events: SessionEvent[] = []
append(event: Omit<SessionEvent, 'id' | 'timestamp'>) {
this.events.push({
...event,
id: generateId(),
timestamp: Date.now()
})
// 同时写入持久化后端(JSONL 或 SQLite)
this.persist(this.events[this.events.length - 1])
}
// 投影:从事件列表重建模型看到的上下文
projectToMessages(): Message[] {
const messages: Message[] = []
for (const event of this.events) {
if (event.type === 'user_message') {
messages.push({ role: 'user', content: event.data.content })
} else if (event.type === 'assistant_message') {
messages.push({ role: 'assistant', content: event.data.content, tool_calls: event.data.tool_calls })
} else if (event.type === 'tool_result') {
messages.push({ role: 'tool', content: event.data.result, tool_call_id: event.data.tool_call_id })
}
// turn_start / turn_end 不影响消息历史,只影响 UI 分组
}
return messages
}
}
崩溃后恢复时,只需要从持久化后端重新加载所有事件,再调用 projectToMessages(),模型就"记得"之前的一切------尽管模型本身没有记忆,是日志替它记住了。
五、循环与钩子体系
你的 20 行循环的真身是 packages/core/agent-loop,有 1,643 行(占全仓 0.89%)。
设计上把「循环做什么」和「外部想插一脚」拆开:
- 在关键位置(发请求前、拿到回复后、工具执行前后)留出钩子点。
- 采用 waterfall(瀑布式事件) ------监听器可以调用
next()继续传,也可以不调next()短路拦截。 - 所有注册必须返回释放函数(disposer),保证插件可装可卸("注册是可逆的副作用")。
代码里长这样:
typescript
// packages/core/dsh-agent-loop/src/index.ts
export function apply(ctx: Context) {
// 注册 agent 服务
ctx.provide('agent', {
async run(input: string, sessionId: string) {
const session = await ctx.session.load(sessionId)
let messages = session.projectToMessages()
// 追加用户输入
messages.push({ role: 'user', content: input })
session.append({ type: 'user_message', data: { content: input } })
while (true) {
// ════════════════════════════════════════════════
// 钩子点 1: agent/pre-step --- 决定模型看到什么
// ════════════════════════════════════════════════
let stepMessages = messages
for (const hook of ctx.agentHooks.preStep) {
stepMessages = await hook(stepMessages) // 每个钩子可以修改消息列表
}
// ════════════════════════════════════════════════
// 钩子点 2: agent/request --- 瀑布式,可短路
// ════════════════════════════════════════════════
let response: AssistantResponse | null = null
// waterfall: 监听器依次执行,每个可以调用 next() 继续
await ctx.emit.waterfall('agent/request', stepMessages, async (messages, next) => {
// 这是默认实现:调用模型
const result = await ctx.llm.chat(messages, { tools: ctx.tools.list() })
response = result
return result
})
// 如果某个钩子短路了(没调 next()),response 可能已经被替换
// 比如缓存命中:直接返回缓存结果,不调用模型
if (!response) {
// 所有监听器都没返回,报错
throw new Error('No response from any agent/request handler')
}
// ════════════════════════════════════════════════
// 钩子点 3: 工具执行前(tools/pre-execute)
// ════════════════════════════════════════════════
if (response.tool_calls) {
for (const toolCall of response.tool_calls) {
// 先过守卫(monotonic guard)
for (const guard of ctx.tools.preExecute) {
const decision = await guard(toolCall)
if (decision === 'deny') {
throw new Error(`Tool ${toolCall.name} denied by guard`)
}
// 注意:只许否决,不许放行(guard 不返回 allow)
// 这是设计意图:没有人能替审批者做决定
}
// 执行工具
const result = await ctx.tools.execute(toolCall)
messages.push({ role: 'tool', content: result, tool_call_id: toolCall.id })
session.append({ type: 'tool_result', data: { tool_call_id: toolCall.id, result } })
}
// ════════════════════════════════════════════════
// 钩子点 4: agent/post-step --- 拿到回复后
// ════════════════════════════════════════════════
for (const hook of ctx.agentHooks.postStep) {
await hook(response, messages)
}
continue // 继续循环,让模型看到工具结果
}
// 没有 tool_calls,结束
return response.content
}
}
})
}
插件如何挂钩子:
typescript
// 一个监控插件:记录每次请求的 token 数
export function apply(ctx: Context) {
// 注册一个 pre-step 钩子
const disposer = ctx.agentHooks.preStep.push(async (messages) => {
const estimate = ctx.tokenMeter.estimate(messages)
console.log(`[monitor] step started with ${estimate} tokens`)
// 修改消息------加一条系统提示
return [{ role: 'system', content: 'You are being monitored.' }, ...messages]
})
// 返回释放函数!这就是"注册是可逆的副作用"
return disposer
}
更高级的:用 ctx.effect() 包装,确保插件卸载时自动清理所有注册:
typescript
export function apply(ctx: Context) {
// effect 会在插件卸载时自动调用返回的清理函数
ctx.effect(() => {
const timer = setInterval(() => console.log('heartbeat'), 1000)
return () => clearInterval(timer) // 插件卸载时停止 heartbeat
})
// 用 ctx.on 注册的事件也会自动清理
ctx.on('agent/request', () => { /* ... */ }) // 插件卸载时自动移除
}
DeepSeek Harness 是一个基于 Cordis、一切皆插件、无特权内核、以 seam 为能力接缝、以事件日志为唯一真相来源 的 Agent 框架。它把 20 行循环膨胀到 20 万行,不是因为循环变复杂了,而是把循环之外的工程问题(界面、持久化、权限、协议适配、可替换性)全部做了进去。