第2085篇:AI在招聘场景的工程实践——简历解析、岗位匹配与面试辅助
2026/4/30大约 12 分钟
第2085篇:AI在招聘场景的工程实践——简历解析、岗位匹配与面试辅助
适读人群:正在为HR系统集成AI能力的工程师 | 阅读时长:约19分钟 | 核心价值:掌握简历结构化解析、岗位语义匹配、AI面试辅助的技术实现,以及如何避免AI招聘的偏见问题
去年帮一个招聘平台做AI改造,最让我印象深刻的不是技术挑战,而是HR团队的反馈:原来他们每天要看100+份简历,筛选工作占了60%的时间,留给候选人深度沟通的时间反而很少。AI介入之后,这个比例倒过来了。
这篇文章把整个系统的实现思路讲清楚,重点是工程细节。
系统架构
简历解析:从非结构化到结构化
/**
* 简历结构化解析
* 支持多种格式,输出统一的候选人数据模型
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ResumeParser {
private final ChatLanguageModel llm;
private final ObjectMapper objectMapper;
/**
* 解析简历,提取结构化信息
*
* 注意:LLM解析简历的准确率通常在90-95%
* 对于关键信息(联系方式、工作年限),建议同时用规则验证
*/
public ResumeProfile parseResume(String resumeText) {
log.info("开始解析简历,长度: {}字符", resumeText.length());
// 截断过长的简历(超过8000字符的简历通常有噪声)
String processedText = resumeText.length() > 8000
? resumeText.substring(0, 8000)
: resumeText;
String prompt = String.format("""
请从以下简历文本中提取结构化信息,以JSON格式输出。
提取规则:
1. 信息必须来自简历原文,不要推断或补充
2. 时间格式统一为 YYYY-MM,如不明确则输出null
3. 技能标签只提取明确提到的技术栈
4. 工作经历按时间倒序排列
简历文本:
%s
请输出如下JSON结构:
{
"basicInfo": {
"name": "姓名",
"phone": "手机号",
"email": "邮箱",
"location": "所在地",
"age": null,
"gender": null
},
"education": [
{
"school": "学校名称",
"major": "专业",
"degree": "学历(本科/硕士/博士/专科)",
"startDate": "YYYY-MM",
"endDate": "YYYY-MM",
"gpa": null
}
],
"workExperience": [
{
"company": "公司名称",
"title": "职位名称",
"startDate": "YYYY-MM",
"endDate": "YYYY-MM或至今",
"description": "工作内容描述",
"achievements": ["成就1", "成就2"]
}
],
"skills": {
"programmingLanguages": ["Java", "Python"],
"frameworks": ["Spring Boot", "React"],
"databases": ["MySQL", "Redis"],
"tools": ["Git", "Docker"],
"certifications": []
},
"projects": [
{
"name": "项目名称",
"role": "担任角色",
"description": "项目描述",
"techStack": ["技术1", "技术2"],
"startDate": null,
"endDate": null
}
],
"summary": "个人简介或自我评价(原文)",
"totalYearsOfExperience": 5
}
只输出JSON,不要其他内容:
""", processedText);
try {
String response = llm.generate(prompt).trim();
String json = extractJsonFromResponse(response);
ResumeProfile profile = objectMapper.readValue(json, ResumeProfile.class);
// 用规则验证和补充关键字段
enrichWithRules(profile, processedText);
log.info("简历解析完成: name={}, experience={}年",
profile.getBasicInfo() != null ? profile.getBasicInfo().getName() : "未知",
profile.getTotalYearsOfExperience());
return profile;
} catch (Exception e) {
log.error("简历解析失败: {}", e.getMessage());
throw new ResumeParseException("简历解析失败,请检查简历格式", e);
}
}
/**
* 用规则验证和补充LLM解析结果
* 规则比LLM更可靠,用于关键字段的验证
*/
private void enrichWithRules(ResumeProfile profile, String text) {
if (profile.getBasicInfo() == null) return;
// 验证手机号格式
if (profile.getBasicInfo().getPhone() != null) {
String phone = profile.getBasicInfo().getPhone().replaceAll("\\s|-", "");
if (!phone.matches("1[3-9]\\d{9}")) {
log.warn("手机号格式可能有误: {}", phone);
// 尝试用正则从原文再提取一次
Pattern phonePattern = Pattern.compile("1[3-9]\\d{9}");
Matcher matcher = phonePattern.matcher(text);
if (matcher.find()) {
profile.getBasicInfo().setPhone(matcher.group());
}
}
}
// 计算工作年限(如果LLM没算准)
if (profile.getTotalYearsOfExperience() == null &&
profile.getWorkExperience() != null &&
!profile.getWorkExperience().isEmpty()) {
int years = calculateTotalExperience(profile.getWorkExperience());
profile.setTotalYearsOfExperience(years);
}
}
private int calculateTotalExperience(List<WorkExperience> experiences) {
// 计算不重叠的工作时间段总长度
// 简化实现:用最早的开始时间到最晚的结束时间
LocalDate earliest = experiences.stream()
.map(e -> parseDate(e.getStartDate()))
.filter(Objects::nonNull)
.min(LocalDate::compareTo)
.orElse(null);
if (earliest == null) return 0;
long months = ChronoUnit.MONTHS.between(earliest, LocalDate.now());
return (int) (months / 12);
}
private LocalDate parseDate(String dateStr) {
if (dateStr == null) return null;
try {
return YearMonth.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM"))
.atDay(1);
} catch (Exception e) {
return null;
}
}
private String extractJsonFromResponse(String response) {
int start = response.indexOf('{');
int end = response.lastIndexOf('}');
if (start >= 0 && end > start) {
return response.substring(start, end + 1);
}
throw new IllegalStateException("响应中没有找到JSON: " + response.substring(0,
Math.min(100, response.length())));
}
}岗位JD解析
/**
* 岗位描述(JD)解析
* 提取岗位的技术要求、软性要求、工作内容
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class JobDescriptionParser {
private final ChatLanguageModel llm;
private final ObjectMapper objectMapper;
public JobProfile parseJobDescription(String jdText, String jobTitle) {
String prompt = String.format("""
解析以下招聘JD,提取结构化信息:
职位名称:%s
JD内容:
%s
输出JSON:
{
"jobTitle": "职位名称",
"department": "部门(如有)",
"location": "工作地点",
"salaryRange": "薪资范围(如有)",
"requirements": {
"mustHave": {
"experienceYears": 3,
"education": "本科",
"programmingLanguages": ["Java"],
"frameworks": ["Spring Boot"],
"databases": ["MySQL"],
"tools": [],
"domainKnowledge": []
},
"niceToHave": {
"skills": [],
"experience": []
}
},
"responsibilities": ["职责1", "职责2"],
"jobLevel": "初级/中级/高级/专家",
"keywords": ["搜索关键词1", "关键词2"]
}
只输出JSON:
""", jobTitle, jdText);
try {
String response = llm.generate(prompt).trim();
String json = extractJson(response);
return objectMapper.readValue(json, JobProfile.class);
} catch (Exception e) {
log.error("JD解析失败: {}", e.getMessage());
throw new JdParseException("JD解析失败", e);
}
}
private String extractJson(String text) {
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
return start >= 0 && end > start ? text.substring(start, end + 1) : "{}";
}
}语义匹配:JD与简历的多维度对比
/**
* 简历-岗位语义匹配
* 多维度计算匹配分数,而不是简单的向量相似度
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ResumeJobMatcher {
private final EmbeddingModel embeddingModel;
private final ChatLanguageModel llm;
/**
* 计算候选人与岗位的匹配度
* 返回多维度分数,便于HR理解
*/
public MatchResult calculateMatch(ResumeProfile candidate, JobProfile job) {
// 1. 技能匹配(规则计算,准确且快速)
SkillMatchScore skillScore = calculateSkillMatch(candidate, job);
// 2. 经验年限匹配
ExperienceScore expScore = calculateExperienceMatch(candidate, job);
// 3. 教育背景匹配
double educationScore = calculateEducationMatch(candidate, job);
// 4. 语义内容匹配(向量相似度)
double semanticScore = calculateSemanticMatch(candidate, job);
// 5. 综合评分
double overallScore =
skillScore.score() * 0.40 +
expScore.score() * 0.25 +
educationScore * 0.15 +
semanticScore * 0.20;
// 6. 生成匹配摘要(给HR看的)
String matchSummary = generateMatchSummary(candidate, job, overallScore, skillScore);
return MatchResult.builder()
.candidateName(candidate.getBasicInfo() != null ?
candidate.getBasicInfo().getName() : "未知")
.jobTitle(job.getJobTitle())
.overallScore(overallScore)
.skillScore(skillScore)
.experienceScore(expScore)
.educationScore(educationScore)
.semanticScore(semanticScore)
.matchSummary(matchSummary)
.recommendation(getRecommendation(overallScore))
.build();
}
private SkillMatchScore calculateSkillMatch(ResumeProfile candidate, JobProfile job) {
if (candidate.getSkills() == null || job.getRequirements() == null) {
return new SkillMatchScore(0, List.of(), List.of());
}
// 收集候选人所有技能
Set<String> candidateSkills = new HashSet<>();
if (candidate.getSkills().getProgrammingLanguages() != null) {
candidateSkills.addAll(normalizeSkills(
candidate.getSkills().getProgrammingLanguages()));
}
if (candidate.getSkills().getFrameworks() != null) {
candidateSkills.addAll(normalizeSkills(
candidate.getSkills().getFrameworks()));
}
if (candidate.getSkills().getDatabases() != null) {
candidateSkills.addAll(normalizeSkills(
candidate.getSkills().getDatabases()));
}
// 岗位必须技能
List<String> requiredSkills = new ArrayList<>();
JobProfile.Requirements req = job.getRequirements();
if (req.getMustHave() != null) {
if (req.getMustHave().getProgrammingLanguages() != null) {
requiredSkills.addAll(normalizeSkills(
req.getMustHave().getProgrammingLanguages()));
}
if (req.getMustHave().getFrameworks() != null) {
requiredSkills.addAll(normalizeSkills(
req.getMustHave().getFrameworks()));
}
}
if (requiredSkills.isEmpty()) {
return new SkillMatchScore(0.8, List.of(), List.of()); // 无要求则给个中等分
}
// 计算命中和缺失
List<String> matched = requiredSkills.stream()
.filter(s -> isSkillMatched(s, candidateSkills))
.toList();
List<String> missing = requiredSkills.stream()
.filter(s -> !isSkillMatched(s, candidateSkills))
.toList();
double score = (double) matched.size() / requiredSkills.size();
return new SkillMatchScore(score, matched, missing);
}
/**
* 技能名称标准化(处理大小写、缩写等)
*/
private List<String> normalizeSkills(List<String> skills) {
return skills.stream()
.map(s -> s.toLowerCase()
.replace("springboot", "spring boot")
.replace("spring-boot", "spring boot")
.replace("postgresql", "postgres")
.replace("js", "javascript"))
.collect(Collectors.toList());
}
/**
* 模糊技能匹配(处理同义词)
*/
private boolean isSkillMatched(String required, Set<String> candidateSkills) {
if (candidateSkills.contains(required)) return true;
// 处理包含关系(如 "spring boot" 包含在 "spring boot 框架" 中)
return candidateSkills.stream()
.anyMatch(s -> s.contains(required) || required.contains(s));
}
private ExperienceScore calculateExperienceMatch(ResumeProfile candidate, JobProfile job) {
Integer candidateYears = candidate.getTotalYearsOfExperience();
Integer requiredYears = job.getRequirements() != null &&
job.getRequirements().getMustHave() != null
? job.getRequirements().getMustHave().getExperienceYears() : null;
if (candidateYears == null || requiredYears == null) {
return new ExperienceScore(0.7, candidateYears, requiredYears, "");
}
String assessment;
double score;
if (candidateYears >= requiredYears) {
score = 1.0;
assessment = "满足要求(" + candidateYears + "年经验,要求" + requiredYears + "年)";
} else if (candidateYears >= requiredYears * 0.7) {
score = 0.7;
assessment = "基本满足(" + candidateYears + "年经验,稍低于要求" + requiredYears + "年)";
} else {
score = 0.3;
assessment = "不满足(" + candidateYears + "年经验,要求" + requiredYears + "年)";
}
return new ExperienceScore(score, candidateYears, requiredYears, assessment);
}
private double calculateEducationMatch(ResumeProfile candidate, JobProfile job) {
String requiredEducation = job.getRequirements() != null &&
job.getRequirements().getMustHave() != null
? job.getRequirements().getMustHave().getEducation() : null;
if (requiredEducation == null || candidate.getEducation() == null ||
candidate.getEducation().isEmpty()) {
return 0.7; // 信息不足,给中等分
}
// 找最高学历
String highestDegree = candidate.getEducation().stream()
.map(Education::getDegree)
.filter(Objects::nonNull)
.max(Comparator.comparingInt(this::degreeLevel))
.orElse(null);
if (highestDegree == null) return 0.7;
int candidateLevel = degreeLevel(highestDegree);
int requiredLevel = degreeLevel(requiredEducation);
if (candidateLevel >= requiredLevel) return 1.0;
if (candidateLevel == requiredLevel - 1) return 0.6;
return 0.3;
}
private int degreeLevel(String degree) {
if (degree == null) return 0;
return switch (degree.trim()) {
case "博士" -> 4;
case "硕士" -> 3;
case "本科" -> 2;
case "专科" -> 1;
default -> 0;
};
}
private double calculateSemanticMatch(ResumeProfile candidate, JobProfile job) {
// 构建候选人的综合描述文本
String candidateText = buildCandidateText(candidate);
String jobText = buildJobText(job);
float[] candidateEmbedding = embeddingModel.embed(candidateText);
float[] jobEmbedding = embeddingModel.embed(jobText);
return cosineSimilarity(candidateEmbedding, jobEmbedding);
}
private String buildCandidateText(ResumeProfile candidate) {
StringBuilder sb = new StringBuilder();
if (candidate.getSkills() != null) {
sb.append("技能: ");
if (candidate.getSkills().getProgrammingLanguages() != null) {
sb.append(String.join(", ", candidate.getSkills().getProgrammingLanguages()));
}
}
if (candidate.getWorkExperience() != null) {
candidate.getWorkExperience().stream()
.limit(3)
.forEach(exp -> {
sb.append("\n").append(exp.getTitle()).append(" at ").append(exp.getCompany());
if (exp.getDescription() != null) {
sb.append(": ").append(exp.getDescription());
}
});
}
return sb.toString();
}
private String buildJobText(JobProfile job) {
StringBuilder sb = new StringBuilder();
sb.append(job.getJobTitle()).append("\n");
if (job.getResponsibilities() != null) {
sb.append(String.join("\n", job.getResponsibilities()));
}
if (job.getKeywords() != null) {
sb.append("\n").append(String.join(", ", job.getKeywords()));
}
return sb.toString();
}
private double cosineSimilarity(float[] a, float[] b) {
double dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
private String generateMatchSummary(ResumeProfile candidate, JobProfile job,
double overall, SkillMatchScore skillScore) {
String prompt = String.format("""
基于以下匹配结果,用2-3句话总结候选人与岗位的匹配情况:
岗位:%s
候选人:%s(%d年经验)
综合匹配度:%.0f%%
技能匹配:满足 %d/%d 项必需技能
缺失技能:%s
总结要点突出,便于HR快速判断,不超过100字:
""",
job.getJobTitle(),
candidate.getBasicInfo() != null ? candidate.getBasicInfo().getName() : "候选人",
candidate.getTotalYearsOfExperience() != null ?
candidate.getTotalYearsOfExperience() : 0,
overall * 100,
skillScore.matchedSkills().size(),
skillScore.matchedSkills().size() + skillScore.missingSkills().size(),
skillScore.missingSkills().isEmpty() ? "无" :
String.join("、", skillScore.missingSkills())
);
return llm.generate(prompt).trim();
}
private String getRecommendation(double score) {
if (score >= 0.85) return "强烈推荐";
if (score >= 0.70) return "推荐";
if (score >= 0.55) return "可考虑";
return "不推荐";
}
public record SkillMatchScore(double score, List<String> matchedSkills,
List<String> missingSkills) {}
public record ExperienceScore(double score, Integer candidateYears,
Integer requiredYears, String assessment) {}
@Builder
public record MatchResult(
String candidateName, String jobTitle,
double overallScore, SkillMatchScore skillScore,
ExperienceScore experienceScore, double educationScore,
double semanticScore, String matchSummary, String recommendation
) {}
}AI面试题生成
/**
* 基于候选人简历和岗位要求生成定制化面试题
* 不是通用题库,而是针对具体候选人的有针对性的问题
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class InterviewQuestionGenerator {
private final ChatLanguageModel llm;
/**
* 生成面试题集
* 覆盖技术深度、项目经验、行为素质三个维度
*/
public InterviewQuestionSet generateQuestions(
ResumeProfile candidate,
JobProfile job,
MatchResult matchResult) {
// 1. 针对匹配薄弱点的技术问题
List<String> techQuestions = generateTechQuestions(candidate, job, matchResult);
// 2. 基于项目经历的深度问题
List<String> projectQuestions = generateProjectQuestions(candidate);
// 3. 岗位特定的行为面试题
List<String> behaviorQuestions = generateBehaviorQuestions(job);
return new InterviewQuestionSet(
candidate.getBasicInfo() != null ? candidate.getBasicInfo().getName() : "候选人",
job.getJobTitle(),
techQuestions,
projectQuestions,
behaviorQuestions
);
}
private List<String> generateTechQuestions(ResumeProfile candidate,
JobProfile job, MatchResult matchResult) {
// 针对缺失技能出考察题
List<String> missingSkills = matchResult.skillScore().missingSkills();
String skillContext = "";
if (!missingSkills.isEmpty()) {
skillContext = "候选人缺少以下技能,请重点考察相关基础:" +
String.join("、", missingSkills) + "\n";
}
// 候选人声称掌握的核心技能,也要深度考察
String candidateSkills = "";
if (candidate.getSkills() != null &&
candidate.getSkills().getProgrammingLanguages() != null) {
candidateSkills = "候选人声称掌握:" +
String.join("、", candidate.getSkills().getProgrammingLanguages()
.subList(0, Math.min(3,
candidate.getSkills().getProgrammingLanguages().size())));
}
String prompt = String.format("""
为一个%s岗位的面试生成5个技术面试题。
岗位核心技术要求:%s
%s
%s
要求:
1. 问题有层次,包含基础概念和实际应用
2. 问题具体,不要过于宽泛
3. 针对%s岗位的实际工作场景
4. 每个问题一行,用数字编号
面试题:
""",
job.getJobTitle(),
job.getRequirements() != null &&
job.getRequirements().getMustHave() != null &&
job.getRequirements().getMustHave().getFrameworks() != null
? String.join("、", job.getRequirements().getMustHave().getFrameworks())
: "相关技术",
skillContext,
candidateSkills,
job.getJobTitle()
);
String response = llm.generate(prompt);
return parseNumberedList(response);
}
private List<String> generateProjectQuestions(ResumeProfile candidate) {
if (candidate.getWorkExperience() == null ||
candidate.getWorkExperience().isEmpty()) {
return List.of("请介绍一下您做过的最有挑战性的项目");
}
// 取最近一段工作经历
WorkExperience recentExp = candidate.getWorkExperience().get(0);
String prompt = String.format("""
基于以下工作经历,生成3个深度追问题,用于了解候选人在该项目的真实贡献和技术深度:
工作经历:
公司:%s
职位:%s
描述:%s
要求:
1. 问题要能区分候选人是核心参与者还是边缘参与者
2. 问题涉及具体的技术决策和遇到的困难
3. 每个问题一行,用数字编号
追问题:
""",
recentExp.getCompany(),
recentExp.getTitle(),
recentExp.getDescription() != null ?
recentExp.getDescription().substring(0,
Math.min(300, recentExp.getDescription().length())) : "未提供"
);
return parseNumberedList(llm.generate(prompt));
}
private List<String> generateBehaviorQuestions(JobProfile job) {
String prompt = String.format("""
为%s岗位生成3个行为面试题(STAR格式),考察候选人的软实力:
岗位主要职责:%s
要求:
1. 问题以"请描述一个..."或"举例说明..."开头
2. 问题针对该岗位实际工作中的挑战场景
3. 每个问题一行,用数字编号
行为面试题:
""",
job.getJobTitle(),
job.getResponsibilities() != null
? String.join(";", job.getResponsibilities()
.subList(0, Math.min(3, job.getResponsibilities().size())))
: "相关工作"
);
return parseNumberedList(llm.generate(prompt));
}
private List<String> parseNumberedList(String text) {
return Arrays.stream(text.split("\n"))
.map(String::trim)
.filter(line -> line.matches("^\\d+[.、。].*"))
.map(line -> line.replaceFirst("^\\d+[.、。]\\s*", ""))
.filter(line -> !line.isEmpty())
.toList();
}
public record InterviewQuestionSet(
String candidateName, String jobTitle,
List<String> techQuestions,
List<String> projectQuestions,
List<String> behaviorQuestions
) {}
}偏见防范
这是AI招聘最容易被忽视但最重要的环节:
/**
* 招聘偏见检测和防范
* AI辅助招聘必须有这个模块,否则会放大系统性偏见
*/
@Component
@Slf4j
public class BiasMitigationService {
// 需要从评分中屏蔽的个人信息字段
private static final List<String> SENSITIVE_FIELDS = List.of(
"age", "gender", "photo", "maritalStatus", "hometown"
);
/**
* 在进行匹配评分前,脱敏候选人个人信息
* 确保AI看不到可能引入偏见的信息
*/
public ResumeProfile anonymizeForScoring(ResumeProfile profile) {
if (profile.getBasicInfo() == null) return profile;
// 脱敏处理:只保留专业信息
ResumeProfile.BasicInfo anonymized = new ResumeProfile.BasicInfo();
anonymized.setName("候选人"); // 用通用称谓替代真实姓名
// 注意:phone、email也不传给评分系统
ResumeProfile anonymizedProfile = new ResumeProfile();
anonymizedProfile.setBasicInfo(anonymized);
anonymizedProfile.setEducation(profile.getEducation());
anonymizedProfile.setWorkExperience(profile.getWorkExperience());
anonymizedProfile.setSkills(profile.getSkills());
anonymizedProfile.setProjects(profile.getProjects());
anonymizedProfile.setTotalYearsOfExperience(profile.getTotalYearsOfExperience());
// 不复制summary(可能包含性别、年龄等信息)
return anonymizedProfile;
}
/**
* 检测AI生成的匹配报告是否包含偏见性内容
*/
public BiasCheckResult checkMatchSummaryBias(String matchSummary) {
List<Pattern> biasPatterns = List.of(
Pattern.compile("年轻|年纪|资历老"),
Pattern.compile("女性|男性|她|他的家庭"),
Pattern.compile("口音|外地|本地人"),
Pattern.compile("颜值|外貌|形象好")
);
List<String> biasedPhrases = new ArrayList<>();
for (Pattern pattern : biasPatterns) {
Matcher matcher = pattern.matcher(matchSummary);
if (matcher.find()) {
biasedPhrases.add(matcher.group());
}
}
if (!biasedPhrases.isEmpty()) {
log.warn("检测到可能的偏见性内容: {}", biasedPhrases);
}
return new BiasCheckResult(biasedPhrases.isEmpty(), biasedPhrases);
}
public record BiasCheckResult(boolean clean, List<String> biasedPhrases) {}
}AI招聘系统的核心价值是提升筛选效率,但它必须只是辅助工具,最终决策权在HR。匹配分数和面试题都是参考,不是判决。
特别要注意偏见放大问题——如果历史录用数据本身有偏见(比如某公司历史上很少录用某类候选人),用这些数据训练的模型会把偏见放大。脱敏评分、偏见检测、定期审计,这三件事做到位才算负责任的AI招聘。
