一个插件能在 DeepSeek Harness(下称 dsh)里做到什么程度?

DeepSeek-Bot(下称 DS Bot,社区常按它早期发布时的包名写作 dsh-bot)给出的答案是:把你面前的一个 Agent 变成一支有名字、有分工、有长期记忆、能互相派活的 Bot 团队——而它没有重写一套 Agent 运行时。

这是本文最想讲清楚的一件事:DS Bot 的"团队感",并不是因为它在 dsh 里造了一个更聪明的编排器,而是因为它把 dsh 的四个原语用到了极致:

  • Session 当作 Bot 的身体(每 Bot 一个 root Session,身份、上下文、工具、权限都在 Session 上);
  • 消息注入 当作 Bot 之间的语言(用带来源标记的 user message 驱动对方的下一轮);
  • 事件监听 当作调度器(session/event 的 turn/end 就是"这个 Bot 空闲了");
  • 系统提示词 当作团队协作协议(谁有权限、消息怎么回、什么时候不要说话)。

所以本文分三步走:

  1. 先看底座:dsh 的插件体系(Cordis、profile/bundle/patch、双半插件、Agent 接缝)到底提供了什么,为什么它可以让一个社区插件拥有官方级别的控制力;
  2. 再拆样本:DS Bot 如何用这些接缝搭出 Bot 团队——数据模型、一轮对话的生命周期、群聊的状态机、长期记忆与上下文管理;
  3. 最后给方案:抽象出 Agent Team 的通用模型,对比三种实现范式,并给出一份可以照着写的最小插件骨架和踩坑清单。

先给出全文结论:

Agent Team 不是一个提示词问题,而是一个分布式系统问题:成员(进程/会话)如何被创建与唤醒、任务如何被表达与调度、消息如何投递与去重、状态如何持久化与恢复。模型只负责决策,团队的正确性由插件代码保证。

调研对象与版本说明

本文的分析基于以下可复现的版本,避免"文章比代码老":

对象 版本/快照 说明
DeepSeek Harness 0.2.1-rc.2 本地发布包 + 源码树
DS Bot(DeepSeek-Bot) ds-bot@0.1.7,commit e13bc34(2026-10-17);v0.1.7 发布于 2026-10-16 仓库 Fei2411/DeepSeek-Bot,插件在 ds-bot/ 子目录
官方 Agent Teams @deepseek-ai/dsh-experimental-agent-team@0.2.1-rc.2 实验性 bundle,ctx.agentTeams
参考插件 @nanmicoder/dsh-agent-teams@0.1.24-rc.1、dsh-agent-team-gui@1.3.0 社区实现,用于横向对比

命名澄清(重要,社区里有三个容易混淆的项目):

  • 本文主角:ds-bot,即 DeepSeek-Bot,作者 Fei2411(媒体常称 FeiZ,早期仓库路径为 FeiZhuLulu/DeepSeek-Bot)。它在早期版本使用过 dsh-bot 这个包名,这也是本文标题与很多早期文章的写法;现在 npm 上的正式包名是 ds-bot。
  • npm 上的 dsh-bot:另一个独立项目(dushaobindoudou/dsh-plugin),做的是"用 cron / webhook / 队列跑 headless dsh agent",与本文讨论的 Bot 团队不是一回事。
  • deepseekbot:第三个项目(BotHarness 团队),它的 npm 包描述里明确写着"Not the same project as DS Bot (ds-bot)"。

所以看到 “dsh-bot” 时,先看仓库和包名,不要被同名包带偏。

需要说明的是:DS Bot 的 README 只声明测试过 dsh 0.2.0-rc.2,package.json 也没有 engines.dsh 约束;本文是在 dsh 0.2.1-rc.2 上静态读码,未实际安装运行,行号与行为以文首所列版本为准。


一、dsh-bot 是什么:一个插件,一支团队

用一句话描述 DS Bot:它是 dsh 的一个插件,把"一个 Agent 陪你写代码"变成"一支 Bot 团队陪你工作"。

安装方式就是 dsh 的标准插件安装:

dsh plugin --profile web add ds-bot

装好之后,侧栏会出现「Bot 模式」。默认团队里有一个 Main Bot(默认名 Chief,即"幕僚长"),你可以:

  • 创建 Bot:给每个 Bot 起名字、写角色说明与指令、选模型、选角色头像(20 种角色 / 12 种颜色,也可上传照片),给它独立的权限预设与工作目录;
  • 私聊与派活:直接和某个 Bot 对话,或者让 Main Bot 把任务转给合适的成员;
  • 拉群:把几个 Bot 拉进群聊,用 @Name 点名,成员轮流发言;
  • 积累记忆:每个 Bot 有自己的长期记忆(Markdown 文件),团队还有一份共享记忆;
  • 接入外部服务:Connectors 页可以把 GitHub(官方 MCP server)挂到团队上,Bot 随即获得一整套 GitHub 工具;此外还有 capabilities(给 Bot 挂 MCP server / skill / 能力包)、schedules(定时任务)、secrets(安全卡片)等外设;
  • 看用量:按时间、Bot、模型统计 token 消耗。

这些功能覆盖了身份、协作、持久化三个层面。但真正值得研究的是它背后的四个设计选择:

选择一:不写 Agent Loop。 DS Bot 没有自己实现"模型请求 → 工具执行 → 再请求"的循环,而是把 dsh 的 root Session 直接当作 Bot 的身体。每个 Bot 就是一个普通 Session,模型调用、工具执行、权限、沙箱、审批由 dsh 负责;上下文组装的大框架由 dsh 提供,Bot 身份、记忆 section、长对话的 checkpoint/换 part 则是插件在接缝上补的。

选择二:不写编排引擎。 Bot 之间怎么协作?答案是"发消息 + 听事件":用 agent.followup() / agent.steer() 把一条带来源标记的用户消息塞进目标 Session 的 inbox,再监听 session/event 里的 turn/start / turn/end 来判断对方在干什么。没有额外的消息队列、没有中央调度器。

选择三:状态外置。 团队名册、群聊、Bot 之间的往来记录、未完成的工作链,全部存在 $DSH_HOME/bot/state.json 里;Session 日志只存"对话"本身。这样重启后团队还在,又不必向 dsh 的会话日志里塞自定义事件类型(原因后文会讲,这是一个平台约束)。

选择四:双半插件。 宿主半(src/host/)负责所有逻辑与持久化,浏览器半(src/client/)只负责渲染与交互,两者通过 dsh 的客户端插件机制拼成一个产品。

把它画成一张图:

graph TB
    subgraph Browser["浏览器端(client half)"]
        UI["侧栏 / 私聊 / 群聊 / 记忆面板<br/>Usage / 设置 / 审批卡片"]
    end

    subgraph Host["dsh Host 进程(host half)"]
        P["ds-bot 插件<br/>roster / turns / groups / memory / parts / tools ..."]
        SEAMS["dsh 接缝<br/>ctx.sessionController · ctx.sessions · ctx.tools<br/>ctx.llm · ctx.systemPrompt · ctx.approval"]
        ST[("state.json<br/>roster · rooms · works")]
        MEM[("memory/<br/>Markdown + journal")]
        IDX[("FTS5 全文索引<br/>node:sqlite")]
    end

    subgraph Sessions["root Session(每个都是一个 Bot)"]
        B1["Chief(Main Bot)"]
        B2["Researcher"]
        B3["Writer"]
        R["群聊 relay Session"]
    end

    UI <-->|"JSON-over-HTTP RPC(POST /api/bot)"| P
    P --> ST
    P --> MEM
    P --> IDX
    P --> SEAMS
    SEAMS -.-> B1 & B2 & B3 & R

代码规模上,src/ 大约 2.5 万行(host / client / shared 三部分),构建出的 client.js 约 830 KB,另外还有一个独立的搜索插件 ds-bot/web-search。这个体量足以说明:做一个"能用"的团队产品,难点不在提示词,在工程。


二、底座:dsh 的插件体系到底提供了什么

在拆 DS Bot 之前,先把 dsh 的插件机制讲清楚。否则你会觉得 DS Bot 的很多写法"莫名其妙",而实际上它们都是平台约束下的最优解。

2.1 Cordis:没有内核,只有一棵插件树

dsh 底层是一个叫 Cordis 的插件框架。官方文档里有一句话很关键:

不存在需要打补丁的特权内核:扩展 dsh 的方式是把插件挂载到其他插件旁边,而各项注册都是副作用,会在其插件卸载时撤销。

Cordis 只有五个核心概念:

  1. 插件就是实现 Service 的对象。 它可以是一个带 inject 和 apply(ctx) 的函数,也可以是一个 Service 子类。没有"主程序",只有一个个插件。
  2. 上下文是服务的容器。 一个服务占据一个稳定的 ctx.<key>,例如 ctx.tools、ctx.llm、ctx.sessions。插件之间通过 key 查找服务,而不是互相 import 具体实现。
  3. inject 声明依赖。 插件声明自己需要哪些服务,Cordis 会等这些服务就绪后再启动它。加载顺序由依赖关系决定,而不是手写启动序列。
  4. 类型化事件负责通信。 服务通过 TypeScript 声明合并注册事件名,再用五种分发模式之一派发:emit(观察)、waterfall(环绕中间件,可短路)、parallel(并行)、serial(串行)、bail(停在第一个非 null / false / undefined 的返回值;注意 0 与 "" 也会 bail)。
  5. 注册是可逆的副作用。 提示词片段、工具 schema、监听器都通过 ctx.effect() / ctx.on() 安装,插件重载或卸载时自动撤销。

