Spring AI | 结构化输出 & 多模态 一篇讲透

本篇目标 :让 ChatClient 的输出直接是 Java record (前端不用再正则解析 JSON),再让 AI 看得懂宠物的皮毛照片自动出初判结论。

技术栈:Spring AI 2.0 + Spring Boot 4 + JDK 21,文本模型 DeepSeek;多模态切换到 OpenAI 兼容的视觉模型

前置知识:《ChatClient & Prompt 篇》、《Function Calling》、《RAG & VectorStore》


01 一个反复出现的痛点:模型的"自由文本"进了 Java 就是场灾难

在前面的案例里,你已经见过让模型"输出症状单"的能力------

bash 复制代码
PetSymptom symptom = chatClient.prompt()
        .user("...")
        .call()
        .entity(PetSymptom.class);    // 看似一行解决,但里面坑不少

当时只是轻描淡写地提了一句。这期我们把"让模型输出 Java 对象"这件事彻底讲透,再顺手把"让模型看图"也一并补上。

💡 类比

模型的"自由文本回答"就像口述 :生动、有温度,但没法被程序消费 。 结构化输出是给口述配一位速记员 ------模型说的每句话都被实时整理成清单,代码可以直接遍历、存库、做判断

多模态则是给口述配一台投影仪------模型不再只听你说什么,还能"看见"你拍的皮毛照片。


02 入门:.entity(Class),三步把"自由文本"变成 record

2.1 定义 record 作为目标类型

bash 复制代码
package com.pet.clinic.dto;

import java.util.List;

public record PetSymptom(
        String species,         // 物种:猫/狗/兔...
        String mainIssue,       // 主要症状描述
        String duration,        // 持续时间,如"3 天"
        String urgency,         // 紧急程度:low / medium / high
        List<String> advice     // 给宠主的初判建议
) {}

2.2 一次调用

bash 复制代码
PetSymptom symptom = chatClient.prompt()
        .user("我家橘猫已经吐了 3 天,没精神,请给出症状摘要")
        .call()
        .entity(PetSymptom.class);

symptom.urgency() 就是 "high"symptom.advice() 就是一个 List<String>直接可路由、可入库、可 if-else

2.3 背后发生的三件事

# 框架做的事 作用
SchemaGenerator 把 record 转成 JSON Schema 给模型一份"填空模板",告诉它要输出什么字段、什么类型
JSON Schema 作为系统提示注入 prompt 模型按模板生成 JSON,而不是"自由发挥"
BeanOutputConverter 把模型返回的 JSON 反序列化为 record 直接当 Java 对象用,不再 ObjectMapper.readTree(...)

这一步对所有模型通用 ------不需要模型支持 OpenAI 的 response_format、不需要厂商提供"结构化输出"特性。缺点是软约束 :模型被劝着"请按这个 JSON 输出",但没有强制,偶尔会多一个字段、少一个字段、或者包一层 markdown 代码块。

💡 类比

这三步就像让模型做"语文考试中的填空题 ":题目(JSON Schema)写清楚要填哪些空、改卷人(反序列化器)按模板批改。但填空题毕竟不是选择题,学生还是可能答歪


03 泛型:.entity(ParameterizedTypeReference)

.entity(Class) 只能处理"非泛型"------如果想要 List<PetSymptom>Map<String, PetSymptom>,必须用 ParameterizedTypeReference(因为 Java 泛型擦除):

bash 复制代码
// 列表:一次给三个症状单
List<PetSymptom> symptoms = chatClient.prompt()
        .user("请为常见的猫、狗、兔子各生成一条典型症状记录")
        .call()
        .entity(new ParameterizedTypeReference<List<PetSymptom>>() {});

// 映射:按物种分组
Map<String, PetSymptom> bySpecies = chatClient.prompt()
        .user("请按物种给出 3 条症状记录")
        .call()
        .entity(new ParameterizedTypeReference<Map<String, PetSymptom>>() {});

一个小坑:泛型套泛型时,模型的 JSON 嵌套层数一多就容易出错。能扁平就扁平,能拆成两次调用就别硬塞。


04 可靠性开关:EntityParamSpec 让"填空题"变"选择题"

EntityParamSpec 暴露两个独立、可组合的开关,让结构化输出从"看模型心情"变成"程序可预期":

4.1 validateSchema() ------ 自愈重试

开了这个开关,Spring AI 会自动校验 模型返回的 JSON 是否符合 schema,不符合就把具体错误 追加到 prompt 里,让模型重做(默认最多 3 次):

bash 复制代码
PetSymptom symptom = chatClient.prompt()
        .user("...")
        .call()
        .entity(PetSymptom.class, spec -> spec.validateSchema());

错误信息长这样:"The required field urgency is missing"------把这条塞回 prompt,模型就明白"哦这次得补上"。

4.2 useProviderStructuredOutput() ------ Provider 侧强制

