我用Spring AI做了个简历优化工具(1):Structured Output实战,让AI返回Java对象

我用Spring AI做了个简历优化工具(1):Structured Output实战,让AI返回Java对象

这是「普通Java开发者的AI提效笔记」第4篇。第3篇聊了@Tool的description怎么写,这篇讲另一个Spring AI的核心能力------怎么让AI直接返回Java对象,而不是一坨文本

事情是这样的

金三银四刚过,金七银八又要来了。好多毕业生们都在改简历,我也翻了下自己的------4年Java开发,简历上写着"负责信息管理系统开发,做了系统重构"。

说实话,这种描述跟"参加了项目开发"有啥区别?HR看10秒就划走了。

市面上简历优化工具我试了几个:要么几十块一次给你改几个词,要么AI生成的简历一看就是编的,数字全靠编。

我心想:我正在学Spring AI,为啥不自己做一个?

于是花了两天,搭了个resume-ai------粘贴JD(职位描述) + 粘贴简历,AI帮你分析匹配度、指出短板、输出优化版简历。4个API,全部跑通。

而这一切的核心,就是Spring AI的Structured Output。


先说痛点:AI返回一坨文本,你怎么用?

我之前做文搭(文创推荐工具)的时候,ChatClient默认返回的就是一坨字符串:

erlang 复制代码
根据您的需求,推荐以下文创产品:1. 青岛啤酒主题文创礼盒,价格约158元...

你拿到这段话,怎么展示给用户?怎么存数据库?怎么和前端对接?

传统做法是自己解析JSON------写正则、处理异常格式、应对LLM偶尔输出的markdown代码块......一套下来比业务逻辑还复杂。

Structured Output就是来解决这个问题的:你告诉Spring AI你想要什么Java对象,它就给你什么对象。不解析、不猜测、不踩坑。

一行代码对比:

scss 复制代码
// ❌ 传统方式:拿字符串自己解析
String result = chatClient.prompt().user("分析这个JD").call().content();
// result = "这个岗位要求5年以上Java经验,需要熟悉JVM..."
ObjectMapper mapper = new ObjectMapper();
JdAnalysis analysis = mapper.readValue(result, JdAnalysis.class); // 经常报错
​
// ✅ Structured Output:直接拿对象
JdAnalysis analysis = chatClient.prompt()
    .user(userSpec -> userSpec.text(prompt).param("content", jd))
    .call()
    .entity(JdAnalysis.class);
// analysis.getPosition() → "Java高级开发工程师"
// analysis.getRequiredSkills() → ["Java", "JVM", "Spring Boot"]

就这么简单。 你定义一个Java类,Spring AI自动让LLM按照你的类结构输出,再自动映射回来。


实战:4步搭出简历优化API

Step 1:定义数据模型(record)

resume-ai的核心是4个数据模型,对应4个接口。我用的Java record,简洁又不可变:

JD解析结果:

arduino 复制代码
public record JdAnalysis(
    String position,           // 岗位名称
    List<String> requiredSkills,   // 必备技能
    List<String> preferredSkills,  // 加分技能
    int minExperience,         // 最低经验年限
    String experienceLevel,    // 经验等级:初级/中级/高级
    List<String> keywords,     // 关键词(用于匹配)
    String industry            // 所属行业
) {}

简历解析结果:

arduino 复制代码
public record ResumeInfo(
    String name,
    List<String> skills,
    List<WorkExperience> workExperiences,
    List<ProjectExperience> projectExperiences,
    String education,
    int totalYears
) {}
​
public record WorkExperience(
    String company,
    String position,
    String duration,
    List<String> achievements
) {}
​
public record ProjectExperience(
    String name,
    String role,
    List<String> techStack,
    String description
) {}

匹配分析报告:

arduino 复制代码
public record MatchReport(
    int matchScore,           // 匹配分数 0-100
    List<String> matchedSkills,    // 已匹配的技能
    List<String> missingSkills,    // 缺失的技能
    List<String> weakPoints,       // 简历薄弱点
    List<String> suggestions       // 优化建议
) {}

