在这里插入图片描述
在这里插入图片描述

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

从一段 200 行的 Python 流式调用代码出发,到交付一个功能完整的"面试AI对话"网页应用。
这篇文章完整记录了项目从需求、技术选型、核心难点(流式转换、提示词工程、结构化渲染)到实测验证的全过程,希望能给想在大模型应用上动手实践的开发者一些启发。


一、缘起:为什么想做"面试 AI 对话"

每个程序员大概都有过这样的时刻:简历投出去了,面试通知迟迟不来;或者好不容易拿到面试机会,却在电话面试里因为紧张、表达混乱而丢分。面试这件事,光靠"临场发挥"是不行的,它和考试一样需要练习。可现实是——

  • 找人模拟面试,成本高、时间难约、反馈主观;
  • 对着题库背题,又和真实面试的氛围相差太远;
  • 市面上现成的面试刷题产品,要么收费,要么套路化,缺少"对话感"。

于是我想:能不能用现在最火的大模型,做一个真正能"对话"的模拟面试官?它应该能做到三件事:

  1. 像真正的面试官一样连续提问,而不是一次输出一整个题库;
  2. 对每道题的作答给出专业点评,包括打分、亮点、不足和参考要点;
  3. 面试结束给出结构化的总结报告,帮用户知道下一步该补什么。

这个想法落地成一个具体的项目,被我命名为 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"))

这段代码的核心逻辑可以拆成四步:

  1. 发起 POST 请求,开启流式读取(stream=True);
  2. 逐行读取响应体;
  3. 只处理以 data: 开头的行(SSE 协议的事件字段);
  4. 遇到结束标记 [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(),收到完整回复后按这个流程处理:

  1. 尝试 JSON.parse 解析回复文本;
  2. 解析成功且 type 命中约定的三种值 → 调用对应渲染函数;
  3. 解析失败或 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 里我也明确写了这条,并给出了生产化路径:

  1. 把调用逻辑搬到后端:由你自己的服务持有 Key,前端只请求自家接口,用服务端代码去调用大模型并转发 SSE;
  2. 接口鉴权:后端接口加自己的鉴权(登录态、Token),避免被刷;
  3. 使用代理解决 CORS:如果一定要浏览器直连且接口有跨域限制,需要一个支持 CORS 的反向代理;
  4. 历史记录上云: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 面试官应该是什么样。
技术是冷的,但好产品应该是有温度的。愿每个求职者,都能在正式上场前,提前遇见那个更好的自己。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