当单个 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 核心原则

  1. Agent 就是 Agent。子 Agent 不是工具的变体,它是对等的 Agent 实体。主 Agent 与子 Agent 之间是协作关系,不是调用关系。
  2. 只有"主 Agent"是特殊角色。任意普通 Agent 都可以被配置为子 Agent——即使它自己也配了 subAgents(即它本身也是一个主 Agent)。被加载为子 Agent 时,它的团队配置不生效,单兵作战。
  3. Agent Team,不是 Agent Company。子 Agent 不能再触发自己的子 Agent——最多 2 层协作(详见第七节)。
  4. 消息驱动,异步协作。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 子 → 主 请求澄清 发起这个疑问的 taskrevision
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"}

通过 msgIdinReplyTo 链,前端可以精确还原每条消息线:

  • 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 层的理由:

  1. 两层覆盖绝大多数协作场景。LLM 拆解任务时自然产出"把大任务拆成几个专家并行做",而不是"先拆给中层,中层再拆给一线"。业界的成熟实践(如 Claude Code 的多 Agent 机制)也只做 2 层,这不是偶然。

  2. 成本可控。取消限制后,一个用户消息可能触发 N 叉树级联:主 Agent 触发 3 个子 Agent → 每个再触发 2 个孙 Agent → …… 用户以为只是一次提问,后台烧了 40 次 LLM 调用。

  3. 可观测性不崩塌。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 系统最大的风险不是做不到,而是做太多——嵌套越深,成本和调试复杂度越是指数级上升。克制本身就是一种架构能力。