我们团队用 Java 给企业微信做了个 AI 客服机器人:从 Hook 截获消息到 DeepSeek 自动回复,全链路架构公开

一个小团队,三套代码,约 17 万行,把「企业微信机器人」从一句口号做成了一个能 7×24 小时接客、答不上来会摇人、全程不骚扰客户的 AI 客服系统。这篇文章不讲概念,只讲我们真实上线的架构、协议和踩过的坑。

关键词:企业微信、AI 客服、DeepSeek、Netty、Xposed、SCRM、大模型应用

0. 先说为什么要自己造

我们团队做 To B 服务,客户全在企业微信里。运营一段时间后,几个问题反复出现:

  • 客服下班后没人回消息。客户晚上 10 点问的问题,第二天早上才回复,早跑去别家了;
  • 同一个问题答 100 遍。价格、安装、售后政策,纯重复劳动,新人还总答错;
  • 客服离职,客户跟着走。聊天记录和客户关系全在员工个人企微号上,人走茶凉;
  • 官方 API 能力有天花板。官方接口对群发、触达频次限制很死,而「接管自己员工的企业微信」这件事,官方根本不开放。

市面上的 SCRM 我们买过、也调研过:走官方 API 路线的,能力受限;走协议路线的,本质是个黑盒——你不知道它什么时候封你的号,数据还全在别人服务器上。

所以我们自己做了一套。定位很清楚:企业自有设备、自有账号、自有数据,机器人只是在员工的企业微信里帮忙干活。整体三端:

  • 安卓端:一个注入企业微信 App 的自动化模块(约 4.8 万行 Java),负责消息感知和动作执行;
  • 服务端:Spring Boot 3 + Netty,约 1000 个 Java 类,负责设备接入、业务逻辑和 AI 大脑;
  • 管理后台:Vue 2,约 11 万行,其中有一个 1.3 万行的「企微中控」页面,人机共用一个工作台。

这篇文章以「客户发来一条消息,AI 自动接待,答不上来转人工」这条最核心的链路为主线,把三端串起来讲。

1. 全局架构:一条消息要经过的 5 个世界

Java 服务端

安卓端 · 注入进程

消息

Protobuf / TCP 长连

HTTP

1070 发消息指令

下行指令

调用企微内部 API

webhook 企微群机器人

HTTP / WebSocket

客户微信

员工手机上的
企业微信 App

Hook 消息事件

AIDL 跨进程
优先级队列

Netty 接入层

消息分发器
20000 缓冲队列

业务处理
落库/规则/SOP

AI 编排服务
聚合+大模型+转人工

DeepSeek /
兼容协议模型

值班客服群

Vue 中控后台

协议是我们自定义的一套 Protobuf 消息信封,167 种消息类型,长这样:

message TransportMessage {
    int64  id          = 1;  // 消息ID(雪花算法)
    string accessToken = 2;  // 认证后颁发的通道令牌
    int32  msgType     = 3;  // 消息类型, 如 1010认证 / 1026消息 / 1070发消息
    Any    content     = 4;  // 具体消息体, 167种之一
    int64  refMessageId= 5;  // 关联的上行消息ID
}

几个贯穿全文的协议号先混个脸熟:

协议号方向含义
1010 / 1011↑↓设备认证请求 / 响应
1020 / 1021↑企微上线 / 下线通知
1026↑聊天消息上行(客户发来的消息走这里)
1028↑发消息任务结果回执
1070↓下发「发消息」任务
3094↓接待开关(是否订阅客户消息)
1001双向心跳(同一个码,服务端主动发起)

2. 「机器人」的身份模型:设备是设备,微信号是微信号

在讲消息之前,必须先讲清楚我们踩过的第一个大坑:连接身份和业务身份必须分离。

一台安卓手机是一个物理设备(可能被 root、装了注入框架),手机上登录的企业微信账号是业务身份。用户会换手机,也会在同一台手机上换号登录。如果把这两者绑死,换一次设备数据就全乱了。

我们的模型:

  • 设备凭证:一台物理设备一个,管连接、踢线、授权计费。首次认证随机生成 DEV_xxxx 的设备码,物理设备标识用 APP 上报的 IMEI / android_id 回填;
  • 业务身份:企微账号 ID(wechatId),所有客户、群、聊天记录归属于它。换号 = 同一设备凭证下换一个业务属性,而不是换一个连接身份。

认证握手(协议 1010)由 APP 在 TCP 连上后立即发起,请求里三个关键字段:

username   = 租户登录用户名     // 用来定位这是哪家企业的设备
credential = 企微账号ID         // 白名单正则 ^[A-Za-z0-9_-]{1,64}$ 清洗
clientValue= 设备指纹(IMEI等)   // 同样做正则清洗, 防注入

这里有一个真实的事故教训。认证逻辑里我们要查用户、查租户、建设备、排同步任务,最初整个方法包在一个事务里。结果 8 月份某天凌晨服务重启,上万台设备同时重连,认证风暴把数据库连接池打满了——事务在持有 DB 连接期间还夹杂网络 I/O,连接迟迟不归还。

现在的做法:

  1. 认证入口限流 100 次/秒,超了直接关连接,APP 5 秒后重连——刻意把万级并发重连打散;
  2. 认证入口方法不加事务,只在必要的写库片段上开短事务;
  3. 需要下发的同步指令全部放到事务 afterCommit 之后执行。

APP 侧的重连也配合做了错峰:固定 10 秒延迟 + 按设备 hash 散列 0~30 秒抖动,避免大家卡点同时连进来。

3. 上行:一条客户消息怎么从企微走进 Java

3.1 Hook:在企业微信自己的进程里「听墙角」

