一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

Spring AI 实战:结构化输出与多模态能力详解

时间:2026-09-21 12:28:01 编辑:袖梨 来源:一聚教程网

大模型返回自然语言很方便,但一旦结果需要进入 Java 业务流程,自由文本就容易带来字段缺失、格式漂移和重复解析等问题;图片输入又会进一步增加模型与接口适配的复杂度。下面从 ChatClient 的实体映射入手,逐步梳理结构化输出的可靠性配置、多模态调用方式及实际使用边界。

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

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

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


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

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

PetSymptom symptom = [email protected]()
        .user("...")
        .call()
        .entity(PetSymptom.class);    // 看似一行解决,但里面坑不少

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

类比

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

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


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

2.1 定义 record 作为目标类型

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 一次调用

PetSymptom symptom = [email protected]()
        .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 泛型擦除):

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

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

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


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

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

4.1 validateSchema() —— 自愈重试

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

PetSymptom symptom = [email protected]()
        .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 都出不来。

PetSymptom symptom = [email protected]()
        .user("...")
        .call()
        .entity(PetSymptom.class, spec -> spec.useProviderStructuredOutput());

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

PetSymptom symptom = [email protected]()
        .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

ResponseEntity<ChatResponse, PetSymptom> result = [email protected]()
        .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 列表。

// ① 底层 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 = [email protected](new Prompt(userMessage));
String result = response.getResult().getOutput().getText();

或者用 ChatClient 的 Fluent API 更顺:

// ② ChatClient 写法(推荐)
String result = [email protected]()
        .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 实战:宠物皮毛初判接口

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

    return new SkinCheckResult(text);
}

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

厂商模型多模态Spring AI 接入
OpenAIgpt-4o / gpt-4-vision-previewspring-ai-starter-model-openai(换 base-url + model
AnthropicClaude 3.x 系列spring-ai-anthropic
GoogleGemini 1.5/2.xspring-ai-vertex-ai-gemini
Mistral AIPixtral 系列spring-ai-mistral-ai
OllamaLLaVA / 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 硬约束)工具定义(本身就是结构化)
能不能组合✅ 在结构化输出的同时作为工具的输入✅ 工具参数就是结构化对象

它们其实是一对搭档

// 场景:让 AI 自己决定要不要"开处方"
// ① 结构化输出:先让 AI 理解病情,输出结构化的"处置建议"
PetSymptom symptom = [email protected]()
        .user("...")
        .call()
        .entity(PetSymptom.class);

// ② 工具调用:拿到 symptom 后,让 AI 决定是否调用挂号工具
if ("high".equals(symptom.urgency())) {
    String reply = [email protected]()
            .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架构学习


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

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

热门栏目