SpringAI 2.0 结构化输出:JSON Schema 验证与 POJO 强类型映射

SpringAI 2.0 结构化输出:JSON Schema 验证与 POJO 强类型映射

前言

在企业级应用中,AI 模型的输出往往是下游系统的输入。如果 AI 返回的是无结构的纯文本,我们需要额外的解析和验证逻辑,这不仅增加了系统的复杂度,还引入了类型安全的风险。

Spring AI 2.0 引入了一套强大的结构化输出机制,通过 JSON Schema 验证和 POJO 强类型映射,让我们能够将 AI 的输出直接映射为 Java 对象。本文将从架构师的视角,深入探讨如何在实战中应用这些特性,构建类型安全的 AI 响应处理流程。


一、结构化输出的架构价值

1.1 传统方式的痛点

在没有结构化输出的情况下,我们通常是这样处理 AI 响应的:

java 复制代码
String response = chatModel.call("提取用户信息:姓名张三,年龄30,邮箱zhangsan@example.com");
// 需要手动解析 JSON 字符串
JsonNode json = objectMapper.readTree(response);
String name = json.get("name").asText();
int age = json.get("age").asInt();
String email = json.get("email").asText();

这种方式存在几个问题:

  • 类型不安全: 字符串解析在运行时才发现错误
  • 冗余代码: 每个响应都需要编写解析逻辑
  • 验证缺失: AI 可能返回不符合预期的格式
  • 维护成本高: 响应结构变化需要修改多处代码

1.2 SpringAI 的解决方案

SpringAI 通过以下三个核心组件解决了上述问题:

  • StructuredOutputConverter: 负责将 JSON 字符串转换为 Java 对象
  • FormatProvider: 生成 JSON Schema 格式提示
  • StructuredOutputChatOptions: 配置原生 JSON Schema 支持

核心接口定义如下:

java 复制代码
public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {}

public interface FormatProvider {
    String getFormat();
}

这个设计非常简洁:

  • Converter: 定义如何从字符串转换为目标类型
  • FormatProvider: 生成格式提示词,告诉 AI 应该返回什么格式的数据

二、核心接口与实现

2.1 StructuredOutputConverter 接口

StructuredOutputConverter 是结构化输出的核心接口,它组合了两个功能:

java 复制代码
public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {
    // Converter 接口方法
    T convert(String source);
    
    // FormatProvider 接口方法
    String getFormat();
}

设计意图:

  • Converter: 负责实际的类型转换,将 JSON 字符串反序列化为 Java 对象
  • FormatProvider: 负责生成格式提示词,包含 JSON Schema 定义

2.2 内置转换器实现

SpringAI 提供了几个常用的转换器实现:

BeanOutputConverter

将 JSON 转换为指定的 Java Bean:

java 复制代码
BeanOutputConverter<ActorsFilms> converter = 
    new BeanOutputConverter<>(ActorsFilms.class);
MapOutputConverter

将 JSON 转换为 Map<String, Object>:

java 复制代码
MapOutputConverter converter = new MapOutputConverter();
Map<String, Object> result = converter.convert(jsonString);
ListOutputConverter

将 JSON 数组转换为 List:

java 复制代码
ListOutputConverter converter = new ListOutputConverter();
List<String> result = converter.convert(jsonString);

2.3 嵌套 POJO 定义

对于复杂的数据结构,我们可以定义嵌套的 POJO 类:

java 复制代码
// 电影信息
public class Film {
    private String title;
    private int year;
    private String genre;
    private double rating;
    
    // 构造器、getter、setter
}

// 演员及其电影作品
public class ActorsFilms {
    private String actor;
    private List<Film> films;
    
    // 构造器、getter、setter
}

三、高级 API:ChatClient 集成

3.1 基础用法

ChatClient 提供了最简洁的结构化输出 API:

java 复制代码
ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt()
    .user("生成 Tom Hanks 和 Bill Murray 的5部电影作品集")
    .call()
    .entity(ActorsFilms.class);

这个示例展示了高级 API 的三个关键点:

  1. 类型推导: 自动将返回结果转换为指定的 POJO 类型
  2. 格式注入: 自动生成 JSON Schema 并注入到提示词中
  3. 结果转换: 自动将 AI 返回的 JSON 转换为 Java 对象

3.2 泛型集合支持

对于集合类型,我们需要使用 ParameterizedTypeReference:

java 复制代码
List<ActorsFilms> actorsFilmsList = ChatClient.create(chatModel).prompt()
    .user("生成5位演员的电影作品集,每位演员包含3部电影")
    .call()
    .entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});

ParameterizedTypeReference 是 Spring 提供的工具类,用于处理泛型类型信息。这是因为 Java 的类型擦除机制导致运行时无法直接获取泛型的实际类型。

3.3 完整示例

下面是一个完整的示例,展示如何使用 ChatClient 生成结构化数据:

java 复制代码
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.core.ParameterizedTypeReference;

public class StructuredOutputExample {
    
    private final ChatClient chatClient;
    
