项目定位:一个同时服务「求职者」和「面试官」两端的 AI 模拟面试平台。 技术栈:React 19 + Vite / Python Flask / MySQL / 通义千问 / Socket.IO + WebRTC + coturn 开源仓库:见文末
市面上做模拟面试的产品不算少,但用下来普遍有两个问题:
第一,它们只服务求职者。 求职者练完题,没有真人给反馈;面试官想找人面试,只能靠 HR 系统或者微信群喊人。两端是割裂的。
第二,AI 面试普遍是「假 AI」。 要么是固定题库按顺序抛出来,要么是把题目丢给大模型让它随便点评两句——回答与追问之间没有上下文,评分更是拍脑袋。
所以 OfferGo 想做两件事:
┌──────────────────────────────────────────────────────────┐
│ 浏览器 / 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 服务与信令服务要分开?因为两者的运行特征完全不同:
user_id -> socket sid 的长连接映射,必须单实例或者做跨实例广播,否则 A 用户在实例 1、B 用户在实例 2,信令就转不过去。分开部署之后,业务服务的重启不会踢掉正在通话的用户,这一点在真实环境里很关键。
user_type 而不是另建一张表注册时前端传 user_type,取值 interviewee(求职者,默认)或 interviewer(面试官,需额外填 company)。整个系统只有一个 users 表,靠这一列做权限分流。
这样做的直接好处是:好友、消息、通知这些社交模块不需要写两套。求职者和面试官可以互加好友、互相发消息——面试官给候选人发面试邀请,走的就是好友关系。
位置 | 规则 |
|---|---|
接口层 | interviewer 才能调 /api/interview/invite、/api/interview/score、POST /api/jobs |
前端路由 | 面试官登录后进「我发出的邀请」,求职者进「我收到的邀请」 |
数据层 | 同一份 interview_invitations 表,靠 interviewer_id / candidate_id 两个视角查询 |
这是一个容易被忽略但很影响体验的细节。用户在同一台电脑上开了两个浏览器标签,或者在手机和电脑同时登录,会话状态会互相打架——A 端点了「进入会议室」,B 端的倒计时还在走。
实现方式是在 JWT 载荷里加一个会话标识:
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。之后每个请求都过一个拦截器:
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 拦在门外。
这是整个项目最核心的部分,也是踩坑最多的地方。
后端不存面试对话状态,每次请求由前端把完整 history 原样带回:
POST /api/interview/chat
{
"position": "前端开发工程师",
"answer": "我觉得闭包是……",
"history": [
{"role": "system", "content": "..."},
{"role": "ai", "content": "请先做个自我介绍"},
{"role": "user", "content": "我是……"}
]
}响应里带回追加后的完整 history:
{
"reply": "好的,你提到了作用域链,那你能说说……",
"history": [...],
"answered": 2,
"finished": false
}为什么把状态放客户端? 因为面试是一个「用户随时可能刷新页面」的场景。状态在服务端意味着要维护会话表、处理超时清理、处理同一用户开两场面试的冲突——成本远高于直接把 history 丢给前端保存(存 localStorage 即可)。而且这个接口天然无状态,将来要扩容不用考虑 session 粘滞。
INTERVIEW_SYSTEM_PROMPT = """你是一位资深互联网大厂技术面试官,正在面试{position}岗位候选人。
要求:
1. 每次只问一个问题,问题要有递进性,能顺着候选人的回答追问
2. 候选人回答后先给一句简短点评,再问下一个问题
3. 全程共问 5 个问题,第 5 题答完后输出评分报告
4. 评分报告必须包含:表达能力、逻辑思维、岗位匹配度三项分数(各 100 分制)与总分
5. 点评要具体,指出回答中的亮点与不足,不要空泛地说"回答得不错"
"""大模型返回的是 Markdown 文本,不是 JSON。这是工程上最麻烦的一点:你要从一段散文里稳定地抽出一个数字。
我写了三个抽取函数,各自负责一类信息:
def _extract_score(reply): # 抽取单题得分
def _extract_first_question_only(reply) # 首题只要问题,不要点评
def _extract_final_feedback(reply): # 抽第 5 题后的多维度报告最终反馈的结构:
{
"expression": 82,
"logic": 78,
"match": 85,
"total": 81,
"strengths": ["对闭包的理解准确", "能主动举例子"],
"improvements": ["回答缺少复杂度分析", "建议补充实际项目场景"]
}strengths / improvements 是数组,所以还需要 _to_str_list() 做类型归一化——模型有时返回数组,有时返回 "1. xxx 2. yyy" 这样的字符串,有时返回 null。这类防御性代码占了这部分代码量的一半以上。
多轮对话里,用户会说「它还有什么特性」「这个怎么优化」。如果直接把问题存进面试记录,回头翻看时根本不知道「它」指的是什么。
所以有一个 _replace_pronouns_in_question():在保存记录前,把问题里的代词替换为上一轮 AI 提问中提到的具体对象。
def _find_last_ai_question(history):
"""回溯 history,取出最近一条 AI 的消息"""这个函数的存在感很低,但它决定了「面试记录」这个页面到底有没有用。
AI 模拟面试之外,平台还支持面试官与候选人一对一真人视频面试。这部分的技术选型:
环节 | 方案 |
|---|---|
信令 | Flask-SocketIO(WebSocket 长连接) |
媒体传输 | WebRTC P2P,优先直连 |
NAT 穿透失败 | 自建 coturn 服务器做 TURN 中继 |
前端 | 原生 RTCPeerConnection + useWebRTC.js |
客户端 → 服务端:
事件 | 载荷 | 说明 |
|---|---|---|
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。
WebRTC 的理想情况是 P2P 直连,但国内网络环境下,双方都在 NAT 后面时直连成功率不稳定。只配 STUN 的话,大概有三成左右的通话会卡在「正在连接」。
自建 coturn 之后:
# coturn 配置要点
listening-port=3478
fingerprint
lt-cred-mech
user=aimian:你的密码
realm=你的域名或IP前端通过环境变量注入:
VITE_TURN_URL=turn:你的IP:3478
VITE_TURN_USERNAME=aimian
VITE_TURN_CREDENTIAL=你的密码RTCPeerConnection 的 iceServers 里同时配 STUN 和 TURN,浏览器会自动优选。
注意:TURN 是带宽消耗大户,中继模式下所有音视频流量都过服务器。演示环境够用,商用需要评估带宽成本。
题库数据结构是三层:
题库(question_banks)
└── 分类(categories)
└── 题目(questions)题目支持两种类型:code(代码题,带 code_example)和 common(理论题)。题目详情页有「AI 生成解析」按钮,调 /api/question/<id>/generate 让模型生成解析、参考答案、代码示例并写回数据库——生成一次,之后所有人共用,避免每次打开都消耗额度。
练习记录用 question_practices 表记录「谁在什么时候练了哪道题」,据此算出:
today_count)practiced / total,且去重)生产环境的拓扑:
用户浏览器
│ 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)为由阻断:
VITE_API_BASE=https://你的域名/api
VITE_SOCKET_URL=https://你的域名
VITE_TURN_URL=turn:你的IP:34784. API 的 base URL 不要写死 localhost。 这个坑我第一次部署就踩了——本地开发时一切都好,上传到服务器后前端还在请求 localhost:3001,页面白屏。
从本地导出 SQL、在服务器导入后,中文全部变成 我是 这样的乱码。原因是从库到导入链路里被编码了两次。
排查方式:
SELECT name, HEX(name) FROM users LIMIT 1;如果 HEX() 出来的是 C3A6C2... 这种(UTF-8 字节又被当成 latin1 存了一遍),就是双重编码。
修复方式是先把数据按 latin1 读出来再转回 UTF-8:
UPDATE users SET name = CONVERT(CAST(CONVERT(name USING latin1) AS BINARY) USING utf8mb4);治本的做法是导入时显式指定字符集:
mysql --default-character-set=utf8mb4 -u user -p dbname < dump.sql前面提到的代词消解就是这个坑的产物。上线后自己用了一遍,翻面试记录时发现存下来的问题是「它还有什么特性」——完全没意义。
模型偶尔会把评分写成 总分:81分 而不是 总分:81,正则直接匹配失败,前端显示出 null。
修复思路是允许多种格式 + 兜底默认值,而不是追求正则的完备性:
def _to_int(v):
"""任意输入安全转 int,失败返回 None"""然后在业务层对 None 做降级展示(显示「暂无评分」而不是崩掉)。
第一,AI 应用的工程难点不在「调通模型」,而在「模型输出不可控」。 把一段自然语言稳定地解析成结构化数据,需要的防御性代码量远超调用本身。
第二,实时通信的状态管理要提前想清楚。 哪个状态放服务端、哪个放客户端、哪个放信令层,一开始想不明白,后期改起来是伤筋动骨的。
第三,双角色系统要尽早统一底层模型。 我一开始想给面试官单独建表,后来改成共用 users + user_type 分流,省掉了整套社交模块的重复实现。
第四,部署不是「把代码传上去」这么简单。 HTTPS、混合内容、编码、TURN 中继,每一个都能让功能在本地正常、线上失败。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。