开启后,schema 会作为 API 级参数 发给模型厂商,由厂商在推理引擎层 强制输出(OpenAI 的 response_format=json_schema、Gemini 的 responseSchema 等)。这是真正硬约束------模型不返回合法 JSON 都出不来。

bash 复制代码
PetSymptom symptom = chatClient.prompt()
        .user("...")
        .call()
        .entity(PetSymptom.class, spec -> spec.useProviderStructuredOutput());

4.3 两个都开,暴力解决"形状漂移"

bash 复制代码
PetSymptom symptom = chatClient.prompt()
        .user("...")
        .call()
        .entity(PetSymptom.class, spec -> spec
                .useProviderStructuredOutput()  // 第一道闸:厂商硬约束
                .validateSchema());             // 第二道闸:自动重试兜底

⚠️ DeepSeek 注意

useProviderStructuredOutput()要求模型厂商实现原生结构化输出 。DeepSeek 当前 API 暂未支持 OpenAI 那套 response_format 协议------Spring AI 检测到不支持时自动降级 为软约束。所以在你这套 DeepSeek 链路上,这个开关基本等同 .entity(Class);要"硬约束"得换 OpenAI / Gemini 等支持原生结构的厂商。

4.4 速查表

你的需求 写法
多数场景,能用就行 .entity(Type.class)
需要 List / Map .entity(new ParameterizedTypeReference<...>(){})
字段偶尔会缺 / 多了字段 .entity(Type.class, spec -> spec.validateSchema())
厂商支持原生结构化、要求 100% 合法 .entity(Type.class, spec -> spec.useProviderStructuredOutput())
关键业务、不容许形状漂移 .entity(Type.class, spec -> spec.useProviderStructuredOutput().validateSchema())
还要拿 token 用量 .responseEntity(...)(同套重载)

05 .entity() 为什么不能流式?.responseEntity() 的妙用

.entity()只能在 .call() 路径用 ,不能在 .stream() 路径用。原因简单而本质:

流式响应是逐 chunk 推 的,每个 chunk 都不完整 JSON;类型解析需要完整 JSON

所以如果你非要"流式地拿到结构化对象",没办法 ------只能 .call() 等完整结果。**stream() 注定是文本流**。

.entity() 还有个同胞兄弟:.responseEntity(...),签名和 .entity 完全一致,只是返回 ResponseEntity<ChatResponse, T>,让你同时拿到解析后的对象 + 原始 ChatResponse

bash 复制代码
ResponseEntity<ChatResponse, PetSymptom> result = chatClient.prompt()
        .user("...")
        .call()
        .responseEntity(PetSymptom.class);

PetSymptom symptom = result.entity();
ChatResponse raw = result.response();
long totalTokens = raw.getMetadata().getUsage().getTotalTokens();

实战里这个特别有用:记 token 用量做计费、做监控、做限流


06 多模态:让模型"看见"宠物的皮毛照片

很多宠物场景缺不了"看图"------宠主发来一张猫皮毛的照片,"这是猫藓还是过敏?"。文本模型干不了这活,需要视觉模型

6.1 Spring AI 多模态 API

Spring AI 的 UserMessage 设计得很干净:文本走 content,图片/音频/视频走可选的 media 列表。

bash 复制代码
// ① 底层 API
Resource photo = new ClassPathResource("/uploads/pet-skin-001.jpg");
UserMessage userMessage = UserMessage.builder()
        .text("请观察这张猫皮毛照片,判断是否有猫藓或过敏症状。")
        .media(new Media(MimeTypeUtils.IMAGE_JPEG, photo))
        .build();

ChatResponse response = chatModel.call(new Prompt(userMessage));
String result = response.getResult().getOutput().getText();

或者用 ChatClient 的 Fluent API 更顺:

bash 复制代码
// ② ChatClient 写法(推荐)
String result = chatClient.prompt()
        .user(u -> u
                .text("请观察这张猫皮毛照片,判断是否有猫藓或过敏症状。")
                .media(MimeTypeUtils.IMAGE_JPEG, new ClassPathResource("/uploads/pet-skin-001.jpg")))
        .call()
        .content();

💡 media 只对 UserMessage 有意义

SystemMessage / AssistantMessage 没有 media 字段(系统提示、模型回复都是纯文本)。多模态输出(让模型生图、生音频)也 走聊天路径,要用专门的 ImageModel / SpeechModel别想当然把图塞 Assistant

6.2 实战:宠物皮毛初判接口

bash 复制代码
@PostMapping("/skin-check")
public SkinCheckResult check(@RequestParam("file") MultipartFile file) throws IOException {
    // 把上传的文件转成临时 Resource 交给 Spring AI
    Resource photo = file.getResource();
    String mime = file.getContentType() != null ? file.getContentType() : "image/jpeg";
    MimeType mimeType = MimeType.valueOf(mime);

    String text = chatClient.prompt()
            .system("你是宠物皮肤科 AI 助理。基于图片做初判;不要替代兽医诊断,输出末尾必须包含「最终请以兽医面诊为准」。")
            .user(u -> u.text("请判断这张宠物的皮毛状况。")
                        .media(mimeType, photo))
            .call()
            .content();

    return new SkinCheckResult(text);
}