安卓端的注入框架是一套基于 Zygisk / Riru 的 Xposed 兼容实现(懂行的同学知道这是 LSPosed 一脉的技术),注入目标是企业微信主进程 com.tencent.wework,我们适配的版本是 5.0.11.81206。

核心思路:企业微信收到新消息时,内部一定会回调它自己的消息监听器。我们 Hook 的就是会话引擎的消息回调(混淆后的 Conversation$2.onAddMessages),在回调里拿到消息对象,反射解析出发送人、内容、类型、引用关系,再交给 16 个消息类型处理器(文本/图片/语音/视频/链接/名片/小程序……)统一转成我们自己的 Bean。

为什么不直接截屏、点屏幕那种「无障碍服务」方案?因为那种方案本质是模拟盲人操作手机,慢、脆、拿不到消息内容、还被官方点名为外挂。Hook 是在进程内部直接调用对方的内部 API,消息是结构化的,这是两个时代的技术。

3.2 三进程架构:别把人家的 App 搞崩

Hook 代码跑在企业微信进程里,但网络连接、重连、队列这些重活绝不能放那儿——你 Hook 进去的每一行代码崩了,崩的都是企业微信,客户直接看到闪退。

所以我们做了三进程隔离:

进程3: :remote 看门狗

进程2: com.easy.crm 主控

进程1: com.tencent.wework 企微

AIDL bindService

监控拉起

am start 兜底拉起

Hook 监听器 + 消息处理器

AIDL 服务
200条优先级队列
任务结果 > 消息 > 会话 > 同步

Netty TCP 长连客户端
心跳/重连/鉴权

双进程互绑保活 + ANR检测 + 重启兜底

  • 企微进程只做「感知」和「执行」,通过 AIDL 把消息绑给主控进程,队列分了优先级——任务回执比消息重要,消息比批量同步重要;
  • 主控进程持有到服务端的 TCP 长连,断连时本地缓存,指数退避重连;
  • 看门狗进程干两件事:双进程前台服务保活,以及检测企微是不是「假死」了。

假死检测是个很实战的设计:Hook 层每 5 秒会回一个 pong 作为企微进程真实健康信号。连续 6 个周期(30 秒)收不到 pong,即使 TCP 心跳还正常,也判定企微卡死,root 权限强杀重启——因为心跳只能证明我们自己的进程活着,证明不了企微没 ANR。

3.3 Netty 接入层:万台设备的连接怎么扛

服务端 Netty 用了 boss / worker / business 三组 EventLoopGroup,业务处理全部丢到独立业务线程池,慢业务绝不允许污染 IO 线程。Pipeline 是标准的 Protobuf 帧编解码:

IdleStateHandler(120s读空闲)
→ ProtobufVarint32FrameDecoder
→ ProtobufDecoder(TransportMessage)
→ LengthFieldPrepender + ProtobufEncoder
→ NettyServerHandler(业务线程组)

上行消息进来后不直接处理,而是进 MessageDispatcher:

  • 一个 20000 容量的阻塞队列把 Netty IO 线程和业务线程池隔开;
  • 单独的消费线程每秒最多向业务池提交 500 条,把设备端突发上报削成数据库扛得住的匀速;
  • 一个协议号可以挂多个处理器(注册表多播),比如聊天消息既要落库、又要触发自动回复、还要推 WebSocket;
  • 队列满了直接丢并记降级日志——对客服场景来说,过载时丢一条「正在批量同步的群列表」远好过整个集群被拖死。

另外两个保命细节:

  • 写缓冲水位线背压:高低水位设 32KB / 256KB,通道积压到高水位就 setAutoRead(false) 停止读,防止慢消费设备把服务端内存堆爆;
  • 心跳由服务端主动发起:每 30 秒下发心跳包,120 秒没响应掐线重连;设备数超过 500 台后分 9 片轮扫,控制单次扫描成本。

4. AI 大脑:这套系统最难的部分

消息落库之后,进入这篇文章的主角:AiAutoReplyService。

先说结论:把大模型接进客服场景,最难的根本不是调 API,而是「什么时候答、答什么、什么时候闭嘴、怎么优雅地把人摇来」。

4.1 消息聚合:客户说话是「一段一段」的

真实的微信对话长这样:

客户: 在吗
客户: 问一下
客户: 你们那个充电桩功率多大的

如果来一条调一次大模型,会发生三件事:① 回复三条,像个傻子;② token 费用 ×3;③ 前两条上下文不完整,模型只能反问。

所以我们做了静默窗口聚合:按「会话 + 发送人」缓冲消息,等客户停下来再整体结算成一个问题。

  • 群聊窗口 10 秒、单聊 5 秒(按租户可配);
  • 自适应提速:消息以 ??!!。 结尾,说明问题完整了,窗口缩到 3 秒,大多数客户不用陪跑满全程;
  • 30 秒封顶:总有话痨能一直输入,连续输入最长只等 30 秒强制结算,防止窗口无限续期。

缓冲不放在内存,而是写 Redis List(TTL 30 秒)。为什么?结算调度器万一重启,内存里的消息就丢了;Redis 里的还在,新消息到达会重新挂调度——最多晚回复,绝不丢消息。

结算时也不是一把删掉整个 List,而是 leftPop 逐条取;pop 的过程中如果又有新消息压进来,1 秒后挂一个「救援结算」兜底。这些都是被竞态教训出来的。

4.2 让大模型输出「程序能用的结果」:四选一 JSON 协议

普通玩家让大模型吐一段话,我们要求它必须吐严格 JSON,因为回复之后的程序动作需要机器可判定:

