拆解 OpenClaw - 01:一条消息是如何变成回复的

3 阅读10分钟

我一直好奇一件事:当我在 Discord 里 @ 一个 AI 机器人,到它回复我,中间到底发生了什么?不是那种"调用了 API"的一句话概括,而是每一步的细节。于是我直接问了跑在 OpenClaw 上的 AI 本身——毕竟没有谁比它更清楚自己是怎么工作的。

以下是一场关于 OpenClaw 架构的深度对话。


我:在聊具体流程之前,先帮我理清几个基础概念。OpenClaw 里的 Gateway、Channel、Agent、Session 分别是什么?

OpenClaw 是一个自部署的网关程序,把聊天平台和大模型连在一起。你在一台机器上跑一个 Gateway 进程,它就能同时对接 Discord、Telegram、iMessage 等平台,把消息转给 AI 处理,再把回复发回去。

理解 OpenClaw 需要抓住四个概念,从外到内:

Channel 是通信平台。Discord 是一个 channel,iMessage 是另一个 channel,Telegram 也是。你可以同时启用多个 channel,Gateway 统一管理。同一个 channel 下还可以有多个 Account——比如在 Discord 上跑两个不同的机器人账号。

Agent 是 AI 的一套配置。一个 agent 绑定一个模型(比如 Claude Opus)、一个工作区目录、一组行为规则。你可以定义多个 agent:一个用高端模型处理复杂任务,另一个用轻量模型做快速问答。不同的 agent 可以有不同的人格、不同的工具权限、不同的记忆空间。

Gateway 是核心进程。它负责所有的脏活:接收消息、路由到正确的 agent、调用大模型 API、执行工具、管理队列和并发、把回复发回聊天平台。Agent 本身没有执行能力——所有动作都是 Gateway 代为执行的。这一点很重要,后面会反复提到。

Session 是 Agent 和具体聊天位置的组合。同一个 agent 在 Discord 的 #general 频道是一个 session,在 #coding 频道是另一个 session。每个 session 有独立的对话历史、独立的上下文。同一时刻,同一个 session 只允许一个 agent run 在执行;不同 session 可以并行处理。

它们的层级关系:

Gateway(核心进程,只有一个)
├── Channel: Discord
   ├── Account: bot-1 ──bindng──→ Agent: flash(轻量模型)
      ├── #general 频道 → Session A(独立对话历史)
      └── #random 频道  → Session B(独立对话历史)
   └── Account: bot-2 ──binding──→ Agent: coding(高端模型)
       ├── #general 频道 → Session C
       └── #coding 频道  → Session D
├── Channel: iMessage
   └── 私聊 ──────────────────→ Agent: main
       └── 与某人的对话  Session E
└── Channel: Telegram
    └── ...

Binding 是把 Account 和 Agent 连起来的配置。当 Discord bot-1 收到消息,Gateway 查找 binding,发现 bot-1 对应 flash agent,就把消息交给 flash 处理。bot-2 对应 coding agent,走另一条路。

有了这些概念,后面的流程就好理解了。

我:明白了。那从我发消息开始,完整走一遍流程?

消息到达后经过三个阶段。第一阶段在 Gateway 层,Discord 插件解析消息,提取发送者、频道、是否 @ 了机器人、消息内容这些元数据。第二阶段是路由,Gateway 查找 binding 确定该由哪个 agent 处理,然后根据频道 ID 生成 session key,定位到具体的会话。第三阶段是组装请求,从磁盘加载这个 session 的对话历史,连同系统提示和你的新消息,打包成一个完整的 API 请求发给大模型。

整个过程的时序如下:

sequenceDiagram
    actor User as 用户
    participant DC as Discord
    participant GW as Gateway
    participant AR as Agent Runtime
    participant LLM as LLM (Claude)

    User->>DC: @bot 你好
    DC->>GW: WebSocket 事件

    Note over GW: 解析消息元数据<br/>匹配 binding → agent<br/>生成 session key<br/>入队(同 session 串行)

    GW->>AR: runEmbeddedPiAgent()

    Note over AR: 读取 session JSONL<br/>加载 bootstrap 文件<br/>组装 system prompt<br/>组装 messages 数组

    AR->>LLM: POST /v1/messages

    Note over LLM: 推理中...

    LLM-->>AR: stop_reason: tool_use

    Note over AR: 执行工具(exec/read/...)<br/>工具结果 → messages

    AR->>LLM: POST /v1/messages(携带工具结果)

    Note over LLM: 推理中...

    LLM-->>AR: stop_reason: end_turn

    Note over AR: 写入 session JSONL

    AR-->>GW: 回复文本
    GW-->>DC: 发送消息
    DC-->>User: 收到回复

中间那段 tool_use → 执行工具 → 再调模型的循环是 ReAct 的核心。这个循环可能重复多次——模型每调一次工具就多走一圈,直到 stop_reason 变成 end_turn 才结束。