💡 踩坑提醒:字段名要具体,LLM才能理解每个字段该填什么。 比如你写field1field2,LLM完全不知道该往里塞什么;写requiredSkillspreferredSkills,LLM就知道一个放硬性要求、一个放加分项。

我第一版没区分requiredSkillspreferredSkills,只写了个List<String> skills,结果LLM把"5年以上经验"和"有AI经验优先"全塞进同一个列表------谁也不知道哪些是硬指标、哪些是加分项。

Step 2:写Prompt模板(.st文件)

Spring AI用.st文件管理Prompt模板,比硬编码在Java里好维护。

jd-analysis.st

yaml 复制代码
你是一位资深的HR和猎头,擅长分析岗位描述(JD)。
​
请分析以下JD内容,提取结构化信息:
​
---
{content}
---
​
提取要求:
1. position:岗位名称
2. requiredSkills:必备技能(JD中明确要求"熟悉/精通/掌握"的技能)
3. preferredSkills:加分技能(JD中说"了解/优先/有经验者加分"的技能)
4. minExperience:最低经验年限(数字)
5. experienceLevel:经验等级(初级/中级/高级/专家)
6. keywords:所有技术关键词,用于简历匹配
7. industry:行业领域
​
注意区分"必备"和"加分"------这直接影响匹配评分。

💡 踩坑提醒:Prompt里要说清楚"你要提取什么",不要只说"分析这个JD"。 我第一版就写了"请分析JD",LLM给我返回了一段200字的分析文字,不是结构化数据。加上"提取结构化信息"和每个字段的说明,LLM才知道该怎么输出。

还有一个坑:没在Prompt里强调"区分必备和加分"的时候,LLM把所有技能都塞进requiredSkills------毕竟JD上写的都是"要求",AI不知道哪些是硬性要求、哪些是锦上添花。加上那句"注意区分"之后,准确率高了很多。

Step 3:写Service调用

核心代码就这几行:

kotlin 复制代码
@Service
public class JdAnalysisService {
​
    private final ChatClient chatClient;
    private final Resource jdTemplate;
​
    public JdAnalysisService(ChatClient chatClient,
            @Value("classpath:prompts/jd-analysis.st") Resource jdTemplate) {
        this.chatClient = chatClient; //此处chatClient注入为省略写法
        this.jdTemplate = jdTemplate;
    }
​
    public JdAnalysis analyze(String content) {
        return chatClient.prompt()
                .user(userSpec -> userSpec
                    .text(jdTemplate)
                    .param("content", content))
                .options(DashScopeChatOptions.builder()
                    .withTemperature(0.3)  // 💡 关键:低temperature保证稳定性
                    .build())
                .call()
                .entity(JdAnalysis.class);
    }
}

💡 踩坑提醒:temperature要设0.3,别用默认值。 我一开始没改,默认0.7------Structured Output偶尔会返回null,或者字段值前后不一致。因为Structured Output需要LLM严格按schema输出,temperature越高,LLM越"有创意",越可能偏离你的数据结构。改成0.3之后,10次测试全部成功。

Step 4:跑通测试

启动项目,curl一发:

swift 复制代码
curl -X POST http://localhost:8080/api/jd/analyze \
  -H "Content-Type: application/json" \
  -d '{
    "content": "岗位:Java高级开发工程师\n5年以上Java开发经验,熟悉JVM原理\n精通Spring Boot、Spring Cloud微服务架构\n熟练使用MySQL、Redis、Kafka消息队列\n熟悉Docker容器化部署,了解Kubernetes\n有AI/大模型应用开发经验者优先"
  }'

返回结果:

css 复制代码
{
  "code": 200,
  "message": "success",
  "data": {
    "position": "Java高级开发工程师",
    "requiredSkills": ["Java", "JVM", "多线程", "Spring Boot", "Spring Cloud", "MySQL", "Redis", "Kafka"],
    "preferredSkills": ["Docker", "Kubernetes", "AI/大模型"],
    "minExperience": 5,
    "experienceLevel": "高级",
    "keywords": ["Spring Boot", "Spring Cloud", "微服务", "分布式系统", "Docker", "Kubernetes", "Kafka", "MySQL", "Redis", "JVM", "多线程"],
    "industry": "互联网"
  }
}

