语音面试陪练:在 Rokid 眼镜上跑一个会出题的 AI
一、开场:为什么要做这个
上周三晚上十点,我正戴着眼镜在工位上改 bug,邮箱弹出一封新邮件:「您投递的前端工程师岗位已通过初筛,明天上午十点视频面试。」
简历是半个月前海投的,项目经验那栏我自己都快忘了写了什么。打开手机想刷两道题——解锁、找 APP、打字,等我点开第一题,眼皮已经开始打架。
那时候脑子里冒出来一个念头:与其低头打字,不如让眼镜直接当面试官。我说它听,我答它评,手不用碰任何东西。周末就把这个「面试陪练」搓出来了,这篇文章记一下怎么做的。

二、它能干什么
打开首页,四个岗位芯片:前端、后端、嵌入式、产品。下面四个难度:新手、初级、中级、高级。滑到哪个亮哪个。不想用预设的也行,直接对着眼镜说「我要面 Java 后端」,它就把这六个字当岗位名带进去。
点开始,进对话页。这个页面和前两个应用长得完全不一样——没有列表、没有卡片,就是聊天气泡。我说的话靠右,绿边黑底;AI 说的话靠左,绿字,左边一条小绿线。AI 出题的时候一个字一个字往外蹦。
最多聊五题。答完第五题,或者说一句「结束面试」,跳到复盘页。它把整场对话整理成四块:整体评价、答得好的、薄弱点、建议补强。然后念给你听。之前面过的场次都存在本地,按时间倒序排着,点进去能回看完整对话。
也可以不碰屏幕直接说:「练一场前端初级」直接进对话页,「看历史」打开历史页。宿主大模型负责听懂这些话,页面负责真正的问答。

三、多轮对话怎么管
前两个应用都是问一句答一句就结束,这次是来回聊。AI 得记得你上一句说了什么,才能追问下一题。核心就是一个数组,每条消息记三个字段:谁说的、说了什么、什么时候说的。
const MAX_CHARS = 6000;
const KEEP_ROUNDS = 6;
const KEEP_MESSAGES = KEEP_ROUNDS * 2;
let messages = [];
function charLen(list) {
let n = 0;
for (const item of list || []) {
n += item && item.content ? String(item.content).length : 0;
}
return n;
}
// 超 6000 字只留最近 6 轮,发给模型;完整记录仍在 messages 里
export function limitForModel(list) {
const src = Array.isArray(list) ? list.slice() : [];
if (charLen(src) <= MAX_CHARS) return src;
if (src.length > KEEP_MESSAGES) return src.slice(src.length - KEEP_MESSAGES);
return src;
}
export function pushMessage(role, content) {
const text = String(content || '').trim();
if (!text) return getMessages();
messages.push({
role: role === 'assistant' ? 'assistant' : 'user',
content: text,
ts: Date.now()
});
return getMessages();
}

有个细节:发给大模型的第一句是「请直接出第一题」,这句不能存进历史记录——不然复盘的时候会出现一句莫名其妙的指令。只在请求那一刻拼上去,不 push 进 messages。完整对话留着写复盘用,发给模型的上下文才裁剪。
四、面试官的"人设"怎么写
大模型本身不知道自己是面试官,得在 system prompt 里把规矩讲清楚。岗位和难度是首页选好的,拼进提示词:
export function buildSystemPrompt(role, level) {
return [
'你是一位严格但友好的技术面试官。岗位:' + role + ',难度:' + level + '。',
'规则:',
'1. 一次只问一个问题,不要一次问多个',
'2. 根据用户的回答简短点评(2-3句话),然后出下一题',
'3. 新手出概念题,初级出实战题,中级出场景题,高级出系统设计题',
'4. 不要自己说"我们开始面试",直接出题',
'5. 用户说"结束面试"时,回复"面试结束,正在生成复盘"',
'6. 每次回复不超过80个字,方便眼镜阅读和语音播报,不要用 markdown'
].join('\n');
}

出题先走 DeepSeek。超时 10 秒、HTTP 报错、返回空、JSON 解析失败,都静默切本地题库。本地题库按岗位和难度分,前端、后端、嵌入式、产品、通用各 16 题,一共 80 题,每个岗位每个难度 4 道。自定义岗位对不上这四类,就从通用题里抽。
// 优先同岗同难度未出过的;没有了同岗其他难度;再没有就通用题
export function pickQuestion(role, level, used) {
const key = resolveRole(role);
const lv = resolveLevel(level);
const usedSet = toSet(used);
let pool = BANK.filter(i => i.role === key && i.level === lv && !usedSet.has(i.id));
if (!pool.length) pool = BANK.filter(i => i.role === key && !usedSet.has(i.id));
if (!pool.length) pool = BANK.filter(i => i.role === '通用' && !usedSet.has(i.id));
return pool[Math.floor(Math.random() * pool.length)];
}

