大家好,我是你们熟悉的技术博主。最近在整理求职工具链时,看到不少开发者讨论“AI 申请层”这个概念,像 ApplyWise 这类应用,本质上不是用 AI 自动把所有岗位投一遍,而是把求职申请中最耗时间的“阅读理解、内容定制、信息管理”等环节,抽象成一个可供系统调用的 AI 层。
这篇文章我想换一个角度来写:不评价某个具体产品的效果,而是从工程实现出发,完整拆解一个“求职申请 AI 层”需要哪些模块、如何设计数据结构、如何写可维护的代码,以及落地时最容易踩的坑。无论你打算用 Claude、GPT 还是开源本地模型,核心思路都可以复用。
1. 为什么求职申请需要单独加一层 AI
1.1 求职申请的流程并不只是“投简历”
很多人会把求职工具简单理解为“批量发送简历”,但真正认真写过简历、准备过面试的人都知道,一次高质量职位申请通常包含以下步骤:
- 阅读职位描述,理解岗位职责和硬性要求。
- 对比自己的技能树,找出匹配点和待补充项。
- 针对职位调整简历中的项目描述、技术栈顺序、关键词。
- 撰写一封不套模板的求职信或开场介绍。
- 预判 HR 或技术面试会关注的问题。
- 记录每家公司的进展状态,避免遗漏。
这些步骤有个共同特点:重复性强、模板化程度高,但每家公司细节又不同。以前只能靠人肉复制粘贴,现在 AI 时代最合适的做法,就是做一个介于“大模型能力”和“业务数据”之间的 AI Layer。
1.2 AI Layer 的职责边界是什么
很多试用过 AI 聊天工具的求职者会觉得,直接把自己的简历复制给对话窗口,再说一句“帮我根据 JD 改简历”就够了。现实中这种用法效果很不稳定,原因有三个:
- 上下文窗口有限,简历很长、JD 可能还包括多条文化要求,一次对话塞太多内容,模型容易丢失前面的信息。
- 没有结构化数据管理,用户每换一个职位都要重新粘贴一遍简历,无法积累。
- 没有流程编排,真正的申请需要多次读写文件、保存版本、调整语气,单纯靠对话无法完成。
AI Layer 解决的不是生成能力,而是把不稳定的“对话式修改”变成稳定的“函数式调用”。简单说:上层是用户界面或定时任务,下层是大模型 API,中间这层负责解析输入、组装提示词、调用模型、校验输出、清洗格式、保存结果。这个分类就是 AI 应用工程实践的第一步。
1.3 常见应用场景
从实际使用看,求职申请 AI 层至少覆盖以下功能:
- 简历解析:从 PDF 或 Markdown 中提取教育经历、工作经历、技能列表。
- JD 解析:提取岗位硬技能、加分项、团队介绍、职责关键词。
- 匹配分析:结合 JD 和简历,给出岗位契合度报告。
- 材料生成:输出定制版简历摘要、个人简介、求职信、作品集说明。
- 面试预测:根据 JD 生成可能的面试问题与回答思路。
- 申请状态管理:将每一阶段的 AI 输出归档到本地,方便追溯。
你会发现这些场景并不需要模型拥有“强推理”能力,核心是保证输入信息不丢失、输出格式可控。这就是工程问题,而不是提示词玄学。
2. 整体架构怎么设计
2.1 先画一条清晰的调用链路
在写代码之前,我建议把所有功能抽象成一条流水线。假设用户现在要做一次申请,流程可以是:
- 读取用户的简历文件,解析成结构化 JSON。
- 读取目标公司的职位 JD,解析成结构化 JSON。
- 将两份 JSON 交给“匹配分析 Agent”。
- 根据匹配结果,调用“简历优化器”生成新简历 Markdown。
- 调用“求职信生成器”生成一段 cover letter。
- 将生成的文档保存到按公司名命名的目录下。
- 输出一份申请摘要,方便用户核对。
这条链路看似简单,却已经包含了完整的 AI Agent 雏形。用户在最终确认前,可以检查每一步的输出内容。
2.2 模块划分建议
从工程上,我通常把 AI Layer 分成以下模块:
- storage:本地文件存储、JSON 读写、版本管理。
- parsers:简历解析器、JD 解析器。
- schemas:统一的数据模型,比如简历模型、岗位模型、匹配报告模型。
- core:核心 Agent,负责调用大模型完成匹配、生成、改写。
- prompts:所有提示词模板独立存放,方便调优。
- runners:命令行入口、Web API 入口或定时任务入口。
这样设计的好处是:替换底层模型时,只需要改 core 模块;提示词优化时,不需要动业务代码。
3. 环境准备与项目结构
3.1 运行环境
本文的示例代码以 Python 为主,建议环境如下:
- Python 3.10 或以上版本。
- 安装 openai 或同类 SDK,用于调用大模型 API。
- pydantic,用于做结构化数据校验。
- python-dotenv,用于管理 API Key 环境变量。
- 可选:fastapi 或 flask,用于把 AI Layer 包装成 Web 服务。
不同模型的 API 地址和参数会随版本变化,这里不写死某一家。通用的做法是配置 MODEL_API_BASE 和 MODEL_NAME 两个环境变量,方便切换。
3.2 创建项目目录
mkdir applywise-demo cd applywise-demo mkdir -p data/resumes data/jds data/output applywise/{core,parsers,prompts,schemas,storage,utils}目录说明:
- data/resumes:存放简历原始文件。
- data/jds:存放职位描述。
- data/output:存放最终的生成结果。
- applywise/core:核心逻辑。
- applywise/parsers:文件解析。
- applywise/prompts:提示词。
- applywise/schemas:数据模型。
- applywise/storage:本地文件读写。
- applywise/utils:通用工具。
3.3 初始化 Python 依赖文件
在项目根目录创建 requirements.txt:
openai>=1.0.0 pydantic>=2.0.0 python-dotenv>=1.0.0 markdown>=3.4.0 pypdf>=3.17.0安装命令:
pip install -r requirements.txt注意:pypdf 用于 PDF 文本提取,如果简历是 Markdown 格式,则可以不安装。没有实际版本依赖的项目,可以在使用时按组件说明调整。
3.4 创建环境变量文件
touch .env写入:
MODEL_API_BASE=https://api.example.com/v1 MODEL_NAME=your-model-name MODEL_API_KEY=your-api-key4. 核心数据模型定义
4.1 简历模型
在 applywise/schemas/resume.py 中编写:
from typing import List, Optional from pydantic import BaseModel class Experience(BaseModel): company: str = "" title: str = "" duration: str = "" highlights: List[str] = [] class Education(BaseModel): school: str = "" degree: str = "" major: str = "" year: str = "" class Resume(BaseModel): name: str = "" job_title: str = "" skills: List[str] = [] years_of_experience: float = 0 experiences: List[Experience] = [] education: List[Education] = [] projects: List[str] = [] summary: str = ""4.2 职位 JD 模型
在 applywise/schemas/job.py 中编写:
from typing import List, Optional from pydantic import BaseModel class JobDescription(BaseModel): company: str = "" position: str = "" responsibilities: List[str] = [] must_have_skills: List[str] = [] nice_to_have_skills: List[str] = [] experience_requirement: str = "" culture_keywords: List[str] = [] raw_text: str = ""4.3 匹配分析报告模型
在 applywise/schemas/match_report.py 中编写:
from typing import List, Optional from pydantic import BaseModel class MatchItem(BaseModel): skill: str status: str # matched / missing / partially_matched evidence: str = "" suggestion: str = "" class MatchReport(BaseModel): overall_score: int = 0 matched_items: List[MatchItem] = [] missing_items: List[MatchItem] = [] risk_analysis: str = ""为什么使用 pydantic?因为在调用大模型时,可以用结构化输出功能让模型返回 JSON,之后再校验字典是否完整。这样避免了模型返回少一个键导致后续代码崩溃的问题。
5. 提示词模板如何设计
很多初学者喜欢把提示词写在代码字符串里,例如:
prompt = f"你是我的求职助手,这是我的简历:{resume},帮我修改..."这种写法在需求简单时还可以,但当一个 AI Layer 有多个功能时,提示词会越来越长,代码也变得越来越难维护。工程上建议把提示词统一放到 prompts 目录。
5.1 匹配分析提示词
在 applywise/prompts/match_prompt.txt 中写入:
你是一名资深技术招聘顾问。下面你会收到一份候选人的结构化简历和一份职位描述的结构化信息。 请分析候选人是否适合这个职位,只输出 JSON 格式结果。 JSON 格式要求: { "overall_score": 0-100 的整数, "matched_items": [ { "skill": "技能名称", "status": "matched/partially_matched", "evidence": "简历中对应的证据", "suggestion": "如何进一步强化这个匹配点" } ], "missing_items": [ { "skill": "缺失技能名称", "status": "missing", "evidence": "", "suggestion": "可以如何弥补或说明" } ], "risk_analysis": "总结这个候选人申请该职位的风险点与优势" } 候选人简历: {resume_json} 职位描述: {job_json} 只输出 JSON,不要输出解释。5.2 求职信提示词
在 applywise/prompts/cover_letter_prompt.txt 中写入:
你是一名求职文案顾问。请根据以下候选人简历、目标职位和匹配分析,写一封不超过 200 字的中文求职信。 语气要专业但不浮夸,突出与职位相关的能力,不要重复候选人简历里已经列出的所有细节。 候选人简历: {resume_json} 目标职位: {job_json} 匹配分析: {match_report_json} 输出格式为纯文本,直接输出求职信正文。5.3 自定义变量格式
为了让提示词文件可以被加载后替换,引入一个简单的模板函数。在 applywise/utils/template.py 中编写:
from pathlib import Path def load_prompt(prompt_file: str, **kwargs) -> str: project_root = Path(__file__).resolve().parents[2] prompt_path = project_root / "applywise" / "prompts" / prompt_file text = prompt_path.read_text(encoding="utf-8") for key, value in kwargs.items(): text = text.replace("{" + key + "}", str(value)) return text这样代码中调用时只需要:
prompt = load_prompt("match_prompt.txt", resume_json=resume.model_dump_json(), job_json=job.model_dump_json())好处是:以后想针对不同行业调整提示词,直接改文案,不用改代码。
6. 核心 Agent 实现
6.1 底层模型客户端
在 applywise/core/llm_client.py 中统一封装模型调用:
import os from openai import OpenAI from dotenv import load_dotenv load_dotenv() _client = None def get_client() -> OpenAI: global _client if _client is None: _client = OpenAI( base_url=os.getenv("MODEL_API_BASE"), api_key=os.getenv("MODEL_API_KEY"), ) return _client def chat(messages, temperature=0.3): client = get_client() resp = client.chat.completions.create( model=os.getenv("MODEL_NAME"), messages=messages, temperature=temperature, ) return resp.choices[0].message.content说明:
- 使用全局单例避免每次调用重新创建连接。
- temperature 设置为 0.3,保证输出稳定性,因为求职材料生成场景更强调准确性,不太需要天马行空的创意。
- 如果使用的是本地模型,比如通过 Ollama 暴露的兼容接口,只需要修改 base_url,代码不需要改动。
6.2 结构化输出解析
大模型输出文本不稳定,即使要求它只输出 JSON,也可能带上 ```json 这样的代码块。所以需要写一个健壮的解析函数。
在 applywise/utils/json_parser.py 中编写:
import json import re def parse_json_from_text(text: str) -> dict: text = text.strip() if text.startswith("```"): text = re.sub(r"^```(?:json)?", "", text) text = re.sub(r"```$", "", text) # 寻找第一个 { 和最后一个 } start = text.find("{") end = text.rfind("}") if start != -1 and end != -1 and end > start: json_str = text[start:end+1] return json.loads(json_str) raise ValueError("模型输出中没有找到合法 JSON 片段")6.3 匹配分析 Agent
在 applywise/core/match_analyzer.py 中编写:
import json from applywise.core.llm_client import chat from applywise.schemas.resume import Resume from applywise.schemas.job import JobDescription from applywise.schemas.match_report import MatchReport from applywise.utils.template import load_prompt from applywise.utils.json_parser import parse_json_from_text class MatchAnalyzer: def __init__(self): pass def analyze(self, resume: Resume, job: JobDescription) -> MatchReport: resume_json = resume.model_dump_json() job_json = job.model_dump_json() prompt = load_prompt( "match_prompt.txt", resume_json=resume_json, job_json=job_json, ) raw_content = chat([{"role": "user", "content": prompt}]) data = parse_json_from_text(raw_content) return MatchReport.model_validate(data)注意:这一步并没有直接把简历和 JD 原文丢给模型,而是先让解析器把它们变成 JSON。为什么这么做?因为直接使用原文时,模型的注意力会被无关内容分散。比如简历中有大量项目描述,但岗位只关心“微服务架构经验”,结构化后模型更容易提取。
6.4 简历优化与求职信生成 Agent
在 applywise/core/cover_letter.py 中编写:
from applywise.core.llm_client import chat from applywise.schemas.resume import Resume from applywise.schemas.job import JobDescription from applywise.schemas.match_report import MatchReport from applywise.utils.template import load_prompt class CoverLetterGenerator: def __init__(self): pass def generate(self, resume: Resume, job: JobDescription, report: MatchReport) -> str: prompt = load_prompt( "cover_letter_prompt.txt", resume_json=resume.model_dump_json(), job_json=job.model_dump_json(), match_report_json=report.model_dump_json(), ) raw_content = chat([{"role": "user", "content": prompt}]) return raw_content.strip()7. 文件解析模块实现
7.1 简历文本解析
如果用户的简历是 PDF 文件,最稳妥的方式是先用 pypdf 提取纯文本,再由大模型整理成结构化 JSON。注意,直接依靠 PDF 内嵌元数据是不可靠的。
在 applywise/parsers/resume_parser.py 中编写:
import json from pathlib import Path from pypdf import PdfReader from applywise.core.llm_client import chat from applywise.schemas.resume import Resume from applywise.utils.template import load_prompt def extract_text_from_pdf(pdf_path: str) -> str: reader = PdfReader(pdf_path) pages = [] for page in reader.pages: pages.append(page.extract_text() or "") return "\n".join(pages) def resume_from_pdf(pdf_path: str) -> Resume: text = extract_text_from_pdf(pdf_path) return resume_from_text(text) def resume_from_text(text: str) -> Resume: prompt = load_prompt("resume_struct_prompt.txt", resume_text=text) raw = chat([{"role": "user", "content": prompt}]) data = json.loads(raw) return Resume.model_validate(data)7.2 简历整理提示词
在 applywise/prompts/resume_struct_prompt.txt 中写入:
请把下面的简历文本转成结构化 JSON。要求: 1. skills 字段提取所有编程语言、框架、工具,统一为小写。 2. years_of_experience 根据已有经历推算。 3. experiences 中每个公司可以有多条 highlight。 4. 只输出 JSON。 简历文本: {resume_text}这个模块的作用是让后续所有 Agent 使用的简历数据格式始终一致,而不是每次重复向模型发送又长又乱的原文。
8. 一个完整可运行的实战案例
下面我们把核心流程串起来,写一个命令行脚本。用户在命令行传入简历 PDF 和职位 JD 文本,AI Layer 输出匹配报告和求职信。
8.1 创建 JD 文件示例
在 data/jds/senior_backend_engineer.md 中创建:
公司:某云计算公司 职位:高级后端工程师 职责: - 负责分布式任务调度系统的设计、开发与维护。 - 参与系统性能调优,保障高并发场景下的稳定性。 - 负责相关服务的监控、告警和故障恢复。 硬性要求: - 5年以上后端开发经验。 - 熟悉 Java 或 Go。 - 熟悉分布式系统常见协议与中间件。 - 有高并发系统设计经验。 加分项: - 有 Kubernetes 生产环境经验。 - 有开源项目维护经历。 - 熟悉消息队列原理。 团队文化: - 重视技术文档沉淀。 - 强调代码可读性和工程规范。8.2 创建入口脚本
在项目根目录创建 run_apply.py:
import sys from pathlib import Path from applywise.schemas.resume import Resume from applywise.schemas.job import JobDescription from applywise.parsers.resume_parser import resume_from_pdf from applywise.core.match_analyzer import MatchAnalyzer from applywise.core.cover_letter import CoverLetterGenerator def load_job_from_markdown(path: str) -> JobDescription: text = Path(path).read_text(encoding="utf-8") # 这里为简化示例写死字段,实际项目中可以将 Markdown 交给 LLM 整理成 JobDescription lines = [line.strip() for line in text.splitlines() if line.strip()] responsibilities = [] must_have = [] nice_to_have = [] current_section = None for line in lines: if line.startswith("职责"): current_section = "resp" continue if line.startswith("硬性要求"): current_section = "must" continue if line.startswith("加分项"): current_section = "nice" continue if line.startswith("团队文化"): current_section = None continue if line.startswith("- ") and current_section == "resp": responsibilities.append(line[2:].strip()) elif line.startswith("- ") and current_section == "must": must_have.append(line[2:].strip()) elif line.startswith("- ") and current_section == "nice": nice_to_have.append(line[2:].strip()) company = "某云计算公司" position = "高级后端工程师" for line in lines: if line.startswith("公司:"): company = line.replace("公司:", "").strip() if line.startswith("职位:"): position = line.replace("职位:", "").strip() return JobDescription( company=company, position=position, responsibilities=responsibilities, must_have_skills=must_have, nice_to_have_skills=nice_to_have, experience_requirement="5年", culture_keywords=["文档", "工程规范"], raw_text=text, ) def main(): resume_path = sys.argv[1] if len(sys.argv) > 1 else "data/resumes/alice.pdf" job_path = sys.argv[2] if len(sys.argv) > 2 else "data/jds/senior_backend_engineer.md" resume: Resume = resume_from_pdf(resume_path) job: JobDescription = load_job_from_markdown(job_path) analyzer = MatchAnalyzer() report = analyzer.analyze(resume, job) print("整体匹配度:", report.overall_score) print("硬性技能缺失项:") for item in report.missing_items: print("-", item.skill, "建议:", item.suggestion) print("风险分析:", report.risk_analysis) generator = CoverLetterGenerator() cover_letter = generator.generate(resume, job, report) print("\n=== 求职信 ===\n") print(cover_letter) if __name__ == "__main__": main()8.3 运行命令与预期效果
python run_apply.py data/resumes/alice.pdf data/jds/senior_backend_engineer.md预期输出包含三部分:
- 整体匹配度,比如 78。
- 缺失技能列表,比如没有 Kubernetes 生产经验。
- 一封 200 字左右的求职信。
在执行真实调用前,建议先确保 API Key 有最低权限,最好是只读访问模型接口,不要使用具有管理权限的 Token。同时,简历 PDF 里的敏感联系方式在格式化输出时会被保留,本地运行没问题,但如果部署为 Web 服务,一定要增加访问权限控制。
9. 把 AI Layer 包装成 Web 服务
命令行工具适合自己用,但如果想做成团队内部工具,或者让非技术用户通过浏览器操作,可以把核心模块用 FastAPI 包一层接口。
在项目根目录创建 server.py:
from fastapi import FastAPI, File, UploadFile, Form from pydantic import BaseModel from applywise.parsers.resume_parser import resume_from_pdf from applywise.core.match_analyzer import MatchAnalyzer from applywise.core.cover_letter import CoverLetterGenerator from applywise.schemas.job import JobDescription app = FastAPI() class JobTextIn(BaseModel): raw_jd: str @app.post("/api/match") async def match( resume_file: UploadFile = File(...), jd_text: str = Form(...), ): # 将上传文件临时保存 temp_path = f"/tmp/{resume_file.filename}" with open(temp_path, "wb") as f: content = await resume_file.read() f.write(content) resume = resume_from_pdf(temp_path) job = JobDescription(raw_text=jd_text) analyzer = MatchAnalyzer() report = analyzer.analyze(resume, job) return { "overall_score": report.overall_score, "risk_analysis": report.risk_analysis, }启动服务:
pip install fastapi uvicorn python-multipart uvicorn server:app --reload --port 8000接口调用示例:
curl -X POST http://localhost:8000/api/match \ -F "resume_file=@data/resumes/alice.pdf" \ -F "jd_text=某云计算公司招聘高级后端工程师..."使用 Web 服务时要特别注意:上传的简历文件包含手机、邮箱、地址等个人隐私。不要让服务监听公网地址,建议默认监听 127.0.0.1,或者放在企业内网并通过网关鉴权。
10. 常见问题与排查思路
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 模型返回内容不是 JSON | 提示词约束不足,或模型本身输出不稳定 | 开启响应格式的 json_object 模式,增加二次解析与重试逻辑 |
| 匹配分数总是不符合预期 | resume 解析时丢失了关键项目背景 | 检查结构化后的简历字段,确认 skills 和 experiences 是否完整 |
| PDF 简历提取后乱码 | 部分 PDF 是扫描件,没有文本层 | 先做 OCR,再送入模型;或者要求用户上传 Markdown/Word 版本 |
| 求职信套话严重 | 提示词缺少具体信息和匹配分析输入 | 传入 MatchReport,并限制“必须引用一个具体项目” |
| API 调用超时 | 简历过长、上下文过大 | 先截断摘要,只传核心字段,或升级支持长上下文的模型 |
| 输出内容包含不存在的项目经历 | 模型“幻觉”,用示例数据集里的信息填补 | 在提示词中明确“不能编造简历中不存在的内容”,并在生成后人工审核 |
开发这类应用时,我强烈建议对最终 AI 生成的内容做两层检查:第一层是程序化校验,比如判断输出是否为空、是否仍是原简历内容;第二层是人工阅读。求职信和面试答案直接关系到面试第一印象,不建议完全无人看管地自动发送。
11. AI Layer 的工程落地建议
11.1 数据安全优先
处理简历这类高隐私数据时,应该默认遵守最小权限原则:
- 全部解析在本地或企业内网完成。
- 如果必须调用云端模型,需要对简历做脱敏处理,比如把手机号和邮箱替换为占位符。
- API Key 使用环境变量或密钥管理服务注入,不要硬编码在源码中,更不要把密钥提交到代码仓库。
11.2 用量可控
求职申请是低频但调用密集的场景,比如一次匹配需要同时传入简历和 JD,token 消耗可能会很高。建议做三层控制:
- 对单次任务设置最大输入长度。
- 对大模型接口增加超时时间。
- 对用户调用频率做限流。
11.3 模板与代码分离
很多 AI 应用的问题在于,业务人员想优化文案,却要被迫修改 Python 代码。在工程上,把提示词抽离成独立文件,既方便测试不同策略,也方便和业务同事协作。
11.4 做好请求日志
每次调用大模型之前和之后,都应该记录输入输出摘要。日志结构可以包含:
- 请求时间。
- 任务类型。
- 输入数据摘要,不要记录完整简历。
- 模型名称。
- 输出成功与否。
- 如果出错,记录错误类型。
日志的目的是帮助复现问题。模型输出差的原因通常来自三个地方:输入数据不完整、提示词不合适、模型不适合当前任务。
11.5 保留版本管理
求职申请 AI 工具修改简历时会覆盖原始版本,建议在生成文件时自动加入时间戳或公司名。例如:
data/output/{company}_{position}_resume.md data/output/{company}_{position}_cover_letter.md这样用户可以对比修改前后,不会被 AI 改动得面目全非。
12. 总结与下一步技术路线
这篇文章用一个实际的求职申请 AI 层项目为线索,从数据模型、提示词设计、模型客户端封装、文件解析到 Web 服务打包,完整展示了一个 AI Agent 应用的基本工程结构。你完全可以基于这套代码,继续扩展“面试问答生成”“申请状态跟踪”等功能。
如果想让系统更智能,可以尝试以下升级方向:
- 引入 RAG,将公司官网、团队博客和以往面试经验作为知识库,在生成求职信前先做检索。
- 接入工作流引擎,比如用 LangGraph 或自研状态机编排多轮调用。
- 添加任务队列,让用户在 Web 页面上传简历后不用等待同步调用,而是先返回任务 ID,后台异步执行。
- 对生成内容做自动质量评估,比如判断是否符合 JD 关键词覆盖率,再决定是否重新生成。
建议你先从本地命令行版本开始跑通全流程,再加上 Web 展示层。记住,AI Layer 不是替代求职者的判断力,而是把重复劳动压缩到几分钟,让你把精力花在真正需要人的经验和临场反应的地方。
代码仓库里的逻辑都比较清晰,动手改起来很容易。有搭建过程中遇到的其他问题,也欢迎在评论区交流。