6.3 ⚠️ 选模型时绕不开的坑:DeepSeek hosted API 不收图片

厂商 模型 多模态 Spring AI 接入
OpenAI gpt-4o / gpt-4-vision-preview spring-ai-starter-model-openai(换 base-url + model
Anthropic Claude 3.x 系列 spring-ai-anthropic
Google Gemini 1.5/2.x spring-ai-vertex-ai-gemini
Mistral AI Pixtral 系列 spring-ai-mistral-ai
Ollama LLaVA / BakLLaVA / Llama 多模态 ✅(本地) spring-ai-ollama
DeepSeek(托管 API) deepseek-v4-pro / deepseek-v4-flash ❌ 暂不收图 仍可走 spring-ai-starter-model-openai,只是不能用 .media(...)

OpenAI 兼容是一把双刃剑 :换 base-url 就能切厂商,某些厂商(DeepSeek、部分国产)只实现了对话兼容、没实现视觉兼容。

💡 实战选型

  • 要纯文本 + Function Calling + RAG → 继续用 DeepSeek deepseek-v4-flash(便宜、量大、能用);

  • 要看图 → 临时把 base-url 切到 https://api.openai.com、模型名改 gpt-4o-mini,其它代码一行不用改;

  • 要看得起 + 跑得起 DeepSeek-VL2 → 拉本地 vLLM,OpenAI 兼容端点对齐,模型名换 deepseek-vl2 之类。

切厂商的成本 = 改两行 application.yml + 换 Key。这正是 ChatClient 抽象的红利。


07 结构化输出 vs Tool Calling:什么时候用哪个?

这是面试和实战都被反复问的问题。一张表说清:

维度 .entity() 结构化输出 @Tool 工具调用
目标 拿到一个结构化数据对象 触发一个应用侧动作
模型"动"了什么 没动任何东西,只返回数据 真正调用了你的代码(查 DB / 发请求)
典型场景 工单分类、症状摘要、用户意图解析 查排班、下订单、发邮件
Schema 强制方式 JSON Schema(软约束 / Provider 硬约束) 工具定义(本身就是结构化)
能不能组合 ✅ 在结构化输出的同时作为工具的输入 ✅ 工具参数就是结构化对象

它们其实是一对搭档

bash 复制代码
// 场景:让 AI 自己决定要不要"开处方"
// ① 结构化输出:先让 AI 理解病情,输出结构化的"处置建议"
PetSymptom symptom = chatClient.prompt()
        .user("...")
        .call()
        .entity(PetSymptom.class);

// ② 工具调用:拿到 symptom 后,让 AI 决定是否调用挂号工具
if ("high".equals(symptom.urgency())) {
    String reply = chatClient.prompt()
            .user("根据" + symptom + ",帮我挂一个今天的号")
            .tools(clinicTools)
            .call()
            .content();
}

也可以让模型一次完成(结构化输出 + 工具调用在同一轮)------ Spring AI 完全支持,只需在 prompt 里写明"如果 urgency=high 就调用 makeAppointment"。


08 总结

回头看阶段2 走过的路:

解决的核心问题
ChatClient & Prompt 把"和模型说话"做成"写 Java 代码"
Function Calling 让模型动手查系统 / 调接口
RAG & VectorStore 让模型带私域知识答问题
结构化输出 & 多模态 让模型说结构化的话 + 看见图
至此,"Agent = LLM + Prompt + Memory + Tools + RAG + 结构化 + 多模态"的所有零件你都拿到了。

下一阶段预告: LangChain4j架构学习


想继续的学习的点个【 】和【收藏】让主编知道!

顺手点个【关注】,感谢各位学习路上的朋友。

相关推荐
程序员清风44 分钟前
聊聊我的AI学习方法与思考!
java·后端·面试
AI深栈1 小时前
第 14 章 · LangGraph4j 入门:AI Agent 什么时候该上状态图
java·人工智能
右耳朵猫AI1 小时前
Node.js周刊2026W38 | 进程中断缺陷修复、Copilot 迁至 Rust、Node 新增 VFS
javascript·后端·node.js
摇滚侠1 小时前
《Spring Boot 3:高级与架构设计》第 2 章 IOC容器的高级机制 Environment 个人理解 4
java·spring boot·笔记·后端
zl_dfq1 小时前
Java学习1 之 【核心机制、程序基础结构、I/O】
java
名字还没想好☜1 小时前
Go 实现指数退避重试:context 取消、抖动 jitter 与什么时候别重试
后端·golang·go
青山木1 小时前
RocketMQ 入门到原理(六):可靠性全景
java·后端·中间件·架构·rocketmq
青山木1 小时前
RocketMQ 入门到原理(五):特殊消息类型
java·后端·中间件·架构·rocketmq
第五页的你1 小时前
SpringBoot 源码理解
后端