Java 17调用OpenAI多模态接口实现商品图片智能问答

发布时间:2026/10/26 14:07:22
Java 17调用OpenAI多模态接口实现商品图片智能问答

上个月一个做跨境电商的老客户跑来找我,说他们的智能客服被用户吐槽得厉害。用户发来一张保温杯的商品图,问“这个杯子能装咖啡吗”“会不会漏”,文本机器人只能干巴巴回复“亲,请查看商品详情页哦”。问题出在哪?答案都在图片里,客服系统压根没有图像输入的能力。我盘了一下手里的技术栈,Java 17 + Spring Boot 3.x 是老搭档了,决定直接用大模型的多模态接口把这套东西打通。最后选了 OpenAI 的 Responses 接口,用 image input 接收商品图,再配合结构化输出去约束回答边界。这篇文章就把整个项目的技术选型、图片编码链路、Prompt 边界设计、完整代码和我在真实环境里踩过的坑都写出来,供同样在做商品智能问答的开发者参考。

1. 为什么选 Java 17 + Responses 而不是传统方案

1.1 Java 17 在这个项目里真正解决了什么

很多人一听“Java 17 调用图像接口”就觉得这不过是版本号新了一点,其实不是。Java 17 里有两个特性对这类项目帮助特别大,一个是文本块,一个是 record。

文本块最大的价值是写 Prompt 的时候不再需要维护一堆\n和转义字符。商品问答的 Prompt 动辄十几行,如果用老 JDK 8,拼接出来的代码谁也看不懂,后面想调整边界规则还得小心翼翼的。用文本块之后,我可以把系统提示词原样写在代码里,项目里什么规则该改、下次怎么调,一眼就能看明白。

record 则让响应对象变得非常轻。模型返回的结构化结果需要反序列化成业务对象,以前要写一堆 getter/setter,现在一行record ProductAnswer(String answer, String confidence, boolean canAnswer) {}就够了。再加上instanceof模式匹配、增强的switch表达式,解析响应和处理枚举类型的时候代码会短很多。

还有一个现实原因:Java 17 是 LTS 版本,Spring Boot 3.x 最低要求就是它。我们项目本身已经锁定了 17,与其为了“兼容老系统”迁就 8 或者 11,不如直接在新功能上用 17 的特性。实测下来,文本块和 record 都属于“一旦用上就回不去”的功能。

1.2 Responses API 和 Chat Completions 的核心差异

在动手写代码之前,我对比过两条路:一是继续用 Chat Completions 补丁式地塞图像,二是换到 Responses。Responses API 是 OpenAI 后来推出的统一接口,把文本、图像、工具调用、结构化输出都整合在同一个请求模型里。对商品问答这种场景,它最省事的一点是图像和文本可以混在一个 input 数组里,并且结构化输出的支持是原生级别的。

维度Chat CompletionsResponses API
输入结构messages 数组input 数组,支持 input_text / input_image 混合
结构化输出需要手动拼 json 或者额外处理原生支持 text.format 里配 JSON Schema
工具调用需要自己循环判断 tool_calls内置工具,会话状态可统一管理
响应结构message.content 里的多个部分output 数组,按类型取 output_text
上下文管理手动维护历史消息数组支持有状态会话,也可以直接用无状态方式

单看表格可能感受不深,我举个例子。以前做商品问答,如果用户问题里有文本又有图片,我需要在 Chat Completions 的 messages 里手工塞入不同类型的内容块,模型返回后还要自己解析函数调用的结果,代码绕来绕去。Responses 出来以后,一次请求就把商品图和问题文本同时丢进去,返回直接按约定好的 JSON Schema 给结果,解析逻辑也简单得多。对于“定义一个结果边界”这个目标,Responses 确实比老方案精准。

2. 环境准备与依赖配置

2.1 用官方 Java SDK 还是裸 HttpClient

接入大模型接口你很快会面对一个选型问题,要不要引入官方 Java SDK。我的建议是分场景:如果你只是想快速验证能不能跑通,用官方 SDKcom.openai:openai-java很省事;如果你跟我一样需要仔细控制请求体、排查线上问题,建议直接用 JDK 自带的java.net.http.HttpClient。

