当单个 Agent 的能力不足以覆盖复杂任务时,有两条路:把更多能力塞进同一个 Agent(更长的 prompt、更多的工具),或者让多个 Agent 协作。我们的内部 Agent 平台选了后者,并且选了一种不那么主流的协作形态——消息传递,而不是管道。
这篇文章完整讲述这套设计:为什么是消息总线、Redis 上的通信机制、fire-and-forget 语义、子 Agent 的懒加载与 idle-loop 唤醒,以及"最多两层协作"这个有意为之的硬限制。
技术栈:Java 21 + Spring Boot(WebFlux)+ Spring AI + Redis(消息队列 + Pub/Sub)。
一、问题:平层 Agent 的天花板
早期的平台里,Agent 是平层独立运行的——每个 Agent 各自启动推理循环,Agent 之间没有协作关系。当一个复杂任务需要多种专业能力时,只能把它们全塞进同一个 Agent 的 system prompt 和工具列表,导致:
- Prompt 过长,LLM 在工具选择时准确率下降;
- 角色定位与方法论混杂,难以复用;
- 单 Agent 无法利用并行性——同一个 LLM 一次只能做一件事。
Agent 协作引入主 Agent + 子 Agent模式:子 Agent 是独立运行的推理线程,有自己的收件箱、自己的工具集、自己的 LLM 循环。Agent 之间通过 MessageBus(消息总线) 收发消息通信。
二、为什么是消息传递,不是管道
2.1 工具调用 vs Agent 协作
先明确协作与调用的边界。在能力模型里,Agent 有四种可装配的能力单元:
知识库 (KB) 工具 (Tool) 技能 (Skill) 子 Agent
────────── ────────── ────────── ───────────
参考什么? 能做什么? 怎么做? 交给谁做?
语义检索 → 上下文 function call → 执行 提示词注入 → 指导行为 独立推理 → 发消息回报
其中工具调用和 Agent 协作的差异是根本性的:
| 维度 | 工具调用 | Agent 协作(MessageBus) |
|---|---|---|
| 语义 | "我用一下这个功能" | "这件事交给你来办,有结果发消息给我" |
| 执行者 | 无状态函数 / 外部 API | 另一个 Agent 的完整推理循环(独立线程) |
| 能力 | 单一功能 | LLM + 自己的工具 + 自己的知识库 + 自己的技能 |
| 通信方式 | 入参 → 出参(同步) | 发消息到 inbox → 对方检查 inbox → 回消息到 inbox(异步) |
| 状态 | 无状态,调用即忘 | 子会话独立记忆,可跨多轮持续对话 |
| 反向沟通 | 不能 | 能(子 Agent 可以发消息追问、澄清、汇报进度) |
| 并行性 | 单轮内可并行调用多个工具 | 多个子 Agent 并行工作,各自独立循环 |
| 生命周期 | 单次调用即销毁 | 首次收到消息时懒加载 → idle loop → 完成任务 → 退出 |
管道(pipeline)模型本质上是把子 Agent 降级成"一个特殊的工具"——同步调用、一次性出参、不能反问。消息传递模型保留了子 Agent 作为对等 Agent 实体的完整能力。
2.2 与 XML 内联委托方案的对比
我们的第一版方案不是消息总线,而是让主 Agent 在输出流中内嵌 <delegate> 标记发起委托,引擎拦截后启动子 Agent。这个方案有本质局限:
| v1: XML 内联委托 | v2: MessageBus | |
|---|---|---|
| 发起委托 | LLM 必须输出特定格式的 XML | 调用 send_message 工具(普通 Function Call) |
| 子 Agent 反问 | 不支持(一次性委托) | 支持(可以发 clarify 消息追问) |
| 多轮迭代 | 不支持(一次委托 = 一次完成) | 支持(可以来回多轮修改) |
| 进度汇报 | 只汇报最终结果 | 可以发 progress 消息(持续通知) |
| 并行模型 | 引擎层并行启动,等待全部完成 | Agent 自己决定何时发、何时查 inbox |
| 解析鲁棒性 | LLM 输出格式不规范 → 解析失败 | Function Calling 参数严格校验 |
| 主 Agent 是否阻塞 | 阻塞(等所有子 Agent 完成) | 不阻塞(发完消息继续推理) |
MessageBus 的核心思想很简单:Agent 之间的通信是消息,不是函数调用。
2.3 核心原则
- Agent 就是 Agent。子 Agent 不是工具的变体,它是对等的 Agent 实体。主 Agent 与子 Agent 之间是协作关系,不是调用关系。
- 只有"主 Agent"是特殊角色。任意普通 Agent 都可以被配置为子 Agent——即使它自己也配了
subAgents(即它本身也是一个主 Agent)。被加载为子 Agent 时,它的团队配置不生效,单兵作战。 - Agent Team,不是 Agent Company。子 Agent 不能再触发自己的子 Agent——最多 2 层协作(详见第七节)。
- 消息驱动,异步协作。Agent 之间不阻塞等待,发完消息继续自己的工作,有空时检查收件箱。
三、MessageBus 设计
3.1 全景图
┌──────────────────────────────────────────────────────────────────────┐
│ MessageBus (Redis) │
│ │
│ mailbox:{sessionId}:{leadId} mailbox:{sessionId}:alice│
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ {msgId:"msg_001", │ │ {msgId:"msg_001", │ │
│ │ from:"alice", │ │ from:"lead", │ │
│ │ type:"result", │ │ type:"task", │ │
│ │ content:"分析完成"} │ │ content:"分析Q2数据"}│ │
│ │ {msgId:"msg_002", │ │ {msgId:"msg_002", │ │
│ │ from:"bob", │ │ from:"lead", │ │
│ │ type:"progress", │ │ type:"clarify_resp",│ │
│ │ content:"搜索80%"} │ │ content:"竞品A/B/C"}│ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ Pub/Sub channel: agent:notify:{sessionId} │
│ ─ 用于唤醒 idle 状态的子 Agent │
└──────────────────────────────────────────────────────────────────────┘
3.2 消息格式
每条消息是一个 JSON 对象:
{
"msgId": "msg_a1b2c3d4e5f6",
"inReplyTo": "msg_001122334455",
"from": "agent_coordinator",
"to": "agent_data_analyst",
"type": "task",
"content": "分析 Q2 行业数据,输出 3 个关键趋势",
"context": "竞品列表: Alpha Corp, Beta Ltd\n数据范围: 2026 Q2",
"ts": "2026-07-07T10:30:00Z"
}
| 字段 | 生成方 | 必填 | 说明 |
|---|---|---|---|
msgId |
MessageBus | ✓ | 全局唯一 ID,send() 自动生成,格式 msg_{12位随机hex} |
inReplyTo |
调用方 | 引用此前消息的 msgId,形成回复链 |
|
from / to |
调用方 | ✓ | 发送方 / 接收方 instanceId |
type |
调用方 | ✓ | 消息类型(见下表) |
content |
调用方 | ✓ | 消息正文 |
context |
调用方 | 附加上下文 | |
ts |
MessageBus | ✓ | 发送时间戳 |
消息类型:
| type | 方向 | 含义 | 典型 inReplyTo 指向 |
|---|---|---|---|
task |
主 → 子 | 分配任务 | (无,新任务) |
clarify |
子 → 主 | 请求澄清 | 发起这个疑问的 task 或 revision |
clarify_resp |
主 → 子 | 回复澄清 | 对应的 clarify |
progress |
子 → 主 | 汇报进度 | 对应的 task |
result |
子 → 主 | 交付结果 | 对应的 task |
revision |
主 → 子 | 要求修改 | 对应的 result |
shutdown |
主 → 子 | 终止任务 | (无) |
3.3 接口与 Redis 实现
接口只有两个方法:
/**
* Agent 之间的消息总线。底层基于 Redis。
*
* 每个 Agent 在当前会话中有一个收件箱(Redis List)。
* send = 生成 msgId → LPUSH 到目标收件箱 + PUBLISH 唤醒通知。
* readInbox = LRANGE 读取全部 + DEL 删除(消费式),返回时按 msgId 去重。
*/
public interface MessageBus {
/** 发送一条消息到目标 Agent 的收件箱,同时通过 Pub/Sub 发送唤醒通知。 */
String send(String sessionId, String fromAgentId, String toAgentId,
String type, String content, String context,
String inReplyTo);
/** 消费式读取收件箱:返回所有未读消息,然后清空。原子操作(Lua 脚本)。 */
List<AgentMessage> readInbox(String sessionId, String agentId);
}
/** 一条 Agent 消息 */
public record AgentMessage(
String msgId,
String inReplyTo, // 引用此前消息的 msgId,可为 null
String from,
String to,
String type,
String content,
String context,
Instant ts
) {}
Redis 存储与操作设计:
Key 设计:
mailbox:{sessionId}:{agentId} — Redis List, 消息队列
agent:notify:{sessionId} — Redis Pub/Sub channel, 唤醒通知
操作:
send:
LPUSH mailbox:{sessionId}:{toAgentId} <json_message>
PUBLISH agent:notify:{sessionId} {toAgentId} ← 告诉目标 "你有新消息"
readInbox (Lua 脚本保证原子性):
local msgs = redis.call('LRANGE', KEYS[1], 0, -1)
if #msgs > 0 then redis.call('DEL', KEYS[1]) end
return msgs
TTL:
会话结束后,mailbox key 设置 EXPIRE(如 1 小时),自动清理。
参考实现:
@Component
public class RedisMessageBus implements MessageBus {
private final StringRedisTemplate redis;
private final ObjectMapper json;
@Override
public String send(String sessionId, String fromAgentId, String toAgentId,
String type, String content, String context,
String inReplyTo) {
String msgId = "msg_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
var msg = Map.of(
"msgId", msgId,
"inReplyTo", inReplyTo != null ? inReplyTo : "",
"from", fromAgentId,
"to", toAgentId,
"type", type,
"content", content,
"context", context != null ? context : "",
"ts", Instant.now().toString()
);
String key = mailboxKey(sessionId, toAgentId);
redis.opsForList().leftPush(key, json.writeValueAsString(msg));
redis.expire(key, Duration.ofHours(1)); // TTL 兜底
redis.convertAndSend(notifyChannel(sessionId), toAgentId);
return msgId;
}
@Override
public List<AgentMessage> readInbox(String sessionId, String agentId) {
// Lua 脚本: LRANGE + DEL(原子消费)
// 去重: 同一个 msgId 只保留第一次出现(Pub/Sub at-least-once 可能重复投递)
String script = """
local msgs = redis.call('LRANGE', KEYS[1], 0, -1)
if #msgs > 0 then redis.call('DEL', KEYS[1]) end
return msgs
""";
var key = mailboxKey(sessionId, agentId);
List<String> raw = redis.execute(
new DefaultRedisScript<>(script, List.class),
List.of(key));
var seen = new HashSet<String>();
return raw.stream()
.map(s -> json.readValue(s, AgentMessage.class))
.filter(m -> seen.add(m.msgId())) // ★ msgId 去重
.toList();
}
}
几个工程细节:
- 先 LPUSH 后 PUBLISH:消息一定先于通知到达 Redis,不存在"收到通知但 inbox 为空"的竞态。
- Lua 原子消费:
LRANGE+DEL在一个原子操作里完成,避免两个读者重复消费或消费中途丢消息。 - msgId 去重:Pub/Sub 是 at-least-once,消费端按 msgId 去重兜底。
- TTL 兜底:mailbox key 一小时过期,会话异常退出也不会堆积垃圾。
3.4 send_message 工具
通信能力通过 send_message 工具暴露给 Agent——主 Agent 和子 Agent 在各自的工具集中自动拥有它,对 LLM 来说就是一次普通的 Function Call:
{
"type": "object",
"properties": {
"to": { "type": "string", "description": "目标 Agent 的 instanceId;子 Agent 发给主 Agent 用 to=\"lead\"" },
"type": { "type": "string", "enum": ["task","clarify","clarify_resp","progress","result","revision","shutdown"] },
"content": { "type": "string", "description": "消息正文" },
"context": { "type": "string", "description": "附加上下文(可选)" },
"inReplyTo": { "type": "string", "description": "引用此前消息的 msgId,响应场景应填写" }
},
"required": ["to", "type", "content"]
}
权限控制在引擎层执行:
- 主 Agent 可以发给它
subAgents列表中的任意 Agent; - 子 Agent 可以发给主 Agent(
to="lead"); - 子 Agent 可以发给同一会话中的其他子 Agent(直接协调,不需主 Agent 中转);
- 跨会话通信禁止;目标必须处于 ACTIVE 状态。
四、执行模型
4.1 生命周期总览
会话启动(用户发送消息到主 Agent)
│
├─ 引擎初始化主 Agent 的 ChatClient + Memory + Inbox
├─ MessageListener 启动,订阅 agent:notify:{sessionId}
│ (监听器本身不做任何 spawn——它只等待消息事件)
├─ 主 Agent 开始第一轮推理
│ (此时没有任何子 Agent 被加载——它们按需启动)
▼
主 Agent 推理循环:
① 检查 inbox(消费式读取),有消息则注入本轮 LLM 上下文
② LLM 推理(流式输出)
· 可能输出文本
· 可能调用普通工具(搜索、时间、计算……)
· 可能调用 send_message → 消息入 MessageBus
③ 若调用了 send_message
→ LPUSH + PUBLISH
→ MessageListener 收到通知
→ 目标 Agent 未加载? → 懒加载
→ 目标 Agent 已加载? → Pub/Sub 唤醒其 idle loop
④ 主 Agent 不等待,继续下一轮
▼
会话结束 → 清理: shutdown 所有已加载子 Agent → 清理 Redis keys
(从未被消息触达的子 Agent 无需清理——它们从未加载)
4.2 懒加载:MessageListener 统一入口
主 Agent 不负责"spawn"子 Agent。它只管调用 send_message 发消息,不管对方是否已经加载。MessageListener 是统一入口——监听 MessageBus 通知,按需加载:
主 Agent 调用 send_message(to="alice", ...)
│
▼
MessageBus.send()
├─ LPUSH mailbox:{sessionId}:alice <json>
└─ PUBLISH agent:notify:{sessionId} "alice"
│
▼
MessageListener 收到通知: "alice"
│
├─ 已加载 → Pub/Sub 自动唤醒其 idle loop
└─ 未加载 → 执行加载:
① 从 DB 加载配置,校验存在、ACTIVE、空间匹配
(找不到 → 错误消息注入主 Agent inbox)
② 创建子会话(parent_session_id 指向主会话)
③ 构建 system prompt:原 prompt + 协作模式说明
(不注入它自己的 subAgents 列表——引擎层忽略)
④ 初始化独立记忆(与主 Agent 完全隔离)
⑤ 注册工具集:activatedTools + send_message
⑥ 注册收件箱(消息已在 LPUSH 时入队,加载后立即可读)
⑦ 启动虚拟线程,进入 idle loop
⑧ 推送 SSE 事件(sub-agent loaded)
与"会话启动时预 spawn"的旧模型对比:
| 预 spawn | 懒加载 | |
|---|---|---|
| 发起方 | 引擎在会话启动时主动创建 | MessageListener 收到第一条消息时被动触发 |
| 未使用的 Agent | spawn 了但 idle 到超时,浪费线程 | 从未加载,零开销 |
| 主 Agent 的感知 | 需要知道"团队已就位" | 不需要知道——发消息就行 |
| 加载失败处理 | 会话启动阶段就报错 | 消息投递失败时,错误注入主 Agent inbox |
4.3 子 Agent 的 Idle Loop
子 Agent 启动后进入 idle loop——没有任务时不动,收到消息才运行:
void runSubAgent(String sessionId, AgentInstance subAgent,
AgentConfig config, String leadAgentId,
MessageBus bus, SessionListener listener) {
var memory = new SessionMemory(); // 独立记忆
int step = 0;
boolean shouldShutdown = false;
while (step < config.maxSteps() && !shouldShutdown) {
// ① 检查 inbox
List<AgentMessage> inbox = bus.readInbox(sessionId, subAgent.getInstanceId());
if (inbox.isEmpty()) {
// 空闲:订阅 Pub/Sub,阻塞等待唤醒(带 60s 超时,防止僵尸线程)
inbox = waitForMessage(sessionId, subAgent.getInstanceId(), 60_000L);
if (inbox.isEmpty()) break; // 超时退出
}
// ② 将 inbox 消息注入 LLM 上下文
String inboxContext = formatInboxForLLM(inbox);
memory.addMessage(new Message("user", inboxContext, now()));
// ③ 运行一轮 LLM 推理(同步、带超时)
try {
String output = runSingleRound(config, memory, subAgent);
// ④ 检查是否收到 shutdown
shouldShutdown = inbox.stream().anyMatch(m -> "shutdown".equals(m.type()));
// ⑤ 检查是否自然结束(没有工具调用、没有 send_message)
if (toolCallsThisRound == 0) {
bus.send(sessionId, subAgent.getInstanceId(), leadAgentId,
"result", buildResultSummary(memory), null, null);
listener.onSubAgentExit(subAgent.getInstanceId());
break;
}
step++;
} catch (Exception e) {
bus.send(sessionId, subAgent.getInstanceId(), leadAgentId,
"result", "[错误] " + e.getMessage(), null, null);
listener.onSubAgentExit(subAgent.getInstanceId());
break;
}
}
}
Idle loop 的状态机:
| 状态 | 行为 |
|---|---|
| Idle | 订阅 Pub/Sub,阻塞等待。收到通知后重新检查 inbox |
| Work | inbox 有消息 → 注入 LLM → 推理 → 可能发消息 → 回到检查 inbox |
| Complete | 任务完成 → 发 result 消息给主 Agent → 退出循环 |
| Timeout | 60s 无新消息 → 自动退出(防止僵尸线程) |
注意异常路径:子 Agent 出错不是悄悄死掉,而是把错误作为 result 消息发回主 Agent——主 Agent 的 LLM 能看到失败并决定重试、换人或向用户报告。错误也是消息。
4.4 fire-and-forget 与唤醒时序
主 Agent 子 Agent "alice"
│ │
│ send_message(to="alice", ...) │ [idle, 阻塞在 Pub/Sub]
│ → MessageBus.send() │
│ → LPUSH + PUBLISH ────────────────────────────→ 收到通知,解除阻塞
│ │
│ 主 Agent 继续推理 │ readInbox() → 有新消息!
│ (不等待 alice) │ 注入 LLM 上下文 → 开始推理
│ │
│ ... alice 工作中 ... │ ...
│ │
│ 检查 inbox → 空 │ LLM 调用 send_message(to="lead", ...)
│ 继续推理 │ → LPUSH + PUBLISH ──→ 主 Agent 下次
│ │ 检查 inbox 时可见
fire-and-forget 的语义要依靠 prompt 明确告知模型:"发完消息后继续你的推理,不要等待回复;子 Agent 的回复会出现在你的收件箱中,你会在下一轮看到。"没有这个提示,模型会倾向于在输出里"假装等待",甚至编造回复。
4.5 并发模型
会话 "sess_xxx" 的线程模型(懒加载):
Virtual Thread: main-agent-loop
│
├─ Virtual Thread: msg-listener-sess_xxx
│ (监听 Pub/Sub,按需触发 Agent 加载)
│
├─ Virtual Thread: sub-agent-alice-loop (send_message 后才加载)
│ ├─ 第 1 轮: 收到 task → 搜索知识库 → 分析
│ ├─ 第 2 轮: 追问 clarify
│ ├─ idle: 等待回复...
│ ├─ 第 3 轮: 收到 clarify_resp → 继续分析
│ └─ 第 4 轮: 完成 → send_message(result) → 退出
│
├─ Virtual Thread: sub-agent-bob-loop (send_message 后才加载)
│ ├─ 第 1 轮: 收到 task → 搜索网页
│ └─ 第 2 轮: 完成 → send_message(result) → 退出
│
└─ reporter 从未收到消息 → 从未加载(零线程开销)
全部跑在 Java 21 虚拟线程上,开销极低,一个会话数十个子 Agent 不成问题。阻塞等待(Pub/Sub subscribe、.block())在虚拟线程上几乎零成本。
五、一个典型场景
用户: "帮我做一份 Q2 竞品分析报告"
会话启动:
主 Agent "项目协调者" 启动 → MessageListener 就绪(子 Agent 尚未加载)
主 Agent(第 1 轮推理):
├─ LLM: "我需要先启动数据分析和竞品搜索"
├─ send_message(to="alice", type="task", content="分析 Q2 行业数据,输出 3 个关键趋势")
├─ send_message(to="bob", type="task", content="搜索竞品 A/B/C 的 Q2 最新动态")
└─ 主 Agent 继续下一轮(不阻塞等待)
子 Agent "alice" 被 Pub/Sub 唤醒:
├─ 第 1 轮: 检查 inbox → 收到 task → 调知识库搜索 → 获取数据
├─ 第 2 轮: 继续分析 → 发现需要更多上下文
├─ send_message(to="lead", type="clarify", content="请确认数据范围是 Q2 还是含 Q1?")
└─ 进入 idle,等待回复
子 Agent "bob" 并行工作:
├─ 第 1 轮: 检查 inbox → 收到 task → 调网页搜索 → 获取竞品信息
├─ 第 2 轮: 完成汇总
├─ send_message(to="lead", type="result", content="竞品动态汇总: ...")
└─ 完成任务,退出
主 Agent(第 2 轮推理):
├─ 检查 inbox: [alice → clarify] + [bob → result]
├─ send_message(to="alice", type="clarify_resp", content="数据范围是 Q2,不含 Q1")
└─ 继续下一轮
主 Agent(第 N 轮推理):
├─ 检查 inbox → alice 的 result 已收到
├─ LLM: "材料齐了,让我委托报告写手"
├─ send_message(to="reporter", type="task", content="基于以下分析结果撰写报告...")
└─ ...(可能还有 revision 多轮迭代)
最终: 主 Agent 输出整合后的报告给用户
关键观察:
- 主 Agent 不阻塞等待——发完消息继续自己的推理,下次检查 inbox 时再处理回复;
- 多个子 Agent 真正并行——alice 和 bob 同时工作,各自独立的 LLM 循环;
- 子 Agent 可以主动追问,不是只能被动执行;
- 支持多轮迭代(写手 → 主 Agent 审核 → 修改 → 再审);
- 子 Agent 空闲时等待,收到消息后被 Pub/Sub 唤醒——不浪费 LLM 调用。
六、SSE 事件协议与可观测性
协作过程对前端完全透明,通过 SSE 事件流实时推送:
| 事件 | 含义 | 何时发送 |
|---|---|---|
as |
Sub-agent loaded | MessageListener 首次加载子 Agent 时 |
am |
Agent message sent | 任意 Agent 调用了 send_message |
ar |
Agent result delivered | 子 Agent 发送了 type=result 的消息 |
ap |
Agent progress | 子 Agent 发送了 type=progress 的消息 |
ac |
Agent clarification | 子 Agent 发送了 type=clarify 的消息 |
ax |
Agent exited | 子 Agent 完成任务退出或超时退出 |
一段真实的 SSE 流(主 Agent 视角):
t: {"c":"我先启动数据收集和分析..."}
ts: {"toolName":"send_message","status":"started"}
am: {"msgId":"msg_001","from":"lead","to":"alice","type":"task","content":"分析Q2数据..."}
tr: {"toolName":"send_message","status":"completed","returned":"msg_001"}
t: {"c":"同时让 bob 搜索竞品动态..."}
am: {"msgId":"msg_002","from":"lead","to":"bob","type":"task","content":"搜索竞品..."}
t: {"c":"我先整理一下用户的需求..."}
-- bob 完成任务 --
ar: {"msgId":"msg_003","inReplyTo":"msg_002","from":"bob","type":"result","summary":"竞品动态汇总..."}
ax: {"agentId":"bob","reason":"completed"}
-- alice 发出追问 --
ac: {"msgId":"msg_004","inReplyTo":"msg_001","from":"alice","content":"请确认数据范围..."}
t: {"c":"alice 有问题需要澄清..."}
am: {"msgId":"msg_005","inReplyTo":"msg_004","from":"lead","to":"alice","type":"clarify_resp","content":"仅Q2..."}
-- alice 完成任务 --
ar: {"msgId":"msg_006","inReplyTo":"msg_001","from":"alice","type":"result","summary":"3个关键趋势..."}
ax: {"agentId":"alice","reason":"completed"}
通过 msgId → inReplyTo 链,前端可以精确还原每条消息线:
msg_001 (task)←msg_004 (clarify)←msg_005 (clarify_resp)msg_001 (task)←msg_006 (result)msg_002 (task)←msg_003 (result)
成本与追踪:主 Agent 的 LLM 调用计入主会话,每个子 Agent 的调用计入子会话(parent_session_id 指向主会话),可按主会话聚合单次用户交互的总成本,也可按子会话下钻分析每个子 Agent 的效率。
七、为什么最多两层:Agent Team,不是 Agent Company
子 Agent 不能再触发子 Agent——协作深度硬限制为 2 层。这是有意为之的产品决策,不是技术做不到(实现上只需在加载子 Agent 时忽略其 subAgents 配置)。
保留 2 层的理由:
两层覆盖绝大多数协作场景。LLM 拆解任务时自然产出"把大任务拆成几个专家并行做",而不是"先拆给中层,中层再拆给一线"。业界的成熟实践(如 Claude Code 的多 Agent 机制)也只做 2 层,这不是偶然。
成本可控。取消限制后,一个用户消息可能触发 N 叉树级联:主 Agent 触发 3 个子 Agent → 每个再触发 2 个孙 Agent → …… 用户以为只是一次提问,后台烧了 40 次 LLM 调用。
可观测性不崩塌。2 层模型下,团队状态一目了然。3 层以上的嵌套会让调试变成灾难——"这个孙子 Agent 是谁触发的?为什么它卡住了?"
什么时候可以放开?当且仅当:真实使用中出现"两层不够"的明确场景(用户反馈,不是预判);配套管控就绪(全局最大深度、单次对话总调用次数上限、成本预估提示);前端有层级可视化方案(树状图而非嵌套卡片)。在那之前,Agent Team 优先于 Agent Company。
八、限制与约束汇总
| 约束 | 值 | 原因 |
|---|---|---|
| 每个主 Agent 最多 N 个子 Agent | 配置列表最多 8 个 | 防止会话线程爆炸 |
| 子 Agent 最大推理轮数 | 由自己的 maxSteps 控制(默认 8) |
防止无限循环 |
| 子 Agent 空闲超时 | 无新消息 60s → 自动退出 | 已完成任务的 Agent 及时回收 |
| 子 Agent 单轮推理超时 | 30s | 防止 LLM hang 住 |
| MessageListener 线程 | 1 个/会话(共享) | Pub/Sub 订阅,开销极低 |
| 子 Agent 不能再触发子 Agent | 硬限制:忽略其 subAgents 字段 |
保持 Team 模型(2 层),防嵌套爆炸 |
| inbox 消息堆积上限 | 50 条/session/agent | 防止内存泄漏(超过时 trim 最老的) |
另外值得注意的是"idle 超时后再被呼叫"的行为:子 Agent 60s 无消息自动退出后,如果主 Agent 又发消息给它,MessageListener 会自动重新懒加载——这对主 Agent 完全透明,它不需要知道子 Agent 是否在线。消息传递模型的优雅之处正在于此:寻址只关心"会话内的名字",不关心"线程是否存活"。
九、结语
多 Agent 协作的核心设计选择不是"用什么框架",而是"Agent 之间是什么关系"。管道模型把子 Agent 当成可调用的函数,消息传递模型把它当成可对话的同事。前者简单但剥夺了反问、迭代、进度汇报的能力;后者多了一套消息总线和一个 idle loop,换来的是对等的协作语义和真正的并行。
而"最多两层"提醒我们:多 Agent 系统最大的风险不是做不到,而是做太多——嵌套越深,成本和调试复杂度越是指数级上升。克制本身就是一种架构能力。