    public StructuredOutputExample(ChatClient chatClient) {
        this.chatClient = ChatClient.create(chatModel);
    }
    
    public List<MovieReview> generateReviews(String movieTitle) {
        return chatClient.prompt()
            .user("为电影《" + movieTitle + "》生成3条影评,每条包含评分和评语")
            .call()
            .entity(new ParameterizedTypeReference<List<MovieReview>>() {});
    }
    
    public static void main(String[] args) {
        ChatModel chatModel = new OpenAiChatModel(...);
        StructuredOutputExample example = new StructuredOutputExample(chatModel);
        
        List<MovieReview> reviews = example.generateReviews("肖申克的救赎");
        reviews.forEach(review -> {
            System.out.println("评分: " + review.getRating());
            System.out.println("评语: " + review.getComment());
        });
    }
}

// 影评 POJO
public class MovieReview {
    private String reviewer;
    private int rating;  // 1-10分
    private String comment;
    private LocalDate reviewDate;
    
    // 构造器、getter、setter
}

四、低级 API:精细控制转换流程

4.1 手动控制流程

低级 API 让我们可以完全控制转换流程,适合需要精细化的场景:

java 复制代码
public class LowLevelApiExample {
    
    private final ChatModel chatModel;
    private final ObjectMapper objectMapper;
    
    public ActorsFilms generateStructuredOutput(String actorName) {
        // 1. 创建转换器
        BeanOutputConverter<ActorsFilms> outputConverter = 
            new BeanOutputConverter<>(ActorsFilms.class);
        
        // 2. 获取 JSON Schema 格式
        String format = outputConverter.getFormat();
        
        // 3. 构建提示词模板
        String template = """
            请为演员 %s 生成5部电影作品。
            %s
            """.formatted(actorName, format);
        
        // 4. 创建提示
        Prompt prompt = new Prompt(template);
        
        // 5. 调用模型
        Generation generation = chatModel.call(prompt).getResult();
        
        // 6. 转换响应
        ActorsFilms actorsFilms = outputConverter.convert(
            generation.getOutput().getContent()
        );
        
        return actorsFilms;
    }
}

4.2 流程图解

低级 API 的执行流程如下:

复制代码
1. 创建转换器
   └─> BeanOutputConverter<ActorsFilms>
       └─> 分析 POJO 类结构
           └─> 生成 JSON Schema

2. 获取格式提示
   └─> getFormat()
       └─> 返回 JSON Schema 字符串

3. 构建提示词
   └─> 用户指令 + JSON Schema
       └─> "生成演员作品... {json_schema}"

4. 调用 AI 模型
   └─> chatModel.call(prompt)
       └─> 模型根据 Schema 生成 JSON

5. 转换响应
   └─> outputConverter.convert(jsonString)
       └─> JSON 字符串 -> POJO 对象
           └─> Jackson 反序列化

4.3 错误处理机制

在低级 API 中,我们可以添加详细的错误处理:

java 复制代码
public ActorsFilms generateWithErrorHandling(String actorName) {
    BeanOutputConverter<ActorsFilms> converter = 
        new BeanOutputConverter<>(ActorsFilms.class);
    
    try {
        String format = converter.getFormat();
        String prompt = String.format("生成 %s 的电影作品\n%s", actorName, format);
        
        Generation generation = chatModel.call(new Prompt(prompt)).getResult();
        String response = generation.getOutput().getContent();
        
        // 验证响应是否为空
        if (response == null || response.trim().isEmpty()) {
            throw new IllegalStateException("AI 返回了空响应");
        }
        
        // 尝试转换
        ActorsFilms result = converter.convert(response);
        
        // 验证转换结果
        if (result.getFilms() == null || result.getFilms().isEmpty()) {
            throw new IllegalStateException("生成的电影列表为空");
        }
        
        return result;
        
    } catch (JsonProcessingException e) {
        // JSON 解析失败
        log.error("JSON 解析失败: {}", e.getMessage());
        throw new RuntimeException("无法解析 AI 响应", e);
    } catch (Exception e) {
        // 其他错误
        log.error("生成结构化输出时发生错误: {}", e.getMessage(), e);
        throw new RuntimeException("生成失败", e);
    }
}

五、错误处理与回退策略

5.1 常见错误类型

在使用结构化输出时,可能会遇到以下错误:

  1. JSON 格式错误: AI 返回的不是有效的 JSON
  2. Schema 不匹配: 返回的数据结构不符合 POJO 定义
  3. 类型转换失败: 字段类型不匹配(如字符串无法转换为整数)
  4. 空字段处理: 必填字段为空或缺失

5.2 重试机制

对于 transient 错误,我们可以实现自动重试:

java 复制代码
import org.springframework.retry.annotation.Retryable;
import org.springframework.retry.support.RetryTemplate;

public class RetryableStructuredOutput {
    
    private final ChatModel chatModel;
    private final RetryTemplate retryTemplate;
    