这次我选择了裸 HttpClient。原因是商品问答场景里我要自己拼图片的 data URI,要挂 JSON Schema,还要控制超时和重试。用 SDK 虽然封装优雅,但它的版本迭代周期很短,不同版本之间的方法命名和参数位置差异很大,线上出了问题还得去翻 SDK 源码才能定位。裸写 HttpClient 虽然代码多一点,但每个请求长什么样我心里完全有数,排查起来直接抓包对比即可。

依赖这块也很干净,除了 Jackson 做 JSON 序列化和反序列化,其他基本不用额外引包。

<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.2</version> </dependency>

java.net.http.HttpClient是 Java 11 开始内置的,Java 17 环境下直接使用即可。Maven 编译级别设成 17,不用加任何第三方 HTTP 库。

2.2 API Key 配置与安全注意事项

API Key 绝对不能写死在代码里,这个我相信大家都知道,但“知道”和“做到”是两回事。我在客户的项目里就见过把 key 直接怼在 application.yml 里的骚操作,后来整个仓库差点被推到公开平台。正确的做法是走环境变量,或者公司配置中心。

public class ApiConfig { public static String apiKey() { String key = System.getenv("OPENAI_API_KEY"); if (key == null || key.isBlank()) { throw new IllegalStateException("请设置环境变量 OPENAI_API_KEY"); } return key; } }

还要提醒一句,日志里千万不能打印完整请求头。Authorization 头里就是你的 API Key,一旦上了日志收集系统,泄露面会非常大。我在代码里统一对敏感信息做了脱敏处理,打印请求体时会把 Authorization 替换成Bearer ***。

另外建议在配置中心里预留model和baseUrl的动态配置项。Responses 接口的模型列表和地址可能会调整,写死的话以后每次升级都要改代码发布,很麻烦。我在项目里用@Value("${openai.model:gpt-4o-mini}")这类方式注入,可以在不重新部署的情况下切换模型。

3. 商品图片输入链路:从图片到接口参数

3.1 Base64 编码为什么要用 Files.readAllBytes

Responses 接口接收图像时,image_url字段支持两种形式:公网 URL 或者 Base64 编码的 data URI。本地图片没有公网地址,所以最稳的做法是把图片字节读出来,转成data:image/jpeg;base64,xxxxx这样的字符串。

读取图片字节时我推荐直接Files.readAllBytes,不要用ImageIO.read再重新编码。因为ImageIO.read会经过一次解码和编码,虽然多数情况下质量损失不大,但商品图细节(比如包装上的小字、材质纹理)经不起反复压缩。直接用原始字节能最大限度保留模型识别所需要的信息。

private String loadImageAsDataUri(Path imagePath) throws IOException { byte[] bytes = Files.readAllBytes(imagePath); String extension = getExtension(imagePath.getFileName().toString()); String base64 = Base64.getEncoder().encodeToString(bytes); return "data:image/" + extension + ";base64," + base64; }

扩展名这里建议从Content-Type或者文件后缀里拿,并且统一转小写。常见的商品图无非 jpg、png、webp 这几种,够用了。如果碰到奇怪后缀,可以先做一次格式探测,确认是合法图片再编码。

3.2 图片压缩的阈值和实现

网上很多教程只说“图不能太大”,却不告诉你多大合适。我实际测试下来的经验是:官方文档对单张图片的体积限制参考值在 5MB 左右,但接口对数据 URI 的整体请求体大小是有隐式约束的,超过一定量就会直接 400。实际项目中我给自己定的红线是单张商品图压缩到 2MB 以内,最好控制在 1MB 左右。

为什么不能完全不压缩?因为商品详情页的原图动不动 5MB、10MB,直接把原始字节塞进 JSON 里,请求体膨胀严重,网络传输慢不说,还容易触发网关超时。模型识图真的需要那么高的分辨率吗?我对比过 2000px 和 800px 的同一张图,对商品属性的识别准确率几乎没有差别。压缩到长边 1600px、质量 0.82 左右的 JPEG,体积基本能压在 500KB 以下。