private static final String BASE_PROMPT =
    "你是微信社群的客服助手,现在代表运营者回复客户消息。回答要求:\n" +
    "1. 必须用中文,语气自然亲切,像真人客服一样口语化\n" +
    "2. 回复简洁,通常1~3句话,适合微信聊天场景,不使用任何markdown格式\n" +
    "3. 优先且仅依据下面提供的业务知识库回答;知识库没有的内容绝不编造价格、政策、承诺\n" +
    "4. 不透露你是AI或模型相关信息\n" +
    "5. 每次回复必须输出严格的JSON对象,格式四选一:\n" +
    "   {\"type\":\"normal\",\"reply\":\"给客户的回复内容\",\"confidence\":0.9,\"sourceIds\":[1,3]}\n" +
    "   {\"type\":\"ignore\"}\n" +
    "   {\"type\":\"handoff\",\"reply\":\"给客户的过渡话术\",\"summary\":\"客户问题一句话摘要\"}\n" +
    "   {\"type\":\"urgent\",\"reply\":\"给客户的应急话术\",\"summary\":\"紧急情况摘要\"}\n" +
    // ……四种 type 的语义说明
    "6. 结合「业务场景」理解客户问题的行业语境……";

四种类型对应四条程序分支:

type含义程序动作
normal能依据知识库回答检查置信度后下发回复
ignore闲聊、寒暄、表情、与业务无关什么都不发,不花一次 token 陪聊,也不拿通用知识瞎编
handoff需要人工先发过渡安抚话术,再通知客服,AI 进入静默
urgent紧急情况先发客户当下可执行的应急指引,再最高优先级通知客服

注意 normal 里的两个字段:confidence 是模型自评的正确率把握,低于 0.6 一律转人工,不硬答;sourceIds 记录引用了知识库哪几条,运营可以审计「这条回复是有依据的还是模型自己发挥的」。

企业客服场景,幻觉是不可接受的。 客户问价格,模型编一个数字出去,损失的是真金白银。所以我们的系统提示词里写死了「知识库没有的内容绝不编造价格、政策、承诺」,再加一层置信度兜底。

这里有一个非常关键的设计决策:系统提示词里只放与行业无关的协议约束,不放任何业务判定标准。 什么问题算紧急、什么语气该转人工,全部由租户自己配置的「业务场景」和知识库决定。同一个系统,卖充电桩的客户和卖化妆品的客户,AI 的判定标准天然隔离——每个租户用自己的 API Key、自己的 baseUrl、自己的模型(DeepSeek / 通义 / Kimi / GPT 都行,OpenAI 兼容协议),平台不提供共享 Key,费用和数据都归租户。

4.3 知识库:全量文档 + 相似度召回 FAQ

租户在后台维护两类知识:文档类(产品说明、售后政策)全量塞进 system prompt;问答类(FAQ)按当前聚合问题做相似度召回,只取相关的。历史对话默认带最近 10 条,单条截断 500 字,防止长文撑爆上下文。

4.4 转人工:先安抚客户,再摇客服

这是我们和 demo 级 AI 客服最大的区别。转人工不是弹个日志完事,而是一个完整闭环:

值班客服企业微信群机器人AI客服客户值班客服企业微信群机器人AI客服客户该客户进入静默期, AI不再抢答连续追问/问了知识库外的问题handoff/urgent 或 置信度<0.6先发过渡话术(1~2句, 告知专人马上来)webhook: text(@值班人) + markdown详情卡片群内@提醒, 卡片带客户问题+AI摘要还在吗?(静默期追问)催促话术 + 再次@客服(5分钟节流)客服到场回复检测到人工发言, 结束静默, AI待命

几个细节值得单独说:

第一,必须先给客户回话,再通知客服。 绝不能客户屏幕上晾半天没人理。过渡话术由模型现场生成(handoff 是安抚,urgent 是可执行的应急指引),生成失败有模板兜底——转人工可能恰好是因为 AI 服务挂了,兜底逻辑不能反过来依赖 AI 活着。

第二,静默是「用户级」的,不搞连坐。 静默期的 Redis key 是 ai:silent:{机器人}:{会话}:{触发用户wxid}。群里 A 触发了转人工,只有 A 的消息 AI 不抢答,B 正常提问照常回答。这个 bug 我们真踩过——早期 senderId 没随聚合消息持久化,key 串到群维度,结果一个人转人工,全群沉默。

第三,静默期客户追问,不能装死。 客服还没认领时,客户追问要回一句催促话术,并且 @值班客服:

private static final String URGE_REPLY =
    "人工同事正在加急赶来,请再稍等一下,马上就有人跟进您的问题~";
// 同一个客户 5 分钟内最多发一条催促, 用 Redis 原子计数节流, 防客户刷屏时轰炸群聊

第四,客服怎么算「认领」? 不需要客服点什么按钮——值班客服在客户群(或通知群)里开口说话,或者在中控后台手动发了消息,就判定已认领,AI 继续静默;客服在中控发一句「已处理 / 恢复AI」,静默立刻结束。这套零成本认领靠聊天消息方向 + 发言人身份就能判定。

第五,重入抑制。 AI 不能当复读机:人工处理中再次触发转人工,只续静默不打扰;通知 webhook 10 分钟内不重推,超时重推最多 3 次封顶。

4.5 防护栏:一个生产级 AI 模块的自我修养

