码道:我用纯前端给 DeepSeek 造了一位“AI 面试官“


码道:我用纯前端给 DeepSeek 造了一位"AI 面试官"
从一段 200 行的 Python 流式调用代码出发,到交付一个功能完整的"面试AI对话"网页应用。
这篇文章完整记录了项目从需求、技术选型、核心难点(流式转换、提示词工程、结构化渲染)到实测验证的全过程,希望能给想在大模型应用上动手实践的开发者一些启发。
一、缘起:为什么想做"面试 AI 对话"
每个程序员大概都有过这样的时刻:简历投出去了,面试通知迟迟不来;或者好不容易拿到面试机会,却在电话面试里因为紧张、表达混乱而丢分。面试这件事,光靠"临场发挥"是不行的,它和考试一样需要练习。可现实是——
- 找人模拟面试,成本高、时间难约、反馈主观;
- 对着题库背题,又和真实面试的氛围相差太远;
- 市面上现成的面试刷题产品,要么收费,要么套路化,缺少"对话感"。
于是我想:能不能用现在最火的大模型,做一个真正能"对话"的模拟面试官?它应该能做到三件事:
- 像真正的面试官一样连续提问,而不是一次输出一整个题库;
- 对每道题的作答给出专业点评,包括打分、亮点、不足和参考要点;
- 面试结束给出结构化的总结报告,帮用户知道下一步该补什么。
这个想法落地成一个具体的项目,被我命名为 ChatSage(面试AI对话)。Sage 在英文里有"智者、贤者"的意思,恰好符合"资深面试官"的定位。
二、需求梳理:先想清楚,再动手写代码
动手之前,我花了不少时间把需求拆细。因为"做个 AI 面试官"这句话里,藏着大量模糊地带:面试官的角色怎么定义?提问节奏怎么控制?点评以什么形式呈现?一句话直接把所有需求写完容易,但做出来的东西大概率"四不像"。
我把核心需求收敛成下面几条:
| 需求项 | 说明 |
|---|---|
| 岗位多样性 | 支持前端、后端、算法、AI、数据分析、客户端、测试、运维等多个方向 |
| 面试模式 | 技术面、行为面、综合面三种典型模式 |
| 难度分级 | 初级、中级、高级,让不同水平的用户都能练 |
| 连续对话 | AI 一轮提问、用户作答、AI 点评并提出下一题,形成完整闭环 |
| 结构化输出 | AI 的回复必须能被程序解析,前端据此渲染"问题卡片 / 点评卡片 / 总结卡片" |
| 流式体验 | 内容流式返回,避免长时间白屏等待 |
| 历史会话 | 面试过程可保存、可回看 |
在这些需求里,最挑战人的其实是"结构化输出"。大模型天生擅长生成自然语言,但要让它稳定地输出能被程序直接解析的 JSON,靠的不是运气,而是精细的提示词工程。这一点我后面会重点讲。
三、技术选型:为什么坚持"纯前端,零依赖"
技术选型我几乎没有犹豫,就定下了三条原则:
- HTML5 + CSS3 + 原生 JavaScript;
- 不引入任何框架和构建工具;
- 不引入任何后端服务。
有人会问:现在前端谁还写原生 JS?框架不香吗?我的考虑有几点:
第一,降低使用门槛。 项目最后的形态是一个可以直接双击打开的网页。用户在本地打开 index.html,配置好 API Key,就能立即开始面试。不需要 npm install、不需要构建、不需要跑一个 Node 服务。这对"个人工具"定位来说,是最轻的使用方式。
第二,聚焦问题本质。 这个项目的技术难点不在"前端工程化",而在浏览器如何直连大模型接口、如何处理 SSE 流、如何解析并渲染结构化数据。去掉框架的干扰,代码反而更透明,更容易被阅读和二次开发。
第三,零依赖意味着零风险。 没有第三方库,就没有供应链风险、没有版本冲突、没有兼容性包袱。整个项目就三个 JS 文件,逻辑一目了然。
四、项目结构一览
定下"零依赖、纯静态"的方向后,项目结构也自然出来了:
ChatSage/
├── index.html # 页面骨架:侧边栏 / 聊天区 / 输入区
├── css/
│ └── style.css # 全部样式(GitHub 风格深色主题)
├── js/
│ ├── config.js # API 配置 + 系统提示词 + 选项枚举
│ ├── api.js # 流式调用层(Python → JS 的转换成果)
│ └── app.js # 界面逻辑、JSON 解析与卡片渲染
└── README.md # 项目说明文档
三个 JS 文件的职责边界非常清楚:
- config.js 是"配置中枢",所有会变的参数(接口地址、模型名、系统提示词)都收敛在这里,改配置不需要动业务代码;
- api.js 是"通信层",只管和大模型接口打交道,把 SSE 流一段一段地解析成 JSON chunk 吐出来,不关心界面;
- app.js 是"交互层",负责发消息、收消息、解析 JSON、渲染卡片、管理会话状态。
这种分层让我在开发调试时效率很高:接口出问题,只看 api.js;渲染不对,只看 app.js。
五、核心难点(一):Python 流式代码 → JavaScript 的迁移
项目最初给我的参考资料,是一段 Python 代码,使用 requests 库实现流式请求:
def query(payload):
response = requests.post(API_URL, headers=headers, json=payload, stream=True)
for line in response.iter_lines():
if not line.startswith(b"data:"):
continue
if line.strip() == b"data:[DONE]" or line.strip() == b"data: [DONE]":
return
yield json.loads(line.decode("utf-8").lstrip("data:").rstrip("/n"))
这段代码的核心逻辑可以拆成四步:
- 发起 POST 请求,开启流式读取(
stream=True); - 逐行读取响应体;
- 只处理以
data:开头的行(SSE 协议的事件字段); - 遇到结束标记
[DONE]就终止,否则把data:后面的 JSON 解析出来并交给调用方。
这段逻辑本身不难,难的是迁移到浏览器端。Python 的 requests.iter_lines() 在浏览器里没有直接对应物,我必须用浏览器原生的 fetch + ReadableStream 手动实现。
5.1 浏览器端的流式读取
浏览器里拿到流式响应,标准做法是:
const response = await fetch(url, { method: "POST", headers, body, signal });
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按行切分处理 ...
}
有几个细节必须处理好:
(1)字节断裂问题。 网络传输是分块的,一个完整的 JSON 很可能被切成两半、分别到达。如果收到一块就立即解析,大概率会失败。解决方案是维护一个 buffer,把每次到达的文本追加进去,再按 \n 切分成完整行;最后一行可能不完整,要留到下一轮再处理。这个"缓冲 + 半行回退"的技巧,是流式解析最核心的细节。
(2)TextDecoder 的断码问题。 中文等 UTF-8 多字节字符可能跨越两个数据块,直接 decode(value) 会把半截字符解成乱码(替换字符 �)。必须使用 decoder.decode(value, { stream: true }),让解码器记住未完成的多字节序列,跨块拼接。这一点踩坑率极高,几乎每个手写流式解析的开发者都会遇到。
(3)SSE 协议解析。 OpenAI 兼容接口的流式响应遵循 SSE(Server-Sent Events)的简单变体:每行以 data: 开头,data: [DONE] 表示结束。我的做法是逐行判断、去掉 data: 前缀后 JSON.parse,失败的行直接忽略并打日志,保证健壮性。
5.2 用异步生成器对齐 Python 的 yield
Python 代码用了生成器(yield),天然支持延迟求值。JavaScript 对应的现代写法是异步生成器(async function*),配合 for await 进行消费。迁移后的核心代码如下:
async *streamChat(messages, overrides = {}, signal) {
const payload = { ...this.defaultParams, model: this.model, messages, ...overrides };
const response = await fetch(this.url, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}` },
body: JSON.stringify(payload),
signal
});
if (!response.ok) throw new Error(`API 请求失败(HTTP ${response.status})`);
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // 最后一行可能是残片,留到下一轮
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") return;
try { yield JSON.parse(data); } catch (_) { /* 忽略无法解析的行 */ }
}
}
}
这段代码的迁移思路可以提炼成一个通用公式:Python 的 iter_lines() ≈ JS 的缓冲区 + 按行切分;Python 的 yield ≈ JS 的异步生成器;Python 的 requests ≈ JS 的 fetch。
除此之外,浏览器端还送了一个 Python 没有的额外能力:AbortController。把它的 signal 传给 fetch,用户就可以在界面上随时"停止生成",这在 Python 脚本里反而要费些周折。
六、核心难点(二):提示词工程,让大模型输出"程序能用的 JSON"
如果说流式转换是项目的"骨架",那提示词工程就是项目的"灵魂"。
6.1 为什么需要结构化输出
刚开始我也想偷懒:让 AI 直接自由发挥,把回复原样显示在气泡里。但很快发现这样体验很差——
- AI 的回复是一大段混合文本,里面既有点评又有下一题,用户很难一眼抓重点;
- 我想做"评分环"“优缺点清单”"难度徽章"这些精美的视觉组件,但自由文本根本无法支撑;
- 想统计面试进度、自动编号,也完全无从下手。
结论很明确:要让前端"更好显示",就必须让 AI 的回复"更好解析"。也就是把"自然语言自由输出"升级为"按约定 Schema 的结构化输出"。
6.2 三类消息的 Schema 设计
我把整个面试流程定义成三种回合,每种回合对应一种 JSON Schema:
第一类:提问回合(type = “question”)
{
"type": "question",
"question": {
"content": "题目内容(可含简要场景描述)",
"category": "所属知识点分类,如 JavaScript / 操作系统",
"difficulty": "easy | medium | hard",
"hint": "给求职者的简要提示,30字以内"
}
}
第二类:点评并追问回合(type = “feedback”)
{
"type": "feedback",
"feedback": {
"score": 8.5,
"score_label": "优秀",
"comment": "对本次回答的整体点评,80字以内",
"strengths": ["亮点1", "亮点2"],
"improvements": ["待改进1", "待改进2"],
"reference_points": ["参考要点1", "参考要点2"],
"next_question": {
"content": "下一个问题",
"category": "知识点分类",
"difficulty": "medium"
}
}
}
第三类:总结回合(type = “summary”)
{
"type": "summary",
"summary": {
"total_score": 85,
"dimensions": [
{ "name": "技术深度", "score": 88, "comment": "该维度点评" },
{ "name": "表达能力", "score": 82, "comment": "该维度点评" }
],
"overall_comment": "整体综合评价,100字以内",
"suggestions": ["学习建议1", "学习建议2"],
"verdict": "通过 | 待定 | 不通过"
}
}
6.3 提示词里的"说服艺术"
光把 Schema 写出来还不够,大模型未必老老实实遵守。我在系统提示词里做了几层设计:
第一层:角色设定。 明确告诉模型它是"拥有 15 年大厂招聘经验的资深技术面试官",并注入当前的面试上下文(职位、类型、难度)。上下文越具体,模型的角色扮演越稳定。
第二层:流程约束。 把面试编排成明确的阶段:
- 第一轮输出
question; - 之后每轮在
feedback的next_question字段里带出下一题; - 约 6 轮后或用户要求结束时,输出
summary。
这相当于给模型一张"剧本",它不会乱开场、不会在第三轮就宣布结束。
第三层:输出硬约束。 这一层是决定成败的关键,我在提示词里用非常强硬的措辞:
“你每一次回复都只能输出【一个合法的 JSON 对象】,不允许输出任何多余文字、解释、注释或 Markdown 代码块标记。”
为什么特别强调"不要输出 Markdown 代码块标记"?因为很多模型喜欢把 JSON 包在 json ... 里,这会导致前端 JSON.parse 直接失败。把这条写进提示词,能从源头消掉一大类解析错误。
第四层:字段兜底。 提示词里写明"字段必须齐全"。这样即使模型偷懒,也会尽量把 difficulty、category 这些次要字段补齐。
当然,提示词工程只能"提高"而非"保证"格式稳定性。所以前端代码里必须有兜底逻辑:如果某次回复解析失败,就降级为普通文本展示,绝不因格式问题让整个会话崩掉。
七、核心难点(三):结构化 JSON 的解析与卡片渲染
提示词负责"让 AI 按格式输出",前端负责"把格式化输出变成好界面"。
7.1 解析与分派
app.js 里有一个统一的入口函数 renderAssistantPayload(),收到完整回复后按这个流程处理:
- 尝试
JSON.parse解析回复文本; - 解析成功且
type命中约定的三种值 → 调用对应渲染函数; - 解析失败或
type不匹配 → 降级展示原始文本。
对应的三个渲染函数,分别产出了三种视觉卡片:
7.2 问题卡片
展示 AI 提出的问题。视觉上包含:提问序号徽章、难度徽章(初级绿 / 中级橙 / 高级红)、知识点分类标签、题目正文,以及一个可选的高亮提示栏。用户扫一眼就能知道"这是什么难度的哪类问题,问题是什么,有没有提示"。
7.3 点评卡片
这是信息密度最高的一张卡片。我用了 SVG 实现环形评分进度:把分数换算成圆环的弧长百分比,绿色弧线代表得分。评分下方依次排布:
- 优点清单(绿色 ▸ 前缀);
- 待改进清单(橙色 ▸ 前缀);
- 参考要点(蓝色 ▸ 前缀);
- 下一题预告(虚线框 + 分类标签 + 难度徽章)。
三类清单用颜色和图标区分,用户可以在不读完整段点评的情况下快速定位"我哪里好、哪里差、该补什么、下题是什么"。
7.4 总结报告卡片
面试结束时输出:
- 大字总分(渐变色数字);
- 多维评分条(维度名 + 渐变进度条 + 分数 + 维度点评);
- 整体评价(左侧蓝色竖线引用样式);
- 学习建议清单;
- 结论徽章:通过(绿)/ 待定(橙)/ 不通过(红)。
这张卡片的"仪式感"很强,用户练完一整场面试,最后看到自己的总结报告,完成感和获得感是普通对话完全给不了的。
7.5 安全性:渲染绝不拼 HTML 的坑
渲染这些卡片时,字段里的文本全部来自大模型,属于不可信输入。如果直接用模板字符串拼接插入 DOM,等于把 XSS 漏洞开在自家门口——模型哪怕无意中输出一段 <script>,都可能被执行。所以我对所有模型返回的文本都做了 HTML 转义(esc() 函数把 & < > " ' 全部转义),同时保留结构字段(如数组、分数)走定制渲染。这是做 AI 应用时最容易忽略、也最不能忽略的防线。
八、体验细节:把"能跑"做到"好用"
功能都通了之后,我在体验细节上又打磨了很多回合。
交互细节一:回车发送、Shift+Enter 换行。 聊天输入框遵循主流对话产品的交互直觉;输入框自适应高度,内容多了自动长高,超过上限后出现内部滚动。
交互细节二:生成中的打字指示器。 AI 回复到达前,气泡内显示三个跳动的圆点,让用户明白"模型正在思考,不是卡死了"。这个看似微小的反馈,对等待体验的提升非常显著。
交互细节三:随时停止生成。 顶部有"停止"按钮,背后是 AbortController 中止 fetch 请求;中止后气泡显示"已停止生成"的提示,不抛丑陋的红色报错。
交互细节四:欢迎屏(Hero)设计。 未开始面试时,聊天区显示项目介绍和三张功能特性卡片,把"这个页面是干什么的、怎么开始"一次说清楚,降低新用户的学习成本。
交互细节五:历史会话。 所有会话用 localStorage 持久化,左侧边栏列出历史会话(职位标签 + 时间 + 首轮问题预览),点击即可回看完整面试过程。回看时也能正确渲染结构化卡片,而不是一堆原始 JSON。
九、实测验证:代码对不对,拉出来遛一遛
写代码不能只靠"我觉得没问题"。这个项目的三个阶段,我都做了真实验证:
验证一:语法与资源加载。 三个 JS 文件通过 node --check 语法检查;本地起静态服务器后,index.html、style.css、三个 JS 文件全部返回 HTTP 200。
验证二:真实 API 流式调用。 我在 Node 环境里加载 config.js 和 api.js,真实调用了一次 GitCode AI 接口。结果:成功收到增量片段,聚合出完整回复,SSE 流式转换逻辑运行正常。
验证三:提示词 JSON 约束的端到端验证。 这是我最在意的一次测试——用系统提示词 + 一条"开始面试"的用户消息,真实请求模型。模型返回的原始文本是:
{
"type": "question",
"question": {
"content": "你好,欢迎参加本次前端面试。我们先从一个基础但核心的问题开始:请解释一下 JavaScript 的事件循环机制,包括宏任务和微任务的执行顺序,并结合一个实际例子说明。",
"category": "JavaScript",
"difficulty": "medium",
"hint": "可先讲调用栈、任务队列,再说微任务与宏任务的优先级。"
}
}
一次命中提问 Schema,字段齐全,JSON.parse 一次通过。说实话,模型对提示词的遵从度超出了我的预期。
这套"先语法、再链路、后语义"的三级验证思路,我建议每个做 AI 应用的人都收藏——不要在没有真实验证的情况下把项目交给别人使用。
十、安全与生产化思考:演示可以,上线要想清楚
必须诚实地说,这个项目里有一个"演示色拉"味道的决策:API Key 直接写在了前端代码里。
在浏览器端嵌 Key,任何打开网页的人都能从开发者工具里把它扒出来。所以它只适合个人本机演示,绝不适合公开部署。README 里我也明确写了这条,并给出了生产化路径:
- 把调用逻辑搬到后端:由你自己的服务持有 Key,前端只请求自家接口,用服务端代码去调用大模型并转发 SSE;
- 接口鉴权:后端接口加自己的鉴权(登录态、Token),避免被刷;
- 使用代理解决 CORS:如果一定要浏览器直连且接口有跨域限制,需要一个支持 CORS 的反向代理;
- 历史记录上云:
localStorage只适合个人本地,做多端同步需要在服务端持久化会话。
另外值得单独强调:SSE 流经后端时,一定要原样转发。很多开发者图省事在后端等全部生成完再一次性返回,这会彻底毁掉流式体验,还把服务端内存和超时配置推到极限。
十一、踩坑记:那些让我印象深刻的坑
这个项目不大,但坑一点都不少。挑三个最有代表性的:
坑一:多字节字符被拦腰截断。 我在流式解析初期,中文偶尔会变成乱码。排查半天,罪魁祸首是 decoder.decode(value) 没带 { stream: true }——UTF-8 的三个字节被分成两批到达,解码器不认识半截字符。这个坑的教训是:手写流式解析,永远记得给解码器开"流模式"。
坑二:模型输出 Markdown 代码块包裹 JSON。 模型很"贴心"地给 JSON 套上了 ```json 代码块,导致 JSON.parse 直接炸。解决分两层:提示词层明确禁止,代码层做了清理兜底。
坑三:流式 chunk 的"半行"处理。 一开始我在每个 chunk 到达时都尝试解析,结果频繁失败。后来改成"缓冲完整行、残片留到下一轮",问题才彻底消失。这个方案用一句话概括:永远不要在流的中间假设"下一个字节不会来"。
十二、成果与展望
最终交付的 ChatSage 项目,用 6 个文件、零依赖,实现了:9 个岗位方向 × 3 种面试模式 × 3 档难度,流式对话、结构化点评、总结报告、历史回看一应俱全。所有代码都已开源在 AtomGit 仓库。
回顾整个项目,如果说有什么最重要的心得,我想是这句话:
AI 应用开发的核心竞争力,不在于"怎么调 API",而在于"怎么用产品思维把大模型的能力封装成用户真正用得上的体验"。
结构化输出、流式体验、错误兜底、安全渲染——这些工作看似不起眼,却恰恰是"一个能用的 AI 产品"和"一个能跑的 Demo"之间的分水岭。
关于未来,我列了几个值得继续做的方向:
- 面试报告的导出能力:把总结报告导出成 PDF/Markdown,方便用户存档复盘;
- 答题语音输入:接入浏览器原生语音识别,模拟真实面试的口头作答;
- 多轮追问的深度:学习真实面试官的追问技巧,对有潜力的回答继续深挖;
- 错题本与学习路径:根据历史面试缺陷,自动推荐针对性学习资源。
如果你也想做一个自己的 AI 对话应用,我的建议是:不要一开始就追求功能多,先把你最在意的那一个交互做到极致。比如这个项目,我把全部精力押在"结构化面试体验"这一个点上,反而做出了差异化。
欢迎 Star 这个项目,也欢迎在评论区聊聊你心中理想的 AI 面试官应该是什么样。
技术是冷的,但好产品应该是有温度的。愿每个求职者,都能在正式上场前,提前遇见那个更好的自己。
更多推荐


所有评论(0)