最难的模块
如果说 AiGateway 是面面通里设计最干净的部分,那面试模块就是最复杂的。
复杂在哪?不是代码量——InterviewView.vue 接近 70KB 确实不少,但真正的复杂度来自状态管理:
- AI 面试官在说话(流式输出中)
- 候选人在打字
- AI 可能在问技术问题、也可能在输出笔试邀请、还可能突然结束了
- SSE 随时可能断,断了要能恢复
- 面试结束后要生成结构化评分报告
这些状态交织在一起,任何一个环节出问题,用户体验就是”面了半小时白面了”。
这篇文章会围绕一条主线讲:让 Prompt 负责判断流程,让系统负责识别标记、接住状态。
如果没有这条主线,面试模块很容易写成一堆 if/else:问了几轮、该不该笔试、什么时候结束、评分 JSON 在哪里。最后代码会越来越像一团线。

把状态画出来,大概是这样:

Prompt 即协议
面试模块最核心的设计决策是:用 Prompt 控制流程,而不是用代码硬编码状态机。
这里的 Prompt 不是单纯的”语气设定”,更像一份协议:AI 可以自由追问,但在关键转折处必须打标记;系统不猜 AI 的意图,只识别这些标记。
面试官系统 Prompt 的核心规则:
你是一位经验丰富的技术面试官"面面",正在面试一位%s岗位的候选人。
## 核心规则
### 1. 一次只问一个问题- 每条回复只包含一个明确的技术问题- 等候选人回答后,根据回答质量决定下一个问题方向
### 2. 逐步深入,自然对话- 从候选人的回答切入,先简短反馈再追问更深一层- 难度曲线:基础概念 → 原理追问 → 场景应用 → 边界/优化- 每 3-4 轮给一次简短评价保持节奏
### 3. 面试流程开场 → 自我介绍 → 技术问答(3-5轮)→ 如果表现良好:输出 [笔试邀请]→ 如果连续 2 问无法回答:结束面试
### 4. 自主触发笔试邀请当判定候选人基础扎实后,先写邀请语,然后独占一行输出:[笔试邀请]
### 5. 自主结束面试条件满足时输出标记 + JSON:[面试结束]{"score":6,"feedback":"...","dimensions":[...],"suggestion":"..."}
### 6. 评分维度(1-10分)1. 基础掌握 2. 表达清晰 3. 深度思考 4. 实战经验 5. 学习潜力关键的设计思路是让 AI 自己决定流程走向,系统只做拦截和响应:
AI 输出 → 系统检查含不含 [笔试邀请] → 有 → 拦截输出,弹编程题编辑器 → 无 → 继续显示AI 输出 → 系统检查含不含 [面试结束] → 有 → 拦截输出,提取 JSON,跳转报告页 → 无 → 继续对话这样面试的流程转折(技术问答 → 编程 → 结束)由 AI 根据候选人表现自主触发,系统不需要预设固定轮数。好处是面试流程可以灵活变化——表现好的多面几轮,表现不好的提前结束,系统代码不需要改。
代价也很明显:AI 只要格式一飘,系统就得兜底。 所以下面真正麻烦的部分,不是怎么写 Prompt,而是怎么从 AI 的输出里稳定抠出系统能理解的东西。
结构化输出:从 AI 的散文里抠出 JSON
面试模块面临的最大工程问题不是 Prompt 怎么写,而是 AI 说话不守规矩。
这一步可以理解成一个小型解析流水线:先找标记,再用括号平衡把完整 JSON 抠出来,最后检查字段能不能生成报告。