AiAutoReplyService 里密密麻麻的注释,记录了这个模块是怎么被真实客户调教出来的:

  • 规则优先:关键词自动回复规则的优先级高于 AI,命中规则就不走 AI,防止客户收到双重回复;
  • 窗口内防双回复:结算前查一次库,这期间如果关键词规则、SOP 或者坐席已经回过了,AI 这批直接丢弃;
  • 下发前二次复检:AI 生成要 5~15 秒,这期间人工可能已经介入。回复真正发出前再查一次静默状态,落后的回答绝不抢发;
  • 每会话限频:默认每分钟最多 AI 回复 5 次(按聚合批次计),防异常循环;
  • 致谢清零:客户说「谢谢 / 好的 / 明白了」这类收尾词,不回复、不计数、轮次清零——否则客户礼貌一下,反而因为「聊满 3 句」被转了人工;
  • 重复追问判定:轮次超限时,用字符二元组 Jaccard 相似度(阈值 0.35)判断客户是不是「还在纠结同一件事」;换新话题就重新计数,不冤枉;
  • 同会话串行:同一个会话的 AI 任务用 CompletableFuture 串成链,上一条生成完再处理下一条;不同会话并行。群里两个人同时提问,回复严格按顺序来,不会张冠李戴;
  • 线程池隔离:大模型 HTTP 调用是同步阻塞的(连接超时 10 秒、读超时 60 秒),严禁在 Netty IO 线程上跑,全部丢独立的 aiReplyExecutor;
  • 失败静默降级:AI 服务挂了只记日志、发告警,绝不影响消息主链路——大不了人工顶上,不能让整个消息收发瘫痪。

4.6 一个排查了很久的坑:max_tokens

上线初期出现过一批「AI 时灵时不灵」的灵异现象,日志只显示生成失败。最后定位到:用的是 DeepSeek 推理模型,思维链(reasoning_content)和正文共享输出预算,默认 max_tokens=4096。复杂问题的思维链实测能跑到 600~1200 tokens,一波动直接把预算吃光,finish_reason=length,正文 content 为空。

显式设成 8192,并对 content 为空的三种成因(预算耗尽 / 服务端偶发空回复 / 报文异常)全部补齐日志字段后,问题才收敛。这种坑不看 finish_reason 根本猜不到。

5. 下行:AI 生成的回复怎么发到客户微信里

AI 生成的内容不是直接 HTTP 调接口发出去的——企业微信没有给我们这个接口。它走的是和所有指令一样的下发链路:

  1. 服务端构造 TalkToFriendTask(协议 1070)消息体,雪花算法生成消息 ID;
  2. 只做通道在线探测,不立刻发。真正的 writeAndFlush 注册到事务 afterCommit——防止「事务回滚了但指令已经发出去」的幽灵消息;
  3. TCP 发到手机,主控进程收到后通过广播转回企微进程,入任务队列,主线程调用企微内部发消息 API(群聊时带上原生 @ 和引用渲染);
  4. 企微真正发送成功后,Hook 到消息状态变更,回执 TalkToFriendTaskResultNotice(1028)上报;
  5. 服务端拿到回执更新消息状态,前端 WebSocket 推送,气泡从「发送中」变成已送达。

协议层没有 TCP ACK,所以一切以 APP 主动上报的业务回执为准。中控页面上发消息采用乐观 UI:先显示「发送中」,失败了气泡变红可重发——交互完全对标你熟悉的微信。

AI 发出的消息会打 msg_source=AI_REPLY 的角标,在中控会话里和人工消息、规则消息明确区分,运营随时能审计 AI 说了什么。

6. 效果与一些数字

上线后我们自己客户侧的体感:

  • 夜间和节假日的首次响应时间从「小时级」变成秒级,大量咨询在非工作时间被完整闭环;
  • 重复性问题(占客服咨询量的大头)由 AI 承接,人工只处理 AI 转来的真问题,客服人效肉眼可见地提升;
  • 所有客户消息、聊天记录沉淀在企业自己的后台,员工离职在后台一键交接,客户无感;
  • 成本可控:聚合机制把调用次数压到「一个问题一次」,加上每租户自带 Key,费用完全可预期。

7. 必须聊的合规与风控

做这类系统,克制比能力重要。我们的原则写在代码里:

  • 只在企业自有设备、自有账号上运行,定位是员工的「效率助手」,不是群控、不是爆粉工具;
  • 封号只监听,不对抗。检测到封号信号只上报、只告警,不做任何本地绕过——你能想到的所有「防封技巧」,风控团队也想得到,对抗只会加速死亡;
  • 反检测保持最小动作,只处理注入框架自身的痕迹,「原版没有的一律不做」,越折腾特征越多;
  • 所有主动行为拟人化:批量动作加随机间隔抖动、有时间窗(如加好友默认每号每天 20 条、09:00–21:00)、有频控;
  • AI 回复同样有节制:限频、静默期、防骚扰、可审计。

2025 年市面上群控相关的判例(有判赔 300 万的)已经说明了红线在哪。技术是中性的,拿它服务自己的客户还是拿去骚扰别人,是两条完全不同的路。

8. 写在最后

这套系统做到现在,三端约 17 万行代码,沉淀下来的真正资产不是某个 Hook 点,而是:一套版本适配方法论(新版企微适配基本等于改一个混淆名字典类)、一条设备到 AI 的可靠消息链路、和一个知道什么时候该闭嘴的客服大脑。

后续我会继续拆这个系列:

  1. 万台安卓设备的 Netty 长连接:限流、背压、心跳与集群路由;
  2. Hook 企业微信 5.0.11:R8 混淆迁移与版本适配体系;
  3. SOP 自动化运营引擎:CAS 状态机如何保证多实例下不重复打扰客户;
  4. 20 个 Java 类写一个商业级 OpenAPI 网关:HMAC 签名、保序投递与死信队列。

我把可以公开的网关层代码、Protobuf 协议定义和架构图整理到了 GitHub(链接放在我主页简介里,自取)。

如果这篇文章对你有帮助,欢迎点赞收藏;想要完整 167 个协议号文档或在做类似系统想交流的同学,评论区扣「企微AI」,我看到都会回,也欢迎私信聊架构和落地问题。


本文仅用于企业自有设备的效率工具技术研究,不提供任何针对微信/企业微信的攻击、破坏手段。请遵守平台规则与法律法规。

