多数企微机器人第一版都能跑,第三个需求进来就开始烂 —— 群里要被点名才响应、单聊不用,于是加个 if;群里回复要带上提问人、单聊不用,再加个 if;一个月后每个处理函数里都有四五个分支在判断这条消息是从哪来的。问题不在写得糙,在于一开始没把「消息从哪来」和「这条消息该干什么」分开。这篇讲这条缝怎么切,以及切完之后开发流程该怎么排;接入部分按 wecomapi 的事件回调与发送方式来写,换成别的接入形态,分层结论一样成立。
先定三份契约,再写第一行业务代码
三层结构本身不新鲜:接入层收发、路由层分发、业务层产出回复。真正决定它能不能撑过一年的,不是分了几层,而是层与层之间那三份契约有没有定死 —— 接入层交给路由层什么、路由层交给业务层什么、业务层还回来什么。契约含糊,分层就只是三个目录名。
- 1归一化消息:接入层无论从群还是单聊拿到事件,都必须产出结构完全一致的一条消息,不留任何「群专用字段」。留一个,业务层就会开始读它。
- 2路由决策:路由层只回答「这条消息交给哪个处理函数、带什么参数」。它不碰业务,也不发消息。
- 3回复意图:业务层只描述「要回什么内容、回到哪个会话、要不要点名提问人」,真正的投递由接入层统一执行。
三份契约定死之后,群机器人和单聊机器人的差异就被挤到了接入层和一张策略表里,业务层可以从头到尾不知道自己在群里还是在单聊里。这是「复用同一套逻辑」唯一可行的姿势 —— 靠自觉在每个处理函数里少写两个 if,是撑不住三个需求的。
接入层:会话标识别取错
接入层要做的事情只有一件:把原始事件翻译成归一化消息。用 wecomapi 的事件回调拿到消息后,别让原始字段直接流进业务层。字段名各家不同,但这几类信息缺一不可 —— 回复目标会话的标识、发送者标识、会话类型(群或单聊)、机器人是否被点名、文本或媒体内容、一个可用于去重的唯一标识,以及一个可排序的时间戳。
最常见的一类线上事故:回复目标取成了发送者标识。单聊里这两者近似等价,怎么写都对;群里发送者是群成员、会话是群,取错的结果就是机器人把本该回到群里的答案私聊给了提问人,而且不报错。归一化时就把「回复往哪发」定成一个独立字段,别让业务层去猜。
「是否被点名」也放在这一层解析。群里的机器人通常只在被点名时才该说话,而点名信息在原始事件里可能藏在文本里、也可能在结构化字段里。让接入层把它解析成一个布尔值,顺便把点名前缀从正文里剥掉,路由层拿到的就是一段干净的指令文本。这一步不做,后面每条规则都得自己处理一遍前缀,而且总有人忘。
路由层:三级匹配加一张策略表
路由不需要一上来就上意图模型。按成本从低到高排三级,命中即停:精确指令(固定前缀开头、参数结构确定,走最快的分支)、关键词或正则(覆盖 FAQ 与常见说法)、兜底(交给大模型,或者干脆什么都不做)。绝大多数内部机器人一辈子只用得上前两级,第三级是留给开放式提问的。
群和单聊的策略差异应该只出现在兜底这一档,而且应该写在配置里而不是 if 里:单聊里没命中规则,可以直接交给模型;群里没命中就该闭嘴 —— 群是多人空间,一个每句话都要接的机器人两天就会被踢出去。把「是否允许兜底」「是否要求被点名才响应」「回复要不要点名提问人」这三项做成按会话类型、必要时按具体群可配的开关,路由层读配置,处理函数一无所知。
onEvent(async (raw) => {
const msg = normalize(raw); // wecomapi 事件回调:群 / 单聊 → 同一结构
if (msg.fromSelf) return; // 忽略机器人自己发出的消息
if (await seen(msg.dedupeKey)) return; // 重复投递直接丢弃
const policy = policyOf(msg.chatType, msg.chatId);
if (policy.requireMention && !msg.mentioned) return;
const hit = route(msg.text, policy); // 指令 → 关键词 → 兜底
if (!hit) return;
// 处理函数拿不到发送客户端,只能返回「要回什么」
const reply = await hit.handler(msg, hit.args);
if (reply) await deliver(msg.chatId, reply, policy);
});这段骨架挂在回调的下游,不是回调本身 —— 回调收到事件要先快速 ACK 再入队,路由和业务处理都不该待在 HTTP 请求的生命周期里。骨架里 handler 也没有拿到任何发送客户端,它只能返回一个回复描述。这不是洁癖:一旦处理函数能自己发消息,限流、审计、失败重试、把回复重定向到别的会话这四件事就没有统一的落点了,只能在每个处理函数里各写一遍。示意代码中的字段名与调用形态仅用于表达链路顺序,精确字段、鉴权与端点以 wecomapi 线上文档为准。
四个上线前必须处理掉的坑
自激循环排第一。机器人发出的消息,在不少接入形态下会重新出现在事件流里;不判断来源,它就会看到自己刚说的话、再回一次、再看到、再回一次。单聊里表现为两条消息的死循环,群里表现为几秒钟刷屏几十条。归一化时就打上「是否本账号发出」这个标记,让它成为第一道判断,而不是等某条规则去过滤。
幂等键选错排第二。用「会话加文本内容」做去重看着挺合理,直到两个同事在群里先后说了同一句「重启一下」,第二条被当成重复丢掉了。幂等键要用事件本身的唯一标识;内容哈希只能用来做业务级的防抖,两者解决的不是同一个问题,不能互相顶替。
限流维度排第三。按账号限流在单聊场景没问题,一旦机器人进了几十个群,一个刷屏的群就能把账号级的令牌桶占满,其它群全部静默 —— 而且这种静默不报错,很难发现。限流要按会话维度分桶,账号维度只留作总闸。顺带澄清一个常见误解:这里限的不是费用。wecomapi 按账号订阅、订阅内不按调用次数计费,多发几条不会变成账单;分桶是为了不让一个刷屏的群吃掉其它群的响应,也是为了保护你自己的下游,同时守住共享环境的公平使用策略。
长耗时回答排第四。查库、调模型、跑一次外部检索,都可能超过提问人的耐心阈值。约定超过一两秒的处理函数先返回一条占位回复,拿到结果再补一条。做法上让回复意图支持返回数组而不是单条,这属于契约的一部分,比事后到处补计时器干净得多。
群和单聊,真正的差异只有五处
把差异列全,就会发现它们全都不属于业务逻辑,而属于接入层和策略表。
- 回复目标:单聊回给对方,群回给群。归一化时分成两个字段,别复用。
- 触发条件:群通常要求被点名才响应,单聊默认全量响应。一个策略开关。
- 兜底策略:群里不命中就静默,单聊可以交给模型。同一个开关的另一项。
- 回复形态:群里的回复常需要点名提问人,否则没人知道在回谁;单聊不需要。
- 限流分桶:群按群分桶,单聊按对话人分桶,两者共用一个账号总闸。
业务层的处理函数从头到尾只面对「一段文本、一个发送者、一个会话」,换个来源照样跑,这就是复用的全部含义。如果你发现某个处理函数非知道自己在群里不可,先别加参数,回头查这五条 —— 多半是有一条没落到位。
企业微信机器人开发的推荐排期
- 1按 wecomapi 的接入方式跑通接入层加一个回声处理函数,在单聊里验证收发闭环。这一步只证明三件事:鉴权通了、回调可达、归一化不崩。
- 2把同一个回声处理函数放进一个测试群,验证点名解析、会话标识取值、自激循环。这三件在单聊里一件都测不出来。
- 3加上策略表和三级路由匹配。到此为止还没有任何真实业务代码。
- 4接第一个真实处理函数。前三步写的东西全部与业务无关,也正因如此,它们不会随业务需求变。
- 5补幂等、限流、结构化日志与灰度开关,再放量到真实群。
第 2 步最常被跳过,代价也最大。群里独有的那三个行为 —— 点名、会话标识、消息回流 —— 在单聊环境下无论怎么测都测不出来,而它们恰好是上线后最先炸的三个。花二十分钟建一个只有自己人的测试群,比上线后回滚便宜太多。
常见问题
- 机器人在群里不停地回自己的消息,从哪查?
- 基本可以直接判定是自激循环:机器人发出的消息重新进入了事件流,而没有被识别为自己发出的。检查归一化环节有没有产出「是否本账号发出」这个标记,并让它成为处理链路的第一道判断。临时止血可以先关掉兜底档只留精确指令,循环会立刻停,再从容修归一化。这件事值得在接入当天确认一次:在测试群里手动发一条消息,再看 wecomapi 推过来的事件里有没有本账号发出的那一条,二十秒就能知道这道判断要不要加,不必等上线后靠刷屏发现。
- 路由一定要上意图识别或大模型吗?
- 不必,而且不建议一上来就上。精确指令加关键词匹配能覆盖内部机器人绝大多数请求,可解释、可单测、成本恒定、响应稳定。把模型放在兜底档,只有前两级都不命中才走,既控制成本,也避免常用指令的响应时延被模型拖长。
- 一套代码怎么同时服务多个群、多个账号?
- 把「群」和「账号」当成配置维度而不是代码分支:策略按会话查表、路由规则允许按会话覆盖、限流按会话分桶,处理函数保持无状态。这样加一个群、加一个账号就是加一行配置,不用发版。多账号托管与实例隔离的具体能力以 wecomapi 文档为准。
准备好动手了?
精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。
