二哥项目资料

求职派:如何让SpringAI返回结构化数据?

正文 26,023 字 #05 原文
本文目录 · 27 节

星球:Java进阶之路&二哥编程星球 | 专栏:求职派 OpenClaw 架构 | 作者:沉默王二 | 发布:2026-07-09

求职派:如何让SpringAI返回结构化数据?

求职派:如何让SpringAI返回结构化数据?

大模型最让我们头疼的问题之一,就是它返回的不是结构化文本,而业务代码需要的是有类型的数据结构。技术派: https://paicoding.com/jobclaw-structured-return

否则,我们很难将 LLM 返回的结果和 Java 的对象一一对应起来。

本篇内容第一版的作者:星球嘉宾灰灰。二哥做了二期的优化和迭代。

图片.png

举例来说,让大模型“返回五部电影”,它可能会给出一段 Markdown 列表、一段带编号的纯文本。

每次格式都不一样,上层业务代码根本没法正确解析。

Spring AI 2.0 的结构化返回机制就是来解决这个问题的,一行 .entity() 调用,大模型返回的无结构化文本就能变成规范的 Java 对象。

求职派的意图识别、用户画像提取、岗位数据采集,全都依赖这个能力。

这篇文章从 Spring AI 的结构化返回 API 讲起,拆解求职派源码里的三个生产级用例,最后讲清楚这套机制的边界在哪,什么场景靠谱,什么场景必须加兜底。

01、Spring AI 的结构化返回

Spring AI 提供了两条路径实现结构化返回,区别在于“谁来拼接提示词”。

第一种,ChatClient.entity() 自动挡

Java

ActorsFilms films = ChatClient.create(chatModel)
    .prompt("帮我返回五个周星驰导演的电影名")
    .call()
    .entity(ActorsFilms.class);

一行代码搞定。

Spring AI 在底层做了三件事。

  • 第一步,根据目标类的字段生成 JSON Schema;

  • 第二步,把 Schema 注入到提示词的上下文中(通过 Advisor 机制在请求发出前拦截并追加格式指令);

  • 第三步,拿到大模型返回的 JSON 后用 Jackson 反序列化成 Java 对象。

我们发送的提示词里是不包含任何格式约束的,但 debug 查看真正发给大模型的请求,会发现 context 参数里已经注入了一段完整的 JSON Schema,这就是 Advisor 在背后干的事情。

图片.png

上图是 debug 截图,可以看到提示词的 context 部分被自动追加了返回格式要求。

我们写的提示词只有“帮我返回五个周星驰导演的电影名”,格式约束完全由框架注入。

图片.png

第二种,BeanOutputConverter 手动挡

Java

var converter = new BeanOutputConverter<>(ActorsFilms.class);
String format = converter.getFormat();
Prompt prompt = new PromptTemplate("帮我返回五个{actor}导演的电影名\n{format}")
    .create(Map.of("actor", "周星驰", "format", format));

手动挡把格式指令(format)暴露出来,我们自己决定在提示词的什么位置插入。

getFormat() 返回的是一段 JSON Schema 描述文本,告诉大模型“按这个格式返回”。拿到响应后再手动调用 converter.convert(text) 完成反序列化。

两种方式是一个原理,在提示词里塞入格式约束,让大模型按约定的 JSON 结构返回。

自动挡省事,手动挡灵活。

当需要在多轮对话中精确控制格式指令的插入位置时,手动挡就派上用场了。

除了映射到 Java 对象的 BeanOutputConverter,Spring AI 还提供了 MapOutputConverter 和 ListOutputConverter,分别用于返回 Map 和 List 结构。

三者的继承关系如下。

图片.png

实际开发中, BeanOutputConverter 覆盖了绝大多数场景。

求职派全部使用它来做结构化返回。原因很简单,业务数据结构一旦确定,用 record 定义是最清晰的选择。

02、record + @JsonPropertyDescription

自动挡的 .entity() 之所以能工作,关键在于 Spring AI 从 Java 类型自动生成了 JSON Schema。

@JsonPropertyDescription 注解决定了 Schema 里每个字段的描述文本,这段描述文本就是大模型理解“这个字段该填什么”的唯一线索。

求职派意图识别的返回结构定义( core/.../IntentClassificationRes.java)是一个典型示例。

Java

public record IntentClassificationRes(
    @JsonPropertyDescription("识别到的意图类型")
    PresetAgentIntro intentType,
    @JsonPropertyDescription("置信度 0.0-1.0")
    double confidence,
    @JsonPropertyDescription("识别理由/推理过程")
    String reasoning
) {}

三个字段,每个都有 @JsonPropertyDescription。

Spring AI 读到这些注解后,生成的 JSON Schema 会告诉大模型“intentType 是意图类型,confidence 是 0 到 1 的数字,reasoning 是推理过程”。

大模型按照这个约束返回 JSON,Spring AI 再用 Jackson 反序列化成 record 实例。

还有一个容易被忽略的注解 @JsonIgnore。同样在这个 record 里,第四个字段 context 标注了 @JsonIgnore,意味着它不会出现在生成的 JSON Schema 中。 context 是程序内部传递额外信息用的,跟大模型没关系。这个设计让同一个 record 既能承接大模型的返回,又能携带业务上下文,不用拆成两个类。

图片.png

这里有一个设计细节。

