我一直好奇一件事:当我在 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_turn 和 tool_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 总结旧对话为摘要(永久,改写历史)。这套机制的细节会在后续文章中展开。