首页/ 智能体/ 智能体团队
● AGENT TEAMS · 0.1.6

智能体团队:把一次会话
变成一支小队

一个 Lead,若干具名 teammate,一块共享任务板,以及一套能在崩溃与重启之后仍然活着的消息通道。 本文从官方源码与文档出发,拆解 DeepSeek Harness 实验性 Agent Teams 的设计—— 它最难的地方不是「怎么并发」,而是怎么让协作状态可回放

dsh-v0.1.6-alpha.2 @deepseek-ai/dsh-experimental-agent-team 阅读约 18 分钟

01为什么需要一支团队

单个 agent 的工作方式是线性的:想一步、做一步、看结果、再想一步。这在大多数任务上足够好—— 直到你让它同时改五个模块,或者先写实现再独立审查再统一提交。这时候它会开始「忘记」: 上下文被前面步骤的输出塞满,前面写下的约定到后面就被稀释了。

dsh 早在 subagent 子系统里给出了第一种解法:把工作委派给子 agent, 子 agent 在独立的上下文里跑完,只把结果交回来。这是一种「扇出」结构——父 agent 是唯一的协调者, 子 agent 之间互不相识。

但真正的协作往往不是扇出,而是网状的:审查者需要知道实现者改了什么,实现者需要知道测试者发现了什么, 而 Lead 需要随时掌握全局。Agent Teams 就是为此设计的第二种解法—— 它把一次编码会话变成一支小型团队,成员之间可以互相直接发消息, 并通过一块共享任务板协调谁做什么。

💡
一句话概括区别

Subagent 是「派活 + 收结果」;Agent Teams 是「派活 + 保持在线 + 互相通信 + 共享看板」。

02三个概念:Lead / teammate / 任务板

整个模型只有三个对象,理解它们就理解了全部:

概念是什么关键约束
Lead 当前会话的 root agent。每个普通运行时 root 都会隐式成为一支团队的 Lead TeamId 就等于它的 SessionId;没有「创建团队」这个事件
Teammate Lead 创建的具名子 agent,有唯一的小写名字(如 reviewer 只有 Lead 能创建与中断;名字永久保留且永不复用,即使创建失败
任务板 团队共享的 task 列表,带依赖关系与 owner 每次变更都是 compare-and-set;依赖未完成的任务不能认领

teammate 的两种启动方式

创建 teammate 时可以选两种上下文模式,这个选择在读代码前就得先明确:

分叉的语义有一条容易被忽略的细节:fork 只捕获一次 Lead 的已完成 turn 前缀。 它不会在之后继续同步 Lead 的新进展——想要同步,得靠发消息。

roster 的五个状态

每个成员从 provisioning 开始,并且只会到达一个终态activefailed。 其余三个运行时状态是单独派生的,绝不会重写那条持久记录:

状态类型含义
provisioning持久 phase已写入成员记录,正在等待 provider 创建 child
failed持久 phase(终态)创建失败。名字仍然被永久占用
active持久 phase(终态)成员创建成功,身份确立
running运行时派生当前拥有活跃的 driver 或 maintenance 任务
idle运行时派生没有活跃工作,但可直接被唤醒
inactive运行时派生存在但未加载;发给它的消息会排队,唤醒后送达
这个设计的价值

持久 phase 与运行时状态分离,意味着「这个成员是谁」和「它此刻在干嘛」是两件独立的事实。 崩溃恢复时只需对账前者,后者重新推导即可——这正是整个系统能活过重启的基础。

03架构:日志是唯一真源

Agent Teams 包的第一句设计原则就定下了整个基调——持久日志,派生状态

Lead 会话日志是唯一真源;roster、mailbox 与任务状态每次读取都从中回放。

—— @deepseek-ai/dsh-experimental-agent-team 设计理念

这句话的工程含义非常具体:团队没有在内存里维护一份可变的状态对象,然后在某个时刻把快照写盘。 相反,每一次成员变动、每一条消息、每一次任务修改,都作为一条仅追加的会话事件写进 Lead 的日志, 并且在操作报告成功之前就 flush

读的时候则反过来:foldTeam() 把一个 Root Session 的事件流回放成三样东西—— roster、任务板、以及「已排队但未投递」的 mailbox。

Lead Agent TeamId = SessionId 隐式 root,无需创建 LEAD SESSION LOG · 仅追加 · 操作成功前 FLUSH team/member team/task team/message/queued …/delivered 这些事件只存在于日志 —— 从不进入会话表面,因此派生模型历史不受协作记录影响 顺序与时间由会话事件的 seq / time 负责,Team 快照不重复保存 foldTeam() 回放 foldTeam(root) 按 TeamId 选取记录 roster 成员身份 + 派生状态 任务板 CAS revision + DAG mailbox queued − delivered append + flush
图 1 · 持久日志是唯一真源,三个视图都是它的纯函数

这个设计的直接好处是崩溃恢复变得平凡。进程重启后不需要「修复」任何内存状态—— 把日志读一遍,团队就回到了崩溃前的样子。未终结的 provisioning 记录会与 child 自己独立持久化的会话做对账: 直接 parent 匹配、且初始用户消息已记录,就判定为 active;其他任何情况判定为 failed

⚠️
一条容易被忽略的边界

TeamId 选取记录意味着:普通 fork 继承的事件保留的是 ancestor 的 id, 绝不会进入新 Root 的状态。所以 fork 一个团队会话,不会凭空多出一支团队。

三项承诺

除了「持久日志,派生状态」,实现文档还明确列出了另外两条,它们划定了能力的边界:

04TeamService:十二个方法

团队领域服务注册在 ctx.agentTeams 上,类型是 TeamService, 由精确的实时 Lead Session 日志支撑。它的方法签名有一个共同特征: 第一个参数永远是调用方自己——身份即凭证。

packages/experimental/agent-team/src/index.tstypescript
// 权限与查询
membership(agent: Agent): TeamMembership
tryMembership(agent: Agent): TeamMembership | undefined
listMembers(agent: Agent): TeamMemberView[]

// 成员生命周期(仅 Lead)
async spawnTeammate(caller: Agent, request: SpawnTeammateRequest)
      : Promise<SpawnTeammateResult>
interrupt(caller: Agent, targetName: string)
      : { previousStatus: 'running' | 'idle' | 'inactive' }

// 通信
async sendMessage(caller: Agent, request: SendTeamMessageRequest)
      : Promise<SendTeamMessageResult>

// 任务板
async createTask(caller: Agent, request: CreateTeamTaskRequest)
      : Promise<TeamTaskView>
getTask(caller: Agent, id: TeamTaskId): TeamTaskView
listTasks(caller: Agent): TeamTaskView[]
async updateTask(caller: Agent, request: UpdateTeamTaskRequest)
      : Promise<TeamTaskView>

// 等待
async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal)
      : Promise<TeamWaitResult>