@JsonPropertyDescription 里的描述文本不是写给人看的文档,是写给大模型看的指令。求职派的岗位采集数据结构( app/.../GatherOcDraftBo.java)就把这一点用到了极致,每个字段的描述都带了具体的枚举示例。

Java

@JsonPropertyDescription("公司类型,如 国企,事业单位,学校,银行,私企等")
String companyType,
@JsonPropertyDescription("招聘类型,如 秋招,校招,实习,社招,秋招提前批等")
String recruitmentType,

“如国企,事业单位,学校”,不是随便写的。

大模型看到这些示例,返回的值就会落在这几个类别里,而不是自由发挥出“央企控股子公司”、“准事业编”这种不在预期范围内的表述。用示例锚定输出范围,比在提示词里长篇大论地描述规则有效得多。

这条 record 一共有 17 个字段(公司名称、类型、行业、地点、招聘类型、招聘对象、岗位、投递进度、更新时间、截止时间、相关链接、招聘公告、内推码、备注、薪资范围、学历要求、工作经验),全部通过 @JsonPropertyDescription 标注了含示例的描述。

大模型一次调用就能从非结构化的招聘文本中提取出这 17 个字段的结构化数据。

图片.png

03、求职派的三个生产用例

理解了原理,我们来看看实际怎么用。

求职派在三个核心业务场景都使用了结构化返回,每个场景的调用方式略有不同。

补充一个前提,求职派没有在业务代码中直接使用 ChatClient,而是封装了一层调用接口。

核心接口 LlmCaller 定义了四个方法,其中结构化返回对应的方法签名是 T call(UserConversationInfo user, Prompt prompt, Class clz)。

图片.png

它的默认实现 SimpleLlmCaller 内部做的事情就是拿到用户对应的模型配置、构建 ChatClient、调用 .entity()。

这层封装把模型选择、系统提示词注入、ReAct 工具链配置等通用逻辑统一封装,业务代码只管传类型参数就行。

意图识别

用户在微信/钉钉/飞书发一条消息过来,消息路由器需要判断这条消息的意图,是要投简历、查岗位还是闲聊。

意图分类器把判断任务交给大模型,要求返回意图类型、置信度和推理过程三个字段。

图片.png

调用代码在 core/.../LLMIntentClassifier.java,只有一行核心逻辑。

Java

var response = simpleLlmCaller.call(
    userConversationInfo, new Prompt(prompt),
    IntentClassificationRes.class);

这里的 simpleLlmCaller.call() 内部调用的就是 ChatClient.entity()。

第三个参数 IntentClassificationRes.class 告诉 Spring AI 用这个 record 的结构生成格式约束。大模型返回后,置信度低于 0.7 的结果会被标记为“不可信”,由关键词匹配兜底。

这个设计避免了手动解析 JSON 字符串的脆弱性。如果大模型返回的 JSON 缺了字段或者格式不对, entity() 调用会直接抛异常,被外层 catch 住后降级为 unknown 结果,不会出现“解析到了但数据不对”的静默错误。

用户画像提取

用户在求职派提交个人信息时,输入的是一段自由文本,比如“我是2026年毕业的计算机硕士,想去武汉的国企做Java开发”。

业务需要从中提取出公司类型、行业、地点、招聘类型、岗位等结构化字段。

图片.png

调用代码在 app/.../UserInterestService.java。

Java

UserInterestBo userInterestBo = chatClient.prompt()
    .user("我现在是一个准备找工作的人," +
        "下面是我的个人信息,我希望从中提取出用于筛选招聘岗位的关键信息: \n" + text)
    .call().entity(UserInterestBo.class);

这是最标准的 .entity() 用法,一段提示词加一个目标类型。

大模型从自由文本中识别出“2026年毕业生”、“武汉”、“国企”、“Java开发”这些关键信息,分别填入对应的字段。提取结果经过数据清洗后存入数据库,用于后续的岗位推荐匹配。

和意图识别不同的是,这里没有走 LlmCaller 封装,而是直接使用了 ChatClient。

原因是用户画像提取属于后台管理功能,不走 IM 消息通道,不需要 LlmCaller 提供的用户模型选择和工具链注入能力。同样的 .entity() 调用,在不同的业务上下文中选择不同的调用层级,这也是工程化的考量。

岗位数据采集

岗位数据采集是最复杂的场景。

采集 Agent 需要从招聘网页或图片中提取出完整的岗位信息列表,每条记录有 17 个字段。

图片.png

调用代码在 app/.../GatherAiAgent.java。

Java

ArrayList list = this.ocAiModelContext
    .chatClient(ModelSelectReq.of(model, GatherModelTypeEnum.CHAT_MODEL))
    .prompt(text).tools(new CrawlerTools())
    .call()
    .entity(new ParameterizedTypeReference>() {});

注意这里用了 ParameterizedTypeReference 而不是直接传 Class。

原因是目标类型 ArrayList 带泛型参数,Java 的类型擦除会在运行时丢掉泛型信息,必须用 ParameterizedTypeReference 来保留完整的类型参数。

另一个细节是 .tools(new CrawlerTools()) 注册了爬虫工具。

大模型在采集过程中,如果发现招聘文本里有链接,可以自主调用爬虫工具去抓取链接内容,把抓到的信息也纳入结构化提取的范围。结构化返回和工具调用在同一次对话中协同工作。

04、大模型截断了怎么办?

结构化返回在单次调用场景下表现稳定,但遇到大批量数据采集时会遇到一个难题,也就是大模型的输出长度有上限。

