首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >OfferGo面向求职者和面试官双角色ai模拟面试平台

OfferGo面向求职者和面试官双角色ai模拟面试平台

原创
作者头像
用户12773398
发布于 2026-09-19 15:17:02
发布于 2026-09-19 15:17:02
1040
举报

OfferGo:我是怎么用 React + Flask + WebRTC 做一个双角色 AI 模拟面试平台的

项目定位:一个同时服务「求职者」和「面试官」两端的 AI 模拟面试平台。 技术栈:React 19 + Vite / Python Flask / MySQL / 通义千问 / Socket.IO + WebRTC + coturn 开源仓库:见文末


一、为什么我要做 OfferGo

市面上做模拟面试的产品不算少,但用下来普遍有两个问题:

第一,它们只服务求职者。 求职者练完题,没有真人给反馈;面试官想找人面试,只能靠 HR 系统或者微信群喊人。两端是割裂的。

第二,AI 面试普遍是「假 AI」。 要么是固定题库按顺序抛出来,要么是把题目丢给大模型让它随便点评两句——回答与追问之间没有上下文,评分更是拍脑袋。

所以 OfferGo 想做两件事:

  1. 把双角色打通:面试官能发职位、筛简历、发起面试邀请、进入会议室、给候选人打分;求职者能刷题、投简历、接面试、参加 AI 模拟面试、看复盘报告。两边共用一套好友关系与消息系统,流程是闭环的。
  2. 把 AI 面试做成真的多轮对话:面试官是 AI,会顺着你的回答追问,答完 5 题后给一份多维度评分报告,而不是「很好,下一个问题」。

二、整体架构

代码语言:javascript
复制
┌──────────────────────────────────────────────────────────┐
│  浏览器 / Flutter 客户端                                   │
│  React 19 + Vite   |   socket.io-client 4.7             │
└───────────────┬──────────────────────┬───────────────────┘
                │ HTTP (57 个接口)      │ WebSocket
                ▼                      ▼
┌──────────────────────────┐  ┌────────────────────────────┐
│  Flask 业务服务           │  │  Flask-SocketIO 信令服务    │
│  server/app.py (~183KB)  │  │  server/rtc.py             │
│  JWT 鉴权 / 单设备登录     │  │  join / call / answer      │
│  MySQL 数据层             │  │  ice / hangup / reject     │
└───────────┬──────────────┘  └────────────┬───────────────┘
            │                              │
     ┌──────▼──────┐                ┌──────▼──────┐
     │   MySQL     │                │  coturn     │
     │  (业务数据)  │                │  TURN 中继   │
     └─────────────┘                └─────────────┘
            │
     ┌──────▼──────────────┐
     │  阿里云百炼 DashScope │  ← OpenAI 兼容协议
     │  qwen-plus 大模型     │
     └─────────────────────┘

为什么 HTTP 服务与信令服务要分开?因为两者的运行特征完全不同:

  • Flask 业务服务:请求-响应模型,接口间无状态,可以多 worker 横向扩。
  • Socket.IO 信令服务:需要维护 user_id -> socket sid 的长连接映射,必须单实例或者做跨实例广播,否则 A 用户在实例 1、B 用户在实例 2,信令就转不过去。

分开部署之后,业务服务的重启不会踢掉正在通话的用户,这一点在真实环境里很关键。


三、双角色是怎么落地的

3.1 用 user_type 而不是另建一张表

注册时前端传 user_type,取值 interviewee(求职者,默认)或 interviewer(面试官,需额外填 company)。整个系统只有一个 users 表,靠这一列做权限分流。

这样做的直接好处是:好友、消息、通知这些社交模块不需要写两套。求职者和面试官可以互加好友、互相发消息——面试官给候选人发面试邀请,走的就是好友关系。

3.2 权限收敛在三个地方

位置

规则

接口层

interviewer 才能调 /api/interview/invite、/api/interview/score、POST /api/jobs

前端路由

面试官登录后进「我发出的邀请」,求职者进「我收到的邀请」

数据层

同一份 interview_invitations 表,靠 interviewer_id / candidate_id 两个视角查询