为什么是「确切实时 Agent」而不是 ID

这个方法签名值得停下来多看两眼。大多数系统会写成 spawn(teamId, callerId, req)—— 传 id,服务去查权限。dsh 传的是活着的对象本身,作为授权凭证

区别在于:id 是可以伪造或过期的。一个已经被 dispose 的 agent,它的 id 字符串依然「看起来合法」。 而传入 Agent 实例,服务就能确认这个调用方此刻确实活着,并且能顺着它解析出 真实的 root、Team 身份、角色与模型可见名。过期身份会落到 tryMembership() 返回 undefined, 而不是被误认为有效。

浏览器侧:三个 Remote 方法

除了服务方法,TeamService 还拥有三个生成的 Remote method,供 Web UI 使用: agentTeams/viewagentTeams/createTaskagentTeams/updateTask。 这里有个设计细节值得学:传输失败与领域拒绝被分开表达——

这避免了「把业务错误当网络错误重试」这类经典事故——客户端能明确知道「服务器收到了,是我的请求有问题」, 而不是「我不知道有没有成功」。

05模型看到的九个工具

领域服务不会自己暴露给模型。让模型能用上团队的,是兄弟包 @deepseek-ai/dsh-experimental-tool-agent-team——它提供九个工具, 按四类能力组织。关键在于:Lead 和每个 teammate 拿到的九个 schema 完全相同, 权限差异在执行时检查,而不是靠给不同人不同的工具清单。

类别工具作用权限
创建成员 spawn_teammate 接名字、描述与初始任务,创建具名 teammate 仅 Lead
发送消息 send_message 在步骤边界 steering 运行中的成员、启动空闲成员、冷恢复非活动成员 任何成员
查看与等待 list_agents 显示带实时状态的 roster 任何成员
wait_agent 等待下一次团队变化,避免轮询 任何成员
interrupt_agent 停止 teammate 当前轮次(不清空其待处理消息) 仅 Lead
任务板 team_task_create 添加任务(标题、详情、依赖、文件触及提示) 任何成员
team_task_list 浏览当前未删除任务任何成员
team_task_get 读取单个任务(含已删除 tombstone)任何成员
team_task_update 认领、完成、释放、重开、指派任何成员

