Ohhnews

分类导航

$ cd ..
Spring Blog原文

Spring AI 2.0中的自纠正结构化输出

#spring ai#结构化输出#自纠正#验证#提供者原生

大型语言模型是文本输入、文本输出的系统——它们的界面是自然语言。[LOADING...]

自然语言对人类来说是极佳的界面,但对软件而言则很差。当下游代码需要对字段进行路由、持久化某个值或根据结果分支时,对话就必须变成一条记录。结构化输出弥补了这一鸿沟。模型被引导生成符合某种模式(Schema)的文本;应用程序将其解析回一个类型化对象,代码库的其余部分可以像对待其他领域类型一样对待它。

Spring AI 从第一天起就通过 ChatClient.call().entity(...) 支持结构化输出。Spring AI 2.0 为其增加了两个新旋钮——提供商原生结构化输出自修正模式校验。默认行为不变,因此现有代码继续正常工作。

本文带你深入了解结构化输出,首先从一个简单可用的案例开始,然后逐个添加可靠性保障。


类型化响应

首先定义一个 Java 记录(Record)来描述你想要返回的形状:

$ java
record ActorsFilms(String actor, List<String> movies) {}

然后让 ChatClient 来填充它。不再用 .content() 结束调用——它会返回原始文本回复——而是用 .entity(...) 并传入你的目标类型:

$ java
ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class);

搞定。结果是一个类型化的 ActorsFilms 对象,你可以将其传递给代码的其余部分:

$ java
System.out.println(films.actor());     // "Tom Hanks"
System.out.println(films.movies());    // ["Forrest Gump", "Cast Away", ...]

.entity(...) 仅限 .call() 使用。 类型化解析需要完整的响应,因此它不适用于流式路径(.stream() 返回文本块,而非类型化对象)。这一点适用于下面介绍的所有变体——ClassParameterizedTypeReference、自定义转换器,无论是否使用新旋钮。

在幕后,Spring AI 做了三件事:一个模式生成器将你的 ActorsFilms 记录转换为 JSON 模式,该模式被附加到提示的系统上下文中,然后模型的 JSON 回答被交给一个类型转换器,它将其解析回你的记录。

[LOADING...]

这在 Spring AI 支持的所有模型上都可以工作——没有任何提供商特定的限制。

但它也没有任何保证。模型只是被要求生成符合模式的 JSON,而不是强制生成。大多数情况下它会遵守。有时则不会——它返回一个额外的字段、省略了必需字段,或者用散文包裹 JSON。此时解析器会抛出异常。

接下来的两个部分分别介绍如何解决这个问题。


添加安全网:validateSchema()

处理格式错误输出的最简单方法是检测并重试。Spring AI 2.0 通过一个开关自动完成此操作:

$ java
ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class, spec -> spec.validateSchema());

spec -> spec.validateSchema() 消费者开启了一个自修正重试循环:

  1. 模型响应。
  2. Spring AI 根据 ActorsFilms 的模式验证响应。
  3. 如果验证通过,你将获得类型化记录。
  4. 如果失败,验证错误("缺少必需字段 actor"、"期望 array,得到 string")会被附加到用户提示中,并重新发起调用——默认最多尝试 3 次。

模型在每次重试时都看到具体的错误。第二次尝试不是盲目重试;模型知道哪里错了并可以纠正。

[LOADING...]

这是由 StructuredOutputValidationAdvisor 驱动的,这是一个递归顾问,当你调用 validateSchema() 时会自动注册。你无需额外配置;这个开关就是全部配置。

自定义顾问

StructuredOutputValidationAdvisor 默认重试 3 次,并使用 Spring AI 的默认 JsonMapper。要自定义——例如更多重试次数、预提供模式或不同的映射器——构建你自己的实例并在 ChatClient 上注册。显式注册的顾问会替换自动注册的那个:

$ java
var validationAdvisor = StructuredOutputValidationAdvisor.builder()
    .outputType(ActorsFilms.class)
    .maxRepeatAttempts(5)
    .build();

ChatClient chatClient = ChatClient.builder(chatModel)
    .defaultAdvisors(validationAdvisor)
    .build();

添加上游保证:useProviderStructuredOutput()