3.3 单设备登录:JWT 里塞一个 jti

这是一个容易被忽略但很影响体验的细节。用户在同一台电脑上开了两个浏览器标签,或者在手机和电脑同时登录,会话状态会互相打架——A 端点了「进入会议室」,B 端的倒计时还在走。

实现方式是在 JWT 载荷里加一个会话标识:

代码语言:javascript
复制
def make_token(user_id, jti=None):
    payload = {
        "id": user_id,
        "jti": jti or str(uuid.uuid4()),   # 会话 id
        "exp": datetime.utcnow() + timedelta(days=7),
    }
    return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

登录/注册时把这个 jti 写进 users.current_token_id。之后每个请求都过一个拦截器:

代码语言:javascript
复制
def _check_single_session():
    # 公开端点白名单直接放行
    if _is_public_endpoint():
        return None
    user_id, jti = get_user_id_from_token()
    row = query_one("SELECT current_token_id FROM users WHERE id=%s", (user_id,))
    if row and row["current_token_id"] != jti:
        return kicked_response()   # 401 + code: SESSION_KICKED

新设备登录会顶掉旧设备,旧设备收到 401 SESSION_KICKED,前端据此弹「账号在另一台设备登录」并跳回登录页。Socket.IO 那边同理,join 时校验失败会推一个 kicked 事件并主动断开旧连接。

白名单里必须包含 login / register / logout / health 这些端点,否则用户在登录页就会被自己的旧 token 拦在门外。


四、AI 面试引擎

这是整个项目最核心的部分,也是踩坑最多的地方。

4.1 多轮对话的状态放在客户端

后端不存面试对话状态,每次请求由前端把完整 history 原样带回:

代码语言:javascript
复制
POST /api/interview/chat
{
  "position": "前端开发工程师",
  "answer": "我觉得闭包是……",
  "history": [
    {"role": "system", "content": "..."},
    {"role": "ai",     "content": "请先做个自我介绍"},
    {"role": "user",   "content": "我是……"}
  ]
}

响应里带回追加后的完整 history:

代码语言:javascript
复制
{
  "reply": "好的,你提到了作用域链,那你能说说……",
  "history": [...],
  "answered": 2,
  "finished": false
}

为什么把状态放客户端? 因为面试是一个「用户随时可能刷新页面」的场景。状态在服务端意味着要维护会话表、处理超时清理、处理同一用户开两场面试的冲突——成本远高于直接把 history 丢给前端保存(存 localStorage 即可)。而且这个接口天然无状态,将来要扩容不用考虑 session 粘滞。

4.2 系统提示词约束了面试官的「人格」

代码语言:javascript
复制
INTERVIEW_SYSTEM_PROMPT = """你是一位资深互联网大厂技术面试官,正在面试{position}岗位候选人。
要求:
1. 每次只问一个问题,问题要有递进性,能顺着候选人的回答追问
2. 候选人回答后先给一句简短点评,再问下一个问题
3. 全程共问 5 个问题,第 5 题答完后输出评分报告
4. 评分报告必须包含:表达能力、逻辑思维、岗位匹配度三项分数(各 100 分制)与总分
5. 点评要具体,指出回答中的亮点与不足,不要空泛地说"回答得不错"
"""

4.3 从模型的自由文本里「抠」出结构化数据

大模型返回的是 Markdown 文本,不是 JSON。这是工程上最麻烦的一点:你要从一段散文里稳定地抽出一个数字。

我写了三个抽取函数,各自负责一类信息:

代码语言:javascript
复制
def _extract_score(reply):              # 抽取单题得分
def _extract_first_question_only(reply) # 首题只要问题,不要点评
def _extract_final_feedback(reply):     # 抽第 5 题后的多维度报告

最终反馈的结构:

代码语言:javascript
复制
{
  "expression": 82,
  "logic": 78,
  "match": 85,
  "total": 81,
  "strengths": ["对闭包的理解准确", "能主动举例子"],
  "improvements": ["回答缺少复杂度分析", "建议补充实际项目场景"]
}