public static byte[] compressImage(byte[] source, int maxSide, float quality) throws IOException { BufferedImage original = ImageIO.read(new ByteArrayInputStream(source)); if (original == null) { throw new IOException("不是合法的图片文件"); } int width = original.getWidth(); int height = original.getHeight(); int max = Math.max(width, height); if (max > maxSide) { double scale = maxSide / (double) max; width = (int) Math.round(width * scale); height = (int) Math.round(height * scale); } BufferedImage resized = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = resized.createGraphics(); g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g.drawImage(original, 0, 0, width, height, null); g.dispose(); ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next(); ByteArrayOutputStream out = new ByteArrayOutputStream(); ImageOutputStream ios = ImageIO.createImageOutputStream(out); writer.setOutput(ios); ImageWriteParam param = writer.getDefaultWriteParam(); param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); param.setCompressionQuality(quality); writer.write(null, new IIOImage(resized, null, null), param); writer.dispose(); return out.toByteArray(); }

注意这个方法有个细节:压缩成 JPEG 时如果原图带透明通道,透明部分会被黑色填充。商品图如果存在 png 透明底,要么先铺白底,要么继续用 png 格式输出。我当时处理唇膏、眼影这类透明背景商品图时就踩过这个坑。

3.3 网络图片为什么也要转 data URI

如果商品图本身就在 CDN 上,确实可以直接把 URL 传给image_url,不需要转 Base64。我一开始也这么干,后来被现实教育了:商品图带的鉴权参数有效期很短,传到模型侧之后模型去拉图时 URL 已经过期,直接返回“无法分析图片”。还有些图床设置了防盗链,模型服务器去访问会被拦截。

所以我的建议是统一走“下载 → 压缩 → data URI”这条链路。下载时设置连接超时和读取超时,不然遇到慢图床会把整个请求拖垮。

public static byte[] downloadImage(String url, int timeoutSeconds) throws IOException { HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(timeoutSeconds)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(timeoutSeconds)) .header("User-Agent", "Mozilla/5.0") .GET() .build(); HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() / 100 != 2) { throw new IOException("下载图片失败,HTTP " + response.statusCode()); } return response.body(); }

这样每次请求大模型接口前,整个图片输入链路是确定性的,不会因为外网 URL 失效导致生产环境时好时坏。

4. 商品问答的关键:Prompt 边界与结构化输出

4.1 “边界”不写在代码里,而是写进 Prompt

所谓“结果边界”,就是明确告诉模型:哪些能答,哪些不能答,拿不准该怎么处理。商品问答最怕两件事,一是模型编造图片里不存在的信息,比如用户发一张不透明杯子的图,问“里面有茶垢吗”,模型看图看不出来却回答“没有”;二是模型跑题,开始做商品营销、对比竞品甚至讲起了品牌故事。

这些约束用代码很难完全覆盖,写 Prompt 是最直接的方式。我给你看现在线上在用的系统提示词模板:

你是一个商品咨询小助手。你需要根据用户提供的商品图片和问题,给出准确、克制的回答。 边界规则: 1. 只能依据图片中客观可见的信息回答,严禁猜测或补全画面外的内容。 2. 如果图片中看不到、无法判断的信息,只回答"根据当前图片无法判断",不要编造理由。 3. 不要评价商品的好坏、性价比,不要给出购买建议。 4. 不要回答与商品属性无关的任何问题。 5. 回答控制在60字以内,简洁、直接、口语化。

规则 1 和规则 2 是最关键的。规则 1 压缩了模型的自由发挥空间,规则 2 给了模型一个体面的“拒绝路径”,模型在拿不准时会倾向选择“无法判断”而不是硬着头皮猜。

4.2 JSON Schema 让结果边界变成程序能校验的东西

Prompt 约束解决的是“语义层面”的边界,但模型输出到达程序手里时,还有一个“格式层面”的边界。如果返回的是一大段自由文本,程序很难判断它有没有越界。Responses 接口支持在text.format里配置json_schema,配合strict: true,可以让模型每次返回都是符合 Schema 的 JSON,不会多字段、少字段、字段类型不对。

我对商品问答设计了三字段结构:answer存回答内容,confidence表示置信度,can_answer标记模型是否认为自己能回答。

