最近看到不少人在折腾openclaw平替,其中nanobot是被讨论得比较多的一个。这篇是nanobot源码解析系列的第七篇,正好讲到Gateway与多渠道集成,我把自己读代码、跑通微信和飞书的完整过程整理出来,给后面想接多渠道的朋友做一个参考。
先说结论:nanobot的Gateway并不复杂,但设计思路很值得抄。它把HTTP回调、签名校验、消息标准化、路由派发、出站发送这几件事拆得明明白白。不管你是想给现有项目接企业微信、飞书还是钉钉,又或者是想自己写一套Agent网关,这篇文章里的思路都能直接用。我读的是当前main分支的代码,后续版本如果函数名有变更,以你本地的源码为准。
1. Gateway为什么非有不可:先看清nanobot的边界
1.1 nanobot到底想解决什么问题
openclaw官方版本的思路是重而全:多智能体编排、Java运行时、消息中心、UI控制台,功能覆盖面广,但部署链路也跟着边长。很多人在Windows上安装openclaw时就碰到过环境变量、依赖冲突、容器网络的问题,这在技术社区里已经是老生常谈。
nanobot走的是完全相反的路线。它的核心只保留三件事:LLM调用、技能执行、网关接入。其中网关联入这一层,被单独抽成了gateway包。你可以把它理解成一个"零外部依赖"的消息出入口:所有渠道进来的都是HTTP回调,所有出去的都是对应渠道的API调用,中间这部分无状态逻辑,就是Gateway的全部。
这个"无状态"三个字是关键。因为Gateway不保存会话数据、不持有Agent实例,所以你可以开多个副本放在负载均衡后面,不需要做任何会话同步。这一点在openclaw那种带状态的重型架构里是很难做到的,属于轻量方案的降维优势。
1.2 Gateway管什么,不管什么
读源码最怕的就是眉毛胡子一把抓。我建议你先把职责边界划清楚,再看代码就不会迷路。
Gateway管三件事。第一是接入层:接收各平台回调、做签名校验、解密、把不同格式的payload解析成统一消息对象。第二是派发层:根据消息里的bot_id和会话ID,找到对应的Agent会话,调用统一的process_message接口。第三是出站层:把Agent的输出按渠道协议封装,调用对应渠道的API发出去。
不管的事也明确划出去。Gateway不参与Agent循环,LLM怎么组织上下文、怎么调用工具,那是大脑模块的事,Gateway只负责把消息递进去、把结果拿出来。Gateway不加载技能,Skill的注册和调度都在skill包里,Gateway只接收执行结果。Gateway也不直接读写持久化存储,memory模块独立,Gateway跟它只通过配置和依赖注入打交道。
这些边界不是文档里写的,是我从代码调用关系里反推出来的。你会发现gateway目录下的模块基本都不import大脑模块的深层逻辑,最多引用一个AgentResponse数据结构,这种解耦做得很彻底。
1.3 代码结构速览
先看目录结构,心里有个地图。
nanobot/ ├── gateway/ │ ├── __init__.py │ ├── server.py # 入口:路由注册、生命周期管理 │ ├── auth.py # 签名校验、解密 │ ├── message.py # 标准化消息结构 │ ├── dispatcher.py # 路由派发 │ ├── sender.py # 出站消息统一入口 │ └── adapters/ │ ├── base.py # 适配器抽象类 │ ├── wecom.py # 企业微信 │ ├── feishu.py # 飞书 │ ├── dingtalk.py # 钉钉 │ └── slack.py # Slack这套结构很直白,server负责Http服务和路由,auth负责安全,message定义统一数据格式,dispatcher做派发,sender管出站,adapters里每个文件对应一个渠道。每一层职责单一,改动一个渠道不会牵连其他渠道。
2. 从渠道回调到Agent输入:一条消息的标准化之旅
2.1 多渠道路径设计
nanobot启动后默认监听0.0.0.0:1572,所有渠道的回调都打到这一个端口上,通过URL前缀区分渠道:
POST /gateway/wecom POST /gateway/feishu POST /gateway/dingtalk POST /gateway/slack这种路径路由的设计好处很明显:部署时只需要暴露一个端口,防火墙规则、反向代理配置都省事。对应的配置在server.py里就是一张简单的路由表,path -> adapter.channel,注册新渠道时只需要把适配器类挂上去。
我们平时看到openclaw需要配置一堆回调地址,nano bot这样统一收口的方式确实清爽不少。
2.2 验签与解密
这是整个Gateway里安全等级最高的一环,也是最容易出问题的地方。各渠道的验签机制各不相同,我踩过的坑基本都集中在这里。
先看nanobot的处理流程:server.py收到请求后,第一步调用auth.verify_signature(channel, params, config),验签通过才继续往下走,否则直接返回HttpResponse(403)。不同渠道的验签逻辑独立实现,但外层统一收敛成一个函数:
# nanobot/gateway/auth.py def verify_signature(channel: str, params: dict, config: ChannelConfig) -> bool: if config.debug: return True if channel == "wecom": return verify_wecom_signature(params, config) if channel == "feishu": return verify_feishu_signature(params, config) if channel == "dingtalk": return verify_dingtalk_signature(params, config) return False注意这个debug开关,建议只在本地联调时设为true,上生产环境必须关掉。我有一次就是忘了关,结果渠道侧反复校验失败,排查了半天才发现是回调被第三方直接转发,签名早就不合法了。
企业微信的验签逻辑做了一层AES加解密:回调参数里有msg_signature、timestamp、nonce和加密的echostr。验签时用token、timestamp、nonce做SHA1排序拼接,结果要和msg_signature一致,再对echostr做AES解密,解密后还要校验其中的随机字符串和消息长度。一旦每一步的逻辑和官方文档不完全对齐,就会报签名错误。
飞书相对简单一点,回调里带token直接比对配置里的Verification Token,但加密事件还另外需要Encrypt Key做AES解密。很多人只填了token没填Encrypt Key,验签直接挂掉。
钉钉的验签是用时间戳和鉴权参数拼接后做HMAC-SHA1,再Base64编码。它跟企业微信一样,同一个参数在不同接口里略有差异,我后面单独讲。
2.3 统一消息结构
验签通过后,下一步是解析payload。nanobot定义了一个dataclass作为所有渠道消息的统一格式:
# nanobot/gateway/message.py @dataclass class InboundMessage: msg_id: str # 渠道侧的消息唯一ID,用来做幂等去重 channel: str # 渠道标识:wecom/feishu/dingtalk bot_id: str # 路由到哪个机器人实例 conversation_id: str # 会话ID,可能是群ID/用户ID/单聊ID user_id: str # 发送者ID content_type: str # text/image/voice text: str # 统一后的文本内容 raw: dict # 原始payload,调试用 ts: int # 消息时间戳,秒级为什么要统一结构?核心原因是下游大脑模块不需要关心你用的是微信还是飞书,它只看一个干净的协议。这就像插座标准一样,不管发电厂是水电还是火电,家电只需要插上标准插座就能工作。nanobot的消息标准化,做的就是"把各渠道的电流统一成220V交流电"这件事。
raw字段是调试利器。线上出问题时,直接dump原始payload对比,能省下大量猜测时间。
2.4 路由派发
消息解析成InboundMessage之后,进入dispatcher.py的dispatch(message)方法。派发逻辑按优先级分三步:先根据message.channel找到对应的适配器,再根据message.bot_id找到对应的Agent实例,最后根据message.conversation_id找到或创建对应的会话上下文。
# nanobot/gateway/dispatcher.py async def dispatch(message: InboundMessage) -> AgentResponse: adapter = registry.get_adapter(message.channel) bot = registry.get_bot(message.bot_id) session = session_manager.get_or_create( bot_id=message.bot_id, conversation_id=message.conversation_id, ) response = await bot.process_message(message, session) return response这段代码有个容易被忽略的细节:会话归属维度是bot_id + conversation_id,而不是channel + user_id。这意味着同一个用户在飞书和企业微信里跟同一个bot对话,上下文是共享的。我在自己项目里恰恰利用了这个特性,让用户在企业微信里聊了一半,再去飞书里继续,Agent仍然记得之前的上下文,体验很连贯。
3. 渠道适配器:一张抽象接口吃下微信、飞书和钉钉
3.1 BaseAdapter的接口约定
nanobot的适配器设计非常简洁,所有渠道的差异都被收敛到4个方法里:
# nanobot/gateway/adapters/base.py from abc import ABC, abstractmethod class BaseAdapter(ABC): channel: str = "" @abstractmethod def verify(self, request) -> VerifyResult: """校验回调是否来自渠道官方服务器""" @abstractmethod def parse_inbound(self, request) -> list[InboundMessage]: """把渠道原始回调解析成统一消息列表""" @abstractmethod def render_outbound(self, response: AgentResponse) -> OutboundPayload: """把Agent输出转换成渠道要求的响应结构""" @abstractmethod def send(self, payload: OutboundPayload) -> SendResult: """调用渠道API主动下发消息"""verify负责安全校验,parse_inbound负责入站解析,render_outbound负责出站转换,send负责实际发送。这四个方法把渠道间所有差异都隔离了。你要接一个新渠道,只需要实现这四个方法,剩下的路由、会话、Agent循环全部复用。
这就是典型的策略模式在软件架构中的应用。每个渠道一个类,各自维护自己的细节,互不干扰。我在自己的代码里也沿用了这套抽象,接新渠道的时间从原来的两三天压缩到半天。
3.2 企业微信:解密和回调里的坑
企业微信的适配器是最复杂的,因为它同时要处理两种消息类型:验证消息和业务消息。验证消息就是URL回调验证,需要解密echostr并原样返回明文;业务消息则是用户发送的消息推送。
解密过程在wecom.py里用了几行关键代码实现,本质是AES-CBC解密:
# nanobot/gateway/adapters/wecom.py def _decrypt(encrypt: str, aes_key: str) -> str: key = base64.b64decode(aes_key + "=") cipher = AES.new(key, AES.MODE_CBC, key[:16]) decrypted = cipher.decrypt(base64.b64decode(encrypt)) # 填充长度和随机字符串的处理 ...这里有个典型的坑:企业微信的AESKey是43位,Base64解码后需要补一个=,而且解密后的内容要做PKCS7反填充,取长度和随机串的方式必须严格按官方文档来。我看过好几个开源项目在这个细节上翻车。
另外企业微信的回调还分"普通消息"和"事件消息"两类。事件消息比如用户进入应用、菜单点击等,它的payload结构和普通消息完全不同。nanobot在parse_inbound里对这两类做了区分,普通消息返回InboundMessage,事件消息只做ack不进入Agent。这个细节很容易被忽略,不处理的话,用户点击菜单也会触发Agent回复,打扰体验。
3.3 飞书和钉钉的差异
飞书适配器相对轻松。它的回调分为URL验证和事件订阅两类,URL验证直接返回challenge字段就行。事件订阅里的消息类型有text、post、image等,nanobot统一把post里的文本拼到text字段里。飞书主动发消息用的是im/v1/messages接口,需要tenant access token,适配器在send里先申请token再发送。
钉钉适配器又不一样。钉钉的签名机制是:把时间戳、鉴权参数做HMAC-SHA1后再Base64编码,放到请求头里。它的消息类型里text最常见,但响应时要注意,钉钉回调要求尽快返回,否则会重推。下表是三个渠道在关键维度上的对比:
| 维度 | 企业微信 | 飞书 | 钉钉 |
|---|---|---|---|
| 验签方式 | msg_signature + AES解密 | token + Encrypt Key | HMAC-SHA1 + Base64 |
| 回调格式 | XML封装 | JSON | JSON |
| 主动推送 | 需要API凭证 | 需要tenant token | 需要robot code |
| URL验证 | 解密echostr返回 | 返回challenge | 返回固定字符串 |
| 超时重推 | 3次 | 最多重推3次 | 3次 |
这张表建议保存下来,接渠道前先看一眼,能少走很多弯路。我在接钉钉时,最意外的是它主动推送前要先调一次robot/query接口获取robotCode,这个在企业微信里是没有的。
3.4 配置驱动注册
适配器的注册是配置驱动的,不是硬编码在代码里。启动时Gateway会读配置里的channels段,逐个实例化适配器并注册到路由表中:
# config.yaml channels: wecom: adapter: wecom app_id: ww123 secret: xxxxx token: xxxxx aes_key: xxxxx feishu: adapter: feishu app_id: cli_xxx app_secret: xxxxx verification_token: xxxxx encrypt_key: xxxxx注册逻辑在server.py的_register_adapters方法里,遍历配置、根据adapter字段找到对应的适配器类、初始化后放入注册表。整个过程像插件机制,加渠道就是加配置,不需要改代码。这也是nanobot能做"平替"的原因之一,接入成本被压得很低。
4. 出站消息的三条路径:同步回包、异步下发与主动推送
4.1 同步回包:短任务的最优解
当Agent能在一个HTTP请求的生命周期内完成响应时,nanobot走的是同步回包路径。dispatcher拿到AgentResponse后,直接交给适配器的render_outbound,转成渠道要求的响应格式,然后作为HTTP响应体返回。
企业微信的同步响应是XML格式,飞书是JSON格式。以飞书为例,它的回调接口要求响应体里有reply字段来做"直接回复",这个接口不走主动推送,速度和可靠性都更好。nanobot对这类短任务的默认策略就是同步回包,不引入任何中间件,整个链路只有"回调进来、Agent处理、响应出去"三步,非常轻。
前提条件是Agent处理时间不能超过渠道的响应超时时间。企业微信和飞书一般允许5秒左右,如果你接的LLM响应很快、或者你的技能都是轻量查询,同步回包完全够用。
4.2 异步下发:慢任务不能死等
一旦任务可能超过5秒,同步回包就要让位给异步下发了。比如说让Agent写一篇长文、调用一个外部API做数据清洗,这类任务执行时间轻松达到几十秒,你不能让渠道侧一直等。
nanobot的做法是把出站任务丢进一个内部线程池,先给渠道返回一个"已收到"的ack,等Agent真正跑完再把结果通过适配器的send接口主动发给用户。线程池大小可以在配置里调,默认值是4。这个设计思路非常实用,它把"慢任务"从HTTP回调线程里摘出去,回调线程只负责快速确认,避免渠道因为等待超时重推。
用户发消息 -> Gateway回ack -> Agent慢慢跑 -> send主动推结果这个流程里的ack响应,各渠道格式也不一样,企业微信返回空串或"success",飞书返回{},钉钉返回"ok"。适配器把这层差异也屏蔽了,上层只需要决定走同步还是异步,不用关心具体渠道的ack怎么写。
4.3 主动推送:sender模块的价值
第三类出站是主动推送,跟Webhook链路完全无关。场景包括定时任务、监控告警、后台运营提醒。比如你每天早上9点让Agent推送一条项目进度汇总到群里,这个过程没有用户消息触发,完全是Gateway内部发起的。
nanobot为这个场景单独设计了sender.py,提供统一的主动触达接口:
# nanobot/gateway/sender.py async def send_to(bot_id: str, channel: str, target: str, content: str): bot = registry.get_bot(bot_id) adapter = registry.get_adapter(channel) payload = adapter.render_outbound(AgentResponse(text=content)) payload.target = target await adapter.send(payload)这段代码里最值得关注的是target参数。不同渠道对"发给谁"的表达方式完全不同:企业微信里要填用户ID或群ID,飞书里要填open_id或chat_id,钉钉里要填robotCode加userId。适配器在render_outbound时如果发现是主动推送场景,会按渠道规则解析target。这个接口在文档里很少被提到,但做通知类机器人时它就是核心入口。
4.4 重试与幂等
出站消息最怕的不是失败,而是失败后的重复发送。渠道侧在超时后都会重推,企业微信、飞书、钉钉默认都会重推最多3次,如果你在处理消息时没有做幂等,用户可能会收到4条一模一样的回复。
nanobot在dispatcher里对msg_id做了短时缓存,同一msg_id在10分钟内只处理一次。这个窗口覆盖了所有主流渠道的重推时间。我建议你在这个基础上再加一层持久化去重,用Redis或者数据库记录msg_id的处理状态,防止Gateway重启导致缓存丢失。
发送侧的重试也要控制节奏。nanobot对send失败默认重试2次,用指数退避,第一次等1秒、第二次等2秒。注意只对网络错误和渠道5xx错误重试,对4xx错误不重试,因为4xx说明请求本身有问题,重试一万次也是失败。
5. 多实例路由、重复请求与超时控制:真实运行中的麻烦事
5.1 多机器人路由策略
当Gateway后面挂了多个Agent实例时,路由就变得关键了。nanobot把bot_id作为一等路由维度,在回调URL里支持这样的格式:
POST /gateway/wecom/bot/agent_a POST /gateway/wecom/bot/agent_b这个路由信息会透传到InboundMessage的bot_id字段。同一个企业微信应用只能配置一个回调URL,如果你要跑多个机器人,要么用不同应用,要么用URL前缀区分。nanobot选择支持后者,让一个应用可以服务多个bot实例。
我在实际使用中确实用它跑了一主一备两个Agent,一个负责日常问答,一个负责数据分析,共用同一个企业微信入口,按关键词前缀路由。这个场景在多渠道机器人里很常见,建议多实例需求的朋友优先考虑这种路径路由方案。
5.2 msg_id去重细节
前面提到过10分钟短时缓存,这里补充几个细节。去重缓存通常放在内存里,用dict加过期时间实现最简单,但要注意内存占用,高并发场景下还是用LRU缓存或者Redis更稳妥。
还有一点容易踩坑:有的渠道消息没有msg_id字段,尤其是事件消息。nanobot的做法是拿channel + user_id + timestamp + content拼一个伪msg_id。这个伪ID有碰撞风险,但概率很低,配合短时间窗口可以接受。如果你的场景对重复极其敏感,建议自己加深一层业务去重。
5.3 超时与线程池
Gateway本身是无状态的,但Agent调用LLM可能有几十秒的耗时,这里就必须考虑超时和资源隔离。
nanobot的dispatcher里为异步任务维护了一个全局线程池,核心参数是max_workers,默认4。每个异步任务在提交前会做一次容量检查,如果线程池满了就直接返回"系统繁忙"给用户,而不是无限排队。这里面的取舍很现实:宁可快速拒绝,也不要让用户等几个小时后收到一条过期回复。
另外,每个出站HTTP调用都有超时限制,nanobot默认设了30秒。之前的版本用的是requests库,超时经常被忽略,后来换了支持超时的异步客户端才解决。这类细节在文档里不会写,只有跑了一段时间后才会发现:当某个外部API响应缓慢时,整个线程池都被占满,所有渠道的响应都变慢。所以建议把外部调用的超时调到10秒以内,宁可失败重试,也不要拖垮整个Gateway。
5.4 配置热更新
Gateway的配置支持运行时reload,不需要重启进程。实现原理是配置管理器持有一个当前的config对象,reload时替换引用,同时通知适配器工厂重建配置变更的渠道实例。
这个能力在调试时极其有用。我经常改一个密钥或换一个模型配置,直接调reload接口,几毫秒就生效,不用重启服务。生产上也可以利用这个特性做渠道的灰度上线。
配置热更新的代价是要保证适配器是线程安全的。因为新老配置切换的瞬间,可能有请求正持有旧配置在执行。nanobot的做法是每次请求进来都从头取一次配置引用,避免一个请求里混用新旧配置。建议自己在扩展时也遵守这个约定。
6. 调试Gateway的实战经验:从本地curl到502排查
6.1 本地模拟回调的正确姿势
联调Gateway时最麻烦的是渠道回调需要公网可达。但在本地开发阶段,你是可以完全绕过公网的:把config.yaml里该渠道的debug设为true,签名校验直接跳过,然后用curl模拟渠道发请求。
以企业微信为例,本地起的服务默认在127.0.0.1:1572,模拟一条文本消息:
curl -X POST http://127.0.0.1:1572/gateway/wecom \ -H "Content-Type: application/xml" \ -d '<xml><ToUserName><![CDATA[ww123]]></ToUserName><FromUserName><![CDATA[user001]]></FromUserName><CreateTime>1700000000</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[你好,介绍一下你自己]]></Content><MsgId>10001</MsgId></xml>'飞书的模拟回调则是JSON格式:
curl -X POST http://127.0.0.1:1572/gateway/feishu \ -H "Content-Type: application/json" \ -d '{"schema":"2.0","header":{"event_id":"evt_1","event_type":"im.message.receive_v1"},"event":{"message":{"message_id":"om_1","message_type":"text","content":"{\"text\":\"你好\"}"},"sender":{"sender_id":{"open_id":"ou_1"}}}}'curl命令能通,就说明从HTTP接入到消息解析这段是通的,之后再逐段排查Agent处理逻辑和出站发送。注意debug模式只跳过验签和加解密,消息解析逻辑和正常模式完全一致,不会出现"本地能跑、线上挂掉"的意外。
6.2 签名失败排查清单
如果你在线上遇到了签名校验失败,按下面的清单逐条排查,能省不少时间:
- 系统时间是否准确。企业微信和钉钉的签名机制里都有时间戳,偏差超过300秒直接拒绝。我看到过的案例里,服务器时间快4分钟导致验签失败的就有一个。
- token、aes_key、secret是否完全一致,包括大小写和末尾等号。企业微信的AESKey经常有
=,复制时容易被忽略。 - 回调参数是否被代理层篡改。有反代时,某些参数会被重新编码,导致验签失败。
- 是否配了多个回调地址。有些应用允许多个回调地址,但验签时用的是哪个地址对应的token,要在配置里一一对应。
- 日志里看验签失败发生在哪一步。nanobot的auth层有校验步骤的日志,能区分是签名不匹配、解密失败还是timestamp超限。
6.3 502 Bad Gateway问题怎么查
技术社区里经常看到unexpected status 502 bad gateway: unknown error, url: http://127.0.0.1:1572这类报错,我一开始也遇到过,来来回回查了好几轮。
先解释一下这个报错是怎么产生的:你的Gateway如果作为某个AI IDE或代码工具的API后端,工具会向127.0.0.1:1572发起HTTP请求。502代表请求已经到达了某个代理或调用框架,但Gateway没有给出有效响应。最常见的原因是Gateway进程根本没有起来,或者起来后端口没监听。
排查顺序是这样:先看进程是否存活,ps aux | grep nanobot;再看端口是否监听,netstat -an | grep 1572;然后直接curl探测一下http://127.0.0.1:1572;最后看Gateway日志有没有报错。十有八九是进程挂了或端口被占用。
另一个常见原因是Gateway响应太慢,上游请求等不到响应就报了502。这种情况下你去看Gateway日志,通常能发现LLM调用超时或者某个技能执行卡住了。把模型超时调短、给慢任务走异步通道,就能缓解。
6.4 各渠道接入的经典坑
最后把各渠道接入时容易踩的坑集中说一下。
企业微信的坑主要在两个地方。一是回调URL必须是公网HTTPS,这是平台硬性要求,本地联调必须靠工具转发。二是企业微信的"消息加解密"开关,如果应用配置里没开启,回调里就没有加密字段,但你的适配器还在尝试解密,必然失败。要保证应用配置和适配器配置对齐。
飞书的坑在于事件订阅的Encrypt Key。很多人只校验了Verification Token就以为完事了,实际上加密事件需要同时配置Encrypt Key,否则回调内容解不开。另外飞书主动推送消息前要获取tenant access token,token有效期约2小时,适配器会缓存并在过期前刷新,这一块不能自己实现得太简陋。
钉钉的坑主要是回调返回格式。钉钉要求响应体返回固定内容"success"并伴随HTTP 200,如果你返回空串,钉钉会判定为失败然后重推。另外钉钉的主动推送需要先查询robotCode,很多教程里不会写这一步,接的时候要注意。
还有一类问题属于通病:回调并发太高导致消息处理乱序。nanobot的异步线程池本身不保证消息顺序,如果你的业务强依赖顺序,比如用户连续发两条关联消息,建议自己在业务层做用户维度的串行处理。简单方案就是给同一个user_id的消息加锁,处理完一条再处理下一条。
我自己的项目里,Gateway最值得借鉴的其实就是Adapter抽象和消息标准化这套结构。接第五个渠道的时候,已经能做到只写一个适配器类、改一段配置就上线。回到开头的主题,openclaw平替的意义不只是省下了一堆部署成本,更在于它把复杂系统拆成了可以按需扩展的模块。如果你也在做多渠道机器人,建议先拿nanobot的Gateway跑通一个渠道,把链路摸熟,再加第二个、第三个,你会发现这套设计的余量比你想象的大得多。