三条工具实现原则

适配器本身很薄,但它的三条原则决定了工具的可预测性:

  1. 按作用域,而非全局——每个注册都位于成员 Agent 自己的 ctx 上, 安装依据「Agent 发布时是否已是成员」进行。maybeInstall 订阅 agent/created, 跳过没有 Team 成员关系的 Agent。
  2. 声明式结果,紧凑 JSON——每个工具都声明完整结果 schema, 把值渲染为紧凑 JSON。编译器会对照「向模型承诺的结果」检查 execute 实现, 同时保证没有任何结果把 token 花在缩进上。
  3. 领域拥有裁决权——工具只是委托给 ctx.agentTeams, 由后者强制执行 Lead 权限与 revision 校验。适配器不添加更弱的旁路
🔧
最小可用配置

团队功能需要持久会话存储才能激活。对现有组合的最小增量是三个条目:

cordis.patch.ymlyaml
# 最小团队配置 — 持久存储 + 两个 Team 包
- name: '@deepseek-ai/dsh-session-persistence-jsonl'
- name: '@deepseek-ai/dsh-experimental-agent-team'
- name: '@deepseek-ai/dsh-experimental-tool-agent-team'
  config:
    freshProvider: spawn   # 默认值
    forkProvider: fork    # 默认值

所有限制都在启动时校验

字段默认值含义
maxMembers16一支团队最多可创建的 teammate 数,包括失败的
maxTasks256任务板上最多的活动任务数(tombstone 不占额)
maxPendingMessagesPerMember64单个成员最多可排队的消息数
maxMessageBytes65,536单条发送消息的最大尺寸
disposalTimeoutMs5,000关闭清理允许的时间

注意「超出上限时明确失败」这条——exhausted 时报告类型化错误, 而不是复用 id 或名字。maxMembers 把失败的成员也算进去,正是因为名字永不复用: 一个创建失败的 reviewer 已经永久占用了这个名字和这个名额。

spawn 注入的身份前缀

创建 teammate 时,spawn_teammate 会在初始 user 消息前加上一段身份说明—— 这段前缀不含 Team id,因此禁用运行时上下文时也能生效

spawn_teammate 注入的初始 user 消息text
<system-reminder>
You are teammate "<name>".
</system-reminder>

<初始任务文本>

而 fork 模式不同:它继承历史,所以不会额外添加 Lead 身份消息—— 分叉出来的成员知道 Lead 做过什么,但不需要被反复告知「你是谁」。

06持久 mailbox 与投递语义

消息是团队的血液,也是最容易出错的地方。dsh 在这里的设计可以概括成一句话: 先存,再投;存了就不重发。

写入顺序

  1. sendMessage() 先校验 peer 成员关系;
  2. 追加 team/message/queued 事件,并且在尝试投递之前 flush
  3. 然后才尝试即时投递;
  4. 只有当目标会话持久持有该消息身份后,才以 team/message/delivered 确认。

「queued − delivered」的差集就构成了恢复 mailbox。 重启后,恢复流程按同一顺序重新投递这些记录。

三类目标的投递策略

每条消息都会尝试用 Steer 投递。具体走哪条路取决于目标当前的 Activation 状态—— 但关键在于:调用方不能选择模式,因此持久记录里不存储调度方式(那是可推导的)。

目标状态投递行为模型何时看到
running 在同一 Activation 中 steer 最近的 step 下一个步骤边界——不用等整轮结束
waiting 唤醒并 steer 同一 Activation 唤醒后立即进入处理
无 Activation(inactive) 冷恢复一个新的 Activation,再 steer 重建 agent 后处理,历史前缀仍可复用

去重是怎么做的

「不丢失、不重复」听起来像分布式共识,但这里其实是个更朴素的保证: 进程内重试 + 目标会话去重。机制是两个身份标记的双写:

docs/subsystems/agent-team.zh.mdtypescript
/** Source retained by the target Session for durable mailbox de-duplication. */
interface TeamMessageSource {
  readonly kind: 'team-message'
  readonly teamId: TeamId
  readonly messageId: TeamMessageId
  readonly senderId: SessionId
  readonly senderName: string
}

于是有一个很实际的推论:「inbox 已接受但模型尚未 claim」时崩溃,不会导致消息复制。 因为重试前会同时折叠 live 与持久两边的 inbox/历史状态,重复的身份会被识别出来。

⚠️
所以:排队的消息绝不能重发

