我给自己写了个「AI 代言人」

我给自己写了个「AI 代言人」:面试官进来问,它只敢照简历答

技术栈:Vue 3 + Vite + Pinia | 裸 node:http + TypeScript + MySQL | FastGPT / DeepSeek 双引擎


一、起因:同一份简历,我讲了七遍

去年秋招,我被同一家公司的三个面试官分别问过同一个问题:「前端渲染怎么优化的?」

三次我都答了。第三次答完,我脑子里蹦出来一个念头------这活儿为什么要我做?

简历上写着的东西,本质是一份静态、结构化、可检索的文档。面试官想预研,无非是想在正式面试前把候选人的技术栈、项目细节摸个大概。这个过程完全可以让机器代劳,前提是:

它绝对不能编。

这是整个项目唯一的红线。一个会瞎编项目经历的「AI 代言人」不是提效工具,是职业事故。所以从第一行代码开始,产品目标就定死了一句话:

回答必须能溯源到简历原文,编不出来就明说「简历未提及」。

于是有了这个项目:候选人本人维护一份档案,面试官拿一串密码进门,跟 AI 预研简历。


二、两扇门:把「候选人」和「面试官」彻底隔开

先说产品结构,因为它直接决定了后面所有技术选型。

系统里只有两类人,但他们的权限差得非常远:

角色 入口 能干什么
候选人(我自己) /admin 改简历、上传原文 PDF、管题库、发分享链接、回看所有对话
面试官 / 只能问,不能改;只能看自己那条会话

两套密码,ADMIN_PASSWORDCHAT_PASSWORD服务端强制两者不能相同。密码只在提交时发给后端,前端不写死、不进 localStorage。

有意思的是面试官这一侧是两扇门

  1. 第一扇:总密码门,进来证明「你是我邀请的面试官」
  2. 第二扇:席位门,选/建「公司」+「身份」,进来证明「你是哪家的哪一位」

为什么要第二扇门?因为面试是有组织结构的。同一家公司会有一面、二面、HR 面,他们问的东西不一样,但共享同一个候选人。我需要:

  • 一个身份 = 一条长会话,刷新不丢
  • 同公司的其他身份能看不能问(二面能翻一面问过什么,避免重复问)
  • 同一个身份同时只许一个人写(防止两个人抢同一条会话)

这三条需求,直接催生了后面最硬的一块技术:写锁

这里有个交互细节我挺满意的:公司和身份都是普通 input,不是下拉选择器。

先搜,命中了点一下就反填进输入框;搜不到就直接拿输入的字新建。新建时如果库里有名字相近的(LIKE %q% 命中),弹窗问一句「确定不是上面那家?」------仍然允许你坚持新建,只是让你确认一次。

为什么不用 el-select?因为 select 的心智模型是「从既有集合里挑」,而这里的真实场景是「大概率是新的,小概率撞已有」。用 input 才顺。

三、选型:砍掉 Express,砍掉 vue-router

这项目最容易被喷的两个决定,我先自己交代。

后端:裸 node:http,不上 Express / Fastify / Koa

后端一共 17 个 TS 文件、3467 行 ,接口 25 个。这个体量上 Express 能省什么?省一个 app.get() 的语法糖,换来一层中间件抽象、一堆 @types、和一个我不需要的路由匹配器。

我需要的东西其实就三样:读 body、写 JSON、写 SSE。这三样自己写一遍不到 130 行(src/http.ts),而且完全可控

举个具体的好处:这项目要在同一个响应里下发多条 Set-Cookiechat_token + chat_desk + chat_share)。裸 http 用 res.setHeader('Set-Cookie', [...]) 传数组,一行搞定,我清楚它到底发了什么。

顺带砍掉的还有 JWT 。会话就是一个随机串 Cookie,HttpOnly + SameSite=Lax + Path=/。我不需要无状态扩容,我需要「能随时把某个人踢下线」------这事儿 JWT 反而更麻烦。

前端:不上 vue-router

前端一共 5 个页面。路由逻辑全文如下:

vue 复制代码
<script setup lang="ts">
/**
 * /admin 改简历;/admin/shares|templates|chats 进后台壳;其余预研对话。不上 vue-router。
 */
