Deepseek Agent Harness教程(二) | DeepSeek Harness 设计思路

从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
  • 返回结构不一样
  • 流式格式不一样

你的选择:

  1. 改代码,搜索替换。改完友商能跑了,原来那家跑不了了。
  2. 加分支
typescript 复制代码
if (provider === 'deepseek') { /* 一套 */ }
else if (provider === 'qwen') { /* 另一套 */ }
else if (provider === 'openai') { /* 第三套 */ }
// ... 五家之后,callModel 300 行
  1. 加一个"超时自动切备用厂商" ------上述分支再乘二。

核心问题:你的 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.mdAGENTS.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 万行,不是因为循环变复杂了,而是把循环之外的工程问题(界面、持久化、权限、协议适配、可替换性)全部做了进去。

相关推荐
探物 AI1 小时前
yolo目标检测中的激活函数对比
人工智能·yolo·目标检测
用户298698530141 小时前
从入门到自动化:TXT 转 Word 的在线工具与代码实战方案
人工智能·后端·python
DeepIntelli1 小时前
AI问答品牌推荐:如何用GEO让品牌出现在豆包、文心一言的答案里
人工智能
m4Rk_1 小时前
【论文阅读】Agent 记忆机制(40):HiAgent——通过子目标级记忆提升长程任务执行能力
论文阅读·人工智能·学习·开源·github
还不秃顶的计科生1 小时前
具身智能论文学习10:π0: A Vision-Language-Action Flow Model for General Robot Control
人工智能·深度学习·算法·机器学习·语言模型·vla·vlm
静开1 小时前
Claude Code快速窥探:原来内核就是一个 while 循环外面套了八层壳
人工智能
AIyy8661 小时前
定制化企业网盘深度解析:技术能力、落地场景与产品选型指南
人工智能
happyprince1 小时前
02-具体观 — Cordis 算法与实现剖析
算法
W_326001 小时前
Python-OpenCV:库、环境搭建、图片与摄像头解析
开发语言·图像处理·python·opencv·机器学习