这是文档里反复强调的一条。发送方看到的结果只有两种: accepted(已立即送达)或 queued(暂时不可用,已安全存储)。 两者都意味着消息已经安全落盘——重发只会造成重复投递。

给 Lead 和给 teammate 的路径不同

这个细节体现了权限模型的一致性:

Sibling 消息绝不会通过公开的「相邻 Agent 消息」操作伪装成 Lead。

——这就是为什么要单独有一条 host-only 路径

模型实际看到什么

每条已投递的 peer 消息对目标而言都是「用户角色」消息——因为从目标 agent 的视角看, 外部输入确实就是用户输入。第一个短文本块包含稳定 message id 与发送者,之后原样附加发送者的内容块。

Token 与 KV cache 影响

roster、task、mailbox 记录只存在于日志,绝不进入派生模型历史,因此任务与 roster 变更不增加模型 token。 Peer 消息追加在目标可复用历史前缀之后;冷恢复会先复用持久对话,再追加尚未投递的消息。

07共享任务 DAG 与 CAS

任务板上最容易出的事故是「两个人同时改同一个任务,后者悄悄覆盖前者」。 dsh 的解法是每次变更都是 compare-and-set,携带 expectedRevision。 基于过期副本的更新会被拒绝(TEAM_TASK_STALE_REVISION),而不是覆盖更新的成果。

docs/subsystems/agent-team.zh.mdtypescript
/** Whole durable task snapshot; every mutation increments {@link revision}. */
interface TeamTaskSnapshot {
  readonly id: TeamTaskId
  readonly revision: number          // CAS 值,每次变更 +1
  readonly subject: string
  readonly description: string
  readonly status: TeamTaskStatus
  readonly ownerId?: SessionId
  readonly blockedBy: TeamTaskId[]      // 必须无环
  readonly writeScopes: string[]        // ⚠️ 提示性,不是锁
}

四种状态

状态含义
pending尚未开始,或已经被释放回板上
in_progress进行中,携带 owner
completed已完成,会满足依赖它的 blocker
deleted保留的 tombstone——不出现在活动列表,但保留以供回放与维持 id 稳定

依赖图的两条硬约束

校验发生在 append 之前——./invariant 伴生插件会把每条候选 Team 事件 对照已提交前缀回放一遍,并在 append 前拒绝非法转换。 这也解释了为什么任务变更能安全地「先写日志」:不会把非法状态写进去。

08往下看:Subagent 提供方

teammate 最终还是要由某个真实的子 agent 实现。这一层由 ctx.subagents 承担—— 它是 dsh 的一个可选能力 seam,与 bash 不同之处在于: 同一上下文中可以共存多个提供方实现,按名字注册,遵循 LLM 适配器注册表的模式。

六个兄弟包

提供方机制说明
subagent-spawn-in-process进程内,全新上下文默认的 freshProvider: spawn
subagent-fork-in-process进程内,继承前缀默认的 forkProvider: fork
subagent-acpACP 协议传输启动前拒绝 agentOptions
subagent-codexCodex CLI同上
subagent-claude-codeClaude Code CLI同上
subagent-dsh-sdk独立子运行时把四个 Agent 路由字段合并到实例默认值之上

两种能力发现方式

这是子系统里一个相当讲究的设计——能力声明被分成了两套机制:

SubagentCapabilitiestypescript
interface SubagentCapabilities {
  readonly agentOptions: boolean   // provider/model/effort/token 覆盖
  readonly outputSchema: boolean   // 结构化输出
  readonly depthLimit: boolean     // 委派深度上限
  readonly toolFilter: boolean     // 工具作用域
  readonly persona: boolean        // 每个子 agent 的人格
}

如果请求依赖提供方不具备的功能,会被明确拒绝(SubagentError('UNSUPPORTED_CAPABILITY')), 绝不会被接受后静默忽略

—— 这条「fail loud, no silent degradation」原则贯穿整个子系统

可继续子 agent:Activation 模型

「可继续」是 Agent Teams 能存在的前提——teammate 必须是后台常驻、可反复交互的会话, 而不是一次性调用。模型是这样的:

可继续子 agent 的结构text
persisted Session
  -> optional live Activation        # 至多一个进程内激活
       -> one retained AgentHandle
       -> Agent inbox as the only turn FIFO
       -> zero or more owned child Activations

这里有个清晰的职责划分:继续执行管理器负责 activation 准入、直接父级鉴权、 实时所有权图、冷恢复与子级优先释放;agent loop 负责一切轮次排序与执行。 而且——「任何可继续路径都不会创建 Task,也不会创建承载中间结果的包装层」。