    public ActorsFilms generateWithRetry(String actorName) {
        return retryTemplate.execute(context -> {
            BeanOutputConverter<ActorsFilms> converter = 
                new BeanOutputConverter<>(ActorsFilms.class);
            
            String format = converter.getFormat();
            String prompt = String.format("生成 %s 的电影作品\n%s", actorName, format);
            
            Generation generation = chatModel.call(new Prompt(prompt)).getResult();
            return converter.convert(generation.getOutput().getContent());
        });
    }
    
    // 配置重试策略
    @Bean
    public RetryTemplate retryTemplate() {
        return RetryTemplate.builder()
            .maxAttempts(3)
            .exponentialBackoff(1000, 2, 5000)
            .retryOn(JsonProcessingException.class)
            .build();
    }
}

5.3 回退策略

当结构化输出失败时,我们可以提供回退方案:

java 复制代码
public class FallbackStructuredOutput {
    
    private final ChatModel chatModel;
    private final StructuredOutputCache cache;
    
    public ActorsFilms generateWithFallback(String actorName) {
        try {
            // 尝试从缓存获取
            ActorsFilms cached = cache.get(actorName);
            if (cached != null) {
                log.info("从缓存获取数据: {}", actorName);
                return cached;
            }
            
            // 尝试生成结构化输出
            return generateStructuredOutput(actorName);
            
        } catch (Exception e) {
            log.warn("结构化输出失败,使用回退策略: {}", e.getMessage());
            
            // 回退到手动解析
            return fallbackToManualParsing(actorName);
        }
    }
    
    private ActorsFilms fallbackToManualParsing(String actorName) {
        String response = chatModel.call("请简要列出 " + actorName + " 的5部电影");
        
        // 手动提取信息
        ActorsFilms result = new ActorsFilms();
        result.setActor(actorName);
        
        // 简单的文本解析逻辑
        List<Film> films = Arrays.stream(response.split("\n"))
            .filter(line -> !line.trim().isEmpty())
            .map(this::parseFilmFromText)
            .collect(Collectors.toList());
        
        result.setFilms(films);
        
        // 缓存结果
        cache.put(actorName, result);
        
        return result;
    }
}

5.4 多级回退策略

更健壮的实现应该包含多级回退:

java 复制代码
public ActorsFilms generateWithMultiLevelFallback(String actorName) {
    // 第一级: 尝试结构化输出
    try {
        return generateStructuredOutput(actorName);
    } catch (Exception e) {
        log.warn("结构化输出失败,进入二级回退");
    }
    
    // 第二级: 尝试带宽松 Schema 的输出
    try {
        return generateWithLenientSchema(actorName);
    } catch (Exception e) {
        log.warn("宽松 Schema 输出失败,进入三级回退");
    }
    
    // 第三级: 手动解析文本
    try {
        return fallbackToManualParsing(actorName);
    } catch (Exception e) {
        log.warn("手动解析失败,进入四级回退");
    }
    
    // 第四级: 返回空结果或默认值
    log.error("所有策略均失败,返回默认结果");
    return createEmptyResult(actorName);
}

六、复杂嵌套结构处理

6.1 多层嵌套 POJO

对于复杂的数据结构,我们可以定义多层嵌套的 POJO:

java 复制代码
// 电影公司
public class ProductionCompany {
    private String name;
    private String country;
    private int establishedYear;
    
    // getter、setter
}

// 电影详细信息
public class DetailedFilm {
    private String title;
    private int year;
    private String genre;
    private double rating;
    private String director;
    private List<String> cast;
    private String plot;
    private ProductionCompany productionCompany;
    private int boxOffice;  // 票房(百万美元)
    
    // getter、setter
}

// 完整的演员信息
public class ComprehensiveActorProfile {
    private String name;
    private LocalDate birthDate;
    private String nationality;
    private String biography;
    private List<Awards> awards;
    private List<DetailedFilm> filmography;
    
    // getter、setter
}

// 奖项信息
public class Awards {
    private String awardName;
    private String category;
    private int year;
    private String movie;
    
    // getter、setter
}

6.2 生成复杂嵌套数据

使用 ChatClient 生成复杂的嵌套结构:

java 复制代码
public ComprehensiveActorProfile generateComprehensiveProfile(String actorName) {
    String prompt = String.format("""
        请为演员 %s 生成一份完整的个人资料,包括:
        1. 基本信息(姓名、出生日期、国籍、生平简介)
        2. 获奖记录(至少3个奖项)
        3. 代表作品(至少5部详细电影信息,每部包括导演、演员表、剧情、制片公司、票房)
        """, actorName);
    
    return ChatClient.create(chatModel).prompt()
        .user(prompt)
        .call()
        .entity(ComprehensiveActorProfile.class);
}

6.3 泛型集合的高级用法

处理包含泛型的复杂集合:

java 复制代码
// 电影目录,包含不同类型的电影
public class MovieCatalog {
    private List<FeatureFilm> featureFilms;      // 故事片
    private List<Documentary> documentaries;     // 纪录片
    private List<ShortFilm> shortFilms;          // 短片
    
    // getter、setter
}

// 使用泛型方式生成
public class GenericStructuredOutput {
    
