ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 17 接入多模态 Responses 图像输入:商品问答场景的工程实践与边界

Java 17 接入多模态 Responses 图像输入:商品问答场景的工程实践与边界 1. 从一次商品问答需求说起为什么要用 Java 17 接 Responses 图像输入去年底接了个电商侧的需求场景很具体用户在商品详情页上传一张实拍图问这个和页面上的是一回事吗我收到的这个颜色对不对这个配件是不是原装的。运营那边希望系统能直接看图回答而不是让用户打字描述半天。第一反应是走传统的图像分类或者 OCR 加关键词匹配但实测下来问题很明显——用户拍的照片角度乱、光线差、背景杂纯分类模型给不出是不是同一款这种需要语义理解的判断OCR 又只能抠文字遇到纯外观比对就歇菜。后来把思路转到多模态大模型的 Responses 接口上让模型直接吃图片加文本问题输出自然语言答案。技术栈定在 Java 17原因有三一是团队现有服务全是 Spring Boot 体系Java 是唯一能快速落地的语言二是 Java 17 是 LTS 版本records、sealed class、pattern matching 这些特性写起 DTO 和响应解析来比 Java 8 舒服太多三是 HTTP Client 在 Java 11 之后已经内置不用再引 OkHttp 或 Apache HttpClient少一个依赖少一份维护成本。这篇内容适合两类人看一类是正在做多模态能力接入的后端工程师尤其是被图片怎么传结果怎么解析边界怎么兜这几个问题卡住的另一类是产品或者技术负责人想搞清楚这套方案到底能答什么、不能答什么避免上线后被用户问出幻觉答案。我会把整个链路拆开讲包括请求怎么构造、图片怎么编码、响应结构怎么解析、以及最关键的——结果边界到底卡在哪里。这些不是文档里抄的是我自己踩过一遍之后总结出来的。2. Responses 图像输入的请求构造Java 17 下的编码与传输细节2.1 图片到底以什么形式塞进请求体多模态接口传图主流就两种方式传 URL 或者传 base64。URL 方式看起来省事但商品问答场景里用户上传的图往往在临时存储上有鉴权、有有效期模型侧拉取经常失败而且多一次外网往返延迟不可控。所以我最终选了 base64 内联。base64 的坑在于体积膨胀。一张 1MB 的 JPEG编码后大约 1.37MB再套进 JSON 字符串整个请求体可能到 1.5MB 以上。Java 17 里读文件用Files.readAllBytes一把梭没问题但要注意别用FileInputStream逐字节读再拼接那样在几百 KB 以上就会明显变慢。下面是我实际用的编码方法public static String encodeImageToBase64(Path imagePath) throws IOException { byte[] bytes Files.readAllBytes(imagePath); return Base64.getEncoder().encodeToString(bytes); }Base64.getEncoder()用的是标准编码不带换行。有些老接口要求 MIME 格式每 76 字符换行那就得用Base64.getMimeEncoder()但 Responses 这类接口一般吃标准编码别自作聪明加换行否则服务端解码可能报错。2.2 请求体的 JSON 结构怎么拼才不容易出错请求体本质就是一个 JSON图片和文本按顺序放进 content 数组。我用 Java 17 的 record 来定义结构配合 Jackson 序列化比手拼字符串靠谱得多public record ImageUrl(String url) {} public record ContentPart(String type, String text, ImageUrl image_url) {} public record ChatRequest(String model, ListContentPart messages) {}这里有个细节值得说type字段决定这一项是文本还是图片文本项只填text图片项只填image_url其余字段留 null。Jackson 默认会把 null 字段也序列化出来导致请求体里出现一堆text:null虽然多数服务端能容忍但干净点总没错加个JsonInclude(JsonInclude.Include.NON_NULL)就解决了。图片项的image_url里url 字段填的是data:image/jpeg;base64,xxxxx这种 Data URI 格式前缀不能省。我一开始只填了纯 base64 串服务端直接返回 400排查了半天才发现是缺了 MIME 前缀。这个前缀里的图片类型要和实际文件一致JPEG 就写image/jpegPNG 写image/png写错了有些服务端会解码失败。2.3 用 Java 17 内置 HttpClient 发请求Java 11 引入的java.net.http.HttpClient在 17 上已经很成熟支持 HTTP/2、异步、超时控制完全够用。我的封装大概长这样HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(endpoint)) .header(Content-Type, application/json) .header(Authorization, Bearer apiKey) .timeout(Duration.ofSeconds(60)) .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) .build(); HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString());超时设置要分两层connectTimeout管建连timeout管整个请求。图像请求因为体大、模型推理慢整体超时给到 60 秒比较稳妥给 30 秒经常在高峰期超时。另外BodyPublishers.ofString一定要显式指定 UTF-8否则中文问题在请求体里可能变成乱码模型收到的就是一堆问号。提示如果图片超过 4MB建议先在客户端压缩再编码。我实测把长边压到 1024 像素、JPEG 质量 0.8体积能降到 200KB 以内模型识别效果几乎无损但请求耗时能砍掉一半以上。3. 商品问答场景下的提示词设计与多轮组织3.1 为什么不能直接把用户问题丢给模型用户问这个和页面上的是一回事吗模型只看到一张图根本不知道页面上的指什么。所以提示词里必须把商品上下文补进去。我的做法是把商品标题、关键属性、主图描述拼成一段背景再附上用户原话最后加一句输出约束。一个实际用的模板大概是这样你是一个商品比对助手。以下是商品页面的信息 标题xxx 颜色xxx 材质xxx 用户上传了一张实拍图并提问{用户问题} 请基于图片和商品信息回答只回答一致不一致无法判断三种结论之一 并用一句话说明理由。如果图片模糊或角度无法判断必须回答无法判断。这个约束很关键。不加约束的时候模型特别爱和稀泥明明图很糊也能编出一段看起来基本一致的分析。强制三选一之后答案的可消费性高很多前端也好做展示。3.2 多轮对话里图片要不要重复传商品问答经常是多轮的用户追问那这个划痕算正常吗。这时候有两种做法一是每轮都把图片重新传一遍二是靠服务端的会话状态记住图片。前者费流量但状态无依赖后者省流量但要求服务端支持会话保持。我选的是前者原因很现实我们的服务是无状态的横向扩容方便而且图片压缩后也就一两百 KB重复传的成本可以接受。如果你们量特别大可以考虑在网关层做个图片缓存用 hash 做 key同一张图短时间内重复请求直接命中缓存省掉重复编码和传输。多轮组织时历史消息里的图片项可以只保留第一轮的后续轮次用文本描述代替比如用户之前上传的图片这样能显著降低 token 消耗。实测一个五轮对话全带图的话 token 能到八千多只留首轮图能压到三千以内。3.3 提示词里的边界声明要写死这是我最想强调的一点。商品问答最容易出的问题不是答错而是自信地答错。用户上传一张完全无关的图模型也能给你分析出个所以然。所以在提示词里必须写死边界图片与商品无关时直接回答图片与商品无关图片模糊、遮挡严重时回答无法判断涉及价格、真伪鉴定、法律责任的一律回答建议咨询人工客服这三条写进去之后线上幻觉率肉眼可见地下降。别指望模型自己懂分寸分寸是提示词给的。4. 响应解析从 JSON 到业务对象的那一层4.1 响应结构长什么样Responses 类接口的返回一般是嵌套的最外层有 id、model、choices 或者 output 数组真正的文本藏在 choices[0].message.content 或者 output[0].content[0].text 里。不同服务商字段名有差异但结构逻辑一致。我用 Jackson 的JsonNode先做宽松解析再映射到业务对象避免字段一变就抛异常JsonNode root objectMapper.readTree(responseBody); JsonNode contentNode root.path(choices).path(0).path(message).path(content); String answer contentNode.asText();用path而不是get是因为path在字段缺失时返回 MissingNodeasText给空串不会 NPE。生产环境里响应结构偶尔会因为服务端升级变动这种防御式解析能救命。4.2 把自然语言答案转成结构化结果模型返回的是一句话但业务需要的是枚举。我在提示词里约束了输出格式解析时用简单的字符串匹配public enum CompareResult { CONSISTENT, INCONSISTENT, UNKNOWN, IRRELEVANT } public static CompareResult parse(String answer) { if (answer.contains(不一致)) return CompareResult.INCONSISTENT; if (answer.contains(一致)) return CompareResult.CONSISTENT; if (answer.contains(无关)) return CompareResult.IRRELEVANT; return CompareResult.UNKNOWN; }注意判断顺序不一致必须放在一致前面否则不一致会先命中一致的子串这是个经典的低级错误我第一版就栽在这。更稳的做法是用正则加词边界或者干脆让模型直接输出枚举值比如要求它只回CONSISTENT或INCONSISTENT解析成本几乎为零。4.3 异常响应的分类处理响应不只是成功和失败两种。实际会遇到限流429、内容审核拦截、模型拒答、超时。这几种要分开处理情况表现处理策略限流HTTP 429指数退避重试最多 3 次审核拦截返回空内容或特定标记直接降级到人工不重试模型拒答返回我无法回答转成 UNKNOWN提示用户换图超时连接或读取超时重试一次仍失败则降级重试一定要加退避别原地疯狂重试那样只会把限流越撞越死。我用的是Thread.sleep(500 * (1L attempt))第一次等 1 秒第二次 2 秒第三次 4 秒。5. 结果边界这套方案到底能答什么、不能答什么5.1 能力边界模型看得懂什么实测下来模型在整体外观比对颜色判断明显款式差异上表现不错准确率能到八成以上。但有几类它天生不擅长精细纹理和材质真皮和人造革在照片上模型经常分不清微小瑕疵一毫米的划痕压缩后基本看不见尺寸和比例没有参照物时模型判断不了大小文字细节吊牌上的小字OCR 都可能糊模型更悬所以商品问答的定位要摆正——它是初筛和辅助不是终审。涉及退换货、质量纠纷的必须有人工兜底。5.2 数据边界图片质量决定上限再强的模型也救不了一张糊图。我在入口做了几道过滤分辨率低于 300x300 的直接拒收提示用户重拍图片体积超过 8MB 的先压缩纯色图、纯文字截图做简单检测明显不是实拍的走另一条链路这些过滤放在编码之前能省掉大量无效的模型调用。上线第一个月光这一层过滤就挡掉了约 15% 的无效请求成本直接降下来。5.3 合规边界哪些问题不能接商品问答里有些问题碰不得比如这是不是正品能不能开发票有没有质量问题。这些涉及鉴定和承诺模型给不出负责任的答案。我的做法是在提示词层面直接拦截命中这些关键词就返回固定话术引导到人工。这不是技术问题是产品边界问题但必须在代码里落实不能靠模型自觉。6. 上线后踩过的坑与性能调优6.1 图片编码拖慢了整个请求最初我在主线程里做 base64 编码一张 2MB 的图编码要 100 多毫秒高并发下线程池很快打满。后来把编码挪到独立的线程池用CompletableFuture异步做主流程只等结果CompletableFutureString encodeFuture CompletableFuture.supplyAsync( () - encodeImageToBase64(path), encodeExecutor);编码线程池大小设成 CPU 核数的两倍实测吞吐提升明显。这个优化不复杂但收益很直接。6.2 大请求体导致的连接复用失效HTTP/2 下连接复用本来很香但请求体一大复用率就下降。我观察到的现象是小请求走同一个连接大请求经常新建连接。解决办法是控制单连接上的并发流数量别让一个大请求把连接占死。Java HttpClient 默认的 HTTP/2 配置一般够用但如果你们并发特别高可以考虑把大图请求和小文本请求分到不同的 client 实例避免互相影响。6.3 缓存能省掉大量重复调用同一张商品图不同用户可能反复上传问同样的问题。我在网关层加了一层结果缓存key 用图片 hash 问题 hashTTL 设 24 小时。命中率比想象中高尤其是热门商品缓存命中能到三成以上。缓存的是结构化结果不是原始响应省内存也好维护。6.4 监控要盯的几个指标上线后我盯这几个数请求成功率、平均耗时、P99 耗时、降级率、缓存命中率。其中 P99 最能反映问题平均值好看但 P99 飙高说明有长尾请求在拖后腿通常是超大图或者模型侧抖动。降级率超过 5% 就要警惕要么是模型不稳定要么是提示词需要调。7. 一些实操心得Java 17 接 Responses 图像输入这件事技术难度不算高难的是把边界想清楚。我最大的体会是多模态能力落地七分靠工程约束三分靠模型本身。提示词里的边界声明、入口的图片过滤、响应的分类处理、结果的缓存和降级这些工程手段决定了系统稳不稳而不是模型强不强。另外提醒一句别一上来就追求全自动。商品问答这种场景先做成模型给建议、人工做确认的半自动模式跑一段时间积累数据看清楚模型在哪些品类、哪些问题上靠谱再逐步放开自动化比例。我见过太多团队一上来就全自动结果被用户投诉到关停。稳一点慢一点反而走得远。如果你们也在做类似的东西建议先把图片编码和请求构造这两块打磨扎实这是整条链路的地基。地基不稳后面提示词调得再花哨也白搭。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进