profileName: youpingfang postId: 308 postType: post categories:

- 6

OpenClaw 把整个 Agent 平台拆成了五层组件,各司其职又首尾相连。

Channel Plugins 是最外层,每个消息平台一个插件,Telegram、Discord、Slack 等各一套,干的活就是协议转换,把平台私有格式翻译成 OpenClaw 内部统一的消息结构。

Gateway 是整个系统的中枢,所有请求都从这过。鉴权、限流、幂等去重全在这一层搞定,然后把消息往下游分发。

Routing 是路由层,拿到消息后根据配置规则决定交给哪个 Agent 处理,同时生成 Session Key 来追踪会话。

Agent Runner 是执行引擎,管理 LLM 调用、工具执行、结果回传这个循环,一个 Agent 跑起来的所有脏活累活都在这。

Context Engine 管对话历史,摄入、组装、压缩全包了,接口化设计,随时可以插拔替换成自定义实现。

一条消息的完整链路:Channel 收到用户消息 → Gateway 鉴权限流后分发 → Routing 匹配到目标 Agent → Agent Runner 驱动 LLM 执行 → Context Engine 维护上下文 → 回复原路返回,经 Gateway 交回 Channel 发出。

扩展知识

为什么要分这么多层

很多人第一反应是"搞这么多层不累吗",但你想想一个 Agent 平台要同时对接 Telegram、Discord、Slack、Web 等,每个平台的消息格式、回调方式、鉴权机制都不一样。如果不抽出 Channel 层做协议转换,核心逻辑里到处都是 if-else 判断平台类型,加一个新渠道得把代码翻个底朝天。

Gateway 单独拎出来也是同理。鉴权、限流、幂等这些横切关注点如果散落在各个模块里,维护成本会爆炸。集中到 Gateway 统一处理,下游组件不用操心这些事。

Routing 独立出来是因为 Agent 系统往往不止一个 Agent。比如一个企业部署了客服 Agent、运维 Agent、数据分析 Agent,一条消息进来得先判断交给谁处理。

OpenClaw 在 src/routing/resolve-route.ts 里实现了路由核心逻辑,支持按 peer(具体用户/聊天)、guild(Discord 服务器)+ 角色、team(Slack 工作区)、account、channel 类型等维度做 binding 匹配。

源码层面的落点

在源码里,这五层各有对应目录:

1)Gateway 在 src/gateway/server.impl.ts 负责启动,server-methods.ts 负责请求分发

2)Channel 在 src/telegram/src/discord/src/slack/ 以及 extensions/ 下的扩展

3)Routing 在 src/routing/resolve-route.ts

4)Agent Runner 在 src/agents/pi-embedded-runner/run.ts,这是执行主入口

5)Context Engine 在 src/context-engine/types.ts 定义接口,legacy.ts 是默认实现(它把消息持久化委托给 SessionManager,assemble 直接透传消息列表,compact 委托给 compaction 模块做摘要压缩)

辅助组件

除了五层核心组件,还有几个关键辅助角色。

Plugin Registry 在 src/plugins/registry.ts,统一收集插件注册的工具、Hook、渠道和 Provider,所有扩展点都汇聚到这。

Session Manager 负责会话持久化,用的 JSONL 格式,每轮对话追加一行,既方便调试也方便回放。

Config 层在 src/config/,管理所有配置的 Schema 验证和解析,用 Zod 做运行时校验,启动时就能把配置错误拦住。

面试官追问

提问:如果要给 OpenClaw 新增一个微信渠道,大概要改哪些地方?

回答:只需要在 Channel 层新增一个微信插件,实现消息收发的协议转换接口就行,核心就是把微信的 XML 消息格式转成 OpenClaw 内部的统一消息结构,再把回复转回微信格式。Gateway 往下的所有组件都不用动,这就是分层的好处。具体来说需要实现 ChannelPlugin 接口(定义在 src/channels/plugins/types.plugin.ts),它采用 adapter 模式,按能力拆分为 messaging(消息收发)、outbound(外发)、streaming(流式输出)、gateway(网关集成)等多个适配器,按需实现即可。

提问:Context Engine 说可以插拔替换,那默认实现和自定义实现的边界在哪?

回答:src/context-engine/types.ts 里定义了接口契约,核心就三件事:摄入新消息(ingest)、组装 Prompt 上下文(assemble)、压缩历史记录(compact)。默认的 legacy.ts 实现比较直白:ingest 是 no-op(消息持久化由 SessionManager 负责),assemble 直接透传消息列表,compact 委托给 compaction 模块做 LLM 摘要压缩。如果业务需要更复杂的策略,比如按语义相关性检索历史、或者接入向量数据库做 RAG,只要实现同一套接口,在配置里切换就行。

提问:Agent Runner 的执行循环什么时候终止?有没有防死循环的机制?

回答:Agent Runner 每次循环就是调 LLM 拿到响应,如果响应里包含工具调用就去执行工具,把结果喂回 LLM 继续下一轮。终止条件是 LLM 返回纯文本回复、不再请求调用工具。防死循环靠多层保护:执行超时时间、工具循环检测(tool-loop-detection.ts 内置多种检测策略:generic_repeat 检测连续调同一工具同一参数、ping_pong 检测两个工具交替调用、known_poll_no_progress 检测轮询无进展、global_circuit_breaker 全局断路器兜底)、以及 context overflow 时的自动 compaction 和降级。超过限制就强制终止,返回一个兜底回复。

提问:Gateway 的幂等去重是怎么做的?

回答:消息平台经常会重复推送同一条消息,比如 Telegram 的 Webhook 超时重试。Gateway 拿到每条消息后会提取一个唯一标识,在本地缓存里做去重判断,如果已经处理过就直接丢弃。这样下游组件压根不用关心重复消息的问题,鉴权和去重全在入口收敛掉了。