我:等一下,所以每条消息都会调用一次大模型?即便我没有 @ 也没有回复?

不是。没有 @ 也没有回复的消息,在 Gateway 路由层就被过滤掉了。这个判断是纯代码逻辑,零 token 消耗。只有 @ 了或者回复了才会进入 agent loop 触发模型调用。不过,当你 @ 我的时候,Gateway 会把频道里最近大约 20 条聊天记录作为上下文一起发给模型——虽然那些消息没触发调用,但模型能看到"之前大家在聊什么"。

我:系统提示里都有什么?

系统提示是一个结构化的长文本,每次请求都带上。它分成几个固定板块:身份声明(告诉模型"你是谁"),工具使用说明(怎么调用工具、参数格式),安全护栏(行为红线),技能列表(只有名字和路径,不含具体内容),以及一组从工作区注入的 Markdown 文件。

这些 Markdown 文件是 OpenClaw 的设计亮点。IDENTITY.md 定义 AI 的名字和性格,USER.md 记录用户的称呼和偏好,SOUL.md 描述人格和语气,AGENTS.md 是操作手册。它们的内容会被完整塞进系统提示,每次请求都带——代价是消耗 token,所以需要保持精简。

我:发给大模型的请求里,哪些是上下文,哪些是可执行的动作?

一次 API 请求有三大块。来看完整的请求结构:

{
  // ─── 模型选择 ───
  "model": "claude-opus-4-6",
  "max_tokens": 8192,            // 单次回复的最大 token 数,超出则截断

  // ─── 上下文:第一块 ───
  // 系统提示,定义模型的身份、规则、注入的工作区文件
  // 每次请求基本不变,是 prompt cache 的主要受益者
  "system": "You are Claude Code... [身份] [工具说明] [安全护栏] [技能列表] [AGENTS.md 内容] [SOUL.md 内容] ...",

  // ─── 上下文:第二块 ───
  // 完整的对话历史,从第一轮对话到最新消息
  "messages": [
    // 历史对话
    { "role": "user", "content": "之前的提问" },
    { "role": "assistant", "content": "之前的回复" },

    // 工具调用的历史痕迹(也在 messages 里)
    { "role": "assistant", "content": [
      { "type": "text", "text": "让我查一下" },
      { "type": "tool_use", "id": "toolu_001", "name": "web_fetch",
        "input": { "url": "https://..." } }
    ]},
    { "role": "user", "content": [
      { "type": "tool_result", "tool_use_id": "toolu_001",
        "content": "网页返回的内容..." }
    ]},
    { "role": "assistant", "content": "根据查到的信息..." },

    // 最新的用户消息(包含群聊元数据和聊天历史)
    { "role": "user", "content": "你刚刚发的新消息" }
  ],

  // ─── 可选动作:工具菜单 ───
  // 模型可以选择调用,也可以不调用
  "tools": [
    {
      "name": "Read",                        // 工具名,大小写敏感
      "description": "Read file contents...", // 模型靠这段文字决定何时使用
      "input_schema": {                       // JSON Schema,定义参数格式
        "type": "object",
        "properties": {
          "file_path": { "type": "string" },
          "offset": { "type": "number" },     // 可选,大文件分段读
          "limit": { "type": "number" }
        }
      }
    },
    {
      "name": "exec",
      "description": "Execute shell commands...",
      "input_schema": { "..." }
    }
    // ... 共 20+ 个工具
  ]
}

system + messages 告诉模型"发生了什么",tools 告诉模型"你能做什么"。模型基于前者理解当前局面,基于后者决定下一步行动。

我:大模型返回的数据结构是什么样的?你怎么知道该回复文本还是调用工具?

模型返回的不是纯文本,而是结构化的 content blocks 数组。根据 stop_reason 的不同,有两种典型形态:

直接回复时:

{
  "role": "assistant",
  "stop_reason": "end_turn",    // 模型主动结束,没有更多动作
  "content": [
    {
      "type": "text",            // 纯文本 block
      "text": "你好!有什么我能帮你的?"
    }
  ]
}

需要调用工具时:

{
  "role": "assistant",
  "stop_reason": "tool_use",    // 模型还没说完,需要先执行工具
  "content": [
    {
      "type": "text",            // 可以有文本(也可以没有)
      "text": "让我查一下文档。"
    },
    {
      "type": "tool_use",        // 工具调用 block
      "id": "toolu_abc123",      // 唯一 ID,用于匹配后续的 tool_result
      "name": "web_fetch",       // 工具名,对应 tools 数组里的定义
      "input": {                 // 参数,符合 input_schema 定义的格式
        "url": "https://docs.openclaw.ai/concepts/agent.md",
        "maxChars": 15000
      }
    }
  ]
}

