背景与痛点:扣子AI能看图,微信客服却“睁眼盲”
最近给公司客服做了一套扣子智能体,本地调试时一切正常:用户上传截图,扣子秒回文字答案,图片里的问题也能被 AI 正确解析。结果一挂到微信客服,同样的截图却直接“罢工”——要么显示空白,要么干脆报错“图片解析失败”。
排查日志发现,扣子后台其实拿到了图片,只是微信端收不到解析结果。根本原因是两条通道的“语言”不一样:
- 扣子内部走 HTTP/JSON,图片用 Base64 嵌在 body 里,字段名随意定;
- 微信客服只认官方消息格式(XML+CDATA),图片必须走 MediaID 或临时素材接口,且大小≤2 MB。
一句话:扣子把图“说”成了微信听不懂的方言,于是前端白屏。
技术方案:让图片说“普通话”
先把常见编码方式拉出来对比:
| 编码方式 | 优点 | 缺点 | 微信兼容性 |
|---|---|---|---|
| Base64 内嵌 | 调试直观,一把梭 | 体积膨胀 33%,字段长度受限 | 直接拒收 |
| 二进制流 | 零膨胀,省流量 | 需额外签名上传 | 需转 MediaID |
| 临时素材 | 官方推荐 | 有效期 3 天,需缓存清理 | 最佳 |
结论:
- 扣子侧继续用 Base64 做内部识别;
- 新增“微信适配层”,把 Base64 先解码成二进制,再调用微信临时素材接口换 MediaID;
- 返回客服时,把 MediaID 塞进 XML 的
<Image>节点,微信就能正常展示。
实现细节:30 行代码打通任督二脉
下面用 Python 演示,框架是 Flask,其他语言思路一致。
1. 扣子回调入口
@app.route("/coze_webhook", methods=["POST"]) def coze_entry(): """ 扣子把用户图片+问题以 JSON 形式 POST 过来 """ payload = request.get_json() img_b64 = payload["image"] # 数据格式:data:image/png;base64,iVBORw0KGgo... question = payload["question"] # 1. 调用扣子 AI 拿到答案 answer = call_coze_llm(img_b64, question) # 2. 把图转成微信可识别的 MediaID media_id = b64_to_wechat_media(img_b64) # 3. 封装微信客服 XML xml_reply = wrap_wechat_xml(media_id, answer) return xml_reply, 200, {"Content-Type": "application/xml"}2. Base64 → 二进制 → 微信临时素材
import base64, requests, os WECHAT_UPLOAD_URL = ( "https://api.weixin.qq.com/cgi-bin/media/upload?" "access_token={}&type=image" ) def b64_to_wechat_media(b64_str: str) -> str: """ 将 base64 图片上传到微信临时素材,返回 MediaID """ # 去掉 data URI 头 header, encoded = b64_str.split(",", 1) binary = base64.b64decode(encoded) # 微信要求 form-data,字段名必须为 "media" files = {"media": ("image.png", binary, "image/png")} token = get_stable_access_token() # 最好全局缓存 7000s url = WECHAT_UPLOAD_URL.format(token) r = requests.post(url, files=files, timeout=10) r.raise_for_status() return r.json()["media_id"]3. 封装微信客服 XML
def wrap_wechat_xml(media_id: str, text: str) -> str: """ 返回微信客服被动消息:图文混排 """ return f""" <xml> <ToUserName><![CDATA[{openid}]]></ToUserName> <FromUserName><![CDATA[{appid}]]></FromUserName> <CreateTime>{int(time.time())}</CreateTime> <MsgType><![CDATA[image]]></MsgType> <Image> <MediaId><![CDATA[{media_id}]]></MediaId> </Image> </xml> """.strip()4. 异常兜底
try: media_id = b64_to_wechat_media(b64_str) except Exception as e: logger.warning("图片上传微信失败: %s", e) # 降级:只返文字,避免用户看到报错 media_id = None性能与安全:别让“适配”变“拖累”
网络耗时
上传微信平均 250 ms(电信机房),对客服体验影响可接受;并发高时可把素材复用——同一图片 MD5 做缓存,3 天内直接读表,省去二次上传。内存占用
Base64 解码瞬间膨胀 33%,但图片上限 2 MB,Flask 线程级处理无压力;若量大,可改用流式解码(base64io)。安全
- 对外接口加签名校验,防止伪造回调;
- 临时素材 3 天失效,敏感图自动过期,降低泄露风险;
- 二进制流不落地磁盘,内存直传微信,减少残留文件。
避坑指南:那些踩过的坑
漏掉
Content-Type: application/xml
微信服务器会重试 3 次,直接把你的 JSON 当乱码,用户看到“公众号暂时无法服务”。Base64 带
\r\n
部分前端库自动换行,解码失败。统一b64_str.replace("\n", "")再处理。图片>2 MB
微信直接拒收,返回 45009。提前在扣子侧做压缩:Pillow 缩放到 1280 px 宽,质量 85,一般压到 500 KB 以内。access_token 多进程竞争
用分布式锁或单点令牌桶,防止同时刷新导致互踢。复用 MediaID 跨用户
微信规定 MediaID 只能发给上传者,A 用户上传的图不能发给 B 用户,否则提示“无效媒体”。
动手验证:三步自测
- 本地起 Flask,Ngrok 映射公网地址;
- 扣子后台填 webhook,上传一张带文字的高清截图;
- 企业微信客服发图,看是否秒回文字答案+图片正常展示。
若仍空白,把日志级别开到 DEBUG,重点看“media_id 是否为空”“微信返回码是不是 0”。
整套流程下来,扣子 AI 的“视觉”能力终于无缝嫁接到微信客服,用户端体验跟原生公众号图片消息没差别。
代码量不大,关键是把“方言”翻译成“普通话”。下次再做跨平台智能体,先拉通两边的“通信协议”,再写业务,能省不少回头路。祝你调试顺利,早点下班!