☰
Spring AI结构化输出实战:用entity()替代手撕JSON
2026/10/14 6:52:04 网站建设 项目流程

1. 为什么我彻底放弃了手撕 JSON 这条路

做 AI 应用集成这半年多,我最深的感受就是:模型返回的文本处理,远比调用模型本身要麻烦得多。刚开始做对话功能的时候,我图省事,直接拿chatClient.prompt().user(...).call().content()拿到一个字符串,然后自己写正则、写substring、写indexOf去抠字段。结果就是,模型稍微换个措辞、多打一个换行、或者把 JSON 包在 Markdown 代码块里,我的解析逻辑就全线崩溃。那段时间我几乎每天都在改解析代码,改到最后整个人都麻了。

后来接触到 Spring AI 的结构化输出(Structured Output)能力,尤其是entity()这个方法,我才意识到之前那些活儿基本都是在做无用功。entity()的核心价值就一句话:你给它一个 JavaBean 的类型,它直接把模型返回的文本映射成这个对象,中间那层 JSON 解析、字段对齐、类型转换,框架全帮你干了。你不用再写ObjectMapper.readValue(),不用再处理模型偶尔冒出来的“好的,以下是结果:”这种废话前缀,也不用担心字段名大小写对不上。

这篇文章我想聊的不是“怎么调 API”这种入门级的东西,而是把entity()这套结构化输出机制背后的设计逻辑、实际落地时的参数选择、踩过的坑、以及怎么把它用在一个真实业务场景里,完整地拆一遍。适合已经用过 Spring AI 基础对话功能、但还在用字符串硬解析的开发者,也适合正在评估“要不要把 AI 输出接入现有业务对象”的团队参考。看完你至少能明白三件事:entity()到底怎么把文本变成对象、什么场景下该用它、以及哪些坑我替你踩过了。

2. 结构化输出的整体设计思路拆解

2.1 从“字符串思维”切换到“对象思维”

传统做法里,我们把模型当成一个“会说话的文本生成器”,拿到字符串之后再想办法解析。这种思路的问题在于,解析逻辑和模型输出格式是强耦合的。模型今天输出{"name":"张三"},明天可能输出name: 张三,后天可能加一句“根据您的要求,结果如下”。你的解析代码就得跟着模型的心情走。

Spring AI 的结构化输出换了个思路:把模型当成一个“会填表单的助手”。你先定义好表单长什么样(也就是 JavaBean 的字段结构),然后告诉模型“请按这个格式填”,框架负责把模型填好的内容转成对象。这个思路的转变很关键,因为它把“格式约束”这件事从你的解析代码里,转移到了框架和模型的交互协议里。

具体到entity()这个方法,它的签名大致是这样的:

<T> T entity(Class<T> type); <T> T entity(ParameterizedTypeReference<T> typeReference); <T> T entity(StructuredOutputConverter<T> converter);

最常用的就是第一个,传一个Class进去,返回这个类型的实例。框架内部会自动做几件事:根据你的类型生成格式说明(schema)、把说明拼进 prompt、调用模型、拿到返回文本、用转换器把文本转成对象。你看到的只是一个方法调用,背后其实是一整套约定。

2.2 为什么是 JavaBean 而不是 Map

有人可能会问,为什么不直接返回Map<String, Object>呢?这样不是更灵活吗?我一开始也这么想,但实际用下来发现,JavaBean 带来的类型安全和可读性,远比 Map 的“灵活”值钱。

用 Map 的话,你拿到之后还是得map.get("userName")这样去取,字段名写错了编译器不报错,运行时才给你一个 null。而且嵌套结构一深,Map套Map套List,代码可读性直接崩盘。用 JavaBean 就不一样了,IDE 能自动补全,字段类型明确,嵌套对象直接点出来,重构的时候也安全。

更重要的是,JavaBean 的字段定义本身就是给模型的“格式说明书”。框架会根据你的字段名、字段类型、以及可选的描述注解,生成一段格式要求塞进 prompt。字段定义得越清晰,模型填得越准。这一点后面讲实操的时候我会展开。