    public <T> List<T> generateList(
        String prompt, 
        Class<T> elementType, 
        int expectedSize
    ) {
        String enhancedPrompt = String.format(
            "%s\n请生成 %d 个 %s 类型的实例",
            prompt, expectedSize, elementType.getSimpleName()
        );
        
        return ChatClient.create(chatModel).prompt()
            .user(enhancedPrompt)
            .call()
            .entity(new ParameterizedTypeReference<List<T>>() {});
    }
    
    // 使用示例
    public MovieCatalog generateCatalog(String studioName) {
        MovieCatalog catalog = new MovieCatalog();
        
        catalog.setFeatureFilms(generateList(
            "为电影公司 %s 生成故事片", 
            FeatureFilm.class, 
            5
        ));
        
        catalog.setDocumentaries(generateList(
            "为电影公司 %s 生成纪录片", 
            Documentary.class, 
            3
        ));
        
        catalog.setShortFilms(generateList(
            "为电影公司 %s 生成短片", 
            ShortFilm.class, 
            4
        ));
        
        return catalog;
    }
}

6.4 自定义序列化器

对于特殊的字段类型,我们可以自定义序列化逻辑:

java 复制代码
public class CustomDeserializer extends JsonDeserializer<LocalDate> {
    
    @Override
    public LocalDate deserialize(JsonParser p, DeserializationContext ctxt) 
        throws IOException {
        
        String dateStr = p.getValueAsString();
        
        // 尝试多种日期格式
        DateTimeFormatter[] formatters = {
            DateTimeFormatter.ISO_LOCAL_DATE,
            DateTimeFormatter.ofPattern("yyyy年MM月dd日"),
            DateTimeFormatter.ofPattern("yyyy-MM-dd"),
            DateTimeFormatter.ofPattern("yyyy/MM/dd")
        };
        
        for (DateTimeFormatter formatter : formatters) {
            try {
                return LocalDate.parse(dateStr, formatter);
            } catch (Exception e) {
                // 继续尝试下一个格式
            }
        }
        
        throw new IOException("无法解析日期: " + dateStr);
    }
}

// 在 POJO 中使用自定义反序列化器
public class Film {
    private String title;
    
    @JsonDeserialize(using = CustomDeserializer.class)
    private LocalDate releaseDate;
    
    // getter、setter
}

七、原生 JSON Schema 支持

7.1 StructuredOutputChatOptions 接口

Spring AI 2.0 引入了 StructuredOutputChatOptions 接口,用于配置原生的 JSON Schema 支持:

java 复制代码
public interface StructuredOutputChatOptions extends ChatOptions {
    String getResponseFormat();
}

支持的模型:

  • OpenAI: 通过 response_format 参数
  • Mistral AI: 原生 JSON 模式验证
  • Ollama: 通过实现 StructuredOutputChatOptions 接口

7.2 Mistral AI 原生支持

Mistral AI 在 Spring AI 2.0.0-M2 中增加了原生 JSON 模式验证:

java 复制代码
@Configuration
public class MistralConfig {
    
    @Bean
    public ChatModel mistralChatModel() {
        return new MistralAiChatModel(
            MistralAiApi.builder()
                .apiKey(System.getenv("MISTRAL_API_KEY"))
                .build(),
            MistralAiChatOptions.builder()
                .model("mistral-large-latest")
                .responseFormat("json")  // 启用 JSON 模式
                .temperature(0.7)
                .build()
        );
    }
}

7.3 Ollama 原生支持

Ollama 在 Spring AI 2.0 中实现了 StructuredOutputChatOptions 接口:

java 复制代码
@Configuration
public class OllamaConfig {
    
    @Bean
    public ChatModel ollamaChatModel() {
        return new OllamaChatModel(
            OllamaApi.builder()
                .baseUrl("http://localhost:11434")
                .build(),
            OllamaChatOptions.builder()
                .model("llama3.2")
                .format("json")  // 启用 JSON 格式
                .temperature(0.7)
                .build()
        );
    }
}

7.4 原生支持的优势

使用原生 JSON Schema 支持有以下优势:

  1. 更高的准确性: 模型直接在生成时进行验证
  2. 更少的 token 消耗: 不需要在提示词中包含完整的 JSON Schema
  3. 更快的响应: 减少了后处理时间
  4. 更好的兼容性: 直接使用模型的原生能力

八、实战案例:电影推荐系统

8.1 场景描述

构建一个智能电影推荐系统,根据用户的偏好生成个性化的电影推荐列表。系统需要:

  1. 分析用户偏好(类型、年代、导演等)
  2. 生成符合偏好的推荐列表
  3. 为每部电影提供详细的推荐理由
  4. 支持多种推荐策略

8.2 数据模型

java 复制代码
// 用户偏好
public class UserPreference {
    private List<String> genres;          // 喜欢的类型
    private List<Integer> decades;        // 喜欢的年代
    private List<String> favoriteDirectors;  // 喜欢的导演
    private double minRating;              // 最低评分
    private boolean includeClassics;       // 是否包含经典电影
    
    // getter、setter
}

