简介:这是一个基于人工智能技术的智能面试系统毕业设计源码,面向计算机相关专业学生及需要完成同类课程设计的人。系统结合自然语言处理、机器学习与计算机视觉,实现面试中的语音识别、语义分析、表情捕捉与自动评分,并覆盖数据存储、用户界面、实时通信、云服务集成等完整模块,有助于理解AI应用从算法到工程落地的过程。资源包共13个文件,以4个Python源码文件为核心,配合TOML、Dockerfile、requirements.txt等配置与部署文件,以及README说明文档、示例图片和输入数据集CSV,整体仅468KB,轻量易部署。目前已有79人学习浏览,适合作为毕业设计参考或课程作业的基础框架。通过阅读源码和文档,可以学习如何调用大模型接口实现面试问答、如何构建Streamlit界面,以及如何配置容器化环境,具备较强的实践参考价值。
1. 智能面试系统:毕设选题里最容易被低估的一条路
不少人把“智能面试系统”想成一套复杂的人事系统,觉得要上语音识别、视频流、题库管理一大堆东西。实际上,毕设和课程作业里最常见的形态就一条链路:上传简历,系统按岗位生成问题,候选人作答,系统给出评分和评语。只要这四步闭环跑通,演示效果就立得住。
这个标题背后真正有价值的部分,不是界面有多炫,而是“简历内容怎么变成题目、回答内容怎么变成分数”这两段逻辑。适合的人群也很明确:要交毕设、要做课程大作业,且希望项目能讲清楚、能演示、能被追问住的人。它最大的隐藏成本在链路打通和现场演示的稳定性上,这两件事做好了,项目就赢了一大半。
2. 系统长什么样:从功能边界到最小可运行骨架
2.1 功能边界:四个模块一张调用链
我见过不少课程项目组一上来就画八个模块,最后连简历解析都交不了差。做智能面试系统,最稳的做法是把范围先锁在四个模块上。
| 模块 | 必做程度 | 主要内容 | 加分项 |
|---|---|---|---|
| 简历上传与解析 | 必做 | 上传 PDF/Word,抽取姓名、技能、教育经历 | 扫描件识别、技能标签化 |
| 题目生成 | 必做 | 按岗位和简历生成开场题、专业题、追问 | 难度分级、题库人工干预 |
| 问答交互 | 可裁剪 | 文本问答记录回答内容 | 音视频回放、语音转写 |
| 评估与报告 | 必做 | 多维评分、评语、结果展示 | 评分依据引用、历史报告 |
四个模块的调用顺序是固定的:简历先进来,解析完得到技能点;技能点喂给题目生成模块;候选人回答后进入评估模块;评估结果落到报告页。这里面最容易被忽略的是“追问”环节,很多作业做到后面,系统只会抛固定题目,答完就结束,没有追问逻辑,答辩时会被评委一句“这跟问卷有什么区别”问住。
2.2 技术栈选型:FastAPI + React 的组合为什么省力
技术栈的选择直接决定你能不能在两周内把核心链路跑通。我一般会推荐后端用 FastAPI,前端用 React 或 Vue,数据库先用 SQLite 起步。
| 环节 | 推荐方案 | 理由 | 什么时候劝退 |
|---|---|---|---|
| 后端框架 | FastAPI | 异步支持好,Pydantic 做请求校验很省事,接大模型接口顺手 | 需要复杂定时任务工作流时 |
| 前端 | React / Vue | 生态成熟,找个管理后台模板就能改 | 组员全是后端,硬写前端会拖垮进度 |
| 数据库 | SQLite 起步 | 零配置,单文件,备份方便 | 并发量上来之后换 MySQL |
| 大模型接入 | 商用 API 起步 | 效果稳定,省去本地部署时间 | 演示环境要求完全离线时 |
| 向量检索 | 可选 | 做简历相似题检索、历史回答召回 | 题目量小的时候没必要引入 |
选型就一条原则:毕设的核心是讲清楚链路和理解每个环节的边界,不是比谁塞的技术名词多。用 SQLite 不代表 low,你只要能在报告里说明“数据量级和并发量评估后选择 SQLite,后续可迁移到 MySQL”,这就是一句合格的工程理由。
2.3 最小可运行骨架:问-答-评闭环的 FastAPI 示例
先把骨架跑起来,再去填细节。下面这段代码就是整个系统最核心的闭环,只有两个接口:开始面试和提交答案。
# app.py —— 最精简的智能面试闭环 from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class StartRequest(BaseModel): resume_text: str # 简历文本,先由解析模块生成 class AnswerRequest(BaseModel): question_id: str answer: str # 用一个列表模拟题库,跑通后再换成大模型生成 QUESTION_POOL = [ {"id": "q1", "text": "请介绍一下你在项目里承担的角色。"}, {"id": "q2", "text": "遇到线上故障时,你的排查步骤是什么?"}, ] @app.post("/api/interview/start") def start_interview(req: StartRequest): # 实际项目里这里会调用简历解析和题目生成模块 return {"session_id": "demo-001", "question": QUESTION_POOL[0]} @app.post("/api/interview/answer") def submit_answer(req: AnswerRequest): # 先用关键词兜底,后续替换成真正的评分模块 score = 65 if "排查" in req.answer: score += 10 return {"score": score, "comment": "回答覆盖了主要逻辑,细节可以再展开。"}这个骨架里有两个关键设计。第一,请求体用 Pydantic 定义,前端传错字段时 FastAPI 会自动返回 422 错误,不用自己写校验。第二,评分函数被刻意做得很简陋,是为了让你知道这里是一个明确的替换点,后面接大模型评分或混合评分时,只需要改这一个函数。
参数上要注意的是resume_text字段。简历解析模块输出的文本会直接喂给题目生成模块,所以这个字段的设计不要只放一个字符串,后面可以扩展成结构化对象,包含技能列表、岗位目标、项目经历等字段。接口返回体里的session_id也很重要,后续的追问逻辑、状态机控制都要靠它关联会话状态。
3. 把简历解析和题目生成落到实处:模型选择与接口配置
3.1 简历解析的三条路线:正则、解析管线、大模型抽取
简历解析是第一个翻车高发区。常见做法有三条路线,难度和效果递增。
| 路线 | 做法 | 效果 | 适用场景 |
|---|---|---|---|
| 正则路线 | 对文本做正则匹配抓电话、邮箱、技能关键词 | 字段抽取率低,格式一变就失效 | 只做固定模板的简历 Demo |
| 解析管线路线 | 先用 PDF 库抽文本,再做规则和字典匹配 | 稳定,但需要维护一套规则 | 简历来源相对固定的课程作业 |
| 大模型抽取路线 | 把文本交给大模型,按 JSON 结构返回字段 | 泛化能力最强,解析率高 | 毕设和大部分真实场景 |
真正的坑在第一步:很多人直接把 PDF 文件丢给大模型或正则库,结果扫描版简历抽出来全是乱码。正确的顺序是先抽取文本层,再判断文本质量,最后才交给模型或规则。
import fitz # PyMuPDF,负责提取 PDF 文本层 def extract_resume_text(pdf_path: str) -> str: doc = fitz.open(pdf_path) text = "\n".join(page.get_text() for page in doc) return text.strip() # 如果 text 字符数少于阈值,说明是扫描版,需要提示用户换上传方式文本抽完,下一步才进入大模型抽取。这里我一般用的提示词模板长这样:
schema_prompt = """ 请从简历文本中抽取如下字段,只返回JSON: { "name": "姓名", "skills": ["技能1", "技能2"], "education": [ {"school": "学校", "major": "专业", "degree": "学历"} ], "experience": [ {"company": "公司", "role": "职位", "summary": "工作概述"} ] } 简历文本: {resume_text} """这个模板有两个注意点。第一,字段名用英文,值用中文,方便后续程序处理和展示。第二,只返回 JSON 这几个字要写在模板里,配合服务端的结构化输出参数一起用,能很大程度避免模型返回大段解释文字。抽取完成后建议做一层校验,比如姓名是否是空、技能列表是否为空,为空就返回给用户“简历内容识别失败,请上传文字版”的提示,而不是让系统带着空数据往下走。
3.2 题目生成:岗位维度、追问逻辑与结构化输出
题目生成模块是整个系统的门面,评委大概率会现场试这个环节。生成逻辑建议分三层:开场题、专业题、追问建议。开场题固定,专业题由技能点映射,追问建议留给问答环节动态触发。
question_prompt = """ 你是面试官,岗位是{position}。 根据简历中的技能点{skills}生成3道面试题。 每道题要求: 1. 题目落在岗位职责范围内,不要问无关领域; 2. 至少包含一道追问建议; 3. 返回JSON数组: [ { "question": "题目内容", "category": "专业题/场景题/开放题", "difficulty": "1-5", "follow_up": "追问建议" } ] """调大模型接口时,有几个参数值得单独讲。temperature建议设在 0.3 到 0.5 之间,面试题生成不是创意写作,温度太高会出“如何做红烧肉”这类跟岗位无关的题。max_tokens按 3 道题的量级给到 800 到 1200 就够,给太少容易截断,给太多浪费等待时间。response_format如果有 JSON 模式就直接开,没有的话必须在提示词里强调“只返回 JSON”,并在代码里做解析兜底。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| temperature | 0.3 - 0.5 | 越高越发散,越低越稳定 |
| max_tokens | 800 - 1200 | 覆盖 3 道题加追问建议 |
| response_format | json_object | 结构化输出的主要保障 |
| timeout | 8 - 10 秒 | 太长前端体验会崩 |
追问逻辑是这个模块里最容易被做成“空壳”的部分。最简单的实现是:用户回答完一道题后,把回答内容和 follow_up 字段一起发给大模型,让它生成一个针对性追问。而不是像问卷一样答完就切下一题。这一个点就能让答辩时的演示观感完全不同。
3.3 评估与评分:关键词、语义相似度、大模型打分怎么取舍
评分模块决定了系统的可信度。毕设答辩时最常见的逼问就是“你这个分数怎么算出来的”,所以评分逻辑必须能解释清楚。
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 关键词评分 | 可解释性最强 | 换种说法就漏分 | 兜底方案、启动阶段 |
| 句向量相似度 | 能抓语义 | 需要准备参考答案,且分数含义难解释 | 与参考答案做对比时 |
| 大模型评分 | 维度丰富 | 分数不稳定、调用有成本 | 主体评分方式 |
三种方式通常是组合用。我一般会先用关键词评分做基础保障,再用大模型给出多维度评分,最后加权合并。这样即使大模型超时或抽风,系统也有一个兜底分数返回,不会白屏。
def hybrid_score(candidate_answer: str, reference_keywords: list) -> dict: # 第一层:关键词得分 hit = [k for k in reference_keywords if k in candidate_answer] keyword_score = round(len(hit) / max(len(reference_keywords), 1), 2) # 第二层:交给 LLM 打主观分(示意,实际调用外部服务) llm_score = call_llm_score(candidate_answer) # 第三层:按权重合并 final_score = int(0.4 * keyword_score * 100 + 0.6 * llm_score) return {"score": final_score, "hit_keywords": hit}这个函数里最值得调的是权重。如果关键词命中率和 LLM 评分结果经常打架,可以把关键词权重降到 0.3,LLM 权重提到 0.7。另外,评分结果里一定要带上命中的关键词列表,这个字段在报告页直接展示成“本题要点覆盖”,能大幅提高评分的说服力。
大模型评分的提示词里,要强制要求输出“分数 + 理由 + 建议”,并且理由里必须引用回答原文片段。有了原文引用,评委再问“为什么给 70 分”,你就能指着一句话说是模型基于这句回答给出的判断,这就不是玄学了。
4. 从本机跑通到能演示:数据、界面与问答体验的施工顺序
4.1 先用模拟数据把端到端流程跑通
很多课程项目组习惯先把前端页面画完,再回头接接口,结果前后端联调时到处对不上。我建议的顺序是反过来的:先造一套模拟数据,把后端的四段链路跑通,再开始写界面。
| 数据文件 | 作用 | 样例内容 |
|---|---|---|
| sample_resume.pdf | 测试简历上传与解析 | 含姓名、技能、项目经历的完整简历 |
| question_pool.json | 测试题目生成与题库下发 | 按岗位分类的 10 道题 |
| sample_answer_pairs.json | 测试评分逻辑 | 标准答案和错误答案成对出现 |
拿到这批数据后,不建议直接用浏览器测,写一个几十行的脚本跑接口更快,失败时能看到完整报错栈。
curl -X POST http://localhost:8000/api/interview/start \ -H "Content-Type: application/json" \ -d '{"resume_text": "熟悉 Java、Spring Boot,做过电商项目"}'接口通了之后再进入下一步。这里有一个经验:模拟数据要故意保留一些残缺字段,比如简历里没有手机号、技能列表里有重复项,这样后端校验逻辑才能提前暴露问题。演示时评委上传的简历千奇百怪,先在家把怪数据测一遍,现场就不慌。
4.2 提问链路:状态机、超时与追问控制
问答环节是系统里最容易出现状态错乱的部分。用户在页面上每提交一次答案,后端必须清楚当前会话处于哪个阶段,否则就会出现“答案还没提交完,下一题已经推出来”的翻车现场。
SESSION_STATE = { "session_id": "demo-001", "state": "QUESTION", # INIT / QUESTION / WAITING / EVALUATING / FINISH "current_question": "q1", "retry_count": 0, }状态流转规则是:接口收到开始面试请求后进入 QUESTION;题目下发后进入 WAITING;用户提交答案时只有 WAITING 状态才能接受,接受后置为 EVALUATING;评分完成回到 QUESTION 并发下一题;全部题目答完置为 FINISH。这个状态机的核心作用就是防重入,不管前端用户怎么连点提交,后端都只认第一次请求。
前端通讯方式上,毕设项目用轮询就足够了,每 2 到 3 秒查一次状态,复杂度低,也稳定。WebSocket 虽然实时性好,但项目里没有强实时聊天需求,引入它会多出一堆断线重连的问题要处理。
4.3 演示加分项:过程可回放与评分可解释
答辩演示最怕的不是功能少,而是现场出意外。我见到的高分项目通常都准备了一套降级方案。
| 演示准备项 | 做法 | 目的 |
|---|---|---|
| 音频录制 | 问答阶段录下回答音频 | 展示过程可回放 |
| 转写文本 | 录制后转成文字并入库 | 为评分提供依据 |
| 评分依据展示 | 报告页显示命中的关键词和模型评语 | 回答“分数怎么来” |
| 断网降级 | 本地题库 + 预生成样例结果 | 现场网络故障时兜底 |
现场演示时,一定不要全程依赖外部大模型接口。常见做法是准备两条链路:一条真实链路,接入大模型现场问答,气氛最好;一条降级链路,用本地题库和提前生成好的结果走完流程。一旦发现接口响应超过 3 秒,就果断切到降级链路,宁可展示预生成结果,也不要让全场干等转圈。
注意:评分报告页一定要能指出“这条评语是根据你回答中哪几句话生成的”,只给一个分数的系统在答辩时是站不住的。
5. 智能面试系统的 5 个常见问题:现象、原因与排查手册
5.1 解析与生成环节的三个坑
问题 1:解析后重要字段大面积为空
现象:上传 PDF 后,姓名能拿到,但技能列表、项目经历全是空数组,或者学校和公司张冠李戴。
原因:最常见的是对 PDF 文件直接做字符串解析。PDF 的文本层可能因为排版问题把同一段文字拆成多个碎片,正则匹配自然失手。另一种常见情况是简历本身是扫描件,根本没有文本层。
解决:解析前先抽文本层,并加一道字符数判断。文本太少时直接提示用户上传 Word 版本或粘贴纯文本,而不是硬着头皮往下走。
def safe_extract_text(pdf_path: str) -> str: # 优先抽文本层 doc = fitz.open(pdf_path) text = "\n".join(page.get_text() for page in doc) # 抽完发现是空文本,多半是扫描件,直接提示换 Word if len(text.strip()) < 20: raise ValueError("没有检测到文本层,请上传可复制的简历或粘贴文本") return text问题 2:生成题目与目标岗位无关
现象:用户选了 Java 岗位,系统却生成一道关于数据库分库分表的题,再一测又变成一道脑筋急转弯。
原因:提示词里没有限定岗位范围,加上 temperature 设得偏高。面试题生成对发散度极其敏感,温度超过 0.7 会开始跑偏。
解决:把岗位职责写进系统提示词,并加一条“不要在岗位技能范围外出题”的约束。同时把 temperature 压到 0.4 以下。修改后最好做一次批量回归,连续调用 10 次,确认题目领域全部在范围内才算合格。
问题 3:大模型返回的 JSON 偶尔解析失败
现象:接口明明设置了 JSON 输出模式,但偶尔返回的内容里带了 ```json 标记,或者干脆多了一段解释性文字,导致json.loads抛异常。
原因:不同模型服务对结构化输出参数的支持程度不一致,有的模型即使开了 JSON 模式,面对复杂指令时也会输出 markdown 代码块。
解决:解析前先做一步清洗,把代码块标记去掉再json.loads,失败时重试一次。如果重试还是失败,就返回上一题并记录日志。
def parse_llm_json(raw: str): # 防止模型返回 ```json 包裹 raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```") try: return json.loads(raw) except json.JSONDecodeError: return None5.2 问答链路与部署演示的两个坑
问题 4:问答环节重复提问或状态串线
现象:用户还没答完当前题,前端又推送了下一题;或者用户明明在回答第二题,后端的评分却算在了第一题上。
原因:后端没有做状态校验。前端页面可能因为重试、连点把同一个回答提交了多次,后端全盘接收,于是状态被覆盖。
解决:在提交答案接口里先判断会话状态,只有 WAITING 状态才允许提交,提交后立刻置为 EVALUATING。评分完成后再回到 QUESTION。这样即使前端重复调用,后端的幂等校验也能拦住。
def submit_answer(session_id: str, answer: str): st = get_session(session_id) if st["state"] != "WAITING": raise HTTPException(status_code=400, detail="当前不处于等待回答状态") st["state"] = "EVALUATING" # 评分完成后 st["state"] = "QUESTION"问题 5:演示现场接口超时,页面一直转圈
现象:演示时现场网络不稳定,大模型接口 5 秒没响应,前端没有超时处理,整个页面卡死在 loading 状态,问答无法继续。
原因:后端调用大模型没有设置超时时间,前端也没有错误态处理。网络一抖动,请求就挂在那里。
解决:后端加超时参数,一般 8 到 10 秒就足够;超时后自动切换到本地题库继续出题,而不是让链路断掉。前端同时加 loading 取消按钮,用户卡住时可以手动跳过当前题。
# 调用大模型时的超时降级 try: result = llm_client.chat.completions.create(..., timeout=8) except TimeoutError: result = mock_question_pool[0] # 降级到本地题库6. 给系统加一道“鉴别力”:用错误答案评测集检验智能面试效果
6.1 为什么需要评测集
很多人的评分模块全靠肉眼试:自己答两句感觉分数还行就收工。但换几个答案试,评分就忽高忽低,这种现象在调大模型时几乎是必然的。想要系统稳定,就要准备一套固定评测集,像给代码加回归测试一样,每次改完提示词都跑一遍。
6.2 一个最小评测脚本
评测集不用大,20 条足够。要覆盖三类样本:瞎答的、答一半的、答得完整的。脚本的目标是验证“完整答案分数显著高于瞎答答案”。
import json TEST_CASES = [ {"answer": "我不知道,没遇到过这个问题。", "expected_bad": True}, {"answer": "先看监控,再查日志,按时间线定位最近的变更。", "expected_bad": False}, ] def run_eval(): ok = 0 for case in TEST_CASES: score = hybrid_score(case["answer"], ["监控", "日志", "变更"]) good = score < 60 if case["expected_bad"] else score >= 60 ok += 1 if good else 0 print(f"通过率: {ok}/{len(TEST_CASES)}")这个脚本里hybrid_score是第 3.3 节那个评分函数,你可以直接替换成自己实现。跑一次如果通过率不到 80%,就说明评分逻辑需要调,可能是关键词列表不全,也可能是大模型评分的提示词约束不够。我一般会把这类评测集固定下来,每次改完提示词先跑一遍,分数分布不对就说明改坏了,再回滚。做到这一步,评分模块就不再是黑匣子,答辩时也讲得出自己的验证方法。希望帮到你。
本文还有配套的精品资源,点击获取