【AI全栈后端12-04】Spring Boot 用结构化输出自动解析简历:让模型按你的 POJO 输出

本文是「Spring Boot + AI 全栈后端」系列第 04 篇。第 03 篇解决了「贵的模型被便宜问题白烧」的成本问题,这一篇换个方向:模型明明读懂了,后端却还是用不了它返回的东西。示例基于 Spring AI 2.0 / Boot 4.1(全部已通过测试)。

一个让后端很憋屈的场景

V哥 带学员做招聘系统实训时,总会先让他们去看一眼 HR 的日常工作:收到一封简历(PDF、Word、甚至邮件正文),人工把姓名、手机号、学历、工作年限、技能关键词、最近职位一个一个敲进表单,再点保存。一份简历平均两三分钟,一天几十份,眼睛看花了就串行、漏字段,录进去的脏数据后面还要返工清洗。

你肯定第一时间想到:让 AI 来读。于是你按第 02 篇的方式接了个对话接口,把简历文本丢给模型,问它「请提取姓名、手机号、学历、工作年限、技能」。

模型回了一大段:

复制代码
这位候选人叫张伟,联系电话是 13800138000,邮箱 zhangwei@example.com。
他本科学历,有 6 年左右的 Java 后端开发经验,比较熟悉的技术包括 Java、Spring Boot 和 MySQL......
最近一份工作是某电商公司的高级后端工程师。

读得准不准?准。但这段东西后端根本没法直接入库------它是一段自然语言,不是一条记录。于是现实里通常出现三种土办法,每一种都埋雷:

  1. 写正则 / 字符串截取:从文本里抠「姓名:」后面的内容。模型今天写「姓名:张伟」,明天写「这位候选人叫张伟」,正则直接失效;
  2. 在提示词里喊「请返回 JSON」 :模型确实返回 JSON 了,但字段名一会儿 name、一会儿 姓名,yearsOfExperience 有时是数字 6、有时是字符串 "6 年",反序列化照样炸;
  3. 让模型输出后再调一次模型「请把上面的内容转成 JSON」:多一轮调用,多一份钱,多一次出错机会。

问题到这里就清楚了:不是模型不会读简历,是它的输出没有「形状」约束,下游没法稳定消费。V哥 做后端二十多年,这条规律反复被验证:凡是靠「约定」而不是靠「约束」的接口,早晚要在脏数据上栽跟头。我们要解决的,就是「让模型的输出必须长成我定义的样子」这件事。

解决思路:给它一份 JSON Schema,让它照着填

结构化输出(Structured Output)的思路很朴素:你先把想要的字段定义成一个 Java 类(POJO),框架拿着这个类自动生成一份 JSON Schema,把 schema 塞进提示词里告诉模型「你必须按这个格式输出」,拿到模型回复后再自动反序列化成 POJO。

对比一下就明白它好在哪:

做法 形状约束 字段类型 维护成本
提示词里喊「返回 JSON」 靠模型自觉 靠运气,数字可能变字符串 每次改字段都要改提示词 + 改解析
手写正则 无 全是字符串 模型换个说法就崩
结构化输出(schema 约束) 框架强制 按 POJO 类型保证 只改 POJO,其它全自动化

Spring AI 里这件事只需要一个 .entity(ResumeFields.class)。下面按「定义字段 → 服务调用 → 脏数据防线 → 接口暴露」四步落地。

第一步:把字段定义成 POJO,注释写给模型看

先定义简历要抽哪些字段。这里最关键的一行不是字段本身,而是 @JsonPropertyDescription------它不是给程序员看的注释,是写给模型看的字段说明书 ,会被 Spring AI 的 JsonSchemaGenerator 读走,变成 JSON Schema 里的 description。

java 复制代码
public record ResumeFields(

        @JsonPropertyDescription("候选人姓名,纯中文人名,不要带称谓")
        String name,

        @JsonPropertyDescription("手机号,11 位数字;没有就返回空字符串")
        String phone,

        @JsonPropertyDescription("邮箱地址;没有就返回空字符串")
        String email,

        @JsonPropertyDescription("最高学历,取值只能是:大专/本科/硕士/博士/其他")
        String education,

        @JsonPropertyDescription("工作年限,整数,不足一年按 0")
        Integer yearsOfExperience,

        @JsonPropertyDescription("技能关键词列表,最多 10 个,每个不超过 8 个字")
        List<String> skills,

        @JsonPropertyDescription("最近一段工作经历的职位名称")
        String latestTitle,

        @JsonPropertyDescription("一句话总结候选人,不超过 40 字")
        String summary) {

    /** 关键字段缺失时打人工复核标记,别让脏数据直接落库。 */
    public boolean needsReview() {
        return isBlank(name) || isBlank(latestTitle)
                || yearsOfExperience == null || yearsOfExperience < 0;
    }

    private static boolean isBlank(String s) {
        return s == null || s.trim().isEmpty();
    }
}