我们团队用 Java 给企业微信做了个 AI 客服机器人:从 Hook 截获消息到 DeepSeek 自动回复,全链路架构公开

一个小团队,三套代码,约 17 万行,把「企业微信机器人」从一句口号做成了一个能 7×24 小时接客、答不上来会摇人、全程不骚扰客户的 AI 客服系统。这篇文章不讲概念,只讲我们真实上线的架构、协议和踩过的坑。

关键词:企业微信、AI 客服、DeepSeek、Netty、Xposed、SCRM、大模型应用

0. 先说为什么要自己造

我们团队做 To B 服务,客户全在企业微信里。运营一段时间后,几个问题反复出现:

  • 客服下班后没人回消息。客户晚上 10 点问的问题,第二天早上才回复,早跑去别家了;
  • 同一个问题答 100 遍。价格、安装、售后政策,纯重复劳动,新人还总答错;
  • 客服离职,客户跟着走。聊天记录和客户关系全在员工个人企微号上,人走茶凉;
  • 官方 API 能力有天花板。官方接口对群发、触达频次限制很死,而「接管自己员工的企业微信」这件事,官方根本不开放。

市面上的 SCRM 我们买过、也调研过:走官方 API 路线的,能力受限;走协议路线的,本质是个黑盒——你不知道它什么时候封你的号,数据还全在别人服务器上。

所以我们自己做了一套。定位很清楚:企业自有设备、自有账号、自有数据,机器人只是在员工的企业微信里帮忙干活。整体三端:

  • 安卓端:一个注入企业微信 App 的自动化模块(约 4.8 万行 Java),负责消息感知和动作执行;
  • 服务端:Spring Boot 3 + Netty,约 1000 个 Java 类,负责设备接入、业务逻辑和 AI 大脑;
  • 管理后台:Vue 2,约 11 万行,其中有一个 1.3 万行的「企微中控」页面,人机共用一个工作台。

这篇文章以「客户发来一条消息,AI 自动接待,答不上来转人工」这条最核心的链路为主线,把三端串起来讲。

1. 全局架构:一条消息要经过的 5 个世界

Java 服务端

安卓端 · 注入进程

消息

Protobuf / TCP 长连

HTTP

1070 发消息指令

下行指令

调用企微内部 API

webhook 企微群机器人

HTTP / WebSocket

客户微信

员工手机上的
企业微信 App

Hook 消息事件

AIDL 跨进程
优先级队列

Netty 接入层

消息分发器
20000 缓冲队列

业务处理
落库/规则/SOP

AI 编排服务
聚合+大模型+转人工

DeepSeek /
兼容协议模型

值班客服群

Vue 中控后台

协议是我们自定义的一套 Protobuf 消息信封,167 种消息类型,长这样:

message TransportMessage {
    int64  id          = 1;  // 消息ID(雪花算法)
    string accessToken = 2;  // 认证后颁发的通道令牌
    int32  msgType     = 3;  // 消息类型, 如 1010认证 / 1026消息 / 1070发消息
    Any    content     = 4;  // 具体消息体, 167种之一
    int64  refMessageId= 5;  // 关联的上行消息ID
}

几个贯穿全文的协议号先混个脸熟:

协议号方向含义
1010 / 1011↑↓设备认证请求 / 响应
1020 / 1021↑企微上线 / 下线通知
1026↑聊天消息上行(客户发来的消息走这里)
1028↑发消息任务结果回执
1070↓下发「发消息」任务
3094↓接待开关(是否订阅客户消息)
1001双向心跳(同一个码,服务端主动发起)

2. 「机器人」的身份模型:设备是设备,微信号是微信号

在讲消息之前,必须先讲清楚我们踩过的第一个大坑:连接身份和业务身份必须分离。

一台安卓手机是一个物理设备(可能被 root、装了注入框架),手机上登录的企业微信账号是业务身份。用户会换手机,也会在同一台手机上换号登录。如果把这两者绑死,换一次设备数据就全乱了。

我们的模型:

  • 设备凭证:一台物理设备一个,管连接、踢线、授权计费。首次认证随机生成 DEV_xxxx 的设备码,物理设备标识用 APP 上报的 IMEI / android_id 回填;
  • 业务身份:企微账号 ID(wechatId),所有客户、群、聊天记录归属于它。换号 = 同一设备凭证下换一个业务属性,而不是换一个连接身份。

认证握手(协议 1010)由 APP 在 TCP 连上后立即发起,请求里三个关键字段:

username   = 租户登录用户名     // 用来定位这是哪家企业的设备
credential = 企微账号ID         // 白名单正则 ^[A-Za-z0-9_-]{1,64}$ 清洗
clientValue= 设备指纹(IMEI等)   // 同样做正则清洗, 防注入

这里有一个真实的事故教训。认证逻辑里我们要查用户、查租户、建设备、排同步任务,最初整个方法包在一个事务里。结果 8 月份某天凌晨服务重启,上万台设备同时重连,认证风暴把数据库连接池打满了——事务在持有 DB 连接期间还夹杂网络 I/O,连接迟迟不归还。

现在的做法:

  1. 认证入口限流 100 次/秒,超了直接关连接,APP 5 秒后重连——刻意把万级并发重连打散;
  2. 认证入口方法不加事务,只在必要的写库片段上开短事务;
  3. 需要下发的同步指令全部放到事务 afterCommit 之后执行。

APP 侧的重连也配合做了错峰:固定 10 秒延迟 + 按设备 hash 散列 0~30 秒抖动,避免大家卡点同时连进来。

3. 上行:一条客户消息怎么从企微走进 Java

3.1 Hook:在企业微信自己的进程里「听墙角」