2.3 转换器在中间扮演的角色

entity()背后真正干活的是StructuredOutputConverter。Spring AI 默认提供的是基于 JSON 的转换器,它的工作流程大致是:

  1. 根据目标类型生成一个 JSON Schema 描述;
  2. 把这个描述作为格式指令附加到 prompt 末尾;
  3. 模型返回文本后,从文本中提取 JSON 片段;
  4. 用 Jackson 把 JSON 反序列化成目标对象。

这里有个细节值得注意:模型返回的文本往往不是纯 JSON。它可能带 Markdown 代码块标记,可能前面有一句“好的,这是结果”,可能后面跟一句“希望对你有帮助”。默认转换器会尝试从文本里“抠”出 JSON 部分,这个抠的逻辑就是很多人第一次用会踩坑的地方。如果你的模型特别“话痨”,抠出来的 JSON 可能不完整,这时候就需要换更严格的转换器,或者在 prompt 里明确要求“只输出 JSON,不要任何其他文字”。

3. 核心细节解析与实操要点

3.1 定义 JavaBean 的几个关键讲究

定义用于接收结构化输出的 JavaBean,和定义普通的 DTO 不太一样,有几个点必须注意。

第一,字段类型尽量用包装类型,别用基本类型。比如用Integer而不是int,用Boolean而不是boolean。原因是模型有可能某个字段没填或者填了个空值,如果字段是基本类型,反序列化的时候会直接抛异常。用包装类型的话,最多是 null,你还能在业务层做兜底。

第二,字段名要语义清晰,别用缩写。因为字段名会进入 prompt 作为格式说明,userName比un更容易让模型理解该填什么。我试过用a、b这种字段名,模型填出来的内容完全是随机的,它根本不知道a代表什么。

第三,嵌套结构要控制层级。两层嵌套基本是极限了,三层以上模型很容易填错或者漏填。如果业务确实需要深层结构,建议拆成多次调用,每次处理一层。

第四,善用描述注解。Spring AI 支持在字段上加@JsonPropertyDescription之类的注解,这些描述会进入 schema,帮助模型理解字段含义。比如一个status字段,你加上“订单状态,只能是 PENDING、PAID、SHIPPED 三者之一”,模型填出来的值就会规范很多。

下面是一个我实际用过的例子,用来从一段自然语言里抽取联系人信息:

public class ContactInfo { @JsonPropertyDescription("联系人姓名,中文全名") private String name; @JsonPropertyDescription("手机号,11位数字") private String phone; @JsonPropertyDescription("所属公司名称,如果没有则留空") private String company; @JsonPropertyDescription("标签列表,如'客户'、'供应商'") private List<String> tags; }

这个类定义好之后,调用就一行:

ContactInfo info = chatClient.prompt() .user("帮我从这段话里提取联系人:张三是某某科技的采购负责人,电话13800138000,是我们的老客户") .call() .entity(ContactInfo.class);

拿到info之后直接info.getName()、info.getPhone(),不用再碰任何字符串。

3.2 prompt 里到底被塞了什么

很多人用entity()的时候是懵的,不知道框架到底往 prompt 里加了什么。我建议你在调试阶段把完整 prompt 打出来看一眼,方法是在配置里开启 debug 日志,或者自己手动拼一个转换器把 schema 输出出来。

默认情况下,框架会在你的原始 prompt 后面追加一段类似这样的内容:

Your response should be in JSON format. Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. Here is the JSON Schema instance your output must adhere to: { "type": "object", "properties": { "name": { "type": "string", "description": "联系人姓名,中文全名" }, "phone": { "type": "string", "description": "手机号,11位数字" }, ... } }

看到这段你就明白了:模型之所以能填对,是因为它拿到了完整的字段说明。这也解释了为什么字段描述写得越清楚,结果越准。如果你发现某个字段总是填错,第一件事就是去检查它的描述是不是太模糊。