当需要返回几十条岗位记录时,JSON 还没写完就被截断了,反序列化必然失败。

求职派的采集 Agent 为这个问题设计了一套多轮对话兜底机制( app/.../GatherAiAgent.java 的 autoContinueChat 方法)。

核心思路是用 ChatMemory 维护多轮对话上下文,如果大模型返回的 JSON 不完整,就自动追问“之前返回的结果不完整,继续返回剩余的内容”。

图片.png

关键代码片段如下。

Java

ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .maxMessages(MAX_CHAT_CNT).build();
// 第一轮:发送原始数据 + 格式要求
// 后续轮次:
msg = new UserMessage("你之前返回的结果不完整,继续返回剩余的内容");

每一轮返回的文本片段,通过 GatherResFormat.extract() 提取出已经完整的 JSON 元素,追加到结果列表中。

循环退出的条件有三个。

  • 第一,返回文本以 ```````````结尾,表示

  • 第二,对话轮次达到上限,默认 10 轮。

  • 第三,大模型重复返回了完整数据,说明它误解了指令在从头生成。

这套机制放弃了 .entity() 的自动反序列化,改为手动拼接 JSON 片段再逐条解析。

听起来笨拙,但在处理“100 条岗位记录”这种数据量时,是目前最稳妥的方案。

Spring AI 的 .entity() 没有内置截断恢复能力,遇到不完整的 JSON 就直接报错。

还有一个防御性设计。

即使多轮对话中某一轮抛了异常,catch 块里不应该丢弃前面已经成功解析的数据。

代码应该直接 break 跳出循环,把 itemList 里已有的记录返回给上层。在大模型调用这种不稳定的场景下,“尽可能多地保留有效结果”比“要么全成功要么全失败”更合理。

格式指令的注入方式也和单次调用不同。

图片.png

第一轮对话通过 PromptTemplate 把 gatherResConverter.getFormat() 拼到提示词末尾,后续轮次不再重复注入格式要求。

ChatMemory 已经保留了第一轮的上下文,大模型记得该用什么格式,不需要每轮都重复。这样就避免了多轮对话中格式指令重复堆积、浪费上下文窗口的问题。

05、结构化返回的选型

从求职派的实践来看,结构化返回的选型可以按场景分成三档。

①、 简单场景,字段少于 5 个、单条记录,直接用 .entity(),没有额外代码。意图识别和用户画像提取属于这一档。

②、 中等场景,字段多但数据量可控,用 .entity() 配合 ParameterizedTypeReference 处理泛型。岗位数据的单次采集属于这一档。record 定义时注意给每个字段写好 @JsonPropertyDescription,带上枚举示例。

③、 复杂场景,返回数据量大、可能被截断,用 BeanOutputConverter 手动挡 + ChatMemory 多轮对话。手动控制格式指令的注入位置,手动解析返回的 JSON 片段。代码量多,但可靠性高。

图片.png

三条实操建议。

  • @JsonPropertyDescription 的描述文本要写示例值,不要只写“xxx类型”。“如 国企,事业单位,学校”比“公司的组织类型”对大模型的约束力强 10 倍

  • record 是结构化返回的最佳搭档。不可变、字段清晰、天然适配 JSON 反序列化。求职派所有结构化返回的目标类型都是 record

  • 不要信任大模型的返回是 100% 稳定的。即使用了 .entity(),外层也要加 try-catch 做降级处理。求职派的意图识别在解析失败时会降级为 unknown,采集 Agent 在异常时保留已解析的部分结果

ending

结构化返回是 Spring AI 里最实用的特性之一。

一行 .entity() 解决了 80% 的场景,剩下 20% 靠手动挡 + 多轮对话兜底。和大多数工程问题一样,简单的部分框架帮我们搞定,复杂的部分还是得自己写逻辑。

图片.png

求职派教程持续更新中,我们下期见。

大模型最让我们头疼的问题之一,就是它返回的不是结构化文本,而业务代码需要的是有类型的数据结构。技术派: https://paicoding.com/jobclaw-structured-return

否则,我们很难将 LLM 返回的结果和 Java 的对象一一对应起来。

本篇内容第一版的作者:星球嘉宾灰灰。二哥做了二期的优化和迭代。

图片.png

举例来说,让大模型“返回五部电影”,它可能会给出一段 Markdown 列表、一段带编号的纯文本。

每次格式都不一样,上层业务代码根本没法正确解析。

Spring AI 2.0 的结构化返回机制就是来解决这个问题的,一行 .entity() 调用,大模型返回的无结构化文本就能变成规范的 Java 对象。

求职派的意图识别、用户画像提取、岗位数据采集,全都依赖这个能力。

这篇文章从 Spring AI 的结构化返回 API 讲起,拆解求职派源码里的三个生产级用例,最后讲清楚这套机制的边界在哪,什么场景靠谱,什么场景必须加兜底。

01、Spring AI 的结构化返回

Spring AI 提供了两条路径实现结构化返回,区别在于“谁来拼接提示词”。

第一种,ChatClient.entity() 自动挡

Java

ActorsFilms films = ChatClient.create(chatModel)
    .prompt("帮我返回五个周星驰导演的电影名")
    .call()
    .entity(ActorsFilms.class);

一行代码搞定。

Spring AI 在底层做了三件事。

  • 第一步,根据目标类的字段生成 JSON Schema;

  • 第二步,把 Schema 注入到提示词的上下文中(通过 Advisor 机制在请求发出前拦截并追加格式指令);

  • 第三步,拿到大模型返回的 JSON 后用 Jackson 反序列化成 Java 对象。

我们发送的提示词里是不包含任何格式约束的,但 debug 查看真正发给大模型的请求,会发现 context 参数里已经注入了一段完整的 JSON Schema,这就是 Advisor 在背后干的事情。

图片.png

上图是 debug 截图,可以看到提示词的 context 部分被自动追加了返回格式要求。

我们写的提示词只有“帮我返回五个周星驰导演的电影名”,格式约束完全由框架注入。

图片.png

第二种,BeanOutputConverter 手动挡

Java

var converter = new BeanOutputConverter<>(ActorsFilms.class);
String format = converter.getFormat();
Prompt prompt = new PromptTemplate("帮我返回五个{actor}导演的电影名\n{format}")
    .create(Map.of("actor", "周星驰", "format", format));

手动挡把格式指令(format)暴露出来,我们自己决定在提示词的什么位置插入。

getFormat() 返回的是一段 JSON Schema 描述文本,告诉大模型“按这个格式返回”。拿到响应后再手动调用 converter.convert(text) 完成反序列化。

两种方式是一个原理,在提示词里塞入格式约束,让大模型按约定的 JSON 结构返回。

自动挡省事,手动挡灵活。

当需要在多轮对话中精确控制格式指令的插入位置时,手动挡就派上用场了。

除了映射到 Java 对象的 BeanOutputConverter,Spring AI 还提供了 MapOutputConverter 和 ListOutputConverter,分别用于返回 Map 和 List 结构。

三者的继承关系如下。

图片.png

实际开发中, BeanOutputConverter 覆盖了绝大多数场景。

求职派全部使用它来做结构化返回。原因很简单,业务数据结构一旦确定,用 record 定义是最清晰的选择。

02、record + @JsonPropertyDescription

自动挡的 .entity() 之所以能工作,关键在于 Spring AI 从 Java 类型自动生成了 JSON Schema。

@JsonPropertyDescription 注解决定了 Schema 里每个字段的描述文本,这段描述文本就是大模型理解“这个字段该填什么”的唯一线索。

求职派意图识别的返回结构定义( core/.../IntentClassificationRes.java)是一个典型示例。

Java

public record IntentClassificationRes(
    @JsonPropertyDescription("识别到的意图类型")
    PresetAgentIntro intentType,
    @JsonPropertyDescription("置信度 0.0-1.0")
    double confidence,
    @JsonPropertyDescription("识别理由/推理过程")
    String reasoning
) {}

三个字段,每个都有 @JsonPropertyDescription。

Spring AI 读到这些注解后,生成的 JSON Schema 会告诉大模型“intentType 是意图类型,confidence 是 0 到 1 的数字,reasoning 是推理过程”。

大模型按照这个约束返回 JSON,Spring AI 再用 Jackson 反序列化成 record 实例。

还有一个容易被忽略的注解 @JsonIgnore。同样在这个 record 里,第四个字段 context 标注了 @JsonIgnore,意味着它不会出现在生成的 JSON Schema 中。 context 是程序内部传递额外信息用的,跟大模型没关系。这个设计让同一个 record 既能承接大模型的返回,又能携带业务上下文,不用拆成两个类。

图片.png

这里有一个设计细节。

@JsonPropertyDescription 里的描述文本不是写给人看的文档,是写给大模型看的指令。求职派的岗位采集数据结构( app/.../GatherOcDraftBo.java)就把这一点用到了极致,每个字段的描述都带了具体的枚举示例。

Java

@JsonPropertyDescription("公司类型,如 国企,事业单位,学校,银行,私企等")
String companyType,
@JsonPropertyDescription("招聘类型,如 秋招,校招,实习,社招,秋招提前批等")
String recruitmentType,

“如国企,事业单位,学校”,不是随便写的。

大模型看到这些示例,返回的值就会落在这几个类别里,而不是自由发挥出“央企控股子公司”、“准事业编”这种不在预期范围内的表述。用示例锚定输出范围,比在提示词里长篇大论地描述规则有效得多。

这条 record 一共有 17 个字段(公司名称、类型、行业、地点、招聘类型、招聘对象、岗位、投递进度、更新时间、截止时间、相关链接、招聘公告、内推码、备注、薪资范围、学历要求、工作经验),全部通过 @JsonPropertyDescription 标注了含示例的描述。

大模型一次调用就能从非结构化的招聘文本中提取出这 17 个字段的结构化数据。

图片.png

03、求职派的三个生产用例

理解了原理,我们来看看实际怎么用。

求职派在三个核心业务场景都使用了结构化返回,每个场景的调用方式略有不同。

补充一个前提,求职派没有在业务代码中直接使用 ChatClient,而是封装了一层调用接口。

核心接口 LlmCaller 定义了四个方法,其中结构化返回对应的方法签名是 T call(UserConversationInfo user, Prompt prompt, Class clz)。

图片.png

它的默认实现 SimpleLlmCaller 内部做的事情就是拿到用户对应的模型配置、构建 ChatClient、调用 .entity()。

这层封装把模型选择、系统提示词注入、ReAct 工具链配置等通用逻辑统一封装,业务代码只管传类型参数就行。

意图识别

用户在微信/钉钉/飞书发一条消息过来,消息路由器需要判断这条消息的意图,是要投简历、查岗位还是闲聊。

意图分类器把判断任务交给大模型,要求返回意图类型、置信度和推理过程三个字段。

图片.png

调用代码在 core/.../LLMIntentClassifier.java,只有一行核心逻辑。

Java

var response = simpleLlmCaller.call(
    userConversationInfo, new Prompt(prompt),
    IntentClassificationRes.class);

这里的 simpleLlmCaller.call() 内部调用的就是 ChatClient.entity()。

第三个参数 IntentClassificationRes.class 告诉 Spring AI 用这个 record 的结构生成格式约束。大模型返回后,置信度低于 0.7 的结果会被标记为“不可信”,由关键词匹配兜底。

这个设计避免了手动解析 JSON 字符串的脆弱性。如果大模型返回的 JSON 缺了字段或者格式不对, entity() 调用会直接抛异常,被外层 catch 住后降级为 unknown 结果,不会出现“解析到了但数据不对”的静默错误。

用户画像提取

用户在求职派提交个人信息时,输入的是一段自由文本,比如“我是2026年毕业的计算机硕士,想去武汉的国企做Java开发”。

业务需要从中提取出公司类型、行业、地点、招聘类型、岗位等结构化字段。

图片.png

调用代码在 app/.../UserInterestService.java。

Java

UserInterestBo userInterestBo = chatClient.prompt()
    .user("我现在是一个准备找工作的人," +
        "下面是我的个人信息,我希望从中提取出用于筛选招聘岗位的关键信息: \n" + text)
    .call().entity(UserInterestBo.class);

这是最标准的 .entity() 用法,一段提示词加一个目标类型。

大模型从自由文本中识别出“2026年毕业生”、“武汉”、“国企”、“Java开发”这些关键信息,分别填入对应的字段。提取结果经过数据清洗后存入数据库,用于后续的岗位推荐匹配。

和意图识别不同的是,这里没有走 LlmCaller 封装,而是直接使用了 ChatClient。

原因是用户画像提取属于后台管理功能,不走 IM 消息通道,不需要 LlmCaller 提供的用户模型选择和工具链注入能力。同样的 .entity() 调用,在不同的业务上下文中选择不同的调用层级,这也是工程化的考量。

岗位数据采集

岗位数据采集是最复杂的场景。

采集 Agent 需要从招聘网页或图片中提取出完整的岗位信息列表,每条记录有 17 个字段。

图片.png

调用代码在 app/.../GatherAiAgent.java。

Java

ArrayList list = this.ocAiModelContext
    .chatClient(ModelSelectReq.of(model, GatherModelTypeEnum.CHAT_MODEL))
    .prompt(text).tools(new CrawlerTools())
    .call()
    .entity(new ParameterizedTypeReference>() {});

注意这里用了 ParameterizedTypeReference 而不是直接传 Class。

原因是目标类型 ArrayList 带泛型参数,Java 的类型擦除会在运行时丢掉泛型信息,必须用 ParameterizedTypeReference 来保留完整的类型参数。

另一个细节是 .tools(new CrawlerTools()) 注册了爬虫工具。

大模型在采集过程中,如果发现招聘文本里有链接,可以自主调用爬虫工具去抓取链接内容,把抓到的信息也纳入结构化提取的范围。结构化返回和工具调用在同一次对话中协同工作。

04、大模型截断了怎么办?

结构化返回在单次调用场景下表现稳定,但遇到大批量数据采集时会遇到一个难题,也就是大模型的输出长度有上限。

当需要返回几十条岗位记录时,JSON 还没写完就被截断了,反序列化必然失败。

求职派的采集 Agent 为这个问题设计了一套多轮对话兜底机制( app/.../GatherAiAgent.java 的 autoContinueChat 方法)。

核心思路是用 ChatMemory 维护多轮对话上下文,如果大模型返回的 JSON 不完整,就自动追问“之前返回的结果不完整,继续返回剩余的内容”。

图片.png

关键代码片段如下。

Java

ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .maxMessages(MAX_CHAT_CNT).build();
// 第一轮:发送原始数据 + 格式要求
// 后续轮次:
msg = new UserMessage("你之前返回的结果不完整,继续返回剩余的内容");

每一轮返回的文本片段,通过 GatherResFormat.extract() 提取出已经完整的 JSON 元素,追加到结果列表中。

循环退出的条件有三个。

  • 第一,返回文本以 ```````````结尾,表示

  • 第二,对话轮次达到上限,默认 10 轮。

  • 第三,大模型重复返回了完整数据,说明它误解了指令在从头生成。