这带来的直接结果是:产品里的模型适配器、工具注册表、会话日志、甚至 Agent Loop 本身,统统是插件。 你可以用 patch 换掉其中任何一个,而不需要 fork 仓库。

对插件作者来说,真正要记住的是事件分三个域:

事件域 例子 语义
会话事件 session/event 广播的 turn/start、turn/end、user/message 持久事实,追加进日志,重启可回放
Agent 事件 agent/created、agent/inbox/*、agent/status 活跃 Agent 的实时协调,进程内有效
能力事件 tools/*、fs/*、llm/* 某个 seam 上的策略与适配器拦截

DS Bot 的骨架,本质上就是"监听会话事件 + 注册工具 + 注入提示词片段"这三件事的组合。

2.2 一个插件的最小形态

一个可以被 dsh 加载的插件,最小只需要两个文件(package.json + 入口);如果要让它能作为组合包被 profile 自动挂载,再加一个 cordis.patch.yml 并声明 dsh.bundle。按"组合包"分发时,目录长这样:

dsh-hello/
├── package.json
├── cordis.patch.yml
└── index.js

package.json 负责声明"我是谁、我怎么被挂载":

{
  "name": "dsh-hello",
  "type": "module",
  "main": "index.js",
  "exports": { ".": "./index.js" },
  "engines": { "dsh": "^0.2.1-rc.2" },
  "peerDependencies": {
    "@deepseek-ai/cordis": "*",
    "@deepseek-ai/dsh-tools": "*"
  },
  "dsh": {
    "bundle": { "patch": "./cordis.patch.yml" }
  }
}

cordis.patch.yml 描述"插到插件树的哪个位置":

- insert:
    - id: hello
      name: dsh-hello
      config:
        greeting: 你好

index.js 是宿主半的逻辑:

import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'dsh-hello'
export const inject = ['tools', 'systemPrompt']

export function apply(ctx, config) {
  // 注册一段系统提示词
  ctx.effect(() => ctx.systemPrompt.section({
    name: 'hello',
    order: 600,
    text: () => config.greeting,
  }))

  // 注册一个模型可见的工具
  ctx.tools.register(defineTool({
    name: 'say_hello',
    description: '向用户问好',
    parameters: {},
    output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
    async execute() {
      return config.greeting
    },
  }))

  // 监听会话事件:这就是"调度器"的原材料
  ctx.on('session/event', (session, event) => {
    if (event.type === 'turn/end') {
      // 一个 Agent 空闲了
    }
  })
}

这里有两个容易混淆的挂载方式:

  • 组合包(bundle):package.json 里声明 dsh.bundle.patch,加到 profile 的 dsh.profile.bundles 后,它的 patch 会被自动应用。DS Bot、官方 dsh-base 都是这种形态。
  • 普通插件:不在 bundles 里出现,只在 cordis.patch.yml 里被 insert 一行。本文主角 DS Bot 的 ds-bot/web-search 就是这个形态——它只是"往 web seam 里加一个 provider"的小插件。

2.3 profile:插件树是怎么被一层层叠出来的

一个运行中的 dsh,是一棵按顺序叠加出来的插件树:

graph LR
    A["空条目列表"] --> B["profile 的有序组合包<br/>dsh-base → dsh-web-app → ..."]
    B --> C["profile 的 cordis.patch.yml<br/>(用户/插件作者的覆盖层)"]
    C --> D["Harness home 的 patch"]
    D --> E["--patch 覆盖层"]
    E --> F["最终插件树"]

每一层都作用在上一层的结果上。patch 的主要操作有两种:按 id 定位某一行并替换它的整个 config,或者用 insert 插入新行;此外还有 disabled: true(关掉某一行,官方 bundle 大量使用)以及 name / group / inject / isolate 等字段。注意"替换整个 config"这个语义——它不合并,所以一个只想改 searchProvider 的 patch,必须把 fetchProvider 也重新写一遍,否则那一项会丢。

这正是 DS Bot 的 cordis.patch.yml 在做的事:它插入自己的两个插件行,同时把 web seam 的默认搜索提供方改成 auto(由 DS Bot 自己的搜索插件接管):

- insert:
    - id: bot
      name: ds-bot
    - id: web-search-auto
      name: ds-bot/web-search

- id: web
  config:
    searchProvider: auto
    fetchProvider: http

一个第三方插件,通过 patch 就改掉了核心 seam 的默认实现。 这就是"一切皆插件"的实际含义。

你可以随时查看自己机器上真实生效的插件树:

dsh --profile web --dump-config

打印出来的任何一行,都可以被你自己的 patch 定位替换或禁用。不过要小心:agent-loop、webserver、connection 这类 required entry 如果被禁用或配错,启动会直接失败(非零退出),而不是静默降级。

2.4 双半插件:宿主半 + 浏览器半

dsh 的 Web 形态天然分两层:

  • **宿主半(host half)**运行在 Node 进程里,是插件树的一员,能碰文件系统、Shell、模型、数据库;
  • **浏览器半(client half)**运行在浏览器里,是一个被 Web 应用动态加载的前端模块,负责渲染 UI。

一个插件可以只做宿主半,也可以同时提供两半。声明方式是在 package.json 里加 dsh.client:

{
  "exports": {
    ".": "./index.js",
    "./client": "./client.js"
  },
  "dsh": {
    "client": { "platform": "web" },
    "bundle": { "patch": "./cordis.patch.yml" }
  }
}

浏览器半不是宿主插件树里的插件,而是浏览器侧那棵 Cordis 插件树里的独立插件(有自己独立的 ctx/apply/inject),只是和宿主半同包不同入口:浏览器半通过 RPC 读取宿主状态、提交用户操作,宿主半校验后返回结果。链路本身由 dsh 的客户端插件运行时、连接层和 Web 服务器提供,插件作者只需要按约定声明导出、注册 UI 扩展点。DS Bot 的具体做法是一条 POST /api/bot 的轻量 JSON-over-HTTP RPC(自定义 {endpoint,payload} envelope,不是 JSON-RPC 2.0;自带约 60 个 endpoint),并靠 state.revision 做增量判断——不是新开一条 WebSocket,也没有用 dsh 的 typert RPC 生成器。

DS Bot 的浏览器半(src/client/)就是这么做的:它注册侧栏入口、私聊界面、群聊界面、记忆面板、Usage 页面、设置页,然后通过 RPC 读写宿主半的团队状态。逻辑只有一份,UI 只是投影。

2.5 Agent 接缝:做团队真正需要的那几个服务

dsh 把所有"能力"都做成了 seam(接缝):一个接口定义(Service Definition)、若干提供方(Provider)、以及使用它的消费方(Consumer)。对做 Agent Team 来说,最核心的是下面这些 ctx 服务:

接缝 职责 做团队时的用途
ctx.agents 活跃 Agent 注册表、Agent 接口、agent/* 事件 找到某个 Session 对应的 Agent,投递消息、取消、读 inbox
ctx.sessions 只追加的 SessionEvent 日志 持久事实的来源;flush() 确保消息落盘
ctx.sessionController Session 的创建、查询、选模型、解析 Agent 创建成员 Session;resolveAgent() 拿到可投递的 Agent
ctx.tools 作用域化工具注册表与执行流水线 给模型暴露 spawn_member、send_message、team_task_* 等工具
ctx.systemPrompt 提示词片段与工具 schema 的组装 注入"团队协议":谁能做什么、消息怎么回、什么时候别说话
ctx.llm 模型路由、流式调用、模型信息 旁路调用(记忆复审、上下文管家、总结)
ctx.subagents 可继续子 Agent 的抽象与 provider 官方 Agent Teams 的成员创建底座
ctx.sessionProjections 已提交事件的增量折叠 把 roster / 任务板投影给浏览器
ctx.approval 审批请求/应答 waterfall 危险操作的人类确认
ctx.workspaceRegistry 工作区注册与会话归档 团队共享工作区;Bot 的独立工作目录
ctx.jobs / ctx.schedule 后台任务与定时任务 长任务、定时唤醒

看懂这张表,你会发现 DS Bot 的 inject 列表几乎就是它的"依赖合同":

export const inject = [
  'llm', 'tools', 'systemPrompt', 'sessionController',
  'sessions', 'sessionTitle', 'workspaceRegistry', 'connection',
]

它依赖的正好是:模型、工具、提示词、会话控制、会话日志、标题、工作区、连接。没有一项是"团队专用"的——团队能力是在这些通用积木上长出来的。


三、拆解 dsh-bot:一个插件如何长成一支团队

下面按"数据 → 装配 → 一轮对话 → 群聊 → 权限 → 记忆 → 上下文 → 工具面 → 工程细节"的顺序拆。每一节末尾都会给一句"设计要点",方便你迁移到自己的团队实现。

3.1 数据模型:Bot 是 Session,团队是一份 JSON

DS Bot 的源码第一行注释就把它的全部秘密写完了:

// DS Bot: a team of long-lived Bots on top of ordinary dsh root Sessions.
// Every Bot is one root Session; the roster, rooms, and the bot-to-bot exchange log live
// in one JSON file under DSH_HOME so they survive restarts without new Session event types.

拆开看是三个决定:

(1)Bot 的身体是一个 root Session。 创建 Bot 时,DS Bot 调用 ctx.sessionController.create({ workspaceId }) 拿到一个 sessionId,然后把这个 sessionId 直接当作 Bot 的 id(roster.js:54、roster.js:113-129):

const { sessionId } = await ctx.sessionController.create({ ...place, ...await botPreset() })
const agent = await rt.resolveAgent(sessionId)
await ctx.sessionTitle.rename(agent.session, title)
return sessionId

Session 标题就是 Bot 名。于是 dsh 原生的会话列表、消息流、工具执行、权限、审批、历史回放,全部自动成为这个 Bot 的能力——DS Bot 一行 Agent Loop 都没写。

(2)Bot 的 id 永远不变,但 Session 会换。 当对话太长、需要开"新的一段"(part)时,Bot 会新建一个 Session,旧 Session 归档进 bot.segments[];bot.sessionId 变了,bot.id 不变。所以"这个 Bot 一直存在"是 DS Bot 名册层的事实,而不是 dsh Session 层的事实。

(3)团队名册与房间状态放在 $DSH_HOME/bot/state.json。 结构大致是:

字段 内容
mainBotIds[] Main Bot 列表(可以有多个)
bots{id} 每个 Bot 的名字、角色、颜色、模型、工作目录、指令、权限、历史 part
rooms{id} 群聊:成员、管理员、模式、公告、消息日志、每个成员的隐藏 Session
agents{id} 每团队至多一个"普通 dsh Agent",与 Bot 并列但不受团队协议约束
exchanges[] Bot 间消息记录(截断到 2000 字符,上限 600 条)
works{id} Bot 间"工作委派"的树,见 3.4
questions / prefs / revision 待回答卡片、界面偏好、单调递增版本号

整个 state.json 每次整份序列化后原子替换。这里有一个关键工程问题:多个窗口/多个宿主可能同时打开同一份团队数据。 DS Bot 的解法是:

  • 用 state.lock 做单写者锁({pid, host, token},wx 独占创建,15 秒心跳,60 秒过期);
  • 拿不到锁的进程进入只读模式:界面照常展示,但所有写路径都会先被 readOnly() 拦下,并给出一句人话解释;
  • 保存走 temp + rename 原子替换,写入串行化(promise 链),写前写后各校验一次锁;
  • JSON 解析失败就备份成 state.json.broken-<时间戳> 并拒绝写入,避免把坏数据写回去。
// store.js 保存路径(节选)
state.revision += 1
const snapshot = JSON.stringify(state)
const run = writing.then(async () => {
  const temp = `${statePath}.${process.pid}.tmp`
  ensureLock()
  await writeFile(temp, snapshot, 'utf8')
  ensureLock()
  await rename(temp, statePath)
})
writing = run.catch(() => {})

设计要点: 团队状态的"真相源"只能有一个。Session 日志是对话的真相源,团队名册是协作的真相源;两者不要互相污染。如果你要让第三方插件持久化自定义状态,外部 JSON + 单写者锁 + 原子替换,通常比往会话日志里塞自定义事件更安全(原因见 4.2 的平台约束)。

3.2 装配:三个导出,29 个模块

index.js 的核心导出是三个(dsh 插件的标准形态),另外还导出了 createRuntime(便于测试)与颜色常量:

export const name = 'ds-bot'
export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController',
                       'sessions', 'sessionTitle', 'workspaceRegistry', 'connection']

export function apply(ctx, config = {}) {
  createRuntime(ctx, config)
}

inject 的 8 个服务对应 8 种能力;apply 只做一件事:构造一个运行时对象 rt,然后按固定顺序安装 29 个模块:

const MODULES = [diagnostics, store, models, permissions, turns, handoffs, delivery,
  roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals,
  resume, memory, parts, images, secrets, connectors, capabilities, schedules,
  browser, usage, update, dshUpdate, channel]

export function createRuntime(ctx, config = {}) {
  const rt = { ctx, config, home, statePath, defaults: {...} }
  for (const module of MODULES) module.install(rt)
  return rt
}

每个模块的 install(rt) 做三类事:注册事件监听器、注册模型工具、注册 ctx.effect 清理器;同时把"别的模块会调用的函数"挂到 rt 上。有状态的跨模块调用一律走运行期的 rt.*;只有纯函数/常量(output、failureLine 等)会被直接 import。

这个模式有三个好处,值得直接抄:

  1. 可测试。 createRuntime 是导出的,测试可以造一个假 ctx 调出整个宿主半;
  2. 无循环依赖。 模块只依赖 rt 的形状,不依赖彼此的模块图;
  3. 顺序即语义。 监听器和工具的注册顺序就是执行顺序,所以数组顺序有意义:
  • usage 必须放最后:它的 llm/stream 监听器要在所有"重写请求"的监听器之后统计 token;
  • resume 必须在 turns 之后:恢复逻辑要读 turns 已经认领的 turn 触发源;
  • store 必须在 agents 之前:名册索引建好后,agents 模块才能建立 Session → Agent 的映射。

另外,inject 只声明必需服务;对可选能力(权限预设、定时任务、审批、沙箱策略、压缩器、模型目录等),DS Bot 一律用 ctx.get?.('permissionPresets') 软探测。这是它能在多个 dsh 版本上活下来的主要原因。

设计要点: 一个"团队插件"内部也应该是一个模块化系统。把"跨模块调用"收敛到一个运行时对象上,安装顺序即监听器顺序——这比任何设计模式都实用。

3.3 一轮对话是怎么跑起来的

这是整个插件的心脏。先看图:

sequenceDiagram
    participant U as 用户/其他 Bot
    participant H as ds-bot host
    participant SC as ctx.sessionController
    participant A as Agent(某 Bot 的 Session)
    participant L as dsh agent loop
    participant S as session 事件流

    U->>H: 消息(用户输入 / message_bot / 群回合)
    H->>H: 组装 header + source 元数据
    H->>SC: resolveAgent(sessionId)
    SC-->>H: Agent
    H->>A: agent.followup(msg) 或 agent.steer(msg)
    H->>A: ctx.sessions.flush(session)
    A->>L: 唤醒 driver
    L->>S: turn/start → user/message → assistant/message → tool/* → turn/end
    S-->>H: 事件回调(turns.js)
    H->>H: 按 turn/end 结局路由回复
    H-->>U: 回复 / 群消息 / 工作回收

核心函数是 deliver()(delivery.js:51)。它把一条消息投递给某个 Session,做四件事:

const message = createUserMessage({
  content: [
    { type: 'text', text: `${headerFor(role, fromName, ...)}\n${text}` },
    ...files.map(attachment => ({ type: 'file', attachment })),
  ],
  source,
})
if (steer) agent.steer(message)   // 在下一个 step 边界插入,不打断当前 turn
else agent.followup(message)      // 排到下一 turn
await ctx.sessions.flush(agent.session)

第一个关键点:消息走的是"用户消息"通道。 对模型来说,Bot 之间的消息就是一条 user message,开头带一行 header:

header 含义 模型该怎么做
[Message from Researcher] 另一个 Bot 的请求 完成后回复会自动回给发起者
[Reply from Writer] 某次委派的结果 不要回消息说谢谢,直接继续工作
[Group chat "xxx" · your turn] 群聊回合 在群里发言,而不是私聊
[Message from X · the user sees your reply here] 让 Bot 自己向用户汇报 回复面向用户,不回给 X
[DeepSeek Harness restarted while you were working] 重启恢复 接着做
[Your last reply wrote a tool call as text, so nothing ran] 模型把工具调用写成文本 重新调用工具

第二个关键点:语义在 source 里,而不是在文本里。 每条消息带一个私有 source:

source = {
  kind: 'bot', role, exchangeId, senderSessionId,
  roomId, work, audience, deferred, resumed, ...
}

宿主侧所有路由(这条消息该不该回、回给谁、算不算工作结果、要不要等子工作)都读 source,不读自然语言。模型看到的是人话,程序看到的是结构化字段——这是把"多 Agent 通信"做成可靠系统的关键。

第三个关键点:turn/end 是唯一的收敛点。 turns.js 用一个 session/event 监听器吃下所有事件,并在 turn/end 时做统一收尾。一轮的结局有六种:

  1. 正常回答:把本轮 assistant 文本拼起来,按 info 回给发起者或群;
  2. 模型失败:只保留 {code, message≤500, status, requestId},且 AUTH 失败时丢弃 message(防止 provider 回显 key),再给等待者发一句"无法回答";
  3. 被中断:也要给等待者发一句"被停止前没来得及回答",否则发起者会永久等待;
  4. 模型把工具调用写成文本:用一个固定提示词重试一轮,且只重试一次;
  5. 还有未回收的子工作:把答案先暂存(holdWork),等所有子结果回来再一起回;
  6. turn 进行中又来了请求:先记进 joined,当前 turn 结束后以"延迟消息"单独成一轮。

设计要点: 多 Agent 系统的失败路径比成功路径重要。每一个"可能永远等不到回复"的地方,都必须有超时、中断或补偿消息。DS Bot 的 turn/end 分支,本质上是在枚举"这一轮可能怎么死"。

3.4 群聊:把"多 Agent 讨论"做成确定性状态机

群聊是 DS Bot 里最反直觉、也最值得抄的设计。

群自己也是一个 Session,但它从不让模型真正说话。 群有一个普通 root Session(room.id),systemPrompt 被改写成"你只是 relay,不是参与者"。更狠的是:这个 Session 的 llm/stream 被完全劫持——不请求模型,而是由宿主合成一次 group_relay 工具调用:

// groups.js:845-857(节选)
ctx.on('llm/stream', (options, next) => {
  ...
  if (rt.roomOf(options.sessionId) === undefined) { yield* next(); return }
  // 判断最后一条是不是新的用户输入;是则合成 group_relay,否则直接结束
})

注释里写了理由:模型会"漂移"去直接私聊某个成员,所以干脆由宿主伪造 relay 步骤。当模型不可靠时,把决策权收回宿主,是比改提示词更可靠的工程手段。

每个成员在每个群有一个独立的隐藏 Session。 首次在群里发言时才创建(groups.js:184-200):

const id = await rt.createSession(`${member.name} · ${room.name}`, { dir })

这个隐藏 Session 与成员的私聊 Session 渲染同一段 prompt 文本,因此共享 provider 的前缀缓存;但它有独立的对话历史——所以"群里的 Bot"不会读到"私聊里的 Bot"的内容。

一轮群聊是一个有明确状态的 round。 runRound() 维护一个 slots 数组,每个成员对应一个 slot,状态机是:

waiting → speaking → posted
                  ↘ passed([PASS] / 空回复)
                  ↘ working(还在跑工具,先让 round 继续)
                  ↘ waiting-approval(等用户审批)
                  ↘ failed / left

几个关键约束:

  • 一次只允许一个成员说话:上一个说完或 PASS,下一个才开始;
  • @Name 可以插队:被点名的成员被 splice 到当前发言者之后;但每次被 @ 的发言机会有上限(按调用者角色:普通成员 2 次、admin 4 次),防止"@ 风暴";
  • 只有 admin 的 @all 能叫所有人;
  • 默认 admin 模式:普通消息只给 admin,由 admin 决定叫谁——这是省 token 的核心(每多一个发言者都要重读整段上下文);
  • 干活不堵 round:成员开始跑工具 15 秒后,round 标记它为 working 继续往下走,结果稍后作为"迟到回复"发回群;
  • turn 数有上限:slots + 4 + (有 admin 再 +4),再加 drain 消息抬升的上限,防止群聊自激振荡。

工作委派是一棵树。 Bot 之间不是"你一句我一句"地聊,而是有明确的"工作"对象:

graph TD
    A["Bot A 的 turn"] -->|"message_bot(B) 开一个 work"| B["Bot B 的 turn"]
    B -->|"B 又委派给 C(挂在本 work 下)"| C["Bot C 的 turn"]
    C -->|"结果回来"| B
    B -->|"所有子工作回收后才回复"| A

state.works 记录每个 work 的 from / to / parent / open[] / results[]。三个不变量:

  1. 谁能等谁:upstreamOf() 保证"正在等我结果的那个 Bot"不能被 message_bot 反过来打扰,否则工作成环;
  2. 回来才走:有未回收子工作时,答案先 holdWork,等全部回来再合并发送;
  3. 删除要通知:删除一个 Bot 时,把它所有 Session 里"等着它回答"的请求找出来,逐个通知发起者"它被删除了"。

设计要点: 真正的多 Agent 协作不是"让模型自由聊天",而是宿主定义好的协议:谁在什么时候说话、谁能叫谁、说完怎么回收、失败了怎么通知。模型负责内容,宿主负责流程。

3.5 权限与审批:能力给宿主,不给模型

DS Bot 直接复用 dsh 的 permission preset(只读 / 工作区可写 / 完全访问),而不是自造一套权限系统:

  • 每个 Bot 有一个权限预设,保存在 bot.permission,并在它的每一个 Session 上重放(私聊、每个群 Session、每个新 part);
  • 群可以设置"统一预设"覆盖成员自己的;
  • dsh 的 auto 预设是 Session 级策略,Bot 和群都不能持久化它,所以被显式过滤掉。

角色层级是硬编码在工具校验里的:

能力 用户 Main Bot 群 admin 普通成员
改 Bot 配置 ✔ ✔ 任意 Bot ✘ ✔ 只能改自己的
创建/删除 Bot ✔ 创建 ✔ / 删除 ✘ ✘ ✘
建群/删群 ✔ ✔ ✘ ✘
改群(名/公告/成员/模式) ✔ ✔ 任意群 ✔ 自己的群 ✘
写团队记忆 ✔ ✔ ✔ ✘
停止别的 Bot ✔ ✔ ✘ ✘
改权限预设 ✔ ✘ ✘ ✘

最后一行是重点:整个插件没有任何"Bot 改权限"的工具或端点。README 写得很直白:“Only you set permissions; Bots cannot change them.” 与代码完全一致。

审批流程还有一个隐藏 Session 的坑:dsh 的审批卡片只在可见 Session 上渲染,而群 Session 是隐藏的——如果直接把审批请求交给 dsh,它会永远挂着。所以 approvals.js 用 prepend: true 抢在 dsh 自己的 answerer 之前接管:

ctx.on('approval/request', (request, next) => {
  const owner = rt.groupOwners.get(sessionId)
  if (botId === undefined) return next()
  ...
  rt.yieldRoomTurn?.(sessionId)   // 把这个成员的 slot 标成 waiting-approval
  // 挂起,等用户通过 RPC 回答
}, { prepend: true })

设计要点: 权限和审批必须由宿主掌握,不能变成模型可以调用的工具。模型能调用 ask_user,但"允许什么"是代码写的。

3.6 长期记忆:把"记忆"做成可审查的纯文本

DS Bot 的记忆不是向量库,而是 Markdown 文件,格式参考 Agent Memory Repo:

  • 一个 entry 是一行 bullet,末尾带 [source: …; added: …; by: …] 元数据;
  • MEMORY.md 是入口,## Index 里用 - [[topic]] 链接主题文件;
  • 固定上限:entry 300 字符、MEMORY.md 4000 字符、topic 16000 字符、每个 scope 最多 40 个 topic;超限直接拒绝写,并提示怎么腾地方。

目录结构:

$DSH_HOME/bot/memory/
├── bots/<botId>/MEMORY.md + <topic>.md   # 每个 Bot 自己的记忆
├── team/MEMORY.md + <topic>.md           # 所有 Bot 都读的团队记忆
├── journal.jsonl                          # 每次变更一行(改/删记 before+after,新增只有 after)
├── snapshots/<sessionId>.md               # 每个 Session 冻结的 memory section
├── summaries/<botId>.json                 # 记忆面板的摘要
├── reviewed.json                          # memory review 的读取游标
└── archive/<botId>-<ts>/                  # 被删 Bot 的记忆整体归档

纠偏: 网上不少介绍 DS Bot 的文章说"每个 Bot 的记忆是一个 Git 工作树"。以 0.1.7 的源码为准,当前实现不是 git:文件原地改写,历史靠 append-only 的 journal.jsonl 记录每次变更的前后行,删除 Bot 时把目录 rename 进 archive/。源码注释写得很清楚:“Files change in place and the journal keeps what each change replaced, so history needs no git.” 如果你看到 Git Memory 的描述,那多半是另一个项目(比如 deepseekbot)的功能。

记忆是怎么被写入的? 有两条路径:

  1. 模型主动调用 remember / forget / recall 工具;
  2. 每轮结束后的自动 review:agent/status 变成 idle 后等 2 秒,进入一个全局串行的队列,读该会话自上次游标以来的新消息,让 Bot 自己的模型输出 {"changes":[...]},逐条校验后走与工具相同的写路径。

review 的几个细节值得学:

  • 只有出现 user 行才真的调模型——Bot 之间互相聊天不花记忆 token;
  • 第一次 review 在模型调用之前先落一个起点游标,失败也不会卡死;
  • 模型一次给满 8 条变更且确实改了东西时重读同一批行,最多 4 轮,防止改一半;
  • 群聊游标按"每个成员在群里"独立维护,落后 30 行以上的成员会被插队 review。

记忆是怎么进入模型的? 两个 MEMORY.md(自己的 + 团队的)合计约 8000 字符,作为 system prompt 的一个 section(order: 645)注入。但这里有个缓存问题:如果每轮都重新拼接记忆,prompt 前缀会变,provider 的前缀缓存就失效了。

DS Bot 的解法是按 Session 冻结:一个 Session 第一次渲染记忆 section 时,把整段文本写到 snapshots/<sessionId>.md 并缓存在内存;之后只读快照,保证 prefix 字节稳定。记忆的更新要到"下一次换 part 或 checkpoint"才可见,但 recall 工具可以立刻查到最新内容。

设计要点: 长期记忆 = 可控大小的注入上下文 + 可审计的文件 + 异步的整理任务。不要指望模型"自己记得",要让宿主在每轮结束后做一次确定性的整理;同时要为 prompt 缓存设计"冻结快照"。

3.7 长上下文:不用 dsh 自带压缩,自己接管"上下文管家"

dsh 本身有 compaction(压缩)机制,但 DS Bot 把 Bot Session 的压缩完全拦了下来。源码注释写得很克制:

// The backend's own entry points leave Bot Sessions alone, earlier parts and group
// records included: automatic pressure and overflow compaction do nothing, and
// /compact and explicit ranges are refused. Only DS Bot condenses a Bot Session,
// when the steward says so, and then the summary is DS Bot's checkpoint.

实现方式非常"插件化":监听 agent/created,拿到该 Agent 使用的 compaction 引擎,在运行时包一层(下面是重写后的示意,实际实现见 parts.js:201-240,原函数用 Symbol 存档以便卸载时还原):

engine.compactIfNeeded = function (target, trigger, signal) {
  if (ownedByBot(target)) return Promise.resolve(null)  // Bot 会话:不自动压缩
  return original.compactIfNeeded.call(this, target, trigger, signal)
}
engine.summarize = function (input, agent, signal) {
  if (isBotTarget(agent)) return writeCheckpoint(input, agent, signal)  // 换成自己的 checkpoint
  return original.summarize.call(this, input, agent, signal)
}

没有 fork,没有改源码,只是在 seam 上包了一层。 这正是"一切皆插件"最有力的证明。

那么它自己的上下文管理怎么做?核心是三条线 + 一个旁路模型调用:

三条线(steward.js:20-27):

window  = 模型声明的上下文窗口(没声明则 500,000)
usable  = max(window - 预留回复空间, window / 2)
hard    = usable × 0.95
check   = min(usable × checkRatio, hard)     // checkRatio 默认 0.8

一次"上下文管家"调用:一轮结束、上下文超过 check 线时,发一次独立的小请求——复用 Bot 自己的模型路由,但只喂进最新约 40 行消息(每行截断到 1500 字符)、设置很小的输出上限、并在支持时关掉 reasoning,只回答一个问题:

{"action":"switch" | "compact","reason":"one short sentence"}
  • switch:当前工作到了停点 → 换到一个全新的 part(新 Session),旧 part 整段归档;
  • compact:工作还在进行 → 把旧消息原地压缩成 checkpoint,工作不中断。

这个判断由模型做,但后果由宿主执行,而且模型(Bot 本人)不会被通知——它只会看到"交接包"或"[Checkpoint N]“消息。

换 part 的交接包包含三样东西:Bot 在空闲时写的 handoff note、最近的若干条消息原文、“其余内容在哪”。handoff note 有一个很细的设计:它在 Bot 空闲时、用与正常请求相同的前缀生成,所以 provider 可以直接命中缓存,用户几乎不等待。

原地压缩的 checkpoint 则保留:一段摘要 + 最近几轮"发给 Bot 的消息"的逐字原文([Checkpoint N] 消息,包含用户消息与其他 Bot 的请求)。这样既省了 token,又不会丢掉"对方到底怎么说的”。

还有几个防抖细节:

  • 一个 part 只有在比上次整理时又增长了 1/4 个 check 线之后才会再次整理,避免每轮都触发;
  • 超过 hard 线时仍会先让 steward 决策、并尝试写 handoff note;只有真的上下文溢出、或切换工作目录时,才不等 note 直接换 part;
  • note 生成失败时保持原 part,延迟重试;
  • 切换过程中被打断的 turn,会在新 part 里以"继续"消息恢复。

压缩之后,历史去哪了? 没有丢。DS Bot 用 SQLite 的 FTS5 给每个 part 建全文索引(trigram tokenizer,三字以上无需分词,中文可用;短词回退到 LIKE),按 BM25 排序。read_own_chat 工具可以搜索所有 part、按 anchor 读取上下文,也能按 part N 读回该段的 handoff note(checkpoint 的摘要不进索引,原文只留在该段 Session 记录里)。索引是从 Session 日志派生的,删掉索引文件最多只是重建一次。

设计要点: 长寿命 Agent 的上下文管理,不能只靠"压缩",而需要"整理 + 搬家 + 可搜索存档"三件套。压缩会丢细节,所以必须保留"原文还能找回来"的通道;换 Session 会丢缓存,所以交接包的生成要利用缓存前缀。

3.8 工具面:25 个工具就是 25 条团队协议

DS Bot 给模型注册了 25 个工具(constants.js:64-71 的 TEAM_TOOLS)。它们不是 API 的简单封装,而是团队协议的动作集合:

类别 工具 说明
Bot 管理 list_bots create_bot update_bot stop_bot list_models 谁能改谁由权限校验决定;stop_bot 只有 Main Bot 能用
群聊 create_group update_group delete_group post_to_group 群名、公告、成员、管理员、模式、统一工作目录
通信 message_bot ask_user 前者支持 when: "now" / "queue"(对应 steer / followup);后者是非阻塞问答卡片
读聊天 read_own_chat read_group_chat 跨 part 全文搜索、按 anchor 读上下文
记忆 remember forget recall 带 scope(自己 / 团队 / 指定 Bot)与写权限
密钥 request_secret list_secrets Bot 永远看不到密钥值,只拿到环境变量名
能力 list_capabilities save_capability delete_capability attach_capability detach_capability 给 Bot 挂/摘 MCP server、skill、能力包(不是权限预设)
浏览器 read_browser Desktop 版的 Bot 浏览器
群 relay group_relay 群 Session 专用,由宿主合成调用

三个值得单独讲的细节:

(1)ask_user 为什么必须非阻塞。 dsh 原生有一个会阻塞 turn 的 ask_user_question(用户不答,这一轮就停在那里)。但在团队里,一个 Bot 阻塞会拖住整轮群聊。所以 DS Bot 把原生阻塞式问答工具对 Bot 隐藏,换成自己的卡片工具:调用即结束 turn,问题存进 state.questions,用户的回答作为"下一条用户消息"回到 Bot。这样 Bot 等用户的同时,其他 Bot 可以继续工作。

(2)message_bot 的 when 参数暴露了底层语义。 "now" 表示 steer——如果对方正在跑 turn,它会在下一个 step 边界读到;"queue" 表示 followup——等对方当前工作结束再处理。这个参数不是为了好玩,而是因为两种投递语义在协作中确实不同:提醒、纠错用 now,派新任务用 queue。

(3)工具要对不同角色隐藏。 DS Bot 还维护了一个"普通 dsh Agent"——不是 Bot,而是团队里一个可以直接用完整 dsh 能力的入口。它的 Session 会通过 agent.ctx.tools.restrict({ deny }) 把团队工具全部禁掉,避免"非团队成员"误用团队协议。

设计要点: 团队的工具面要"少而语义化"。每个工具都对应一条可审计的协作动作;能用参数表达语义(now/queue)就不要新增工具;阻塞式交互要改造成"结束本轮 + 下一条消息回来"。

3.9 工程细节:一半代码在处理"意外"

翻完 src/host/ 你会发现,真正"让模型变聪明"的代码很少,大部分代码在处理异常与恢复。几个典型:

  • 重启恢复:dsh 重启时会把未完成的 turn 作为 interrupted 关闭,并取消 Session inbox 里排队的消息。DS Bot 用 inflight.json 记录"已经投递但还没被消费"的消息与正在跑的 turn;启动 3 秒后逐条重发——跳过已经取用的、跳过还在 inbox 里的、给正在跑 turn 的补一条"重启了,继续"。
  • 长消息:超过 20000 字符的 Bot 消息会转成文件投递,state.json 里只保留前 2000 字符,避免团队文件越写越慢。
  • 图片:模型不支持读图时,先用视觉模型把图转成文字,再进入对话。
  • 密钥:request_secret 在聊天里弹一张安全卡片,用户输入的值只写入 secrets.json,Bot 通过 $DSH_SECRET_<NAME> 环境变量使用,永远看不到明文。
  • 用量:监听 llm/stream 统计 token,同时从 Session 日志补历史用量;按时间、Bot、模型三个维度聚合。
  • 数据目录禁区:Bot 的工作目录不允许指向 $DSH_HOME 或团队数据目录本身,防止 Bot 改自己的名册与权限。
  • 只读模式:拿不到写锁的宿主只读展示,所有写操作都会被明确拒绝——而不是静默失败。

设计要点: 做一个能演示的 Agent Team,一两千行足够;做一个每天在用的 Agent Team,剩下 80% 的工作是恢复、限额、审计和权限。评估工作量时,别只算"主流程"。

3.10 浏览器半:逻辑只有一份,UI 只是投影

最后补上双半插件的另一半。DS Bot 的浏览器半声明在 package.json:

{
  "exports": { ".": "./index.js", "./client": "./client.js", "./web-search": "./web-search.js" },
  "dsh": { "client": { "platform": "web" }, "bundle": { "patch": "./cordis.patch.yml" } },
  "scripts": { "build": "node scripts/build-client.mjs" }
}

src/client/ 的源码由 esbuild 打包成一个 client.js(约 830 KB,CJS 产物),外层套一个 window.__ModuleLoader__.load({ id: 'ds-bot', factory }):页面启动时只注册 factory,模块体(含 CSS 注入)要到第一次真正使用才执行。dsh 的 Web 应用启动后按清单把它作为客户端插件加载。它注册的 UI 包括:侧栏、私聊界面、群聊界面、Bot 详情、记忆面板、Usage 页面、设置页,以及 cells.js 里的一批自定义消息渲染器。

浏览器半与宿主半之间只通过一条 RPC 通道通信:宿主半用 ctx.connection.fetch.register() 注册 POST /api/bot,浏览器半发请求、带浏览器会话凭证;鉴权与 Host/Origin 检查由 dsh 的连接层在路由前完成,宿主半只做 payload 校验与 endpoint 分派,并把 state.revision 一起返回给客户端做增量判断。

值得抄的设计是:浏览器半不持有服务端业务真相。它不读 state.json、不碰 Session 日志、不做授权判断(只按宿主返回的 readOnly/角色做 UI 级禁用),本地缓存仅限界面偏好与未读标记;它只做两件事——把宿主的状态渲染出来,把用户的操作发回去。

设计要点: 双半插件的边界 = 安全边界。所有能碰文件、Shell、模型、密钥的逻辑都必须在宿主半;浏览器半只做展示与输入。UI 里出现的每个"操作",宿主半都必须再校验一次。


四、抽象:实现一个 Agent Team,到底要实现什么

看完 DS Bot,我们把"团队"这个概念从具体产品里抽出来。你会发现,任何 Agent Team 都绕不开五样东西。

4.1 团队的五要素

graph TB
    subgraph Team["一支 Agent Team"]
        M["成员 Members<br/>身份 · Session/子 Agent · 模型 · 工具 · 权限"]
        T["任务板 Tasks<br/>状态机 · 依赖 DAG · owner · revision"]
        Q["消息通道 Messages<br/>投递语义 · 送达确认 · 唤醒"]
        S["调度器 Scheduler<br/>事件监听 · claim · 超时 · 恢复"]
        W["共享状态 Workspace/Store<br/>文件 · 数据库 · Session 日志"]
    end
    L["Lead / Captain"] --> M
    M --> T
    S --> T
    S --> Q
    M <--> Q
    T --> W
    M --> W

逐项拆开:

(1)成员。 一个成员至少要有:稳定的身份(名字不可复用)、一个能持续对话的载体(root Session 或可继续 subagent)、一个模型路由、一组工具、一份权限。它还要有生命周期:创建中 → 活跃 → 空闲 → 失败 → 被中断。

(2)任务。 任务不是"一句提示词",而是一份带版本的状态记录:pending / in_progress / completed / deleted,加上 owner、blocked_by(依赖)、writeScopes(提示性的写作用域)。每一次变更携带 expectedRevision 做 CAS,防止两个成员互相覆盖。

(3)消息。 消息必须回答三个问题:投给谁、什么时候被读到、怎么确认送达。在 dsh 里有两个现成语义:steer(运行中的成员在最近的 step 边界读到)与 followup(排到下一轮)。发送成功只代表对方 inbox 接收,不代表模型已经处理——这是所有团队实现都必须自己兜底的语义。

(4)调度器。 谁来派活?两种答案:人(用户发言、@ 某人)或事件(成员空闲、任务就绪)。事件驱动的关键素材是 agent/status(running/idle)和 session/event(turn/end)。不要用轮询 turn 来"叫醒"成员——那会白白消耗 token。

(5)共享状态。 团队的真相存在哪里:Session 日志、外部 JSON、还是数据库?这个选择直接决定了重启恢复、并发安全与可观测性。

4.2 先决定状态放哪:一个 dsh 特有的平台约束

这一节值得单独讲,因为它是很多插件设计分歧的根源。

dsh 的 Session 日志有一个严格的兼容性规则:持久化读取路径会拒绝"未知且非 ignorable"的事件类型。官方生成的事件类型清单(KNOWN_SESSION_EVENT_TYPES)只包含本仓库 SessionEventMap 声明的事件;源码注释写得很清楚:

The persistence read path refuses to interpret a log containing a type outside this set unless the event carries the envelope’s ignorable marker: such a log was likely written by a newer harness, and silently skipping a required event would reconstruct a wrong session.

也就是说:第三方插件如果随手往 Session 日志里 append 一个自定义事件类型,重启后读取会被确定性拒绝(除非该事件带 ignorable)。 dsh 0.2.1 提供了一条实验性通道 appendPluginRecord():事件类型必须是 plugin: 命名空间、自动带 ignorable 标记、读取方不认识时"保留并跳过";但它明确是实验 API:源码注释写着"只有 packages/experimental/ 下的生产代码可以调用"(仓库内有 CI 门禁),格式迁移只"best-effort"保留这些记录,第三方应视为不稳定接口。

于是生态里出现了三种状态载体:

载体 谁在用 优点 代价
Session 事件(team/member、team/task) 官方 experimental Agent Teams 与 Session 同生命周期、可回放、天然有 seq/time 只有 first-party 能进 known 清单;事件类型格式受 Session 版本约束
外部 JSON / JSONL DS Bot(state.json)、dsh-agent-teams(team.json + 邮箱) 完全可控、易调试、与宿主版本解耦 要自己做锁、原子写、迁移、崩溃恢复
数据库(storage-domain) dsh-agent-team-gui 多表事务、查询方便 依赖宿主存储 API;schema 迁移受限

设计要点: 团队状态放哪,决定了你的恢复语义和升级风险。第三方插件的最优解通常是"外部 JSON 做真相源 + Session 只存对话";如果你要做官方生态级能力,才值得用 appendPluginRecord 这类实验通道。

4.3 最干净的参考实现:官方 experimental Agent Teams

如果你想要一份"标准答案",dsh 官方的实验性 Agent Teams(ctx.agentTeams)是最值得读的:它有完整的数据模型、工具集、授权规则与恢复语义,而且本文这个会话里 Lead 用的 Team 工具就是它(随产品交付的 profile 把 maxMembers 从包默认的 16 覆盖成了 8)。

身份模型:

  • 每一个普通运行时 root Session 都是一个隐式 Team 的 Lead,TeamId 就等于这个 Lead 的 SessionId——没有"建团队"事件,团队状态从第一条成员/任务记录开始;
  • 成员名字是不可变标签,永不复用,即使创建失败;
  • 每个成员从 provisioning 开始,最终只到达两个终态之一:active 或 failed;running/inactive 是运行时派生的状态,不会改写持久记录。

成员创建:

spawnTeammate(caller, {
  name, description, prompt, context: 'fresh' | 'fork', provider, signal
})
  • fresh 不携带 Lead 的历史;fork 只捕获一次 Lead 已完成 turn 的前缀;
  • 名字在第一条 provisioning 记录里就被保留;
  • 只有 Lead 能 spawn、interrupt、reassign;每个服务方法都要求传"确切的实时调用方 Agent"作为授权凭证。

消息:

  • sendMessage(caller, { target, content, signal }) 返回目标 inbox 的 MessageId;
  • 每条消息都尝试 steer 投递:运行中的目标在最近 step 边界收到,空闲目标被唤醒(或冷恢复);
  • 调用方不能选择投递模式,所以持久记录不存调度方式;
  • 发送成功 = inbox 接收,不等于模型处理;Team 不做重试、不去重,也不保留"未接收的发送意图"。

任务板:

createTask(caller, { subject, description, blockedBy, writeScopes })
updateTask(caller, { taskId, expectedRevision, action, ... })

支持 8 种动作:claim / release / edit / set_dependencies / complete / reopen / reassign / delete。规则很克制:

  • 每条任务存完整快照,revision 每次变更 +1;过期的 expectedRevision 会被拒绝;
  • blockedBy 必须指向未删除任务,并且保持无环;
  • writeScopes 只是"规范化的提示性路径前缀",不是锁——视图会对 in-progress 任务的重叠发出警告,但绝不阻止操作;
  • deleted 是保留的墓碑,completed 满足依赖;ready 是派生的;
  • 只有 Lead 能 reassign 或中断;in_progress 的 owner 不会因为成员空闲、中断、进程退出而自动释放。

等待与上限:

  • waitForChange() 等待下一次 roster / task / 消息 / 运行状态变化,时长 10 秒到 1 小时(默认 30 秒),只报告是否超时或 noProgress,调用方随后重新读取状态;它不会唤醒空闲成员,没有其他活跃成员时立即返回 noProgress——这是"事件驱动而非轮询"的标准写法;
  • 部署上限都是显式校验的:maxMembers 默认 16(随产品交付的 experimental profile 覆盖为 8)、maxTasks 默认 256、maxMessageBytes 默认 64 KiB、disposalTimeoutMs 默认 5 秒。

对浏览器暴露: 通过 agentTeam 这个 Session 投影,把 roster 与未删除任务板投影给 Web UI;成员活动来自 Session 状态,模型标签来自 modelSelection 投影。投影只是视图,不是真相源。

对应的 9 个模型工具(默认组合;interrupt_agent 可被配置关掉),正好覆盖了团队协作的全部动作:

工具 作用
spawn_teammate 创建具名、可继续的 teammate(fresh / fork)
send_message 给任意成员或 Lead 发消息
list_agents 列出 roster 与当前可用性
wait_agent 等待下一次变化,超时返回
interrupt_agent 只中断当前 turn,保留 inbox
team_task_create / team_task_get / team_task_list / team_task_update 任务板的增查改与 CAS 状态迁移

你会发现这套工具刻意做得很"薄":没有"自动完成一切"的高级编排工具,只有成员、消息、任务三类原语。编排智能留给 Lead 的模型——服务提供确定性,模型提供决策。

4.4 实现范式怎么选:三条路线、四个代表

把生态里的实现放在一起对比,本质是三种"状态与信任放哪里"的选择;其中 Task-board 路线有官方与社区两个代表,所以下表是三条路线、四个代表:

维度 A. Session-backed(DS Bot) B1. 官方 Team(experimental) B2. 社区 Task-board(dsh-agent-teams) C. Recipe / DAG(team-gui 为代表)
状态载体 Session + 外部 JSON Lead Session 里的 team/member、team/task 事件 工作区文件 team.json + 每 agent 一个 JSONL 邮箱 storage-domain 的多张 durable 表(agents/squads/runs…);官方 workflow 则是进程内 run
成员身份 每个 Bot 一个长期 root Session Lead 的可继续 subagent,生命周期跨 turn Lead 的可继续 subagent,生命周期跨 turn 配方里的角色,一次 run 生成一次
任务表达 无显式任务板,靠消息与 @ 接力 状态机 + 依赖 DAG + revision CAS(无 attempt) 状态机 + 依赖 DAG + attempt 代际(可重派、可撤销) 有界 DAG + 拓扑波次 + 结构化 handoff
唤醒 人类消息 / @ 无调度器:模型/Lead 主动派活,wait_agent 只等变化,不会唤醒空闲成员 idle 事件驱动 claim,调度器自动派工 一次性派发,脚本 await;无自动重新唤醒
失败恢复 checkpoint / handoff / 新 part 无 attempt/重试;消息发送不重试、不去重,重启靠 Session 持久化 attempt 代际 + reassign + cold recovery 一次 team-gui:retry-once + 结构化诊断 + 一次 continuation + run 对账;官方 workflow 无 journaling/resume
成本控制 每 Bot 常驻上下文 成员 idle 不发请求;maxMembers/maxTasks 上限 同左 + 成员深度/工具策略 并发上限 + 软 token 预算(team-gui;官方 workflow 无 token 预算词汇)
适合 长期陪伴、多角色群聊、记忆型助理 想让模型自己决定怎么分工、要最小机制的官方实现 单工作区长任务、要自动派工与可审计任务状态 可复用团队、一次性交付、需要 run 历史与复现

选择建议:

  • 要"长期人格 + 记忆 + 随时插话" → A(Session-backed);
  • 要"可审计的任务完成 + 自动派工 + 失败重派" → B2(社区 Task-board);要"最小机制 + 让模型自己决定分工" → B1(官方 Team);
  • 要"可复用的团队配方 + 一次运行的完整历史" → C(Recipe/DAG)。
  • 真实产品往往是混合:DS Bot 用 A 做身份与对话,再在内部补一层"工作委派树"(works)来做 B 的回收语义;官方 Team 用 B 的状态机,但成员本身是 A 的 Session。

4.5 动手前的 12 个问题

不管选哪种范式,实现前先把这 12 个问题答完,能省掉一半返工:

  1. 成员怎么创建、怎么命名、名字能否复用、最多几个、能不能再嵌套?
  2. 成员空闲/忙碌/失败的状态从哪来?是事件还是轮询?
  3. 任务有哪些状态?允许哪些迁移?终态能不能重开?
  4. 依赖怎么表达?成环怎么检测?上游 failed 时下游怎么办?
  5. 两个人同时抢一个任务怎么防?用 CAS 还是锁?
  6. 消息投递语义是什么?发送成功之后,怎么知道对方处理了?
  7. 成员被中断、被删除、进程重启时,等待者怎么被通知?
  8. 写作用域是提示还是锁?文件冲突怎么发现、怎么仲裁?
  9. 预算怎么算:成员数、任务数、消息大小、token、时间?
  10. 权限和审批由谁掌握?模型能自己提权吗?
  11. 状态存在哪?格式怎么迁移?回放能力如何?
  12. UI 从哪里取数据?投影和真相源会不会不一致?

五、动手:写一个最小可运行的 Agent Team 插件

理论讲完,我们真的写一个。目标不是复刻 DS Bot,而是用最少的机制跑通一支团队:

  • 一个 Lead + 最多 8 个成员,成员各有独立 Session;
  • 一块共享任务板,任务有依赖、有 owner、有 revision;
  • 成员空闲时自动领取 ready 任务;
  • 状态落盘,重启不丢。

完整骨架约 400 行(index.js + lib/team.js),任务板逻辑另有 11 个单测;下面只讲关键代码与设计理由,核心片段全文给出。

5.1 结构:五个文件,四层职责

dsh-agent-team/
├── package.json        # dsh.bundle.patch → 自动挂载
├── cordis.patch.yml    # insert 一行:id: agent-team
├── index.js            # 插件入口:成员/消息/工具/调度/提示词
├── lib/team.js         # 纯状态机:无 dsh 依赖,可单测
└── test/team.test.js   # 任务板单测
graph LR
    U["Lead / 用户"] -->|"工具调用"| P["agent-team 插件"]
    P -->|"sessionController.create"| M1["成员 Session A"]
    P -->|"agent.steer + sessions.flush"| M1
    M1 -->|"session/event: turn/end"| P
    P -->|"claim ready task (CAS)"| T[("team.json")]
    P -->|"dispatch 下一条任务"| M2["成员 Session B"]

第一层:纯状态机(lib/team.js)。 它不知道 dsh 的存在,只操作一个可 JSON 序列化的 team 对象。这是整个插件里最值得写单测的部分。

第二层:插件入口(index.js)。 它负责把状态机接到 dsh 的接缝上:创建 Session、投递消息、监听事件、注册工具、注入提示词。

第三层:持久化。 外部 JSON + 单写者锁 + 原子替换。

第四层:协议。 一段 system prompt section,告诉模型"你是谁、任务板怎么用、完成后怎么标记"。

5.2 任务板:状态机 + 依赖 DAG + CAS

任务板的全部规则集中在几个纯函数里。先看数据结构:

// lib/team.js
{ version: 1, revision: 0, name: 'team',
  members: { writer: { name, description, phase: 'active', revision: 0 } },
  tasks: {
    'task-1': { id, revision: 0, subject, description, status: 'pending',
                blockedBy: [], writeScopes: [], ownerId?, createdAt },
  },
  nextTask: 2 }

(1)依赖与就绪。 一个任务"就绪"的定义只有一条:

export function isReady(team, task) {
  if (task.status !== 'pending') return false
  return task.blockedBy.every(id => team.tasks[id]?.status === 'completed')
}

注意一个容易踩的坑:上游 failed / deleted 永远不会让下游就绪。这不是 bug,而是有意的——失败必须被显式处理——注意 reopen 通常只接受 completed,失败的任务要新建"修复任务"(或实现一个专门的 repair 动作),否则下游会在错误的前提上继续工作。另外,删除仍被依赖的任务必须被拒绝(否则下游会永久 blocked)。

(2)CAS(compare-and-set)。 任务的每一次变更都必须带上 expectedRevision:

function assertRevision(task, expected) {
  if (expected !== task.revision) {
    throw new TeamError('TASK_STALE_REVISION', `${task.id} is at revision ${task.revision}, not ${expected}`)
  }
}

为什么需要它?因为团队成员是并发工作的:Lead 可能正在 reassign,成员同时提交了完成。没有 CAS,后写者会悄悄覆盖前写者。CAS 让"过期视图"直接失败,调用方重新读一次再决定。

(3)依赖成环检测。 在设置依赖时从每个 blocker 出发 DFS,遇到自己就是环:

function assertAcyclic(team, selfId, blockers) {
  const visiting = new Set()
  const walk = (id) => {
    if (id === selfId) throw new TeamError('TASK_DEP_CYCLE', 'dependency graph would contain a cycle')
    if (visiting.has(id)) return
    visiting.add(id)
    for (const dep of team.tasks[id]?.blockedBy ?? []) walk(dep)
  }
  for (const id of blockers) walk(id)
}

(4)写作用域只是提示。 两个 in-progress 任务的 writeScopes 重叠时,视图给出警告,但绝不阻止 claim:

// 只产生警告,不影响状态迁移
warnings.push(`${scope} overlaps ${other.id} (${otherScope}) owned by ${other.ownerId ?? 'nobody'}`)

这是 dsh 官方 Agent Teams 的明确设计(“advisory, not a lock”),原因很现实:Bash、formatter、代码生成器都可以绕过文件版本检查,与其做一个假的锁,不如把冲突暴露给 Lead。

(5)一个有分歧的语义:reassign 算不算开工? 本 demo(与社区 dsh-agent-teams 一致)把"指派"和"开始执行"分开:Lead 指派只落 ownerId,成员自己 claim 才真正开工。好处是"被指派但一直没开工"的任务在状态上可见。

case 'reassign':
  task.ownerId = owner   // 只落 owner
  break                  // status 仍是 pending;成员自己 claim 才变 in_progress

但要注意:官方 experimental Agent Team 的选择相反——它的 reassign 会在校验"任务 pending/in_progress、依赖已 ready、目标 owner active"之后直接把任务置为 in_progress,也就是"Lead 派工即开工"。两种都合理,关键是把它写进协议、并在 UI 与工具描述里保持一致;不要一半按"指派"、一半按"开工"解释。

5.3 成员与消息:用 root Session 当成员的身体

成员创建走 dsh 的会话 API,然后把第一条消息投进去:

// 建成员:一个 root Session 就是它的身体
const { sessionId } = await ctx.sessionController.create({ cwd: workspace })
team.members[name].sessionId = sessionId

// 投递:模型看到的是用户消息,程序看到的是结构化 source
const payload = createUserMessage({
  content: [{ type: 'text', text: message }],
  source: { kind: 'team', role: 'request', exchangeId: randomUUID(), sender: 'lead' },
})
agent.steer(payload)                     // 运行中的成员在下一个 step 边界读到
await ctx.sessions.flush(agent.session)  // 不 flush,消息可能一直停在 inbox

这里有两个必须记住的点:

  1. flush() 是持久化屏障,不是投递动作。 投递由 steer() / followup() 完成;flush() 等待消息与事件真正落到持久存储。漏掉它不会让消息"不生效",但崩溃或重启后可能丢掉这条记录,冷恢复也就找不回来。
  2. 消息要带 source。 模型看到的是自然语言,宿主看到的是 source 里的结构化字段。不要靠解析模型输出来判断"这条消息是任务还是闲聊"。

5.4 调度:turn/end 就是"这个成员空闲了"

调度器只做一件事:监听会话事件,在成员空闲时给它找活干。

ctx.on('session/event', (session, event) => {
  if (event.type !== 'turn/end') return
  note()  // 唤醒所有 wait_agent 等待者
  const member = Object.values(team.members).find(entry => entry.sessionId === session?.id)
  if (member?.phase !== 'active') return
  void dispatch(member).catch(error => ctx.logger?.warn?.('dispatch failed: %s', error))
})

async function dispatch(member) {
  const ready = readyTasksFor(team, member.name)   // 指派给自己的优先
  if (ready.length === 0) return
  const task = claimTask(team, { id: ready[0].id, owner: member.name, expectedRevision: ready[0].revision })
  await save()                                      // 先落盘,再发消息:崩溃也不会重复派工
  await deliver(member, `[Task ${task.id}] ${task.subject}\n${task.description}`)
}

这段代码里有三个决定成败的细节:

  • 先 claim 再发消息。 如果先发消息再 claim,进程在两者之间崩溃会留下"消息已发、任务仍 pending"的状态:任务可能被另一个成员再次 claim,造成重复劳动(而不是同一条 claim 被派给两人);
  • 每个成员一个串行队列。 如果短时间内来了多个 turn/end,并发 dispatch 可能重复派工;实际实现里要按成员串行化(用 promise 链或 Map<name, Promise>);
  • wait_agent 只报告超时(官方同名工具还会返回 noProgress,这里简化为只看超时)。等待者不携带业务数据,醒来后重新读状态——这避免了"等待期间状态已变,但等待者拿到旧值"的问题。

5.5 持久化与上限:别省这两种代码

持久化看起来最简单,其实最容易出事。骨架里的做法是:

const save = () => {
  const snapshot = JSON.stringify(team, null, 2)
  const run = writing.then(async () => {
    await ensureLock()
    const temp = `${statePath}.${process.pid}.tmp`
    await writeFile(temp, snapshot, 'utf8')
    await rename(temp, statePath)     // 原子替换
  })
  writing = run.catch(() => {})       // 写入串行化
  return run
}
  • 单写者锁:open(lockPath, 'wx') 独占创建,拿不到就拒绝写(而不是静默覆盖);
  • 原子替换:temp + rename,任何时刻磁盘上都是一份完整 JSON;
  • 串行化:writing = run.catch(() => {}) 把并发写压成一条链。

上限也必须显式:成员数、任务数、单条消息大小、等待时长。没有上限的团队在模型"热情过头"时会迅速烧掉预算。

5.6 从演示到生产的 10 个坑

把骨架变成能每天用的东西,还要补这些(多数能在 DS Bot、官方 Team 或社区实现里找到对应;少数——比如跨进程协调、文件锁——目前谁都没有现成答案):

  1. 发送成功 ≠ 已处理。 send_message 返回只代表对方 inbox 接收;要有回执、超时、重试或补偿消息。
  2. 重复唤醒。 一次 turn/end 可能触发多次 dispatch;按成员串行化 + CAS claim。
  3. 旧 worker 的迟到写入。 reassign 之后,旧 owner 可能才提交完成;需要 attempt 代际(attemptId)把过期写入判 stale。
  4. 上下文膨胀。 每个成员都是独立上下文,任务描述必须短;长材料放文件,消息里只给路径。
  5. 文件写冲突。 writeScopes 只是提示;要么单写者、要么分目录、要么用 worktree 隔离。
  6. 孤儿任务。 成员空闲、被中断、进程退出都不会自动释放 owner;要有 reclaim 策略。
  7. 成本失控。 成员数量、任务数量、单次运行 token、总时长,四个都要有上限。
  8. 重启恢复。 记录"已投递未消费"的消息与正在跑的 turn;启动后对账,跳过已取用的,重发被取消的。
  9. 提示词漂移。 工具集与团队协议从第一次请求起就固定,别在运行中改 schema——会击穿 KV cache。
  10. 权限逃逸。 成员不能自己提权;工具按角色 restrict;工作目录禁止指向 $DSH_HOME 等数据目录。
  11. 消息风暴。 成员之间互相"收到/谢谢/我这边完成了"会指数级放大 turn 数;协议里要明确"只发需要对方行动的消息",并给每个成员/每轮设置消息上限,必要时合并或摘要。
  12. 陈旧锁。 单写者锁用 open(wx) 创建后,如果进程崩溃,锁文件会残留,下一个进程会永久 TEAM_LOCKED;锁里要写 pid + 心跳时间,并在超时后允许接管(DS Bot 的 15 秒心跳/60 秒过期就是干这个的)。
  13. 成员冷恢复。 成员的 Session id 落盘只是第一步:重启后要 resolveAgent() 或冷恢复它,并校验它还是原来的身份、旧 attempt 是否还有效;否则消息会发给一个"存在但已经不接受工作"的成员。

5.7 不想自己造?三种现成底座

  • 要"官方标准答案":启用 @deepseek-ai/dsh-experimental-agent-team-profile,得到 spawn_teammate / send_message / team_task_* 一整套服务与 Web UI;
  • 要"可审计的任务协作":用社区 @nanmicoder/dsh-agent-teams,captain + 持久成员 + 依赖任务 DAG + 自动调度 + 原生 Team 面板;
  • 要"可复用团队配方 + 一次运行的完整历史":用 dsh-agent-team-gui,Team/Solo/Inherited 模式 + 有界 DAG + Run Center + provider 上报的 token 用量;
  • 要"长期人格 + 记忆 + 群聊":用 DS Bot(本文主角),每个 Bot 一个长期 Session,记忆与上下文自成体系。

六、结论:团队是工程问题,不是提示词问题

回到最初的问题:dsh-bot 是怎么做到的?它没有发明新框架,而是把 dsh 已有的原语组合到了极致:

  1. 插件体系让"团队"成为可插拔的能力组合——Session、工具、提示词、事件、LLM、工作区,每一块都能被替换或拦截,所以一个社区插件可以拥有官方级别的控制力;
  2. Session 即成员让"长期身份、独立上下文、原生工具与权限"直接复用,DS Bot 因此只需要写编排层,不需要写 Agent 运行时;
  3. 状态外置 + 事件驱动让团队在崩溃、重启、并发下仍然可恢复——这是"能用"与"看起来能用"的分水岭;
  4. 协议写进提示词、约束写进代码——模型负责内容和决策,宿主负责流程与边界。

如果只记一句话:

Agent Team = 一组有身份的长期 Agent + 一块带版本的共享任务板 + 一条可确认的消息通道 + 一个事件驱动的调度器。模型提供智能,代码提供确定性。

最后,如果你要开始写自己的团队插件,建议按这个顺序落地:

  1. 先写纯状态机(任务板 + 依赖 + CAS),单测跑通;
  2. 再接一个成员(创建 Session、发一条消息、收到回复),确认投递与事件;
  3. 再加调度器(成员空闲 → 领取任务),确认不会重复派工;
  4. 最后补持久化、上限、恢复与权限——这些才是生产环境的主要工作量。

参考资料

对象项目

  1. DeepSeek-Bot(DS Bot):https://github.com/Fei2411/DeepSeek-Bot,npm ds-bot@0.1.7,Apache-2.0
  2. DeepSeek Harness:https://github.com/deepseek-ai/deepseek-harness,本地版本 0.2.1-rc.2
  3. 官方 Agent Teams 子系统文档:docs/subsystems/agent-team.zh.md(仓库内)
  4. 官方 Agent Teams 包:@deepseek-ai/dsh-experimental-agent-team、@deepseek-ai/dsh-experimental-tool-agent-team、@deepseek-ai/dsh-experimental-client-ui-agent-team
  5. 社区 Task-board 团队:https://github.com/NanmiCoder/dsh-agent-teams,@nanmicoder/dsh-agent-teams@0.1.24-rc.1
  6. 社区团队配方/DAG:https://github.com/toolclub/dsh-agent-team-gui,dsh-agent-team-gui@1.3.0
  7. 同名但不同的项目:npm dsh-bot(另一个独立项目,npm 描述为"用 cron / webhook / 队列跑 headless dsh agent")、deepseekbot(BotHarness,github.com/BotHarness/BotHarness;npm 包描述注明"Not the same project as DS Bot (ds-bot)")

文档

  1. dsh 架构说明:docs/architecture.zh.md(Cordis、profile/bundle/patch、轮次流程、能力 seam)
  2. Cordis 入门:docs/cordis-primer.zh.md
  3. Agent 轮次与步骤生命周期:docs/agent-lifecycle.zh.md
  4. 会话格式与 known event types:packages/core/session/src/known-event-types.ts
  5. 插件最小形态与检查清单:本文第二节

配套代码

  1. 本文示例骨架:dsh-agent-team(index.js + lib/team.js + 11 个单测;核心代码见第五节)