// 电影推荐
public class MovieRecommendation {
    private String title;
    private int year;
    private String genre;
    private String director;
    private double rating;
    private String reason;              // 推荐理由
    private List<String> reasons;       // 推荐原因列表
    private double matchScore;          // 匹配度分数
    
    // getter、setter
}

// 推荐结果
public class RecommendationResult {
    private UserPreference preference;
    private List<MovieRecommendation> recommendations;
    private String summary;
    private int totalRecommendations;
    private LocalDate generatedDate;
    
    // getter、setter
}

8.3 推荐服务实现

java 复制代码
@Service
public class MovieRecommendationService {
    
    private final ChatClient chatClient;
    private final RecommendationCache cache;
    
    public RecommendationResult generateRecommendations(UserPreference preference) {
        // 构建提示词
        String prompt = buildRecommendationPrompt(preference);
        
        // 生成推荐
        RecommendationResult result = chatClient.prompt()
            .user(prompt)
            .call()
            .entity(RecommendationResult.class);
        
        // 后处理:计算匹配分数
        result.getRecommendations().forEach(rec -> {
            rec.setMatchScore(calculateMatchScore(rec, preference));
        });
        
        // 排序:按匹配分数降序
        result.getRecommendations().sort(
            Comparator.comparing(MovieRecommendation::getMatchScore).reversed()
        );
        
        // 缓存结果
        cache.put(preference, result);
        
        return result;
    }
    
    private String buildRecommendationPrompt(UserPreference preference) {
        return String.format("""
            请根据以下用户偏好生成电影推荐列表:
            
            偏好类型: %s
            偏好年代: %s
            喜欢的导演: %s
            最低评分: %.1f
            是否包含经典电影: %s
            
            要求:
            1. 推荐不少于5部、不超过10部电影
            2. 为每部电影提供详细的推荐理由(至少3条)
            3. 匹配度分数范围为0-100
            4. 提供推荐摘要
            """,
            String.join(", ", preference.getGenres()),
            formatDecades(preference.getDecades()),
            String.join(", ", preference.getFavoriteDirectors()),
            preference.getMinRating(),
            preference.isIncludeClassics() ? "是" : "否"
        );
    }
    
    private double calculateMatchScore(MovieRecommendation rec, UserPreference pref) {
        double score = 0.0;
        
        // 类型匹配(40分)
        if (pref.getGenres().contains(rec.getGenre())) {
            score += 40;
        }
        
        // 年代匹配(30分)
        if (isInPreferredDecades(rec.getYear(), pref.getDecades())) {
            score += 30;
        }
        
        // 导演匹配(20分)
        if (pref.getFavoriteDirectors().contains(rec.getDirector())) {
            score += 20;
        }
        
        // 评分匹配(10分)
        if (rec.getRating() >= pref.getMinRating()) {
            score += 10 * (rec.getRating() / 10.0);
        }
        
        return Math.min(score, 100);
    }
    
    private boolean isInPreferredDecades(int year, List<Integer> decades) {
        int decade = (year / 10) * 10;
        return decades.contains(decade);
    }
    
    private String formatDecades(List<Integer> decades) {
        return decades.stream()
            .map(d -> d + "年代")
            .collect(Collectors.joining(", "));
    }
}

8.4 控制器实现

java 复制代码
@RestController
@RequestMapping("/api/recommendations")
public class RecommendationController {
    
    private final MovieRecommendationService recommendationService;
    
    @PostMapping("/generate")
    public ResponseEntity<RecommendationResult> generateRecommendations(
        @RequestBody UserPreference preference
    ) {
        try {
            RecommendationResult result = recommendationService
                .generateRecommendations(preference);
            return ResponseEntity.ok(result);
        } catch (Exception e) {
            log.error("生成推荐失败", e);
            return ResponseEntity.internalServerError().build();
        }
    }
    
    @GetMapping("/example")
    public ResponseEntity<UserPreference> getExamplePreference() {
        UserPreference preference = new UserPreference();
        preference.setGenres(Arrays.asList("剧情", "科幻", "犯罪"));
        preference.setDecades(Arrays.asList(1990, 2000, 2010));
        preference.setFavoriteDirectors(Arrays.asList("诺兰", "克里斯托弗·诺兰"));
        preference.setMinRating(7.5);
        preference.setIncludeClassics(true);
        
        return ResponseEntity.ok(preference);
    }
}

8.5 完整工作流程

复制代码
1. 用户提交偏好
   └─> UserPreference 对象
       └─> 类型、年代、导演等

2. 构建提示词
   └─> 将偏好转换为自然语言描述
       └─> 加入格式要求

3. 调用 AI 模型
   └─> ChatClient 调用
       └─> 模型生成结构化推荐

4. 后处理
   └─> 计算匹配分数
       └─> 按分数排序

5. 返回结果
   └─> RecommendationResult 对象
       └─> 包含推荐列表和摘要

九、性能优化与最佳实践

9.1 缓存策略

对于重复的查询,我们可以实现缓存机制:

java 复制代码
@Service
public class StructuredOutputCache {
    
    private final Cache<String, Object> cache;
    private final int maxEntries;
    private final Duration ttl;
    