const path = location.pathname.replace(/\/$/, '') || '/'
const isHub = path === '/admin/shares' || path === '/admin/templates' || path === '/admin/chats'
const isAdmin = path === '/admin'
</script>

<template>
  <AdminHub v-if="isHub" />
  <ResumeWorkspace v-else-if="isAdmin" />
  <InterviewerChat v-else />
</template>

就这样。后台那三个 tab 的切换用 history.pushState + KeepAlive,切换不闪、地址栏正常、刷新能落到对应 tab。

为什么敢这么干? 因为这项目的页面之间几乎没有「路由级」需求:没有嵌套路由、没有路由守卫(鉴权是接口 401 驱动的)、没有懒加载分包压力、没有过渡动画。引入 vue-router 是为了解决我根本没有的问题。

我不是在推荐所有人都别用 router。我是想说:依赖的价值应该由你的问题规模来定,不是由社区默认配置来定。


四、技术拆解:几块我觉得值得聊的

4.1 写锁:一条 SQL 解决并发抢占

「同一个身份同时只许一人写」,用应用层内存记状态是错的------重启就丢、多实例就崩。正确做法是把锁下沉到数据库,用条件更新的原子性兜底:

ts 复制代码
/**
 * 尝试占用写锁。成功返回 token,失败返回空串。
 */
export async function tryLock(identityId: number): Promise<string> {
  const token = randomBytes(16).toString('hex')
  const [result] = await getPool().query<ResultSetHeader>(
    `UPDATE identities
     SET lock_token = ?, lock_until = DATE_ADD(NOW(), INTERVAL ? MINUTE)
     WHERE id = ? AND (lock_token IS NULL OR lock_until IS NULL OR lock_until < NOW())`,
    [token, config.chatLockMinutes, identityId]
  )
  return result.affectedRows ? token : ''
}

关键在 WHERE 那一串:锁为空、或已过期,才允许写入affectedRows 是 0 就说明没抢到。两个人同时打这条 SQL,MySQL 的行锁保证只有一个 affectedRows = 1。不需要 Redis、不需要分布式锁库。

续锁也是同一个套路,多带一个 lock_token = ? 保证只能续自己的:

ts 复制代码
/**
 * 先续自己的锁;续不上就抢过期锁。抢不到返回空串。
 */
export async function ensureLock(identityId: number, token: string): Promise<string> {
  if (token && (await touchLock(identityId, token))) return token
  return tryLock(identityId)
}

ensureLock 这个「先续、续不上就抢」的组合拳,处理掉了一个很讨厌的边界:面试官关了浏览器没点退出 。他的锁会在 15 分钟(CHAT_LOCK_MINUTES 可配)后过期,下一个人的 tryLock 自然就抢到了。不需要心跳、不需要清理任务。

抢不到锁的人不是被拒绝,而是降级成只读------顶栏明确标出「只读」,能翻记录不能发问。

4.2 双引擎开关:把「钱烧完了」当成一等公民

RAG 这块我用的是线上免费版 FastGPT(自建 Docker 我那台最低配机器带不动)。但免费额度是 100 积分------联调够用,正式跑必爆。

所以从设计阶段我就假定它会挂,.env 里放了一个开关:

ini 复制代码
USE_FASTGPT=true   # true 走线上 FastGPT,false 直连 DeepSeek

两条路的差别:

USE_FASTGPT=true USE_FASTGPT=false
保存简历时 按字段切片,覆盖同步进 FastGPT 知识库 不同步
检索 FastGPT 向量检索 内存切片检索
生成 经 FastGPT 直连 DeepSeek
出处 sources FastGPT 引用映射 本地检索结果

关键是这个分歧被收在一个函数里,对外只暴露一套 SSE:

ts 复制代码
export async function streamChatAnswer(question: string, res: ServerResponse): Promise<ChatAnswer> {
  const resume = await readResume()

  // 空问题、简历没内容、命中禁区 → 一个模型都不调
  if (!question.trim() || !hasResumeContent(resume) || isForbidden(question)) {
    return flushAnswer(res, absentOf(hint))
  }

  openSse(res)
  if (!config.useFastgpt) {
    const picked = await retrieveLocalSources(question)
    const text = await askDeepseek(question, picked.sources, (d) => writeSse(res, { type: 'delta', text: d }))
    // ...
  } else {
    const result = await askFastgpt(question, (d) => writeSse(res, { type: 'delta', text: d }))
    // ...
  }
  writeSse(res, { type: 'done', text, absent, hint, sources })
}

