用
.entity()收结构化输出,模型偶尔把 JSON 包进 Markdown 代码围栏再返回,BeanOutputConverter直接抛异常,还把原始响应藏起来不让你看。1.x 得自己清理重建;2.0 用validateSchema()让框架自动重试校正。
Issue & Env(案发现场与环境)
- 业务场景:用
ChatClient的.entity()把模型返回映射成强类型对象,偶尔解析失败。 - 环境:Spring AI 1.1.0(issue #5060 报告版本),该问题在 1.x 持续存在;2.0 引入自校正机制。
StackTrace(堆栈日志)
issue #5060 报告的异常形态:模型返回的 JSON 反序列化失败时,JsonParseException 被包进一个不带任何额外字段的 RuntimeException。报错原文通常是 Jackson 的解析错误:
com.fasterxml.jackson.core.JsonParseException: Unexpected character ('H' (code 72)): expected a valid value (number, String, array, object, 'true', 'false' or 'null')
完整堆栈:
java.lang.RuntimeException
at org.springframework.ai.converter.BeanOutputConverter.convert(BeanOutputConverter.java:120)
at org.springframework.ai.chat.client.DefaultChatClient$DefaultChatClientRequestSpec$InternalChatClientRequest.entity(DefaultChatClient.java:720)
at com.example.demo.movie.FilmographyController.filmography(FilmographyController.java:27)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)
Caused by: com.fasterxml.jackson.core.JsonParseException: Unexpected character ('H' (code 72)): expected a valid value (number, String, array, object, 'true', 'false' or 'null')
at com.fasterxml.jackson.core.JsonParser._constructError(JsonParser.java:2391)
at com.fasterxml.jackson.core.base.ParserMinimalBase._reportError(ParserMinimalBase.java:745)
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3751)
触发它的典型返回是这种——模型把 JSON 用 Markdown 代码围栏包了起来:
Here's the filmography:
```json
{ "actor": "Tom Hanks", "movies": ["Forrest Gump", "Cast Away"] }
```
BeanOutputConverter 拿到的是整段文字,JSON 前面多了说明和围栏,解析直接失败。
Reproduce(复现代码)
定义一个目标类型,再用 .entity() 收:
record ActorsFilms(String actor, List<String> movies) {}
ActorsFilms films = chatClient.prompt()
.user("Generate the filmography for a random actor.")
.call()
.entity(ActorsFilms.class);
多数时候能成,但只要某一次模型把 JSON 包进围栏、或塞进几句说明,.entity() 就崩。
Root Cause(根因分析)
BeanOutputConverter 是严格转换器,对模型输出只有一个预期:纯 JSON。它不会帮你剥掉 Markdown 围栏、说明文字、尾逗号,也不做任何容错。模型一旦在 JSON 外面多写点什么,Jackson 就抛 JsonParseException。
更麻烦的是 issue #5060 指出的点:这个异常被包成不带任何字段的 RuntimeException。你想在 catch 里看看模型到底回了什么,既拿不到原始文本,也读不到完整错误——设计上就没给你留这个后门。
本质原因是 LLM 是文本模型,没人保证它每次都输出裸 JSON。官方文档也明说 StructuredOutputConverter 只是「尽力而为」,不是保证。
The Fix(解决方案)
1.x 的绕法:自己清理再转
先拿原始文本,剥掉围栏和前后噪声,再手动转换:
String raw = chatClient.prompt()
.user("generate filmography")
.call()
.content();
String json = raw.replaceAll("```json", "")
.replaceAll("```", "")
.trim();
ActorsFilms obj = new BeanOutputConverter<>(ActorsFilms.class).convert(json);
或者干脆收 String,用 Jackson 自己反序列化,把清洗逻辑集中到一处。
2.0 的正解:validateSchema()
Spring AI 2.0 给 .entity() 加了自校正,校验失败就带着具体错误重试:
ActorsFilms films = chatClient.prompt()
.user("generate the filmography for a random actor")
.call()
.entity(ActorsFilms.class, spec -> spec.validateSchema());
开关一拉,StructuredOutputValidationAdvisor 自动注册。模型返回后先按 schema 校验,失败就把「缺了哪个字段、类型哪里不对」拼回 prompt 重发,默认最多重试 3 次。想调次数就自己建 advisor:
var advisor = StructuredOutputValidationAdvisor.builder()
.outputType(ActorsFilms.class)
.maxRepeatAttempts(5)
.build();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(advisor)
.build();