1. 引言

使用大语言模型(LLMs)时,我们通常不期望得到结构化响应。这些模型的行为难以预测,输出结果往往不符合预期。不过,Spring AI 提供了多种方法来提高生成结构化响应的概率(尽管不是100%),并将这些响应解析为可用的代码结构。

本教程将深入探讨 Spring AI 的结构化输出工具,展示如何高效、可靠地处理模型输出。

2. 聊天模型简介

与 AI 模型交互的核心接口是 ChatModel

public interface ChatModel extends Model<Prompt, ChatResponse> {
    default String call(String message) {
        // 实现略
    }

    @Override
    ChatResponse call(Prompt prompt);
}

call() 方法本质是发送消息并接收响应。虽然默认支持字符串参数,但现代实现更推荐使用 Prompt 对象——它支持多消息组合和参数调节(如温度参数控制创造性)。通过依赖注入(如 spring-ai-openai-spring-boot-starter),可直接使用 OpenAiChatModel 实现。

3. 结构化输出 API

Spring AI 通过 StructuredOutputConverter 接口封装 ChatModel 调用,实现结构化输出:

public interface StructuredOutputConverter<T> extends Converter<String, T>, FormatProvider {}

该接口组合了两个关键组件:

3.1 FormatProvider 接口

public interface FormatProvider {
    String getFormat();
}

getFormat() 在调用模型前生成格式指令,确保输出一致性。例如 JSON 格式指令:

public String getFormat() {
    String template = "Your response should be in JSON format.\n"
      + "Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.\n"
      + "Do not include markdown code blocks in your response.\n"
      + "Remove the ```json markdown from the output.\nHere is the JSON Schema instance your output must adhere to:\n```%s```\n";
    return String.format(template, this.jsonSchema);
}

这些指令会附加到用户输入后。

3.2 Converter 接口

@FunctionalInterface
public interface Converter<S, T> {
    @Nullable
    T convert(S source);
 
    // 默认方法
}

模型响应后,convert() 将输出解析为类型 T 的数据结构。工作流程如下:

Structured Output Converter

4. 可用的转换器

我们以生成 D&D 游戏角色为例,演示 Spring AI 提供的转换器实现。⚠️ 注意:底层使用 Jackson 的 ObjectMapper,需确保实体类有空构造器。

5. BeanOutputConverter:实体类转换

BeanOutputConverter 将模型输出转换为指定类的实例,通过生成 RFC8259 兼容的 JSON 指令实现。使用 ChatClient API 的示例:

@Override
public Character generateCharacterChatClient(String race) {
    return ChatClient.create(chatModel).prompt()
      .user(spec -> spec.text("Generate a D&D character with race {race}")
        .param("race", race))
        .call()
        .entity(Character.class); // <-------- 实际调用 ChatModel.call()
}

流程解析:

  1. ChatClient.create() 创建客户端
  2. prompt() 构建请求(仅含用户文本)
  3. call() 返回包含模型和请求的 CallResponseSpec
  4. entity() 基于类型创建转换器并触发模型调用

底层实现版本(直接使用 ChatModel):

@Override
public Character generateCharacterChatModel(String race) {
    BeanOutputConverter<Character> beanOutputConverter = new BeanOutputConverter<>(Character.class);

    String format = beanOutputConverter.getFormat();

    String template = """
                Generate a D&D character with race {race}
                {format}
                """;

    PromptTemplate promptTemplate = new PromptTemplate(template, Map.of("race", race, "format", format));
    Prompt prompt = new Prompt(promptTemplate.createMessage());
    Generation generation = chatModel.call(prompt).getResult();

    return beanOutputConverter.convert(generation.getOutput().getContent());
}

关键点:

  • PromptTemplate 是 Spring AI 的核心提示模板组件(底层使用 StringTemplate 引擎)
  • Generation 提取内容后,通过转换器解析为 Java 对象

实际响应示例(OpenAI 输出):

{
    name: "Thoren Ironbeard",
    age: 150,
    race: "Dwarf",
    characterClass: "Wizard",
    cityOfOrigin: "Sundabar",
    favoriteWeapon: "Magic Staff",
    bio: "Born and raised in the city of Sundabar, he is known for his skills in crafting and magic."
}

矮人法师?稀有组合!

6. MapOutputConverter 和 ListOutputConverter:集合转换