写这份 POJO 有三条经验,是 V哥 在项目里一条条试出来的:

  • 取值要收敛:学历不要写「学历」,写「取值只能是:大专/本科/硕士/博士/其他」。模型的自由度给得越大,它给你的花样越多;
  • 缺失要约定:找不到就「字符串填空串、数字填 0、列表填空数组」,不要让模型自己编------编出来的手机号比空值危险一百倍;
  • 列表要给边界:「最多 10 个,每个不超过 8 个字」,否则技能列表能被它写成 30 个同义词。

本篇的测试里专门断言了 schema 的生成结果:converter.getFormat() 里既包含字段 yearsOfExperience,也包含我写的中文说明「工作年限」------说明这份说明书确实送到了模型面前。

第二步:服务里一行 entity(),三步活全包了

有了 POJO,服务层异常简单:

java 复制代码
@Service
public class ResumeParserService {

    private static final String INSTRUCTION = """
            你是招聘系统的简历解析助手。请从下面的简历文本中抽取字段,严格按 JSON Schema 输出。
            规则:
            1. 只输出 JSON,不要任何解释、不要 Markdown 代码块;
            2. 文本里找不到的字段,字符串填空串、数字填 0、列表填空数组,不要自己编造;
            3. 工作年限按「截止到今天」折算成整数。

            简历文本:
            {resume}
            """;

    private final ChatClient chatClient;

    public ResumeParserService(ChatModel chatModel) {
        this.chatClient = ChatClient.create(chatModel);
    }

    public ResumeFields parse(String resumeText) {
        try {
            return chatClient.prompt()
                    .user(u -> u.text(INSTRUCTION).param("resume", resumeText))
                    .call()
                    .entity(ResumeFields.class);
        } catch (Exception ex) {
            throw new ResumeParseException("模型输出无法解析为简历字段,请转人工复核", ex);
        }
    }
}

别看只有一行 .entity(ResumeFields.class),它背后替你干了三步:

  1. 生成 schema :拿着 ResumeFields 生成 JSON Schema(就是 BeanOutputConverter 干的事);
  2. 注入提示词:把 schema 和一段格式指令拼到你的提示词后面。这段指令是框架内置的,大意是「只提供符合 RFC8259 的 JSON 响应、不要解释、不要 Markdown 代码块,输出必须遵循下面的 JSON Schema」;
  3. 反序列化 :模型返回文本后,自动剥掉可能的 Markdown 围栏、转成 ResumeFields 对象。

想自己掌控这三步(比如想把 schema 打进日志排查、或是在流式场景里手动收尾),可以手动用 BeanOutputConverter:

java 复制代码
BeanOutputConverter<ResumeFields> converter = new BeanOutputConverter<>(ResumeFields.class);
String format = converter.getFormat();   // 拿到带 schema 的格式指令,可注入任意提示词
ResumeFields fields = converter.convert(jsonText);  // 手动反序列化

日常用 entity(),需要定制提示词或排查 schema 时用 BeanOutputConverter------两者是同一套机制的两层封装,不是两套东西。

第三步:别信模型,留两道防线

结构化输出把「大概率对」变成了「基本可信」,但 AI 应用必须有兜底,这一节是本篇最该抄走的部分。