validateSchema() 是一个响应侧安全网——它在事后捕获错误输出并重试。互补的方法是请求侧约束:在 API 层面告诉模型提供商,响应必须符合某个模式。大多数现代提供商都支持这一点(OpenAI 的结构化输出、Anthropic 的结构化输出扩展、Gemini 的 responseSchema、Mistral 的 response_format)。

Spring AI 通过同一个消费者上的另一个开关以可移植方式暴露它:

$ java
ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class, spec -> spec.useProviderStructuredOutput());

在传输层面发生的变化:

  • 系统提示不再携带 JSON 格式指令(更干净,更少的 token)。
  • 模式以 API 级别的字段发送给提供商。
  • 提供商的运行时强制遵守——根本不会发出无效响应。

截至 2.0 版本支持的提供商:OpenAIAnthropicGoogle GenAIMistral AIOllama(模型相关)。无论接入的是哪个提供商,.useProviderStructuredOutput() 调用都有效。

Spring AI 通过检查模型的聊天选项是否实现 StructuredOutputChatOptions 接口来检测支持情况。如果不支持,该标志会被静默忽略,调用回退到基于提示的默认方式。

为什么默认关闭

兼容性。较旧或不支持的模型会拒绝请求,而基于提示的默认方式在所有地方都能工作。即使在受支持的提供商上,也有一些已知的限制值得提及:

  • 部分 JSON 模式支持。 原生结构化输出支持通常是部分的——即使在宣传此功能的提供商上,接受的 JSON 模式表面也有所不同。$ref、深层嵌套数组、allOf/anyOf/oneOf、正则模式以及递归类型是常见的限制。这种形状偏差正是 validateSchema()(下一节)擅于捕获的。
  • Ollama 搭配推理(“思考”)模型——像 qwen 这样的变体可能会将其内部推理痕迹作为纯文本输出,而不是 JSON,导致解析错误。请使用非推理模型,或者将原生输出与 validateSchema() 结合使用,以使行为不正常的响应被重试。
  • OpenAI 不接受顶级数组在其结构化输出 API 中。在原生请求之前,将列表包装在一个容器记录中(record FilmographyList(List<ActorsFilms> films) {})。

两者结合

两个开关解决不同的问题,并且自然组合:

$ java
ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorsFilms.class, spec -> spec
        .useProviderStructuredOutput()
        .validateSchema());

useProviderStructuredOutput() 通过在 API 层面约束模型来最小化格式错误输出的可能性。validateSchema() 捕获剩余情况——提供商的边缘情况、上面提到的 Ollama 推理怪癖——并自动纠正。

当下游代码无法容忍形状偏差时——缺失字段或错误类型会导致状态损坏、后续抛出异常或静默路由错误——请同时使用两者。


泛型类型:List、Map 等

.entity(Class) 用于具体类。对于泛型类型——List<ActorsFilms>Map<String, ActorsFilms>——使用 ParameterizedTypeReference

$ java
List<ActorsFilms> films = chatClient.prompt()
    .user("Generate filmographies for three random actors.")
    .call()
    .entity(new ParameterizedTypeReference<List<ActorsFilms>>() {});

同样的 EntityParamSpec 消费者也适用:

$ java
List<ActorsFilms> films = chatClient.prompt()
    .user("Generate filmographies for three random actors.")
    .call()
    .entity(new ParameterizedTypeReference<List<ActorsFilms>>() {},
            spec -> spec.validateSchema());

需要注意一点:OpenAI 的结构化输出 API 不接受顶级数组。如果你在 OpenAI 上将 List<...>.useProviderStructuredOutput() 结合使用,调用会失败。解决方法是一个单行包装记录:

$ java
record FilmographyList(List<ActorsFilms> films) {}

FilmographyList result = chatClient.prompt()
    .user("Generate filmographies for three random actors.")
    .call()
    .entity(FilmographyList.class, spec -> spec.useProviderStructuredOutput());

默认的基于提示的流程没有这样的限制——顶级数组在不使用 useProviderStructuredOutput()时完全正常。


获取完整响应

