NEW

免费试用已开放

立即开始

AI · 大模型 · 智能客服

企业微信 AI 接口怎么设计

更新于 2026-08-169 分钟

「企业微信AI接口」在搜索里其实指两件事:一是调哪个模型的接口,二是你自己系统里那条把消息交给模型、再把结果交回去的内部接口。前者选型两天就定了,后者决定你半年后能不能换模型、能不能把同一套 AI 接到工单和网页上、能不能在不打扰客户的前提下跑一次评测。这篇只讲后者:通道层和模型层之间该定哪两份契约,切歪了又怎么看出来。下面按 wecomapi 的接入形态展开。

先分清两种「AI 接口」

把模型接进企业微信,代码上最短的写法是在回调处理函数里直接调模型、直接调发送接口,中间没有任何抽象。这个写法在验证阶段是对的,问题在第二个需求出现时暴露:你想让同一套问答能力也服务网页客服,发现逻辑和企微的消息结构长在了一起;你想灰度一个新模型,发现模型调用散在四个处理函数里;你想跑一次回归评测,发现系统里没有任何一个地方可以「只跑模型不发消息」。

这三件事指向同一个缺失:模型层和通道层之间没有一条明确的边界。补这条边界不需要引入框架,只需要定死两份契约 —— 通道层交给模型层什么,模型层交回来什么。契约定死之后,两边可以各自替换,评测也就有了插桩点。

  • 通道层:负责收发、身份解析、会话状态、限速与幂等。它知道企业微信,不知道模型
  • 模型层:负责理解与生成。它知道模型,不知道消息从哪个渠道来
  • 两者之间只通过两份契约通信,不共享对象、不互相引用

输入契约:该带什么,绝不该带什么

输入契约的设计原则只有一条:把模型层需要的判断依据全部前置填好,不要让它自己回头去查。模型层一旦开始调通道层取数据,边界就失效了 —— 评测时你没法造数据,换渠道时你要改模型层。

用 wecomapi 的事件回调拿到一条消息之后,通道层要做的是把它翻译成一个与渠道无关的输入对象。必须带的有五样:已经按你的规则切过段的会话标识、发言人角色与身份标签、归一化后的文本与附件引用、这一轮的对话历史,以及一组用于权限过滤的可见性标签。

  1. 1会话标识用你自己生成的,平台侧标识作为一列外部引用挂在旁边。换接入路线、同一客户跨渠道时不用回填主键。
  2. 2身份标签必须在通道层填。谁是外部客户、谁是内部同事、这条消息在群里还是单聊,模型层没有能力也不该有权限去查。
  3. 3附件走引用不走内容。图片和文件在契约里只出现一个引用和类型,需要理解内容时由模型层显式取。
  4. 4时间用绝对时间戳,别用「刚刚」这类相对描述。评测回放时相对时间会全部错位。

同样重要的是不该出现的东西:平台的原始报文、以消息类型码形式出现的枚举、渠道特有的字段名。这些一旦进了契约,模型层就绑死在企业微信上,后面接网页或工单时你会发现提示词模板里到处是企微的词汇。

输出契约:回复不是一段字符串,是一个决策

第二份契约错得更普遍:让模型层返回一个字符串,通道层拿到就发。这个设计里没有位置表达三件常见的事 —— 这次不该回、这次该转人工、这次要过一会儿才有结果。于是它们被塞进字符串里:返回空串表示不回,返回一个特殊前缀表示转人工。这类约定在第三个调用点就会被写错一次。

正确形态是让模型层返回一个明确的决策对象,回复文本只是其中一种情形携带的载荷。至少要有四种情形:正常回复、明确沉默、移交人工、延后补发。沉默必须是一等公民 —— 群里没 @ 到自己、客户只发了个表情、人工已经接管,这些场景下「不说话」是正确输出,不是失败。

  • reply:带文本、可选的出处、以及一个置信度或其等价物
  • silent:带原因。原因要能统计,「为什么今天沉默率涨了」是个必须能回答的问题
  • handoff:带移交原因与交接上下文,具体怎么设计站内讲转人工那篇有展开
  • defer:带一个任务句柄,表示结果稍后由另一条路径送达

决策对象还要带可观测字段:用了哪个模型、命中了哪些知识条目、耗时多少、消耗了多少上下文。这些不写进契约就只能散在日志里,而散在日志里的东西没法做聚合分析。

示意:两份契约的形状typescript
// 示意结构,类型与字段均为自拟;精确的事件与消息字段以线上接口文档为准

// 通道层 → 模型层:与渠道无关,模型层不需要再回头查任何东西
type AiInput = {
  sessionId:   string;                            // 你自己的会话标识,已按边界切段
  speaker:     { role: "customer" | "staff" | "bot"; visibility: string[] };
  text:        string;
  attachments: { kind: string; ref: string }[];   // 只带引用,不带内容
  history:     { role: string; text: string; at: number }[];
};

