Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
2671 字
13 分钟
AI 面试引擎:一个 Prompt 控制面试官、笔试邀请和结构化评分

最难的模块#

如果说 AiGateway 是面面通里设计最干净的部分,那面试模块就是最复杂的。

复杂在哪?不是代码量——InterviewView.vue 接近 70KB 确实不少,但真正的复杂度来自状态管理

  • AI 面试官在说话(流式输出中)
  • 候选人在打字
  • AI 可能在问技术问题、也可能在输出笔试邀请、还可能突然结束了
  • SSE 随时可能断,断了要能恢复
  • 面试结束后要生成结构化评分报告

这些状态交织在一起,任何一个环节出问题,用户体验就是”面了半小时白面了”。

这篇文章会围绕一条主线讲:让 Prompt 负责判断流程,让系统负责识别标记、接住状态。

如果没有这条主线,面试模块很容易写成一堆 if/else:问了几轮、该不该笔试、什么时候结束、评分 JSON 在哪里。最后代码会越来越像一团线。

面试界面

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

AI 面试引擎状态流转


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 面试报告 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)做流式推送,前后端的协作方式:

后端#

@Service
public 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 觉得”这候选人不错,可以试试写代码”。然后输出一行 [笔试邀请]

系统拦截到这个标记后:

  1. 隐藏对话输入框,弹出 CodeMirror6 编辑器
  2. 后端从题库随机抽一道题(算法或补全代码)
  3. 候选人在编辑器里写代码、运行(通过 Piston 在线执行)
  4. 提交后 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 格式不崩。

AI 面试引擎:一个 Prompt 控制面试官、笔试邀请和结构化评分
https://qiandaos.top/posts/mianmiantong-series/03-ai-interview-engine/
作者
千岛寒流
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00