.entity(...) 只返回解析后的对象。如果你还需要底层的 ChatResponse——用于 token 用量、可观测性元数据或超出实体之外的任何内容——请使用 .responseEntity(...)

$ java
ResponseEntity<ChatResponse, ActorsFilms> result = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .responseEntity(ActorsFilms.class);

ActorsFilms films = result.entity();
ChatResponse raw = result.response();
long totalTokens = raw.getMetadata().getUsage().getTotalTokens();

它具有与 .entity(...) 相同的重载集——ClassParameterizedTypeReference 以及 EntityParamSpec 消费者都适用。


内置转换器不够用时

内置的 BeanOutputConverter 很严格:它期望模型的响应必须是可解析的 JSON,仅此而已。但模型经常将 JSON 包裹在 Markdown 代码块中:

Here's the filmography:
    ```json
    { "actor": "Tom Hanks", "movies": ["Forrest Gump", "Cast Away"] }
    ```

BeanOutputConverter 会在 "Here's" 的第一个 "H" 处抛出异常。常见的解决方法是自定义转换器,它去除代码块标记、提取 JSON,然后委托给默认解析器:

$ java
public class LenientJsonOutputConverter<T> implements StructuredOutputConverter<T> {

    private static final Pattern FENCE = Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```");

    private final BeanOutputConverter<T> delegate;

    public LenientJsonOutputConverter(Class<T> targetType) {
        this.delegate = new BeanOutputConverter<>(targetType);
    }

    @Override public String getFormat()     { return delegate.getFormat(); }
    @Override public String getJsonSchema() { return delegate.getJsonSchema(); }

    @Override
    public T convert(String source) {
        var matcher = FENCE.matcher(source);
        String json = matcher.find() ? matcher.group(1).trim() : source.trim();
        return delegate.convert(json);
    }
}

将其传递给 .entity(...) 而不是 Class

$ java
ActorsFilms films = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(new LenientJsonOutputConverter<>(ActorsFilms.class));

因为此转换器将 getJsonSchema() 委托给底层的 BeanOutputConverter,两个新旋钮仍然有效——validateSchema()useProviderStructuredOutput() 都针对默认转换器使用的相同模式进行操作。你获得了弹性解析加上自修正,无需额外配置。

getJsonSchema() 的作用。 在 2.0 中作为 StructuredOutputConverter 的默认方法添加,getJsonSchema() 是让转换器参与 useProviderStructuredOutput()validateSchema() 的桥梁。实现它以返回你的模式(通常通过委托给 BeanOutputConverter),新旋钮即可工作;保留默认实现,两个旋钮对该转换器都变成空操作。

非 JSON 格式

对于 JSON 无法覆盖的格式——用作配置生成器的 YAML、用于数据提取的 CSV——从头实现 StructuredOutputConverter:编写你自己的 getFormat() 提示和你自己的 convert(...) 解析器。将 getJsonSchema() 保留为默认值,两个新旋钮将不参与——基于提示的路径会像内置转换器一样运行。


速查表

你需要什么使用
默认——在所有提供商上工作.entity(Type.class)
泛型类型如 List<T>Map<K,V>.entity(new ParameterizedTypeReference<...>() {})
不要因为格式错误输出而失败.entity(Type.class, spec -> spec.validateSchema())
来自提供商的更强上游保证.entity(Type.class, spec -> spec.useProviderStructuredOutput())
两者兼备——请求约束 + 响应重试.entity(Type.class, spec -> spec.useProviderStructuredOutput().validateSchema())
实体旁边的 token 用量 / 元数据.responseEntity(...)(相同重载)
模型将 JSON 包裹在 Markdown 代码块中,或非 JSON 格式实现 StructuredOutputConverter<T> 并传递给 .entity(...)
流式响应不支持——.entity(...) 仅限 .call().stream() 返回文本块,而非类型化对象

总结

Spring AI 2.0 中的结构化输出仍然是你已经熟悉的 .entity(...) 调用,增加了两个新开关:用于响应侧自修正的 validateSchema(),以及用于请求侧强制约束的 useProviderStructuredOutput()。每个都可独立使用;组合起来则能约束请求并自修正响应。现有代码保持不变;新代码可以在每次调用中选择加入,而无需重新配置应用。


参考