从0到1开发一款AI模拟面试工具:我的踩坑与思考
前段时间我给自己安排了一个小目标:做一款能帮人模拟面试的工具。听起来不复杂,但做下来我也碰到不少门道——题库怎么设计才能零代码扩展?AI追问怎么避免重复?激活码怎么防篡改?打包怎么从2.4GB压到94MB?这篇文章记录整个过程,有方案有代码有踩坑,适合想做类似工具或对AI应用落地感兴趣的朋友。
一、先说需求:面试这件事,缺的不是题,是"模拟感"
市面上面试题库一搜一大把,但大多数人缺的不是题目,而是真实的面试体验。
想象一下这个场景:求职者在面试前能对着AI做一次完整的模拟面试——AI出题、AI追问、AI打分,结束后还能导出一份评价报告,发现自己的表达短板。
这就是这款工具的核心定位:不是题库,是一场可以反复练习的AI面试。
具体需求:
- 支持11个主流岗位(行政、财务、技术、产品、运营、销售……)
- 每场15道预设题 + 2-5轮AI追问
- 追问内容基于真实回答,不是随机出题
- 生成五维评分报告,可导出
- 单exe文件,双击即用
二、技术架构:四张图讲清楚核心设计
2.1 整体架构
用户双击exe
│
▼
┌─────────────────────────┐
│ new_run.py(启动入口) │
│ - API Key解密加载 │
│ - 启动Gradio服务(子线程) │
│ - 轮询端口就绪后开浏览器 │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ app.py(面试核心) │
│ - 状态机驱动 │
│ - 题型配额动态计算 │
│ - Gradio流式输出UI │
└──────┬──────────┬────────┘
│ │
▼ ▼
┌──────────┐ ┌────────────────┐
│题库JSON │ │ InterviewAgent │
│(11岗位)│ │ - AI追问选题 │
│questions/│ │ - 闲聊检测 │
└──────────┘ │ - 五维评分 │
└───────┬────────┘
│
▼
阿里云DashScope
(通义千问 / DeepSeek)
2.2 面试状态机
这是整个系统的核心,四种状态严格单向流转:
# app.py 状态定义
class InterviewSession:
phase: str = "idle" # idle → preset → followup → done
questions: Dict[str, List[str]] = {} # 模块名→题目列表
used_questions: set = set() # 已出过的题
interview_agent: InterviewAgent = None # AI代理
# 状态转换规则(严格单向,禁止回退)
# idle → preset : 点击"开始面试"
# preset → followup : 预设15题完成,进入追问
# followup → done : 追问完成或超时
# done → idle : 导出记录,重置
为什么用状态机而不是一个大函数:面试流程有明显的阶段边界,状态机让每个阶段的输入输出完全独立,调试时可以直接从任意状态切入,边界情况(跳过、提前结束、超时)都很清晰。
2.3 题库模块热插拔:增删题型不用改代码
这是我觉得整个项目设计最漂亮的地方。
题库JSON格式:
{
"position": "技术开发岗",
"professional_ability_desc": "对开发工作的理解深度、技术栈掌握程度...",
"基础题": ["题目1", "题目2", "..."],
"场景题": ["题目1", "题目2", "..."],
"压力题": ["题目1", "题目2"],
"AI题": ["题目1", "题目2"]
}
程序运行时自动扫描:
# app.py - 核心代码仅此一行
modules = [
k for k, v in bank.items()
if k not in {"position", "professional_ability_desc"} # 元信息字段跳过
and isinstance(v, list) # 只要list类型
and len(v) > 0 # 跳过空模块
]
这意味着什么:
- 在JSON里加一个新题型key,程序自动识别,不用动任何代码
- 每个岗位可以有不同的题型,行政岗有"压力题",产品岗有"AI题",天然解耦
- 运营者改JSON就能维护题库,不需要开发者介入
2.4 动态题型配额:固定15题怎么分配
题型有权重,程序自动计算每模块出几道:
_MODULE_WEIGHTS = {
"基础题": 1.0,
"场景题": 1.2, # 权重高 → 场景题多分配,体现"重要问题多问"
"压力题": 0.6,
"AI题": 0.6,
}
def _calc_module_quotas(modules: list, total: int = 15) -> dict:
weights = {m: _MODULE_WEIGHTS.get(m, 0.8) for m in modules}
total_weight = sum(weights.values())
# 初始按比例取整(但每模块至少1题)
quotas = {m: max(1, int(weights[m] / total_weight * total))
for m in modules}
# 调整到恰好等于total
diff = total - sum(quotas.values())
if diff != 0:
# 余量按权重降序逐个补偿
sorted_modules = sorted(modules, key=lambda m: weights[m], reverse=True)
for i, m in enumerate(sorted_modules):
if diff > 0:
quotas[m] += 1
diff -= 1
elif quotas[m] > 1: # 减题时不低于1
quotas[m] -= 1
diff += 1
if diff == 0:
break
return quotas
以4模块为例,15题分配结果:
| 题型 | 权重 | 配额 |
|---|---|---|
| 基础题 | 1.0 | 5 |
| 场景题 | 1.2 | 6 |
| 压力题 | 0.6 | 2 |
| AI题 | 0.6 | 2 |
三、核心功能实现:三个有意思的技术点
3.1 AI追问选题:不是随机,是分析
追问不是随机选题,而是AI分析完整回答后,判断哪些题值得深挖。
追问Prompt设计(注入到agent.py):
角色:专业面试官
任务:从以下15道题目中,选出2-5个最需要追问的题目编号
选题标准:
1. 回答不够详细,缺乏具体案例
2. 存在逻辑漏洞或模糊之处
3. 展现了值得深挖的经验
4. 回答质量明显低于其他题
5. 与其他已选题回答不重复(去重)
输出:JSON { followup_indices: [...], reasons: {...} }
AI会根据回答质量智能选题——回答含糊的深挖,简历精彩的追问细节,逻辑不清的拆解质疑。
3.2 追问去重双保险:AI不够可靠就加本地兜底
AI在去重这件事上不够可靠(上下文有限、token限制),所以加了本地二次校验:
# modules/agent.py
def _compute_answer_similarity(answer_a: str, answer_b: str) -> float:
"""
字符级 trigram Jaccard 相似度
用于检测候选人在不同问题中是否使用了重复/相似的回答
"""
def char_ngrams(s: str, n: int = 3):
if len(s) < n:
return {s}
return {s[i:i+n] for i in range(len(s) - n + 1)}
ngrams_a = char_ngrams(answer_a.lower().strip())
ngrams_b = char_ngrams(answer_b.lower().strip())
intersection = len(ngrams_a & ngrams_b)
union = len(ngrams_a | ngrams_b)
return intersection / union if union > 0 else 0.0
SIMILARITY_THRESHOLD = 0.65 # 相似度超过0.65视为重复
双保险流程:
AI选题(prompt中有去重要求)
↓
本地相似度校验(逐个对比已选题的回答)
↓
相似度≥0.65 → 自动移除该题
↓
输出最终追问列表
3.3 闲聊检测:把面试拉回正轨
用户有时候会跟AI"聊起来",比如问"你们公司待遇怎么样"——这不是面试内容,需要识别并引导回来:
# modules/agent.py
def is_chat_content(self, answer: str, question: str) -> bool:
"""
判断回答是否为闲聊
通过AI判断内容是否与面试相关
"""
prompt = f"""判断以下回答是否在回答面试问题。
问题:{question}
回答:{answer}
只回答:是 或 否"""
response = self.api_client.chat.completions.create(
model=get_model_name(),
messages=[{"role": "user", "content": prompt}],
temperature=0
)
result = response.choices[0].message.content.strip()
return result == "是"
检测为闲聊时,给出引导语,但不消耗题目(继续问同一道题),保持面试节奏。
四、防篡改体系:次数管理怎么做到可信
4.1 双存储防篡改
由于是单机运行,并且使用我的api-key,我就对工具做了一些低层次的安全措施,小白用户也能轻易重置使用次数,我把数据同时存在两处:
- Windows注册表:
HKCU\Software\AIInterview\Settings - 隐藏文件:
~/.ai_interview_assistant/count.dat(Fernet对称加密)
读取时取两者最小值:
reg_count = load_from_registry()
file_count = load_from_encrypted_file()
final_count = min(reg_count, file_count)
为什么取最小值:如果注册表被篡改成999,文件里是50,取最小值后还是50。次数只能越改越少,无法增加。
4.2 HMAC激活码:不可伪造的续费机制
激活码格式:INT-XXXXXXXX-XXXXXX-MMMMMMMM
前两段编码增加次数,第三段是校验码,第四段用HMAC-SHA256防伪造:
# 生成(开发者端)
import hmac, hashlib
secret_key = b"AI_Interview_HMAC_2026_SecretKey"
machine_id = get_machine_id() # 本机MAC地址哈希
part4 = hmac.new(secret_key, machine_id.encode(), hashlib.sha256)\
.digest()[:8].hex().upper()
# 验证(用户端)
# 用同样算法算一遍,比对结果
expected = hmac.new(secret_key, machine_id.encode(), hashlib.sha256)\
.digest()[:8].hex().upper()
is_valid = (part4 == expected)
为什么不用简单哈希:直接hash(机器码)[:8]的问题在于——别人知道机器码和算法就能算出合法第四段。HMAC加了密钥这一未知量,必须逆向拿到密钥才能伪造。
五、打包:从2.4GB到94MB的血泪史
5.1 环境隔离是第一步
开发环境和打包环境必须分离。我一开始在同一个Python环境里反复pip install,结果numpy等包消失,环境被污染。
解决:创建独立conda环境,专门用于打包:
conda create --name interview_py311 python=3.11.15
conda activate interview_py311
pip install gradio openai cryptography pyinstaller
5.2 体积优化:不是排除,是精确包含
初始打包:2.4GB ❌
原因是包含了所有依赖(包括ML框架、sklearn等)。加排除规则后:
# interview.spec
excludes=[
"torch", "tensorflow", "sklearn", "transformers",
"xgboost", "matplotlib", "scipy", "pandas", ...
]
结果:94MB ✅
5.3 三个致命陷阱
陷阱1:DLL路径写错
# ❌ 我的错误:指向父目录,路径不存在!
dlls = os.path.join(os.path.dirname(sys.prefix), 'Library', 'bin')
# ✅ 正确
dlls = os.path.join(sys.prefix, 'Library', 'bin')
症状:日志显示"Build complete!"一切正常,但27个关键DLL一个都没打进去,运行时DLL load failed。
陷阱2:pydub被误排除
Gradio的processing_utils间接依赖pydub,必须显式从excludes中移除。
陷阱3:Gradio 6.x目录结构变化
Gradio 4.x→6.x目录结构完全不同,需要同步更新打包配置:
- 移除:
_frontend_code - 新增:
icons、media_assets、stubs
核心教训:打包日志说"Build complete!"不代表成功,exe实际运行前你永远不知道缺了什么。
六、收获:做这个项目我学到了什么
6.1 技术层面
热插拔优于硬编码。题库模块设计让运营者改JSON就能加新题型,不需要开发者介入。代码只负责"扫描",不负责"注册",这是解耦的精髓。
双保险思维无处不在。AI选题不够可靠,就加本地相似度校验;单存储不够安全,就加双存储取最小值;Python环境不稳定,就做隔离。在不可靠的系统上叠加确定性约束,是工程化的常用思路。
系统思考比局部优化重要。PyInstaller打包的问题,我一开始花了很多时间在修改gradio源码上(试图打补丁让它跳过某些步骤),但真正的全局问题是spec的datas根本没有包含那些文件。局部最优≠全局最优,先画全局图再动手。
6.2 产品层面
时长兜底保护产品价值。面试不足5分钟不评分、不扣次——这个设计防止了"快速刷题刷次数"的行为,也保证了AI评分有足够的数据。"面试本就需要时间"这是合理的产品约束。
流式输出减少等待焦虑。AI生成需要10-15秒,流式输出让文字逐字出现,比"转圈圈"体验好很多。面试场景中,用户看到AI正在"认真阅读"自己的回答,心理感受完全不同。
6.3 后续优化方向
- 联网激活验证:激活码现在依赖本地存储,换电脑后理论上可以绕过
- 语音输入:支持语音回答,降低输入门槛
- 简历导入:根据简历生成个性化问题
- 群面模拟:多候选人同时面试场景
最后:这个项目从想法到成品大概花了两周,踩了不少坑,但也验证了一个判断——AI应用落地的难点不在于调API,而在于工程化:状态管理、错误处理、防篡改、打包分发……这些"脏活累活"才是真正考验人的地方。
这次只是简单的大模型的应用,后续会再做一些RAG,Agent相关的项目, 欢迎交流
本文基于实际项目编写,项目代码托管于私人GitLab,如需工具请联系开发者。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)