Ohhnews

分类导航

$ cd ..
Baeldung原文

Spring AI 结构化输出实战指南

#spring ai#结构化输出#大语言模型#java#数据转换

[LOADING...]

1. 概述

在与聊天机器人交互时,我们已经习惯了大型语言模型(LLMs)返回的纯文本响应。然而,当把这些 LLM 集成到应用程序中时,以编程方式使用这些响应就成了一个问题。

Spring AI 通过其结构化输出支持解决了这个问题。无需处理原始字符串,我们可以指示模型返回直接映射到 Java 类、集合和其他类型的数据。

在本教程中,我们将探讨如何使用 Spring AI 从 LLM 接收结构化输出。

2. 搭建项目

在深入实现之前,我们先搭建项目。

2.1. 配置聊天模型

首先,在项目的 pom.xml 文件中添加必要的依赖:

$ xml
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
    <version>2.0.1</version>
</dependency>

这里,我们导入 Spring AI 的 OpenAI starter 依赖,用它来与聊天模型交互。

接下来,在 application.yaml 文件中配置 OpenAI API 密钥和聊天模型:

$ config
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        model: gpt-5.6-luna

这里,我们通过 gpt-5.6-luna 模型 ID 指定 OpenAI 的 GPT 5.6 Luna模型。或者,我们也可以使用其他聊天模型,因为具体 AI 模型或提供商对本演示并不重要。

设置这两个属性后,Spring AI 会自动创建一个 ChatModel 类型的 bean,我们将用它来构建 ChatClient bean:

$ java
@Bean
ChatClient chatClient(ChatModel chatModel) {
    return ChatClient
      .builder(chatModel)
      .build();
}

ChatClient 类是与配置好的聊天补全模型交互的主要入口点。

2.2. 定义领域实体

接下来,定义一个领域实体,用于将模型响应转换为该实体:

$ java
record Recipe(
  String name,
  String cuisine,
  Difficulty difficulty,
  int prepTimeMinutes,
  List<Ingredient> ingredients,
  List<String> steps
) {
    record Ingredient(
      String name,
      String quantity
    ) {}
    enum Difficulty { EASY, MEDIUM, HARD }
}

这里我们定义了一个 Recipe record,其中包含嵌套的 Ingredient record 和 Difficulty enum。

在定义领域实体时,我们应选择具有描述性的字段名,因为这有助于 LLM 正确填充它们。对于含义模糊的字段,我们可以用 @JsonPropertyDescription 标注它们,为模型提供额外上下文。

3. 将响应转换为领域实体

设置完成后,我们使用 ChatClient bean 向模型请求一份食谱,并将其响应转换为我们的 record:

$ java
Recipe recipe = chatClient
  .prompt("Generate a recipe for a vegetarian lasagna.")
  .call()
  .entity(Recipe.class);
assertThat(recipe)
  .hasNoNullFieldsOrProperties()
  .satisfies(r -> assertThat(r.ingredients())
    .hasSizeGreaterThan(1)
  );

这里,我们传入一条生成食谱的指令,然后使用 Recipe 类调用 entity() 方法,而不是调用 content()——后者会返回原始文本响应。

entity() 方法会交给我们一个完整填充的 Recipe 实例,而我们无需编写任何解析逻辑。在幕后,Spring AI 执行以下步骤:

  • 首先,根据我们传入的目标类型生成 JSON schema。
  • 然后,将该 schema 以及一组格式指令追加到用户提示词中。
  • 最后,当模型回复后,将响应文本反序列化为目标类型的实例。

4. 自校正结构化输出

即使提示词中已有清晰的 schema 和格式指令,聊天模型仍可能返回不符合要求的响应。下面看看一些从这类失败中恢复的方法。

4.1. 使用 validateSchema() 进行客户端验证

在第一种方法中,响应在反序列化之前会在我们这一侧进行验证:

$ java
Recipe recipe = chatClient
  .prompt("Generate a recipe for a high protein dessert.")
  .call()
  .entity(Recipe.class, spec -> spec.validateSchema());

