前段时间我给自己安排了一个小目标:做一款能帮人模拟面试的工具。听起来不复杂,但做下来我也碰到不少门道——题库怎么设计才能零代码扩展?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
  • 新增:iconsmedia_assetsstubs

核心教训:打包日志说"Build complete!"不代表成功,exe实际运行前你永远不知道缺了什么。


六、收获:做这个项目我学到了什么

6.1 技术层面

热插拔优于硬编码。题库模块设计让运营者改JSON就能加新题型,不需要开发者介入。代码只负责"扫描",不负责"注册",这是解耦的精髓。

双保险思维无处不在。AI选题不够可靠,就加本地相似度校验;单存储不够安全,就加双存储取最小值;Python环境不稳定,就做隔离。在不可靠的系统上叠加确定性约束,是工程化的常用思路。

系统思考比局部优化重要。PyInstaller打包的问题,我一开始花了很多时间在修改gradio源码上(试图打补丁让它跳过某些步骤),但真正的全局问题是spec的datas根本没有包含那些文件。局部最优≠全局最优,先画全局图再动手。

6.2 产品层面

时长兜底保护产品价值。面试不足5分钟不评分、不扣次——这个设计防止了"快速刷题刷次数"的行为,也保证了AI评分有足够的数据。"面试本就需要时间"这是合理的产品约束。

流式输出减少等待焦虑。AI生成需要10-15秒,流式输出让文字逐字出现,比"转圈圈"体验好很多。面试场景中,用户看到AI正在"认真阅读"自己的回答,心理感受完全不同。

6.3 后续优化方向

  1. 联网激活验证:激活码现在依赖本地存储,换电脑后理论上可以绕过
  2. 语音输入:支持语音回答,降低输入门槛
  3. 简历导入:根据简历生成个性化问题
  4. 群面模拟:多候选人同时面试场景

最后:这个项目从想法到成品大概花了两周,踩了不少坑,但也验证了一个判断——AI应用落地的难点不在于调API,而在于工程化:状态管理、错误处理、防篡改、打包分发……这些"脏活累活"才是真正考验人的地方。
这次只是简单的大模型的应用,后续会再做一些RAG,Agent相关的项目, 欢迎交流


本文基于实际项目编写,项目代码托管于私人GitLab,如需工具请联系开发者。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