本地点评不走 AI,按关键词匹配:你的回答里提到了这道题的 goodPoints 里的词,就说「答到了XX」;没提到,就说「可以补充YY」。页面右上角有个小徽章,显示「AI 在线」或「本地题库」,一眼就知道现在跑的是哪条路。用户中途退出触发的中止请求不算失败,不能因此降级——不然你刚答完一题准备说下一句,它突然切到本地题库,体验很怪。
五、对话界面怎么渲染
对话页没有按钮栏、没有列表,就两列气泡。用户消息靠右,荧光绿边框,最大宽度七成;AI 消息靠左,纯绿字,没框,左边一条 4px 绿条。两个气泡间距 6px,新消息从底部顶上来。
<scroll-view class="scroll" scroll-y="true"> <view ink:for="{{ bubbles }}" ink:key="id" id="{{ item.anchor }}" class="{{ item.rowClass }}"> <view class="bar" ink:if="{{ item.showBar }}"></view> <view class="{{ item.bubbleClass }}"> <view class="dots" ink:if="{{ item.showDots }}"> <text class="dot d1">.</text> <text class="dot d2">.</text> <text class="dot d3">.</text> </view> <text class="{{ item.textClass }}" ink:if="{{ item.showText }}"> {{ item.content }} </text> </view> </view></scroll-view>

AI 还在想的时候,气泡里显示三个跳动的点,不是转圈图标。打字机效果配合出来,就是它边想边说的感觉。
焦点系统上也踩了坑。首页有 11 个可聚焦项:4 个岗位、4 个难度、自定义、开始、历史。默认已经选了「前端」和「初级」,如果用同一种亮绿色表示「已选中」和「当前焦点」,屏幕上会同时亮三个。后来拆成两套样式:已选中用暗绿加细线,焦点用亮绿加粗。滑到已选中的那项时,只保留焦点样式。
六、AI 回答怎么做到打字机效果
这是体验上最爽的一点。DeepSeek 支持流式输出,不用等它想完整句再一次性显示。用 wx.request 发请求时开 enableChunked,监听 onChunkReceived。每个 chunk 可能是 ArrayBuffer,要用 TextDecoder 解码成字符串,再按 SSE 的 data: 行拆,取 delta.content。

task = wx.request({
url: CONFIG.ENDPOINT,
method: 'POST',
enableChunked: true,
dataType: 'text',
header: { Authorization: 'Bearer ' + CONFIG.DEEPSEEK_API_KEY },
data: {
model: CONFIG.MODEL,
thinking: { type: 'disabled' },
temperature: CONFIG.TEMPERATURE,
max_tokens: CONFIG.MAX_TOKENS,
stream: true,
messages: messages
},
success(res) { /* ... */ }
});
task.onChunkReceived((chunk) => {
const text = decodeChunk(chunk); // ArrayBuffer -> string
rest += text;
const lines = rest.split('\n');
rest = lines.pop();
for (const line of lines) {
const piece = deltaFromLine(line); // 解析 data: {...}
if (piece) takeDelta(piece); // 逐字塞进打字机队列
}
});
打字机本身是个小队列,每 16ms 吐一个字到当前 AI 气泡。万一流式不支持,降级成普通请求,拿到完整回复后用同样的队列逐字显示,视觉效果一样只是慢一点。TTS 也跟着走:AI 的回答按句切,每句不超过 50 字,排队念出来。
七、面试结束:复盘报告怎么生成
答完五题,把整场对话重新拼一遍发给 DeepSeek,要求只返回 JSON:

export async function createReview(session) {
if (session.source === 'local') return localReport(session);
const messages = limitForModel(session.messages || []);
const prompt = [
'请根据以下面试对话,生成一份结构化复盘报告,输出 JSON:',
'{"summary":"整体表现一句话","goodPoints":["答得好的点"],',
'"weakPoints":["薄弱点"],"suggestions":["建议补强方向"]}',
'对话记录:',
formatDialog(messages)
].join('\n');
try {
const content = await requestJson({
messages: [
{ role: 'system', content: '只输出 JSON 对象,不要 markdown,不要解释。' },
{ role: 'user', content: prompt }
]
});
return parseReport(content);
} catch (err) {
return localReport(session);
}
}



parseReport 做了 JSON 容错:如果大模型在 JSON 前后多写了字,就从第一个 { 到最后一个 } 之间截取再 parse。本地题库模式不发请求,把这五题的 goodPoints 和 weakPoints 拼一份简单复盘。每场面试存在本地 Storage,key 是 interview_sessions_v1,最多留 30 场,按时间倒序。
八、工程初始化与发布
config.js 就几样东西:

