惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
C
CXSECURITY Database RSS Feed - CXSecurity.com
D
Docker
有赞技术团队
有赞技术团队
WordPress大学
WordPress大学
Jina AI
Jina AI
小众软件
小众软件
Last Week in AI
Last Week in AI
Hugging Face - Blog
Hugging Face - Blog
博客园 - 三生石上(FineUI控件)
宝玉的分享
宝玉的分享
美团技术团队
爱范儿
爱范儿
V
V2EX
大猫的无限游戏
大猫的无限游戏
人人都是产品经理
人人都是产品经理
J
Java Code Geeks
博客园 - 司徒正美
博客园 - 叶小钗
S
SegmentFault 最新的问题
量子位
S
Secure Thoughts
月光博客
月光博客
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
O
OpenAI News
L
LINUX DO - 最新话题
罗磊的独立博客
SecWiki News
SecWiki News
雷峰网
雷峰网
Recent Announcements
Recent Announcements
V2EX - 技术
V2EX - 技术
T
Tailwind CSS Blog
H
Hacker News: Front Page
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
云风的 BLOG
云风的 BLOG
Schneier on Security
Schneier on Security
T
The Blog of Author Tim Ferriss
IT之家
IT之家
博客园 - 聂微东
腾讯CDC
N
News | PayPal Newsroom
P
Proofpoint News Feed
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
The GitHub Blog
The GitHub Blog
Hacker News: Ask HN
Hacker News: Ask HN
aimingoo的专栏
aimingoo的专栏
Webroot Blog
Webroot Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Google DeepMind News
Google DeepMind News
K
Kaspersky official blog

Spring

A Bootiful Podcast: Russ Miles on Safer, More Productive Interactions with AI This Week in Spring - July 14th, 2026 Spring Office Hours Podcast: S5E18 - The Latest from OpenAI, Anthropic and Spring AI 2.0 A Bootiful Podcast: Spring Boot legend Moritz Halbritter on the latest and greatest in Spring Boot 4 and 4.1 This Week in Spring - July 7th, 2026 A New Home for Spring Cloud Contract: Transitioning to Stubborn.sh Spring Office Hours Podcast: S5E17 - Spring Boot 4.1 with Phil Webb A Bootiful Podcast: Sébastien Deleuze on the latest-and-greatest in Spring AI and Spring Framework This Week in Spring - June 30th, 2026 A Bootiful Podcast: My friend Francesco Ciulla on developer advocacy and more Spring Boot 3.5.16 available now Spring Data 2025.0.13 released This Week in Spring - June 23rd, 2026 MongoDB-backed Spring Batch jobs and more in Spring Boot 4.1 A Bootiful Podcast: DaShaun Carter on patching, Spring Boot 4.1, and security in the world of AI This Week in Spring - June 16th, 2026 Spring Tools 5.2.0 released Tool Calling in Spring AI 2.0: A Composable, Agentic Architecture Spring AI 1.0.9, 1.1.8 Available Now Spring AI 2.0.0 GA Available Now A Bootiful Podcast: Spring Security lead Rob Winch answers some security questions for me Spring Modulith 2.1 GA, 2.0.7, and 1.4.12 released Spring Shell 4.0.3 and 3.4.3 are out! Spring Cloud 2025.0.3 (aka Northfields) Has Been Released Spring Cloud 2025.1.2 (aka Oakwood) Has Been Released Spring for GraphQL 1.4.6 and 2.0.4 released Spring gRPC 1.1.0 available now Spring Vault 4.0.3 and 3.2.1 available Spring Vault 4.1 Generally Available Spring Batch 6.0.4 and 5.2.6 available now Spring Boot 3.5.15 available now Spring AI 2.0.0-RC1 Available Now A Bootiful Podcast: JetBrains' Marit van Dijk This Week in Spring - June 2nd, 2026 Spring and Security In The Times Of AI A Bootiful Podcast: Microsoft's Martijn Verburg Spring AI 2.0.0-M8 Available Now This Week in Spring - May 26th, 2026 Spring AI 1.0.8, 1.1.7, 2.0.0-M7 Available Now A Bootiful Podcast: Hadi Hariri, Jetbrains legend This Week in Spring - May 19th, 2026 Spring Office Hours Podcast: S5E16 - May Release Train Shift & What's Coming in Spring Boot 4.1 A Bootiful Podcast: the legendary Adib Saikali This Week in Spring - May 12th, 2026 May Release Train Date Changes Spring Office Hours Podcast: S5E15 - Upgrading Spring and OSS Security Spring AI 1.0.7, 1.1.6, 2.0.0-M6 Available Now Spring Cloud Function and Config Have Been Released To Address Several CVEs A Bootiful Podcast: Daniel Garnier-Moiroux on his new book 'Testing Spring Boot Applications' This Week in Spring - May 5th, 2026 Spring Office Hours Podcast: S5E14 - Spec Driven Development with Simon Martinelli Ronald Dehuysser, founder of JobRunr, on their ambitious new JavaClaw-like agent runtime This Week in Spring - April 28th, 2026 Spring AI 1.0.6, 1.1.5, 2.0.0-M5 Available Now Spring Modulith 2.1 RC1, 2.0.6, and 1.4.11 released Spring Shell 4.0.2 is out! A Bootiful Podcast: A Bootiful Podcast: Dr. Venkat Subramaniam and James Ward on Intelligent Kotlin and So Much More Spring Boot 3.5.14 available now Spring Boot 4.0.6 available now Spring Boot 4.1.0-RC1 available now Spring for Apache Kafka 4.1.0-RC1, 4.0.5, and 3.3.15 Available Spring for Apache Pulsar 1.2.17 and 2.0.5 are now available This Week in Spring - April 21st, 2026 Spring Authorization Server 1.5.7 Available Now Spring Security 2026.04 Releases - Contains CVE Fixes Spring Integration 7.1.0-RC1 Available Spring Vault 4.1.0-RC1 and 4.0.2 released Spring Data 2026.0.0-RC1 enters release candidate phase Spring Data 2025.1.5 and 2025.0.11 released Spring Framework 6.2.18 and 7.0.7 Available Now A Bootiful Podcast: the legendary Craig Walls Spring AI Agentic Patterns (Part 7): Session API — Event-Sourced Short-Term Memory with Context Compaction This Week in Spring - April 14th, 2026 Catch the Spring Team at Spring I/O 2026! A Bootiful Podcast: Mark Kropf on AI orchestration Spring Office Hours Podcast: S5E12 - Developer Soft Skills with Arun Gupta Spring AI Agentic Patterns (Part 6): AutoMemoryTools — Persistent Agent Memory Across Sessions
Self-Correcting Structured Output in Spring AI 2.0
tzolov · 2026-06-23 · via Spring