// 模型层 → 通道层:一个决策,不是一段字符串
type AiDecision =
  | { kind: "reply";   text: string; sources?: string[]; confidence: number }
  | { kind: "silent";  reason: string }
  | { kind: "handoff"; reason: string; context: unknown }
  | { kind: "defer";   taskId: string };

// 只有 reply 才落到发送动作,形如 https://manager.wecomapi.com/message/sendText

异步是默认形态,不是优化

用 wecomapi 的事件回调接消息时,站内反复讲过要先快速 ACK 再异步处理。这条约束顺着传导到 AI 接口上,结论是:模型层不能假设自己运行在某个请求的生命周期里。它可能几秒后返回,也可能几分钟后由另一个进程返回。契约必须为此留位置,而不是靠调用方「记得用异步」。

具体做法是把「产出回复」和「发出消息」两件事解耦:模型层产出决策,通道层负责在合适的时机把它落成消息。这样 defer 这种情形才有意义 —— 一个多步任务在后台跑完,直接产出一个新的决策交给通道层,不需要回到原来那次调用的上下文里。

  • 通道层要能凭会话标识随时发起一次发送,不依赖任何「当前请求」
  • 决策必须能被持久化。跨进程投递的东西只能是数据,不能是闭包或回调函数
  • 同一会话的决策落地要串行,否则延后补发的结果可能插到新回复前面

这条做对了还有一个副作用收益:模型层天然可以被离线驱动。给它喂一批历史输入契约,它产出一批决策,不接任何发送通道 —— 这就是评测跑批,不需要为它单独写一套代码。

这条边界买到的三样东西

定契约是有成本的:多两个类型、多一次转换。它买到的是三件在没有边界时根本做不了的事。

  1. 1模型可替换与可灰度。模型层背后挂几个实现,按会话分桶路由,出问题切回去只改一个配置。没有契约时,灰度意味着在四个处理函数里各加一个 if。
  2. 2渠道可复用。同一套模型层同时服务单聊、外部群、网页客服和工单系统,通道层各写各的,提示词只写一份。
  3. 3离线评测。评测集就是一批输入契约实例加上期望的决策,跑一遍对比结果。这是三样里最值钱的。

第三样值得多说一句。评测集怎么攒、按什么分层采样,站内讲 AI 客服系统那篇已经说过;契约在这里多给的一样东西是回放保真度 —— 落一份输入契约实例,等于把当时的身份标签、可见性范围和那一轮的历史一起冻住,重跑时模型层看到的输入和当时逐字一致,差异只可能来自你改的那一处。这也是为什么契约里的时间必须是绝对时间戳:换成相对描述,回放出来的就不是同一道题了。

切歪了的四个信号

这几条都能直接 grep 出来,比对着架构图讨论有用。

  1. 1模型层的文件里能搜到平台侧字段名、消息类型码,或者提示词模板里出现了渠道文案 —— 输入契约漏了。
  2. 2模型层直接引用了发送函数 —— 输出契约形同虚设,它已经能自己说话了。
  3. 3模型层里出现「如果是群聊就……」这类分支 —— 渠道差异没在通道层被抹平,接第二个渠道时这里会再长一个分支。
  4. 4想跑一次评测必须起一个假的回调服务 —— 说明模型层还捏着通道层的对象。

反过来也有不值得定契约的场景:只服务一个渠道、只有一个模型、也不打算做评测的一次性工具。这类东西的生命周期比抽象的回本周期还短,直接写死更快。但有一条即便是原型也该守 —— 别让模型层直接调发送接口。这条的成本在写的时候是零,在补的时候是把每个调用点都翻一遍。

本文讲的是内部接口的边界与契约形状,示意代码中的类型与字段均为自拟。企业微信侧的消息、事件与端点定义以 wecomapi 线上接口文档为准。

常见问题

只接一个模型、只服务企业微信,还需要分这两层吗?
看三件事会不会发生:会不会换模型或加多模型兜底、会不会接第二个渠道、要不要做回归评测。三个都是「不会」,直接写死更快。只要有一个是「会」,就在写第三个处理函数之前把契约定下来,成本大约半天;晚一个月做就是一次重构,因为那时提示词里已经混进渠道文案了。
模型层需要知道消息是从群里来的还是单聊吗?
需要知道「可见性」,不需要知道「渠道形态」。把它抽象成身份与可见性标签放进输入契约,模型层据此决定能引用哪些历史、该不该沉默,但契约里不出现「群聊」这类平台概念。用 wecomapi 的事件回调收到消息时,通道层就把这组标签填好,模型层不再回头查。
沉默为什么要单独作为一种输出,返回空字符串不行吗?
空字符串带不了原因,也和「模型没答出来」这种异常混在一起。沉默是正常业务结果,需要被统计:今天沉默率突然上涨,是人工接管变多了,还是群里的 @ 判定写错了?这两种情况的处置完全不同,而空串把它们抹平成了同一个现象。

准备好动手了?

精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。

相关文章