这套机制放弃了 .entity() 的自动反序列化,改为手动拼接 JSON 片段再逐条解析。

听起来笨拙,但在处理“100 条岗位记录”这种数据量时,是目前最稳妥的方案。

Spring AI 的 .entity() 没有内置截断恢复能力,遇到不完整的 JSON 就直接报错。

还有一个防御性设计。

即使多轮对话中某一轮抛了异常,catch 块里不应该丢弃前面已经成功解析的数据。

代码应该直接 break 跳出循环,把 itemList 里已有的记录返回给上层。在大模型调用这种不稳定的场景下,“尽可能多地保留有效结果”比“要么全成功要么全失败”更合理。

格式指令的注入方式也和单次调用不同。

图片.png

第一轮对话通过 PromptTemplate 把 gatherResConverter.getFormat() 拼到提示词末尾,后续轮次不再重复注入格式要求。

ChatMemory 已经保留了第一轮的上下文,大模型记得该用什么格式,不需要每轮都重复。这样就避免了多轮对话中格式指令重复堆积、浪费上下文窗口的问题。

05、结构化返回的选型

从求职派的实践来看,结构化返回的选型可以按场景分成三档。

①、 简单场景,字段少于 5 个、单条记录,直接用 .entity(),没有额外代码。意图识别和用户画像提取属于这一档。