    public StructuredOutputCache(int maxEntries, Duration ttl) {
        this.cache = Caffeine.newBuilder()
            .maximumSize(maxEntries)
            .expireAfterWrite(ttl)
            .build();
        this.maxEntries = maxEntries;
        this.ttl = ttl;
    }
    
    @SuppressWarnings("unchecked")
    public <T> T get(String key, Class<T> type) {
        Object value = cache.getIfPresent(key);
        return value != null && type.isInstance(value) ? (T) value : null;
    }
    
    public void put(String key, Object value) {
        cache.put(key, value);
    }
    
    public void invalidate(String key) {
        cache.invalidate(key);
    }
    
    public void clear() {
        cache.invalidateAll();
    }
}

// 在服务中使用缓存
public class CachedStructuredOutputService {
    
    private final ChatModel chatModel;
    private final StructuredOutputCache cache;
    
    public ActorsFilms generateWithCache(String actorName) {
        String cacheKey = "actors:" + actorName;
        
        // 尝试从缓存获取
        ActorsFilms cached = cache.get(cacheKey, ActorsFilms.class);
        if (cached != null) {
            log.info("从缓存获取: {}", actorName);
            return cached;
        }
        
        // 生成新的结果
        ActorsFilms result = ChatClient.create(chatModel).prompt()
            .user("生成 " + actorName + " 的电影作品")
            .call()
            .entity(ActorsFilms.class);
        
        // 缓存结果
        cache.put(cacheKey, result);
        
        return result;
    }
}

9.2 批量处理

对于需要生成多个结构化输出的场景,我们可以实现批量处理:

java 复制代码
@Service
public class BatchStructuredOutputService {
    
    private final ChatModel chatModel;
    private final ExecutorService executor;
    
    public List<ActorsFilms> generateBatch(List<String> actorNames) {
        // 使用并行流处理
        return actorNames.parallelStream()
            .map(this::generateSingle)
            .collect(Collectors.toList());
    }
    
    private ActorsFilms generateSingle(String actorName) {
        return ChatClient.create(chatModel).prompt()
            .user("生成 " + actorName + " 的电影作品")
            .call()
            .entity(ActorsFilms.class);
    }
    
    // 或者使用 CompletableFuture 进行更精细的控制
    public CompletableFuture<List<ActorsFilms>> generateBatchAsync(
        List<String> actorNames
    ) {
        List<CompletableFuture<ActorsFilms>> futures = actorNames.stream()
            .map(actor -> CompletableFuture.supplyAsync(
                () -> generateSingle(actor),
                executor
            ))
            .collect(Collectors.toList());
        
        return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .thenApply(v -> futures.stream()
                .map(CompletableFuture::join)
                .collect(Collectors.toList())
            );
    }
}

9.3 Schema 优化

为了减少 token 消耗,我们可以优化 JSON Schema:

java 复制代码
public class OptimizedSchemaConverter {
    
    private final BeanOutputConverter<ActorsFilms> converter;
    
    public String getOptimizedFormat() {
        String originalSchema = converter.getFormat();
        
        // 移除不必要的描述信息
        String optimized = originalSchema
            .replaceAll("\"description\":\\s*\"[^\"]*\",?", "")
            .replaceAll(",\\s*}", "}")
            .replaceAll(",\\s*]", "]");
        
        return optimized;
    }
}

9.4 错误监控

实现完善的错误监控和日志记录:

java 复制代码
@Aspect
@Component
public class StructuredOutputMonitor {
    
    private final MeterRegistry meterRegistry;
    
    @Around("@annotation(org.springframework.ai.chat.client.ChatClient)")
    public Object monitorStructuredOutput(ProceedingJoinPoint joinPoint) 
        throws Throwable {
        
        String methodName = joinPoint.getSignature().getName();
        Timer.Sample sample = Timer.start(meterRegistry);
        
        try {
            Object result = joinPoint.proceed();
            
            // 记录成功指标
            meterRegistry.counter("structured.output.success",
                "method", methodName
            ).increment();
            
            sample.stop(Timer.builder("structured.output.duration")
                .tag("method", methodName)
                .tag("status", "success")
                .register(meterRegistry));
            
            return result;
            
        } catch (Exception e) {
            // 记录失败指标
            meterRegistry.counter("structured.output.error",
                "method", methodName,
                "error", e.getClass().getSimpleName()
            ).increment();
            
            sample.stop(Timer.builder("structured.output.duration")
                .tag("method", methodName)
                .tag("status", "error")
                .register(meterRegistry));
            
            throw e;
        }
    }
}

十、Spring AI 2.0.0-M2 新特性

10.1 Mistral AI 原生 JSON 模式验证

Mistral AI 在 2.0.0-M2 版本中增加了原生 JSON 模式验证支持:

java 复制代码
@Configuration
public class MistralNativeJsonConfig {
    
