# 企业微信深度优化 **让一个真正能被企业内部群里几十号人用起来的 bot,远不止"接通就行"。** [多渠道接入 → 企业微信](./channels#企业微信) 那一节是把 bot 跑起来;这一篇是把 bot **跑稳**——所有 MateClaw 在企业微信适配层做过的非显然优化、踩过的平台边角,以及为什么这么处理。 阅读对象: - 已经把企业微信渠道连通、想理解"为什么我的群聊体验是这样"的运维 / 一线 - 想加新功能但需要先知道平台限制的开发者 - 想把 bot 推给真实业务团队前做技术评估的负责人 --- ## 平台一句话总结 **企业微信 AI Bot 是个"看起来像聊天 SDK,本质是个事件回调"的平台。** 它给你三种能力: 1. **接收事件** —— 用户在群里 @ bot,平台通过长连接(WebSocket)或 webhook 把消息推过来 2. **回复**(同一会话内)—— 用 `aibot_respond_msg` 把答案"贴"到对应的 frame 上 3. **主动推**(不限于回复)—— 用 `aibot_send_msg` 但**仅限单聊** **最关键的隐藏规则**:第 2、3 条在群聊里是不一样的,单聊里也不一样。下面的所有优化都围绕这个矩阵展开。 --- ## 群聊多用户协作 ### 平台默认行为 群聊里 A、B、C 三个人都在 @ bot,平台会把每个人的消息当一条独立 frame 推过来,但都打到**同一个 chatId** 上。 如果你直接按 chatId 分会话(这是最自然的做法),后果是: - 持久化的对话历史里全是 `user: ...` 没有发送人前缀,模型读历史看到的是一锅粥 - 防抖窗口(500ms / 2.5s 自适应)会把 A 和 B 的连发消息合并成一条 - A 问"我想查 X",B 接着问"我想查 Y",bot 看到的是"用户问了 X 和 Y 两个不相关的事" ### MateClaw 的处理 **两层修复**: **1. 防抖按 sender 切边界。** 同一会话内连续两条消息进来时,先看 senderId: - 同一个人 → 合并(典型场景:粘贴长文被 IM 客户端切片) - 不同人 → 立即 flush 已有 pending,给新发送人开新窗口 代码层面是 [`ChannelMessageRouter.isSameSender`](https://github.com/anthropics/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java)。null 防御:任一 senderId 缺失都不合并,宁可多 flush 一次也不要错串归属。 **2. 持久化 + Prompt 都带 `[@sender]` 前缀。** 群聊(`chatId != null`)的每条 user 消息在落库时和送给 LLM 之前都会被 `applyGroupTag(message, content)` 包一层: ``` [@XuZhanFu] @迈特云的机器人 我想查 X [@xuzf] @迈特云的机器人 我想查 Y ``` 这样: - 第 30 条历史消息也能让模型知道是谁说的 - 持久化的对话时间线读起来像 `[@A] ...; [@B] ...; [@A] ...`,模型能正确处理跟问、引用回复、互相纠错 - 单聊(`chatId == null`)零开销,行为不变 senderName 优先于 senderId(友好),都没有时返回 null(避免 `[@null]` 这种垃圾标签)。 ### 你能观察到什么 ``` [wecom] Sender boundary in conversation wecom:{chatId}: flushing pending from sender=A, accepting new sender=B ``` DB 里 `mate_message.content` 列直接看 `[@xxx]` 前缀。 --- ## 上传约束矩阵 企业微信平台对 bot 上传的媒体有**硬性大小限制**,超限的请求在 **chunk-finish 阶段**被拒(已经传完所有字节才报错),用户体验是"传了三分钟然后什么都没发出来"。 ### 限制 | 类型 | 大小上限 | 格式要求 | |------|---------|---------| | 文件 | **20 MB** | 任意 | | 图片 | **10 MB** | 任意常见格式 | | 视频 | **10 MB** | 任意常见格式 | | 语音 | **2 MB** | **必须 AMR**(其他格式平台拒收) | | 全局 | **20 MB** | 兜底硬上限 | ### MateClaw 的处理 **客户端预检**,避免无效上传。`applyWeComUploadLimits(fileSize, mediaType, contentType)` 在上传前判定结果: - 文件 > 20 MB → 拒绝,告诉用户"超过 20MB 上限" - 图片 > 10 MB → 降级为文件上传(用户在群里能看到附件,只是不再是缩略图) - 视频 > 10 MB → 降级为文件上传 - 语音 > 2 MB **或** mime 不是 `audio/amr` → 降级为文件上传 - 文件 + 任何类型 > 20 MB → 直接拒绝(绝对硬上限) 降级时附带一段说明文字("图片超过 10MB,已转为文件附件发送"),用户立刻知道发生了什么,不会以为 bot 抽风。 ### 智能识别没有 filename 的文件 WeCom 群里转发的文件经常**没有 filename 字段**。落地存成 `file.bin` 的话,下游所有按扩展名 dispatch 的工具(PDF 阅读、DOCX 解析等)会全部失效。 修复:通过 magic-byte 嗅探还原扩展名: - `%PDF` → `.pdf` - `PK\x03\x04` 是 ZIP 容器;进一步 peek 内部条目区分 `.docx` / `.xlsx` / `.pptx` / `.odt` / `.epub` / `.jar` - 其他常见格式(PNG / JPEG / MP4 / MP3 / WAV)都能正确识别 - 实在认不出 → 保留 `.bin`,至少不假装是其他格式 实现在 `MediaTypeSniffer.sniff()` + `MediaTypeSniffer.refineZipKind()`(被 `InboundMediaDownloader.download()` 调用)。 --- ## 引用消息(quote) WeCom 用户引用前一条消息(图片、文件、文本、语音、小程序)然后追加问题,是**最常见的群聊交互模式**。 ### 支持的引用类型 | 引用类型 | bot 看到的 | 是否能进一步处理 | |----------|------------|------------------| | 引用文本 | `[引用消息: 之前的文本内容]\n用户的新问题` | ✅ 文本一并送给模型 | | 引用语音 | `[引用消息: [语音] ASR 转文字]\n用户的新问题` | ✅ 语音 ASR 结果作为上下文 | | 引用图片 | `[引用消息: [图片]]\n用户的新问题` + 图片 attached part | ✅ 视觉模型 sidecar 看图 | | 引用文件 | `[引用消息: [文件: report.pdf]]\n用户的新问题` + 文件 attached part | ✅ 文件 tool 可读 | | 引用混合 | 各子类按上面规则展开 | ✅ | ### 实现要点 - **媒体一并下载**:引用的图片 / 文件不只是个标记字符串,会真的下载、AES-256-CBC 解密、落到 `data/chat-uploads/{conversationId}/...`,然后作为 MessageContentPart 给 agent - **路径一致**:媒体落盘的 conversationId **必须**等于 `mate_conversation` 表里的 conversationId,否则下游 `/api/v1/chat/files/{convId}/{name}` 会因 `isConversationOwner` 查不到行直接 403,前端 `` 显示图裂 历史 bug:早期版本 `inboundConversationId()` 给群聊路径加了 `wecom:group:` 中缀,但 router 持久化时是 `wecom:{chatId}` 没中缀,两边一对不上整批群聊引用图片全部图裂。已修。 --- ## appmsg 消息类型 `msgtype=appmsg` 是 WeCom 给富媒体卡片留的扩展点,常见四种子变体: | 变体 | 实际是什么 | bot 怎么处理 | |------|-----------|--------------| | `appmsg.file` | 转发的文件(PDF / Word / Excel) | 走完整下载 pipeline,等同 `msgtype=file` | | `appmsg.image` | 图片卡片 | 走完整下载 pipeline,等同 `msgtype=image` | | `appmsg.url` | **公众号文章 / 外链** | 见下一节 | | `appmsg.miniprogram` | 小程序 | 把 title 暴露给模型,附件无法获取 | 未知子类型 fallback 成 `[appmsg: title]` 标记,至少模型知道"用户分享了某种富媒体"。 ### 公众号文章 mp.weixin.qq.com 的文章页是**带 captcha-gated SSR 的**,任何 LLM 工具都抓不到正文。如果 bot 假装能读,模型会**凭标题瞎编内容**(生产里观察到:"本文讲了三个要点……" 完全是幻觉)。 MateClaw 在 link 分支检测到 `mp.weixin.qq.com` 后,会自动给模型追加一段提示: > (提示:该链接为公众号文章,正文需要用户在微信内打开后复制粘贴,请优先请用户粘贴正文,不要凭标题猜测内容。) 效果:模型不再编造,主动让用户粘贴正文。其他正常网址(github、维基、随便一个外链)**不**触发提示,因为它们的 body 是普通工具能 fetch 的。 --- ## 群聊主动推送(aibot_send_msg vs aibot_respond_msg) ### 平台规则 ``` 单聊:aibot_send_msg ✓ aibot_respond_msg ✓ 群聊:aibot_send_msg ✗ aibot_respond_msg ✓ (必须绑定一个 inbound frame 的 reqId) ``` 群聊里 bot 任何主动消息(cron 推送、异步任务回推、图像生成完成)都必须**搭一辆顺风车**——绑到一个之前用户 inbound 的 frameReqId 上,否则平台拒收。 ### MateClaw 的处理 **LRU 缓存最近 inbound reqId**。`lastChatReqIds: ConcurrentHashMap` 在每条群聊 inbound 进来时被更新,上限 1000 个 chat。 **统一出口 `sendOutboundFrame(chatId, body)`**: - 缓存命中 → `aibot_respond_msg` + 缓存的 reqId - 缓存未命中 → 降级 `aibot_send_msg`(单聊或新 chat) 这样: - cron 定时摘要 → 群聊有人说过话 → 走 respond 推送成功;从来没说过话 → 降级 send_msg 失败,但至少不会一刀切都失败 - 异步任务(图像 / 音乐 / 视频生成)完成后 → `AsyncTaskMediaDispatcher` 调用统一出口 - 同一条 LLM 回复跨多个 chunk → 同一个 reqId 复用 ### 你能观察到什么 ``` [wecom] Group send via aibot_respond_msg: chatId=..., reqId=... ``` --- ## 异步任务回推 图像生成 (`image_generate`) / 音乐生成 (`music_generate`) / 视频生成 (`video_generate`) / 3D 模型生成 (`model3d_generate`) 都是**异步任务**——agent 拿到 task id 立刻返回,真正的产物 30 秒~几分钟后才出来。 历史问题:产物只出现在 Web 控制台的会话历史里,**WeCom 群里看不到**。 修复:`AsyncTaskMediaDispatcher.forwardToImIfBound(conversationId, parts)`—— - 任务完成后,从 `ChannelSessionStore` 反查 conversationId 绑的渠道 - 跳过 `web` / `webchat`(SSE 已经覆盖) - 调对应渠道适配器的 `sendContentParts(targetId, parts)` - WeCom:image / audio / video / file 全部支持,走原生附件 - Slack:通过 `filesUploadV2` 直传(参考 [Slack channel](./channels#slack)) - 不支持 `sendContentParts` 的渠道(QQ 等):catch UnsupportedOperationException + log,不让一个不支持的渠道卡住整批分发 文件路径默认在 `data/chat-uploads/{conversationId}/`,但当会话的 Agent / Workspace 配置了 `basePath` 时,附件落在 `{basePath}/chat-uploads/{conversationId}/`(解析优先级:Agent `workspaceBasePath` → Workspace `basePath` → 默认目录 `mateclaw.chat.upload.base-dir`)。读取与清理会同时探测新旧位置,迁移前的旧附件仍可访问。serve URL 是 `/api/v1/chat/files/{conversationId}/{storedName}`,前端 / 渠道附件视图都按这个 URL 读。 --- ## 模型行为:假装调用工具 观察:**qwen3.6-plus** 在长上下文 + 工具调用密集的场景下偶发地"懒"——它会用 Markdown 代码块**伪装**自己调了工具,但实际 `toolCallCount=0`: ```` 🎵 《在熟悉的路口》 重新创作任务已提交! ⏳ 生成约需 1-2 分钟,完成后音频会自动推送到对话中... ```json { "prompt": "...", "lyrics": "..." } ``` ```` 后端没拿到 tool_call → 永远不会真的发起音乐生成 → 用户永远收不到歌。 **目前的应对**:换更稳定执行 tool_calls 的模型(kimi-for-coding、claude-sonnet-4.5、deepseek-r1)。在 [模型配置](./models) 里把 agent 的默认模型改掉即可。 未来可能加:服务端检测"任务已提交 + toolCallCount=0"模式 → 自动注入纠正提示重试一次。 --- ## 模型行为:自循环输出 另一种偶发故障:模型陷入"思考-输出"自循环,重复同一段中文回答几十次直到耗尽 max_tokens(16384)。生产上观察到的模式: ``` "Wait, I should X." → 写中文答案 → "Done." → 写同一份中文答案 → "Wait, Y." → 同一份答案 → ... ``` 用户全程看 "生成中..." 等几十秒到几分钟,最后收到一坨重复文本。 ### MateClaw 的处理 **两层守卫**: 1. **检测**:[`hasRepeatingSuffix`](https://github.com/anthropics/mateclaw/blob/main/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java) 探测 buffer 尾部是否被同一个 24~240 字符的 unit 连续重复 4 次以上 → 立即 dispose 上游订阅 2. **去重 + 标记**:`dedupTrailingRepeats` 把已累积的 buffer 尾部 N 份拷贝缩成 1 份;ReasoningNode 把 finishReason 设为 `INCOMPLETE`,前端展示截断卡 + "重新生成"按钮 为什么不无脑发警告就算了:用户已经在 SSE 流里看到那坨重复文本(SSE 单向 push 没法 unsend),但 **DB 持久化** 和 **WeCom 回推** 用的都是 `finalAnswer`——所以 IM 群里只看到一份干净的回答 + INCOMPLETE 提示。 阈值选得**特别窄**(4 次 verbatim 连续)就是为了不误伤合法的"TL;DR / body / TL;DR 三段式"输出。 --- ## 网络层稳定性 ### TLS / Socket 瞬时错误重试 DashScope / OpenAI / 各家 LLM 网关在公网传输中偶发产生: - `bad_record_mac`(TLS RFC 5246 §7.2.2 fatal alert 20) - `SSLHandshakeException` - `SocketException: Connection reset by peer` - `Premature close` / `Broken pipe` 之前这些一旦发生直接给 agent 抛 `LLM 调用失败` 红字,没有重试。 修复:把这些都归类成 `SERVER_ERROR`,走现有的指数退避重试链:3s → 6s → 12s(带 jitter)最多 5 次。详见 [agents 引擎](./agents#错误恢复)。 ### keepalive 群聊回复用 `aibot_respond_msg` 时,平台对**单条流**有 60 秒 TTL——超过 60 秒不发新数据,平台会丢弃这个 stream slot,后续真正的 reply 静默失败。 agent 处理复杂任务(多次工具调用 + LLM 推理)经常超过 60 秒。`WeComKeepaliveScheduler` 每 30 秒往 stream 上发一个 noop "正在处理..." 心跳,slot 永不过期。180 秒兜底强制 finish,避免任务真挂了 keepalive 一直续命。 ### 重连 + 指数退避 WeCom 长连接断开时(NAT 超时、网络抖动),适配器自动重连:2s → 4s → 8s → 16s → 30s 封顶。**永远不会放弃**——只要进程还活着,下次能连上就立刻恢复消息接收。 控制台健康视图能看到当前重连次数,运维心里有数。 --- ## 平台级约束(不是 bug,是限制) 这些是**企业微信平台本身**的约束,没法在代码层绕过,只能配置层规避: ### 数据权限锁 API 模式 bot 在企业微信管理后台勾选**任何一项数据使用权限**(如"读取消息"、"获取群信息"),bot 会**自动锁定为仅创建者可用**。其他成员发消息 bot 不响应。 **解决**:在管理后台**取消勾选**全部 7 项数据权限,bot 即可对所有授权成员可见。MateClaw 通过 webhook 拿消息,不需要这些数据权限。 ### 可见范围 + 数据权限二维矩阵 | 可见范围 | 数据权限 | 实际效果 | |---------|---------|---------| | 全员 | 全部勾选 | **仅创建者**可用(数据权限锁覆盖可见范围) | | 全员 | 全部取消 | 全员可用(推荐) | | 指定部门 | 全部取消 | 指定部门成员可用 | | 指定人员 | 全部取消 | 指定人列表内可用 | ### 群聊里 @bot 才会触发 WeCom 群的 bot 必须被 `@` 才收到消息。私聊不需要 `@`。这是平台行为,没办法绕过。MateClaw 不会在群里 broadcast 监听所有消息(也做不到)。 --- ## 调试技巧 ### 看群聊归属是否生效 ```sql SELECT content FROM mate_message WHERE conversation_id = 'wecom:{chatId}' AND role = 'user' ORDER BY id DESC LIMIT 5; ``` 期望:每条 user 消息都以 `[@username]` 开头。 ### 看媒体落盘路径 ```bash ls data/chat-uploads/wecom:{chatId}/ ``` **不应该**有 `wecom:group:{chatId}` 这种带 `group:` 中缀的目录(早期 bug 残留可以手动清理)。 ### 看群聊回推路径 后端日志里: ``` [wecom] Group send via aibot_respond_msg: chatId=..., reqId=... ``` 如果群聊里 bot 没回复,但日志里看到这行 + reqId 不为空,说明回推到了平台但平台拒收(一般是 reqId 已被消费过、或 bot 已被踢出群)。 ### 看 keepalive 状态 ```bash grep "wecom-keepalive" logs/mateclaw.log | tail ``` 期望看到周期性的 "Heartbeat sent" + "Heartbeat ACK received" / 偶尔的 "force-finished stream" 强制完成。 --- ## 已知 corner case | 场景 | 当前行为 | 后续可能 | |------|---------|---------| | 群里第一条消息就是 cron 推送(chat 还没人说过话) | 缓存里没有 reqId,降级 `aibot_send_msg` 被平台拒 | 加 ring buffer 缓存多条历史 reqId(仅修复有限场景,不上) | | 模型在长会话里"懒"得调工具 | 用户重发 / 换模型 | 加服务端检测注入纠正提示 | | 同一群同时来 3 条不同 sender 的消息 | 串行处理,每个用户独立窗口(生效) | — | | 公众号文章用户拒绝粘贴正文 | bot 礼貌引导用户复制 | — | | OOXML 文档 magic-byte 误判(极小概率) | 退回到 `.zip` | 已通过 ZIP 内部条目 peek 解决 90% 场景 | --- ## 一图概括 ``` ┌─────────────────────┐ │ 企业微信群里的用户 │ └──────────┬──────────┘ │ inbound (含 chatId) ▼ ┌────────────────────────────────────────┐ │ WeComChannelAdapter │ │ ├─ chunk upload pre-check (4 类限制) │ │ ├─ magic-byte sniff (OOXML peek) │ │ ├─ AES 解密 + 落 chat-uploads/{convId}/ │ │ ├─ quote 引用解析(5 子类型) │ │ ├─ appmsg 解析(4 子类型 + 公众号提示) │ │ └─ 缓存 lastChatReqIds[chatId] │ └──────────────┬─────────────────────────┘ │ ChannelMessage(content="[@xxx] ...") ▼ ┌────────────────────────────────────────┐ │ ChannelMessageRouter │ │ ├─ 自适应 debounce (500ms / 2.5s) │ │ ├─ sender boundary 切断(群聊关键) │ │ ├─ applyGroupTag 落库 + 送 LLM │ │ └─ 队列 + sessionLock 串行 │ └──────────────┬─────────────────────────┘ │ ▼ ┌──────────┐ │ Agent │ ← StateGraph + ReAct └─────┬────┘ │ finalAnswer / tool_calls ▼ ┌────────────────────────────────────────┐ │ sendOutboundFrame(chatId, body) │ │ ├─ 缓存命中 → aibot_respond_msg │ │ ├─ 缓存未命中 → aibot_send_msg │ │ ├─ keepalive scheduler (60s TTL 续命) │ │ └─ 重连退避(NAT / 抖动自愈) │ └────────────────────────────────────────┘ ``` --- ## 相关阅读 - [多渠道接入](./channels) — 9 个渠道的总览 + 设置 - [Agent 引擎](./agents) — TLS 重试、错误分类、自循环检测 - [模型配置](./models) — 怎么换默认模型、failover chain - [安全与审批](./security) — 群里执行高风险工具的审批流 - [Doctor 健康检查](./doctor) — 怎么用诊断命令排查渠道问题