{ "type": "json_schema", "name": "product_answer", "strict": true, "schema": { "type": "object", "properties": { "answer": { "type": "string" }, "confidence": { "type": "string", "enum": ["high", "medium", "low"] }, "can_answer": { "type": "boolean" } }, "required": ["answer", "confidence", "can_answer"], "additionalProperties": false } }

这个 Schema 本身就是“边界”的物质载体。can_answer是布尔开关,程序拿到以后只要判断为 false,就直接返回预置话术“根据当前图片无法准确回答”,不会把模型的一段胡话原样抛给用户。confidence是枚举值,不允许模型自己发明中间状态,方便后续做数据统计。

团队里如果有产品经理问“模型乱说话怎么办”,你不需要用嘴解释,直接把additionalProperties: false甩过去,告诉他这是从输出结构上堵死了多余的自由发挥空间。

4.3 温度、Token 数对稳定性的影响

除了结构约束,生成参数也会直接影响结果边界。温度越高,模型越“兴奋”,越容易跳出 Prompt 设定。我做商品问答测试时对比了 0.1、0.2、0.5、0.8 四档,最终把temperature固定在 0.2 附近。低于 0.1 虽然更保守,但回答会变得生硬,甚至出现重复字符;高于 0.5 就开始出现加形容词、加主观评价的情况了。

max_output_tokens则是回答长度边界。我设过 200,发现部分回答会被截断,JSON 还没闭合就断了,解析直接报错;设到 400 后基本够用,因为商品属性回答本来就该短平快。如果你是在做更复杂的多轮问答,可能需要 800 以上,但那样就要警惕产生大段废话,反而模糊边界。

最终这一组参数我建议作为起步配置:

参数推荐值理由
temperature0.2保守稳定,减少自由发挥
max_output_tokens400覆盖完整 JSON 回答,避免截断
timeout 总超时30 秒图片上传 + 模型生成需要预留时间

5. 完整实现代码:Java 17 调 Responses 的落地写法

5.1 构造请求体的完整代码

理论讲得再多,不如直接看能跑的代码。我这里把请求构造用 Jackson 完成,避免手拼 JSON 字符串时处理特殊字符出错。特别注意一点,图片的 Base64 字符串里面可能会包含+、/、=等特殊字符,如果用字符串拼接方式嵌入 JSON,很容易在转义环节出问题。用ObjectNode构造就不会有这种困扰。

public class ProductQaService { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final String API_URL = System.getenv("OPENAI_API_URL") == null ? "https://api.openai.com/v1/responses" : System.getenv("OPENAI_API_URL"); private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); public String buildRequestBody(String model, String dataUri, String question) throws Exception { ObjectNode body = MAPPER.createObjectNode(); body.put("model", model); ArrayNode input = body.putArray("input"); ObjectNode userMsg = input.addObject(); userMsg.put("role", "user"); ArrayNode content = userMsg.putArray("content"); ObjectNode textNode = content.addObject(); textNode.put("type", "input_text"); textNode.put("text", question); ObjectNode imageNode = content.addObject(); imageNode.put("type", "input_image"); imageNode.put("image_url", dataUri); body.put("temperature", 0.2); body.put("max_output_tokens", 400); ObjectNode textConfig = body.putObject("text"); ObjectNode format = textConfig.putObject("format"); format.put("type", "json_schema"); format.put("name", "product_answer"); format.put("strict", true); ObjectNode schema = format.putObject("schema"); schema.put("type", "object"); ObjectNode properties = schema.putObject("properties"); ObjectNode answerProp = properties.putObject("answer"); answerProp.put("type", "string"); ObjectNode confidenceProp = properties.putObject("confidence"); confidenceProp.put("type", "string"); ArrayNode confidenceEnum = confidenceProp.putArray("enum"); confidenceEnum.add("high"); confidenceEnum.add("medium"); confidenceEnum.add("low"); ObjectNode canAnswerProp = properties.putObject("can_answer"); canAnswerProp.put("type", "boolean"); ArrayNode required = schema.putArray("required"); required.add("answer"); required.add("confidence"); required.add("can_answer"); schema.put("additionalProperties", false); return MAPPER.writeValueAsString(body); } }