    @Bean
    public ChatModel mistralNativeJsonModel() {
        return new MistralAiChatModel(
            MistralAiApi.builder()
                .apiKey(System.getenv("MISTRAL_API_KEY"))
                .build(),
            MistralAiChatOptions.builder()
                .model("mistral-large-latest")
                .responseFormat("{ \"type\": \"json_object\" }")
                .build()
        );
    }
}

10.2 Ollama 原生支持

Ollama 实现了 StructuredOutputChatOptions 接口:

java 复制代码
@Configuration
public class OllamaNativeJsonConfig {
    
    @Bean
    public ChatModel ollamaNativeJsonModel() {
        return new OllamaChatModel(
            OllamaApi.builder()
                .baseUrl("http://localhost:11434")
                .build(),
            OllamaChatOptions.builder()
                .model("llama3.2")
                .format("json")
                .build()
        );
    }
}

10.3 嵌入模型可配置参数

嵌入模型增加了可配置的 dimensions 参数:

java 复制代码
@Configuration
public class EmbeddingModelConfig {
    
    @Bean
    public EmbeddingModel embeddingModel() {
        return new OpenAiEmbeddingModel(
            OpenAiApi.builder()
                .apiKey(System.getenv("OPENAI_API_KEY"))
                .build(),
            OpenAiEmbeddingOptions.builder()
                .model("text-embedding-3-small")
                .dimensions(1536)  // 可配置的维度
                .build()
        );
    }
}

十一、常见问题与解决方案

11.1 JSON 解析失败

问题: AI 返回的 JSON 无法解析

解决方案:

java 复制代码
public class RobustJsonParser {
    
    private final ObjectMapper objectMapper;
    
    public <T> T parseRobustly(String json, Class<T> type) throws Exception {
        // 尝试直接解析
        try {
            return objectMapper.readValue(json, type);
        } catch (JsonProcessingException e) {
            // 尝试提取 JSON 片段
            String cleaned = extractJsonFragment(json);
            return objectMapper.readValue(cleaned, type);
        }
    }
    
    private String extractJsonFragment(String text) {
        // 使用正则表达式提取 JSON 片段
        Pattern pattern = Pattern.compile("\\{[^}]*\\}|\\[[^\\]]*\\]");
        Matcher matcher = pattern.matcher(text);
        
        if (matcher.find()) {
            return matcher.group();
        }
        
        return text;
    }
}

11.2 字段为 null

问题: 某些字段总是返回 null

解决方案:

java 复制代码
// 在提示词中明确要求字段
public class FieldRequirement {
    
    public String getPromptWithRequirements() {
        return """
            请生成用户信息,必须包含以下所有字段:
            - name: 姓名(字符串,不能为空)
            - age: 年龄(整数,必须大于0)
            - email: 邮箱(字符串,格式正确)
            - address: 地址(字符串,可以是详细地址或城市)
            
            所有字段都是必填的,不能省略。
            """;
    }
}

11.3 数组大小不符合预期

问题: 生成的数组大小不符合要求

解决方案:

java 复制代码
public class ArraySizeControl {
    
    public String getPromptWithSizeControl(int expectedSize) {
        return String.format("""
            请生成包含恰好 %d 个元素的列表。
            不要生成更多或更少的元素。
            每个元素都必须完整且有效。
            """, expectedSize);
    }
    
    // 后处理验证
    public <T> List<T> ensureSize(List<T> list, int expectedSize) {
        if (list.size() != expectedSize) {
            throw new IllegalStateException(
                String.format("期望 %d 个元素,实际得到 %d 个", 
                    expectedSize, list.size())
            );
        }
        return list;
    }
}

11.4 性能问题

问题: 结构化输出响应较慢

解决方案:

  1. 使用原生 JSON 模式: 减少后处理时间
  2. 优化 Schema: 减少不必要的字段
  3. 启用缓存: 缓存常见查询
  4. 批量处理: 合并多个请求
  5. 降低温度值: 减少随机性,提高一致性
java 复制代码
@Configuration
public class PerformanceOptimizationConfig {
    
    @Bean
    public ChatModel optimizedChatModel() {
        return new OpenAiChatModel(
            OpenAiApi.builder()
                .apiKey(System.getenv("OPENAI_API_KEY"))
                .build(),
            OpenAiChatOptions.builder()
                .model("gpt-4o")
                .temperature(0.3)  // 降低温度值
                .maxTokens(2000)   // 限制输出长度
                .build()
        );
    }
}

十二、完整示例:智能表单处理系统

12.1 场景描述

构建一个智能表单处理系统,能够自动从用户提交的自由文本中提取结构化信息,并将其填充到预定义的表单中。

12.2 数据模型

java 复制代码
// 用户信息表单
public class UserRegistrationForm {
    private String fullName;
    private String email;
    private String phone;
    private LocalDate birthDate;
    private String address;
    private String city;
    private String postalCode;
    private String country;
    private List<String> interests;
    private Map<String, String> customFields;
    
    // getter、setter
}

// 提取结果
public class ExtractionResult {
    private UserRegistrationForm form;
    private double confidence;      // 提取置信度
    private List<String> warnings;  // 警告信息
    private Map<String, String> extractedFields;  // 原始字段映射
    