安卓端的注入框架是一套基于 Zygisk / Riru 的 Xposed 兼容实现(懂行的同学知道这是 LSPosed 一脉的技术),注入目标是企业微信主进程 com.tencent.wework,我们适配的版本是 5.0.11.81206。

核心思路:企业微信收到新消息时,内部一定会回调它自己的消息监听器。我们 Hook 的就是会话引擎的消息回调(混淆后的 Conversation$2.onAddMessages),在回调里拿到消息对象,反射解析出发送人、内容、类型、引用关系,再交给 16 个消息类型处理器(文本/图片/语音/视频/链接/名片/小程序……)统一转成我们自己的 Bean。

为什么不直接截屏、点屏幕那种「无障碍服务」方案?因为那种方案本质是模拟盲人操作手机,慢、脆、拿不到消息内容、还被官方点名为外挂。Hook 是在进程内部直接调用对方的内部 API,消息是结构化的,这是两个时代的技术。

3.2 三进程架构:别把人家的 App 搞崩

Hook 代码跑在企业微信进程里,但网络连接、重连、队列这些重活绝不能放那儿——你 Hook 进去的每一行代码崩了,崩的都是企业微信,客户直接看到闪退。

所以我们做了三进程隔离:

进程3: :remote 看门狗

进程2: com.easy.crm 主控

进程1: com.tencent.wework 企微

AIDL bindService

监控拉起

am start 兜底拉起

Hook 监听器 + 消息处理器

AIDL 服务
200条优先级队列
任务结果 > 消息 > 会话 > 同步

Netty TCP 长连客户端
心跳/重连/鉴权

双进程互绑保活 + ANR检测 + 重启兜底

  • 企微进程只做「感知」和「执行」,通过 AIDL 把消息绑给主控进程,队列分了优先级——任务回执比消息重要,消息比批量同步重要;
  • 主控进程持有到服务端的 TCP 长连,断连时本地缓存,指数退避重连;
  • 看门狗进程干两件事:双进程前台服务保活,以及检测企微是不是「假死」了。

假死检测是个很实战的设计:Hook 层每 5 秒会回一个 pong 作为企微进程真实健康信号。连续 6 个周期(30 秒)收不到 pong,即使 TCP 心跳还正常,也判定企微卡死,root 权限强杀重启——因为心跳只能证明我们自己的进程活着,证明不了企微没 ANR。

3.3 Netty 接入层:万台设备的连接怎么扛

服务端 Netty 用了 boss / worker / business 三组 EventLoopGroup,业务处理全部丢到独立业务线程池,慢业务绝不允许污染 IO 线程。Pipeline 是标准的 Protobuf 帧编解码:

IdleStateHandler(120s读空闲)
→ ProtobufVarint32FrameDecoder
→ ProtobufDecoder(TransportMessage)
→ LengthFieldPrepender + ProtobufEncoder
→ NettyServerHandler(业务线程组)

上行消息进来后不直接处理,而是进 MessageDispatcher:

  • 一个 20000 容量的阻塞队列把 Netty IO 线程和业务线程池隔开;
  • 单独的消费线程每秒最多向业务池提交 500 条,把设备端突发上报削成数据库扛得住的匀速;
  • 一个协议号可以挂多个处理器(注册表多播),比如聊天消息既要落库、又要触发自动回复、还要推 WebSocket;
  • 队列满了直接丢并记降级日志——对客服场景来说,过载时丢一条「正在批量同步的群列表」远好过整个集群被拖死。

另外两个保命细节:

  • 写缓冲水位线背压:高低水位设 32KB / 256KB,通道积压到高水位就 setAutoRead(false) 停止读,防止慢消费设备把服务端内存堆爆;
  • 心跳由服务端主动发起:每 30 秒下发心跳包,120 秒没响应掐线重连;设备数超过 500 台后分 9 片轮扫,控制单次扫描成本。

4. AI 大脑:这套系统最难的部分

消息落库之后,进入这篇文章的主角:AiAutoReplyService。

先说结论:把大模型接进客服场景,最难的根本不是调 API,而是「什么时候答、答什么、什么时候闭嘴、怎么优雅地把人摇来」。

4.1 消息聚合:客户说话是「一段一段」的

真实的微信对话长这样:

客户: 在吗
客户: 问一下
客户: 你们那个充电桩功率多大的

如果来一条调一次大模型,会发生三件事:① 回复三条,像个傻子;② token 费用 ×3;③ 前两条上下文不完整,模型只能反问。

所以我们做了静默窗口聚合:按「会话 + 发送人」缓冲消息,等客户停下来再整体结算成一个问题。

  • 群聊窗口 10 秒、单聊 5 秒(按租户可配);
  • 自适应提速:消息以 ??!!。 结尾,说明问题完整了,窗口缩到 3 秒,大多数客户不用陪跑满全程;
  • 30 秒封顶:总有话痨能一直输入,连续输入最长只等 30 秒强制结算,防止窗口无限续期。

缓冲不放在内存,而是写 Redis List(TTL 30 秒)。为什么?结算调度器万一重启,内存里的消息就丢了;Redis 里的还在,新消息到达会重新挂调度——最多晚回复,绝不丢消息。

结算时也不是一把删掉整个 List,而是 leftPop 逐条取;pop 的过程中如果又有新消息压进来,1 秒后挂一个「救援结算」兜底。这些都是被竞态教训出来的。

4.2 让大模型输出「程序能用的结果」:四选一 JSON 协议

普通玩家让大模型吐一段话,我们要求它必须吐严格 JSON,因为回复之后的程序动作需要机器可判定:

