客服对话里,客户经常只发一张截图就不说话了。

那张图往往是唯一的故障证据。而「客户发完图就没下文」本身也是一个信号——通常意味着问题已经解决、或者他在等你看图。

但最早的实现里,这类消息被整条丢掉了:提取文本时把图片 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、一次丢了图的重试——模型在每一种情况下都表现得「不够聪明」。但它从来没有机会看到真实的输入。

在怀疑模型之前,先打印一下你到底喂给了它什么。