    // getter、setter
}

12.3 服务实现

java 复制代码
@Service
public class IntelligentFormProcessor {
    
    private final ChatClient chatClient;
    private final FormValidator validator;
    
    public ExtractionResult extractFromText(String text) {
        // 构建提取提示词
        String prompt = buildExtractionPrompt(text);
        
        // 生成提取结果
        ExtractionResult result = chatClient.prompt()
            .user(prompt)
            .call()
            .entity(ExtractionResult.class);
        
        // 验证表单
        List<String> validationErrors = validator.validate(result.getForm());
        
        if (!validationErrors.isEmpty()) {
            result.getWarnings().addAll(validationErrors);
            // 尝试修复
            fixErrors(result, validationErrors);
        }
        
        return result;
    }
    
    private String buildExtractionPrompt(String text) {
        return String.format("""
            请从以下文本中提取用户注册信息:
            
            文本内容:
            %s
            
            提取要求:
            1. 提取所有可识别的字段
            2. 对于不明确的字段,请标记为警告
            3. 计算提取置信度(0-1)
            4. 记录原始字段映射
            5. 如果字段缺失,可以留空但要在警告中说明
            
            字段说明:
            - fullName: 完整姓名
            - email: 电子邮箱
            - phone: 电话号码
            - birthDate: 出生日期(ISO格式)
            - address: 详细地址
            - city: 城市
            - postalCode: 邮政编码
            - country: 国家
            - interests: 兴趣爱好列表
            - customFields: 其他自定义字段
            """, text);
    }
    
    private void fixErrors(ExtractionResult result, List<String> errors) {
        // 实现自动修复逻辑
        // 例如: 格式化日期、规范化电话号码等
    }
}

12.4 控制器实现

java 复制代码
@RestController
@RequestMapping("/api/forms")
public class FormProcessingController {
    
    private final IntelligentFormProcessor processor;
    
    @PostMapping("/extract")
    public ResponseEntity<ExtractionResult> extract(
        @RequestBody String text
    ) {
        try {
            ExtractionResult result = processor.extractFromText(text);
            return ResponseEntity.ok(result);
        } catch (Exception e) {
            log.error("表单提取失败", e);
            return ResponseEntity.internalServerError().build();
        }
    }
    
    @PostMapping("/validate")
    public ResponseEntity<ValidationResult> validate(
        @RequestBody UserRegistrationForm form
    ) {
        ValidationResult result = validator.validate(form);
        return ResponseEntity.ok(result);
    }
}

总结

SpringAI 2.0 的结构化输出机制为我们提供了一套完整的解决方案,用于处理 AI 模型的类型安全输出。通过本文的深入探讨,我们学习了:

  1. 核心接口: StructuredOutputConverter、FormatProvider
  2. API 层级: ChatClient 高级 API 和低级 API 的使用
  3. 错误处理: 重试机制、回退策略、多级回退
  4. 复杂场景: 嵌套 POJO、泛型集合、自定义序列化
  5. 原生支持: Mistral AI、Ollama 的 JSON Schema 原生验证
  6. 性能优化: 缓存策略、批量处理、Schema 优化
  7. 实战应用: 电影推荐系统、智能表单处理

结构化输出不仅提高了代码的类型安全性,还大大简化了 AI 响应的处理逻辑。在实际项目中,我们应该根据具体需求选择合适的 API 和策略,构建健壮、高效、可维护的 AI 应用。

随着 SpringAI 的不断发展,结构化输出的能力将会越来越强大。作为架构师,我们需要持续关注新特性,并将其应用到实际项目中,为业务创造更大的价值。


参考资源


本文代码基于 Spring AI 2.0.0-M2 版本编写,实际使用时请根据具体版本进行调整。

相关推荐
MonkeyKing_sunyuhua3 小时前
什么是 VAD , VAD 切分是怎么切分的
人工智能·语音识别
墨染天姬3 小时前
【AI】linux-windows即将消亡,未来模型即系统
linux·人工智能·windows
undsky_5 小时前
【n8n教程】:Luxon日期时间处理,打造智能时间自动化工作流
人工智能·ai·aigc·ai编程
Surmon5 小时前
基于 Cloudflare 生态的 AI Agent 实现
前端·人工智能·架构
冷小鱼10 小时前
pgvector 向量数据库完全指南:PostgreSQL 生态的 AI 增强
数据库·人工智能·postgresql
陈天伟教授10 小时前
人工智能应用- 天文学家的助手:08. 星系定位与分类
前端·javascript·数据库·人工智能·机器学习
啵啵鱼爱吃小猫咪10 小时前
机械臂阻抗控制github项目-mujoco仿真
开发语言·人工智能·python·机器人
放下华子我只抽RuiKe510 小时前
算法的试金石:模型训练、评估与调优的艺术
人工智能·深度学习·算法·机器学习·自然语言处理·数据挖掘·线性回归
songyuc10 小时前
【PyTorch】感觉`CrossEntropyLoss`和`BCELoss`很类似,为什么它们接收labels的shape常常不一样呢?
人工智能·pytorch·python