strengths / improvements 是数组,所以还需要 _to_str_list() 做类型归一化——模型有时返回数组,有时返回 "1. xxx 2. yyy" 这样的字符串,有时返回 null。这类防御性代码占了这部分代码量的一半以上。

4.4 代词消解:一个不起眼但很影响体验的细节

多轮对话里,用户会说「它还有什么特性」「这个怎么优化」。如果直接把问题存进面试记录,回头翻看时根本不知道「它」指的是什么。

所以有一个 _replace_pronouns_in_question():在保存记录前,把问题里的代词替换为上一轮 AI 提问中提到的具体对象。

代码语言:javascript
复制
def _find_last_ai_question(history):
    """回溯 history,取出最近一条 AI 的消息"""

这个函数的存在感很低,但它决定了「面试记录」这个页面到底有没有用。


五、实时音视频:真人不只是 AI

AI 模拟面试之外,平台还支持面试官与候选人一对一真人视频面试。这部分的技术选型:

环节

方案

信令

Flask-SocketIO(WebSocket 长连接)

媒体传输

WebRTC P2P,优先直连

NAT 穿透失败

自建 coturn 服务器做 TURN 中继

前端

原生 RTCPeerConnection + useWebRTC.js

5.1 信令事件表

客户端 → 服务端:

事件

载荷

说明

join

{token}

连接后先鉴权,服务端记录 user_id -> sid 并加入房间 user_{id}

call

{to, offer, video}

发起通话

answer

{to, answer}

回应 offer

ice

{to, candidate}

转发 ICE candidate

hangup / reject

{to}

挂断 / 拒接

服务端 → 客户端:ready、kicked、incoming_call、call_answer、ice_candidate、peer_hangup、peer_reject。

关键点:to 用的是用户数字 id(users.id),不是 socket sid。 服务端维护 user_id -> sid 映射来转发。这样设计的好处是业务层完全不需要知道 socket 层的存在——发邀请、接受邀请、进入房间这些逻辑只认用户 id。

5.2 为什么要自建 TURN

WebRTC 的理想情况是 P2P 直连,但国内网络环境下,双方都在 NAT 后面时直连成功率不稳定。只配 STUN 的话,大概有三成左右的通话会卡在「正在连接」。

自建 coturn 之后:

代码语言:javascript
复制
# coturn 配置要点
listening-port=3478
fingerprint
lt-cred-mech
user=aimian:你的密码
realm=你的域名或IP

前端通过环境变量注入:

代码语言:javascript
复制
VITE_TURN_URL=turn:你的IP:3478
VITE_TURN_USERNAME=aimian
VITE_TURN_CREDENTIAL=你的密码

RTCPeerConnection 的 iceServers 里同时配 STUN 和 TURN,浏览器会自动优选。

注意:TURN 是带宽消耗大户,中继模式下所有音视频流量都过服务器。演示环境够用,商用需要评估带宽成本。


六、题库与刷题模块

题库数据结构是三层:

代码语言:javascript
复制
题库(question_banks)
  └── 分类(categories)
        └── 题目(questions)

题目支持两种类型:code(代码题,带 code_example)和 common(理论题)。题目详情页有「AI 生成解析」按钮,调 /api/question/<id>/generate 让模型生成解析、参考答案、代码示例并写回数据库——生成一次,之后所有人共用,避免每次打开都消耗额度。

练习记录用 question_practices 表记录「谁在什么时候练了哪道题」,据此算出:

  • 今日刷题数(首页 today_count)
  • 各题库完成率(practiced / total,且去重)
  • 我的练习页的今日列表与历史列表

七、部署:从本地 5173 到 HTTPS

生产环境的拓扑:

