
去年我在维护一个电商客服机器人的识图问答服务时线上出过一次值得复盘的事故用户上传了一张口红照片问“这支还有货吗”机器人回答“这款口红很滋润适合秋冬季节使用”——推荐语本身没错但它完全绕开了库存问题。更麻烦的是另一个用户上传了产品官网的宣传图机器人把背景里入镜的眉笔也当成主角一并识别进了回答里。这两次事故有个共同根源当时的调用链只靠一段自由文本提示词既没有约束输出结构也没有在模型推理和商品业务逻辑之间画出清晰的结果边界。后来我们整体迁移到 Responses API用 Java 17 重写了服务端调用层把图像输入、结构化输出、函数调用和兜底策略彻底分开了。这篇就记录一下整个改造过程里我认为最值得说的几个点尤其是“结果边界”这个容易被忽视的设计问题。1. 从排查线上事故说起商品问答为什么需要“结果边界”1.1 事故复盘识别、回答、业务数据全混在一层先说那次事故是怎么排查出来的。当晚我们拉到对话日志发现机器人确实收到了图片也确实返回了一大段文本。问题出在三个地方模型把“识别商品外观”和“回答库存问题”揉在了一起提示词里没有明确划分“看到什么”和“查询什么”的责任。库存、价格、优惠这些数据根本不在模型的知识范围内但模型仍然会基于训练语料“猜”出一个答案。多商品场景下图片里出现多个主体时模型默认选了一个“看起来最像主角”的对象没有返回任何关于确定性的置信度信号。这三个问题本质上是同一个问题调用方没有把模型输出当成一个中间结果而是直接当成了终态答案。等价于让一个只看过商品图的实习生直接对库存系统拍板不核对任何业务数据。1.2 Responses API 在商品问答场景里解决了什么Responses API 和早期纯文本补全接口最大的差别是它把多模态输入、结构化输出、函数调用整合成了同一套请求体系。对商品问答这种强业务场景它的价值不在“多一个接口”而在于三点原生支持input_image类型不用再绕道把图片伪装成文本描述。内置json_schema输出格式约束模型必须按我们给出的字段结构返回不能自由发挥。函数调用不再是隐式约定而是可以在请求里声明“什么时候该查库、查库需要哪些参数”由模型在对话中决定触发。Java 17 配合这套接口也正好合适JDK 自带的HttpClient够用record适合承载不可变 DTO文本块可以直接承载 JSON 模板sealed interface适合表达“识别成功 / 需要查库 / 不确定”这种互斥结果类型。整条链路可以不引任何重量级框架这让后续排查和性能调优都简单很多。1.3 这篇分享适合谁如果你正在做电商客服、选品助手、拍照搜同款、商品图片内容审核或者任何“传一张图让模型回答商品问题”的功能这篇应该对你有参考价值。尤其是当你不满足于“能跑通”而是想让结果稳定、字段可控、业务数据不依赖模型“猜”的时候。下文会按调用链的顺序展开从图像进请求体到结构化输出再到结果边界划分最后给出兜底策略。2. Java 17 调用 Responses 图像输入的完整落地链路2.1 环境准备JDK 17 自带能力足够不需要额外框架先说我最终用的技术栈JDK 17、Java 自带的java.net.http.HttpClient、java.util.Base64、javax.imageio.ImageIOJSON 序列化用的 Jackson。没有引入 Spring没有引入任何 AI 框架的 Java SDK。原因很简单Responses 的请求体就是 JSON图像输入就是 Base64 Data URIHttpClient 发一个 POST 请求就能完成多包一层框架反而增加黑盒。Maven 里只需要 Jackson 两个依赖dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.17.2/version /dependency dependency groupIdcom.fasterxml.jackson.datatype/groupId artifactIdjackson-datatype-jdk8/artifactId version2.17.2/version /dependency第二个依赖是为了让 Jackson 能正确处理 Java 17 常见的Optional、Instant等类型。如果不想引任何第三方库也可以用jakarta.json手动拼 JSON但后续反序列化还是会麻烦所以我保留了 Jackson。2.2 图像进入 payloadMIME 类型推导与 Base64 Data URIResponses API 的图像输入走的是input_image类型image_url字段可以直接传 Base64 Data URI。关键代码如下public record ImageSource(String mimeType, String dataUri) { public static ImageSource fromFile(Path imagePath) throws IOException { String mime Files.probeContentType(imagePath); if (mime null) { // 后缀名兜底避免未知类型被服务端拒绝 String fileName imagePath.getFileName().toString().toLowerCase(); if (fileName.endsWith(.jpg) || fileName.endsWith(.jpeg)) { mime image/jpeg; } else if (fileName.endsWith(.png)) { mime image/png; } else { mime image/jpeg; } } byte[] bytes Files.readAllBytes(imagePath); String base64 Base64.getEncoder().encodeToString(bytes); return new ImageSource(mime, data: mime ;base64, base64); } }这里有个容易被忽略的点Files.probeContentType依赖操作系统的文件类型探测机制在部分 Linux 精简镜像上可能返回 null。我们线上就吃过这个亏后来统一加了后缀名兜底逻辑。另外如果你存的是byte[]或InputStream也要在业务层提前确定 MIME 类型不要在 Data URI 里写死image/jpeg因为 PNG 和 WebP 的编码方式会影响服务端解析。拼进请求体之后大概是这样的结构{ input: [ { role: user, content: [ {type: input_text, text: 识别图片中的商品并回答用户问题}, {type: input_image, image_url: data:image/jpeg;base64,...} ] } ] }2.3 大图预处理尺寸缩放与压缩质量阈值直接传原图是最大的隐形坑。用户上传的商品图动辄几 MB手机拍摄的原图甚至十几 MB。Base64 之后体积还会膨胀大约 33%不仅请求慢还会导致服务端因为单张图片 token 超限而报错。我们的预处理策略分三步读取图片尺寸如果最长边超过 1568 像素按比例缩放。重新编码为 JPEG压缩质量设为 0.85。如果压缩后仍超过 1 MB把质量降到 0.7 再试一次。代码大致如下public static byte[] resizeIfNeeded(byte[] data, int maxDimension) throws IOException { BufferedImage src ImageIO.read(new ByteArrayInputStream(data)); int width src.getWidth(); int height src.getHeight(); int maxSide Math.max(width, height); if (maxSide maxDimension) { return data; } double scale (double) maxDimension / maxSide; int newWidth (int) Math.round(width * scale); int newHeight (int) Math.round(height * scale); BufferedImage resized new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB); Graphics2D g resized.createGraphics(); g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g.drawImage(src, 0, 0, newWidth, newHeight, null); g.dispose(); ByteArrayOutputStream bos new ByteArrayOutputStream(); ImageWriter writer ImageIO.getImageWritersByFormatName(jpeg).next(); ImageWriteParam param writer.getDefaultWriteParam(); param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); param.setCompressionQuality(0.85f); writer.setOutput(bos); writer.write(null, new IIOImage(resized, null, null), param); writer.dispose(); return bos.toByteArray(); }注意压缩转码会丢失透明通道和 EXIF 方向信息。如果商品图是白底 PNG转成 JPEG 后白底会保留但如果是透明背景转 JPEG 会默认填充黑色这会严重干扰模型识别。透明背景图必须保留 PNG 格式或者先把透明区域填充为白色再转 JPEG。这里的 1568 是经验值不是绝对的。图片不是越大识别越准商品识别的关键在主体是否清楚、有没有被遮挡而不在像素总量。我们实测过从 2000px 缩到 1568px识别准确率几乎没有变化但请求耗时平均降低了 40%。2.4 文本块与 recordJava 17 语法让请求体组装更优雅Java 17 的文本块非常适合承载 JSON 模板record则适合定义不可变的请求响应结构。我们定义一个ResponsesRequest用于组装请求体public record ResponsesRequest(String model, ListInputItem input, TextConfig text) { public record InputItem(String role, ListContentPart content) {} public sealed interface ContentPart permits TextPart, ImagePart {} public record TextPart(String type, String text) implements ContentPart { public static TextPart of(String text) { return new TextPart(input_text, text); } } public record ImagePart(String type, String imageUrl, String detail) implements ContentPart { public static ImagePart of(String dataUri) { return new ImagePart(input_image, dataUri, auto); } } public record TextConfig(Format format) { public record Format(String type, String name, JsonNode schema, boolean strict) {} } public static String toJson(ResponsesRequest request) { // 这里用 Jackson ObjectMapper 序列化即可record 默认支持 } }用过 record 之后你会发现这类请求体 DTO 写起来非常干净。它天然不可变Jackson 从 2.12 开始也原生支持 record 反序列化不需要额外写构造器。再配合文本块组装最终请求串虽然实际工程里我们直接序列化对象但在调试时文本块模板非常直观String debugTemplate { model: %s, input: [ { role: user, content: [ {type: input_text, text: %s}, {type: input_image, image_url: %s, detail: auto} ] } ] } .formatted(model, prompt, dataUri);2.5 发送请求、解析响应与超时处理HttpClient 的配置很少但超时策略建议单独想清楚。商品问答是一个偏实时的场景用户等在聊天窗口后面我们对单次请求的期望是 5 秒内返回。但如果图片大、问题复杂模型有时会到 10 秒以上。我们的做法是连接超时设 5 秒。读取超时设 30 秒给模型充分时间。应用层再包一层带重试的调用第一次超时重试一次如果重试仍超时走兜底话术。HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(API_ENDPOINT)) .header(Authorization, Bearer apiKey) .header(Content-Type, application/json) .POST(HttpRequest.BodyPublishers.ofString(payload)) .timeout(Duration.ofSeconds(30)) .build(); HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString());响应体里我们最关心的是output数组找到type为message的条目取其content数组再找到output_text字段那就是最终的文本结果。如果启用了函数调用还要扫描function_call类型条目。这些逻辑在下面两节会具体展开。3. 商品问答的结构化输出把“看图说话”变成可入库数据3.1 为什么自由文本回答在客服场景不可用很多人第一次接多模态问答时直接让模型“根据图片回答问题”然后把返回的整段文本发给用户。这在 demo 场景完全没问题但一上生产就会暴露三个问题前端渲染不可控。同一件商品模型今天说“这款口红是哑光质地”明天可能说“这款唇膏色泽饱满”用户感受不到一致性。业务字段拿不到。客服系统需要对回复打标比如“价格类问题”“库存类问题”“售后类问题”如果模型只返回一段话分类还得再做一层 NLP成本和误差都上去了。无法程序化校验。我们想在模型输出里拿到“商品名称”“类目”“置信度”做后续的业务逻辑自由文本完全没法解。解决方案就是结构化输出通过text.format指定 JSON Schema让模型返回固定的 JSON 结构。3.2 用 JSON Schema 钉死输出字段Responses API 里的 JSON Schema 结构化输出格式大致如下{ text: { format: { type: json_schema, name: product_qa_result, strict: true, schema: { type: object, properties: { productName: {type: string, description: 图片中主体商品的名称}, category: {type: string, enum: [美妆, 数码, 服饰, 家居, 食品, 其他]}, highlights: {type: array, items: {type: string}, description: 商品核心卖点最多3条}, confidence: {type: number, description: 识别结果的置信度范围0到1}, needsStockQuery: {type: boolean, description: 用户问题是否涉及库存/价格/优惠}, answerText: {type: string, description: 整合后的最终回答内容} }, required: [productName, category, highlights, confidence, needsStockQuery, answerText], additionalProperties: false } } } }有两个细节值得单独说明第一strict: true模式下additionalProperties必须为false且所有字段必须出现在required里。否则服务端会拒绝请求。这不是“建议”是硬性校验规则。我们第一次接入时漏了additionalProperties: false直接报 400。第二enum能有效压缩模型的自由发挥空间。类目枚举就能把“护肤品”“彩妆”、“美妆”全部归一成“美妆”。商品场景的枚举宁可粗一点也不要追求精确因为模型在封闭枚举上的稳定性远高于开放文本。3.3 Java 17 record sealed interface 反序列化输出拿到 JSON 响应后我们用 record 接收public record ProductQaResult( String productName, String category, ListString highlights, double confidence, boolean needsStockQuery, String answerText ) { public static ProductQaResult fromJson(String json) throws JsonProcessingException { ObjectMapper mapper new ObjectMapper(); JsonNode root mapper.readTree(json); JsonNode textNode findTextOutput(root); return mapper.treeToValue(textNode, ProductQaResult.class); } }这里findTextOutput的作用是遍历output数组找到message类型的条目然后从content里取第一个output_text字段的文本。注意 Responses 的响应里可能同时存在function_call条目和message条目不要默认第一个输出就是文本。sealed interface的用武之地在后续分支逻辑。一个完整的商品问答结果实际上可能有三种终态SUCCESS识别成功且不需要查业务库直接返回答案。NEED_QUERY需要调商品中心查库存、价格后再补全回答。UNKNOWN置信度过低或图片里没有明确商品主体需要转人工或引导用户重新上传。用 sealed interface 表达这三种状态然后用 Java 17 的switch模式匹配消费代码会非常清晰public sealed interface QaOutcome { record Success(ProductQaResult result, String reply) implements QaOutcome {} record NeedQuery(ProductQaResult result, String queryType) implements QaOutcome {} record Unknown(String message, double confidence) implements QaOutcome {} } public String handle(QaOutcome outcome) { return switch (outcome) { case Success s - s.reply(); case NeedQuery nq - handleQuery(nq); case Unknown u - 抱歉我没法确定图片中的商品请重新拍一张清晰些的照片; }; }这种写法的好处是编译器保证你处理了所有分支新增一种终态时所有调用方都会被强制修改不会出现漏判断的情况。3.4 枚举容错与字段缺失策略结构化输出不等于输出永远符合 schema。生产环境里我们踩过两种和预期不符的情况一种是枚举外值。理论上strict: true会限制枚举但我们仍然在代码里做了兜底反序列化时如果category不在枚举内默认归到“其他”并记一条日志告警便于后续补充枚举值。另一种是空数组和空字符串。highlights可能返回[]productName可能返回空字符串。我们判断的逻辑是productName为空或confidence 0.6直接判定为UNKNOWN不再往下走业务查询。public QaOutcome toOutcome(ProductQaResult r) { if (r.productName() null || r.productName().isBlank() || r.confidence() 0.6) { return new QaOutcome.Unknown(low confidence, r.confidence()); } if (r.needsStockQuery()) { return new QaOutcome.NeedQuery(r, r.category()); } return new QaOutcome.Success(r, r.answerText()); }阈值 0.6 不是拍脑袋定的。我们初期设过 0.5发现隔三差五会把背景里的非主体商品当主角调到 0.65 又发现有些模糊但能判断的商品被拦下来客服人工介入率偏高。最终靠两周线上数据对比0.6 是误判率和兜底率平衡最好的值。这个数字需要根据你自己的图片质量和用户上传习惯来调不能照搬。4. 结果边界到底画在哪模型职责、系统职责与兜底策略4.1 边界一模型只负责“看到并理解”不负责“给出业务事实”这是整个改造里最核心的一条原则。我在第一节提到的那次线上事故本质就是模型擅自“编造”了库存。后来我们定了这样一条铁律模型输出里只能包含“从图片中能看见或推断出的内容”——商品外观、品牌标识、类目、卖点、成分配料表上的文字以及“用户问题属于哪个类型”。一切业务事实库存、价格、优惠、是否在售必须由本地系统查询后拼装。翻译成具体实现就是ProductQaResult里压根没有“stockStatus”“price”这类字段。即使模型在训练数据里见过这款口红的定价我们也不允许它输出到结构化结果里。needsStockQuery只负责告诉系统“用户问了库存/价格类问题”真正的库存数字由后面的函数调用去商品中心取。这个边界划分还有一个附带好处业务数据变更时不需要重新调模型。今天口红促销降价昨天模型学会的“价格知识”不会留在我们的链路里因为价格根本不是它输出的。4.2 边界二函数调用的触发时机与参数拼装当needsStockQuery为 true或者用户问题里明显包含“多少钱”“有货吗”“能不能发货”之类的意图时我们在第二轮请求里触发函数调用。Responses API 的函数调用是声明式的。我们在请求里注册一个query_product_desk函数{ tools: [ { type: function, name: query_product_desk, description: 查询商品中心的库存、价格、优惠信息, parameters: { type: object, properties: { productName: {type: string}, category: {type: string}, skuHint: {type: string, description: 图片上可见的SKU或条形码编号没有则为null} }, required: [productName, category, skuHint], additionalProperties: false } } ] }这里有个 Java 工程化的细节函数调用的参数解析仍然用 record 接收。public record QueryProductDeskArgs(String productName, String category, String skuHint) {} public String handleFunctionCall(JsonNode functionCallNode) { JsonNode arguments functionCallNode.get(arguments); QueryProductDeskArgs args mapper.treeToValue(arguments, QueryProductDeskArgs.class); // 调用商品中心查询库存与价格 ProductDeskInfo info productDesk.query(args.productName(), args.category(), args.skuHint()); return buildFinalAnswer(info); }是否需要触发函数调用有两条判定路径第一条第一轮结构化输出里needsStockQuery true这是模型显式给出的信号。第二条needsStockQuery false但问题文本里存在“库存、价格、优惠、发货”关键词这是规则兜底防止模型判断偏差。两条路径都能进函数调用分支。区别是规则命中的场景我们会同时记录一条模型行为日志用来后续评估模型判断的准确率。函数调用返回后最终话术由 Java 侧拼装而不是把返回结果再丢给模型“润色”。原因是省钱也是省延迟。查到的库存数字是精确事实模模型再组织语言只是徒增一次调用。我们自己写一个模板拼接即可String finalReply STR. 商品「\{info.productName}」\{info.stockStatus} 当前价格\{info.price}元 促销说明\{info.promo null ? 暂无 : info.promo} ;注意这里我用了 Java 21 的字符串模板语法STR——如果你还在 Java 17需要退回到String.format或text block formatter。我们的生产环境当时是 Java 17所以实际用的还是String.format这里只是提醒Java 17 能用文本块和formatted()字符串模板要等后续版本不要写出编译不过的代码。4.3 边界三置信度阈值与转人工兜底链路结构化输出里的confidence是模型对“图片主体是什么”的把握不是对回答正确性的把握。这句话要反复强调因为很多人会把confidence误解成“回答可不可信”。我们的兜底链路分三层confidence 0.8直接输出最终回复。0.6 confidence 0.8回复里附加一句“根据图片初步判断仅供您参考”不阻塞回答。confidence 0.6或productName为空进入人工队列同时给用户推送“换张清晰图片重拍”的引导语。这个策略在客服场景里是必须的。商品图片质量参差用户可能在晚上灯光下拍摄可能隔着塑料包装拍可能拍的是商品的背面标签。模型识别对了但置信度不高的情况很常见直接拒绝用户会伤体验硬答又可能误导。折中方案就是“回答但明确提示这是初步判断”。4.4 边界四多商品主体与背景杂物的排除文章开头提到的“背景里的眉笔被识别成主角”事故是另一个需要单独画边界的地方。我们的解决方案是在提示词里显式声明主体判定规则图片中可能存在多个商品。只识别构图中心、面积最大、或用户问题明确指向的那个商品。包装上的小图、背景里的其他商品、倒影中的物体一律视为无关对象。同时在 JSON Schema 里增加一个hasMultipleSubjects字段让模型输出图片里是否存在多个疑似主体hasMultipleSubjects: { type: boolean, description: 图片中是否存在多个可能被误认为主体的商品 }如果hasMultipleSubjects true即使productName合法、置信度够高我们也会在最终回答里加一句“图片中包含多个商品请问您问的是哪一个”——这个设计避免了机器人在多主体图片上“强行选一个”的尴尬。实际效果加了这条之后多主体图片导致的人工介入率从 17% 降到了 6% 左右这是一个非常明显的改善。5. 实测中的意外情况与边界检查清单最后一节分享几个系统上线后真实遇到过的意外你可以当做一个“边界检查清单”来用。第一图片方向问题。手机拍照默认可能有 EXIF 方向信息JPEG 重压缩时如果没处理模型看到的可能是旋转 90 度的图。解决方法是读 EXIF 里的Orientation字段重编码前先旋转。ImageIO 不直接支持 EXIF 方向读取我们用了metadata-extractor一个很小的 Java 库来做这件事。这个问题早期很容易被忽略直到有用户上传了竖拍图识别结果全错。第二空白背景与“无商品”问题。用户可能上传一张空桌面、一张纯白背景图。此时模型容易硬找一个“主体”出来编答案。我们的措施是提示词里显式声明“如果图片中没有清晰、明确的商品主体credit score 输出 0productName 输出空字符串”。配合 Java 侧的空值判断这类请求会直接进人工队列。第三重复提问与追问。用户先传图问“这是什么”接着又追问“那它适合油皮吗”。第一轮的productName需要保留在会话上下文里。Responses API 支持传多轮input历史我们把它实现成了一个会话窗口数组最多保留最近 6 轮避免请求体无限制膨胀。第四图片 token 超限。2.3 提到缩略图策略但还有一类特殊情况用户上传的是超长截图长图像素量不大但宽高比极端。我们的预处理逻辑对这类图有用吗——1568 像素的缩放只是压缩最长边一张 800×4000 的截图压缩后仍然很长仍可能接近 token 限制。最终我们加了“如果宽高比大于 3:1 或小于 1:3裁剪中间区域之后再送识别”的规则实测比其他方案更稳。第五函数调用结果返回格式。当我们把query_product_desk的结果作为function_call_output传回模型时要注意 Responses 的格式要求call_id必须对上输出内容是字符串化的 JSON。Java 侧容易犯的错是把对象直接塞进去导致序列化双引号被转义得乱七八糟。统一用ObjectMapper.writeValueAsString转成字符串再放进output字段能少踩很多坑。关于结果边界我现在脑子里常驻一张职责表每次评审新功能都会对照检查职责归属说明识别图片中的商品主体模型输出 productName、category、confidence判断用户问题的类型模型输出 needsStockQuery、hasMultipleSubjects查询库存、价格、优惠本地系统通过函数调用绝不允许模型编造最终话术拼装本地系统结合模型识别结果与业务数据模板化输出低置信度判定与转人工本地系统根据 confidence 阈值和多主体标识决策这套边界规则上线运行了小半年线上误答率从最开始的 23% 降到了 4% 以内其中很大一部分改进并不是模型换得更强而是把“模型擅长的事”和“模型不擅长的事”分清楚了。如果你也在做类似的商品问答项目我建议你先别急着调提示词先把这张职责表画出来再决定每一件事该让模型做还是让 Java 这侧的代码做。边界画清楚了后续的优化才有稳固的落脚点。