stop_reason 是整个 ReAct 循环的控制信号。一共有四种值:

  • end_turn:模型主动说完了,退出循环,把文本发给用户。
  • tool_use:模型需要工具的帮助,继续循环——执行工具,把结果塞回 messages,再调一次模型。
  • max_tokens:输出被截断了,达到了 max_tokens 上限,模型其实还没说完。
  • stop_sequence:命中了自定义的停止序列(OpenClaw 场景中基本不用)。

实际运行中 99% 的情况就是 end_turntool_use 交替出现。它们的关系可以这样理解:tool_use 是"循环继续",end_turn 是"循环结束"。

我:所以这是 ReAct 架构?

对。不是 Plan-and-Execute,没有独立的规划模块,没有任务 DAG。模型在同一个循环里同时负责推理和行动:看到问题→决定要不要调工具→拿到结果→继续推理→可能再调工具→直到不需要更多信息为止。整个过程是动态的,不是按预定计划执行的。

我:工具是大模型自己执行的吗?

不是。模型没有任何执行能力。它返回的 tool_use 只是一段 JSON 文本,表达了"我想执行这个操作"的意图。真正执行的是 Gateway——它解析 JSON,调用对应的工具实现代码,拿到结果后包装成 tool_result 塞回 messages 数组,再次调用模型。模型和工具执行之间隔着 Gateway 这一层,Gateway 完全可以拒绝执行、修改参数、截断结果。模型无法验证,也无法绕过。

这个设计意味着系统提示里的安全护栏只是建议,真正的安全靠 Gateway 层的硬控制。

我:异常情况怎么处理?比如输出被截断,或者大模型没正常返回?

先看完整的 ReAct 循环伪代码,包含了所有异常分支:

def agent_run(agent, session_key, user_message):
    # 1. 加载历史 & 组装请求
    history = read_session_jsonl(session_key)
    system_prompt = build_system_prompt(agent)
    messages = [*history, {"role": "user", "content": user_message}]

    # 2. 上下文窗口检查
    if estimate_tokens(system_prompt, messages) > context_window * 0.7:
        prune_old_tool_results(messages)        # 第一层:裁剪工具返回
    if estimate_tokens(system_prompt, messages) > context_window * 0.6:
        summary = call_llm("总结这段对话")       # 第三层:压缩
        messages = [compaction_summary, *recent_messages]

    # 3. ReAct 循环
    while True:
        # 调用模型(带 fallback)
        response = None
        for model in [agent.primary, *agent.fallbacks]:
            try:
                response = call_llm(model, system_prompt, messages, tools)
                break
            except APIError:
                continue                         # 主模型挂了,试下一个
        if response is None:
            raise AllModelsFailed()               # 全挂了,报错给用户

        # 把 assistant 回复加入历史
        messages.append({"role": "assistant", "content": response.content})

        # 根据 stop_reason 分支
        if response.stop_reason == "end_turn":
            break                                 # 正常结束,退出循环

        elif response.stop_reason == "tool_use":
            for tool_call in response.tool_uses:
                try:
                    result = execute_tool(tool_call.name, tool_call.input)
                except ToolError as e:
                    result = {"content": str(e), "is_error": True}

                messages.append({
                    "role": "user",
                    "content": [{
                        "type": "tool_result",
                        "tool_use_id": tool_call.id,
                        "content": truncate(result),  # 截断过大的结果
                        "is_error": result.get("is_error", False)
                    }]
                })
            continue                              # 带着工具结果再调模型

        elif response.stop_reason == "max_tokens":
            break                                 # 截断了,照样发出去

    # 4. 持久化 & 发送
    append_session_jsonl(session_key, messages)
    payloads = extract_text(messages)
    filter_no_reply(payloads)                     # 过滤 NO_REPLY
    deduplicate_message_tool(payloads)            # 去重 message 工具已发的
    if payloads:
        send_to_discord(payloads)
    elif has_tool_error(messages):
        send_to_discord(format_error(messages))   # 工具报错兜底
    # else: 静默

几个要点值得展开。

API 调用失败时,OpenClaw 有 fallback 机制。每个 agent 可以配置一个降级链,比如主模型是 Claude Opus,挂了自动切 MiniMax,再挂切 Kimi。所有 fallback 都失败才报错给用户。

工具执行失败不会中断循环。错误信息会原样作为 tool_result 返回给模型,由模型自己决定怎么办——换个参数重试,或者告诉用户出了什么问题。这是 ReAct 的优势:错误处理也是推理的一部分。

上下文窗口快满时会触发三层压缩机制:pruning 裁剪旧的工具返回(临时,不改文件),memory flush 抢救重要信息到记忆文件(静默,用户看不到),compaction 总结旧对话为摘要(永久,改写历史)。这套机制的细节会在后续文章中展开。