WeClaw 的消息路径全景:一条消息的六种旅程
把微信、API、定时任务、多 Agent 协同这些入口摊开看一遍——你会发现这个"微信 AI 桥"本质上是一台消息路由器。本文按代码实现逐条梳理 WeClaw 现在的消息路径,并给出每条路径的设计取舍。
一、总览:消息从哪来,到哪去
WeClaw 的定位是"把微信接到 AI Agent"。但跑起来之后,它其实同时承担了六件事:
入站 出站
┌─────────────────────────────┐ ┌──────────────────────────┐
│ ① 微信消息(ilink 长轮询) │ │ 微信回复(文本分片/媒体) │
│ ② API 反代(/v1/chat/...) │ ────────► │ API 响应(JSON / SSE) │
│ ③ 定时任务(cron / timer) │ Agent │ relay 推送(问答对) │
│ ④ 主动推送(/api/send) │ 执行 │ 自动归档(archive) │
│ ⑤ 多 Agent(debate/roundtable│ │ 发布(博客/知识库/播客) │
│ ⑥ 媒体(图片/文件/语音) │ └──────────────────────────┘
└─────────────────────────────┘
下面逐条拆。
二、微信主路径:HandleMessage 的六道闸门
微信消息由每个账号一个的 monitor 长轮询拉取(cmd/start.go 里按账号启动),拿到消息后统一进入 Handler.HandleMessage。这个函数是一条六级瀑布,每级按优先级拦截:
| 级别 | 判断 | 去向 |
|---|---|---|
| 0 | 消息类型/状态过滤 + message_id 去重 | 丢弃(语音会重复推 finish) |
| 1 | shell 模式(用户 /sh 进入后) | 直接执行白名单命令,不进 AI |
| 2 | 纯 URL | 存 linkhoard;微信公众号文章额外送分析队列 |
| 3 | 「重试」/ /retry | 重发最近一次失败的发布任务 |
| 4 | / 开头且命中命令表 | /info /new /save /hub /debate /publish… |
| 5 | @agent 前缀 | sendToNamedAgent(指定 agent,跳过意图路由) |
| 6 | 媒体附件 | sendMediaToAgent(自动挑 vision agent 或 draw) |
| 7 | 其余全部 | sendToDefaultAgent(默认入口,见下节) |
这个顺序是有讲究的:越靠前的闸门越"确定"。用户敲 / 命令时不需要 AI 参与;URL 保存不需要 AI 参与;而只有第 7 级才是"让 AI 理解意图"的地方——把不确定性收敛到最后一站。
三、默认入口:意图分发的五层路由
sendToDefaultAgent 是所有"普通对话"的总入口,也是最近改造最集中的地方。它内部是五层路由:
第 1 层:生图/改图改道
routeImageIntent —— 命中"画一张""把图改成蓝色"这类词时,把 agent 改道到 draw,并注入图片上下文(最近发的图、画布路径、多图融合)。
第 2 层:闲聊短路
isDefiniteChitchat —— "你好""谢谢""👍"这类极高置信寒暄直接对话,连意图决策都不做。词表取反设计(只收"确定闲聊"),与"确定意图"的正向词表天然不冲突。
第 3 层:意图代理(核心)
对于没有原生 function calling 能力的 agent(NoFC:无 MCP 或走 mcp_text 文本协议,典型如 dsfree),调用 gemini 作为意图代理:
dec, ok := h.routeIntentWithAgent(ctx, router, userID, text, cachedSummary)
// dec.Intent ∈ {search, publish, image_draw, image_edit} 或未调用工具=chat
实现上是一次带 route_decision 工具的 chat/completions 请求——gemini 用原生 function calling 返回结构化决策,参数零解析。三个关键设计:
-
chat不是枚举值,而是"模型没调用工具"本身——工具不存在即意图不存在,最高频路径零协议开销; -
决策失败降级到收紧词表(子句首约束 + 否定过滤 + 动作连词),绝不把"猜"当主路径;
-
决策结果按意图分发:search → 检索后注入事实再交回本尊;publish → 执行发布;image → draw。
第 4 层:降级链(意图代理不可用时)
matchTriggerStrict(子句首约束)/ routeTwoPhaseToolCallStrict(两段式)/ isShortSendIntent。这层是安全网,不是主路径。
第 5 层:有原生 FC 的 agent
直接进工具循环,模型自主决定调什么工具,不需要意图代理代劳。
四、API 路径:从"直通管道"到"也懂意图"
WeClaw 暴露两个 OpenAI 兼容入站端点:
-
POST /v1/chat/completions(标准 OpenAI 格式) -
POST /api/ask(简化格式,{"prompt": "...", "agent": "..."})
两者都进 handleAsk,然后按目标 agent 的类型分流:
| agent 配置 | 处理器 | 行为 |
|---|---|---|
| web_search: true | proxyResponsesSearch | 走 /v1/responses + web_search 工具 |
| 有 MCP servers | proxyMCPChat | function-calling 循环(原生 FC / Responses / 文本协议) |
| 无 MCP(NoFC) | tryIntentRoutingForNoFC(新) | 补意图路由,失败则纯转发 |
| 非 HTTP(acp/cli) | handler.Ask / AskStream | 走本地 agent 进程 |
这里有一个刚补上的重要缺口。 原来 API 路径是"透明管道"——直接把请求转发给上游 agent。但无工具能力的 agent(如 dsfree)在 API 端因此既不能搜索也不能发布:意图代理只挂在微信路径上。
修复方式是让 API 路径复用同一套意图代理,但对 search 采用**"注入后转发"**而非"代答":
case "search":
facts, _ := s.handler.CollectFactsForAPI(...)
injected := injectFactsIntoBody(bodyBytes, facts) // 把事实追加到最后一条 user 消息
return injected, false // 继续转发,不劫持
理由很实际:API 客户端(pi、opencode、OpenAI SDK)要的是流式输出 + 该 agent 自己的风格。如果 weclaw 自己生成回答,这两点都会丢。注入事实后原样转发,agent 基于事实作答——既拿到了实时信息,又保住了它的人设。
而 publish / image 这类"动作型"意图则直接执行,打包成标准 chat.completion 返回。
五、Agent 执行层:三种进程模型,四种工具协议
消息路由决定"谁来处理",Agent 层决定"怎么处理"。WeClaw 支持三类 agent:
| 类型 | 通信 | 进程模型 | 典型场景 |
|---|---|---|---|
| ACP | stdio JSON-RPC 长连接 | 常驻子进程 | Claude Code、Codex、Hermes |
| CLI | 每消息启动 + --resume | 逐条新进程 | claude -p、codex exec |
| HTTP | OpenAI 兼容 REST | 无本地进程 | 各种 API 模型、自建反代 |
HTTP 型内部又按上游能力分四种工具协议(SelectMCPChat 统一裁决):
-
原生 function calling(chat/completions)——支持 FC 的模型直接调工具;
-
Responses API——Poe/DeepSeek 等只在
/v1/responses暴露 FC 的上游; -
文本协议(
mcp_text)——上游完全不支持 FC 时的兜底:工具清单进提示词,模型用tool_call围栏表达,weclaw 解析执行; -
无工具——纯对话。
这个分层是 WeClaw 能在"免费/反代/官方"各种七拼八凑的上游池里稳定工作的关键:能力探测 + 协议降级。
六、多 Agent 与定时:把"一条消息"变成"一场协作"
除了单 agent 对话,命令表里还有三条多 Agent 协同命令(注意:它们属于"命令路径"的子路由,不是独立入站路径):
-
/debate:多个 agent 就一个议题辩论; -
/roundtable:圆桌讨论; -
/workflow:按工作流串行调度多个 agent。
另外有失败恢复机制(不属于协同,但同属"一条消息的后续旅程"):发布/发送失败时用 recordFailedTask 把任务落盘,用户说「重试」或 /retry 即可重放——这正是第二节六级瀑布的第 3 级闸门。
定时侧有两条:
-
cron:周期性任务,支持三类 job——
text(定时发文本)、agent(定时让 agent 以某用户上下文作答)、workflow(定时跑工作流); -
timer:一次性延时提醒。
定时任务和微信消息共用 agent 与会话缓存——所以 cron 触发的 agent 回复会出现在该用户的微信里,就像他自己问的一样。
七、出站:回复不止一种形态
一条回复的出口也有多条:
| 出口 | 说明 |
|---|---|
| 微信回复 | 长文自动分片;图片/文件走 CDN 上传后发送 |
| API 响应 | 非流式 chat.completion;流式 SSE(含"上游忽略 stream 时包装成 SSE"的兜底) |
| relay | 把问答对推送到远端(博客/知识库/剪贴板) |
| archive | 自动归档到按日期分目录的归档区 |
| 发布 | publisher MCP:publish 工具支持 blog / ima / nas / knowly / kb 五个目标 |
发布这条值得单说:publish 是一个 MCP 工具,由 executor 池的 agent 调用。它的五个目标各有独立契约——博客走 REST /api/publish,IMA 笔记走 /api/ima/import,播客走同一 REST 的 targets:["nas"],Knowly 走 multipart 上传,而 IMA 知识库走官方 openapi 的 create_media → COS → add_knowledge 三步链路。
八、一张表看完
| 入站路径 | 入口 | 是否过意图路由 | 典型用途 |
|---|---|---|---|
| 微信消息 | HandleMessage | ✅(默认分支) | 日常主入口 |
| 微信 @agent | sendToNamedAgent | ❌(用户已指定) | 点名某个模型 |
| 微信命令 /xxx | 命令表 | ❌ | 管理操作 |
| 微信媒体 | sendMediaToAgent | 部分(生图走 draw) | 看图/改图 |
| API 反代 | tryProxyToAgent | ✅(NoFC agent) | pi/opencode/第三方客户端 |
| 定时任务 | cron / timer | ❌(直接进 agent) | 定时提醒/报告 |
| 多 Agent 协同(命令触发) | /debate /roundtable /workflow | ❌ | 协作任务 |
注意层级:前四行都是同一条微信消息在
HandleMessage里按优先级落进的子路由(一条消息只走其中一条);最后一行则是第三行"微信命令"的子路由。真正并列的入站来源只有四个:微信、API、定时、以及出站用的/api/send。
九、设计心得
梳理完会发现几条反复出现的设计原则:
1. 不确定性收敛到最后一站。 命令、URL、媒体这些"确定"输入在前置闸门就地处理;只有真正需要理解的自然语言才进意图路由。这让绝大多数消息的路径是可预测、可测试的。
2. 意图判断交给擅长判断的组件。 用 gemini 的原生 function calling 做意图决策,而不是 weclaw 用正则猜;正则退居安全网。字面上是"多一次模型调用",实际是把误判率从"口语词命中即劫持"降到"模型理解上下文"。
3. 注入优于代答。 需要外部信息时,把事实注入上下文再交给本尊作答,而不是让路由层自己生成回答——保住流式、保住风格、保住职责边界。
4. 能力探测 + 协议降级。 上游五花八门(原生 FC / Responses / 文本协议 / 无工具),同一份配置能在不同能力的上游上跑起来,靠的就是"探测能力、选择协议、失败降级"。
5. 每条路径都有安全网。 意图代理失败 → 收紧词表;MCP 循环失败 → 纯转发;发布失败 → 落盘待重试。没有一条路径会把消息"吞掉"。
本文基于 WeClaw 当前代码实现梳理,含 2026-09-10 的意图路由改造(意图代理 + API 路径接入)。
修订说明(二次发布):修正首版把「pending task」误列入「多 Agent 协同」的分类错误——它是失败重试机制(HandleMessage 第 3 级闸门),与多 Agent 协同无关;同时补充了"入站来源 vs 子路由"的层级说明。