代码语言:javascript
复制
用户浏览器
   │  https://你的域名  (443)
   ▼
 nginx  ──► 静态资源 (dist/)          前端
   │
   ├── /api/*  ──► 127.0.0.1:3001    Flask 业务服务
   └── /socket.io/* ──► 127.0.0.1:3002  Flask-SocketIO 信令服务

几个实战要点:

1. 80 必须 301 跳到 443。 否则浏览器的 getUserMedia(要摄像头麦克风权限)在非安全上下文下会被直接拒绝——这是 WebRTC 项目最常见的「本地能跑、线上黑屏」原因。

2. HTTPS 证书用 Let's Encrypt 走 certbot 自动续期。 不要手撸证书,90 天过期一次会忘。

3. 前端构建时的环境变量要指向 wss。 页面是 HTTPS,Socket.IO 就必须走 wss://,否则会被浏览器以混合内容(mixed content)为由阻断:

代码语言:javascript
复制
VITE_API_BASE=https://你的域名/api
VITE_SOCKET_URL=https://你的域名
VITE_TURN_URL=turn:你的IP:3478

4. API 的 base URL 不要写死 localhost。 这个坑我第一次部署就踩了——本地开发时一切都好,上传到服务器后前端还在请求 localhost:3001,页面白屏。


八、踩过的坑

8.1 MySQL 双重 UTF-8 编码

从本地导出 SQL、在服务器导入后,中文全部变成 我是 这样的乱码。原因是从库到导入链路里被编码了两次。

排查方式:

代码语言:javascript
复制
SELECT name, HEX(name) FROM users LIMIT 1;

如果 HEX() 出来的是 C3A6C2... 这种(UTF-8 字节又被当成 latin1 存了一遍),就是双重编码。

修复方式是先把数据按 latin1 读出来再转回 UTF-8:

代码语言:javascript
复制
UPDATE users SET name = CONVERT(CAST(CONVERT(name USING latin1) AS BINARY) USING utf8mb4);

治本的做法是导入时显式指定字符集:

代码语言:javascript
复制
mysql --default-character-set=utf8mb4 -u user -p dbname < dump.sql

8.2 面试记录里全是「它」「这个」

前面提到的代词消解就是这个坑的产物。上线后自己用了一遍,翻面试记录时发现存下来的问题是「它还有什么特性」——完全没意义。

8.3 评分抽取偶发失败

模型偶尔会把评分写成 总分:81分 而不是 总分:81,正则直接匹配失败,前端显示出 null。

修复思路是允许多种格式 + 兜底默认值,而不是追求正则的完备性:

代码语言:javascript
复制
def _to_int(v):
    """任意输入安全转 int,失败返回 None"""

然后在业务层对 None 做降级展示(显示「暂无评分」而不是崩掉)。


九、这个项目让我学到了什么

第一,AI 应用的工程难点不在「调通模型」,而在「模型输出不可控」。 把一段自然语言稳定地解析成结构化数据,需要的防御性代码量远超调用本身。

第二,实时通信的状态管理要提前想清楚。 哪个状态放服务端、哪个放客户端、哪个放信令层,一开始想不明白,后期改起来是伤筋动骨的。

第三,双角色系统要尽早统一底层模型。 我一开始想给面试官单独建表,后来改成共用 users + user_type 分流,省掉了整套社交模块的重复实现。

第四,部署不是「把代码传上去」这么简单。 HTTPS、混合内容、编码、TURN 中继,每一个都能让功能在本地正常、线上失败。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • OfferGo:我是怎么用 React + Flask + WebRTC 做一个双角色 AI 模拟面试平台的
    • 一、为什么我要做 OfferGo
    • 二、整体架构
    • 三、双角色是怎么落地的
      • 3.1 用 user_type 而不是另建一张表
      • 3.2 权限收敛在三个地方
      • 3.3 单设备登录:JWT 里塞一个 jti
    • 四、AI 面试引擎
      • 4.1 多轮对话的状态放在客户端
      • 4.2 系统提示词约束了面试官的「人格」
      • 4.3 从模型的自由文本里「抠」出结构化数据
      • 4.4 代词消解:一个不起眼但很影响体验的细节
    • 五、实时音视频:真人不只是 AI
      • 5.1 信令事件表
      • 5.2 为什么要自建 TURN
    • 六、题库与刷题模块
    • 七、部署:从本地 5173 到 HTTPS
    • 八、踩过的坑
      • 8.1 MySQL 双重 UTF-8 编码
      • 8.2 面试记录里全是「它」「这个」
      • 8.3 评分抽取偶发失败
    • 九、这个项目让我学到了什么
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档