最近在把微信接到 OpenClaw,想做一个真正能在微信里使用的 Agent 入口。
这件事看起来好像只是:
收消息 -> 调 OpenClaw -> 发回去
但实际做起来,坑比想象中多得多。
下面我把目前踩过的 7 个最典型的坑总结一下,给后面要做类似事情的人少踩一些雷。
坑 1:回调打通不等于功能可用
这是最容易误判的地方。
很多人只要看到:
/wechat/callback能收到 POST- 消息能打印出来
- 日志里有内容
就以为这件事差不多完成了。
实际上,回调打通只代表:
入口层能接收到数据。
而真正决定系统是否可用的,是:
- 回调解析对不对
- 群消息识别对不对
- 自己发的消息有没有过滤
- session 是否合理
- 并发模型会不会乱
也就是说,“能收到消息”和“这个系统能用”,中间还隔着很多细节。
坑 2:微信群消息不能按私聊逻辑来理解
这类接口里,群消息最容易写错的地方,是把“群 ID”和“真实发送人 ID”混在一起。
我一开始也踩了这个坑,后来才按回调结构拆清楚。
正确判断群消息的逻辑类似这样:
is_group = from_user.endswith("@chatroom") or to_user.endswith("@chatroom")
而真实发送人有时候还要从内容里拆:
if is_group and raw_content and ":\n" in raw_content:
possible_sender, possible_text = raw_content.split(":\n", 1)
if possible_sender.startswith("wxid_"):
sender_wxid = possible_sender
actual_text = possible_text.strip()
如果这块一开始没处理好,后面:
- 白名单判断会乱
- 群上下文会乱
- 机器人会把群 ID 当成人 ID
- session 会错得很离谱
坑 3:不忽略自己发的消息,机器人会自回环
这件事看起来小,但真的很致命。
如果你不做这层判断,机器人就会这样:
- 收到用户消息
- 调 OpenClaw 得到回复
- 发回微信
- 又收到自己刚刚发出去的回复
- 再把这条回复当成新消息送给 OpenClaw
最后形成一个非常难排查的自回环。
所以我后面强制加了这一步:
is_self = bool(wxid and from_user == wxid)
if parsed.get("is_self"):
return {"status": "ignored_self"}
这一步不做,后面一切都不稳。
坑 4:只要底层还是 CLI,每条消息都会有冷启动成本
我一开始的调用方式很直白:
cmd = [self.bin, "agent", "--session-id", sid, "--message", message.strip()]
res = subprocess.run(
cmd,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=self.chat_timeout
)
逻辑没问题,但体感问题非常明显。
因为它意味着每条消息都在做这些事情:
- 起一个新进程
- 恢复 session
- 加载 provider
- 初始化环境
- 请求模型
- 退出
这就是为什么很多时候:
- OpenClaw 前端 10 秒左右能出结果
- 接到微信里,可能就拉到 20~30 秒甚至更久
所以如果你也想做这种接入,最好先接受一个事实:
微信这层可以优化,但只要底层还是 OpenClaw CLI 单次调用,延迟就很难像原生前端那样顺滑。
坑 5:session_id 不是随便拼就行
我一开始为了看着直观,session_id 设计成这种:
wechat:dm:wxid_xxx
wechat:group:chatroom_xxx
wechat:group:chatroom_xxx:user:wxid_yyy
结果后面很快就踩到兼容性问题,有些场景下会报:
Invalid session ID
所以最后我把它收敛成更保守的格式,只保留字母、数字、下划线、短横线:
def build_session_id(chat_id: str, sender_wxid: str, is_group: bool, config: dict) -> str:
def norm(s: str) -> str:
return re.sub(r"[^a-zA-Z0-9_-]", "_", str(s or "").strip())
if not is_group:
return f"wechat_dm_{norm(chat_id)}"
if config["GROUP_SESSION_MODE"] == "per_user":
return f"wechat_group_{norm(chat_id)}_user_{norm(sender_wxid)}"
return f"wechat_group_{norm(chat_id)}"
这一步虽然小,但能省掉很多奇怪的会话问题。
坑 6:不要一开始就接所有消息类型
微信消息类型很多:
- 文本
- 图片
- 语音
- 文件
- 名片
- 引用
- 小程序
- 系统通知
- 撤回
- 拍一拍
如果你一上来就想全吃,复杂度会直接爆炸。
我后来很快就收敛到一个策略:
第一阶段只处理文本消息。
代码里也直接限制:
if msg_type != 1:
logger.info("📭 暂时只处理文本消息,已忽略 MsgType=%s", msg_type)
return {"status": "ignored_msg_type"}
这么做的好处很明显:
- 回路先跑稳
- 日志更好看
- 逻辑更容易定位
- 后续再逐步放开图片、文件等能力
坑 7:如果没有并发设计,群一热闹就堵住
只用一个全局队列 + 一个 worker,是很多 demo 项目的默认写法。
但只要一进群聊场景,它的问题就会立刻暴露:
- 所有会话都串行
- 一个慢请求拖垮后面所有消息
- 用户体感会非常差
所以我后来改成了:
- 不同 session 可以并发
- 同一个 session 固定路由到同一个 worker
- 保证顺序的同时提升吞吐
核心逻辑:
def shard_index_for_session(session_id: str, worker_count: int) -> int:
h = int(hashlib.md5(session_id.encode("utf-8")).hexdigest(), 16)
return h % worker_count
然后:
worker_queues[shard_idx].put_nowait(task)
这一步非常重要,它本质上决定了这个系统是“玩具”,还是“入口网关”。
我后来做了哪些修正
把这些坑踩完以后,我后面做了几件真正有价值的改动:
1. 首次运行改成命令行初始化
不让用户先打开配置文件手改。
2. 自动生成带注释配置文件
方便后面自己继续改参数。
3. 加白名单
私聊不是谁都能直接触发。
4. 加群触发词
避免群里所有消息都打进来。
5. 默认关闭“正在处理中”
不然体验很像机器人在刷屏。
6. 失败提示收口
不要把 OpenClaw 的底层报错直接吐给最终用户。
我现在的判断
把微信接到 OpenClaw,绝对不是不值得做。
但它真正难的地方,从来不是“把接口接通”,而是:
把这条链路做到像一个产品,而不是一个脚本。
如果你现在也在做类似事情,我最想给的建议是:
不要一开始就追求“全功能”,先把最小稳定闭环做出来。
只要这个闭环稳定了,后面的能力都可以继续叠加。
最后
我现在更愿意把这件事看成:
- OpenClaw 负责能力层
- 微信负责入口层
- 网关负责把两者真正接起来
而不是简单地理解成“消息转发脚本”。
如果你也在做这一类方向,欢迎交流。