这里,我们向 entity() 传入一个额外的 lambda,并调用 validateSchema() 方法。

启用后,Spring AI 会在反序列化之前,根据生成的 JSON schema 验证模型的响应。如果失败,它会将验证错误发送回模型。此过程会重复,直到输出有效或达到重试上限;默认上限为三次。

或者,我们可以在构建 ChatClient bean 时注册 StructuredOutputValidationAdvisor。使用这种方法,我们可以覆盖默认设置,并对每次调用应用客户端验证:

$ java
@Bean
ChatClient validatingChatClient(ChatModel chatModel) {
    return ChatClient
      .builder(chatModel)
      .defaultAdvisors(StructuredOutputValidationAdvisor.builder()
        .maxRepeatAttempts(5)
        .outputType(Recipe.class)
        .jsonMapper(JsonMapper.builder().build())
        .build())
      .build();
}

这里,我们将重试上限提高到五次,并声明 Recipe 为默认输出类型。此外,我们提供一个自定义 JsonMapper 实例来执行验证和反序列化任务。这里我们只是用默认设置配置了一个实例,但可以根据需求进行自定义。

4.2. 使用 useProviderStructuredOutput() 进行服务端验证

另一种方法是,不自行验证响应,而是将这项工作委托给模型提供商。大多数现代提供商,如 OpenAI、Anthropic、Google 和 Mistral,都接受在 API 请求中包含 schema,并保证响应符合该 schema。

我们可以通过同一个 lambda 上的另一个方法使用此能力:

$ java
Recipe recipe = chatClient
  .prompt("Generate a recipe for a gluten-free breakfast.")
  .call()
  .entity(Recipe.class, spec -> spec.useProviderStructuredOutput());

启用后,Spring AI 会将 schema 作为专用 API 字段发送给提供商,而不是将指令追加到用户提示词中。

不过,在依赖它之前,我们应确认模型和提供商都支持它。我们甚至可以将其与 validateSchema() 一起使用,以获得更具弹性的设置。

5. 将响应转换为 List

有时我们可能希望模型返回一组结果,而不是单个对象:

$ java
List<Recipe> recipes = chatClient
  .prompt("Generate 3 recipes for vegetarian dishes.")
  .call()
  .entity(new ParameterizedTypeReference<List<Recipe>>() {});
assertThat(recipes)
  .hasSize(3)
  .allSatisfy(recipe -> assertThat(recipe)
    .hasNoNullFieldsOrProperties()
  );

这里,我们向 entity() 方法传入的不是一个类,而是一个 ParameterizedTypeReference 实例。这个包装器保留了有关 Recipe record 的泛型类型信息,并允许 Spring AI 相应地生成 JSON schema。

或者,当我们只需要一个普通字符串列表时,可以向 entity() 方法传入一个 ListOutputConverter 实例:

$ java
List<String> dishes = chatClient
  .prompt("List 5 popular vegetarian dishes.")
  .call()
  .entity(new ListOutputConverter());
assertThat(dishes)
  .hasSize(5)
  .allSatisfy(dish -> assertThat(dish)
    .isNotBlank()
  );

在这个轻量级选项中,转换器要求模型返回简单的逗号分隔列表,而不是 JSON,然后将回复拆分为 String 值的 List。

6. 将响应转换为 Map

在事先不知道响应形态的场景中,我们可以向 entity() 方法传入一个 MapOutputConverter 实例:

$ java
Map<String, Object> nutritionFacts = chatClient
  .prompt("Provide the nutrition facts per serving for a vegetarian lasagna.")
  .call()
  .entity(new MapOutputConverter());
assertThat(nutritionFacts)
  .isNotEmpty()
  .allSatisfy((nutrient, value) -> {
    assertThat(nutrient).isNotBlank();
    assertThat(value).isNotNull();
  });

这里,我们收到一个以 String 为键、Object 为值的 Map。转换器指示模型以 JSON 对象回复。在缺少固定 schema 的情况下,由模型决定返回哪些键。

