面向千万毕业生的求职场景,Agent 要当好“求职搭子”,单靠一句自然的开场白远远不够。简历上传之后能不能读懂,岗位 JD 每天都在变化,模拟面试之后能不能把薄弱环节沉淀到下一轮计划里,这些才是用户是否愿意长期使用的关键。真正决定产品口碑的部分往往在水面以下:Agent 的记忆结构、检索质量、工具调用边界、中断恢复和人工兜底机制。
本文不绑定某个具体产品,而是从“求职搭子”这类任务型 Agent 的通用工程视角切入。我们会先拆解求职场景要完成哪些子任务,再说明为什么需要“计划-行动-观察”循环,然后搭建一个最小可运行的 Agent 示例,最后补上 RAG、状态管理、隐私安全、评测和排错方案。学完这套结构后,也可以把它迁移到简历诊断、模拟面试、岗位提醒等不同功能里。
1. 先拆清求职搭子要做的事,再谈大模型能力
很多团队开发第一版 Agent 时,容易陷进“模型很聪明,对话很流畅”的误区。对求职用户来说,一段热闹但没有任务的对话没有价值。求职搭子的本质是一个任务执行器,它要把模糊的描述变成可操作的求职动作,并且跟踪结果。
1.1 用户需要的不是一个聊天机器人
传统聊天机器人做的事情是“答”,用户问一句,模型答一句。求职搭子要做的事情是“办”,用户说“我最近在投后端岗位,有点焦虑”,它可能需要完成以下动作:读取简历、定位目标岗位、检索岗位 JD、计算匹配度、找出缺失技能、生成复习计划、把任务写进日历。
这两类产品的差别直接决定技术架构。聊天机器人只需要对话模型和会话缓存,任务型 Agent 至少需要以下能力:
- 识别用户这次请求属于简历、岗位、面试、提醒中的哪一类意图。
- 从对话中抽取实体,比如简历编号、职位编号、时间、公司名称。
- 调用工具获取真实数据,比如岗位接口、简历服务、日历服务。
- 根据工具返回结果生成用户可读的结论,而不是凭模型记忆编造。
- 把结果写回记忆,下一次对话能知道上次推进到哪个环节。
如果只把大模型当作文本生成器,那么产品只会“聊得热闹,办不成事”。
1.2 从功能表反推技术模块
可以先把“求职搭子”的核心功能列成一张表,每一行对应一组 Agent 子任务。技术设计前先做功能拆解,是为了避免开发时只做“提示词工程”。
| 功能 | 用户典型说法 | Agent 要做什么 | 需要的资源和工具 |
|---|---|---|---|
| 简历诊断 | 帮我看下这份简历哪里需要改 | 读取简历、抽取技能和教育经历、对比岗位方向、给出修改建议 | 简历解析服务、职位方向知识库 |
| 岗位匹配 | 这个岗位适合我吗 | 获取简历和 JD、抽取关键词、计算匹配分、说明优劣势 | 简历库、岗位库、匹配策略服务 |
| 模拟面试 | 按这个岗位面我一次 | 检索 JD 和简历、生成题目、逐题点评、统计薄弱点 | 面试题库、知识库、面试评估提示词 |
| 求职提醒 | 提醒我周四下午参加某公司二面 | 理解时间/公司/环节、创建日程、设置提醒 | 日历工具、定时任务服务 |
| 求职问答 | 银行科技岗一般问什么 | 搜索相关面经、组织答案、注明信息时效 | 岗位面经知识库、RAG 检索 |
这些功能看起来都是“对话”,但“岗位匹配”和“模拟面试”内部的工作流差异很大。岗位匹配是查询和计算,模拟面试是多轮状态流转。因此代码里不能写死一套提示词,而应该让 Agent 根据意图选择不同子流程。
1.3 本文选择的技术主线
为了让文章有可复现的落点,下面的实现主线选择“简历与岗位匹配”这个最小场景。完整流程为:
- 用户提交请求,并携带 resume_id 和 job_id。
- Agent 收到文本后判断是否必须调用工具。
- 调用匹配工具读取数据库中的简历和岗位。
- 工具返回关键词命中、缺失技能和评分。
- Agent 基于结果生成建议,并询问下一步。
这里的关键不是匹配算法本身,而是 Agent 如何决定调用工具、如何把工具结果转化为回答、如何避免没有数据就编造。这一套机制可以复用到提醒、面试评估和简历诊断中。
2. 要让 Agent 真正“做事”:计划、调用工具、观察结果
2.1 一次大模型调用解决不了所有求职任务
写单轮 Prompt 时,开发者会把“简历内容 + JD 内容 + 指令”一次性塞给模型,让它直接输出结论。这在简历很短、字段很稳定的演示环境里可行,但在生产环境会遇到三个问题。
第一,上下文太长。一份完整简历可能包含项目经历、实习经历、专业技能、自我评价,如果每次匹配都拼进上下文,成本会线性上升。第二,数据时效和精确性无法保证。岗位 JD 可能来自第三方招聘接口,简历可能有多个版本,模型无法单靠自身记忆知道当前数据库里存的是什么。第三,错误责任不清。一旦结果错误,我们无法判断是模型判断错误,还是它根本没读取到正确数据。
所以任务型 Agent 更适合采用“模型负责决策,工具负责执行”的架构。模型看到用户问题后,决定调用哪个工具、传什么参数;工具返回结构化结果之后,模型再把这些结果转成用户能看懂的语言。
2.2 用 Function Calling 把能力暴露给模型
在 OpenAI 兼容的接口里,可以通过 tools 参数定义工具。以匹配工具为例,它的核心作用是接收两个业务 ID,返回一个结构化的匹配结果。
{ "type": "function", "function": { "name": "get_resume_job_match", "description": "读取指定的简历和职位,返回匹配度评分以及缺失技能", "parameters": { "type": "object", "properties": { "resume_id": { "type": "string", "description": "简历编号" }, "job_id": { "type": "string", "description": "职位编号" } }, "required": ["resume_id", "job_id"] } } }工具定义的关键不只是“有哪些参数”,还包括 description 怎么写。模型需要从 description 里判断“这个用户请求该不该调用这个工具”。如果描述写成泛泛的“匹配函数”,模型可能在一个只需解释面试流程的请求里误调用。推荐写法是明确写出触发条件和参数含义。
模型返回的内容里如果包含tool_calls,就说明它认为需要调用工具。此时业务代码应该停下对话生成,先执行工具,再把工具返回值以role: "tool"的消息追加回对话上下文。这个过程也叫“计划-行动-观察”循环,模型先计划,代码行动,模型再观察工具输出并给出最终回复。
2.3 记忆是求职搭子的隐形状态
很多 Agent Demo 只实现了单轮 Function Calling,跑完一次就结束。求职场景天然需要长期记忆,因为用户可能今天上传简历,明天咨询岗位,后天参加模拟面试,一周后再回来问“上一次建议我补什么”。
推荐至少维护三层记忆:
| 记忆层 | 存储内容 | 典型字段 |
|---|---|---|
| 短期会话记忆 | 当前对话上下文 | 消息列表、最近意图、待确认参数 |
| 用户长期档案 | 简历、求职偏好、学历技能 | user_id、resume_id、目标岗位、地域偏好 |
| 业务状态记忆 | 当前任务推进到哪一步 | thread_id、state、关联的 job_id、面试时间 |
不能只在数据库里存“聊天记录”。例如模拟面试任务需要维护当前题目编号和用户回答,如果只存聊天记录,恢复线程时很难定位“这是第几题、是否已经点评”。建议单独设计任务状态表,状态字段的值可以是idle、collecting、matching、interviewing、confirming_schedule。
3. 从零搭建最小可运行的求职搭子 Agent
3.1 环境准备和依赖
下面示例使用 Python 和 OpenAI 兼容的 Chat Completions 接口。实际项目中可以替换为任意支持 Function Calling 或 Tool Calling 的大模型服务,只要 endpoint 和模型名符合对应规范。
建议的开发环境如下:
| 环境项 | 建议值 | 说明 |
|---|---|---|
| Python | 3.11 及以上 | 类型标注和异步支持更友好 |
| API 客户端 | openai Python SDK | 用于 Chat Completions 和 Embeddings |
| 数据校验 | Pydantic | 校验模型结构化输出 |
| Web 框架 | FastAPI | 后期封装 HTTP 接口时使用 |
| 数据库 | MySQL 或 PostgreSQL | 存储简历、职位、会话状态 |
| 向量库 | Chroma / Milvus / pgvector | 简历和岗位语义检索 |
requirements 文件可以先这样写,落地前需要固定版本生成 lock 文件:
openai>=1.40.0 pydantic>=2.7.0 python-dotenv>=1.0.1 fastapi>=0.111.0 uvicorn[standard]>=0.30.0在 .env 文件中配置模型访问信息。不要把密钥写进代码库。
LLM_API_KEY=your_api_key_here LLM_BASE_URL=https://api.your-llm-provider.example.com/v1 LLM_MODEL=model-name EMBEDDING_MODEL=embedding-model-name如果原始资料没有给出明确的模型名称,落地前要确认目标模型是否支持工具调用,以及 Embedding 模型的最大输入 token。不同模型的参数差异会影响切分策略。
3.2 实现一个通用 Agent 主循环
下面代码的核心逻辑是:循环请求模型,如果模型返回tool_calls就执行对应工具并把结果追加回消息列表,如果模型返回纯文本就结束循环。
import json import os from openai import OpenAI client = OpenAI( api_key=os.environ["LLM_API_KEY"], base_url=os.environ.get("LLM_BASE_URL"), ) TOOLS = [ { "type": "function", "function": { "name": "get_resume_job_match", "description": "读取指定的简历和职位,返回匹配度评分、命中关键词和缺失技能", "parameters": { "type": "object", "properties": { "resume_id": {"type": "string", "description": "简历编号"}, "job_id": {"type": "string", "description": "职位编号"} }, "required": ["resume_id", "job_id"] } } } ] def run_agent(user_input: str) -> str: messages = [ { "role": "system", "content": ( "你是求职搭子。只要用户想判断某个岗位是否适合他," "你就必须调用 get_resume_job_match 工具。" "在没有工具结果之前,不要直接下结论。" ) }, {"role": "user", "content": user_input} ] for step in range(5): response = client.chat.completions.create( model=os.environ["LLM_MODEL"], messages=messages, tools=TOOLS, tool_choice="auto", ) assistant_message = response.choices[0].message # 转成普通字典,避免不同 SDK 版本之间的兼容问题 messages.append(json.loads(assistant_message.model_dump_json())) if not assistant_message.tool_calls: return assistant_message.content or "" for tool_call in assistant_message.tool_calls: arguments = json.loads(tool_call.function.arguments) if tool_call.function.name == "get_resume_job_match": result = compute_match( resume_id=arguments.get("resume_id"), job_id=arguments.get("job_id"), ) else: result = {"error": "unknown tool"} messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False), }) return "任务未在限定步数内完成,已转人工处理。"这里需要特别说明循环退出的标志。模型返回的assistant_message同时包含content和tool_calls时,应该优先处理tool_calls,因为最终答案可能依赖工具返回值。如果只读取content,会漏掉 Agent 的决策过程。
3.3 用最简单的规则实现匹配工具
示例的匹配工具不依赖复杂算法,只做两件事:从 JD 中提取关键词,统计这些关键词是否出现在简历文本中。
JOB_KEYWORD_BANK = [ "Python", "Java", "MySQL", "Redis", "Docker", "Kubernetes", "数据分析", "大模型", "算法", "前端", ] def extract_job_keywords(jd_text: str) -> list[str]: return [kw for kw in JOB_KEYWORD_BANK if kw in jd_text] def compute_match(resume_id: str, job_id: str) -> dict: # 实际项目中从数据库读取 resume_text = demo_resume_content(resume_id) job_description = demo_job_content(job_id) keywords = extract_job_keywords(job_description) matched = [kw for kw in keywords if kw in resume_text] missing = [kw for kw in keywords if kw not in resume_text] score = 0.0 if keywords: score = round(100 * len(matched) / len(keywords), 1) return { "resume_id": resume_id, "job_id": job_id, "score": score, "matched_keywords": matched, "missing_keywords": missing, }这个规则实现适合演示。真实项目中更常见的是“关键词打分 + Embedding 相似度 + 规则过滤”的组合:关键词负责显性技能判断,向量相似度负责语义匹配。比如简历里写“熟悉容器化部署”,即使没有出现“Docker”字样,向量检索也能关联上。
值得注意的是:不能把关键词银行做得过大,否则 JD 里无关技能会干扰结果。需要结合岗位方向建立技能词库,并为不同职能配置不同权重。
3.4 运行示例与预期输出
调用入口可以这样写:
if __name__ == "__main__": result = run_agent("请帮我看看简历 R001 和岗位 J002 是否匹配") print(result)正常成功时会看到类似日志和输出:
[Trace] tool call: get_resume_job_match [Trace] arguments: {"resume_id": "R001", "job_id": "J002"} [Trace] tool result: {"score": 60.0, "matched_keywords": ["Python"], "missing_keywords": ["Redis", "Docker"]} [Assistant] 根据岗位 JD 中的技能要求,简历匹配度约 60%。Python 已经是加分项,但岗位强调 Redis 和 Docker,简历里还没有体现。需要我针对缺失技能生成一份准备计划吗?如果用户没有提供 resume_id 或 job_id,模型会尝试从上下文推断。此时两个参数可能是空值,匹配结果没有意义。开发阶段可以增加一个校验工具,或者要求模型在参数缺失时先向用户确认,而不是继续调用工具。
4. 简历、岗位和面试题库如何成为 Agent 的知识
4.1 为什么不能把所有资料都写进 Prompt
有人会问:既然大模型能读文本,为什么不把所有岗位 JD 和简历都放到 Prompt 里?答案很直接:放不下,也放不准。
一个真实的求职平台可能有数十万条岗位信息,简历也有多个版本。Prompt 的上下文窗口有限,费用和延迟都随长度增长。更关键的是,检索和生成是两件事。让模型在海量资料中找到目标,既浪费 token,又容易受到无关信息干扰。正确做法是先用 RAG 召回最相关的资料片段,再把小批资料交给 Agent 使用。
需要先理解 RAG 在这里的角色:它不是回答问题的最终模型,而是 Agent 的“资料员”。Agent 判断该查资料后,RAG 负责返回与问题最相关的段落。接下来,Agent 可以用这些段落生成建议、生成面试题或组织回答。
4.2 文档切分与向量化示例
简历和 JD 的文本结构不同,不能简单按固定长度截断。推荐先按段落边界切分,再通过重叠窗口保留上下文。
import re def chunk_document(document: str, max_chars: int = 800, overlap: int = 80) -> list[str]: paragraphs = re.split(r"\n\s*\n", document) chunks = [] current = "" for paragraph in paragraphs: paragraph = paragraph.strip() if not paragraph: continue if len(current) + len(paragraph) + 1 <= max_chars: current = f"{current}\n{paragraph}" if current else paragraph else: if current: chunks.append(current) current = paragraph if current: chunks.append(current) # 对过长段落做硬切分,并保留 overlap final_chunks = [] for chunk in chunks: if len(chunk) <= max_chars: final_chunks.append(chunk) continue start = 0 while start < len(chunk): end = min(start + max_chars, len(chunk)) final_chunks.append(chunk[start:end]) start = end - overlap if start < 0: start = 0 if start >= len(chunk): break return final_chunks切分时不要只考虑字符长度。简历里的“项目经历”可能只有 200 字,但内部信息高度浓缩;面试经验帖可能是散列表单,切成多个小块后不能丢失标题上下文。因此生产方案通常会给每个 chunk 带上业务元数据,比如简历编号、板块标题、岗位类型。
向量化代码非常短,关键是选对 Embedding 模型和存储方案。
def embed_document(document: str) -> list[float]: resp = client.embeddings.create( model=os.environ["EMBEDDING_MODEL"], input=document, ) return resp.data[0].embedding def cosine_similarity(vec1: list[float], vec2: list[float]) -> float: dot = sum(a * b for a, b in zip(vec1, vec2)) norm1 = sum(a * a for a in vec1) ** 0.5 norm2 = sum(b * b for b in vec2) ** 0.5 if norm1 == 0 or norm2 == 0: return 0.0 return dot / (norm1 * norm2)线上服务不要每来一次请求都全量扫描向量,应该把向量写入独立的向量库,并用top-k召回。快速验证阶段可以用本地内存向量列表,生产环境建议切换到支持索引和过滤的向量数据库,并根据岗位方向、城市、公司名称做元数据过滤。
4.3 存储层设计:关系表与向量表如何配合
Agent 需要同时处理两类数据:一类是强业务结构的记录,比如用户、简历、岗位、会话;另一类是语义检索用的向量。强数据结构放在关系库,向量结构放在向量库,两者通过业务 ID 关联。
下面是一组简化 DDL:
CREATE TABLE resume ( resume_id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, title VARCHAR(255), raw_text MEDIUMTEXT, parsed_json JSON, embedding_status TINYINT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE job ( job_id VARCHAR(64) PRIMARY KEY, source VARCHAR(64), title VARCHAR(255), company_name VARCHAR(255), description MEDIUMTEXT, published_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE thread ( thread_id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, resume_id VARCHAR(64), job_id VARCHAR(64), state VARCHAR(32) DEFAULT 'idle', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );字段说明中要特别注意embedding_status。简历内容被修改后,不能等下次对话才想起重新向量化。建议在更新简历后触发异步任务,把该记录的向量状态置为待更新,并在向量库中删除旧向量或生成新版本。否则 Agent 拿到的可能是旧版简历。
面试题库也类似。题库内容要带来源、标签、章节、难度、发布日期等字段,检索时只召回当前岗位方向的内容。没有元数据的纯文本向量库,检索结果很难过滤。
5. 从 Demo 到可上线:还要补上这些内容
如果代码停在“能调用一次工具”的 Demo 阶段,离上线还差很多。下面这些点不是锦上添花,而是决定用户是否会投诉、客服是否有线索、数据是否需要删除的关键措施。
5.1 状态机、会话与任务恢复
求职搭子的任务往往不是一次问答能结束的。比如模拟面试流程:用户说“开始面试”,Agent 出第一题,用户回答,Agent 判断答案并追问下一题,最后输出综合评估。这个过程不能用单一的 Prompt 硬撑,否则用户刷新页面或断线后,状态会丢。
推荐为每种业务定义状态机:
| 业务状态 | 含义 | 可执行动作 |
|---|---|---|
| idle | 无任务 | 等待用户输入 |
| collecting | 正在收集简历或岗位信息 | 追问缺失字段 |
| matching | 正在执行匹配 | 调用匹配工具 |
| interviewing | 正在模拟面试 | 出题、判题、推进题号 |
| scheduling | 正在创建日程 | 确认时间、调用日历工具 |
| done | 任务完成 | 展示总结,等待新任务 |
状态字段应该存到业务表里,而不是只存在前端内存。用户再次打开页面时,后端通过 thread_id 恢复状态,Agent 才能继续上一题或重新生成建议。
5.2 安全与个人隐私
简历中包含姓名、电话、教育经历、项目经历等个人敏感信息。进入生产前需要处理几个问题:
- 传输加密:应用层使用 HTTPS,数据库连接使用 TLS。
- 脱敏:调用大模型前不要把完整手机号、身份证号等无关敏感信息发送给模型服务。
- 最小授权:简历更新接口、删除接口必须做身份鉴权,不能只靠一个 resume_id 就能读取。
- 用户删除权:用户注销时,需要级联删除简历文本、向量数据、会话记录和匹配报告。
不要把“数据只有内部系统能访问”当作默认前提。日志、向量库、模型调用平台都可能成为数据落点,需要单独确认。
5.3 成本、延迟与模型分级
Agent 每次任务可能多次调用模型。如果所有请求都使用同一个高能力模型,费用会很快增长,并且延迟可能超过用户耐心。可按任务难度分流:
| 任务类型 | 推荐模型策略 | 原因 |
|---|---|---|
| 意图识别 | 小模型或本地分类模型 | 快速、便宜,无法确定时再请求大模型 |
| 工具参数抽取 | 支持 Function Calling 的模型 | 需要结构稳定输出 |
| 最终回答生成 | 高能力模型 | 需要组织建议和上下文 |
| 简历向量化 | Embedding 模型 | 离线任务,对实时延迟不敏感 |
此外,要设置单次 Agent 任务的最大循环次数和超时时间。生产环境建议用异步任务执行完整流程,用户端先展示“正在处理”,完成后通过轮询或消息推送返回结果。
5.4 可观测和评测
Agent 应用不能只看“大模型有没有回复”,还要看每一步是否合理。推荐在每次任务执行时记录:
- 用户原始输入
- 模型每次返回的 tool_calls 参数
- 工具执行耗时和返回结果
- 最终回答长度和生成 token 数
- 当前 thread 状态变化
这些日志能为排查“为什么这次回复错误”提供关键线索。评测方面,需要准备至少几十条包含简历编号和岗位编号的真实测试样本,每一条都要有预期结论。不能只凭“看起来自然”验收,要检查是否调用了正确工具、评分是否准确、缺失技能是否合理。
6. 高频问题排查与可复用清单
6.1 常见现象、原因和处置
开发过程中会反复遇到几类问题。把它们做成表格,可以快速定位。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 模型不调用工具,直接生成结论 | 模型不支持 Function Calling,或工具描述不清晰 | 查看模型返回是否包含 tool_calls、看模型列表 | 换支持工具调用的模型,或强化 system 指令 |
| 工具调用后报参数缺失 | 用户没有提供 resume_id/job_id | 打印 arguments 参数 | 增加参数抽取校验,缺失时先反问用户 |
| 工具结果返回后仍生成错误结论 | 工具结果没有回填到上下文,或 tool_call_id 不匹配 | 查看消息列表中的 role=tool 记录 | 让工具结果尽量结构化,保留原始命中字段 |
| 检索结果不相关 | 切分过长、没有元数据过滤、向量模型不适合 | 检查召回 top-k 的内容 | 细化切分、增加重排、缩小业务过滤范围 |
| 用户第二次提问时找不到上次任务 | 只存聊天记录,没存业务状态 | 查看 thread 表 state | 在 thread 表持久化 resume_id、job_id、state |
排查顺序建议:先确认用户输入参数,再确认文件路径或业务 ID 是否正确,再确认配置和依赖版本,然后看日志中的 tool_calls 与 tool 返回,最后才考虑模型能力问题。不要一上来就改 Prompt。
6.2 发布前检查清单
下面这份清单可以直接用于项目自查:
- 模型支持工具调用,并且已在指定 endpoint 上验证过。
- 每个工具都有明确的触发条件和参数说明,参数缺失时有追问逻辑。
- 所有工具执行结果都带业务 ID,能被回填到上下文;单次任务有最大步数限制。
- 数据落库包含 timeline 或创建时间,能在出错时回放。
- 用户简历、岗位文本等数据在发送给模型服务前经过最小化脱敏处理。
- 向量索引和关系数据能同步更新;简历变更会触发重新向量化。
- 会话和任务状态写在服务端,不依赖前端保持。
- 日志覆盖用户原始输入、tool_calls、工具结果、最终输出,不记录明文密钥。
- 针对至少 50 条业务样本做了回归测试,并记录评分和缺失技能是否准确。
- 定义人工兜底入口,当 Agent 多次循环未完成或用户主动触发“转人工”时,能接管会话。
这类求职搭子项目的长期价值不一定来自“第一次回答多机智”,更可能来自“用户上传简历三个月后,它还记得当初推荐的岗位方向,并能根据面试结果动态调整下一轮计划”。建议新团队先用最小匹配流程跑通工具调用,再加入 RAG 和状态机,最后补安全、评测和人工兜底。每一步都把日志和验证做好,Agent 能力才能从演示走向稳定服务。