输入一段JD文本,直接拿到Java对象,JSON自动映射------这就是Structured Output的魅力。


测试结果与发现

4个接口全部通过,端到端流程也能跑通。贴一组完整流程的结果:

接口 状态 响应时间
JD解析 /api/jd/analyze 2.1s
简历解析 /api/resume/parse 3.3s
匹配分析 /api/match 2.8s
优化重写 /api/optimize 4.1s

但也发现了两个真实问题:

💡 问题1:技能去重------JVM和JVM原理算不算一个技能?

不同运行时LLM的提取结果可能不同------有时提取为"JVM",有时提取为"JVM原理"。

JD里写了"熟悉JVM原理、多线程编程",简历里写了"Java、JVM、多线程"。

匹配分析的时候,LLM把"JVM原理"和"JVM"当成两个不同的技能------missingSkills里出现了"JVM原理",但matchedSkills里已经有"JVM"了。

这不是bug,是语义理解的问题。 对人来说JVM和JVM原理显然是一回事,但LLM做字符串匹配的时候,它确实不一样。

✅ 解法方向:在匹配分析的Prompt里加一条规则------"技能匹配时考虑语义等价,如'JVM'和'JVM原理'、'Spring Cloud'和'微服务'应视为匹配"。阶段2我会做技能标准化映射来彻底解决。

💡 问题2:优化接口过度编造------AI自己加数字

这是个更严重的问题。我输入的简历写的是"负责订单管理系统开发",优化接口给我改成了:

"主导订单管理系统核心模块开发,基于Spring Boot + MyBatis架构,支撑日均订单量10万+,通过Redis缓存优化将接口响应时间从200ms降至50ms。"

看着很漂亮对吧?但这些数字全是AI编的。 原文里根本没有"10万+"、"200ms"、"50ms"这些数据。

简历优化可以润色表达,但绝不能编造数据------这是底线问题。面试官一问就露馅。

✅ 解法方向:在optimize接口的Prompt里加上严格约束------"只能优化表达方式和结构,不能添加原文中没有的具体数字和参数。如果原文缺少量化数据,建议用户补充而非自行编造。"

这两个问题都是Prompt层面可以优化的,不需要改代码架构。也说明了:Structured Output解决了"格式"问题,但"内容质量"还得靠Prompt设计。


总结

这次做resume-ai,Structured Output给我最大的感受是:AI开发终于像写Java代码了。

以前用LLM,拿到一坨字符串,解析、容错、兜底......这些和业务无关的代码占了三分之一。现在定义好record,一行.entity()搞定,专注写业务逻辑就行。

回顾下关键点:

  1. record定义要具体------字段名就是AI的"填空题",写清楚AI才填得对
  2. Prompt要说清楚提取什么------别只说"分析",要说"提取X、Y、Z"
  3. temperature设0.3------Structured Output的稳定性杀手锏
  4. 内容质量靠Prompt------格式对了不等于内容对了,过度编造得靠规则约束

下一步我准备做阶段2------加RAG简历模板库,让优化效果更精准(而不是AI编数字)。到时候再写第5篇。


下篇预告:resume-ai开发实录(2)------RAG模板库实战,让AI优化简历不再"编数字"


相关推荐
东风微鸣1 小时前
Argo CD 用户管理:本地用户配置与权限分离实践
git·后端
Yeats_Liao1 小时前
Java网络编程(五):Selector选择器与高并发实现
java·后端·架构
小小龙学IT2 小时前
Go语言后端开发入门指南
开发语言·后端·golang
土星碎冰机2 小时前
实现飞书群推送报错接口,critical复现curl
后端·飞书
淘源码A2 小时前
专科医院云HIS系统源码:技术栈包括SpringBoot、Angular、MySQL等
spring boot·后端·源码·云his·医院信息系统·医院his系统
小马爱打代码2 小时前
基于 SpringBoot 的微服务文件上传下载组件设计与实现
spring boot·后端
花椒技术3 小时前
AI 代码评审落地实践:GitLab 接入、项目规则与反馈闭环
后端·github·agent
掘金者阿豪3 小时前
Node.js 连接金仓数据库踩坑记(上篇):环境搭建与基础操作
后端