②、 中等场景,字段多但数据量可控,用 .entity() 配合 ParameterizedTypeReference 处理泛型。岗位数据的单次采集属于这一档。record 定义时注意给每个字段写好 @JsonPropertyDescription,带上枚举示例。

③、 复杂场景,返回数据量大、可能被截断,用 BeanOutputConverter 手动挡 + ChatMemory 多轮对话。手动控制格式指令的注入位置,手动解析返回的 JSON 片段。代码量多,但可靠性高。

图片.png

三条实操建议。

  • @JsonPropertyDescription 的描述文本要写示例值,不要只写“xxx类型”。“如 国企,事业单位,学校”比“公司的组织类型”对大模型的约束力强 10 倍

  • record 是结构化返回的最佳搭档。不可变、字段清晰、天然适配 JSON 反序列化。求职派所有结构化返回的目标类型都是 record

  • 不要信任大模型的返回是 100% 稳定的。即使用了 .entity(),外层也要加 try-catch 做降级处理。求职派的意图识别在解析失败时会降级为 unknown,采集 Agent 在异常时保留已解析的部分结果

ending

结构化返回是 Spring AI 里最实用的特性之一。

一行 .entity() 解决了 80% 的场景,剩下 20% 靠手动挡 + 多轮对话兜底。和大多数工程问题一样,简单的部分框架帮我们搞定,复杂的部分还是得自己写逻辑。

