DEV Community

Henry Lin
Henry Lin

Posted on

DeepSeek Harness (`dsh`) Agent 开发教程

DeepSeek Harness (dsh) Agent 开发教程

本教程面向想开发 Agent 的开发者。这里的"开发 Agent"有不同含义,dsh 对应不同做法:组合一个可运行的 agent(声明式)、写程序驱动 agent 的协议层/UI、"造"一个自己的 agent 驱动,以及让 agent 能委派子代理。教程把每条路都走一遍,并在开头讲清楚该选哪条。

目录

  1. Agent 在架构中的位置
  2. 五个核心概念
  3. 三条开发路径怎么选
  4. 路径一:组合一个可运行 Agent
  5. 路径二:程序化创建与驱动 Agent
  6. 路径三:实现自定义 Agent 驱动
  7. 拦截与定制:不改 loop 的扩展点
  8. 委派:子代理(Subagent)
  9. 测试与验证
  10. 常见问题排查
  11. 完整示例:多角色量化研究 Agent

1. Agent 在架构中的位置

dsh 是插件驱动的 harness。Agent 不是内置的"黑盒",而是由一组插件搭出来的 spine。每个部分都可以替换,包括 agent 主循环本身。

由六个包构成的核心 spine(各自职责见 docs/subsystems/core.md):

