☰
Java 后端 AI 转型实录 (三)Spring‑AI 结构化输出 entity () 实战,踩坑 Prompt 花括号注入风险
2026/10/19 16:24:24 网站建设 项目流程

一名 Java 后端的 AI 转型实录(三):Day 3,让模型 “说给程序听”

AI 转型系列・第 3 篇

Day 1 跑通模型调用链路,Day 2 实现流式输出与对话记忆。今天要解决一个非常现实的痛点:就算大模型回答再通顺,返回的是自由大段文本,Java 代码很难直接消费。结构化输出,让模型输出稳定转化为 Java 对象。

为什么需要结构化输出

前面两天接口返回的都是String字符串,这种返回是给人阅读的。但业务系统中,模型输出结果后续要交给程序处理:数据库入库、业务分支判断、作为下游组件输入。

如果手动让模型输出 JSON,再自己调用JSON.parse解析,会遇到大量现实问题:模型经常额外输出前置说明文字,例如 “好的,以下是您要的结果”,或者输出 markdown 代码块包裹 JSON,直接导致 JSON 解析异常。

Spring AI 提供了非常方便的能力:.call().entity(YourRecord.class)。本篇实战验证该能力,同时踩坑梳理花括号带来的三重边界问题。

三十行代码:entity () 的标准编码姿势

我们用贴近业务的示例场景:从商品描述文本中,抽取商品结构化信息。使用 Java Record 作为接收载体。

import java.util.List; /** * 商品抽取实体 */ public record ProductInfo( String name, String category, List<String> sellingPoints ) {}

编写 Controller 接口:

import org.springframework.ai.chat.client.ChatClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api") public class ExtractController { private final ChatClient chatClient; public ExtractController(ChatClient chatClient) { this.chatClient = chatClient; } @GetMapping("/extract") public ProductInfo extract(@RequestParam String q) { return chatClient.prompt() .user(u -> u.text(""" 从下面的商品描述中抽取结构化信息: - name:商品名称 - category:商品类目 - sellingPoints:卖点列表 商品描述: {desc} """).param("desc", q)) .call() .entity(ProductInfo.class); } }

curl 测试命令:

curl -G "http://localhost:8080/api/extract" \ --data-urlencode "q=这款不锈钢保温杯500ml,双层真空,12小时保温,适合通勤户外"

正常返回结果示例:

{"name":"不锈钢保温杯","category":"水杯/保温容器","sellingPoints":["双层真空","12小时保温","适合通勤户外"]}

💡Record + entity()是绝佳组合:Record 的字段名直接映射 JSON 的 key,不可变对象,几乎没有样板代码,类定义即数据契约。

当需要一次性抽取多条商品(顶层是集合),要使用ParameterizedTypeReference:

// 一次性抽取多条商品,返回 List<ProductInfo> .call() .entity(new ParameterizedTypeReference<List<ProductInfo>>() {});

⚠️ 重要位置细节:entity()调用在.call()之后,和.content()属于同级调用。
旧的 Milestone 版本教程会把 entity 写在 prompt 链中间,GA 正式版 API 已经发生调整,不要照搬老文档写法。

entity () 底层真相:它不是魔法

很多同学会误以为 entity 能力是大模型原生功能,其实底层逻辑:

根据传入的 Java 实体类自动生成 JSON Schema,同时追加强制格式约束提示词(告诉模型只返回 JSON,不要解释,不要 markdown 代码块);拿到模型返回内容后做清洗,再反序列化为 Java 对象。

可以打印框架自动生成的格式约束提示,直观看到框架替我们做的工作:

BeanOutputConverter<ProductInfo> converter = new BeanOutputConverter<>(ProductInfo.class); // 打印自动生成的schema+格式指令 System.out.println(converter.getFormat());

生产环境重要认知

entity()只能提升模型输出合规 JSON 的概率,不是 100% 绝对保证。大模型依旧有小概率输出不符合 schema 的内容。

✅ 生产环境三个必备习惯:

  1. 返回对象的业务关键字段,手动做判空校验;
  2. 捕获结构化解析异常,做好业务降级,不要直接抛出 500;
  3. 稳定性敏感业务场景,增加一次重试策略。

本次简单测试输入无关文本 “今天天气不错”,模型返回全部字段为空的合法对象没有崩溃,但不能因此放松防护意识:结构化输出降低出错概率,不等于零风险。

花括号三重坑:不可信内容的边界问题

Prompt 模板使用示例:

.user(u -> u.text("分析这段商品描述:\n{desc}") .param("desc", q))

{desc}属于模板占位符,使用param()填充变量。坑来了:用户输入 / 检索文档本身就包含花括号,例如文本只售{2599}元,会发生什么?

坑 1:Prompt 模板层花括号问题

  • 使用.param()传入变量值:变量内容里面包含{}不会被模板引擎解析,安全隔离。
  • 禁止直接字符串拼接把原始内容塞进text()。

整理三种常见模板写法行为对比:

表格

场景写法说明运行结果
占位符多于参数模板定义 2 个占位符,param 只传入 1 个直接运行时报错
参数多于占位符param 传入参数,但模板没有对应占位符静默直接忽略,不会报错(隐蔽坑)
字符串拼接原始输入u.text("分析:" + q),用户输入直接拼模板字符串q 中{xxx}会被识别为未定义占位符,直接抛出异常

🚨 RAG 开发高危提醒:检索返回的文档内容属于完全不受控的外部文本,文档里面很可能包含 JSON、代码片段,大量花括号。
铁律:用户输入、检索文档内容,一律通过 param 传入,禁止直接拼接进模板字符串!

坑 2:URL 请求层面花括号问题

同样的文本只售{2599}元,如果直接拼接到 URL 请求参数。Tomcat 会直接拒绝请求:

Invalid character found in the request target ... The valid characters are defined in RFC 7230 and RFC 3986

{}不属于 RFC3986 规定的 URL 允许裸传字符,没有做 URL 编码直接放在请求路径 / 参数中,Web 容器解析直接失败。
解决:使用--data-urlencode、前端自动 urlencode、Spring@RequestParam自动编码。

引申认知:后端老经验在 AI 时代的延伸

表格

不可信数据流向处理手段
不可信内容进入 HTTP URL必须做 URL 百分号编码(RFC3986)
不可信内容进入 Prompt 模板使用 param 参数化传参,禁止字符串直接拼接
不可信内容进入 SQL使用预编译参数#{},防止 SQL 注入
不可信内容渲染 HTML 页面HTML 转义,防御 XSS

核心思想不变:所有来自外部的不可信数据,进入协议 / 模板层,都要做隔离处理。
AI 应用开发,Prompt 成为新的注入攻击面,这一点做 RAG 的时候尤其要时刻牢记。

entity () 的 3 种重载,怎么选

Spring AI1.1.0 中entity()提供 3 种重载,区分的核心就是:类型信息能不能用普通Class<?>来表达。

// ① 普通Java类 / Record,单个对象 .entity(ProductInfo.class) // ② 顶层为泛型集合:List<T>、Map<K,V> .entity(new ParameterizedTypeReference<List<ProductInfo>>() {}) // ③ 完全自定义:自己实现转换器,高级场景才使用 .entity(StructuredOutputConverter<T>)

Java 泛型擦除知识点:List<ProductInfo>.class语法非法,运行时 Class 丢失泛型参数。ParameterizedTypeReference通过匿名内部子类,把泛型类型信息保存在字节码,运行时反射读取。和 SpringMVC RestTemplate 的用法完全同源。

选型对照表

表格

需要得到的返回类型使用方案
单个对象(Record / POJO,内部字段可以包含 List)①xxx.class
返回顶层直接就是集合 / Map:List<ProductInfo>、Map<String,Object>②ParameterizedTypeReference

❗容易混淆点:实体内部嵌套 List 集合不属于顶层泛型。
例如ProductInfo内部有List<String> sellingPoints,直接用ProductInfo.class即可。只有返回值最外层本身是 List/Map,才需要用ParameterizedTypeReference。

⚠️ParameterizedTypeReference后面的大括号{}千万不能省略,匿名子类靠这个捕获泛型类型,去掉花括号会丢失类型信息导致解析失败。

Day3 收获清单

  • ✅entity()单对象 + 顶层集合两种实战写法,使用 Record 接收结构化输出;
  • ✅ Record 作为输出载体:类定义即数据契约,消除样板代码;
  • ✅ Prompt 模板最佳实践:text()+param()参数化传参,隔离外部不可信文本;
  • ✅ 吃透花括号两大坑:Prompt 模板占位符、URL 非法字符;确立 Prompt 安全铁律;
  • ✅ 建立新认知:Prompt 是 AI 应用中新的注入攻击面;
  • ✅ 明确结构化输出能力边界:仅提升合规概率,非绝对保证;生产环境三大防护手段(判空、异常降级、重试);
  • ✅ 掌握entity()三个重载的选型逻辑,理解ParameterizedTypeReference对抗泛型擦除的原理。

ChatClient 层面三件核心武器全部集齐:
说给模型听(人设 / Prompt 模板)、模型记住对话(会话记忆)、模型说给程序听(结构化输出)。

写在最后

本篇业务代码量并不大,但花括号衍生出来的边界与安全思考,把后端传统安全思维延伸到 Prompt 工程,是前两篇没有涉及的新维度。

继续坚持个人学习纪律:每天一个闭环,宁可少学,不可断链—— Day 3,闭环。

下一篇正式进入 Embedding 向量与语义检索,RAG 的底层地基。从单纯调用大模型,进入到给大模型喂私有知识库的阶段。

如果你也在转型 Java 后端做 AI 开发,欢迎评论区交流:你业务场景下希望模型输出哪些复杂的结构化对象?


本文是「Java 后端 AI 转型实录」系列第 3 篇。项目后续会开源在 GitHub,每周更新。

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

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

立即咨询