Large language models are text-in, text-out systems — their interface is natural language.

Natural language is a great interface for humans and a poor one for software. The moment downstream code needs to route on a field, persist a value, or branch on a result, the conversation has to become a record. Structured output bridges that gap. The model is steered to produce text conforming to a schema; the application parses it back into a typed object the rest of the codebase can treat like any other domain type.

Spring AI has supported structured output since day one through ChatClient.call().entity(...). Spring AI 2.0 adds two new dials to it — provider-native structured output and self-correcting schema validation. Defaults are unchanged, so existing code keeps working.

This post walks through structured output, starting with a simple, working case first, then add reliability piece by piece.


Typed Response

Define a Java record for the shape you want back:

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

Ask ChatClient to populate it. Instead of finishing the call with .content() — which returns the raw text reply — finish with .entity(...) and pass your target type:

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

That's it. The result is a typed ActorsFilms you can pass to the rest of your code:

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

.entity(...) is .call()-only. Typed parsing requires the complete response, so it isn't available on the streaming path (.stream() returns text chunks, not typed objects). This applies to every variant covered below — Class, ParameterizedTypeReference, custom converter, with or without the new dials.

Behind the scenes, Spring AI did three things: a schema generator turned your ActorsFilms record into a JSON schema, that schema was appended to the prompt's system context, and the model's JSON answer was then handed to a type converter that parsed it back into your record.

This works on every model Spring AI supports — there's nothing provider-specific about it.

It also has no guarantees. The model is asked to produce JSON matching the schema, not forced to. Most of the time it complies. Sometimes it doesn't — it returns an extra field, omits a required one, or wraps the JSON in prose. When that happens, the parser throws.

The next two sections fix that, one approach at a time.


Adding a Safety Net: validateSchema()

The simplest way to handle malformed output is to detect it and retry. Spring AI 2.0 does this automatically with a single switch:

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

The spec -> spec.validateSchema() consumer turns on a self-correcting retry loop:

  1. The model responds.
  2. Spring AI validates the response against the schema for ActorsFilms.
  3. If validation passes, you get your typed record back.
  4. If it fails, the validation error ("missing required field actor", "expected array, got string") is appended to the user prompt, and the call is re-issued — up to 3 attempts by default.

The model sees the specific error on each retry. The second attempt isn't a blind re-try; the model knows what was wrong and can correct it.

This is powered by StructuredOutputValidationAdvisor, a recursive advisor that's auto-registered when you call validateSchema(). You don't have to wire anything; the switch is the entire configuration.

Customizing the advisor

StructuredOutputValidationAdvisor defaults to 3 retry attempts and uses Spring AI's default JsonMapper. To customize — for example, more attempts, a pre-supplied schema, or a different mapper — build your own instance and register it on the ChatClient. An explicitly registered advisor replaces the auto-registered one:

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

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

Adding Upstream Guarantees: useProviderStructuredOutput()

validateSchema() is a response-side safety net — it catches bad output after the fact and retries. The complementary approach is a request-side constraint: tell the model's provider, at the API level, that the response must conform to a schema. Most modern providers support this (OpenAI's Structured Outputs, Anthropic's structured output extension, Gemini's responseSchema, Mistral's response_format).

Spring AI exposes it portably with another switch on the same consumer:

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