职责 ctx key
core/session 追加式会话事件日志,事实源 ctx.sessions
core/system-prompt prompt 段 + 工具 schema 组装 ctx.systemPrompt
core/tools 工具注册表 + 执行管线 ctx.tools
core/agent Agent 接口、活注册表、agent/* 事件、initiator ctx.agents
core/agent-loop 具体驱动(唯一实现) ctx.agentLoop
core/scope 每个 agent 的独立注册空间 agent.ctx

重要约定:扩展插件依赖 agent 包,从不对 agent-loop 编程,所以 loop 保持可替换。默认的完整 spine 打包在 agent-spine-demo 里,任何 app 包通过它组合出一个能跑的 agent。

一次 turn 的流程(完整时序 docs/agent-lifecycle.md):

followup(wake) → running → turn/start → claim inbox → agent/pre-step (waterfall)
  → step/start → user/message → agent/request (waterfall) → llm/stream
  → assistant/chunk* → assistant/message → tool/call* → tools/execute → tool/result*
  → step/end → turn/end → idle
Enter fullscreen mode Exit fullscreen mode

turn/*step/*user/messageassistant/*tool/* 是 durable 会话事件,追加进日志、重启可重建;agent/*tools/* 是 live 运行期事件,负责状态、拦截、排队。


2. 五个核心概念

2.1 Agent:活 agent 的编程面

Agent 是每个插件(UI、钩子、编排器)对着写程序的接口(以下为方法清单节选,完整声明在 packages/core/agent/src/types.ts):

interface Agent {
  readonly id: SessionId        // 与会话共享的唯一身份
  readonly options: AgentOptions // provider / model / maxTokens
  readonly session: Session      // 活会话;日志是 durable 事实源
  readonly inbox: Inbox          // 待处理消息的持久投影
  readonly status: AgentStatus   // 'idle' | 'running'
  readonly ctx: Context          // 这个 agent 私有的 scoped context

  send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
  followup(message: UserMessage): void   // 排队下一 turn 并唤醒
  steer(message: UserMessage): void      // 驱动最近一个 step
  inject(message: UserMessage): void     // 加入模型上下文,不唤醒
  cancel(cause: AgentCancelCause, options?: CancelOptions): void
  whenIdle(): Promise<void>
  runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
}
Enter fullscreen mode Exit fullscreen mode

2.2 ctx.agents:注册表与创建入口

ctx.agents 追踪所有活 agent,create() / resume() 创建时调用已注册的 factory:

const handle = await ctx.agents.create({
  sessionId: SessionId('my-session'),
  agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
// 之后:handle.agent.followup(...);结束时 await handle.dispose()
Enter fullscreen mode Exit fullscreen mode

返回的 AgentHandle唯一拿得到 dispose 能力的地方ctx.agents.get(id) 只返回裸 Agent,没有归属就不允许拆掉它。

2.3 三种送达方式:followup / steer / inject

方法 目标 唤醒?
followup() next-turn inbox,作为自己 turn 的唯一普通消息
steer() next-step inbox,运行中 agent 在下一个 step 醒来即消费 是(idle 时开一个 turn)
inject() next-step inbox,注入的上下文 否(idle agent 保持 idle)

followup() 不返回任何会关联到未来结果的句柄;MessageId 只标识 durable 的在册插入/认领/丢弃事实。VM 要确认工作"跑完了",用 whenIdle()(agent 整体静止),而不是用一个消息的 turn 结束去猜。

2.4 SessionId 与品牌化 ID

跨包的 id 都是 branded 类型:SessionIdCallIdJobId 结构上都是 string,但类型上不通用。用各自工厂构造,比如 SessionId('abc')。别用裸字符串在 ctx.agents 里查。

2.5 Initiator(发起人)作用域

ctx.agents.withInitiator(agent, op) 给一段异步调用链挂上"发起人是这个 agent"的因果归属;currentInitiator() 读取它。适用于日志、遥测、归属。注意:在场不等于授权,不做 liveness 证明。


3. 三条开发路径怎么选

你的目标 路径 典型产出
做一个能跑、行为可调的产品 agent 组合(拼 cordis.yml + spine + leaf) 一条 cordis.yml,一个 app bin
写一个"壳"来操作 agent(CLI/UI/协议/编排) 程序化驱动 ACP / JSON-RPC 桥、UI 插件、编排器
换掉默认的 turn 驱动逻辑 自定义驱动 实现了 Agent 契约的服务
让 agent 内部能互相委派 子代理(第 8 章) 新增 ctx.subagents provider
只改某个环节行为(拦截/注入/钩子) 第 7 章扩展点 监听器插件,不动 loop

按顺序:先组合,再驱动,最后才是改 loop。绝大多数需求落在前三条,自定义 loop 是极少数。


4. 路径一:组合一个可运行 Agent

4.1 原理:bundle 组合 + leaf 提供 backend

dsh-agent-spine-demo 把一切 agent 通用件打包成一个 bundle:

  • 装好:llm 抽象、session、system-prompt、tools、subagent 注册表、agent-loop(具体驱动)、skill 本地 provider、AGENTS.md 加载器、todo/jobs 工具…
  • 故意不装:LLM 适配器、bash 执行器、非本地 skill provider、app 入口。这些由加载它的 leaf 补上 —— 这正是"seam"的作用:缝合处(spine)与可替换件(backend)分开。

参考真实 leaf:examples/headless-agent/cordis.yml —— 一次性编码 agent 的完整组合。

4.2 用 agents 配置项预先创建 agent

spine 把 agents 列表转发给 dsh-agent-loop,启动时就在注册表里建好 agent:

- id: agent-spine
  name: '@deepseek-ai/dsh-agent-spine-demo'
  config:
    agents:
      - id: main
        provider: deepseek-official
        model: deepseek-v4-flash
        cwd: !!js process.cwd()
    workspaceContext:
      maxBytes: 65536
    persona: |
      You are headless-agent, a coding assistant powered by the {{model}} model.
      Verify your work by running the code or tests. Keep answers brief.
Enter fullscreen mode Exit fullscreen mode

像 ACP 这类 app 反而不预建 agent:session/new 时动态 create()(见第 5 章)。

4.3 补 leaf:适配器、沙箱、持久化

组合里还要补三类 backend:

# LLM 适配器(真实 provider)
- id: llm-deepseek
  name: '@deepseek-ai/dsh-llm-deepseek'
  config:
    models:
      - id: deepseek-v4-flash

# bash 执行链:子进程 + bash 后端
- id: subprocess
  name: '@deepseek-ai/dsh-subprocess-local'
- id: bash
  name: '@deepseek-ai/dsh-bash-local'
  config:
    timeoutMs: 60000

# 会话持久化 JSONL
- id: persistence
  name: '@deepseek-ai/dsh-session-persistence-jsonl'
  config:
    root: './.sessions'
Enter fullscreen mode Exit fullscreen mode

工具链、审批策略、沙箱、web/fetch/lsp 等按需加;dsh-web-app / dsh-headless 这类 bundle 已经把常见组合打包好了。

4.4 运行与验证

# 从源码跑源码里的组合
pnpm dsh --profile headless "把仓库的 packages/ 结构总结成树"

# 自己的组合:用 expose 一个 bin 的 app 包(如 dsh-jsonrpc-agent)加载它
pnpm dsh-jsonrpc-agent path/to/cordis.yml

# 只检查组合出来的树,不启动
pnpm dsh --profile headless --dump-config
Enter fullscreen mode Exit fullscreen mode

组合能跑之后,agent 就"是"这一棵插件树。想要不同能力就改组合,而不是改代码。


5. 路径二:程序化创建与驱动 Agent

这条路径是 UI、协议桥、编排器的写法:进程里已经有一个 spine,插件在 apply() 里用 ctx.agents 操作活 agent。

5.1 最小协议桥骨架

import type { Context } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'

export const name = 'my-protocol-bridge'
export const inject = ['agents', 'sessions', 'sessionPersistence']

export function apply(ctx: Context) {
  // 外部协议 -> agent
  async function onPrompt(text: string) {
    const handle = await ctx.agents.create({
      sessionId: SessionId(`client-${nextId()}`),
      agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
    })
    handle.agent.followup(createUserMessage({
      content: [{ type: 'text', text }],
      source: { kind: 'user' },
    }))
    return handle
  }

  // agent -> 外部协议:消费 durable 会话事件
  ctx.on('session/event', (_session, event) => {
    if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
      sendToClient(event.data.chunk.text)
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

要点:

  • 只对 session/event 的 durable 流做回放式消费,不用 agent/* 的 live 事件重建会话 —— 那会丢掉可重建性。
  • 低层协议请求拿到的是入队回执,不是结果句柄:followup() 不返回结果。要等"跑完了"由调用方显式持有 receipt→whenIdle 区间,别用 MessageId 去关联 turn/end
  • 要优雅停机:handle.dispose() 会 stop + await 退出,到达 quiescence。参考实现:dsh-acp

5.2 resume:加载持久化会话

const handle = await ctx.agents.resume({
  resumeSessionId: SessionId('some-existing'),
  agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
Enter fullscreen mode Exit fullscreen mode

这会先加载持久化会话再在其上恢复 agent —— fork、断点续跑、历史会话回放都走这里(sessions.create(id, { seed }) 可做重放)。

5.3 前端 / UI 插件

UI 从 session/event 渲染,把用户输入用 followup() / steer() 送去。一个最小 UI 插件就是:监听 assistant/chunk 画 token 流,用户敲字 → followup()。完整示例与 ConversationNodeDefinition 注册(给内置 Web Client 加业务节点)见 添加一个 Chat 节点extension-cookbook 的 UI 段


6. 路径三:实现自定义 Agent 驱动

默认 loop(dsh-agent-loop)是 Agent 契约的唯一实现,但契约本身可以再实现。为什么极少需要:绝大多数"不同行为"都能落到第 7 章的扩展点上。只有当你要重写 turn 驱动语义(认领、重试、步进策略)时,才需要自己的驱动。

实现契约的方式:构造自己的服务,实现 AgentFactorycreate / resume),通过 ctx.agents.setFactory(factory) 注册,会让 ctx.agents.create/resume 走你的实现而不是默认 loop。每个 agent 的注册与生命周期要走 ctx.agents.withInitiator()agent/* 事件词汇和 session/event 日志纪律 —— Agent 接口就是你和所有消费者之间的完整契约,接口本身在 packages/core/agent/src/types.ts

先确认你知道自己在放弃什么:换驱动等于退出产品配好的编排(预设、checkpoint、计费、重放),这些通常比"自定义步进"更有价值。


7. 拦截与定制:不改 loop 的扩展点

新行为 = 往文档化扩展点挂监听器。没有任何一行要改 loop(他们的微内核承诺)。

7.1 agent/* 事件速查

事件 规格 用途
agent/created emit agent 发布完成(scope 过滤)
agent/disposed emit agent 离开注册表
agent/error emit step/turn 出错
agent/inbox/inserted / claimed / discarded emit inbox 变更通知
agent/pre-step waterfall 否决 step 或替换进入的 messages
agent/request waterfall 替换请求配置(LlmCallConfig
agent/request-error waterfall 失败恢复,返回 retry 不调 next() 即认领
agent/turn-stopping serial turn 无延续时收尾;可 steer 下一步

waterfall 监听器必须调 next() 放行,忘了它就短路整条链。

7.2 钩子示例:拦请求换模型

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

export const name = 'model-switcher'
export const inject = ['agents']

export function apply(ctx: Context) {
  ctx.on('agent/request', async (_payload, next): Promise<LlmCallConfig> => {
    const config = await next()          // 取机器本来要用的配置
    return {
      ...config,
      model: config.model?.startsWith('deepseek') ? config.model : 'deepseek-v4-flash',
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

7.3 注入模型上下文 vs 唤醒

要"塞给下一次请求"动态上下文(AGENTS.md 变更、文件改动通知、工具结果摘要),用 agent.inject();它 appends durable 上下文,不唤醒 idle agent:

try {
  agent.inject(createUserMessage({
    content: [{ type: 'text', text: 'NOTE: AGENTS.md changed; re-read it.' }],
    source: { kind: 'plugin', plugin: 'my-watcher' },
  }))
} catch {
  // agent 已 dispose 时忽略(guarded)
}
Enter fullscreen mode Exit fullscreen mode

7.4 每个 agent 一套能力:预设与 scope

ctx.agentPresets.mount(agentCtx, id) 把某一套插件组合(预设)装进这个 agent 的 scoped context;ctx.agentPresets.composeFrom(agentCtx, parentCtx) 让子 agent 复用父 agent 的同一代组合。注册表、prompt 段、工具都可以 per-agent 隔离,是"给不同会话不同工具集"的手段。预设与服务行 isolate realm 组合时,服务对组外不可见。


8. 委派:子代理(Subagent)

子代理是可选的能力缝,不是 loop 的一部分。特点:多个 provider 同时注册,按名字选(ctx.subagents),不同于 bash 的单执行器。Service Definition 在 dsh-subagent,Consumer 是 dsh-tool-subagent(把配置好的 provider 暴露给模型)。

8.1 注册一个 Provider

实现 SubagentProvider.start(request)(以及可选的 prepareContinuable)并注册进 ctx.subagents,用 provider 的 name 区分:

// provider 自身携带 name;注册返回 effect disposer,HMR 安全
const disposer = ctx.subagents.registerProvider(myProvider)

// 一次调用请求里最重要的字段(类型定义在 dsh-subagent):
//   parent: Agent         生成者(派生 cwd/lineage/depth)
//   prompt: ContentBlock[] 送给子 agent 的 user message
//   signal: AbortSignal    启动前后统一取消通道
//   outputSchema / maxDepth / toolFilter / persona  需要匹配 capability flags
Enter fullscreen mode Exit fullscreen mode

不支持的 start-time 能力(SubagentCapabilities)在 start 前就 fail loud,绝不 accept-then-ignore。

8.2 用 tool 把 provider 交给模型

headless 组合里典型一段:

# 两个 in-process provider:fresh spawn 与 fork
- id: subagent
  name: '@deepseek-ai/dsh-subagent'
- id: subagent-spawn-in-process
  name: '@deepseek-ai/dsh-subagent-spawn-in-process'
  config: { providerName: spawn }
- id: subagent-fork-in-process
  name: '@deepseek-ai/dsh-subagent-fork-in-process'
  config: { providerName: fork }

# 暴露为模型工具
- id: tool-subagent
  name: '@deepseek-ai/dsh-tool-subagent'
  config:
    provider: spawn
    toolName: subagent
    backgroundMode: continuable
    maxDepth: 1
Enter fullscreen mode Exit fullscreen mode

模型通过 subagent / subagent_fork 等工具委托,子事件进入各自会话日志。continuable(可延续)子代理额外装全局 send_message 控制和子 scope 里的 report 返回通道——注意 fork 保持 one-shot,因为可延续子代理的 report/prompt 段先于 fork 复用的历史(见头文件注释)。

其他 shipped provider:-acp-codex-claude-code-dsh-sdk。实现 provider 前先读 dsh-subagent README 的完整契约(features 发现、capability flags、start 校验顺序)。


9. 测试与验证

  • 单元测试pnpm run test(每文件 100% 是 CI 门槛)。先看 docs/testing.md 的分层约定。
  • 真实组合测试:产品可见的 agent 行为,测试必须通过 Loader + 进程 boot 一个 cordis.yml,对手搭的 ctx.plugin(...) 单测不足够。agent-spine-demo 有个"library only" spine 可以插进这种测试。
  • 快照回放(keyless)pnpm run test:snapshot 重放记录过的会话,验证组装成产品的输出;非平凡的模型可见行为变化要在同一个 PR 里带上快照。
  • 真实 APIpnpm run test:e2e(有 DEEPSEEK_API_KEY 才跑)。
  • 操作手册级别验证:pnpm dsh --profile headless "任务" 把 agent 真的跑一遍。

自研驱动、协议桥、子代理 provider 这些"缝实现",尤其要覆盖生命周期:创建、取消、dispose、HMR 卸载(注册必须可逆)、静默收敛。


10. 常见问题排查

现象 原因 / 处理
create() 拒绝 / 订阅不了 provider 没有 factory:leaf 没装 spine(dsh-agent-loop),或 setFactory 没被调用。先 --dump-config
dispatch 时 UNKNOWN_MODEL / MISSING_CREDENTIAL 组合里少了对应 provider 适配器,或凭证没配(参考 usage-tutorial 第 4 章)
新工具模型看不到 scope(preset/restrict)把它滤掉了;在 agent.ctx 里排查注册归属
忘了 next() waterfall 直接短路:pre-step/request 类监听器必须放行
followup 后拿不到结果 followup() 本来就不返回结果;自己持有 receipt→whenIdle 区间才叫"跑完"
inject 后 agent 不动 inject() 不唤醒;要真的动起来用 steer()/followup()
子代理能力被拒 provider 的 SubagentCapabilities 没声明该能力(outputSchema/maxDepth/toolFilter/persona),start 前 fail loud
dispose 后还收到事件 自己持有了未释放的 effect/监听;注册要走 ctx.on/ctx.effect,避免裸订阅
换驱动后行为"丢了" 自定义 loop 退出了产品编排;确认你不需要预设/checkpoint/计费/重放

11. 完整示例:多角色量化研究 Agent

tutorials/agent-demo 是本教程的配套 demo:把 personas/ 下的 4 个角色定义(planner / executor / reviewer / synthesizer)做成一个真正可运行的 agent。编排是"一个 coordinator + 4 个角色子代理"——coordinator(main agent)的 persona 描述"计划 → 执行 → 审查 → 综合"流水线,模型通过 delegate_* 工具把每个阶段委派给对应角色,并把上一阶段的输出传给下一阶段。

每个角色 = 一个具名 ctx.subagents provider,核心代码只有一个类(agent-demo/src/providers.ts):

export class RoleSubagentProvider implements SubagentProvider {
  readonly capabilities: SubagentCapabilities = {
    outputSchema: true, depthLimit: true, toolFilter: true, persona: true,
  }
  readonly inheritsParentContext = false // fresh child:零父上下文

  constructor(
    readonly name: RoleId,
    private readonly persona: string,
  ) {}

  start(request: ResolvedSubagentStartRequest): Promise<SubagentRun> {
    return startInProcessRun({ ...request, persona: this.persona }, {})
  }
}
Enter fullscreen mode Exit fullscreen mode

start() 复用共享驱动 startInProcessRun(),在请求上盖 persona: <角色定义文本>,子代理即"这个角色"。插件入口(agent-demo/src/index.ts)只做注册:

export function apply(ctx: Context, config: Config) {
  const personas = loadRolePersonas(config.personasDir ?? defaultPersonasDir())
  for (const role of ROLES) {
    ctx.subagents.registerProvider(new RoleSubagentProvider(role, personas[role]))
  }
}
Enter fullscreen mode Exit fullscreen mode

角色 persona 文件缺失时 loadRolePersonas() 加载即失败(fail loud)。剩下的全是组合(agent-demo/cordis.yml,第 4 章的路子):dsh-agent-spine-demo 建 coordinator 并注入流水线 persona,dsh-llm-deepseek / dsh-bash-local / 会话持久化补齐执行环境,dsh-subagent 提供注册表,再用 4 个 dsh-tool-subagent 实例把角色暴露成模型可见的 delegate_planner / delegate_executor / delegate_reviewer / delegate_synthesizer 工具(maxDepth: 1,角色子代理不能再往下委派)。

跑起来(tutorials/ 不是 workspace 成员,先打包安装进 profile):

cd tutorials/agent-demo && pnpm run build && pnpm pack
npx @deepseek-ai/dsh plugin --profile headless add ./deepseek-ai-dsh-agent-demo-0.1.0.tgz
export DEEPSEEK_API_KEY=...
npx @deepseek-ai/dsh --profile headless "分析贵州茅台:先规划,再执行,审查后综合成报告"
Enter fullscreen mode Exit fullscreen mode

模型会依次调用 4 个 delegate_* 工具,只把最终综合报告返回给用户。完整说明(配置、测试、安装到 web profile、已知设计取舍)见 agent-demo/README.md


延伸阅读

Top comments (0)