然而,这种灵活性会牺牲类型安全性,因此当我们知道期望的结构时,仍应优先使用专用的领域实体。

7. 使用 ChatModel 手动转换响应

到目前为止,我们都让 ChatClient bean 为我们处理一切。然而,当我们直接使用更底层的 ChatModel 抽象时,entity() 方法并不可用。

在这种情况下,我们可以自行使用转换器类:

$ java
BeanOutputConverter<Recipe> outputConverter = new BeanOutputConverter<>(Recipe.class);
String response = chatModel
  .call("Generate a recipe for a vegetarian lasagna. " + outputConverter.getFormat());
Recipe recipe = outputConverter.convert(response);
assertThat(recipe)
  .hasNoNullFieldsOrProperties();

这里,我们为 Recipe record 创建一个 BeanOutputConverter 实例。这与 Spring AI 在内部为我们的领域实体使用的是同一个转换器。

然后,我们将其 getFormat() 方法的输出追加到提示词中,其中包含生成的 JSON schema 和格式指令。最后,将模型的响应传给它的 convert() 方法,以获得 Recipe 实例。

这种手动方法并不局限于领域实体。我们也可以对列表和映射遵循完全相同的流程。唯一需要改变的是所创建的转换器实例。

8. 创建自定义输出转换器

到目前为止,我们一直依赖 Spring AI 提供的转换器,它们满足了大多数需求。然而,我们只需实现 StructuredOutputConverter 接口,就可以自己实现一个转换器:

$ java
class YamlOutputConverter<T> implements StructuredOutputConverter<T> {
    private final YAMLMapper yamlMapper = YAMLMapper.builder().build();
    private final Class<T> targetType;
    YamlOutputConverter(Class<T> targetType) {
        this.targetType = targetType;
    }
    @Override
    public String getFormat() {
        String schema = new BeanOutputConverter<>(targetType).getJsonSchema();
        return """
          Return a YAML response that matches this JSON schema: %s
          Do not include any explanations or markdown code fences.
          """.formatted(schema);
    }
    @Override
    public T convert(String source) {
        return yamlMapper.readValue(source, targetType);
    }
}

这里,我们创建了一个用于处理 YAML 响应的转换器。

我们复用 BeanOutputConverter 为目标类型生成 JSON schema,并在 getFormat() 方法中编写要追加到用户提示词中的指令。然后,在 convert() 中,我们使用 YAMLMapper将模型响应反序列化为目标类型。

现在,验证一下我们的转换器能否正确反序列化响应:

$ java
String yamlResponse = """
name: "Mediterranean Veggie Salad"
cuisine: "Mediterranean"
difficulty: "EASY"
prepTimeMinutes: 15
ingredients:
  - name: "Cucumber"
    quantity: "1 medium"
  - name: "Cherry tomatoes"
    quantity: "1 cup"
  - name: "Extra virgin olive oil"
    quantity: "2 tbsp"
steps:
  - "Step 1: Chop the cucumber and halve the cherry tomatoes."
  - "Step 2: Drizzle with olive oil and toss everything together."
""";
Recipe recipe = new YamlOutputConverter<>(Recipe.class)
  .convert(yamlResponse);
assertThat(recipe)
  .hasNoNullFieldsOrProperties();

这里,我们将一个示例模型响应传给转换器的 convert() 方法,并确认 Recipe record 被填充。

要将其用于真实模型,我们只需将 YamlOutputConverter 实例传给 entity() 方法。

9. 结论

在本文中,我们探讨了 Spring AI 中的结构化输出支持。

我们介绍了如何将聊天模型的响应转换为自定义领域实体、列表和映射。此外,还讨论了有助于在模型返回不符合 schema 的响应时进行恢复的自校正功能。最后,我们探索了如何实现自定义转换器。

一如既往,本文中使用的所有代码示例都可以在 GitHub 上获取。

文章 《Spring AI 结构化输出指南》 首次出现在 Baeldung。