private static final String BASE_PROMPT =
    "你是微信社群的客服助手,现在代表运营者回复客户消息。回答要求:\n" +
    "1. 必须用中文,语气自然亲切,像真人客服一样口语化\n" +
    "2. 回复简洁,通常1~3句话,适合微信聊天场景,不使用任何markdown格式\n" +
    "3. 优先且仅依据下面提供的业务知识库回答;知识库没有的内容绝不编造价格、政策、承诺\n" +
    "4. 不透露你是AI或模型相关信息\n" +
    "5. 每次回复必须输出严格的JSON对象,格式四选一:\n" +
    "   {\"type\":\"normal\",\"reply\":\"给客户的回复内容\",\"confidence\":0.9,\"sourceIds\":[1,3]}\n" +
    "   {\"type\":\"ignore\"}\n" +
    "   {\"type\":\"handoff\",\"reply\":\"给客户的过渡话术\",\"summary\":\"客户问题一句话摘要\"}\n" +
    "   {\"type\":\"urgent\",\"reply\":\"给客户的应急话术\",\"summary\":\"紧急情况摘要\"}\n" +
    // ……四种 type 的语义说明
    "6. 结合「业务场景」理解客户问题的行业语境……";

四种类型对应四条程序分支:

type含义程序动作
normal能依据知识库回答检查置信度后下发回复
ignore闲聊、寒暄、表情、与业务无关什么都不发,不花一次 token 陪聊,也不拿通用知识瞎编
handoff需要人工先发过渡安抚话术,再通知客服,AI 进入静默
urgent紧急情况先发客户当下可执行的应急指引,再最高优先级通知客服

注意 normal 里的两个字段:confidence 是模型自评的正确率把握,低于 0.6 一律转人工,不硬答;sourceIds 记录引用了知识库哪几条,运营可以审计「这条回复是有依据的还是模型自己发挥的」。

企业客服场景,幻觉是不可接受的。 客户问价格,模型编一个数字出去,损失的是真金白银。所以我们的系统提示词里写死了「知识库没有的内容绝不编造价格、政策、承诺」,再加一层置信度兜底。

这里有一个非常关键的设计决策:系统提示词里只放与行业无关的协议约束,不放任何业务判定标准。 什么问题算紧急、什么语气该转人工,全部由租户自己配置的「业务场景」和知识库决定。同一个系统,卖充电桩的客户和卖化妆品的客户,AI 的判定标准天然隔离——每个租户用自己的 API Key、自己的 baseUrl、自己的模型(DeepSeek / 通义 / Kimi / GPT 都行,OpenAI 兼容协议),平台不提供共享 Key,费用和数据都归租户。

4.3 知识库:全量文档 + 相似度召回 FAQ

租户在后台维护两类知识:文档类(产品说明、售后政策)全量塞进 system prompt;问答类(FAQ)按当前聚合问题做相似度召回,只取相关的。历史对话默认带最近 10 条,单条截断 500 字,防止长文撑爆上下文。

4.4 转人工:先安抚客户,再摇客服

这是我们和 demo 级 AI 客服最大的区别。转人工不是弹个日志完事,而是一个完整闭环:

值班客服企业微信群机器人AI客服客户值班客服企业微信群机器人AI客服客户该客户进入静默期, AI不再抢答连续追问/问了知识库外的问题handoff/urgent 或 置信度<0.6先发过渡话术(1~2句, 告知专人马上来)webhook: text(@值班人) + markdown详情卡片群内@提醒, 卡片带客户问题+AI摘要还在吗?(静默期追问)催促话术 + 再次@客服(5分钟节流)客服到场回复检测到人工发言, 结束静默, AI待命

几个细节值得单独说:

第一,必须先给客户回话,再通知客服。 绝不能客户屏幕上晾半天没人理。过渡话术由模型现场生成(handoff 是安抚,urgent 是可执行的应急指引),生成失败有模板兜底——转人工可能恰好是因为 AI 服务挂了,兜底逻辑不能反过来依赖 AI 活着。

第二,静默是「用户级」的,不搞连坐。 静默期的 Redis key 是 ai:silent:{机器人}:{会话}:{触发用户wxid}。群里 A 触发了转人工,只有 A 的消息 AI 不抢答,B 正常提问照常回答。这个 bug 我们真踩过——早期 senderId 没随聚合消息持久化,key 串到群维度,结果一个人转人工,全群沉默。

第三,静默期客户追问,不能装死。 客服还没认领时,客户追问要回一句催促话术,并且 @值班客服:

private static final String URGE_REPLY =
    "人工同事正在加急赶来,请再稍等一下,马上就有人跟进您的问题~";
// 同一个客户 5 分钟内最多发一条催促, 用 Redis 原子计数节流, 防客户刷屏时轰炸群聊

第四,客服怎么算「认领」? 不需要客服点什么按钮——值班客服在客户群(或通知群)里开口说话,或者在中控后台手动发了消息,就判定已认领,AI 继续静默;客服在中控发一句「已处理 / 恢复AI」,静默立刻结束。这套零成本认领靠聊天消息方向 + 发言人身份就能判定。

第五,重入抑制。 AI 不能当复读机:人工处理中再次触发转人工,只续静默不打扰;通知 webhook 10 分钟内不重推,超时重推最多 3 次封顶。

4.5 防护栏:一个生产级 AI 模块的自我修养