💡
关于 0.1.6 的并发默认值

可继续对话的 Subagent 链默认最多同时保留 8 个子代理、委派深度为 1, 均可在设置中调整。这意味着默认情况下 teammate 不会再往下开团队——深度 1 是有意保守的默认。

09一次协作的完整时序

把前面所有机制串起来,一次典型的「Lead 派活 → teammate 执行 → 汇报」是这样的。 注意每个参与者读写的是同一个日志,这也是所有状态能保持一致的原因。

Lead root session reviewer fresh teammate Tester fork teammate 1 spawn_teammate(name="reviewer", context=fresh) 先追加 provisioning 成员记录,再要求 provider 创建 child 2 spawn_teammate(name="tester", context=fork) fork 继承 Lead 已完成轮次前缀 —— KV cache 可复用 3 team_task_create(subject, blockedBy=[], writeScopes) revision=1,写入 Lead 日志;blockedBy 必须无环 4 team_task_list() → 读取可认领任务 5 team_task_update(claim, expectedRevision=1) CAS 成功 → revision=2,owner=reviewer。过期 revision 会被拒绝 执行审查工作 共享 cwd,改动立即可见 6 send_message(target="lead", content=[...]) → accepted 先 append + flush team/message/queued,再尝试投递 7 team_task_update(complete, expectedRevision=2) → revision=3,满足 blocker,解除下游依赖 wait_agent() 8 依赖解除 → tester 冷恢复或唤醒,认领下游任务 inactive 成员:消息排队,唤醒后按顺序送达
图 2 · 从 spawn 到依赖解除:每个箭头都是一次 append + flush

模型侧的策略文本

除了工具,成员还会收到一段共享的 system 策略(team:policy 段落), 说明显式委派要求、共享 cwd 行为、文件陈旧版本恢复、Bash/formatter/codegen 风险、 task 与 write-scope 协调、Steer 投递、mailbox 不重试规则,以及Lead 必须在回答前等待

🎯
一个令人安心的默认行为

固定策略只在明确要求团队或 teammate 时才创建成员,因此普通任务永远不会自行触发委派。 它不会在你只是想改个 bug 的时候,自作主张拉起一支小队。

10已知限制:别把它当银弹

官方文档在这一节写得相当坦诚,这些是当前包约束,值得逐条理解:

限制实际含义
实验原型,无稳定性承诺 以实验性名称公开发布,孵化期间约定仍可自由变更。schema 会变。
单进程、共享 checkout 成员共享 cwd,修改立即可见。不提供 worktree、远端成员、merge 或文件锁。
write scope 仅作提示 这是最容易误解的一条:writeScopes 不是锁。Bash、formatter、 代码生成器与直接外部写入都能绕过文件版本检查——Lead 必须自己协调 owner 并检查最终 diff。
扁平且不可变的 roster 只有 Lead 能创建直接 teammate;不支持嵌套 Team、重命名、删除或名字复用。
不会自动释放 owner idle、interrupt、进程退出与工作失败都不会释放任务 owner。 任务卡在某人名下需要手动处理。
mailbox 不保证跨进程 exactly-once 不支持多个 harness 进程并发操作同一 Team。

还有一个时序上的已知问题

工具适配器层有一个坦诚到近乎罕见的限制记录:进程内一次性子代理在发布后才获得 subagent descriptor。这会导致 Team 安装可能把它们误认作 Lead, 从而暴露 Team 策略和工具。不过 descriptor 随后会识别出它们并非成员,调用会被拒绝。 安装时序的修复留待以后处理。

把这种「知道有问题、知道影响面、知道为什么现在不修」记录在案,而不是藏起来—— 这本身就是项目成熟度的一个信号。

11小结

Agent Teams 最值得学的不是「怎么让多个 agent 并发」——那是容易的部分。 难的是让协作状态可回放

这些原则都不依赖具体实现,把它们搬到任何多 agent 系统上都成立。 这大概也是为什么 dsh 把 Agent Teams 放在「一切皆插件」的架构上—— 它本来就是一棵插件树里的一个可替换节点,做得不对,换掉就好。


📌
时效性声明

本文基于 dsh-v0.1.6-alpha.2(2026-09-17)的官方仓库文档与源码整理。 Agent Teams 是实验性特性,不承诺稳定性——API 与 schema 在后续版本中可能变化, 落地前请以你使用版本的官方文档为准。