防线一:输出不干净也能救回来。 模型经常把 JSON 用 ```````json```` 代码块包起来,甚至带点「好的,以下是结果:」的前缀。Spring AI 内置了输出清理器,本篇测试专门模拟了这种情况------桩模型返回被 Markdown 围栏包裹的 JSON,entity() 照样解析出正确字段。所以别自己写正则去剥围栏,框架已经替你干了。

防线二:字段缺失要打复核标记,不要直接落库。 模型说「简历里没写手机号」时,正确做法不是把空值塞进数据库,而是标记这条数据需要人工看一眼:

java 复制代码
public boolean needsReview() {
    return isBlank(name) || isBlank(latestTitle)
            || yearsOfExperience == null || yearsOfExperience < 0;
}

防线三:彻底解析不了要有明确出口。 模型偶尔会抽风返回「抱歉,我无法完成这个请求」。这时候不能让异常变成一堆堆栈甩给用户,包一层业务异常,再在全局异常处理器里转成能直接展示的响应:

java 复制代码
/** 模型输出不合 schema:不是服务挂了,而是这条数据需要转人工,返回 422。 */
@ExceptionHandler(ResumeParseException.class)
public ResponseEntity<Map<String, String>> handleParseFailure(ResumeParseException ex) {
    return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
            .body(Map.of("error", ex.getMessage(), "review", "true"));
}

注意这里返回的是 422(不可处理的实体)而不是 500 :服务没挂,是这条数据不合格。前端拿到 review = true,直接把这条简历推进「人工复核」队列,用户体验和反悔成本都最小。

第四步:接口暴露,控制器不碰模型

java 复制代码
@RestController
@RequestMapping("/api/resume")
public class ResumeParseController {

    private final ResumeParserService parserService;

    public ResumeParseController(ResumeParserService parserService) {
        this.parserService = parserService;
    }

    @PostMapping("/parse")
    public ResponseEntity<ResumeParseResult> parse(@Valid @RequestBody ResumeParseRequest request) {
        ResumeFields fields = parserService.parse(request.resumeText());
        return ResponseEntity.ok(new ResumeParseResult(fields, fields.needsReview()));
    }
}

ResumeParseResult 只是把「结构化结果 + 是否要复核」打包出去:

java 复制代码
public record ResumeParseResult(ResumeFields data, boolean review) {}

控制器从头到尾没出现任何模型 API------模型在哪、用的哪档、schema 长什么样,它一概不知。这就是第 03 篇说的「业务只认抽象」在结构化场景里的延续。

怎么验证:离线也能跑通这套逻辑

外部 LLM 要密钥要联网,但要验证的是「schema 生成对不对、反序列化稳不稳、兜底逻辑好不好使」,用一个桩模型返回固定 JSON 就够了,重点验证这几件事:

测试 验证什么
parse_returnsStructuredFields 发真实 HTTP,断言姓名/电话/学历/年限/技能/职位逐字段正确,review=false
schemaIsGeneratedFromFieldDescriptions getFormat() 含「JSON Schema」、字段名和中文说明,证明说明书真的送到了模型
markdownWrappedJson_isStillParsed 桩返回被 Markdown 围栏包裹的 JSON,照样解析成功
manualConverterMode_producesSameFields 手动 BeanOutputConverter.convert() 与 entity() 结果一致
missingKeyFields_markedForManualReview 关键字段为空 → needsReview() 为 true
unparsableOutput_throwsResumeParseException 模型返回非 JSON → 抛业务异常(→ 422)
blankResumeText_returnsBadRequest 简历文本为空 → 400 校验拦截

全程不需要任何真实密钥,也不依赖网络。

落地要点

  • schema 别贪多:一次抽 20 个字段,模型的注意力被摊薄,每个字段的准确率都会掉。真要抽 20 个,拆成两次调用,一次抽「基本信息」、一次抽「项目经历」;
  • 不确定就给「未知」:与其让模型猜,不如约定一个明确的兜底值,下游逻辑才好判断;
  • schema 本身吃 token:字段越多、说明越长,每次调用都要多付这笔钱。说明写到「够约束」就停,别写小作文;
  • 长简历先截断:超过模型上下文的简历要先切块或抽关键段落,别整本丢进去,否则又贵又慢;
  • 合规要前置:简历是个人敏感信息,调用外部模型前确认对方的数据留存策略,必要时走私有化部署或脱敏(姓名、手机号先打码再解析)。V哥 给企业做方案时,这条永远是评审会上第一个要过的关。

最后一句:别再拿正则去抠模型说的话------把字段定义成 POJO,让 schema 逼着模型按你的形状输出,再留一道「解析不了就转人工」的出口,AI 才算真正接进了你的业务系统,而不是停在演示里。下一篇(05)V哥 带你解决更尴尬的一种情况:模型答不上实时数据,让它自己调你的 Java 方法去查订单和库存。

相关推荐
海盗12341 小时前
AI 新闻日报 2026-09-30:智能体边界下沉到芯片、微软统一多智能体 SDK、具身工具链走向智能体可用
人工智能·microsoft·机器人·人工智能aigc
代数狂人2 小时前
机器学习数学基础──第 4 章 矩阵:数据的仓库与批量计算的引擎
人工智能·机器学习·矩阵
我的xiaodoujiao2 小时前
Django 基础知识详细图文教程 12-Django 模型定义与使用 2
后端·python·django
孙启超3 小时前
【AI开发之Rust】第 22 课:一键多平台与工程收尾 —— CI、发布检查与结课
开发语言·后端·rust
Hi202402177 小时前
Vortex CUDA 生态适配:让 CUDA C、CUTLASS 与 Triton 在 RISC-V GPGPU 上运行
人工智能·risc-v·gpgpu
xcl09258 小时前
北京24小时自助健身房系统软件开发实战:从架构到部署全流程指南
java·spring boot·架构
龙腾AI白云10 小时前
AI检索增强生成(RAG):解决大模型幻觉的核心落地技术
数据库·人工智能·机器学习·知识图谱
一 乐10 小时前
动漫书销售商城|基于springboot + vue动漫书销售商城(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕业设计