Spring AI调用entity()时JSON解析失败?Markdown包裹、字段缺失与Schema不兼容完整排查

文章摘要

Spring AI的ChatClient.call().entity()可以把模型输出直接转换为Java对象,但生产项目经常遇到JSON前后带解释文字、被Markdown代码块包裹、必填字段缺失、枚举值不匹配、日期格式错误或Provider不支持完整JSON Schema等问题。默认结构化输出本质上仍可能是“Prompt约束+文本解析”,并不天然保证100%成功。本文从目标类型设计、原始响应留存、Provider原生结构化输出、Schema校验与自修复、流式限制和错误分级六个方面给出完整排查方案。

一、典型错误表现

代码:

OrderRisk result = chatClient.prompt()
        .user(prompt)
        .call()
        .entity(OrderRisk.class);

可能出现:

JsonParseException
MismatchedInputException
UnrecognizedPropertyException
InvalidFormatException
Cannot deserialize value of type

模型实际返回:

以下是分析结果:

```json
{
  "riskLevel": "HIGH",
  "reason": "客户连续逾期"
}
人能看懂,但Jackson看到的不是纯JSON。

## 二、先保留原始响应

很多项目只调用:

```java
.entity(OrderRisk.class)

一旦失败,只看到反序列化异常,看不到模型到底返回了什么。

排查时先改为:

String raw = chatClient.prompt()
        .user(prompt)
        .call()
        .content();

log.debug("structured.raw={}", sanitize(raw));

然后再手工转换:

BeanOutputConverter converter =
        new BeanOutputConverter(OrderRisk.class);

OrderRisk result = converter.convert(raw);

生产环境不要记录敏感原文,可以记录:

  • 响应哈希;
  • 长度;
  • 前后少量脱敏片段;
  • 错误类型;
  • 模型和Prompt版本;
  • Schema版本。

三、目标Java类型是否适合生成

错误类型设计:

public class OrderRisk {
    private final RiskLevel riskLevel;
    private final BigDecimal score;
    private final LocalDateTime createdAt;

    public OrderRisk(...) {
        ...
    }
}

可能存在:

  • 无默认构造;
  • 字段没有Getter;
  • Jackson配置不一致;
  • 时间格式不明确;
  • 枚举值约束不清晰;
  • 非静态内部类;
  • 循环引用。

推荐优先使用Record:

public record OrderRisk(
        RiskLevel riskLevel,
        double score,
        String reason,
        List evidenceIds
) {
}

结构越简单,模型和反序列化器越稳定。

四、枚举值为什么最容易失败

定义:

public enum RiskLevel {
    LOW,
    MEDIUM,
    HIGH
}

模型可能返回:

{
  "riskLevel": "高风险"
}

或:

{
  "riskLevel": "high"
}

这都会造成枚举转换失败。

Prompt中应明确:

riskLevel只能取LOW、MEDIUM、HIGH之一。
不得输出中文值、缩写或其他值。

业务层仍应对未知值进行错误处理,而不是偷偷映射为默认值。

五、日期和数字格式需要收敛

日期

不推荐让模型自由输出:

2026年8月2日上午
08/02/26
2 Aug 2026

推荐:

ISO-8601
2026-08-02T08:30:00+08:00

如果只是业务日期,可以使用:

LocalDate

并要求:

YYYY-MM-DD

数字

金额不要让模型返回:

1,200元
约1.2K
人民币1200

结构化字段使用:

{
  "amount": 1200.00,
  "currency": "CNY"
}

六、额外字段导致失败怎么办

模型返回:

{
  "riskLevel": "HIGH",
  "reason": "连续逾期",
  "analysis": "详细推理过程"
}

而Java类型没有analysis

两种策略:

严格模式

禁止额外字段,任何漂移都视为错误。

适合:

  • 工作流路由;
  • 工具参数;
  • 订单操作;
  • 财务数据;
  • 审批结果。

宽松模式

忽略未知字段。

适合:

  • 非关键展示;
  • 兼容旧模型;
  • 渐进迁移。

企业系统建议对关键结构使用严格模式,避免模型偷偷扩展协议。

七、默认entity()并不是强制保证

结构化输出存在两个层次。

Prompt约束

把JSON Schema或格式指令加入Prompt
→ 模型尽量遵守
→ 客户端解析

优点:

  • Provider兼容广;
  • 大多数模型可用。

缺点:

  • 可能输出解释文字;
  • 可能漏字段;
  • 可能多字段;
  • 可能输出无效JSON。

Provider原生结构化输出

JSON Schema作为API级约束发送
→ Provider约束模型输出

可靠性通常更高,但存在:

  • Provider支持差异;
  • 模型版本差异;
  • JSON Schema子集限制;
  • 顶层数组限制;
  • 复杂递归结构不支持。

八、启用Provider原生结构化输出

Spring AI 2.0可以在目标模型支持时启用原生结构化输出:

OrderRisk result = chatClient.prompt()
        .user(prompt)
        .call()
        .entity(
                OrderRisk.class,
                spec -> spec.useProviderStructuredOutput()
        );

