基于MCP协议构建AI可查询的简历解析服务器实战
1. 项目缘起:当AI面试官需要一份“活”的简历
最近在折腾AI Agent开发时,遇到一个挺实际的问题:我想让一个负责技术招聘的AI Agent帮我筛选简历,或者模拟面试。最直接的想法,就是把候选人的PDF简历文件喂给它。但实际操作起来,问题一大堆。PDF文件对AI来说,就像一本被胶水粘起来的书——它能“看到”文字,但很难理解里面的结构。一份简历里的“工作经历”、“项目经验”、“技能清单”是几个关键模块,但PDF解析后常常变成一锅粥,AI分不清哪段文字属于哪个部分。更别提那些用设计软件做的、文字是矢量图形或者图片的简历了,OCR识别效果时好时坏,格式更是丢得一干二净。
这时候,我接触到了MCP(Model Context Protocol)。简单来说,MCP不是一个具体的工具,而是一套“协议”或“标准”。它由Anthropic提出,目的是让大模型(比如Claude)能够安全、结构化地访问外部工具、数据和计算资源。你可以把它想象成给AI Agent装上一个标准的“USB接口”,通过这个接口,AI可以按需、精准地调用各种“外设”(服务器),而不是一次性吞下所有杂乱无章的原始数据。
于是,一个想法就诞生了:为什么不把静态的PDF简历,通过一个MCP Server,转换成AI Agent可以按需、精准查询的“动态数据库”呢?这样一来,AI不需要每次都全文阅读并理解整个PDF,它只需要像我们人类面试官一样,提出具体问题:“请列出这位候选人过去三年的工作经历”、“他在XX项目中承担的核心职责是什么”、“他的Java熟练程度如何”。而我们的MCP Server,就扮演那个对简历内容了如指掌的“秘书”,快速从结构化的数据中找出答案,精准回复给AI Agent。这个项目,就是搭建这样一个“简历查询秘书”——一个能将PDF简历内容结构化,并通过MCP协议暴露给AI Agent进行智能查询的服务器。
2. 核心架构设计:简历MCP Server的蓝图
要实现“按需查询”,核心是构建一个MCP Server。这个Server需要完成两件大事:一是解析与结构化,把非结构化的PDF简历变成机器好懂的数据;二是接口与查询,按照MCP协议提供工具,让AI Agent能调用这些工具来提问。
2.1 技术栈选型与理由
首先得把技术架子搭起来。经过一番调研和对比,我选择了以下组合:
- 后端框架:FastAPI。选择它的理由很直接:轻量、异步支持好、自动生成API文档(Swagger UI)。MCP Server本质上是一个HTTP服务器,需要处理AI Agent(客户端)发来的工具调用请求。FastAPI的异步特性在处理可能耗时的PDF解析或复杂查询时,能更好地保持服务的响应性。而且其基于Pydantic的数据验证,能让我们非常严谨地定义工具输入输出的“形状”,这对于和AI交互至关重要——数据格式错一点,AI可能就理解不了。
- PDF解析库:pdfplumber 和 PyMuPDF (fitz)。为什么选两个?因为它们互补。
pdfplumber在提取文本、特别是保留文本的布局信息(如坐标、表格结构)方面非常出色,对于格式规整的简历解析准确率高。而PyMuPDF速度极快,处理某些复杂文档或需要渲染页面为图片进行备用OCR时更有优势。我的策略是优先使用pdfplumber,如果遇到解析结果异常(比如提取出的文字杂乱无章),则降级使用PyMuPDF,并考虑触发OCR流程。 - OCR引擎:Tesseract。这是开源界的常青树。当简历是扫描件或包含图片形式的文字时,它就是救星。虽然云服务(如Azure、Google的OCR)可能更准,但考虑到项目的可离线部署性和成本,Tesseract是更独立自主的选择。我会配合
Pillow库进行图像预处理(如二值化、降噪),来提升识别精度。 - 大模型交互(可选):OpenAI / Anthropic API。注意,这里是“可选”且用于Server内部增强功能。MCP Server本身不一定要用大模型,但我们可以利用大模型来做一个更智能的“信息提取与标准化”层。例如,将
pdfplumber提取的原始文本块,喂给大模型,指令其按照预定格式(如JSON Schema)输出结构化的简历信息。这能极大提升从五花八门的简历格式中抽取关键信息的鲁棒性。这一步不是必须的,但它代表了进阶玩法。 - MCP协议实现:我们需要实现MCP的特定接口。核心是提供
tools列表和resources(本项目以工具为主)。Anthropic提供了官方的mcpPython SDK,它大大简化了协议层的实现。我们会用它来注册我们定义的查询工具。
整个数据流的设计思路是这样的:
- 初始化阶段:Server启动后,可以预加载一批简历PDF到指定目录,或者提供上传接口。
- 解析阶段:当一份新简历加入,Server后台自动或手动触发解析流水线(pdfplumber -> 若效果差则降级 -> 若为图片则OCR),最终产出结构化的简历数据(字典或JSON对象)。
- 存储阶段:将结构化的简历数据存储起来。为了简单,第一期我用内存字典或本地小文件(如JSON)来存,key是简历ID(可以是文件名哈希),value是结构化数据。生产环境可以考虑SQLite或轻量级文档数据库。
- 服务阶段:AI Agent(如Claude Code)通过MCP连接到本Server。Agent看到我们暴露的“工具”,例如
query_resume。当Agent想了解某个候选人的信息时,它调用这个工具,传入参数(简历ID, 问题)。Server收到请求后,根据简历ID找到结构化数据,再根据“问题”在数据中查找或进行简单的自然语言匹配(后期可集成向量搜索),将结果返回。 - 返回阶段:结果以清晰的文本格式返回给AI Agent,Agent再将其整合到自己的思考或回复中。
2.2 MCP工具的设计:定义AI的“提问方式”
这是项目的灵魂。我们不能只给AI一个“简历数据”资源,让它自己读。我们要设计好工具,引导它如何提问。
首先,我设计了一个核心工具,叫get_resume_info。
- 功能:获取一份简历的概览信息。
- 参数:
resume_id(字符串, 必填)。比如可以是”candidate_zhangsan_2024”。 - 返回:一个结构化的文本摘要,例如:“候选人:张三 | 当前职位:高级Java开发工程师 | 工作年限:8年 | 主要技能:Java, Spring Cloud, MySQL, Redis | 最近公司:ABC科技”。
这个工具让AI能快速建立对候选人的第一印象。
然后,是最重要的query_resume_section工具。
- 功能:查询简历的特定部分。
- 参数:
resume_id(字符串, 必填)section(字符串, 必填)。这里我们不是让AI自由输入问题,而是给它一个“下拉菜单”。选项包括:"work_experience","project_experience","education","skills","self_introduction"。这样设计是为了保证查询的精确性,避免歧义。query(字符串, 可选)。在指定部分内的进一步查询。例如,section为"work_experience",query可以为“在2020年至2022年间的工作经历”。
- 返回:对应部分的详细内容。如果提供了
query,则尝试在该部分内容中进行关键词匹配或简单语义筛选后返回。
例如,AI Agent可以这样发起调用:
调用工具:query_resume_section 参数:{“resume_id”: “candidate_zhangsan_2024”, “section”: “project_experience”, “query”: “微服务架构”}Server收到后,会在张三的“项目经验”里,寻找描述中包含“微服务”字样的项目,返回具体描述。
最后,我还设计了一个辅助工具list_resumes。
- 功能:列出当前Server管理的所有简历ID和候选人姓名。
- 参数:无。
- 返回:一个简历列表。这帮助AI Agent在开始一系列查询前,知道有哪些候选人可供选择。
注意:工具的参数设计遵循“结构化优于非结构化”原则。直接让AI问一个自由文本问题(如“张三会Redis吗?”),虽然更灵活,但实现起来复杂很多,需要Server端集成一个轻量级的NLP模型或进行复杂的规则匹配,容易出错。而通过
section参数约束范围,能极大提高查询的准确率和Server的响应速度。这是一种在能力与复杂度之间的权衡。
3. 实现细节:从PDF乱码到结构化的JSON
蓝图画好了,接下来就是动手编码。最棘手、最核心的一步,就是把PDF里那些格式不一的文字,变成整齐的结构化数据。
3.1 PDF解析的实战与陷阱
我首先用pdfplumber写了一个解析函数。基础代码很简单:
import pdfplumber def parse_resume_with_pdfplumber(pdf_path): text_content = [] with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: # 提取文本,并尝试保留布局 text = page.extract_text(layout=True) # layout=True有助于保留一些顺序 if text: text_content.append(text) return "\n".join(text_content)但很快就踩了坑。坑一:布局保留并非万能。layout=True对于简单的两栏简历可能有效,但遇到更复杂的排版,提取出的文本顺序依然可能是乱的。比如“技能”部分的标题跑到了“工作经历”的内容后面。坑二:表格处理。很多简历用表格来排列时间线和经历。pdfplumber的page.extract_table()能提取表格,但需要指定区域,且不同简历的表格样式天差地别。
我的应对策略是“分层解析与启发式规则”:
- 原始文本提取:先用
pdfplumber以layout=False(更依赖PDF内部的文本流顺序)和layout=True各提取一次,对比结果,选择段落顺序更合理的一个。 - 区块探测:利用
pdfplumber获取每个文本字符的坐标。我写了一个简单的算法,将同一水平线上、间距接近的字符聚合成“行”,再将垂直方向接近的“行”聚合成“区块”。这样能得到一个粗略的物理布局区块列表。 - 关键词锚定与分割:这是将无结构文本转为结构的关键。我定义了一个简历章节的关键词词典:
然后遍历文本行或区块,寻找包含这些关键词的行,将其作为“章节标题”。从这个标题开始,到下一个章节标题出现之前的所有内容,都归属于这个章节。这是一种简单但非常有效的规则方法。SECTION_KEYWORDS = { "work_experience": ["工作经历", "工作经验", "employment", "work history"], "project_experience": ["项目经验", "项目经历", "project experience"], "education": ["教育背景", "学历", "education"], "skills": ["专业技能", "技术栈", "skills", "technologies"], # ... 其他部分 } - 表格特殊处理:对于疑似表格的区域,我会尝试用
pdfplumber的表格提取功能。如果提取成功,则将表格内容转换为Markdown格式的字符串,附加到对应章节(通常是工作或项目经历)中,这样AI也能较好地理解表格信息。
当pdfplumber解析出的文本质量极差(比如全是乱码或空白)时,流程会降级到PyMuPDF:
import fitz # PyMuPDF def parse_resume_with_fitz(pdf_path): doc = fitz.open(pdf_path) text_content = [] for page in doc: text = page.get_text("text") # 获取纯文本 text_content.append(text) return "\n".join(text_content)如果PyMuPDF提取的文字还是很少,且页面中有图像,就会触发OCR流程:用PyMuPDF将页面渲染为图像,然后用Pillow预处理,最后交给Tesseract识别。
3.2 利用大模型进行智能结构化
基于规则的分割在大多数情况下工作良好,但对于那些设计花哨、关键词不标准(比如用图标代替“工作经历”标题)的简历,就力不从心了。这时,我引入了“大模型清洗”这一步作为增强。
我的做法是,将前面步骤提取出的“相对干净的全文文本”,发送给大模型API(比如GPT-4或Claude-3),并给出一个非常详细的指令(Prompt):
你是一个专业的简历解析助手。请将以下简历文本,严格按照下面的JSON格式输出,只输出JSON,不要任何其他解释。 JSON格式: { "candidate_name": "候选人姓名", "work_experience": [{"company": "公司名", "position": "职位", "period": "时间段", "description": "工作描述"}], "project_experience": [{"project_name": "项目名", "role": "担任角色", "period": "时间段", "description": "项目描述", "technologies": ["技术1", "技术2"]}], "education": [{"school": "学校", "degree": "学历", "major": "专业", "period": "时间段"}], "skills": {"programming_languages": ["语言1", "语言2"], "frameworks": ["框架1", "框架2"], "tools": ["工具1", "工具2"]}, "self_introduction": "自我介绍文本" } 需要解析的简历文本: {这里是提取的简历全文}大模型强大的理解能力,能很好地从自由文本中抽取出结构化信息,并填到指定的JSON字段中。这样得到的结构化数据质量非常高,直接就可以用于后续的查询。当然,这会产生API调用成本,并且依赖网络。因此,我在Server中将其配置为一个可选项,对于重要或解析困难的简历才开启。
实操心得:不要试图让大模型直接从原始PDF二进制数据开始处理。先利用本地库进行初步的文本提取和清理,哪怕只是把文本按顺序拼出来,也能极大降低大模型处理的难度和Token消耗,提高解析成功率。这是一种“本地预处理+云端精加工”的混合策略。
3.3 构建MCP Server并暴露工具
有了结构化数据,接下来就是用mcp库来搭建Server了。以下是核心代码框架:
from mcp import Server, Tool import json # 假设我们有一个全局的简历存储字典 resume_store = { "resume_1": {...}, # 结构化的简历数据 "resume_2": {...}, } # 定义工具 list_resumes_tool = Tool( name="list_resumes", description="列出所有可查询的简历ID和候选人姓名。", input_schema={"type": "object", "properties": {}} # 无输入参数 ) query_resume_section_tool = Tool( name="query_resume_section", description="查询特定简历的某个部分。", input_schema={ "type": "object", "properties": { "resume_id": {"type": "string", "description": "简历的唯一标识符"}, "section": {"type": "string", "enum": ["work_experience", "project_experience", "education", "skills", "self_introduction"], "description": "要查询的简历部分"}, "query": {"type": "string", "description": "在该部分内进行筛选的查询词(可选)"} }, "required": ["resume_id", "section"] } ) # 创建Server实例 server = Server("resume-mcp-server") # 注册工具 @server.tool() async def list_resumes() -> str: """实现列出简历的逻辑""" result = [] for rid, data in resume_store.items(): result.append(f"ID: {rid}, 姓名: {data.get('candidate_name', 'N/A')}") return "\n".join(result) if result else "当前没有简历数据。" @server.tool() async def query_resume_section(resume_id: str, section: str, query: str = None) -> str: """实现查询简历部分的逻辑""" if resume_id not in resume_store: return f"错误:未找到ID为 '{resume_id}' 的简历。" resume_data = resume_store[resume_id] if section not in resume_data: return f"错误:该简历中没有 '{section}' 部分。" content = resume_data[section] # 如果content是列表(如工作经历),将其格式化为易读文本 if isinstance(content, list): formatted_content = [] for item in content: formatted_content.append(json.dumps(item, ensure_ascii=False, indent=2)) result_text = "\n---\n".join(formatted_content) else: result_text = str(content) # 如果提供了query,进行简单过滤(这里用字符串包含作为示例) if query: if isinstance(content, list): filtered = [item for item in content if any(query.lower() in str(v).lower() for v in item.values())] result_text = "\n---\n".join([json.dumps(item, ensure_ascii=False) for item in filtered]) if filtered else "未找到匹配内容。" else: if query.lower() in result_text.lower(): pass # 保留全文 else: result_text = "未找到匹配内容。" return result_text # 运行Server (例如使用uvicorn) if __name__ == "__main__": import uvicorn uvicorn.run(server.app, host="0.0.0.0", port=8000)这样,一个具备基本查询功能的简历MCP Server就搭建完成了。它运行在http://localhost:8000,等待AI Agent通过MCP客户端来连接和调用工具。
4. 与AI Agent集成:以Claude Code为例
Server跑起来了,怎么让AI Agent用上它呢?这里以集成到Claude Code(或任何支持MCP的Claude环境)为例。
首先,你需要在运行AI Agent的环境(比如你的开发机)上,确保我们的简历MCP Server正在运行(python server.py)。
然后,配置AI Agent的MCP客户端来连接我们的Server。具体方式因客户端而异。对于Claude Desktop或支持MCP的IDE插件,通常需要一个配置文件。例如,在Claude Desktop的配置中(位于~/Library/Application Support/Claude/claude_desktop_config.jsonon Mac),添加:
{ "mcpServers": { "resume-server": { "command": "python", "args": ["/绝对路径/到/你的/server.py"], "env": { "PYTHONPATH": "/你的/项目/路径" } } } }或者,如果Server已经作为独立进程运行,可以配置为通过Stdio或HTTP连接。更常见的是通过HTTP连接一个已启动的Server:
{ "mcpServers": { "resume-query": { "url": "http://localhost:8000" } } }配置完成后,重启Claude Code。当你在聊天框中与Claude交互时,Claude就能“看到”我们Server提供的工具了。你可以这样和它对话:
你:“我现在要筛选一些Java开发者的简历。请先帮我看看有哪些候选人。”Claude:(调用
list_resumes工具) “当前可查询的简历有:1. ID: resume_1, 姓名: 张三; 2. ID: resume_2, 姓名: 李四。”你:“我想了解张三的项目经验,特别是涉及微服务和Redis的。”Claude:(调用query_resume_section工具,参数:resume_id=“resume_1”, section=“project_experience”, query=“微服务 Redis”) “以下是张三相关的项目经验:1. 项目:XX电商平台微服务重构... 使用了Spring Cloud, Redis作为缓存... 2....”你:“他的工作年限是多少?”Claude:(需要先理解“工作年限”可能从work_experience中的时间段推算,或直接查询skills部分是否有注明。它可能会先调用query_resume_section查看work_experience,然后自己计算,或者如果简历结构数据中有直接字段,它也可能尝试查询一个不存在的字段而报错。这提示我们,工具设计可以更细致,比如增加一个get_candidate_summary工具,直接返回年限、当前职位等摘要信息。)
这个过程展示了AI Agent如何“按需”查询,而不是被动接收整个文档。它可以根据对话的上下文,主动决定调用哪个工具、传递什么参数,来获取它完成任务所需的具体信息。
5. 踩坑实录与进阶优化方向
在实际开发和测试中,我遇到了不少问题,也总结出一些优化思路。
坑一:PDF解析的准确率是波动最大的因素。我收集了十几份风格各异的简历进行测试,发现对于纯文本、排版简单的简历,解析和分割准确率能达到90%以上。但对于使用了复杂字体、大量图标、多栏排版、或者根本就是图片的简历,规则方法就捉襟见肘了。解决方案:建立了一个“解析信心度”评分机制。根据提取出的文本长度、章节关键词匹配数量、是否存在明显的乱序等指标,给每次解析打分。低信心的简历会自动标记,并建议通过“大模型清洗”通道重新处理,或者提醒用户可能需要手动校正。
坑二:MCP工具调用的错误处理。最初,当工具调用出错(如简历ID不存在),我直接返回Python异常信息给AI Agent。结果Claude有时会被这些技术性的错误信息搞糊涂,影响后续对话。解决方案:在所有工具函数内部进行完善的错误捕获,并返回对AI友好、可操作的错误信息。例如,“未找到该简历,请使用list_resumes工具查看可用简历。”而不是“KeyError: 'resume_xyz'”。
坑三:AI Agent对工具的理解和使用。即使工具描述写得很清楚,AI有时也会以意想不到的方式调用,或者不理解某些参数的枚举值(enum)。解决方案:一是优化工具的描述(description),使用更自然、包含示例的语言。二是在Server端对输入参数做更严格的验证和人性化的提示。三是提供更细粒度的工具。例如,除了按部分查询,可以增加一个search_resume工具,允许跨字段的简单关键词搜索,满足AI更自由的提问方式。
进阶优化方向:
- 向量搜索集成:当前的查询主要基于关键词匹配。要实现“类似经历查询”或更模糊的语义搜索(如“有高并发处理经验的项目”),可以将每段工作经历、项目描述的文本转换为向量嵌入(embedding),存入向量数据库(如Chroma、Qdrant)。在
query参数传入时,也将其向量化,进行相似度搜索。这能让查询能力产生质变。 - 简历比对与排序:可以开发新工具,如
compare_candidates,输入两个简历ID和一个能力维度(如“Java深度”、“架构经验”),让Server基于结构化数据给出对比分析。或者rank_by_skill,输入一个技能列表,返回候选人匹配度的排序。 - 持久化与数据库:将内存存储换成SQLite或TinyDB,支持简历的增删改查(CRUD)管理,并记录每次查询日志,用于分析AI Agent的查询模式。
- 标准化与扩展:定义更详细、行业通用的简历JSON Schema(可参考JSON Resume标准),使解析输出的数据结构更统一。同时,支持更多文件格式,如Word (.docx)、Markdown,甚至从LinkedIn等平台导出的数据。
这个项目从一个简单的需求点出发,却串联起了PDF处理、规则引擎、大模型应用、协议开发等多个知识点。它最让我兴奋的地方在于,通过MCP这个“标准接口”,我们为AI Agent赋予了安全、可控、深度的数据访问能力。未来,不仅仅是简历,任何格式的文档(产品文档、知识库、报表)都可以通过类似的MCP Server变成AI可灵活查询的“知识源”。这或许是构建真正实用AI应用的一个小而美的基石。
