.entity()傳裸Map.class或窄泛型Map<String, String>時,泛型信息在編譯期就被擦掉了。Jackson 只能按LinkedHashMap反序列化,字段類型完全取決於模型返回了什麼。模型一旦把某個 key 給成陣列或嵌套物件,就拋MismatchedInputException。根子在於沒給模型一份明確的 schema,正解是定義 DTO/record。
Issue & Env(案發現場與環境)
- 業務場景:用
ChatClient的.entity()收結構化輸出,圖省事直接拿Map接。 - 運行環境:Spring AI 1.1.x,OpenAI 系模型;編譯期還會帶一條 unchecked 警告。
- 觸發條件:模型返回的 JSON 裡,某個字段的結構比目標類型「寬」——聲明是字符串,實際是陣列或物件。
StackTrace(堆疊日誌)
運行期拋出的例外原文:
org.springframework.ai.converter.ConversionException: Failed to convert ChatResponse to com.example.demo.dto.CitiesResponse
Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.lang.String` from Array value (token `JsonToken.START_ARRAY`)
at [Source: (String)"{"cities":[{"name":"New York","state":"NY"}]}"; line: 1, column: 25] (through reference chain: java.util.LinkedHashMap["cities"])
編輯器和編譯器給出的警告也值得一起看:
Unchecked assignment: 'java.util.Map' to 'java.util.Map<java.lang.String,java.lang.Object>'
它和運行期報錯其實在同一條線上,都指向同一件事:類型信息丟了。
Reproduce(復現程式碼)
省事的寫法,裸類型完全沒有泛型信息:
Map raw = chatClient.prompt()
.user("列出三个美国城市,含 name 和 state 字段")
.call()
.entity(Map.class);
再窄一點,直接聲明值類型是 String:
Map<String, String> narrow = chatClient.prompt()
.user("列出三个美国城市,含 name 和 state 字段")
.call()
.entity(new ParameterizedTypeReference<Map<String, String>>() {});
模型返回的是這種結構:
{"cities": [{"name": "New York", "state": "NY"}]}
第二種窄泛型寫法必崩:cities 的值是陣列,目標卻聲明成 String。第一種裸類型不崩,但拿回來的值是什麼類型,沒人能保證。
Root Cause(根因分析)
問題分兩層。
第一層是泛型擦除。Map<String, String> 裡的 String 只活在源碼裡,編譯後就沒影了,字節碼只留一個 Map。所以 entity(Map.class) 這種裸類型寫法,Jackson 除了「反序列化成 LinkedHashMap」之外,拿不到任何字段類型約束。此時 cities 是什麼類型,全由模型說了算。
第二層是 schema 缺失。Spring AI 的結構化輸出,靠轉換器在調用前把 JSON Schema 拼進 prompt。BeanOutputConverter 能從 Java 類或 ParameterizedTypeReference 推導出一份 DRAFT 2020-12 的 JSON Schema,再當格式指令餵給模型。MapOutputConverter 只引導模型產出 RFC8259 兼容的 JSON,落到 Map<String, Object> 就到底了,粒度上限是「一個物件」。你給它窄到 Map<String, String>,它沒法把這層約束表達成 schema 傳給模型。
兩層疊加的結果:模型不知道 cities 該是陣列還是字符串,隨手給成陣列;Jackson 按聲明的 String 去解,讀到 START_ARRAY 直接拋出 MismatchedInputException。官方文檔也說得很直白:StructuredOutputConverter 只是盡力而為,不保證模型一定按格式返回。
順帶看例外結構。ConversionException 只是 Spring AI 的包裝,真原因在 Caused by 裡,排查時別隻盯第一行。
The Fix(解決方案)
首選:改成 DTO / record
把目標定義成明確的類型,嵌套結構照樣展開:
record City(String name, String state) {}
record CitiesResponse(List<City> cities) {}
CitiesResponse result = chatClient.prompt()
.user("列出三个美国城市,含 name 和 state 字段")
.call()
.entity(CitiesResponse.class);
Spring AI 會依據這個類生成 JSON Schema,並注入格式指令。模型知道 cities 是物件陣列、每個元素有 name 和 state,返回結構就穩了。這是最省事也最可靠的一條路。
非要 Map:用 ParameterizedTypeReference
確實需要動態結構時,別用裸 Map.class,用 ParameterizedTypeReference 把泛型留住,並把值類型放寬到 Object:
Map<String, Object> result = chatClient.prompt()
.user("列出三个美国城市,含 name 和 state 字段")
.call()
.entity(new ParameterizedTypeReference<Map<String, Object>>() {});
Map<String, Object> 能裝下陣列、數字和嵌套物件,不會因為某個值類型不符就崩。代價是拿回來仍是弱類型,取值要自己判類型、自己做轉換。所以它更適合探索或純透傳場景。
prompt 裡只給純 JSON 結構描述
給模型的格式說明裡別混註釋、解釋文字或帶 Markdown 的示例。多補一句「cities 是陣列」,就少一次類型翻車。這塊別和「Markdown 圍欄導致解析失敗」混為一談,那個是包裝問題,這裡是類型不匹配。
2.0 的變化點
Spring AI 2.0 給 .entity() 加了自校正,可以在類型不符時帶著錯誤重試:
CitiesResponse result = chatClient.prompt()
.user("列出三个美国城市,含 name 和 state 字段")
.call()
.entity(CitiesResponse.class, spec -> spec.validateSchema());
它能緩解模型偶發跑偏,但替代不了合理的類型定義——schema 本身就是從你的類型推出來的。
