资讯动态

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

发布时间:2026/10/26 14:07:25 来源:尧图企业网站定制
上个月一个做跨境电商的老客户跑来找我说他们的智能客服被用户吐槽得厉害。用户发来一张保温杯的商品图问“这个杯子能装咖啡吗”“会不会漏”文本机器人只能干巴巴回复“亲请查看商品详情页哦”。问题出在哪答案都在图片里客服系统压根没有图像输入的能力。我盘了一下手里的技术栈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 groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.17.2/version /dependencyjava.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.readAllBytesResponses 接口接收图像时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(); HttpResponsebyte[] 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数组里找typemessage的节点再从它的content数组里取typeoutput_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(); HttpResponseString 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图片显示该保温杯内胆为不锈钢材质可以装咖啡。 confidencemedium canAnswertrue6. 问题排查与实测记录我在生产环境踩过的坑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_answertrue但answer内容明显在编造的情况。比如商品图只有一个正面视角用户问“背面有没有提手”模型回答“背面没有提手”。这在图片里根本看不出来。代码层面能做的是加一道校验服务针对特定商品类目维护一份“可判定属性清单”比如保温杯类目下“颜色、杯盖类型、容量标识、内胆材质”可以看图判断“密封性、保温时长”需要实测进入清单即不允许回答。如果模型给出的回答涉及清单外属性就强制把can_answer置为 false。public ProductAnswer guardBoundary(ProductAnswer raw, ListString 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-86.6 实测记录三张商品图的问答效果最后分享一组我当时的实测样本。我在本地准备了保温杯、T恤、运动鞋三张图各提了三个不同的问题看边界控制是否生效。商品图用户问题模型输出摘要边界判定保温杯正面图这个杯子是什么颜色米白色杯盖为灰色正常回答保温杯正面图保温效果能维持多久根据当前图片无法判断正确拒绝保温杯正面图和某品牌比哪个好根据当前图片无法判断正确拒绝T恤平铺图领口是圆领还是V领圆领正常回答T恤平铺图含棉量是多少根据当前图片无法判断正确拒绝运动鞋侧面图鞋底大概有多厚约3厘米正常回答运动鞋侧面图适合跑步还是篮球根据当前图片无法判断正确拒绝从这组测试里能明显看出来规则的约束力是存在的模型在拿不准的时候会默认走“无法判断”这条安全路径。不过我也遇到过几次模型坚持给出推测性答案的情况比如从“鞋底厚 3 厘米”推断出“适合跑步”。这就是为什么不能只依赖 Prompt代码层的清单校验一定要做。最后再分享一个我个人的小技巧线上环境我会把“压缩后的图片 hash 问题文本 hash”作为 Redis 缓存 key相同问题命中缓存直接返回能省掉八九成的模型调用成本。项目上线后客服对这类带图问题的有效回答率从文本机器人的 30% 左右提到了 85% 以上运营那边最满意的反而是“无法判断”这句话的出现频率因为以前用户经常投诉客服瞎编。如果大家也在做类似的多模态商品问答我建议先把边界设计做扎实了再去调模型参数顺序反了后面返工真的很疼。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