也可以在ChatClient级启用相应参数。

但要注意:

打开开关
≠ 所有Provider都严格支持全部Schema

上线前必须对具体:

  • Provider;
  • 模型;
  • API版本;
  • Schema;
  • 顶层结构;

进行实测。

九、使用validateSchema()自修复

当输出不符合Schema时,可以让Spring AI把具体校验错误反馈给模型并重新生成:

OrderRisk result = chatClient.prompt()
        .user(prompt)
        .call()
        .entity(
                OrderRisk.class,
                spec -> spec.validateSchema()
        );

也可以组合:

.entity(
    OrderRisk.class,
    spec -> spec
        .useProviderStructuredOutput()
        .validateSchema()
)

逻辑:

Provider约束输出
→ 本地Schema再次校验
→ 失败后携带错误重试

这种方式比盲目重试更有效,因为模型会看到:

缺少哪个字段
哪个字段类型错误
哪个值不符合枚举

十、重试不是免费的

结构化输出自修复会增加:

  • Token;
  • 延迟;
  • 模型费用;
  • 并发占用;
  • Provider限流压力。

需要记录:

structured_output_attempts
schema_validation_failure_count
structured_output_total_tokens
structured_output_latency_ms
repair_success_rate

如果大量请求都需要第二次或第三次修复,说明问题不应只靠重试解决。

应检查:

  • Schema是否过于复杂;
  • 模型是否适合;
  • Prompt是否冲突;
  • 字段描述是否不清;
  • 是否选择了不稳定的推理模式;
  • Provider原生支持是否完整。

十一、不要用一个超大对象承载全部结果

错误Schema:

客户分析
+风险判断
+行动计划
+邮件正文
+审批意见
+工具参数

一次返回几十个嵌套字段,失败率会快速上升。

推荐拆成:

第一步:分类
第二步:提取字段
第三步:生成建议
第四步:生成文本

关键路由对象保持小而稳定:

public record IntentDecision(
        IntentType intent,
        double confidence,
        boolean requiresHumanReview
) {
}

十二、顶层数组为什么容易踩坑

部分Provider的原生结构化输出对顶层数组支持有限。

不推荐:

List

推荐包装:

public record OrderRiskList(
        List items
) {
}

JSON:

{
  "items": [
    {
      "riskLevel": "HIGH",
      "score": 0.91
    }
  ]
}

这种结构更容易跨Provider迁移。

十三、流式输出不能直接entity()

.entity()需要完整响应才能解析,因此通常用于:

.call()

流式:

.stream()

返回的是文本片段,不能在每个Chunk上完成完整JSON反序列化。

如果必须流式展示结构化任务,建议:

流式发送进度事件
→ 模型完整生成
→ 服务端聚合
→ Schema校验
→ 最终发送result事件

不要尝试把半截JSON当成完整对象解析。

十四、BeanOutputConverter升级后Schema变化

Spring AI 2.0中,BeanOutputConverter的JSON Schema生成逻辑与工具调用Schema进一步对齐。

升级时需要检查:

  • Kotlin可选属性是否仍在required中;
  • @JsonProperty(required = false)行为;
  • LocalDateTime等format提示;
  • 自定义Schema后处理扩展点;
  • 新旧Schema是否兼容历史Prompt和测试集。

不要只验证代码能编译,还要保存并比较生成Schema差异。

十五、推荐错误分级

public enum StructuredOutputError {
    EMPTY_RESPONSE,
    INVALID_JSON,
    SCHEMA_MISMATCH,
    ENUM_VALUE_INVALID,
    DATE_FORMAT_INVALID,
    PROVIDER_NOT_SUPPORTED,
    RETRY_EXHAUSTED
}

不同错误使用不同策略:

空响应
→ 可短次数重试

Schema不匹配
→ 带错误自修复

Provider不支持
→ 回退Prompt模式

重试耗尽
→ 转人工或返回稳定错误

十六、完整排查清单

□ 是否保留了脱敏后的原始响应
□ Java目标类型是否可反序列化
□ 枚举取值是否明确
□ 日期和金额格式是否固定
□ 是否存在额外字段
□ Schema是否过度复杂
□ Provider是否支持原生结构化输出
□ 是否启用了useProviderStructuredOutput
□ 是否启用了validateSchema
□ 是否记录了修复次数和累计Token
□ 是否误在stream()中直接解析entity
□ 升级后生成Schema是否发生变化

总结

entity()解析失败并不只是“模型偶尔不听话”,而可能来自:

目标类型设计
+Prompt约束不足
+Provider能力差异
+Schema不兼容
+流式调用方式错误

生产级方案应采用:

简单稳定的领域类型
+Provider原生约束
+本地Schema校验
+有上限的错误自修复
+失败降级和完整观测

延伸阅读

如果你正在关注企业级AI应用、Spring AI、RAG、Agent与MCP工程化落地,欢迎访问 智元界

https://www.zyentor.com/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。