Job Searcher
Job Searcher
Hugging Face 发布 Job Searcher,一个基于 AI 的求职搜索工具。用户上传简历并设定偏好后,系统使用教师模型 DeepSeek V4 Pro 生成 LinkedIn 搜索查询,通过 JobSpy 抓取职位,再对学生模型 Qwen3-8B(8B 参数)进行 LoRA 微调,对每个职位从技能匹配、经验相关性、教育背景、行业领域契合度和资历对齐五个维度给出评分和推理。训练在 Modal 平台单张 A100 上完成。推理部署于 Hugging Face ZeroGPU Space,使用 llama.cpp 实现流式输出。项目开源。
这个 hackathon 项目把教师蒸馏和 LoRA 微调 8B 模型的流程全部开源在 HF 上,做模型定制和部署的开发者能直接抄作业,尤其是推理部署踩的坑(ZeroGPU 上下文重用)很实用。
作为一名应届毕业生,找工作本身就是一份全职工作。你每周要翻阅数百条招聘信息,才能找到少数几个值得投递的岗位。你不停地点“轻松投递”,点到眼睛发酸。你把同一封求职信写了四十遍。到了求职的第二个月,你开始投递那些你本不会接受的职位、那些你毫不在意的行业,因为到那个时候,思考每一条招聘信息的成本,已经高于直接投递一份的成本了。
观看简短演示:上传一份简历,观看查询流式输出,阅读针对每个职位的推理过程。
工作原理
一次运行包含三个步骤。
- 查询。该学生读取简历和你设置的偏好(职位类型、工作模式、地点、自由格式备注),并起草一小组 LinkedIn 风格的搜索查询,同时边思考边说出推理过程。
- 搜索。这些查询通过 JobSpy 逐一访问 LinkedIn。
- 评分。对于每一条招聘信息,模型读取
(resume, job)对,并写出一个五维匹配评分:* 技能匹配 * 经验相关性 * 学历与认证 * 行业 / 领域契合度 * 资历匹配
图 1. 该框架的端到端步骤。
你得到的并不是一份包含五十个职位的列表,而是一份简短且带有可辩护推理的候选名单。你可以看到模型为何认为排名第二的职位优于排名第三的职位。
技术细节
数据集整理——教师与学生
教师是 DeepSeek V4 Pro。它擅长结构化推理,愿意遵循严格的输出模式,且成本足够低,可以离线对大规模语料库运行一次。它被用作标签生成器,而非推理时的依赖项。
学生是 Qwen3-8B。它小到在量化为 Q4_K_M 后可以装进单个 ZeroGPU 切片,又大到足以吸收教师的结构化判断。
该语料库来自一个闭环、感知简历的端到端流程:
- 简历。 2,500 份,基于 Divyaamith/Kaggle-Resume 构建。
- 查询。 教师首先根据每份简历起草了 LinkedIn 风格的搜索查询。
- 职位。 随后 JobSpy 抓取了 LinkedIn 上这些查询实际返回的结果。大约 10,000 条职位发布,每一条都是由教师模型自己针对那份特定简历所写的查询检索出来的。
- 标签。 随后教师模型对每一个生成的
(resume, job)配对,按照推理时所用的同样五个维度进行打分,每个维度附一句话的推理说明。
所有内容都以四种外键干净的配置发布,位于 build-small-hackathon/job-search-distill。
训练(Modal)
通过 Modal 在单块 A100 上运行两次 LoRA SFT,每个任务一次:
- 适配器。 Rank 16,alpha 16,关闭 dropout,注意力加 MLP 投影。
- 调度。 每个任务一个 epoch。每 200 步保存一次 epoch 中途检查点,以便在完整运行结束前对部分运行进行健全性检查。
- 输出。 Safetensors 位于
build-small-hackathon/job-searcher-qwen3-8B,以及一个 Q4_K_M 基础模型加 LoRA-GGUF 附属文件,位于build-small-hackathon/job-searcher-qwen3-8B-gguf,用于 llama.cpp 服务路径。
LoraConfig(
r=16,
lora_alpha=16,
task_type="CAUSAL_LM",
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
)
Space - 推理(llama.cpp)
该 Space 在 HuggingFace ZeroGPU Space 上使用预构建的 CUDA wheel 运行 llama-cpp-python。两个关键的设计选择:
Llama放在@spaces.GPU内部。 ZeroGPU 每次调用都会回收 CUDA 上下文,因此模块级实例在第二次使用时将持有一个已失效的上下文。- 每次提交只调用一次 GPU,而不是每个任务调用一次。 一次提交的所有 fit 评估都在单次
@spaces.GPU调用中运行。模型只加载一次,并为每个任务产出事件,而不必为每条职位信息支付一次全新的冷启动和一次全新的代理 token 请求。
流式输出使用 OpenAI 风格的 create_chat_completion(stream=True),因此推理结果会逐 token 地呈现在 UI 中。在线演示位于 build-small-hackathon/job-search-assistant。
追踪记录
构建这个 Space 的整个 Claude Code 会话已作为 HuggingFace agent-traces 数据集发布在 build-small-hackathon/job-search-assistant-agent-trace。原始 JSONL 事件、原生 HuggingFace trace 查看器,每一次死胡同和恢复过程都记录在案。如果你想看看这东西实际上是如何一步步搭建起来的,而不是读它经过整理后的版本,这会很有用。
试一试
把你的简历投到 huggingface.co/spaces/build-small-hackathon/job-search-assistant。别再逐条筛选了。
我的收获
两个适配器胜过单个。我尝试把查询生成和拟合评估折叠进单个 LoRA。模型在两个方向上都出现了格式泄漏:查询任务上输出 JSON,评估任务上输出散文。把它们拆成同一基座上的两个头,每次调用时热切换,彻底消灭了这一整类 bug。
教师的提示词比学生的规模更重要。重写教师的标注提示词,让它针对具体的简历细节打分(“四年 Rust 经验;该职位要求五年”,而不是“技术匹配度高”),这一改动通过知识蒸馏传递了下去。学生也养成了同样的习惯。
来源:Hugging Face:Blog(RSS) · huggingface.co