理想情况:AI 输出 [面试结束]{"score":6,"feedback":"..."},干净利落。
实际情况:AI 可能输出:
好的,我觉得我们已经聊了足够多了。下面我来做一个总结。
[面试结束]
{"score": 6, "feedback": "这位候选人的基础知识...", "dimensions": [...], "suggestion": "建议加强..."}
再次感谢你参加面试!有换行、有空行、有额外文字、JSON 里的字段顺序可能不一样,甚至可能把 [面试结束] 和 JSON 放在不同行。
面试报告解析器专门处理这个问题:
class InterviewReportParser {
Map<String, Object> parseReport(String aiResponse, String marker) { String jsonStr = extractJson(aiResponse, marker); if (jsonStr == null && aiResponse.contains("{")) { // fallback:全文搜索第一个 { 到最后 } jsonStr = aiResponse.substring( aiResponse.indexOf("{"), aiResponse.lastIndexOf("}") + 1 ); } return objectMapper.readValue(jsonStr, Map.class); }
String extractJson(String text, String marker) { int markerIdx = text.indexOf(marker); int jsonStart = afterMarker.indexOf("{"); // 括号平衡匹配 - 处理嵌套 {} int depth = 0; for (int i = jsonStart; i < afterMarker.length(); i++) { char c = afterMarker.charAt(i); if (c == '{') depth++; else if (c == '}') { depth--; if (depth == 0) { end = i + 1; break; } } } return afterMarker.substring(jsonStart, end); }}括号平衡是关键。AI 输出的 JSON 里可能有嵌套对象(dimensions 数组里套对象),简单的 indexOf 第一个 } 会截断。只有平衡匹配才能准确取出完整 JSON。
时序竞争
一个更隐蔽的 bug:面试结束时,前端收到 [面试结束] 标记后要跳转到报告页。但 SSE 是逐 token 推送的,可能前一个 token 是 [面试结,后一个才是 束]。
最初的代码写的是:
// 错误写法sse.onmessage = (event) => { if (event.data.includes('[面试结束]')) { endDetected = true // 但是这个标记可能在当前 token 没传完整 navigateToReport() }}修复方案是加一个拼接缓冲:
let buffer = ''
sse.onmessage = (event) => { buffer += event.data if (buffer.includes('[面试结束]')) { // 延迟一帧确保 JSON 也接收完了 setTimeout(() => navigateToReport(), 0) }}SSE 是字符流,不是消息边界。这个坑踩了很久才发现。
SSE 流式架构
面试对话和论文润色都用了 SSE(Server-Sent Events)做流式推送,前后端的协作方式:
后端
@Servicepublic class InterviewService {
public SseEmitter startInterview(InterviewStartRequest req) { SseEmitter emitter = new SseEmitter(600_000L); // 10 分钟超时
CompletableFuture.runAsync(() -> { try { // 调 AiGateway 获取流式响应 aiGateway.streamChat(request, userId, token -> { emitter.send(SseEmitter.event() .name("token") .data(token)); });
emitter.send(SseEmitter.event() .name("finish") .data("面试完成")); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } });
return emitter; }}SseEmitter 是 Spring Boot 内置的 SSE 支持,设了 10 分钟超时。面试对话一般不会超过这个时间。
前端
// useInterviewStream.ts — 简化版export function useInterviewStream() { let eventSource: EventSource | null = null let buffer = ''
function connect(url: string) { eventSource = new EventSource(url)
eventSource.addEventListener('token', (event) => { buffer += event.data // 逐 token 追加到对话气泡 appendToLastBubble(event.data) // 检查是否包含结束标记 if (buffer.includes('[面试结束]') || buffer.includes('[笔试邀请]')) { checkFlowMarkers(buffer) } })
eventSource.addEventListener('finish', () => { eventSource?.close() })
eventSource.onerror = () => { // 断线重试——保存当前状态,重新连接 if (lastSessionId) { reconnect(lastSessionId) } } }
return { connect, disconnect, buffer }}断线重试
SSE 连接可能因为网络问题、服务器重启、浏览器休眠等原因断开。
面面通的做法是:前端在 localStorage 里保存最后一次面试的 sessionId 和对话上下文。 重新连接时带上 sessionId,后端恢复对话历史,继续流式推送。
function reconnect(sessionId: string) { // 用新的 EventSource 连接恢复端点 eventSource = new EventSource(`/api/interview/resume/${sessionId}`)}这个方案不是完美的——如果服务器重启导致会话数据丢失,恢复不了。但实战中 90% 的断线场景都能覆盖。
编程环节的集成
面试聊天到一定程度,AI 觉得”这候选人不错,可以试试写代码”。然后输出一行 [笔试邀请]。
系统拦截到这个标记后:
- 隐藏对话输入框,弹出 CodeMirror6 编辑器
- 后端从题库随机抽一道题(算法或补全代码)
- 候选人在编辑器里写代码、运行(通过 Piston 在线执行)
- 提交后 AI 从 5 个维度审查:正确性、代码风格、时间复杂度、边界处理、可读性
编程环节的数据流:
AI 输出 [笔试邀请] → 前端拦截,显示编辑器 → 后端调 AlgorithmProblemService 出题 → 候选人写完提交代码 → 后端调 CodingService 提交给 Piston 执行 → 执行结果发给 AI 做代码审查 → AI 输出审查结果 + 继续面试或结束这里有个细节:编程环节的代码执行用的是 Piston。后端把代码、语言和版本发给 Piston,由它返回 stdout、stderr、运行错误或超时结果。至少不要在业务服务器上直接起 Shell 跑用户代码,这个风险太离谱了。
编程题不是 AI 生成的,而是来自面面通的预置题库(docs/algorithm_problems_seed.sql 里存了上百道题)。AI 只负责判断”什么时机触发笔试”,具体的题目从题库抽——这样题目的质量是可控的。
面试服务拆分
面试模块一开始全在一个类里,接近 1000 行。后来拆成了 4 个文件:
InterviewService → 主流程编排(调 AI、保存会话、SSE)InterviewPromptBuilder → Prompt 构建(系统提示词 + 评估提示词)InterviewTranscriptManager → 对话历史管理(JSON 序列化/反序列化)InterviewReportParser → 报告提取(括号平衡 JSON 解析 + fallback)拆分的触发点是加了编程环节之后——面试主流程里混入了代码执行逻辑,一个文件装不下了。
这件事给我的教训是:先看在代码上加新功能会不会觉得”塞不进去”,会就拆,不要等 1000 行再动。
尤其是这种 AI 流程模块,Prompt、状态、解析、存储、流式输出都容易互相缠住。拆开之后,后面调 Prompt 就只碰 InterviewPromptBuilder,修报告解析就只碰 InterviewReportParser,心里会安定很多。
回头看我比较满意的是 Prompt 即协议的设计。面试的流程控制不在代码里而是在 Prompt 里,这让面试的灵活度很高——想调整面试节奏不需要改后端代码,改几行 Prompt 就行了。
这种模式有代价,就是 AI 输出格式不一致时你得做好解析容错。但如果让我重新选择,我还是会用这个方案。代码控制流程死板但可靠,Prompt 控制流程灵活但有 bug——平衡点在于你的容错做得多好。
面面通的面试模块还有很多细节(多轮追问控制、评分一致性、笔试结果回流),完整代码在 GitHub 上。
下一篇是这个系列的最后一篇——简历 DOCX 导出:AI 改写后怎么保住 Word 格式不崩。
部分信息可能已经过时





