ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

【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邮箱 zhangweiexample.com。 他本科学历有 6 年左右的 Java 后端开发经验比较熟悉的技术包括 Java、Spring Boot 和 MySQL…… 最近一份工作是某电商公司的高级后端工程师。读得准不准准。但这段东西后端根本没法直接入库——它是一段自然语言不是一条记录。于是现实里通常出现三种土办法每一种都埋雷写正则 / 字符串截取从文本里抠「姓名」后面的内容。模型今天写「姓名张伟」明天写「这位候选人叫张伟」正则直接失效在提示词里喊「请返回 JSON」模型确实返回 JSON 了但字段名一会儿name、一会儿姓名yearsOfExperience有时是数字 6、有时是字符串 “6 年”反序列化照样炸让模型输出后再调一次模型「请把上面的内容转成 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。publicrecordResumeFields(JsonPropertyDescription(候选人姓名纯中文人名不要带称谓)Stringname,JsonPropertyDescription(手机号11 位数字没有就返回空字符串)Stringphone,JsonPropertyDescription(邮箱地址没有就返回空字符串)Stringemail,JsonPropertyDescription(最高学历取值只能是大专/本科/硕士/博士/其他)Stringeducation,JsonPropertyDescription(工作年限整数不足一年按 0)IntegeryearsOfExperience,JsonPropertyDescription(技能关键词列表最多 10 个每个不超过 8 个字)ListStringskills,JsonPropertyDescription(最近一段工作经历的职位名称)StringlatestTitle,JsonPropertyDescription(一句话总结候选人不超过 40 字)Stringsummary){/** 关键字段缺失时打人工复核标记别让脏数据直接落库。 */publicbooleanneedsReview(){returnisBlank(name)||isBlank(latestTitle)||yearsOfExperiencenull||yearsOfExperience0;}privatestaticbooleanisBlank(Strings){returnsnull||s.trim().isEmpty();}}写这份 POJO 有三条经验是 V哥 在项目里一条条试出来的取值要收敛学历不要写「学历」写「取值只能是大专/本科/硕士/博士/其他」。模型的自由度给得越大它给你的花样越多缺失要约定找不到就「字符串填空串、数字填 0、列表填空数组」不要让模型自己编——编出来的手机号比空值危险一百倍列表要给边界「最多 10 个每个不超过 8 个字」否则技能列表能被它写成 30 个同义词。本篇的测试里专门断言了 schema 的生成结果converter.getFormat()里既包含字段yearsOfExperience也包含我写的中文说明「工作年限」——说明这份说明书确实送到了模型面前。第二步服务里一行 entity()三步活全包了有了 POJO服务层异常简单ServicepublicclassResumeParserService{privatestaticfinalStringINSTRUCTION 你是招聘系统的简历解析助手。请从下面的简历文本中抽取字段严格按 JSON Schema 输出。 规则 1. 只输出 JSON不要任何解释、不要 Markdown 代码块 2. 文本里找不到的字段字符串填空串、数字填 0、列表填空数组不要自己编造 3. 工作年限按「截止到今天」折算成整数。 简历文本 {resume} ;privatefinalChatClientchatClient;publicResumeParserService(ChatModelchatModel){this.chatClientChatClient.create(chatModel);}publicResumeFieldsparse(StringresumeText){try{returnchatClient.prompt().user(u-u.text(INSTRUCTION).param(resume,resumeText)).call().entity(ResumeFields.class);}catch(Exceptionex){thrownewResumeParseException(模型输出无法解析为简历字段请转人工复核,ex);}}}别看只有一行.entity(ResumeFields.class)它背后替你干了三步生成 schema拿着ResumeFields生成 JSON Schema就是BeanOutputConverter干的事注入提示词把 schema 和一段格式指令拼到你的提示词后面。这段指令是框架内置的大意是「只提供符合 RFC8259 的 JSON 响应、不要解释、不要 Markdown 代码块输出必须遵循下面的 JSON Schema」反序列化模型返回文本后自动剥掉可能的 Markdown 围栏、转成ResumeFields对象。想自己掌控这三步比如想把 schema 打进日志排查、或是在流式场景里手动收尾可以手动用BeanOutputConverterBeanOutputConverterResumeFieldsconverternewBeanOutputConverter(ResumeFields.class);Stringformatconverter.getFormat();// 拿到带 schema 的格式指令可注入任意提示词ResumeFieldsfieldsconverter.convert(jsonText);// 手动反序列化日常用entity()需要定制提示词或排查 schema 时用BeanOutputConverter——两者是同一套机制的两层封装不是两套东西。第三步别信模型留两道防线结构化输出把「大概率对」变成了「基本可信」但 AI 应用必须有兜底这一节是本篇最该抄走的部分。防线一输出不干净也能救回来。模型经常把 JSON 用json代码块包起来甚至带点「好的以下是结果」的前缀。Spring AI 内置了输出清理器本篇测试专门模拟了这种情况——桩模型返回被 Markdown 围栏包裹的 JSONentity()照样解析出正确字段。所以别自己写正则去剥围栏框架已经替你干了。防线二字段缺失要打复核标记不要直接落库。模型说「简历里没写手机号」时正确做法不是把空值塞进数据库而是标记这条数据需要人工看一眼publicbooleanneedsReview(){returnisBlank(name)||isBlank(latestTitle)||yearsOfExperiencenull||yearsOfExperience0;}防线三彻底解析不了要有明确出口。模型偶尔会抽风返回「抱歉我无法完成这个请求」。这时候不能让异常变成一堆堆栈甩给用户包一层业务异常再在全局异常处理器里转成能直接展示的响应/** 模型输出不合 schema不是服务挂了而是这条数据需要转人工返回 422。 */ExceptionHandler(ResumeParseException.class)publicResponseEntityMapString,StringhandleParseFailure(ResumeParseExceptionex){returnResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(Map.of(error,ex.getMessage(),review,true));}注意这里返回的是422不可处理的实体而不是 500服务没挂是这条数据不合格。前端拿到review true直接把这条简历推进「人工复核」队列用户体验和反悔成本都最小。第四步接口暴露控制器不碰模型RestControllerRequestMapping(/api/resume)publicclassResumeParseController{privatefinalResumeParserServiceparserService;publicResumeParseController(ResumeParserServiceparserService){this.parserServiceparserService;}PostMapping(/parse)publicResponseEntityResumeParseResultparse(ValidRequestBodyResumeParseRequestrequest){ResumeFieldsfieldsparserService.parse(request.resumeText());returnResponseEntity.ok(newResumeParseResult(fields,fields.needsReview()));}}ResumeParseResult只是把「结构化结果 是否要复核」打包出去publicrecordResumeParseResult(ResumeFieldsdata,booleanreview){}控制器从头到尾没出现任何模型 API——模型在哪、用的哪档、schema 长什么样它一概不知。这就是第 03 篇说的「业务只认抽象」在结构化场景里的延续。怎么验证离线也能跑通这套逻辑外部 LLM 要密钥要联网但要验证的是「schema 生成对不对、反序列化稳不稳、兜底逻辑好不好使」用一个桩模型返回固定 JSON 就够了重点验证这几件事测试验证什么parse_returnsStructuredFields发真实 HTTP断言姓名/电话/学历/年限/技能/职位逐字段正确reviewfalseschemaIsGeneratedFromFieldDescriptionsgetFormat()含「JSON Schema」、字段名和中文说明证明说明书真的送到了模型markdownWrappedJson_isStillParsed桩返回被 Markdown 围栏包裹的 JSON照样解析成功manualConverterMode_producesSameFields手动BeanOutputConverter.convert()与entity()结果一致missingKeyFields_markedForManualReview关键字段为空 →needsReview()为 trueunparsableOutput_throwsResumeParseException模型返回非 JSON → 抛业务异常→ 422blankResumeText_returnsBadRequest简历文本为空 → 400 校验拦截全程不需要任何真实密钥也不依赖网络。落地要点schema 别贪多一次抽 20 个字段模型的注意力被摊薄每个字段的准确率都会掉。真要抽 20 个拆成两次调用一次抽「基本信息」、一次抽「项目经历」不确定就给「未知」与其让模型猜不如约定一个明确的兜底值下游逻辑才好判断schema 本身吃 token字段越多、说明越长每次调用都要多付这笔钱。说明写到「够约束」就停别写小作文长简历先截断超过模型上下文的简历要先切块或抽关键段落别整本丢进去否则又贵又慢合规要前置简历是个人敏感信息调用外部模型前确认对方的数据留存策略必要时走私有化部署或脱敏姓名、手机号先打码再解析。V哥 给企业做方案时这条永远是评审会上第一个要过的关。最后一句别再拿正则去抠模型说的话——把字段定义成 POJO让 schema 逼着模型按你的形状输出再留一道「解析不了就转人工」的出口AI 才算真正接进了你的业务系统而不是停在演示里。下一篇05V哥 带你解决更尴尬的一种情况模型答不上实时数据让它自己调你的 Java 方法去查订单和库存。
返回列表