拆解 OpenClaw - 03:子 Agent 与任务编排

5 阅读4分钟

前两篇覆盖了单个 Agent 从接收消息到回复的完整链路,以及记忆系统的三层架构。这一篇聚焦多 Agent 协作:主 Agent 如何拆分任务、子 Agent 如何独立执行、结果如何汇总回来。


我:主 Agent 是怎么创建子 Agent 的?

严格来说,主 Agent 不能"创建"任何东西。它只能返回一段 JSON 表达意图:

{
  "type": "tool_use",
  "name": "sessions_spawn",
  "input": {
    "task": "把这个 Python 脚本转成 TypeScript",
    "model": "sonnet",
    "label": "refactor-job"
  }
}

真正创建子 Agent 的是 Gateway。它解析这段 JSON,生成新的 session key,入队到 subagent 队列,调用和主 Agent 完全相同的 runEmbeddedPiAgent() 函数。子 Agent 的 session 是全新的——没有主 Agent 的对话历史,只有 task 描述作为第一条消息。系统提示也是精简版,省掉了人格、用户档案、心跳规则这些跟任务无关的内容。

主 Agent 和子 Agent 的差异:

                    主 Agent                 子 Agent
System Prompt      完整版(full)            精简版(minimal)
注入的文件          全部 6 个 .md            只有 AGENTS.md + TOOLS.md
对话历史            完整的频道历史            只有 task 描述
模型               可以不同                  可以指定不同模型
结果去向            发给用户                  推送回主 Agent 会话
队列 lane           main(并发 4)           subagent(并发 8

这个设计揭示了一个容易误解的事实:模型没有执行能力。所有工具——读文件、跑命令、发消息、创建子 Agent——都是模型返回 JSON,Gateway 执行。模型是大脑,Gateway 是身体。

我:主 Agent 和子 Agent 之间怎么通信?

三种通道,都经过 Gateway 中转:

sequenceDiagram
    actor User as 用户
    participant MA as 主 Agent
    participant GW as Gateway
    participant SA as 子 Agent

    User->>MA: "帮我做三件事"
    MA->>GW: tool_use: sessions_spawn

    Note over GW: 创建新 session<br/>入队 subagent lane

    GW->>SA: task 描述(唯一初始输入)
    MA-->>User: "已经在后台处理了"

    Note over User,MA: 用户可以继续跟主 Agent 聊别的

    Note over SA: ReAct 循环执行中...

    opt 中途干预(可选)
        MA->>GW: subagents steer
        GW->>SA: 注入新消息
        Note over SA: 下一个工具边界处读到<br/>调整方向
    end

    SA->>GW: end_turn(任务完成)

    Note over GW: 提取子 Agent 输出<br/>构造系统消息

    GW->>MA: [System Message] 子任务完成:结果...

    Note over MA: 被动唤醒<br/>新一轮 ReAct 循环

    MA-->>User: "任务完成了,结果是..."

启动时,主 Agent 通过 sessions_spawn 传递 task 描述,这是子 Agent 收到的唯一初始输入。运行中,主 Agent 可以通过 subagents steer 注入新指令,子 Agent 在下一个工具边界处读到它。完成时,子 Agent 的结果由 Gateway 自动以系统消息注入回主 Agent 的会话。

这套通信是单向推送的。子 Agent 不能主动联系主 Agent,也不能和其他子 Agent 通信。如果任务之间有依赖关系,不适合并行 spawn,应该在主 Agent 里串行处理。

我:系统消息是什么?为什么子 Agent 的结果要包装成系统消息?

系统消息是 OpenClaw 内部的通信机制。LLM API 只有三种 role:system(系统提示)、user(用户)、assistant(模型)。Gateway 没有其他方式把消息塞进对话,只能伪装成 user message,但用 [System Message] 前缀标识。

{
  "role": "user",
  "content": "[System Message] Sub-agent \"refactor-job\" completed:\nTypeScript 转换完成,改了 3 个文件..."
}

模型的系统提示里有明确规则:系统消息是内部上下文,用户在聊天界面看不到;如果系统消息报告了任务完成,应该用自己的话转述给用户,不要原样转发。

产生系统消息的场景不只是子 Agent 完成。Cron 定时任务触发、Discord reaction 通知、心跳轮询、用户在处理过程中发新消息(steering)——这些都会以系统消息的形式注入会话。它本质上是 Gateway 和模型之间的"内部通信频道",借用了 user message 的壳。

我:主 Agent end_turn 后 Gateway 把消息发给用户,子 Agent end_turn 后 Gateway 把消息发给主 Agent——是这个逻辑吗?

完全正确。而且子 Agent 的结果注入主 Agent 会话后,会自动触发主 Agent 的一次新模型调用——不是轮询,是被动唤醒。系统消息进来就像用户发了条新消息一样,直接启动一轮新的 ReAct 循环。主 Agent 看到结果后决定要不要转述给用户。如果不需要打扰用户,它也可以回复 NO_REPLY 保持沉默。

graph LR
    subgraph "主 Agent"
        MA_END["end_turn"]
    end
    subgraph "子 Agent"
        SA_END["end_turn"]
    end

    MA_END -->|"Gateway 路由"| USER["用户(Discord/Telegram/...)"]
    SA_END -->|"Gateway 包装为系统消息"| MA_SESSION["主 Agent 会话"]
    MA_SESSION -->|"触发新一轮 ReAct"| MA_END

我:NO_REPLY 一定是 Agent 自己决定的吗?

两种情况都有。模型可以主动回复 NO_REPLY 表示"没什么要说的"。Gateway 也会在发送前做过滤:

graph TD
    A["LLM 返回 end_turn"] --> B{"文本里有 NO_REPLY?"}
    B -->|"有"| C["过滤掉"]
    B -->|"没有"| D{"message 工具已经发过?"}
    D -->|"是"| E["去重,移除重复确认"]
    D -->|"否"| F{"过滤后还有内容?"}
    E --> F
    C --> F
    F -->|"有"| G["发到 Discord"]
    F -->|"没有"| H{"有工具错误?"}
    H -->|"有且没发过回复"| I["发 fallback 错误消息"]
    H -->|"否"| J["静默,不发送"]

模型的自觉是第一层,Gateway 的过滤是第二层,双重保障。

我:Gateway 怎么同时管理主 Agent 和多个子 Agent?

它们跑在同一个 Node.js 进程里,用的是同一套异步函数调用和事件流机制。没有网络通信,没有 RPC,没有消息队列中间件。Gateway 调用 runEmbeddedPiAgent() 后,通过事件订阅接收模型的流式输出、工具调用状态、生命周期事件。主 Agent 和子 Agent 的唯一区别是排队的 lane 不同。

graph TB
    GW["Gateway(单个 Node.js 进程)"]

    GW --> EL["Event Loop"]
    GW --> Q["Promise 队列"]
    GW --> FS["文件系统"]

    subgraph "运行时状态(进程内)"
        EL
        Q
    end

    subgraph "持久化状态(磁盘)"
        FS --> J["会话历史:JSONL"]
        FS --> C["配置:JSON"]
        FS --> MD["记忆:Markdown"]
        FS --> SQ["向量索引:SQLite"]
    end

整个 OpenClaw 系统没有数据库。会话历史是 JSONL 文件,配置是 JSON 文件,记忆是 Markdown 文件,向量索引是 SQLite。所有状态都在文件系统上。备份就是复制目录,迁移就是搬文件,调试就是打开文件看。

这是一个为个人助手场景高度简化的架构。代价是不支持分布式部署和多实例。但对于"一个人的 AI 助手"这个定位,它做到了足够的简洁和可靠。