OpenClaw源码拆解:揭秘不串台、并行回复与正确回群的三大工程机制
最近开源圈被刷屏的 AI 助理框架里OpenClaw 绝对算一个。社区里老用户都叫它“龙虾”因为它那 logo 就是一只挥着大钳子的爪爪OpenClaw 这名字本身也带了“claw爪子”的意思。很多人照着官方文档把它跑起来之后接上微信、Telegram装了一堆 skill很快就开始遇到三个高频问题它同时在好几个群里被 回答却互不串台到底怎么做到的它一条消息还没处理完下一条又来了还能秒回是不是真的在并行它有时候要调工具、查网页绕了一大圈才回复居然还能准确回到刚才那个群这个“记性”是哪来的如果你只是把 OpenClaw 当黑盒用这三个问题不影响日常。但如果你想改点逻辑、自己写 skill或者想搞懂社区里那些高级玩法就需要从源码层面把这三个问题彻底搞清楚。这篇文章是我的 OpenClaw 系列第 1 期不聊安装不聊写 skill专门拆“不串台、能并行、总回对群”这三个能力背后的源码设计。我会把核心代码做大幅精简只保留跟主题相关的关键分支方便你对着自己仓库里的源码一起看。1. 先搞清楚 OpenClaw 的运转链路1.1 一条消息从进来到出去到底经历了什么OpenClaw 本质上是一个“消息进、动作出”的 Agent 运行时。它不像普通机器人那样写死一堆关键词回复而是把收到的消息交给大模型让模型决定“该回答什么”或“该调用哪个工具”。所以要理解它的三个能力得先知道一条消息进到系统后走了哪些环节。我这边的简化流程图大概是这样接入网关收到消息后先解析成统一格式拿到“哪个渠道、哪个群、哪个人、什么内容”这些信息然后按 session_id 找到对应的会话对象把这条消息追加进该会话的历史记录接着把事件丢进一个并发调度器调度器为每个会话分配独立的处理任务Agent 从历史里取上下文调用模型如果需要工具就去执行 skill最后把回复交给消息分发模块由它根据消息里携带的“回程地址”发回原来的群。这里面最关键的一点是从消息入站的第一毫秒开始所有关键信息都被绑定在一个事件对象上并且一路传递到最终回复。不串台、能并行、回对群本质上都不是大模型的魔法而是这个工程链路里的数据结构和调度逻辑设计得好。大模型只负责生成文字它甚至不关心这段文字要发到哪去发到哪是工程层决定的。1.2 三个能力在源码里的“落点”很多人在源码仓库里翻半天不知道从哪里看起。我给个地图省得你迷路能力核心模块核心机制主要配置项不串台会话管理模块按 channel external_id 生成 session_id每个会话独立存历史历史条数上限、会话目录位置能并行调度与运行时模块会话级队列 全局信号量用异步协程跑 agent 循环最大并发数、队列长度总回对群消息类型与分发模块入站消息自带 channel_id 和 reply_target回复时原样带回渠道网关配置、回复模式看到没有这三件事其实被拆成了三个独立的工程问题数据隔离、异步调度、路由随消息走。后面几章我会分别拆开讲。2. “不串台”的源码秘密会话隔离是用目录和 ID 硬隔开的2.1 session_id 由 channel external_id 双键生成先说不串台。我在源码里翻了半天最后发现它的实现比我想象中还要“笨”——没有用任何魔法纯粹是用一个复合 ID 把会话物理隔开了。每个会话都有一个唯一标识 session_id由两个东西拼出来消息来自哪个渠道channel以及在这个渠道里的外部 IDexternal_id。我简化了一下核心代码大概是这个逻辑# src/openclaw/session/session.py极简示意 from pathlib import Path import json BASE_DIR Path(data/sessions) class Session: def __init__(self, channel: str, external_id: str): self.channel channel self.external_id external_id # 关键双键拼出唯一会话ID self.session_id f{channel}:{external_id} self.history_path BASE_DIR / self.session_id / history.jsonl def load_history(self, max_messages: int 40) - list[dict]: if not self.history_path.exists(): return [] with self.history_path.open(r, encodingutf-8) as f: lines f.readlines()[-max_messages:] return [json.loads(line) for line in lines] def append_message(self, message: dict) - None: self.history_path.parent.mkdir(parentsTrue, exist_okTrue) with self.history_path.open(a, encodingutf-8) as f: f.write(json.dumps(message, ensure_asciiFalse) \n)你注意看session_id 不是简单用“用户 ID”或“群 ID”单独生成的而是渠道 ID 外部 ID 双键拼接。举个例子就明白了同一个用户在 A 群说话external_id 是 A 群的群 IDsession_id 就是wechat:groupA。同一个用户又在 B 群说话external_id 是 B 群的群 IDsession_id 就是wechat:groupB。同一个用户私聊你external_id 是用户自己的 IDsession_id 就是wechat:user123。这三个会话的历史记录分别存在三个独立的文件里模型在读上下文时只会读其中一个文件天然就不会把 A 群聊的话题带到 B 群去。这里有个细节值得注意实际生产环境里不会直接把wechat:groupA当文件夹名因为渠道名和外部 ID 里可能带特殊字符甚至被人构造出路径穿越。所以源码里做了一层编码通常是 hash 一下再当目录名同时把原始 session_id 存在文件头里。我在本地二次开发时也踩过这个坑如果你自己写会话存储记得别拿原始字符串拼路径。2.2 历史记录按会话读系统提示词和用户上下文边界清晰光有 session_id 还不够。就算每个会话一个文件如果读历史的时候把别的会话也读进来了照样串台。OpenClaw 的做法很干净Agent 每次加载上下文只调用当前 session 的load_history()从当前会话的历史文件里取最近 N 条消息。这个 N 默认通常是 40 条左右具体看你的配置里context_messages参数。这里有一个很多新手会忽略的细节系统提示词system prompt是全局共享的但用户消息历史是会话隔离的。系统提示词好比“公司规章制度”每个员工入职都要看同一份但每个员工手头的工作文档是各归各的。源码里把这两部分分开处理async def build_prompt(self, session: Session) - list[dict]: system_prompt load_global_prompt() # 全局每个会话都一样 history session.load_history(max_messagesself.max_context_messages) return [{role: system, content: system_prompt}] history一旦你能分清“全局提示词”和“会话历史”很多群里串台问题其实都能从数据层面定位。有人在自己改提示词的时候不小心把用户历史塞进了全局变量结果 A 群的对话跑到 B 群来了这就是典型的把全局和会话边界搞混了。2.3 串台最容易踩的三个坑我在实际部署和二次开发里发现“不串台”这个目标最常见的翻车点有三个。第一个坑是全局变量存历史。早期很多人写机器人喜欢user_context {}这种字典把每个用户的消息往里面塞。这个方案在单用户单群里跑没问题一旦多群并发字典 key 写错一个就串。OpenClaw 用文件系统做存储等于每个会话一个独立涂鸦本谁也不会翻到别人的本子。第二个坑是群聊和私聊共用同一个 external_id。我在一个部署现场见过私聊用户问问题群里竟然也出现了同样的回答。后来排查发现接入层解析消息时私聊的 external_id 取了用户 ID群聊的 external_id 也取了用户 ID而不是群 ID两个会话就变成同一个 session_id 了。所以你在写渠道适配层时一定要分清楚群聊模式下 external_id 必须是群 ID私聊模式下才是用户 ID。第三个坑是并发读写同一个历史文件。OpenClaw 在单进程内用异步锁保证同一个会话的写入是串行的但如果你自己挂了多个进程或者手动改了历史文件就可能出现两个人同时在 A 群说话历史被写乱的情况。这个在源码里其实有防线我在后面讲并行时详细说。3. “能并行”的源码秘密会话级队列 全局并发闸门3.1 为什么不用线程用协程很多第一次看 OpenClaw 源码的人会问它怎么做到“同时”处理这么多群的是不是给每个群开了一个线程其实不是。OpenClaw 的并发模型是 Python asyncio也就是协程。线程本身有两个问题一是线程切换的开销比较大几百个线程同时跑光调度就能把 CPU 打满二是 Agent 的主要操作——调模型接口、执行 skill、访问网页、读写数据库——绝大多数是网络 IO 和磁盘 IO这些操作在等待响应的时候根本不消耗 CPU。用线程等 IO等于雇了一堆人坐在工位上干等快递太浪费了。协程的思路是一个人可以同时发出很多个请求等快递的时间去处理别的事快递到了再回来拆。Python 的async/await就是一种协程语法。OpenClaw 的 Agent 循环从接收消息到调模型到回复的整条链路几乎都是异步函数所以才扛得住多群同时轰炸。3.2 会话级队列同群不乱序、跨群不等待并发的难点不只是“能同时跑”更在于“同一个群的多个消息不能乱序处理”。你可以让 100 个群各自并行但群 A 里的三条消息必须按到达顺序处理。OpenClaw 的调度核心是一个SessionEventLoop类我这里做了一个极简版本# src/openclaw/runtime/queues.py极简示意 import asyncio class SessionEventLoop: def __init__(self, max_concurrency: int 8): self.semaphore asyncio.Semaphore(max_concurrency) self.queues: dict[str, asyncio.Queue] {} self.tasks: dict[str, asyncio.Task] {} async def push(self, session_id: str, event: dict) - None: if session_id not in self.queues: self.queues[session_id] asyncio.Queue(maxsize20) self.tasks[session_id] asyncio.create_task( self._consume(session_id) ) await self.queues[session_id].put(event) async def _consume(self, session_id: str) - None: q self.queues[session_id] while True: event await q.get() async with self.semaphore: await self._handle(session_id, event) q.task_done()你注意看这两个关键点第一每个 session_id 第一次收到消息时会动态创建一个队列并启动一个常驻的消费任务_consume。同一个会话的所有消息都进同一个队列消费任务是一条一条从队列里取出来处理的所以同一个群的多条消息天然顺序执行不会出现后发先至的乱序问题。第二不同的 session_id 各有各的队列和消费任务它们互相独立。群 A 的消费任务在等模型返回群 B 的消费任务完全不受影响可以直接开始自己的处理。这就是“能并行”的真正来源——不是一条消息拆成多个任务并行而是多个会话各自串行、彼此并发。3.3 全局信号量把并发关在笼子里并行之后还有一个问题如果来的群特别多每个群都同时调一次模型接口模型厂商那边迟早会给你限流甚至封号。OpenClaw 用一个全局信号量Semaphore来控制“同时正在处理的事件总数”。上面代码里self.semaphore asyncio.Semaphore(max_concurrency)就是干这个用的。每个消费任务在真正处理事件之前都要async with self.semaphore拿一个许可拿不到就等着拿到才处理。这就像一个厕所门口挂着“最多同时进 8 个人”的牌子在多出来的都在门口排队但排队不影响排队的人聊天。这个设计很巧妙因为它把两个目标统一了对同一个群保持严格顺序对全局保持并发可控。我见过有人为了解决模型限流直接在 Agent 函数里time.sleep(随机几秒)那不仅丑而且会把整个进程阻塞住。OpenClaw 的做法是用信号量做背压让新的请求在队列里自然排队而不是粗暴地睡死。3.4 并发参数怎么调别盲目拉大既然能并行是不是max_concurrency拉得越大越好我的实测经验是别盲目拉大。官方默认值一般在 8 到 16 之间这个值是经过权衡的。如果你只接了一个本地 Ollama 模型并发太高会把显卡显存打爆如果你接了云厂商模型接口并发太高容易触发限流如果你跑在飞牛 NAS 这类家用设备上CPU 和内存本来就不富裕建议并发调到 4 甚至更低。我自己在低配机器上部署时观察过 CPU 占用和平均响应时间的关系发现并发超过 16 以后响应时间非但没有下降反而因为 CPU 被撑满而变长了。还有一个队列长度的参数也要注意。上面代码里Queue(maxsize20)只示意了上限存在实际配置项在不同版本里名字不太一样但作用都一样如果某个群突然刷屏队列塞满之后新消息会被直接丢弃或返回错误而不是无限堆积把内存吃光。你可以根据群活跃度调整这个值但不能设成无限大否则一场刷屏就能把进程搞挂。4. “总回对群”的源码秘密入站消息自带“回程地址”4.1 入站消息的标准结构第三个能力是“总回对群”。你可能会觉得这有什么难的谁发的就回给谁呗。但实际场景比你想的复杂Agent 可能要调一个耗时 3 分钟的工具用户早就在别的群聊开了或者用户说“10 分钟后提醒我”10 分钟后这条提醒该发到哪总不能发到默认群吧。OpenClaw 的做法是在消息入站那一刻就把所有路由信息打包成一个标准对象并且让这个对象跟随整条处理链路走。核心数据结构我简化成下面这样# src/openclaw/messages/types.py极简示意 from dataclasses import dataclass, field dataclass class InboundMessage: channel: str # wechat / telegram / web ... channel_id: str # 群ID或用户ID决定回复到哪 sender_id: str sender_name: str content: str is_group: bool reply_target: str # 被引用消息ID可空 extra: dict field(default_factorydict) dataclass class OutboundMessage: channel: str channel_id: str content: str reply_target: str 注意这里有个容易被忽略的点channel_id不等于sender_id。在群聊里channel_id是群 IDsender_id才是说话的人。很多自定义开发把这两个混为一谈结果就是“用户私聊公司小助手公司大群也收到了回复”。4.2 回复时路由信息怎么跟着任务走消息对象只是第一步。关键在于OpenClaw 在处理事件时会把这条 InboundMessage 放到任务的上下文对象里而不是临时塞进某个全局变量。这样即使处理过程中插入了工具调用、网络请求、模型推理回复函数依然能从任务上下文里拿到最初的来源。async def send_reply(task_ctx, text: str) - None: await gateway.dispatch(OutboundMessage( channeltask_ctx.origin.channel, channel_idtask_ctx.origin.channel_id, contenttext, reply_targettask_ctx.origin.reply_target, ))这段代码的精髓是task_ctx.origin——它是这个任务最开始收到的那条消息。只要任务上下文不丢这个 origin 就一直在。哪怕你在处理过程中 await 了十个工具调用最后发消息时还是能准确找到当初那个群。我见过有人在这里偷懒写了一个全局变量current_channel每次收到消息都更新。单用户单群没问题多群一并发就乱套了A 群的消息正在处理B 群的消息也来了把current_channel改成了 B 群等 A 群处理完回复时就发到 B 群去了。这就是经典的回错群事故。OpenClaw 用任务上下文绑定而不是全局变量从根上杜绝了这个问题。4.3 延迟任务和后台任务最容易回错群的地方比正常回复更考验人的是延迟任务。比如用户说“1 个小时后提醒我喝水”这个提醒任务可能被存进数据库、可能被一个定时调度器扫描等真正执行时初始的消息早就处理完了。这时候如果任务对象里没有保留来源信息提醒就会变成一个“无家可归”的消息。源码里的解决方案非常直白把 InboundMessage 整个对象或者它的关键路由字段存进调度任务里。# src/openclaw/tasks/scheduler.py极简示意 from dataclasses import dataclass from datetime import datetime dataclass class ScheduledTask: run_at: datetime payload: dict origin: InboundMessage # 关键把来源消息对象存下来等到时间到了定时任务执行时直接从origin里拿channel和channel_id发消息就能准确回到原来的群。这里我要提醒一句如果你的定时任务要存到 Redis 或数据库里InboundMessage 需要序列化成 JSON 再存取出来时再反序列化。我见过有人存的时候只存了sender_id没存channel_id结果恢复出来的任务只能发私聊发不回群里。序列化时这几个路由字段一个都不能少。4.4 机器人、引用回复、广播三种场景的差异OpenClaw 对不同消息场景的回法也不一样源码里的判断逻辑大致可以分成三类。第一种是私聊消息不管有没有 都直接回给这个用户channel_id就是用户 ID。第二种是群聊里的 消息或引用消息会回给当前群。如果用户引用了某条消息reply_target会被带上回复时就能自动“回复”那条被引用消息如果只是 机器人则reply_target为空正常发到群里即可。第三种是广播类消息比如系统通知、全局广播源码会把它发到配置默认的渠道和群而不是复用任何入站消息的路由信息。这其实是刻意为之——广播不属于任何会话不能硬塞给某个群。我在写 skill 时最喜欢利用的就是reply_target。比如用户引用一条历史消息问“刚才这个结果是什么意思”skill 能拿到被引用的原始消息内容结合当前问题一起丢给模型回答会准确很多。这个字段在源码里完整保留是官方设计的一个隐藏高级功能。5. 实测现场串台、并行失效、回错群的排查实录5.1 排查案例一两个群“串台”的真凶有一次用户反馈A 群在聊项目排期B 群突然开始回复“排期没问题下周三上线”把 B 群的人搞得一脸懵。我第一反应是历史串了打开 A 群和 B 群的会话文件一看果然两个群的 session_id 完全一样都是wechat:user12345。再往前查接入层代码发现适配器在解析群消息时把external_id取成了message.sender.id而不是message.chat.id。这意味着不管用户在哪一个群说话系统都认为他在同一个私聊会话里。修复方法很简单群聊场景下 external_id 改用群 ID。这个案例再次说明接入层解析是否规范直接决定会话隔离是否正确。5.2 排查案例二并发参数拉高后反而变慢另一次是用户把max_concurrency从 8 调到了 32结果所有群平均响应时间从 2 秒变成了 10 秒。表面上看并发高了应该更快实际上他用的是本地 Ollama 模型显卡只有 8G 显存32 个请求同时塞进去GPU 排队严重每个请求都变慢了。排查方式是看运行时日志里的“事件开始处理”和“事件处理完成”两个时间戳。如果发现大量事件几乎同时开始处理但完成时间全部集中在一个很窄的区间之后说明不是网络问题是算力瓶颈。把并发降回 8响应时间立刻恢复正常。这个经验是并发参数不是越大越好得根据模型推理能力和机器配置来定。5.3 排查案例三定时提醒发到了默认群还有一个典型的回错群案例。用户设置了“明天早上提醒我开会”结果第二天提醒没有发到他的私聊而是发到了运营大群。排查定时任务存储发现任务表里只存了remind_content、run_at和user_id完全没有存channel和channel_id。原因是我自己写的一个 skill 在创建定时任务时手动构造了一个新对象只拷贝了部分字段。系统到时间执行时找不到来源渠道只能走广播逻辑发到默认群。修复特别简单创建定时任务时把原始 InboundMessage 的关键路由字段完整复制进任务对象。另外我后来还加了一个校验逻辑创建任务时如果检测不到 channel直接报错提醒避免静默发错群。5.4 问题排查速查表我把自己遇到过的问题整理成一张速查表方便你遇到同类问题对号入座症状可能原因排查方向修复参考群 A 的内容突然出现在群 Bsession_id 被错误拼成用户 ID查看会话目录名和 session_id 生成逻辑群聊 external_id 用群 ID私聊才用用户 ID两个会话历史互相污染全局变量存了历史或上下文检查是否有current_user这类全局变量改用会话级历史文件禁止全局存用户上下文并发提高后响应变慢模型推理或后端成为瓶颈对比开始/完成时间戳调低 max_concurrency检查显存和 CPU同一群消息乱序没有用会话级队列直接全局并发看消息处理日志里的顺序按 session_id 建立独立队列同会话串行消费回复发到了默认群定时任务没有携带路由字段检查任务存储表有无 channel 字段任务对象完整保存 InboundMessage 路由字段图片或文件消息响应不到消息解析丢弃了附件信息看接入层有没有处理非文本消息在 extra 里保留附件 URL 和类型5.5 独家避坑技巧最后分享几个我在多次部署和二次开发里沉淀下来的小技巧这些在官方文档里很难一次性讲全第一调试“串台”问题最有效的办法是开 DEBUG 日志然后只保留两个会话做复现。日志里每条事件都会带 session_id你把两个群的消息各自发一条看日志里 session_id 分别是多少马上能定位是不是 ID 生成的问题。不要同时开十个群复现变量太多很难判断。第二给你的会话文件加一个schema_version字段。OpenClaw 升级会把历史记录格式改掉没有版本号的话旧文件被新代码读出来可能解析失败。我在一次升级后遇到过历史记录全部失效的问题就是因为版本升级后消息结构变了老文件直接读不出来。加了版本号之后至少能在加载时给出清晰提示。第三千万不要用单例保存“当前用户”或“当前会话”。这几乎是所有回错群 bug 的根源。OpenClaw 源码里所有状态都挂在任务上下文或会话对象上你写自定义 skill 时也要养成这个习惯。如果发现自己的代码里出现了self.current_user xxx大概率离串台不远了。第四如果你接了某些第三方 IM 接入网关偶尔会遇到服务端风控或会话残留导致的消息异常。症状是某个会话突然无响应或者所有回复都跑偏。这种时候先别急着改代码重启一下网关连接清理一下会话缓存很多问题就消失了。这不是 OpenClaw 本身的 bug而是外部通道的状态问题。我在实际使用 OpenClaw 的过程中最大的体会是它并没有用什么高深莫测的算法而是把工程上的基本功做得非常扎实。消息路由字段跟着流程走、会话按目录物理隔离、并发用队列和信号量控制这些思路任何一个有经验的工程师都能想出来但真正能把它们组合得这么干净、让普通用户无感知的确实不多。你理解了这三个机制之后再去看它的 skill 开发文档和源码心里会通透很多。接下来我准备在系列第 2 期里拆一拆 OpenClaw 的 skill 编写与工具调用机制尤其是那些能自动剪辑视频、控制浏览器、操作手机的 skill 到底是怎么被大模型调起来的到时候我们继续从源码角度往下挖。