# 我们构建的是辅助筛选工具,而非决策者一个本地CV分析流水线读取PDF文件夹和职位说明书,为每位候选人提取可验证的事实(职位、日期、提及的技能、教育背景),将其与招聘方设定的书面标准进行比对,最终生成带有解释的排序结果。与简单的‘AI评分’不同,该流程有两个关键特点:计算在代码中完成,而非在模型内部执行;每个评估标准均关联到候选人简历中的具体段落,招聘方可随时重新查阅。最终输出是一份需审查的候选人排序列表,绝非拒绝名单。
本地执行关闭了一扇门,即候选人数据发送至第三方服务商的通道。但它并未关闭其他风险:模型可能复制偏见、误读列式排版的简历,或虚构技能。因此,本指南同等关注推理流程机制以及保障其合理使用的安全措施。
# 法律框架:已确认的内容与常被夸大之处✓
本地智能体套件
在你的机器上执行操作的智能体:具备智能体能力的 Cline、MCP、n8n + Ollama、本地自动化。
有时会看到这样的说法:自 2024 年起,法国禁止将简历发送给托管式 AI 服务。没有任何法律规定这种一概禁止的做法。真正需要注意的问题更为复杂,而且同样适用于本地运行。首先是《通用数据保护条例》(RGPD)第 22 条:数据主体有权不受完全基于自动化处理(包括画像分析)作出的、对其产生法律效力或重大影响的决定约束。如果没有人真正作出决定,拒绝录用就属于这一范围。
第二项是欧盟《人工智能法案》。其附件 III 将用于人员招聘或选拔的系统列为高风险系统,尤其包括用于分析和筛选求职申请以及评估候选人的系统。时间表已有变动:根据 Bird & Bird 律师事务所的分析,2026 年 5 月 7 日就所谓的 Digital Omnibus 一揽子方案达成的临时协议,将附件 III 中高风险系统相关义务的实施日期推迟至 2027 年 12 月 2 日。在确定合规时间表之前,请查阅《欧盟官方公报》中最终通过的文本:本指南不构成法律意见。
处理流程中需要落实的义务
主题 所需条件 具体落实方式 自动决策(GDPR,第22条) 由人真正作出决定 输出结果是一份待审核的列表,不会自动拒绝候选人。 高风险(《人工智能法案》,附件 III) 风险管理、人工监督、文档记录、可追溯性 输入输出日志、复核流程 告知求职者 告知他们有关数据处理的情况 在招聘启事和招聘政策中说明 最小化 仅处理与岗位相关的必要数据 在将信息交给模型之前,屏蔽联系方式和个人身份信息 Non-discrimination 不采用任何被禁止的筛选标准 已审阅的岗位说明书,定期开展偏差测试
!
两点注意事项
该表格总结了一些原则,而非详尽的义务:数据保存期限、法律依据以及影响评估由您的数据保护官负责。且本地处理并不免除合规义务:无论处理在何处进行,候选人信息的处理仍属于个人数据处理。
# 架构:PDF 读取器、本地模型、强制格式PyMuPDF 这样的 PDF 阅读工具可提取文本。Ollama 负责提供模型服务:拥有 90 亿参数的 Qwen 3.5 大小为 6.6 GB,支持 256,000 个 token 的上下文,可装入 8 GB 显存的显卡;Mistral Small 24B(14 GB)则需要更多内存。Ollama 还可以将回答约束为符合指定 JSON 模式:根据其文档,应将该模式传入 format 字段。这比简单的 JSON 模式更可靠,因为键和类型都受到约束。
一份简历是一个简短的文档:两页纸大约包含几千个标记。因此,问题并不在于上下文窗口,除非您还附加了求职信。真正的难点在于排版,这将在下一步中处理。
# 提取文本并隐藏不必要的内容许多简历采用两栏布局,并在侧栏列出技能和语言能力。这时,PDF 的读取结果取决于文件中文本块的顺序。PyMuPDF 提供一个排序选项,先按纵坐标、再按横坐标排列文本:对于分栏简历,它可能会把两栏的文本行混在一起。默认方式遵循文件中的原始顺序,往往能更好地读取分栏简历。请在您最复杂的简历上测试这两种方式,并保留能生成连贯文本的那一种。
屏蔽与岗位无关的数据属于数据最小化措施:电子邮箱、电话号码、邮寄地址、个人资料链接、出生日期。正则表达式能匹配有规律的格式,却无法找出文本中的名或姓。一种可靠的方法是处理简历的第一行,因为这一行几乎总是包含身份信息,并用档案编号替代个人身份信息。
提取与遮蔽(PyMuPDF) ⧉ 复制
import fitz # PyMuPDF
import re
MOTIFS = [
(r'[\w.+-]+@[\w-]+\.[\w.-]+', '[EMAIL]'),
(r'(?:\+33|0033|0)[\s.-]?[1-9](?:[\s.-]?\d{2}){4}', '[TEL]'),
(r'https?://\S+|www\.\S+', '[LIEN]'),
(r'\b\d{1,2}[/.]\d{1,2}[/.](?:19|20)\d{2}\b', '[DATE]'),
]
def lire_cv(chemin):
doc = fitz.open(chemin)
texte = '\n'.join(page.get_text() for page in doc) # essayez aussi sort=True
if len(texte.strip()) < 200:
raise ValueError('CV sans texte : scan ou image, passer par un OCR')
lignes = texte.split('\n')
lignes[0] = '[IDENTITE]' # la première ligne porte presque toujours le nom
texte = '\n'.join(lignes)
for motif, remplacement in MOTIFS:
texte = re.sub(motif, remplacement, texte)
return textei
不要将隐藏与匿名化混淆
遮蔽身份信息后的简历仍包含身份线索:学校、协会、就学年份、街区。按照 GDPR 的定义,它仍属于个人数据。遮蔽处理减少了信息暴露,也降低了与姓名相关的偏见风险,但并不会使数据处理变成匿名处理。
模型应提取文本中写明的信息,而不是进行计算。像“annees_experience = 工作经历时长之和”这样的指令会导致错误的总数:模型不善于对相互重叠或以月和年表示的时长进行求和。因此,应让模型列出各个职位及其开始和结束日期,再用 Python 合并重叠的时间段并计算总时长。
结构化提取与工作经验计算 ⧉ 复制
import json, requests
from datetime import date
SCHEMA = {'type': 'object', 'properties': {
'competences': {'type': 'array', 'items': {'type': 'string'}},
'langues': {'type': 'array', 'items': {'type': 'object', 'properties': {
'nom': {'type': 'string'}, 'niveau': {'type': ['string', 'null']}}}},
'formation_plus_haute': {'type': ['string', 'null']},
'postes': {'type': 'array', 'items': {'type': 'object', 'properties': {
'titre': {'type': 'string'}, 'entreprise': {'type': ['string', 'null']},
'debut': {'type': ['string', 'null']}, 'fin': {'type': ['string', 'null']}}}}
}, 'required': ['competences', 'postes']}
PROMPT = ('Extrais du CV les informations demandées. Réponds par un JSON conforme à ce schéma : '
+ json.dumps(SCHEMA) + '. Dates au format AAAA-MM ; fin = null si le poste est en cours. '
'Si une information est absente, mets null ou une liste vide. N\'invente rien.\n\nCV :\n')
def extraire(texte, modele='qwen3.5:9b'):
r = requests.post('http://localhost:11434/api/chat', json={
'model': modele, 'stream': False, 'format': SCHEMA,
'messages': [{'role': 'user', 'content': PROMPT + texte}],
'options': {'temperature': 0, 'num_ctx': 8192}})
return json.loads(r.json()['message']['content'])
def mois(s):
a, m = s.split('-')
return int(a) * 12 + int(m)
def annees_experience(postes):
aujourdhui = date.today(); fin_defaut = aujourdhui.year * 12 + aujourdhui.month
plages = []
for p in postes:
try:
d = mois(p['debut']); f = mois(p['fin']) if p.get('fin') else fin_defaut
except (ValueError, KeyError, AttributeError, TypeError):
continue
if f >= d:
plages.append((d, f))
total, courant = 0, None
for d, f in sorted(plages):
if courant is None:
courant = [d, f]
elif d <= courant[1]:
courant[1] = max(courant[1], f)
else:
total += courant[1] - courant[0]; courant = [d, f]
if courant:
total += courant[1] - courant[0]
return round(total / 12, 1)# 对照评估标准:使用评估表,而不是编造分数让模型给出“一个 0 到 100 的分数”,得到的数字看似精确,实际上却并不精确:两次运行的结果可能不同,而且没人知道这个分数衡量的是什么。使用评估表更稳健。招聘人员一次性写好评估标准,区分必备条件和加分条件。对于每项标准,模型只返回三种状态之一:符合、不符合、无相关记载,并摘录简历中作为依据的原句。随后通过代码计算分数,所用规则应当能够向候选人解释清楚。
逐项评估 ⧉ 复制
CRITERES = [
{'id': 'sql', 'libelle': 'Pratique de SQL en contexte professionnel', 'obligatoire': True},
{'id': 'anglais', 'libelle': 'Anglais professionnel', 'obligatoire': False},
{'id': 'encadrement', 'libelle': 'Encadrement d\'une équipe', 'obligatoire': False},
]
SCHEMA_EVAL = {'type': 'array', 'items': {'type': 'object', 'properties': {
'critere': {'type': 'string'},
'statut': {'type': 'string', 'enum': ['satisfait', 'non_satisfait', 'non_documente']},
'preuve': {'type': ['string', 'null']}},
'required': ['critere', 'statut', 'preuve']}}
def evaluer(texte_cv, modele='qwen3.5:9b'):
consigne = ('Pour chaque critère, indique si le CV le satisfait, ne le satisfait pas, ou ne permet pas de savoir. '
'Pour satisfait, recopie mot pour mot la phrase du CV. N\'utilise que le CV. Critères : '
+ json.dumps(CRITERES, ensure_ascii=False))
r = requests.post('http://localhost:11434/api/chat', json={
'model': modele, 'stream': False, 'format': SCHEMA_EVAL,
'messages': [{'role': 'user', 'content': consigne + '\n\nCV :\n' + texte_cv}],
'options': {'temperature': 0, 'num_ctx': 8192}})
return json.loads(r.json()['message']['content'])
def score(evaluation, texte_cv):
par_id = {e['critere']: e for e in evaluation}
a_examiner, points = [], 0
for c in CRITERES:
e = par_id.get(c['id'], {'statut': 'non_documente', 'preuve': None})
ok = e['statut'] == 'satisfait' and e['preuve'] and ' '.join(e['preuve'].split()) in ' '.join(texte_cv.split())
if ok:
points += 2 if c['obligatoire'] else 1
elif c['obligatoire']:
a_examiner.append(c['id'])
return points, a_examiner这套方案有三个优点。证据由程序核验:模型声称引用、但简历中并不存在的句子不计入评分。排名可以复现,因为分数由公式计算得出。而未找到某项必备条件的证据,也不会导致候选人被淘汰:系统会向招聘人员标记这一情况,由招聘人员阅读简历,因为相关信息可能只是采用了不同的表述。
# 生成列表并留存记录最终列表按得分对候选人排序,同时将某项必备条件的评估结果为“待审查”的候选人置于列表最前面。请为每份简历保存一条注明日期的记录:标识符、模型版本、所用评估标准和原始输出。这些记录可用于回答候选人关于自己如何被评估的询问,并证明确实实施了人工监督;被归类为高风险的招聘系统必须具备这种监督。
清单与审计日志 ⧉ 复制
from pathlib import Path
from datetime import datetime
def traiter(dossier, sortie='audit.jsonl'):
lignes = []
for chemin in sorted(Path(dossier).glob('*.pdf')):
try:
texte = lire_cv(str(chemin))
faits = extraire(texte)
evaluation = evaluer(texte)
points, a_examiner = score(evaluation, texte)
except Exception as e:
lignes.append({'fichier': chemin.name, 'erreur': str(e)}); continue
lignes.append({'fichier': chemin.name, 'points': points, 'a_examiner': a_examiner,
'experience_ans': annees_experience(faits['postes']),
'evaluation': evaluation, 'date': datetime.now().isoformat(),
'modele': 'qwen3.5:9b'})
with open(sortie, 'a', encoding='utf-8') as f:
for l in lignes:
f.write(json.dumps(l, ensure_ascii=False) + '\n')
return sorted((l for l in lignes if 'points' in l), key=lambda l: (-l['points'], l['fichier']))!
无自动拒绝
脚本不会淘汰任何人,也不会发送任何消息。未排在名单前列的候选人仍可供查阅:招聘人员必须能够重新审阅他们的资料,至少进行抽样复核。这正是决策辅助与自动化决策的区别。
# 评估偏见,而不是假定偏见不存在语言模型在处理简历时存在偏见,这一点已有研究记录。华盛顿大学于 2024 年 10 月介绍的一项研究,在超过 550 份真实简历中替换了与白人、黑人、男性和女性相关联的名字:受测模型在 85% 的情况下偏向与白人相关联的名字,只有 11% 的情况下偏向与女性相关联的名字,而且从未优先选择与黑人男性相关联的名字,而不是与白人男性相关联的名字。这些模型是排序系统,并非您将运行的模型;这里的启示是,偏见可能存在,却无人察觉。
必须采取两项措施。第一项是置换测试:选取几十份简历,将其中的名字替换为与其他背景或另一性别相关联的名字,然后比较结果。在不做信息遮蔽的情况下,差异必须为零;否则,该处理流程就无法投入使用。第二项是跟踪结果:如果您能在筛选后重新划分类别,就比较各类的初筛入选率。对于无法解释的差异,应通过修订岗位说明和评估表来处理。
替换名字测试 ⧉ 复制
def ecart_permutation(texte_cv, prenom, autres_prenoms):
base = score(evaluer(texte_cv), texte_cv)[0]
ecarts = []
for p in autres_prenoms:
variante = texte_cv.replace(prenom, p)
ecarts.append(score(evaluer(variante), variante)[0] - base)
return ecarts # doit rester à zéro si l'identité n'influence pas le résultat# 确认该工具能节省时间针对一个已经招到人的岗位,取约三十份简历,将工具给出的排名与招聘人员给出的排名进行比较。统计招聘人员选中的候选人有多少排在工具列表的前列,以及哪些优秀候选人被工具排得过低。如果被排除的优秀候选人太多,应先修正评分表,再扩大使用范围。最常见的原因是某项标准表述不当。
# 实践中行不通的情况
创意简历与扫描件 图形化排版或扫描版简历能提取出的文本很有限。脚本会拒绝处理不含文本的文件,并明确提示这一情况,而不是盲目评估。
隐含能力 候选人可能熟练掌握某个工具,却没有提及其名称。“未记载”状态正是为此设置的:它会触发人工审阅,而不是直接淘汰候选人。
语言与海外经历 处理多语言简历或涉及学历等同认定的简历时,质量会下降。请专门测试这些简历。
职位说明中的偏差 模糊或过时的标准(如“充满活力的初级人才”)会引入年龄偏见。将这些标准输入模型前,请重新审阅。
过度信任 排名会让人感到放心,并使招聘人员倾向于不再复核简历。请衡量招聘人员实际复核的简历比例。 FAQ
在法国能否用 AI 对简历进行筛选? + 可以,但有条件。GDPR 禁止仅基于自动化处理作出决策,欧盟人工智能法规将此类工具归类为高风险系统。必须告知候选人,尽量减少所用数据,确保由人真正作出决定,并记录数据处理活动。法律并不禁止人工智能辅助筛选本身。
向ChatGPT发送简历是否违法? + 并不存在全面禁止此类行为的规定,但这属于向服务提供商传输个人数据,需要具备合法依据、告知候选人、签订数据处理委托合同,而且通常还需要进行影响评估。本地处理可以避免这类数据传输,但并不免除其他义务:告知候选人、遵循数据最小化原则,并让人参与决策。
本地使用哪个模型来分析简历? + 像 Qwen 3.5 9B 这样的 90 亿参数模型(6.6 GB,宣称支持 256,000 个 token),在 8 GB 显存的显卡上就足以从简历中提取事实。Mistral Small 24B(14 GB)需要更多内存。请使用本指南中的评分表和顺序置换测试,在您自己的简历材料上比较这两个模型。
为什么不让模型按满分 100 分来评分? + 因为这个数值没有明确的定义:每次运行都可能不同,也无法向候选人解释其依据。使用一份评估标准表,让模型仅判断各项标准“满足”“不满足”或“无相关记录”,并引用证据,就能计算出可复现且可以解释的分数。
如何验证模型不存在歧视行为? + 进行名字替换测试:将几十份简历中的名字替换为代表不同性别和出身背景的名字,并比较结果;结果应保持一致。随后,在将简历交给模型之前隐藏身份信息;如果能够还原各类别的预筛选率,再持续跟踪这些比率。出现无法解释的差异时,必须重新审视这套机制。
简历筛选能节省多少时间? + 这取决于您的数据量,无法预测:需实际测量。在数十份已有岗位的简历中,对比工具与招聘方的排序结果,并记录输出内容的复读时间。若复读理由所耗时间几乎与阅读简历相当,则收益甚微。
这份指南对您有帮助吗?
有反馈、发现了错误,或想补充说明?请告诉我们,让这份指南对每个人都更有帮助。
👍 有用 👎 不清楚 ✏️ 报告错误