What changes at the wire level:

  • The system prompt no longer carries a JSON format instruction (cleaner, fewer tokens).
  • The schema is sent to the provider as an API-level field.
  • The provider's runtime enforces conformance — invalid responses can't be emitted at all.

Supported providers as of 2.0: OpenAI, Anthropic, Google GenAI , Mistral AI, Ollama (model-specific). The same .useProviderStructuredOutput() call works regardless of which is wired in.

Spring AI detects support by checking whether the model's chat options implement the StructuredOutputChatOptions interface. If not, the flag is silently ignored and the call falls back to the prompt-based default.

Why it's off by default

Compatibility. Older or non-supporting models would reject the request, and the prompt-based default works everywhere. A few known limitations are worth mentioning even on supported providers:

  • Partial JSON Schema support. Native structured output support is often partial — even on providers that advertise the feature, the accepted JSON Schema surface varies. $ref, deeply nested arrays, allOf/anyOf/oneOf, regex patterns, and recursive types are common limitations. The shape drift this can cause is exactly what validateSchema() (next section) is good at catching.
  • Ollama with reasoning ("thinking") models — variants like qwen may emit their internal reasoning trace as plain text instead of JSON, causing parse errors. Use a non-reasoning model, or combine native output with validateSchema() so misbehaving responses are retried.
  • OpenAI doesn't accept top-level arrays in its Structured Outputs API. Wrap a list in a container record before requesting it natively (record FilmographyList(List<ActorsFilms> films) {}).

Combining Both

The two switches solve different problems and compose naturally:

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

useProviderStructuredOutput() minimizes the chance of malformed output by constraining the model at the API level. validateSchema() catches the residual cases — provider edge cases, the Ollama reasoning quirk above — and corrects them automatically.

Reach for both when downstream code can't tolerate shape drift — when a missing field or wrong type would corrupt state, throw later, or silently misroute.


Generic Types: Lists, Maps, and Beyond

.entity(Class) is for concrete classes. For generic types — List<ActorsFilms>, Map<String, ActorsFilms> — use ParameterizedTypeReference:

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

The same EntityParamSpec consumer works:

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

One thing to watch out for: OpenAI's Structured Outputs API doesn't accept a top-level array. If you combine List<...> with .useProviderStructuredOutput() on OpenAI, the call will fail. The fix is a one-line wrapper record:

record FilmographyList(List<ActorsFilms> films) {}

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

The default prompt-based flow has no such restriction — top-level arrays work fine without useProviderStructuredOutput().


Getting the Full Response

.entity(...) returns only the parsed object. If you also need the underlying ChatResponse — for token usage, observability metadata, or anything beyond the entity — use .responseEntity(...):

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();

It has the same overload set as .entity(...)Class, ParameterizedTypeReference, and the EntityParamSpec consumer all apply.


When the Built-ins Aren't Enough

The built-in BeanOutputConverter is strict: it expects the model's response to be parseable JSON, full stop. But models often wrap their JSON in markdown code fences:

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

BeanOutputConverter will throw on the first H of "Here's". The common fix is a custom converter that strips fences and extracts the JSON before delegating to the default parser:

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);
    }
}

Pass it to .entity(...) instead of a Class:

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

Because this converter delegates getJsonSchema() to the underlying BeanOutputConverter, both new dials still work — validateSchema() and useProviderStructuredOutput() operate against the same schema the default converter would use. You get resilient parsing plus self-correction with no extra wiring.

The role of getJsonSchema(). Added in 2.0 as a default method on StructuredOutputConverter, getJsonSchema() is the bridge that lets a converter participate in useProviderStructuredOutput() and validateSchema(). Implement it to return your schema (typically by delegating to a BeanOutputConverter) and the new dials work; leave it at the default and both dials become no-ops for that converter.

Non-JSON formats

For formats outside JSON's reach — YAML for config generators, CSV for data extraction — implement StructuredOutputConverter from scratch: write your own getFormat() prompt and your own convert(...) parser. Leave getJsonSchema() at its default, and both new dials sit out — the prompt-based path runs as it does for the built-ins.


Cheat Sheet

You need Use
Default — works on every provider .entity(Type.class)
Generic types like List<T>, Map<K,V> .entity(new ParameterizedTypeReference<...>() {})
Don't fail on malformed output .entity(Type.class, spec -> spec.validateSchema())
Stronger upstream guarantees from the provider .entity(Type.class, spec -> spec.useProviderStructuredOutput())
Both — request constraint + response retry .entity(Type.class, spec -> spec.useProviderStructuredOutput().validateSchema())
Token usage / metadata alongside the entity .responseEntity(...) (same overloads)
Model wraps JSON in markdown fences, or non-JSON format Implement StructuredOutputConverter<T> and pass it to .entity(...)
Streaming responses Not supported — .entity(...) is .call()-only; .stream() returns text chunks, not typed objects

Wrapping Up

Structured output in Spring AI 2.0 is the same .entity(...) call you already know, with two new switches: validateSchema() for response-side self-correction, useProviderStructuredOutput() for request-side enforcement. Each is independently useful; together they constrain the request and self-correct the response. Existing code keeps working unchanged; new code can opt in per call without rewiring the application.


References