积分烧完了怎么办?改一个字符,重启,完事。前端一行代码都不用动 ,面试官那边打的还是我自己的 /api/chat/desk/messages,收的还是 delta + done

这里有个我特意没做的东西:积分归零自动切换。要自动切就得探测余额,而余额接口的可靠性我没法保证------一个不稳的探测逻辑会把「明确的失败」变成「随机的行为」。宁可手动。

还有一条硬规矩,藏在上面代码的第一个 if 里:没命中检索、或者命中薪资/离职原因这类禁区,直接返回固定文案,一个 token 都不给模型花。

ts 复制代码
const ABSENT_TEXT = '简历未提及该内容。'

同时给一句解释性的 hint:「我只基于 XXX 上传的简历回答,不会猜测或补充简历之外的信息。这一问题建议在真实面试中直接向他本人确认。」

4.3 0 点跑一次的热门问题:白天一个模型都不调

「热门问题」这功能,最蠢的实现是每次请求都让模型算一遍。

我的做法是:统计和展示彻底分离。

  • 白天GET /api/chat/templates 只读 hot_board 表里那一行 JSON,空位用题库补满 6 条。零模型调用,零延迟。
  • 凌晨 0 点(上海时区):捞前一个自然日的用户提问 → 交给 DeepSeek 做近义合并 → 写榜。

近义合并这块我原本想写规则(去标点、去停用词、编辑距离),写了一半发现是个无底洞:「你们前端团队多少人」和「前端几个人」这种,规则根本收不住。直接扔给模型,让它顺便挑展示句和排序:

ts 复制代码
messages: [{
  role: 'system',
  content: '你合并近义的面试提问。输入是 {q,n} 数组,n 是该原文出现次数。' +
    '把同义问法合成一条,n 相加,q 用出现最多的那句原文。只保留合并后 n>=2 的,最多 6 条,按 n 降序。' +
    '返回 JSON:{"templates":[{"cat":"01 · 技术栈","q":"...","n":3}]}。不要解释。'
}]

response_format: { type: 'json_object' },省掉一堆解析容错。

三条兜底规则,全是「宁可不更新,也不给错的」:

ts 复制代码
// 1. 当天没人提问 → 直接跳过,一个 token 都不花
if (!dayCount) {
  logInfo(`热门统计:${start.slice(0, 10)} 无人提问,跳过`)
  return
}

// 2. 模型挂了或没配 Key → 沿用旧榜,不清空
catch (err) {
  logError(`热门统计失败,沿用旧榜:${message}`, err)
  return
}

// 3. 模型返回空 → 同样沿用旧榜
if (!templates.length) {
  logError('热门统计:模型结果为空,沿用旧榜')
  return
}

定时器也没上系统 cron,进程内自己排:

ts 复制代码
export function scheduleHotBoard(): void {
  const at = nextShanghaiMidnightMs()
  setTimeout(() => {
    void runHotBoardJob().finally(() => scheduleHotBoard())  // 跑完再排下一天
  }, Math.max(1000, at - Date.now()))
}

时区这块得单独说一句:服务器可能是 UTC,用户是上海。我没引 dayjs / date-fns,就手算了个偏移:

ts 复制代码
const SH_MS = 8 * 60 * 60 * 1000
export function shanghaiYmd(ms = Date.now()): string {
  const t = new Date(ms + SH_MS)
  return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`
}

中国不用夏令时,所以固定 +8 是安全的。这是明确认下来的简化,不是疏忽------写在注释里了。

4.4 上传一份 PDF,让模型帮我填表单

简历这东西,我手上本来就有一份 PDF。让我把它再手敲一遍进表单?不干。

所以有了 P10:原文 PDF 解析填表。流程是:

  1. /admin 上传一份 PDF(替换即覆盖,只留一份)
  2. 上传本身不解析(这是刻意的:上传是上传,解析是解析,别把两件事绑死)
  3. 想填表时点「解析」→ unpdf 抽文字 → 交给 DeepSeek 按简历 JSON 的形状吐回
  4. 弹窗展示抽到的内容,可以直接改,再按区块勾选
  5. 确认后只覆盖勾中的区块,写进内存表单
  6. 点「保存」才真入库

第 4、5 步的交互我调了挺久。区块分成四组:基本信息、技能、工作经历与项目经验(绑一起)、自我评价。默认勾选规则是:

该区块表单还空 → 默认勾上;已经有内容 → 默认不勾。

理由很简单:模型的产出是「建议」,不是「事实」。 已经手写好的内容凭什么被覆盖?工作经历和项目必须绑一起勾,因为项目里的 company 字段必须选自工作经历------拆开勾会产生孤儿数据。

失败路径也写死了:没配 Key、抽不出文字、模型挂了 → 明确报错,一个字都不改表单。扫描件 PDF 和 Word 直接不支持,OCR 不做------这个坑的深度和这个项目的收益不成比例。

4.5 分享链接:一把链接绑一家公司

发密码给面试官这事儿有点尬。所以做了分享链接:/?s=xxxx 直接跳过密码门。

但这里有个非显而易见的设计:一把链接绑一家公司。

  • 手输总密码进来的人,可以任意选/建公司
  • /?s= 进来的人:第一个人 照常搜/选/新建公司,公司一旦写进库就锁死在这把链接上
  • 后面用同一条链接进来的人,公司名写死展示,只能搜/建身份
  • 作废链接 = 链接失效 + 释放公司绑定
  • 重新生成 = 换 s公司绑定保留,旧链接立刻废

为什么?因为发给 A 公司的链接,不应该有能力在 B 公司名下建会话。第一个人的选择就是这把链接的身份认定。

Cookie 三件套的分工也是为这个服务的:

Cookie 存什么 什么时候清
chat_token 过了密码门(约 7 天) 点退出
chat_desk 当前公司 / 身份 / 锁票 退出、或手输密码重新登录
chat_share 分享票(仅 /?s= 才有) 换另一条链接、或手输密码登录

最细的一条:同一条链接、同一台机器再打开,沿用上次身份直接进聊天 ;换一条 /?s= 才清席位重选。这条规则让面试官二次打开链接时零摩擦,同时保证换链接不会串号。

4.6 日志:终端给人看,文件给机器看

最后一块是排查日志,做法很简单但很好用:同一份日志,两种格式。

  • 终端:中文一行,人眼扫得动
  • 文件 :JSON,按天写 data/logs/YYYY-MM-DD.log,只留最近 7 天

每个 HTTP 请求落一行总览(方法、路径、状态码、耗时、请求号),失败再带错误摘要。关键链路(登录、席位锁、问答、PDF 解析、热门统计、外部 API)用 startSpan 逐步打点:

ts 复制代码
const done = startSpan('lock.try')
// ...
done({ ok })   // 或 done({ ok: false, err: message })

LOG_LEVEL 默认 info(只有访问行 + ERROR),debug 才打节点进出和 stack。

几条隐私红线,写需求的时候就定死了:

  • 提问和回答只记「有没有」(hasQuestion / hasAnswer / quotes 条数),不写正文
  • 不记 body、不记 Cookie、不记密码
  • 查询串里的敏感参数(比如分享票 s)打成 *
  • 请求号只写服务端,不回给前端

日志的价值是定位问题,不是留一份可以泄露的副本。


五、数据模型:8 张表,没有向量表

javascript 复制代码
resume            一行 JSON payload,就是唯一数据源
companies         公司
identities        身份(写锁的 lock_token / lock_until 挂在这儿)
messages          消息(热门统计的数据来源)
chat_templates    题库(分类 / 题目 / 排序 / 启用)
hot_board         一行,最近一次成功的模型榜
share_link        多把分享票,可绑 company_id
resume_original   原文 PDF 元信息(有没有 / 文件名 / 上传日)

两个刻意的决定:

1. 简历就是一行 JSON,不拆成经历表 / 项目表。 它永远整份读、整份写,没有单独查某一段经历的场景。拆表除了增加 join 什么也没换来。

2. 不自己建向量切片表。 知识库在 FastGPT 那边,resume.payload 是唯一源数据,改简历就整份重切覆盖同步。双写两份「真相」是灾难的开始------迟早不一致,然后你就得写对账逻辑。

3. PDF 二进制不进 MySQL,落本地盘,库里只记元信息。

建表 SQL 和启动时的 CREATE TABLE IF NOT EXISTS 是同一套(sql/schema.sql 对齐 src/db.ts),字段全带 COMMENT


六、部署:本地不装 Docker

线上是一台阿里云最低配 + 阿里云 MySQL。

  • 前端pnpm builddist/ 覆盖到服务器,Nginx 容器挂载目录,不用重新 build 镜像
  • 后端pnpm run bundle 打出 server.zip → 上传 → 云上 docker build → 映射 8081:3001
  • Nginx 把 /api 转到 8081,浏览器全程同源,Cookie 不用操心跨域

前端更新只要覆盖静态文件,这是挂载目录换来的好处------改个文案不需要走一遍镜像构建


七、认下来的坑

写文章最容易的是只讲亮点。这几条是明确的短板,我认:

1. 0 点任务绑在 Node 进程上。 进程没挂着就不跑。最低配机器上我认这个,靠 Docker restart: unless-stopped 兜。真要稳就得上系统 cron 或者分布式调度,现在的规模不值当。

2. PDF 落本地盘。 换机器要一起拷 data/。compose 里挂了卷,但这是个「记得住就没事、忘了就丢文件」的设计。

3. 免费 FastGPT 的 100 积分不够正式用。USE_FASTGPT=false 兜。这不是解决方案,是应急阀门。

4. 手输公司名靠 LIKE %q% 搜。 拼音、错字模糊搜不做。「阿里巴巴」和「阿里」能互相命中,「albb」不行。


八、写完之后的几点想法

关于依赖: 这项目最舒服的部分,是我对每一行代码的行为都有确定的预期。25 个接口、5 个页面的规模,裸 http + 手写路由的心智负担比一层框架抽象更低 。但我很清楚这个结论有边界------接口到 100 个、开发者到 5 个人的时候,框架的价值就回来了。选型是对当下规模的回答,不是永久信仰。

关于 AI 功能: 这项目里模型出现在三个地方------问答生成、热门近义合并、PDF 解析填表。三处的共同点是:模型的输出永远是「建议」,永远有确定性的兜底路径。

  • 问答:没命中就固定文案,不让模型自由发挥
  • 热门:模型挂了沿用旧榜,宁可不更新
  • 解析:结果进弹窗给人核对勾选,不直接落库

接 AI 不等于把决定权交给 AI。 想清楚「它错了会怎样」比想清楚「它对了有多爽」重要得多。

关于那条红线: 回头看,「编不出来就说简历未提及」这个约束,反而让整个系统简单了。因为我不需要考虑「回答质量评分」「幻觉检测」「置信度阈值」这些复杂问题------不知道就说不知道,是一个用几行 if 就能实现的诚实。


如果你也想给自己整一个,几个建议:先把「绝对不能做什么」写下来(比我这条红线更具体越好),再想功能。约束定得早,后面每个技术决定都会自己找到答案。

有问题评论区聊。

相关推荐
FungLeo1 小时前
React 管理后台实战 · 列表里突然出现一堆空白行?PIPL 擦除后前端怎么处理才不露馅
前端·react.js·状态模式·pipl
BigTopOne1 小时前
ArLiveLite — System Architecture
前端
JL151 小时前
如何用 MySQL 持久化、历史快照与游标实现多步 Redo/Undo
前端·数据库·mysql
灯澜忆梦1 小时前
【基于GO的Web开发9】gin框架返回json
前端·后端·golang·gin
IMPYLH1 小时前
HTML 的 <ins> 元素
前端·javascript·html
kymjs张涛2 小时前
DeepSeek Harness源码分析:非 AI 开发者的 Agent 学习总结
前端·后端·面试
Dovis(誓平步青云)2 小时前
番茄钟真正难的不是倒计时:启动、暂停、重置与页面销毁
android·服务器·前端
一千柯橘2 小时前
了解 dev-tools 不同的 debug 模式
前端
晨米酱2 小时前
Umi Mock 如何从文件声明变成 HTTP 响应
前端·javascript·设计