图片.png

求职派教程持续更新中,我们下期见。

大模型最让我们头疼的问题之一,就是它返回的不是结构化文本,而业务代码需要的是有类型的数据结构。技术派: https://paicoding.com/jobclaw-structured-return

否则,我们很难将 LLM 返回的结果和 Java 的对象一一对应起来。

本篇内容第一版的作者:星球嘉宾灰灰。二哥做了二期的优化和迭代。

图片.png

举例来说,让大模型“返回五部电影”,它可能会给出一段 Markdown 列表、一段带编号的纯文本。

每次格式都不一样,上层业务代码根本没法正确解析。

Spring AI 2.0 的结构化返回机制就是来解决这个问题的,一行 .entity() 调用,大模型返回的无结构化文本就能变成规范的 Java 对象。

求职派的意图识别、用户画像提取、岗位数据采集,全都依赖这个能力。

这篇文章从 Spring AI 的结构化返回 API 讲起,拆解求职派源码里的三个生产级用例,最后讲清楚这套机制的边界在哪,什么场景靠谱,什么场景必须加兜底。

01、Spring AI 的结构化返回

Spring AI 提供了两条路径实现结构化返回,区别在于“谁来拼接提示词”。

第一种,ChatClient.entity() 自动挡

Java

ActorsFilms films = ChatClient.create(chatModel)
    .prompt("帮我返回五个周星驰导演的电影名")
    .call()
    .entity(ActorsFilms.class);

一行代码搞定。

Spring AI 在底层做了三件事。

  • 第一步,根据目标类的字段生成 JSON Schema;

  • 第二步,把 Schema 注入到提示词的上下文中(通过 Advisor 机制在请求发出前拦截并追加格式指令);

  • 第三步,拿到大模型返回的 JSON 后用 Jackson 反序列化成 Java 对象。

我们发送的提示词里是不包含任何格式约束的,但 debug 查看真正发给大模型的请求,会发现 context 参数里已经注入了一段完整的 JSON Schema,这就是 Advisor 在背后干的事情。

图片.png

上图是 debug 截图,可以看到提示词的 context 部分被自动追加了返回格式要求。

我们写的提示词只有“帮我返回五个周星驰导演的电影名”,格式约束完全由框架注入。

图片.png

第二种,BeanOutputConverter 手动挡

Java

var converter = new BeanOutputConverter<>(ActorsFilms.class);
String format = converter.getFormat();
Prompt prompt = new PromptTemplate("帮我返回五个{actor}导演的电影名\n{format}")
    .create(Map.of("actor", "周星驰", "format", format));

手动挡把格式指令(format)暴露出来,我们自己决定在提示词的什么位置插入。

getFormat() 返回的是一段 JSON Schema 描述文本,告诉大模型“按这个格式返回”。拿到响应后再手动调用 converter.convert(text) 完成反序列化。

两种方式是一个原理,在提示词里塞入格式约束,让大模型按约定的 JSON 结构返回。

自动挡省事,手动挡灵活。

当需要在多轮对话中精确控制格式指令的插入位置时,手动挡就派上用场了。

除了映射到 Java 对象的 BeanOutputConverter,Spring AI 还提供了 MapOutputConverter 和 ListOutputConverter,分别用于返回 Map 和 List 结构。

三者的继承关系如下。

图片.png

实际开发中, BeanOutputConverter 覆盖了绝大多数场景。

求职派全部使用它来做结构化返回。原因很简单,业务数据结构一旦确定,用 record 定义是最清晰的选择。

02、record + @JsonPropertyDescription