AiAutoReplyService 里密密麻麻的注释,记录了这个模块是怎么被真实客户调教出来的:

  • 规则优先:关键词自动回复规则的优先级高于 AI,命中规则就不走 AI,防止客户收到双重回复;
  • 窗口内防双回复:结算前查一次库,这期间如果关键词规则、SOP 或者坐席已经回过了,AI 这批直接丢弃;
  • 下发前二次复检:AI 生成要 5~15 秒,这期间人工可能已经介入。回复真正发出前再查一次静默状态,落后的回答绝不抢发;
  • 每会话限频:默认每分钟最多 AI 回复 5 次(按聚合批次计),防异常循环;
  • 致谢清零:客户说「谢谢 / 好的 / 明白了」这类收尾词,不回复、不计数、轮次清零——否则客户礼貌一下,反而因为「聊满 3 句」被转了人工;
  • 重复追问判定:轮次超限时,用字符二元组 Jaccard 相似度(阈值 0.35)判断客户是不是「还在纠结同一件事」;换新话题就重新计数,不冤枉;
  • 同会话串行:同一个会话的 AI 任务用 CompletableFuture 串成链,上一条生成完再处理下一条;不同会话并行。群里两个人同时提问,回复严格按顺序来,不会张冠李戴;
  • 线程池隔离:大模型 HTTP 调用是同步阻塞的(连接超时 10 秒、读超时 60 秒),严禁在 Netty IO 线程上跑,全部丢独立的 aiReplyExecutor;
  • 失败静默降级:AI 服务挂了只记日志、发告警,绝不影响消息主链路——大不了人工顶上,不能让整个消息收发瘫痪。

4.6 一个排查了很久的坑:max_tokens

上线初期出现过一批「AI 时灵时不灵」的灵异现象,日志只显示生成失败。最后定位到:用的是 DeepSeek 推理模型,思维链(reasoning_content)和正文共享输出预算,默认 max_tokens=4096。复杂问题的思维链实测能跑到 600~1200 tokens,一波动直接把预算吃光,finish_reason=length,正文 content 为空。

显式设成 8192,并对 content 为空的三种成因(预算耗尽 / 服务端偶发空回复 / 报文异常)全部补齐日志字段后,问题才收敛。这种坑不看 finish_reason 根本猜不到。

5. 下行:AI 生成的回复怎么发到客户微信里

AI 生成的内容不是直接 HTTP 调接口发出去的——企业微信没有给我们这个接口。它走的是和所有指令一样的下发链路:

  1. 服务端构造 TalkToFriendTask(协议 1070)消息体,雪花算法生成消息 ID;
  2. 只做通道在线探测,不立刻发。真正的 writeAndFlush 注册到事务 afterCommit——防止「事务回滚了但指令已经发出去」的幽灵消息;
  3. TCP 发到手机,主控进程收到后通过广播转回企微进程,入任务队列,主线程调用企微内部发消息 API(群聊时带上原生 @ 和引用渲染);
  4. 企微真正发送成功后,Hook 到消息状态变更,回执 TalkToFriendTaskResultNotice(1028)上报;
  5. 服务端拿到回执更新消息状态,前端 WebSocket 推送,气泡从「发送中」变成已送达。

协议层没有 TCP ACK,所以一切以 APP 主动上报的业务回执为准。中控页面上发消息采用乐观 UI:先显示「发送中」,失败了气泡变红可重发——交互完全对标你熟悉的微信。

AI 发出的消息会打 msg_source=AI_REPLY 的角标,在中控会话里和人工消息、规则消息明确区分,运营随时能审计 AI 说了什么。

6. 效果与一些数字

上线后我们自己客户侧的体感:

  • 夜间和节假日的首次响应时间从「小时级」变成秒级,大量咨询在非工作时间被完整闭环;
  • 重复性问题(占客服咨询量的大头)由 AI 承接,人工只处理 AI 转来的真问题,客服人效肉眼可见地提升;
  • 所有客户消息、聊天记录沉淀在企业自己的后台,员工离职在后台一键交接,客户无感;
  • 成本可控:聚合机制把调用次数压到「一个问题一次」,加上每租户自带 Key,费用完全可预期。

7. 必须聊的合规与风控

做这类系统,克制比能力重要。我们的原则写在代码里:

  • 只在企业自有设备、自有账号上运行,定位是员工的「效率助手」,不是群控、不是爆粉工具;
  • 封号只监听,不对抗。检测到封号信号只上报、只告警,不做任何本地绕过——你能想到的所有「防封技巧」,风控团队也想得到,对抗只会加速死亡;
  • 反检测保持最小动作,只处理注入框架自身的痕迹,「原版没有的一律不做」,越折腾特征越多;
  • 所有主动行为拟人化:批量动作加随机间隔抖动、有时间窗(如加好友默认每号每天 20 条、09:00–21:00)、有频控;
  • AI 回复同样有节制:限频、静默期、防骚扰、可审计。

2025 年市面上群控相关的判例(有判赔 300 万的)已经说明了红线在哪。技术是中性的,拿它服务自己的客户还是拿去骚扰别人,是两条完全不同的路。

8. 写在最后

这套系统做到现在,三端约 17 万行代码,沉淀下来的真正资产不是某个 Hook 点,而是:一套版本适配方法论(新版企微适配基本等于改一个混淆名字典类)、一条设备到 AI 的可靠消息链路、和一个知道什么时候该闭嘴的客服大脑。

后续我会继续拆这个系列:

  1. 万台安卓设备的 Netty 长连接:限流、背压、心跳与集群路由;
  2. Hook 企业微信 5.0.11:R8 混淆迁移与版本适配体系;
  3. SOP 自动化运营引擎:CAS 状态机如何保证多实例下不重复打扰客户;
  4. 20 个 Java 类写一个商业级 OpenAPI 网关:HMAC 签名、保序投递与死信队列。

我把可以公开的网关层代码、Protobuf 协议定义和架构图整理到了 GitHub(链接放在我主页简介里,自取)。

如果这篇文章对你有帮助,欢迎点赞收藏;想要完整 167 个协议号文档或在做类似系统想交流的同学,评论区扣「企微AI」,我看到都会回,也欢迎私信聊架构和落地问题。


本文仅用于企业自有设备的效率工具技术研究,不提供任何针对微信/企业微信的攻击、破坏手段。请遵守平台规则与法律法规。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