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