export const CONFIG = {
DEEPSEEK_API_KEY: 'sk-你的key',
ENDPOINT: 'https://api.deepseek.com/v1/chat/completions',
MODEL: 'deepseek-v4-flash',
TIMEOUT_MS: 10000,
TEMPERATURE: 0.7,
MAX_TOKENS: 400,
REVIEW_MAX_TOKENS: 512
};
四个页面:home 选岗选难度,chat 聊天气泡,review 复盘,history 历史记录。六个模块:interviewer.js 出题、stream.js 请求和打字机、local-bank.js 题库、chat-history.js 上下文、review.js 复盘、storage.js 存取。
AGENTS.md 里定义了宿主大模型怎么开页面:说「练一场前端初级」直接进对话页,说「我要面试」但没说岗位就打开首页,说「看历史」打开历史页。已经在对话页时宿主不再抢答,出题和点评都交给页面。
在 AIUI Studio 创建智能体后,把工程目录导入 Craft,右侧麦克风就能模拟说话。调试好了打包提审,记得传 3-5 张截图和 aix 包。
九、运行效果
运行效果如下:

十、踩过的坑
首页同时亮了三处。默认选了「前端」和「初级」,滑动焦点停在「产品」,三个位置用同一种亮绿,看起来像三个都选中了。解法是把「已选中」和「当前焦点」拆成两套样式:选中用暗绿加细线,焦点用亮绿加粗,两者重叠时只保留焦点样式。
function buildHome(state, listening) {
const focus = state.focus;
const roles = [];
for (let i = 0; i < ROLES.length; i++) {
const on = !state.customOn && state.roleIndex === i;
const focused = focus === i;
let boxClass = 'chip';
if (on && !focused) boxClass += ' chip-on'; // 已选中:暗绿细线
if (focused) boxClass += ' chip-focus'; // 焦点:亮绿加粗
roles.push({
name: ROLES[i],
boxClass: boxClass,
textClass: focused ? 't-focus' : (on ? 't-pick' : 't-off')
});
}
// 难度行同理...
}
.chip-on { border-bottom-color: rgba(64,255,94,0.42); }
.chip-focus { border-bottom-width: 2px; border-bottom-color: #40FF5E; }
.t-pick { color: rgba(64,255,94,0.55); } /* 已选中:暗绿 */
.t-focus { color: #40FF5E; font-weight: 700; } /* 焦点:亮绿加粗 */
对话页没有退出入口。最初设计是按退格结束,但戴眼镜时根本想不起来还有退格这回事。后来在右下角加了「退出」按钮,滑到它上面会变成亮绿粗边浅底,单击才退出。回放历史时这个按钮自动变成「返回」,不会误生成复盘。
<view bindtap="onExitTap" class="{{ exitClass }}"> <text class="{{ exitTextClass }}">{{ exitLabel }}</text></view></view>
onExitTap() {
this.setFocus(1);
if (this._replay || this._finishing) this.goBack(); // 回放时只返回
else this.finishInterview(); // 正式面试才生成复盘
}
滑动看起来没反应。一开始把滑动理解成滚动对话,但屏幕上就一道短题,根本没超出可视区,滚动条纹丝不动。而且模拟器的前后滑动发的是方向键,如果页面只处理上下不处理左右,手势就被吃了。四个方向键都要认。按键抬起时要 preventDefault,不然宿主会自己处理退格返回和方向键滚动。
slideDir(event) {
const code = (event && (event.code || event.key)) || '';
if (code === 'ArrowUp' || code === 'ArrowLeft' ||
code === 'Up' || code === 'Left') return -1;
if (code === 'ArrowDown' || code === 'ArrowRight' ||
code === 'Down' || code === 'Right') return 1;
return 0;
}
onKeyUp(event) {
try { event.preventDefault(); } catch (e) {} // 必须在抬起时拦
const code = event && event.code;
if (code === 'Backspace') { this.finishInterview(); return; }
if (this.onSlide(event)) return;
if (code === 'Enter') this.onEnterUp();
}
退出按钮没有高光。按钮一直长一个样,滑动也不高亮,用户根本不知道现在单击会触发说话还是退出。解法是在说话提示和退出按钮之间切焦点,滑到退出时才变亮绿粗边。
setFocus(index) {
const i = index ? 1 : 0;
this._focus = i;
this.setData({
exitClass: i === 1 ? 'exit exit-on' : 'exit',
exitTextClass: i === 1 ? 'exit-text exit-text-on' : 'exit-text',
hintClass: i === 1 ? 'hint' : 'hint hint-on'
});
}
.exit { border: 1px solid rgba(64,255,94,0.28); }
.exit-on { border: 2px solid #40FF5E; background: rgba(64,255,94,0.18); }
.exit-text { color: rgba(64,255,94,0.4); }
.exit-text-on { color: #40FF5E; font-weight: 700; }
十一、写在最后
这个应用和前两个最大的区别在于:前两个是「录一句话存下来」,这个是「你一句我一句来回聊」。多轮上下文管理和流式输出是两个真正不同的技术点。
下次临时收到面试通知,不用掏手机了。戴上眼镜,说一句「帮我练十分钟前端面试」就行。
程序源码:https://gitcode.com/qq_52611326/mianshi
更多推荐

所有评论(0)