提示:不同模型对这段格式指令的遵循程度差异很大。有些模型会严格只输出 JSON,有些则会在 JSON 外面包一层解释文字。如果你的模型属于后者,要么换模型,要么在 prompt 里额外强调“只输出 JSON”。

3.3 转换器的选择与自定义

Spring AI 默认的转换器对大多数场景够用了,但有两类情况你需要考虑自定义:

一类是模型输出格式特别不规范的。比如某些模型喜欢用 YAML 而不是 JSON,或者喜欢把结果放在代码块里还带语言标记。这时候你可以实现自己的StructuredOutputConverter,在convert()方法里写针对性的清洗逻辑。

另一类是你想用非 JSON 的格式。虽然 JSON 是主流,但有些场景下 CSV 或者自定义分隔符更省 token。Spring AI 允许你传入自定义转换器来实现这些格式。

自定义转换器的接口不复杂,核心就是两个方法:getFormat()返回格式说明(会拼进 prompt),convert(String)负责把文本转成对象。我写过一个简单的 CSV 转换器,用来处理批量数据抽取,token 消耗比 JSON 少了将近一半。

不过要提醒一句:自定义转换器是把双刃剑。JSON 之所以成为默认选择,是因为模型对它的“理解”最成熟,填错率最低。换成 CSV 之后,模型偶尔会漏掉分隔符或者多打逗号,你得在convert()里做更多容错。如果不是 token 特别紧张,我建议还是用默认的 JSON 转换器。

4. 完整实操过程与核心环节实现

4.1 环境准备与依赖配置

先把基础环境搭起来。我用的是 Spring Boot 3.2 加 Spring AI 1.0.0-M1 版本,JDK 17。依赖方面,除了 Spring AI 的核心 starter,还需要对应的模型 starter。这里我用一个通用的写法,具体模型名你按自己用的替换:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-core</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency>

配置文件里把模型相关的连接信息填好,这部分每个模型厂商不一样,我就不展开了。重点是确认ChatClient能正常注入,这是后面所有操作的基础。

@Configuration public class AiConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem("你是一个严谨的信息抽取助手,只输出要求的格式,不做任何额外解释。") .build(); } }

注意这里我加了一句defaultSystem,明确告诉模型“只输出要求的格式”。这句话看起来不起眼,但能显著降低模型“话痨”的概率,后面解析成功率会高很多。

4.2 从一段非结构化文本抽取订单信息

我拿一个真实场景来演示:从客服聊天记录里抽取订单相关信息。原始文本是这样的:

“客户说上周买的那个蓝色保温杯,订单号好像是 A123 开头的,具体记不清了,要退两个,说是盖子有问题。客户电话我发你,138 后面是 0013 8000,你查一下。”

这段文本信息很散,有订单号、商品、数量、退货原因、联系方式,但都不规整。如果手写正则,光是“订单号好像是 A123 开头”这种模糊表述就没法处理。用entity()就简单多了。

先定义目标对象:

public class OrderExtract { @JsonPropertyDescription("订单号,如果文本中没有明确给出则返回 null") private String orderNo; @JsonPropertyDescription("商品名称") private String productName; @JsonPropertyDescription("退货数量,整数") private Integer quantity; @JsonPropertyDescription("退货原因") private String reason; @JsonPropertyDescription("客户联系电话,11位数字") private String phone; }

然后调用:

String rawText = "客户说上周买的那个蓝色保温杯,订单号好像是 A123 开头的..."; OrderExtract result = chatClient.prompt() .user(u -> u.text("从下面的客服记录中抽取订单信息:\n{text}") .param("text", rawText)) .call() .entity(OrderExtract.class); System.out.println(result.getProductName()); // 蓝色保温杯 System.out.println(result.getQuantity()); // 2 System.out.println(result.getReason()); // 盖子有问题