6.1 MapOutputConverter 示例

@Override
public Map<String, Object> generateMapOfCharactersChatClient(int amount) {
    return ChatClient.create(chatModel).prompt()
      .user(u -> u.text("Generate {amount} D&D characters, where key is a character's name")
        .param("amount", String.valueOf(amount)))
        .call()
        .entity(new ParameterizedTypeReference<Map<String, Object>>() {});
}
    
@Override
public Map<String, Object> generateMapOfCharactersChatModel(int amount) {
    MapOutputConverter outputConverter = new MapOutputConverter();
    String format = outputConverter.getFormat();
    String template = """
            "Generate {amount} of key-value pairs, where key is a "Dungeons and Dragons" character name and value (String) is his bio.
            {format}
            """;
    Prompt prompt = new Prompt(new PromptTemplate(template, Map.of("amount", String.valueOf(amount), "format", format)).createMessage());
    Generation generation = chatModel.call(prompt).getResult();

    return outputConverter.convert(generation.getOutput().getContent());
}

⚠️ 注意:当前 MapOutputConverter 不支持泛型值(需用 Object),后续将构建自定义转换器解决。

6.2 ListOutputConverter 示例

@Override
public List<String> generateListOfCharacterNamesChatClient(int amount) {
    return ChatClient.create(chatModel).prompt()
      .user(u -> u.text("List {amount} D&D character names")
        .param("amount", String.valueOf(amount)))
        .call()
        .entity(new ListOutputConverter(new DefaultConversionService()));
}

@Override
public List<String> generateListOfCharacterNamesChatModel(int amount) {
    ListOutputConverter listOutputConverter = new ListOutputConverter(new DefaultConversionService());
    String format = listOutputConverter.getFormat();
    String userInputTemplate = """
            List {amount} D&D character names
            {format}
            """;
    PromptTemplate promptTemplate = new PromptTemplate(userInputTemplate,
      Map.of("amount", amount, "format", format));
    Prompt prompt = new Prompt(promptTemplate.createMessage());
    Generation generation = chatModel.call(prompt).getResult();
    return listOutputConverter.convert(generation.getOutput().getContent());
}

7. 自定义转换器剖析

构建支持 Map<String, V>(V 为泛型)的转换器。需实现 StructuredOutputConverter<T>,核心方法:

7.1 getFormat() 实现

生成模型指令,指定 Map 结构并提供值类型的 JSON Schema(使用 com.github.victools.jsonschema 库,Spring AI 已内置)。

7.2 convert() 实现

使用 Jackson 的 ObjectMapper 解析 JSON,需移除 Markdown 代码块标记(避免解析异常)。

使用示例:

@Override
public Map<String, Character> generateMapOfCharactersCustomConverter(int amount) {
    GenericMapOutputConverter<Character> outputConverter = new GenericMapOutputConverter<>(Character.class);
    String format = outputConverter.getFormat();
    String template = """
            "Generate {amount} of key-value pairs, where key is a "Dungeons and Dragons" character name and value is character object.
            {format}
            """;
    Prompt prompt = new Prompt(new PromptTemplate(template, Map.of("amount", String.valueOf(amount), "format", format)).createMessage());
    Generation generation = chatModel.call(prompt).getResult();

    return outputConverter.convert(generation.getOutput().getContent());
}

@Override
public Map<String, Character> generateMapOfCharactersCustomConverterChatClient(int amount) {
    return ChatClient.create(chatModel).prompt()
      .user(u -> u.text("Generate {amount} D&D characters, where key is a character's name")
        .param("amount", String.valueOf(amount)))
        .call()
        .entity(new GenericMapOutputConverter<>(Character.class));
}

8. 结论

本文探讨了如何通过 Spring AI 的 StructuredOutputConverter 实现 LLM 结构化输出:

  • 核心机制:通过 FormatProvider 生成格式指令 + Converter 解析输出
  • 内置转换器BeanOutputConverter(实体类)、MapOutputConverter/ListOutputConverter(集合)
  • 自定义扩展:构建支持复杂泛型的转换器

这些工具显著提升了 AI 输出的可控性,使 Java 应用集成结构化 AI 响应更简单可靠。完整示例见 GitHub 仓库


原始标题:A Guide to Structured Output in Spring AI | Baeldung