写到这里有个地方要提醒:text.format里的 JSON Schema 在部分模型上必须用对应模型的 schema 语法,比如不允许有default字段,required必须全部列出。上线前一定先用官方文档比对,否则接口会返回校验错误。

5.2 发送请求并解析响应

请求发送我们直接用HttpClient。关键点在于,Responses 的返回结构和 Chat Completions 不一样,不是拿一个choices[0].message.content就完事了,而是要从output数组里找type=message的节点,再从它的content数组里取type=output_text的节点。

public ProductAnswer ask(String dataUri, String question) throws Exception { String body = buildRequestBody("gpt-4o-mini", dataUri, question); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(API_URL)) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + ApiConfig.apiKey()) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new RuntimeException("接口调用失败 status=" + response.statusCode() + " body=" + safeLog(response.body())); } JsonNode root = MAPPER.readTree(response.body()); String answerText = extractOutputText(root); return parseAnswer(answerText); } private String extractOutputText(JsonNode root) { JsonNode output = root.get("output"); if (output == null || !output.isArray()) { return ""; } for (JsonNode node : output) { if ("message".equals(node.path("type").asText())) { JsonNode content = node.get("content"); if (content != null && content.isArray()) { for (JsonNode item : content) { if ("output_text".equals(item.path("type").asText())) { return item.path("text").asText(); } } } } } return ""; }

parseAnswer就是把模型返回的 JSON 字符串反序列化成 record。这里我加了一层防护,如果模型返回的文本不是合法 JSON,直接抛异常,由上层兜底成“无法判断”的文案。

private ProductAnswer parseAnswer(String answerText) throws Exception { if (answerText == null || answerText.isBlank()) { return new ProductAnswer("根据当前图片无法判断", "low", false); } JsonNode json = MAPPER.readTree(answerText); return new ProductAnswer( json.path("answer").asText(), json.path("confidence").asText("low"), json.path("can_answer").asBoolean(false) ); }

5.3 一个可直接跑通的最小 Demo

把前面几块拼起来就是完整的最小实现,商品图路径 + 问题文本,调用ask就能得到结构化答案。

public static void main(String[] args) throws Exception { ProductQaService service = new ProductQaService(); Path imagePath = Paths.get("/tmp/water_cup.jpg"); String dataUri = service.loadImageAsDataUri(imagePath); ProductAnswer answer = service.ask(dataUri, "这个保温杯可以装咖啡吗?"); System.out.println("answer=" + answer.answer()); System.out.println("confidence=" + answer.confidence()); System.out.println("canAnswer=" + answer.canAnswer()); }

如果一切正常,你会看到类似这样的输出:

answer=图片显示该保温杯内胆为不锈钢材质,可以装咖啡。 confidence=medium canAnswer=true

6. 问题排查与实测记录:我在生产环境踩过的坑

6.1 401 鉴权失败与环境变量配置问题

这个是最容易踩的,尤其是部署到 Linux 服务器时。本地环境变量明明配好了,服务器上就是报 401。我排查了半天,发现是因为启动脚本里把环境变量写在了sudo之前,导致sudo java -jar跑起来的时候环境变量根本没传进去。另外还有一次是 Windows 服务注册的时候用了系统账号,读不到用户级别的环境变量。

排查建议:先写一个最小的测试代码只打印apiKey().substring(0, 5),确认程序能拿到值,再去看 401 的报错是不是 Key 本身失效。注意日志不要打印完整 Key。

6.2 图片格式不对导致 400

有一类 400 错误很容易被忽略:图片本身是合法的,但 data URI 里的 MIME 类型和实际字节不符合。比如一张实际编码是 webp 的文件,后缀却叫.jpg,程序拼出来的data:image/jpg;base64,...接口不认。所以更稳妥的做法是先通过ImageIO.read或Files.probeContentType探测真实类型,再拼 data URI。

还有一种是图片压缩后ImageWriter写 JPEG 时,如果原图是 CMYK 色彩空间,得到的 JPEG 可能出现颜色异常,模型识别会出错。这种情况建议统一转成 RGB 再压缩。

6.3 模型输出越界了怎么办