实测下来,productName、quantity、reason、phone这几个字段基本都能准确抽出来。orderNo因为原文说的是“A123 开头”,模型有时候会返回A123,有时候会返回 null,这取决于它对“明确给出”的理解。这就是为什么我在描述里写了“如果文本中没有明确给出则返回 null”,给模型一个明确的兜底指令。

4.3 参数选择与效果验证

这里有个参数值得单独说:temperature。结构化输出场景下,我强烈建议把 temperature 调到 0 或者接近 0。原因是这个场景要的是“准确复现”,不是“创意发挥”。temperature 高了之后,模型可能会自作主张地改写字段值,比如把“蓝色保温杯”写成“蓝色水杯”,虽然意思差不多,但对下游业务来说就是错误数据。

设置方式是在调用时覆盖默认参数:

OrderExtract result = chatClient.prompt() .user(...) .options(ChatOptions.builder().temperature(0.0).build()) .call() .entity(OrderExtract.class);

验证效果的时候,我建议准备一组测试用例,覆盖几种典型情况:信息完整的、信息缺失的、有干扰信息的、格式特别乱的。每跑一次记录一下哪些字段对了、哪些错了。我自己的测试集里有 20 条客服记录,第一版 prompt 的准确率大概 75%,主要错在orderNo和quantity上。后来在字段描述里补充了更明确的说明,准确率提到了 92%。

4.4 批量处理时的性能考量

如果你要处理的是批量数据,比如一次抽取几百条记录,直接循环调用entity()会非常慢,因为每次调用都是一次完整的模型请求。我的做法是把多条记录合并到一个 prompt 里,让模型返回一个列表。

对应的 JavaBean 就变成一个List的包装:

public class OrderExtractList { private List<OrderExtract> items; }

调用时用ParameterizedTypeReference:

OrderExtractList result = chatClient.prompt() .user("从下面多条记录中分别抽取订单信息,返回一个数组:\n" + allText) .call() .entity(new ParameterizedTypeReference<OrderExtractList>() {});

这样一次请求能处理 10 到 20 条记录,吞吐量提升非常明显。但要注意,批量处理时单条记录的准确率会略微下降,因为模型要同时处理多个任务,注意力会被分散。我的经验是每批不超过 15 条,超过之后错误率上升得比较快。

5. 常见问题与排查技巧实录

5.1 解析失败的五种典型表现

用entity()最常遇到的问题就是解析失败,抛出的异常信息往往很模糊。我把踩过的坑整理成一张表,方便你对照排查:

异常表现根本原因解决方向
JsonParseException: Unexpected character模型返回的不是纯 JSON,带了额外文字在 system prompt 里强调只输出 JSON
字段值为 null模型没找到对应信息,或字段描述不清补充@JsonPropertyDescription
数字字段解析成字符串报错模型把数字加了引号字段用 String 接收,业务层再转
嵌套对象字段全空嵌套层级太深,模型没填拆成多次调用
返回内容被截断输出 token 超限调大 maxTokens 或减少字段数

其中最常见的是第一种。模型返回的内容长这样:

