DeepSeek Harness (dsh) Agent 开发教程
本教程面向想开发 Agent 的开发者。这里的"开发 Agent"有不同含义,dsh 对应不同做法:组合一个可运行的 agent(声明式)、写程序驱动 agent 的协议层/UI、"造"一个自己的 agent 驱动,以及让 agent 能委派子代理。教程把每条路都走一遍,并在开头讲清楚该选哪条。
目录
- Agent 在架构中的位置
- 五个核心概念
- 三条开发路径怎么选
- 路径一:组合一个可运行 Agent
- 路径二:程序化创建与驱动 Agent
- 路径三:实现自定义 Agent 驱动
- 拦截与定制:不改 loop 的扩展点
- 委派:子代理(Subagent)
- 测试与验证
- 常见问题排查
- 完整示例:多角色量化研究 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
turn/*、step/*、user/message、assistant/*、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>
}
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()
返回的 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 类型:SessionId、CallId、JobId 结构上都是 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.
像 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'
工具链、审批策略、沙箱、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
组合能跑之后,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)
}
})
}
要点:
-
只对
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' },
})
这会先加载持久化会话再在其上恢复 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 驱动语义(认领、重试、步进策略)时,才需要自己的驱动。
实现契约的方式:构造自己的服务,实现 AgentFactory(create / 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',
}
})
}
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)
}
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
不支持的 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
模型通过 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 里带上快照。 -
真实 API:
pnpm 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 }, {})
}
}
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]))
}
}
角色 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 "分析贵州茅台:先规划,再执行,审查后综合成报告"
模型会依次调用 4 个 delegate_* 工具,只把最终综合报告返回给用户。完整说明(配置、测试、安装到 web profile、已知设计取舍)见 agent-demo/README.md。
延伸阅读
-
docs/subsystems/core.zh.md:
Agent/AgentHandle/ 取消 / 拦截 / 创建与归属的精确契约 - docs/agent-lifecycle.md:turn/step 时序图
- docs/cookbook/extension-cookbook.md:协议驱动与 UI 插件参考片段
- docs/cookbook/adding-a-conversation-node.md:给 Web Client 加业务节点
- docs/subsystems/subagent.zh.md:子代理 provider 完整契约
- packages/examples/agent-spine-demo/README.md:spine 装的树与 leaf 要补的东西
- 真实 app 样例:dsh-acp、examples/headless-agent、examples/acp-agent
- 配套:插件开发教程(先掌握插件写法再动 agent)
- 配套 demo:多角色量化研究 Agent(见第 11 章;完整说明在 agent-demo/README.md)
Top comments (0)