自动挡的 .entity() 之所以能工作,关键在于 Spring AI 从 Java 类型自动生成了 JSON Schema。

@JsonPropertyDescription 注解决定了 Schema 里每个字段的描述文本,这段描述文本就是大模型理解“这个字段该填什么”的唯一线索。

求职派意图识别的返回结构定义( core/.../IntentClassificationRes.java)是一个典型示例。

Java

public record IntentClassificationRes(
    @JsonPropertyDescription("识别到的意图类型")
    PresetAgentIntro intentType,
    @JsonPropertyDescription("置信度 0.0-1.0")
    double confidence,
    @JsonPropertyDescription("识别理由/推理过程")
    String reasoning
) {}

三个字段,每个都有 @JsonPropertyDescription。

Spring AI 读到这些注解后,生成的 JSON Schema 会告诉大模型“intentType 是意图类型,confidence 是 0 到 1 的数字,reasoning 是推理过程”。

大模型按照这个约束返回 JSON,Spring AI 再用 Jackson 反序列化成 record 实例。

还有一个容易被忽略的注解 @JsonIgnore。同样在这个 record 里,第四个字段 context 标注了 @JsonIgnore,意味着它不会出现在生成的 JSON Schema 中。 context 是程序内部传递额外信息用的,跟大模型没关系。这个设计让同一个 record 既能承接大模型的返回,又能携带业务上下文,不用拆成两个类。

图片.png

这里有一个设计细节。

@JsonPropertyDescription 里的描述文本不是写给人看的文档,是写给大模型看的指令。求职派的岗位采集数据结构( app/.../GatherOcDraftBo.java)就把这一点用到了极致,每个字段的描述都带了具体的枚举示例。

Java

@JsonPropertyDescription("公司类型,如 国企,事业单位,学校,银行,私企等")
String companyType,
@JsonPropertyDescription("招聘类型,如 秋招,校招,实习,社招,秋招提前批等")
String recruitmentType,

“如国企,事业单位,学校”,不是随便写的。

大模型看到这些示例,返回的值就会落在这几个类别里,而不是自由发挥出“央企控股子公司”、“准事业编”这种不在预期范围内的表述。用示例锚定输出范围,比在提示词里长篇大论地描述规则有效得多。

这条 record 一共有 17 个字段(公司名称、类型、行业、地点、招聘类型、招聘对象、岗位、投递进度、更新时间、截止时间、相关链接、招聘公告、内推码、备注、薪资范围、学历要求、工作经验),全部通过 @JsonPropertyDescription 标注了含示例的描述。

大模型一次调用就能从非结构化的招聘文本中提取出这 17 个字段的结构化数据。

图片.png

03、求职派的三个生产用例

理解了原理,我们来看看实际怎么用。

求职派在三个核心业务场景都使用了结构化返回,每个场景的调用方式略有不同。

补充一个前提,求职派没有在业务代码中直接使用 ChatClient,而是封装了一层调用接口。

核心接口 LlmCaller 定义了四个方法,其中结构化返回对应的方法签名是 T call(UserConversationInfo user, Prompt prompt, Class clz)。

图片.png

它的默认实现 SimpleLlmCaller 内部做的事情就是拿到用户对应的模型配置、构建 ChatClient、调用 .entity()。

这层封装把模型选择、系统提示词注入、ReAct 工具链配置等通用逻辑统一封装,业务代码只管传类型参数就行。

意图识别

用户在微信/钉钉/飞书发一条消息过来,消息路由器需要判断这条消息的意图,是要投简历、查岗位还是闲聊。

意图分类器把判断任务交给大模型,要求返回意图类型、置信度和推理过程三个字段。

图片.png

调用代码在 core/.../LLMIntentClassifier.java,只有一行核心逻辑。

Java

var response = simpleLlmCaller.call(
    userConversationInfo, new Prompt(prompt),
    IntentClassificationRes.class);

这里的 simpleLlmCaller.call() 内部调用的就是 ChatClient.entity()。

第三个参数 IntentClassificationRes.class 告诉 Spring AI 用这个 record 的结构生成格式约束。大模型返回后,置信度低于 0.7 的结果会被标记为“不可信”,由关键词匹配兜底。

这个设计避免了手动解析 JSON 字符串的脆弱性。如果大模型返回的 JSON 缺了字段或者格式不对, entity() 调用会直接抛异常,被外层 catch 住后降级为 unknown 结果,不会出现“解析到了但数据不对”的静默错误。

用户画像提取

用户在求职派提交个人信息时,输入的是一段自由文本,比如“我是2026年毕业的计算机硕士,想去武汉的国企做Java开发”。

业务需要从中提取出公司类型、行业、地点、招聘类型、岗位等结构化字段。

图片.png

调用代码在 app/.../UserInterestService.java。

Java

UserInterestBo userInterestBo = chatClient.prompt()
    .user("我现在是一个准备找工作的人," +
        "下面是我的个人信息,我希望从中提取出用于筛选招聘岗位的关键信息: \n" + text)
    .call().entity(UserInterestBo.class);

这是最标准的 .entity() 用法,一段提示词加一个目标类型。

大模型从自由文本中识别出“2026年毕业生”、“武汉”、“国企”、“Java开发”这些关键信息,分别填入对应的字段。提取结果经过数据清洗后存入数据库,用于后续的岗位推荐匹配。

和意图识别不同的是,这里没有走 LlmCaller 封装,而是直接使用了 ChatClient。