好的,根据您提供的信息,抽取结果如下: ```json {"productName":"蓝色保温杯","quantity":2}

希望对您有帮助。

默认转换器会尝试从这段文本里抠 JSON,大多数时候能抠对,但偶尔会因为代码块标记或者中文标点导致抠失败。我的处理方式是在 system prompt 里加一句“直接输出 JSON,不要使用 Markdown 代码块,不要添加任何解释文字”,这样模型返回的就是纯 JSON,解析成功率接近 100%。 ### 5.2 字段描述到底该怎么写 字段描述是影响准确率最大的因素,没有之一。我总结了三条经验: **第一,描述要具体到“格式”层面。** 不要只写“电话号码”,要写“11位数字的手机号,不带空格和横线”。不要只写“日期”,要写“格式为 yyyy-MM-dd 的日期”。模型对格式指令的遵循度很高,你写得越具体,它填得越规范。 **第二,给枚举字段列出所有可能值。** 比如一个 `priority` 字段,描述写成“优先级,只能是 HIGH、MEDIUM、LOW 三者之一”,模型就不会给你返回“高”“中等”这种中文值。 **第三,明确缺失时的行为。** 对于可能不存在的字段,描述里加上“如果没有找到则返回 null”或者“如果没有找到则返回空字符串”。不给这个指令的话,模型有时候会自己编一个值填进去,这比返回 null 更危险。 ### 5.3 模型选择对结构化输出的影响 不同模型在结构化输出上的表现差异非常大。我测试过几个主流模型,结论是:**参数规模越大的模型,对 schema 的遵循度越高**。小模型经常出现字段漏填、类型填错、格式不遵循的问题。 如果你的场景对准确率要求高,建议用能力较强的模型,哪怕成本高一点。如果只是做原型验证,小模型也能凑合用,但要做好容错的准备。另外,有些模型厂商专门提供了“结构化输出”模式,开启之后模型会强制按 schema 输出,准确率比普通模式高一大截。如果你的模型支持这个特性,一定要打开。 ### 5.4 一个容易被忽略的坑:中文标点 这个坑我踩了整整一个下午才找到原因。模型返回的 JSON 里,如果字段值包含中文,有时候会用中文引号 `“”` 而不是英文引号 `""`,导致 Jackson 解析失败。更隐蔽的是,中文逗号 `,` 和英文逗号 `,` 混用,也会让 JSON 结构出错。 解决办法有两个:一是在 system prompt 里明确要求“所有标点使用英文半角”,二是在自定义转换器里做一次标点替换。我两个都做了,双保险。替换逻辑很简单,就是把中文引号、中文逗号、中文冒号统一替换成英文的,再交给 Jackson 解析。 > 注意:标点替换要小心,如果字段值本身就需要保留中文标点(比如商品名称里带书名号),替换会破坏数据。所以更稳妥的做法还是从 prompt 层面约束模型,替换只作为兜底。 ### 5.5 调试技巧:把中间结果打出来 最后分享一个调试技巧。`entity()` 是个黑盒,出问题的时候你不知道模型到底返回了什么。我的做法是在开发阶段不用 `entity()`,而是先用 `content()` 拿到原始字符串,手动调转换器,把中间结果打出来看。 ```java String raw = chatClient.prompt().user(...).call().content(); System.out.println("RAW: " + raw); BeanOutputConverter<OrderExtract> converter = new BeanOutputConverter<>(OrderExtract.class); OrderExtract result = converter.convert(raw);

这样你能清楚看到模型返回的原始文本长什么样,是格式问题还是内容问题一目了然。等 prompt 调稳定了,再换回entity()一行搞定。这个习惯帮我省了大量猜测的时间。

6. 结构化输出在业务链路里的定位

把entity()用熟之后,我对它在整个业务链路里的定位有了更清晰的认识。它本质上是一个边界适配器,负责把模型这个“不确定的文本生成器”的输出,转换成业务系统能消费的“确定的对象”。这个转换做得好不好,直接决定了 AI 功能能不能稳定上线。

我的建议是,凡是需要把模型输出接入现有业务逻辑的场景,都应该用结构化输出,而不是自己解析字符串。自己解析的代码看起来简单,但维护成本极高,模型一换、prompt 一改,解析逻辑就得跟着动。用entity()的话,你只需要维护好 JavaBean 的定义,剩下的交给框架。

另外,结构化输出也让测试变得容易。你可以针对 JavaBean 写单元测试,mock 模型返回各种边界情况的文本,验证转换结果是否符合预期。这种测试在字符串解析时代几乎没法做,因为解析逻辑和具体文本格式绑得太死。

后续如果要做更复杂的场景,比如多轮对话中持续抽取结构化信息,可以考虑把entity()和对话记忆结合起来,每一轮都从模型回复里抽取对象,逐步补全一个完整的业务实体。这个方向我还在摸索,等有成熟经验了再单独写一篇。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询