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 时可以选两种上下文模式,这个选择在读代码前就得先明确:
fresh(新起)——不携带 Lead 对话的任何记忆。适合职责独立、边界清晰的角色, 比如「审查这个 diff」或「跑一遍基准测试」。它开局干净,不会被 Lead 的历史干扰。fork(分叉)——继承 Lead 已完成轮次的前缀。适合需要共享大量背景的任务, 而且因为前缀可复用,KV cache 命中率更高。
分叉的语义有一条容易被忽略的细节:fork 只捕获一次 Lead 的已完成 turn 前缀。 它不会在之后继续同步 Lead 的新进展——想要同步,得靠发消息。
roster 的五个状态
每个成员从 provisioning 开始,并且只会到达一个终态:active 或 failed。
其余三个运行时状态是单独派生的,绝不会重写那条持久记录:
| 状态 | 类型 | 含义 |
|---|---|---|
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。
这个设计的直接好处是崩溃恢复变得平凡。进程重启后不需要「修复」任何内存状态——
把日志读一遍,团队就回到了崩溃前的样子。未终结的 provisioning 记录会与 child 自己独立持久化的会话做对账:
直接 parent 匹配、且初始用户消息已记录,就判定为 active;其他任何情况判定为 failed。
按 TeamId 选取记录意味着:普通 fork 继承的事件保留的是 ancestor 的 id,
绝不会进入新 Root 的状态。所以 fork 一个团队会话,不会凭空多出一支团队。
三项承诺
除了「持久日志,派生状态」,实现文档还明确列出了另外两条,它们划定了能力的边界:
- 进程内归属——所有协作都位于单一进程。保证是重试加去重,绝不是跨进程共识。 这意味着多个 harness 进程并发操作同一支团队不受支持。
- 显式权限——每个服务方法都接收确切的实时调用方
Agent作为凭证。 只有 Lead 能 spawn、reassign、interrupt。没有「当前用户」这种隐含的全局状态。
04TeamService:十二个方法
团队领域服务注册在 ctx.agentTeams 上,类型是 TeamService,
由精确的实时 Lead Session 日志支撑。它的方法签名有一个共同特征:
第一个参数永远是调用方自己——身份即凭证。
// 权限与查询 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/view、agentTeams/createTask、agentTeams/updateTask。
这里有个设计细节值得学:传输失败与领域拒绝被分开表达——
- 传输失败保留在外层
RemoteResult里; - create / update 的拒绝(比如任务名冲突、revision 过期)则是传输成功响应中的显式领域结果, 其中过期的 update revision 会被区分为 task conflict。
这避免了「把业务错误当网络错误重试」这类经典事故——客户端能明确知道「服务器收到了,是我的请求有问题」, 而不是「我不知道有没有成功」。
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 |
认领、完成、释放、重开、指派 | 任何成员 |
三条工具实现原则
适配器本身很薄,但它的三条原则决定了工具的可预测性:
- 按作用域,而非全局——每个注册都位于成员 Agent 自己的
ctx上, 安装依据「Agent 发布时是否已是成员」进行。maybeInstall订阅agent/created, 跳过没有 Team 成员关系的 Agent。 - 声明式结果,紧凑 JSON——每个工具都声明完整结果 schema,
把值渲染为紧凑 JSON。编译器会对照「向模型承诺的结果」检查
execute实现, 同时保证没有任何结果把 token 花在缩进上。 - 领域拥有裁决权——工具只是委托给
ctx.agentTeams, 由后者强制执行 Lead 权限与 revision 校验。适配器不添加更弱的旁路。
团队功能需要持久会话存储才能激活。对现有组合的最小增量是三个条目:
# 最小团队配置 — 持久存储 + 两个 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 # 默认值
所有限制都在启动时校验
| 字段 | 默认值 | 含义 |
|---|---|---|
maxMembers | 16 | 一支团队最多可创建的 teammate 数,包括失败的 |
maxTasks | 256 | 任务板上最多的活动任务数(tombstone 不占额) |
maxPendingMessagesPerMember | 64 | 单个成员最多可排队的消息数 |
maxMessageBytes | 65,536 | 单条发送消息的最大尺寸 |
disposalTimeoutMs | 5,000 | 关闭清理允许的时间 |
注意「超出上限时明确失败」这条——exhausted 时报告类型化错误,
而不是复用 id 或名字。maxMembers 把失败的成员也算进去,正是因为名字永不复用:
一个创建失败的 reviewer 已经永久占用了这个名字和这个名额。
spawn 注入的身份前缀
创建 teammate 时,spawn_teammate 会在初始 user 消息前加上一段身份说明——
这段前缀不含 Team id,因此禁用运行时上下文时也能生效:
<system-reminder>
You are teammate "<name>".
</system-reminder>
<初始任务文本>
而 fork 模式不同:它继承历史,所以不会额外添加 Lead 身份消息—— 分叉出来的成员知道 Lead 做过什么,但不需要被反复告知「你是谁」。
06持久 mailbox 与投递语义
消息是团队的血液,也是最容易出错的地方。dsh 在这里的设计可以概括成一句话: 先存,再投;存了就不重发。
写入顺序
sendMessage()先校验 peer 成员关系;- 追加
team/message/queued事件,并且在尝试投递之前 flush; - 然后才尝试即时投递;
- 只有当目标会话持久持有该消息身份后,才以
team/message/delivered确认。
「queued − delivered」的差集就构成了恢复 mailbox。 重启后,恢复流程按同一顺序重新投递这些记录。
三类目标的投递策略
每条消息都会尝试用 Steer 投递。具体走哪条路取决于目标当前的 Activation 状态—— 但关键在于:调用方不能选择模式,因此持久记录里不存储调度方式(那是可推导的)。
| 目标状态 | 投递行为 | 模型何时看到 |
|---|---|---|
running |
在同一 Activation 中 steer 最近的 step | 下一个步骤边界——不用等整轮结束 |
waiting |
唤醒并 steer 同一 Activation | 唤醒后立即进入处理 |
| 无 Activation(inactive) | 冷恢复一个新的 Activation,再 steer | 重建 agent 后处理,历史前缀仍可复用 |
去重是怎么做的
「不丢失、不重复」听起来像分布式共识,但这里其实是个更朴素的保证: 进程内重试 + 目标会话去重。机制是两个身份标记的双写:
- 发送方在消息内容开头写入稳定身份:
Team message <id> from <name>:; 同时在TeamMessageSource里保留同一 id 与发送者; - 目标会话在 pending inbox 条目与已记录的用户消息上都保留这份归因; 跨这两处折叠该 source,就得到目标侧的去重键。
/** 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 的路径不同
这个细节体现了权限模型的一致性:
- 投递给 Lead:直接调用
Agent.steer()。 - 投递给 teammate:走 continuation owner 的 host-only Steer 路径—— 这条路径会保留 Team 发送者 source,同时授权 Lead→child 边、并在需要时冷恢复 inactive 目标。
Sibling 消息绝不会通过公开的「相邻 Agent 消息」操作伪装成 Lead。
——这就是为什么要单独有一条 host-only 路径
模型实际看到什么
每条已投递的 peer 消息对目标而言都是「用户角色」消息——因为从目标 agent 的视角看, 外部输入确实就是用户输入。第一个短文本块包含稳定 message id 与发送者,之后原样附加发送者的内容块。
roster、task、mailbox 记录只存在于日志,绝不进入派生模型历史,因此任务与 roster 变更不增加模型 token。 Peer 消息追加在目标可复用历史前缀之后;冷恢复会先复用持久对话,再追加尚未投递的消息。
07共享任务 DAG 与 CAS
任务板上最容易出的事故是「两个人同时改同一个任务,后者悄悄覆盖前者」。
dsh 的解法是每次变更都是 compare-and-set,携带 expectedRevision。
基于过期副本的更新会被拒绝(TEAM_TASK_STALE_REVISION),而不是覆盖更新的成果。
/** 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 稳定 |
依赖图的两条硬约束
blockedBy的边必须指向未删除的任务;- 必须维持无环图。
校验发生在 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-acp | ACP 协议 | 传输启动前拒绝 agentOptions |
subagent-codex | Codex CLI | 同上 |
subagent-claude-code | Claude Code CLI | 同上 |
subagent-dsh-sdk | 独立子运行时 | 把四个 Agent 路由字段合并到实例默认值之上 |
两种能力发现方式
这是子系统里一个相当讲究的设计——能力声明被分成了两套机制:
- 启动时能力:提供方通过静态描述符
SubagentCapabilities公布, 服务会在单次 run 存在之前就检查。 - 可继续能力:由唯一一个可选方法
prepareContinuable把关—— 方法存在即为能力,用 TypeScript 的类型收窄作为发现机制。
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 必须是后台常驻、可反复交互的会话, 而不是一次性调用。模型是这样的:
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,也不会创建承载中间结果的包装层」。
可继续对话的 Subagent 链默认最多同时保留 8 个子代理、委派深度为 1, 均可在设置中调整。这意味着默认情况下 teammate 不会再往下开团队——深度 1 是有意保守的默认。
09一次协作的完整时序
把前面所有机制串起来,一次典型的「Lead 派活 → teammate 执行 → 汇报」是这样的。 注意每个参与者读写的是同一个日志,这也是所有状态能保持一致的原因。
模型侧的策略文本
除了工具,成员还会收到一段共享的 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 并发」——那是容易的部分。 难的是让协作状态可回放:
- 日志是唯一真源,roster / mailbox / 任务板都是它的纯函数,所以恢复不需要「修复」状态;
- 身份即凭证,每个方法都要求确切的实时 Agent,没有隐含的全局「当前用户」;
- 先存再投、存了不重发,把「消息不丢」变成一个可以推理的朴素保证;
- CAS + 不变式伴生插件,让并发修改与非法状态都在写入前被拦下;
- 超出上限时明确失败,而不是复用 id 或静默降级。
这些原则都不依赖具体实现,把它们搬到任何多 agent 系统上都成立。 这大概也是为什么 dsh 把 Agent Teams 放在「一切皆插件」的架构上—— 它本来就是一棵插件树里的一个可替换节点,做得不对,换掉就好。
本文基于 dsh-v0.1.6-alpha.2(2026-09-17)的官方仓库文档与源码整理。
Agent Teams 是实验性特性,不承诺稳定性——API 与 schema 在后续版本中可能变化,
落地前请以你使用版本的官方文档为准。
一切皆插件:Cordis 与配置树叠加
profile、bundle、patch 三层如何叠出一棵可替换的插件树——为什么 dsh 没有「特权内核」。