原因是用户画像提取属于后台管理功能,不走 IM 消息通道,不需要 LlmCaller 提供的用户模型选择和工具链注入能力。同样的 .entity() 调用,在不同的业务上下文中选择不同的调用层级,这也是工程化的考量。

岗位数据采集

岗位数据采集是最复杂的场景。

采集 Agent 需要从招聘网页或图片中提取出完整的岗位信息列表,每条记录有 17 个字段。

图片.png

调用代码在 app/.../GatherAiAgent.java。

Java

ArrayList list = this.ocAiModelContext
    .chatClient(ModelSelectReq.of(model, GatherModelTypeEnum.CHAT_MODEL))
    .prompt(text).tools(new CrawlerTools())
    .call()
    .entity(new ParameterizedTypeReference>() {});

注意这里用了 ParameterizedTypeReference 而不是直接传 Class。

原因是目标类型 ArrayList 带泛型参数,Java 的类型擦除会在运行时丢掉泛型信息,必须用 ParameterizedTypeReference 来保留完整的类型参数。

另一个细节是 .tools(new CrawlerTools()) 注册了爬虫工具。

大模型在采集过程中,如果发现招聘文本里有链接,可以自主调用爬虫工具去抓取链接内容,把抓到的信息也纳入结构化提取的范围。结构化返回和工具调用在同一次对话中协同工作。

04、大模型截断了怎么办?

结构化返回在单次调用场景下表现稳定,但遇到大批量数据采集时会遇到一个难题,也就是大模型的输出长度有上限。

当需要返回几十条岗位记录时,JSON 还没写完就被截断了,反序列化必然失败。

求职派的采集 Agent 为这个问题设计了一套多轮对话兜底机制( app/.../GatherAiAgent.java 的 autoContinueChat 方法)。

核心思路是用 ChatMemory 维护多轮对话上下文,如果大模型返回的 JSON 不完整,就自动追问“之前返回的结果不完整,继续返回剩余的内容”。

图片.png

关键代码片段如下。

Java

ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .maxMessages(MAX_CHAT_CNT).build();
// 第一轮:发送原始数据 + 格式要求
// 后续轮次:
msg = new UserMessage("你之前返回的结果不完整,继续返回剩余的内容");

每一轮返回的文本片段,通过 GatherResFormat.extract() 提取出已经完整的 JSON 元素,追加到结果列表中。

循环退出的条件有三个。

  • 第一,返回文本以 ```````````结尾,表示

  • 第二,对话轮次达到上限,默认 10 轮。

  • 第三,大模型重复返回了完整数据,说明它误解了指令在从头生成。

这套机制放弃了 .entity() 的自动反序列化,改为手动拼接 JSON 片段再逐条解析。

听起来笨拙,但在处理“100 条岗位记录”这种数据量时,是目前最稳妥的方案。

Spring AI 的 .entity() 没有内置截断恢复能力,遇到不完整的 JSON 就直接报错。

还有一个防御性设计。

即使多轮对话中某一轮抛了异常,catch 块里不应该丢弃前面已经成功解析的数据。

代码应该直接 break 跳出循环,把 itemList 里已有的记录返回给上层。在大模型调用这种不稳定的场景下,“尽可能多地保留有效结果”比“要么全成功要么全失败”更合理。

格式指令的注入方式也和单次调用不同。

图片.png

第一轮对话通过 PromptTemplate 把 gatherResConverter.getFormat() 拼到提示词末尾,后续轮次不再重复注入格式要求。

ChatMemory 已经保留了第一轮的上下文,大模型记得该用什么格式,不需要每轮都重复。这样就避免了多轮对话中格式指令重复堆积、浪费上下文窗口的问题。

05、结构化返回的选型

从求职派的实践来看,结构化返回的选型可以按场景分成三档。

①、 简单场景,字段少于 5 个、单条记录,直接用 .entity(),没有额外代码。意图识别和用户画像提取属于这一档。

②、 中等场景,字段多但数据量可控,用 .entity() 配合 ParameterizedTypeReference 处理泛型。岗位数据的单次采集属于这一档。record 定义时注意给每个字段写好 @JsonPropertyDescription,带上枚举示例。

③、 复杂场景,返回数据量大、可能被截断,用 BeanOutputConverter 手动挡 + ChatMemory 多轮对话。手动控制格式指令的注入位置,手动解析返回的 JSON 片段。代码量多,但可靠性高。

图片.png

三条实操建议。

  • @JsonPropertyDescription 的描述文本要写示例值,不要只写“xxx类型”。“如 国企,事业单位,学校”比“公司的组织类型”对大模型的约束力强 10 倍

  • record 是结构化返回的最佳搭档。不可变、字段清晰、天然适配 JSON 反序列化。求职派所有结构化返回的目标类型都是 record

  • 不要信任大模型的返回是 100% 稳定的。即使用了 .entity(),外层也要加 try-catch 做降级处理。求职派的意图识别在解析失败时会降级为 unknown,采集 Agent 在异常时保留已解析的部分结果

ending

结构化返回是 Spring AI 里最实用的特性之一。

一行 .entity() 解决了 80% 的场景,剩下 20% 靠手动挡 + 多轮对话兜底。和大多数工程问题一样,简单的部分框架帮我们搞定,复杂的部分还是得自己写逻辑。

图片.png

求职派教程持续更新中,我们下期见。