即使加了 Prompt 和 Schema,我还是遇到过模型返回can_answer=true但answer内容明显在编造的情况。比如商品图只有一个正面视角,用户问“背面有没有提手”,模型回答“背面没有提手”。这在图片里根本看不出来。

代码层面能做的是加一道校验服务:针对特定商品类目维护一份“可判定属性清单”,比如保温杯类目下“颜色、杯盖类型、容量标识、内胆材质”可以看图判断,“密封性、保温时长”需要实测,进入清单即不允许回答。如果模型给出的回答涉及清单外属性,就强制把can_answer置为 false。

public ProductAnswer guardBoundary(ProductAnswer raw, List<String> allowedAttributes) { boolean containsUnallowed = allowedAttributes.stream() .noneMatch(attr -> raw.answer().contains(attr)); if (raw.canAnswer() && containsUnallowed) { return new ProductAnswer("根据当前图片无法判断", "low", false); } return raw; }

这段代码是底线兜底。Prompt 和 Schema 管住大多数情况,清单校验管住极端情况。

6.4 Base64 特殊字符与中文乱码

如果你在调试时发现请求体打印出来 Base64 字符串中间有空格,或者是中文问题变成了问号,多半是字符集问题。HttpRequest.BodyPublishers.ofString(body)默认用 UTF-8,理论没问题,但如果前面的 JSON 构造环节用了平台默认编码,就可能出现乱码。建议在构造ObjectMapper时显式指定 UTF-8。

用 Jackson 构造 JSON 的优势在这里体现得很明显。手动拼字符串很容易忘记对"和\转义,Base64 里的+虽然不会破坏 JSON 结构,但如果在传给java.net.URL时没有URLEncoder处理,就可能变成空格,导致请求被拒。所以我坚持所有字段都进入ObjectNode,由 Jackson 统一序列化。

6.5 故障排查速查表

我在项目里整理过一张排查表,遇到问题直接对照着查,比翻文档快得多。

现象常见原因排查方向解决方案
401 Unauthorized环境变量没传进去 / Key 失效打印 Key 前几位检查启动脚本和环境
400 Bad Request图片 MIME 类型或体积超限打印 data URI 头尾校正类型、压缩图片
请求超时图片太大 / 网络慢看日志耗时分布压缩到 1MB 内
解析 JSON 报错max_output_tokens 截断看原始返回调大 tokens
回答明显编造Prompt 边界不够强抽查越界样本加清单校验兜底
中文乱码编码问题检查请求体内容统一 UTF-8

6.6 实测记录:三张商品图的问答效果

最后分享一组我当时的实测样本。我在本地准备了保温杯、T恤、运动鞋三张图,各提了三个不同的问题,看边界控制是否生效。

商品图用户问题模型输出摘要边界判定
保温杯正面图这个杯子是什么颜色?米白色,杯盖为灰色正常回答
保温杯正面图保温效果能维持多久?根据当前图片无法判断正确拒绝
保温杯正面图和某品牌比哪个好?根据当前图片无法判断正确拒绝
T恤平铺图领口是圆领还是V领?圆领正常回答
T恤平铺图含棉量是多少?根据当前图片无法判断正确拒绝
运动鞋侧面图鞋底大概有多厚?约3厘米正常回答
运动鞋侧面图适合跑步还是篮球?根据当前图片无法判断正确拒绝

从这组测试里能明显看出来,规则的约束力是存在的,模型在拿不准的时候会默认走“无法判断”这条安全路径。不过我也遇到过几次模型坚持给出推测性答案的情况,比如从“鞋底厚 3 厘米”推断出“适合跑步”。这就是为什么不能只依赖 Prompt,代码层的清单校验一定要做。

最后再分享一个我个人的小技巧,线上环境我会把“压缩后的图片 hash + 问题文本 hash”作为 Redis 缓存 key,相同问题命中缓存直接返回,能省掉八九成的模型调用成本。项目上线后,客服对这类带图问题的有效回答率从文本机器人的 30% 左右提到了 85% 以上,运营那边最满意的反而是“无法判断”这句话的出现频率,因为以前用户经常投诉客服瞎编。如果大家也在做类似的多模态商品问答,我建议先把边界设计做扎实了,再去调模型参数,顺序反了,后面返工真的很疼。