1. 从字符串拼接到类型安全:为什么我彻底放弃了手撕 JSON
刚接触大模型应用开发那会儿,我写过一段现在想起来都脸红的代码。模型返回一段文本,我用indexOf找第一个{,用lastIndexOf找最后一个},截出来丢给 JSON 解析库,解析失败就正则清洗一遍再试。这套逻辑在 demo 阶段跑得挺欢,一上真实业务就原形毕露——模型偶尔在 JSON 前后加一句"好的,以下是结果:",偶尔把字段名写成中文,偶尔在字符串值里塞个没转义的换行符。每次线上报错,我都要对着日志一行行比对,那种感觉就像在雷区里跳踢踏舞。
后来我把这套"手撕 JSON"的活儿彻底交给了 Spring AI 的entity()方法,世界清净了。这篇就聊聊entity()到底解决了什么问题、它背后的转换链路是怎么走的、以及我在实际项目里踩过的那些坑。如果你正在用 Spring AI 做应用,或者准备把大模型输出接进现有的 Java 业务系统,这篇内容应该能帮你省下不少调试时间。核心关键词就一个:结构化输出——让模型的回答直接变成你能用的 JavaBean,而不是一堆需要你二次加工的字符串。
先说清楚entity()的定位。它是 Spring AI 里ChatClient提供的一个响应转换方法,作用是在发起对话请求时,直接告诉框架"我要的不是一段文本,而是一个特定类型的对象"。框架会在底层把目标类型的结构信息(字段名、类型、描述)组装进提示词,引导模型按这个结构输出,拿到响应后再自动反序列化成你指定的 Java 对象。整个过程对调用方来说就是一行.entity(SomeClass.class),但这一行背后藏着提示词工程、类型映射、容错解析三层机制。
我之所以强调"别再手撕 JSON",不只是因为省事。手写解析最大的问题是脆弱性不可控:你不知道模型这次会怎么"发挥",而业务代码需要的是确定性。entity()把这种不确定性收敛到了框架层,让业务代码重新回到类型安全的轨道上。下面我会从实际使用场景出发,把这条链路拆开讲透。
2. entity() 的转换链路:一行调用背后发生了什么
2.1 提示词注入:框架怎么"告诉"模型要输出什么结构
很多人以为entity()只是个后置的解析器,其实它从请求发出那一刻就开始工作了。当你调用.entity(WeatherInfo.class)时,Spring AI 会读取WeatherInfo这个类的结构,生成一段结构描述,拼接到你的原始提示词后面。这段描述大致包含:需要输出哪些字段、每个字段的类型、字段的含义说明。
举个具体的例子。假设你定义了这样一个类:
public class WeatherInfo { private String city; private String condition; private Double temperature; private Integer humidity; }框架会据此生成类似"请以 JSON 格式返回,包含 city(城市名,字符串)、condition(天气状况,字符串)、temperature(温度,数值)、humidity(湿度,整数)这些字段"的指令。模型收到这个增强后的提示词,输出的内容就会天然带上结构约束。
这里有个关键点容易被忽略:字段名和类型信息本身就是提示词的一部分。这意味着你的类设计得越清晰,模型输出越准。我见过有人把字段命名成f1、f2,然后抱怨模型老是填错——模型又不会读心术,你给它f1,它只能瞎猜。字段名用有语义的英文单词,必要时通过注解补充描述,这是提升准确率最廉价的手段。
2.2 类型映射:Java 类型和模型输出格式的对应关系
entity()支持的目标类型不是随便什么类都行,它需要能被序列化框架处理。实际项目里我用得最多的是这几种:
| Java 类型 | 典型用途 | 注意事项 |
|---|---|---|
| 简单 POJO | 单个结构化结果 | 需要无参构造和字段访问器 |
List<POJO> | 批量结果,如多条记录 | 泛型信息要能保留 |
Map<String, Object> | 结构不固定的场景 | 灵活性高但失去类型安全 |
| 嵌套 POJO | 层级化数据 | 嵌套层级别太深,模型容易漏字段 |
简单 POJO 是最常见的场景。比如从一段用户反馈里抽取"问题类型、紧急程度、涉及模块"三个字段,定义一个对应的类就行。List<POJO>适合"从这段文本里提取所有订单信息"这类需求,模型会返回一个数组。Map我一般只在探索阶段用,因为一旦用了 Map,就等于放弃了编译期检查,又回到了"运行时才知道字段对不对"的老路。
嵌套 POJO 要特别小心。我做过一个"解析简历"的功能,类里嵌套了教育经历列表、工作经历列表、技能列表三层。实测下来,模型对最外层字段的填充准确率很高,但嵌套到第二层、第三层就开始丢字段或者类型错乱。后来我把嵌套结构拍平,改成多次调用分别抽取,准确率反而上去了。结构越扁平,模型越不容易出错,这是我在多个项目里反复验证过的经验。
2.3 反序列化容错:模型输出不完美时框架怎么兜底
即便有提示词约束,模型也不是每次都乖乖输出标准 JSON。常见的"意外"有这么几种:前后带解释性文字、用了单引号、字段值里有多余空格、数字被写成了字符串。Spring AI 在反序列化阶段做了一定程度的容错处理,比如尝试从响应文本里提取 JSON 片段,而不是要求整段文本就是合法 JSON。
但这个容错是有边界的。我遇到过模型把temperature字段输出成"25度"这种带单位的字符串,而目标类型是Double,这时候反序列化就会失败。框架能帮你处理格式层面的小瑕疵,但处理不了语义层面的类型不匹配。所以我的做法是:能用 String 接的字段先用 String 接,业务层再做转换。比如温度字段定义成 String,拿到"25度"之后我自己用正则提取数字。这样虽然多了一步,但稳定性高得多。
提示:反序列化失败时,先别急着改代码,把模型的原始响应打印出来看看。十有八九是提示词里对字段的描述不够明确,导致模型理解偏了。
3. 把 entity() 用进真实业务:三个我实际落地的场景
3.1 场景一:非结构化文本抽取成业务对象
这是entity()最典型的用法。我做过一个客服工单自动分类的功能,输入是用户提交的一段自由文本,输出需要是结构化的工单对象:问题分类、紧急程度、涉及产品、建议处理人。以前的做法是写一堆正则和关键词匹配,维护起来痛苦不堪,新分类一加就要改规则。换成entity()之后,我只需要定义好TicketInfo类,把分类枚举值写进字段描述里,模型就能按这个结构输出。
这里有个实操细节值得说:枚举值的约束要写进提示词。比如紧急程度只有"高、中、低"三档,你光定义个 String 字段,模型可能给你输出"比较紧急""一般般"这种自由发挥的词。我的做法是在字段上加描述注解,或者在提示词里明确列出可选值。Spring AI 支持通过注解给字段补充说明,这些说明会被带进提示词,效果比在提示词里手写一遍更省心。
另一个细节是空值处理。有些字段模型可能判断不出来,比如"建议处理人"在信息不足时应该为空。如果不做约束,模型可能会硬编一个名字出来。我在字段描述里明确写了"如果无法判断,返回 null",实测下来模型会老老实实返回 null,而不是瞎编。
3.2 场景二:多轮对话中的意图识别与参数提取
聊天机器人场景里,用户说"帮我查一下明天北京的天气",你需要从这句话里提取出意图(查天气)和参数(时间=明天,地点=北京)。传统做法是训练一个意图分类模型加一个槽位填充模型,现在用entity()一次调用就能搞定。
我定义的类大概长这样:意图字段用枚举,参数用一个嵌套的 Map 或者专门的参数类。这里的关键是意图和参数要一起抽,而不是分两次调用。分两次调用不仅慢,而且两次调用之间可能产生不一致——第一次识别出意图是查天气,第二次提取参数时模型可能理解成查航班。一次调用让模型在同一个上下文里完成两件事,一致性有保障。
多轮对话还有个坑:历史消息会干扰结构化输出。如果对话历史里模型之前输出过别的结构,当前这轮可能会被带偏。我的处理方式是在系统提示词里明确当前轮次需要输出的结构,并且在拼接历史消息时做适当裁剪,只保留最近几轮的关键信息。
3.3 场景三:批量数据的结构化清洗
有一次我需要把一批格式混乱的地址数据标准化,输入是各种手写风格的地址字符串,输出需要是"省、市、区、详细地址"四个字段。数据量有几千条,我不可能一条条调模型,于是用了批量处理:每次请求让模型处理 10 条,返回List<AddressInfo>。
批量场景下entity()的表现和单条不太一样。单条时模型注意力集中,准确率高;批量时模型要同时处理多条,偶尔会把两条数据的字段串在一起。我的应对策略是:批量大小控制在 5 到 10 条之间,太多容易串,太少效率低。另外每条数据在输入时加上明确的序号标记,输出时要求模型带上同样的序号,这样即使顺序乱了我也能对应回去。
还有个经验是批量处理一定要做结果校验。我会检查返回的列表长度是否和输入一致,字段是否有明显异常值(比如省份字段出现了非省份的内容)。校验不通过的批次单独重跑,而不是整批丢弃。这套机制跑下来,整体准确率能稳定在可接受范围内。
4. 那些让我加班到深夜的坑:entity() 使用避坑清单
4.1 字段类型选错导致的静默失败
最坑的一种情况是反序列化"成功"了,但数据是错的。比如我把一个表示数量的字段定义成Integer,模型输出"3个",某些序列化配置下会把它解析成3,看起来没问题,但如果模型输出"三个",就会解析失败或者变成0。这种错误不会抛异常,只会让业务逻辑拿到错误数据,排查起来极其痛苦。
我的应对原则是:凡是模型可能带单位、带修饰词的字段,一律用 String 接。数量、金额、日期这些字段,先用 String 拿到原始值,再在业务层用明确的解析逻辑转换。多写几行转换代码,换来的是可预测的行为。另外我会在关键字段上加校验,比如数量字段解析后必须大于等于零,不满足就记录日志并走降级逻辑。
4.2 提示词与类定义不一致引发的"精神分裂"
有一次我改类定义时漏改了提示词里的字段描述,结果模型按旧描述输出,反序列化按新类定义解析,字段全对不上。这种不一致在代码 review 时很难发现,因为类定义和提示词在两个地方。后来我养成了一个习惯:字段描述尽量通过注解写在类定义旁边,让类定义成为唯一事实来源。Spring AI 支持这种注解方式,改类的时候顺手就把描述改了,不会漏。
如果确实需要在提示词里写结构说明,我会在代码里加一个注释,标明"此处结构与 XxxClass 对应,修改时需同步"。虽然土,但管用。
4.3 模型"自作主张"补充字段的应对
模型有时候会"贴心"地补充一些你没要求的字段。比如你只要 city 和 temperature,它给你返回 city、temperature、condition、windSpeed 一大堆。大多数情况下反序列化会忽略多余字段,不会报错,但如果你用的是严格模式,就会失败。
我的做法是在提示词里明确说"只返回以下字段,不要添加其他字段"。这句话看起来多余,但实测能显著减少模型的自作主张。另外在类定义上,如果框架支持忽略未知字段的配置,把它打开,作为第二道防线。
4.4 长文本输入时的截断与遗漏
输入文本很长时(比如一篇几千字的文章),模型可能会漏掉部分信息,导致某些字段为空。这不是entity()的问题,而是模型上下文处理的固有限制。我的应对是分段处理加结果合并:把长文本切成若干段,每段单独抽取,最后把结果合并去重。切分时尽量按语义边界切(比如按段落),而不是按固定字数硬切,这样每段的信息完整性更好。
5. 让结构化输出更稳的几个进阶技巧
5.1 用字段描述做"软约束"而不是硬编码校验
前面提到枚举值要写进描述,其实这个思路可以推广。任何对字段取值的约束,都可以通过描述传达给模型。比如"日期格式为 yyyy-MM-dd""金额单位为元,保留两位小数""如果信息不足返回 unknown"。这些描述相当于给模型一份填写说明,比事后用代码校验再重试要高效得多。
但要注意,描述是"软约束",模型不保证 100% 遵守。所以业务层的校验不能省,描述只是提高首次成功率,校验是兜底。两者配合,才能既高效又可靠。
5.2 失败重试时的提示词微调策略
反序列化失败后重试,如果原样重发,大概率还是失败。我的做法是在重试时把失败原因反馈给模型。比如第一次输出不是合法 JSON,重试时在提示词里加一句"上次输出不是合法 JSON,请确保输出纯 JSON 格式,不要包含任何解释性文字"。这种反馈式重试的成功率比盲目重试高很多。
重试次数我一般设两次,两次都失败就走降级逻辑(返回默认值或抛业务异常)。无限重试不仅浪费资源,还可能陷入死循环。
5.3 监控结构化输出的成功率
上线之后一定要监控entity()调用的成功率。我会在每次调用后记录:是否成功、失败原因、重试次数、耗时。这些指标能帮我发现提示词退化、模型行为变化等问题。比如某天成功率突然下降,可能是模型版本更新了,需要调整提示词。没有监控,这些问题只能等用户投诉才发现。
监控数据还能指导优化。如果发现某个字段的填充失败率特别高,说明这个字段的描述需要改进,或者模型对这个概念理解有困难,需要换种表达方式。
6. 关于结构化输出,我踩过坑之后最想说的几句话
entity()这个能力,用好了是真省心,用不好也会让你怀疑人生。我的核心体会是:把它当成一个需要调教的合作者,而不是一个即插即用的黑盒。类定义要清晰、字段描述要到位、校验逻辑要兜底、监控指标要跟上,这四件事做到位,结构化输出的稳定性就能满足生产要求。
另外别指望一次就设计出完美的类结构。我现在的做法是先跑通最小可用版本,观察模型的实际输出,再根据输出情况调整类定义和描述。迭代两三轮之后,结构才会稳定下来。这个过程急不得,但每轮迭代的收益都很明显。
最后说个心态问题。手撕 JSON 的时代,出错了你知道错在哪,因为代码是你写的。用了entity()之后,出错时你面对的是一个"黑盒",容易慌。这时候别急着换回手写解析,先把模型的原始响应打出来,对照类定义看哪里对不上,十有八九是描述不够明确。搞清楚这条链路之后,你会发现entity()带来的确定性,远比你失去的那点"掌控感"值钱。