.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 本身就是从你的类型推出来的。
