客服对话里,客户经常只发一张截图就不说话了。
那张图往往是唯一的故障证据。而「客户发完图就没下文」本身也是一个信号——通常意味着问题已经解决、或者他在等你看图。
但最早的实现里,这类消息被整条丢掉了:提取文本时把图片 URL 整段删除,删完文本为空,if (!text) continue——这条消息就不存在了。
第一步:先留一个占位
改成留一个 [图片] 标记。4 个字符,成本可以忽略,prompt 里告诉模型怎么理解它。
这一步很小,但它把「这里发生过一件事」还给了模型。一条被删掉的消息和一条内容为空的消息,对模型来说是完全不同的两段对话。
第二步:真的把图给模型看
接入多模态之前,我的直觉是:
图片是带签名的 OSS 地址,会过期;从页面
fetch还会跨域失败。所以必须在 content script 里先把图片取下来、转成 base64,再塞进请求。
这是错的。 照做的话要白写一整条取图 + 编码链路,还要把几百 KB 的 base64 塞进消息体。
两条证据推翻了它:
① 同类产品就是直接传 URL 的。 翻了一下宿主官方的同类功能,它直接把原始的签名 URL 塞进 image_url.url,全文零 base64——说明模型网关是在服务端拉图的。
② fetch 失败 ≠ URL 不可用。 在页面里 fetch 那些签名 URL,确实报 Failed to fetch。但同一个 URL 用 <img> 能正常加载,解出 2048×1536。签名也没过期。
失败纯粹是浏览器的 CORS,和服务端能不能拉到毫无关系。
判断一个资源可不可达,要用一条不受 CORS 约束的路径交叉验证(
<img>、<script>)。否则会把 CORS 误诊成「资源失效」,然后设计出一整套多余的绕行方案。
所以最终的实现很简单:
// 标准 OpenAI 形态:content 从 string 放宽成 string | ContentPart[]type ContentPart = | { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } };
function buildUserContent(convo: string, images: string[], visionOn: boolean): string | ContentPart[] { // ★ 无图或开关关闭 → 返回纯字符串,与改造前逐字节一致,零回归 if (!visionOn || !images.length) return convo; return [ { type: 'text', text: convo }, ...images.map((url) => ({ type: 'image_url' as const, image_url: { url } })), ];}类型从 string 放宽成 string | ContentPart[] 之后,类型检查零报错——说明没有任何消费方假设它是字符串,模型客户端对 messages 本来就是原样透传。这是一个很好的信号:改动的影响面就是你以为的那么大。
节流:最多 3 张,取最后 3 张
- 最多 3 张:图片的 token 成本远高于文本;
- 取最后 3 张:结论通常在对话后半段;
- 每张在文本流里标成
[图片 IMG_1],模型引用时对得上; - 没被选中的图保留裸
[图片]占位——让模型知道「这里还有图但你看不到」,比假装它不存在更诚实。
用真实会话验过三类:2 张图的(两个标记落在客户和客服各一条上,上下文连贯)、4 张图的(正确截断到 3 张,第 4 张退回裸占位)、0 张图的(正确退化成纯字符串,零行为变化)。
开关留给用户
多模态有一个用户自己可以关的开关,不上云。它不是业务口径,是成本和降级的阀门:批量任务连跑几十条时,图片可能触发网关限流——真出问题时用户能自己关掉,退回纯文本。
第三步:文档和视频别伪装成图片
上线一个月后,收到反馈:「模型只会把这种链接认成图片,我需要它识别更多内容。」
第一反应是模型的识别能力不够。不是。
查下来是取数层就把信息毁了:提取逻辑只认 type === 'image',其它媒体消息落到了文本提取分支——取出来的是一个文件 URL,然后被「删掉图片链接」的那条规则命中,统统变成了 [图片]。
于是客户发的 PDF、Word、录屏,在模型眼里全都是「一张图片」。文件名整个丢失了——而文件名往往是那条消息里唯一有语义的东西。
在几十个真实会话、八百多条消息里抓到的文件和视频消息,旧实现喂给模型的文本无一例外是 [图片]:
| 真实消息 | 旧版模型看到的 | 新版模型看到的 |
|---|---|---|
| 一份培训文档(.pdf) | [图片] | [文件:培训问答.pdf] |
| 一份通知函(.docx) | [图片] | [文件:变更通知函.docx] |
| 一段录屏(.mp4) | [图片] | [视频:屏幕录制.mp4] |
| 一段手机录像(.MOV) | [图片] | [视频:IMG_0001.MOV] |
新版:图片照旧走多模态;文件和视频只给文件名占位,不喂多模态——模型读不到内容,但知道「客户发了一份叫这个名字的文件」。
两条硬结论
① 媒体类型要按扩展名判,不能只看消息类型。 一段 1MB 的 .MOV 录屏,走的是 type: 'file'。只按 type 分类会把视频当文档。
② 按 type 过滤系统消息,不要赌文本正则。 满意度邀请、关闭会话这几类操作记录,它们的「系统消息」标记字段是空串,不是预期的那个值。旧代码只能靠一条文本正则兜——「客服昵称后面跟着『发送满意度』」,而那条正则限制了昵称最多 8 个字。昵称超过 8 个字就漏了。 既然 type 已经明确标出了这是操作记录,就按 type 过滤。
一个静默的真 bug:重试时图片被丢
顺带修掉了一个很阴险的问题。
首次调用模型时,传的是带图片的 userContent;重试分支却写死回了纯文本。于是「首次失败 → 重试成功」这条路把图全丢了。而且文本里的 [图片] 没有编号,prompt 里「按 IMG_1… 对应上文」的说明也一并落空。
表现是:多模态时灵时不灵,而且完全静默——日志不会告诉你少了图。
重试分支是最容易和主分支漂移的地方。它应该复用主分支构造请求的同一个函数,而不是自己再拼一遍。
prompt 也要跟着改
旧 prompt 里有一句写死的「当前无法读取图片内容」。上了真多模态之后,这句就等于叫模型无视已经发给它的图。
拆成了三条:
[图片 IMG_k]= 已经发给你了,直接看图;[图片]= 这里有图,但没发给你(超限或开关关了);[文件:x]/[视频:x]= 读不到内容,只有文件名可用。
而且这条改动要代码和云端配置两边都改——提示词线上以云端为准,只改代码里的默认值等于没改(细节在远程配置那篇)。
这件事前后三步,每一步修的其实都是同一个东西:模型拿到的输入,和真实发生的事情之间的差距。
一条被删掉的图片消息、一个被改写成「图片」的 PDF、一次丢了图的重试——模型在每一种情况下都表现得「不够聪明」。但它从来没有机会看到真实的输入。
在怀疑模型之前,先打印一下你到底喂给了它什么。