你好,我是月影。
接下来,我们就要正式进入复杂多轮对话的实现环节,这是 AI 应用里很复杂、很具有挑战的一部分。不过不要担心,跟住我的节奏,你就能掌握其中精髓。
在具体实现之前,我们要先定义一些概念。
-
会话(session):用来标记一轮面试,我们用同一个会话表示一个面试,相同会话的 Agent(智能体)之间才能共享和交换数据。
-
记忆(memory):用来记录之前的内容,根据前一节设计的MoE模型,我们会用一个 Agent 来专门负责记忆。在真正的应用中,记忆是需要存储的,通常采用 redis、数据库等方式进行存储。但为了同学们实操方便,在这里我们将它简化为存储在内存的对象中,不影响对基本原理的理解。
-
时间线(timeline):这是一份配置,用来控制面试流程,它的每个节点包含起、止时间,和在该区间内的整体目标(objective)。在实际代码里,我们还会有一个控制器来操纵时间线,让 AI 有一定的自主权。你可能会想 AI 为什么要控制时间线,这个问题我先抛出来供你思考,我们后面深入讲解的时候再解释。
-
目的表(purpose table):这也是一份配置表,用于思考的 Agent 根据时间线和记忆,参考目的表来筛选出当前轮次的目的,根据目的来生成一组具体的行动方针(actions),对话 Agent 会根据候选人的回答和行动方针,参考目的表来进行下一轮的提问。
下面是一个典型的对话、分析和思考配合过程的示意图。

根据上面的示意图,当 AI 询问“你能描述一下JavaScript的事件循环吗?”之后,等待候选人回答的期间,AI 将制定如下行动方针:
- 如果你觉得用户回答得很完美,那么你从purpose列表中选一个其他方向的问题
- 如果你觉得用户回答得还可以,但你想继续深入,那么你从purpose列表中选一个同方向的问题继续深入
- 如果你觉得……
这里需要强调的是,就是整体 Agent 协同的过程是一个异步过程。仔细看上面的图,当 AI 询问一个问题时,候选人开始回答,同时 AI 开始准备下一步的策略,生成行动方针。注意行动方针并不考虑候选人实际是如何回答的,而是将候选人任何一种回答的可能性都考虑进去,并针对该可能性制定计划(即行动方针)。正是这样的诀窍,大大提升了面试流程的思考和对话并行能力,极大提高了面试流程的实时性。
时间线(Timeline)
前面已经提到过,面试整体要遵循一定的节奏,所以我们可以通过配置时间线,来让AI针对不同的时间点思考不同的面试对策。这一点和真正的面试官是一样的,我们在真实的面试中,面试官也会考虑如何安排整体时间,从而把握整个面试节奏。
在 Server 端,时间线是一份配置文件,我们创建 timeline.config.ts ,内容如下:
/*
定义面试过程的时间线
0~3 分钟 自我介绍
3~10 分钟 项目讨论
10~17 分钟 技术讨论
17~25 分钟 代码和算法讨论
25~30 分钟 非技术问题讨论
30~32 分钟 反问
32 分钟以上 结束
*/
interface TimelineStep {
startTime: number;
endTime: number;
focus: string; // 聚焦的问题,例如项目、技术、代码、算法、非技术问题
prompt: string; // 提示语
}
interface TimelineConfig {
steps: TimelineStep[];
}
export const timelineConfig: TimelineConfig = {
steps: [
{
startTime: 0,
endTime: 3,
focus: '自我介绍',
prompt: `当前在自我介绍阶段,你要求候选人提供简历或者自我介绍,简历和介绍内容包括:
1. 个人信息
2. 教育背景
3. 工作经历
4. 项目经历
5. 擅长的技术
6. 自我评价
你首先根据<memory>的信息,判断候选人是否完成了介绍,如果没有完成,你询问并收集候选人缺失的信息,以便于后续的面试。
`,
}, {
startTime: 3,
endTime: 10,
focus: '项目讨论',
prompt: `当前在项目讨论阶段,你要求候选人讨论他/她在项目中的表现。
你首先根据<memory>的信息,回顾 interviewSummary,针对你感兴趣的项目经历或技术细节,深入询问候选人,务必问出技术深度,以便于判断候选人整体的技术能力。
`,
}, {
startTime: 10,
endTime: 17,
focus: '技术讨论',
prompt: `当前在技术讨论阶段,你要求候选人讨论他/她在技术方面的表现。
如果你之前在和候选人讨论TA过往项目,你可以收尾,然后开始讨论前端工程师的技术栈和基础知识。
首先根据候选人应聘的岗位描述,判断对前端技术的总体要求,并结合<memory>的信息,针对你感兴趣的技术点,深入询问候选人,务必问出技术深度,以便于判断候选人的技术能力。
注意整体节奏遵循先JS(包括Node.js如果岗位要求或候选人简历提到的话)、再CSS/HTML、然后是框架和库,最后是工程化和性能优化。
你通过<memory>中的askedQuestions回溯之前的问题,以把控整体节奏。
`,
}, {
startTime: 17,
endTime: 25,
focus: '代码和算法讨论',
prompt: `当前在代码和算法讨论阶段,你要求候选人讨论他/她在代码和算法方面的表现。
如果你之前在和候选人讨论前端技术栈和基础知识,你可以收尾,然后开始讨论代码问题和算法题。
首先根据候选人应聘的岗位描述,判断对代码和算法的总体要求,并结合<memory>的信息,针对你感兴趣的代码和算法点,深入询问候选人,务必问出技术深度,以便于判断候选人的代码和算法能力。
整体节奏上你先出一道简单的代码题,让候选人说出代码运行结果,然后你再出一道算法题,让候选人说出算法思路并给出答案。
你通过<memory>中的askedQuestions回溯之前的问题,以把控整体节奏。
`
}, {
startTime: 25,
endTime: 30,
focus: '非技术问题讨论',
prompt: `当前在非技术问题讨论阶段,你要求候选人讨论他/她在软素质方面的表现。
如果你之前在和候选人讨论代码和算法,你可以收尾,然后开始讨论非技术问题。
首先根据候选人应聘的岗位描述,判断对软素质的总体要求,并结合<memory>的信息,针对你感兴趣的软素质点,深入询问候选人,务必了解候选人的想法,以便于判断候选人的软素质能力。
整体节奏上你按照价值观、团队沟通协作、项目管理、时间管理、学习能力、沟通表达能力、解决问题能力、自我认知等顺序,逐个询问候选人。
你通过<memory>中的askedQuestions回溯之前的问题,以把控整体节奏。
`
}, {
startTime: 30,
endTime: 32,
focus: '反问',
prompt: `当前在反问阶段,你要求候选人反问面试官,补充判断候选人对岗位是否有兴趣。
候选人提问后,你进行回答,如果候选人说没有问题了,你可以结束面试。
`
}, {
startTime: 32,
endTime: Infinity,
focus: '结束',
prompt: `当前在结束阶段,你礼貌地与候选人沟通,结束面试。`
}
]
};
在这里,我们适当缩减了面试整体时间,将面试节奏把控在大约35分钟之内,这是为了体验和测试方便。在真实产品中,我们可以根据需要调整时间节奏,延长面试的总体时间。
我们先看一下上面的代码,TimelineConfig是一份配置数据,它的steps是一个TimelineStep数组,数组中每一个元素表示当前时间下的面试阶段配置,它由以下属性构成:
-
startTime 当前阶段开始时间,以分钟为单位
-
endTime 当前阶段结束时间,以分钟为单位
-
focus 当前面试阶段聚焦的问题
-
prompt 当前面试阶段的主要策略提示词
在实际使用的时候,我们将根据前端传入的时间参数来控制当前具体的阶段。当然,这是一种简易的办法,根据产品特点的不同,还有其他决定时间的方式。
因为我们的产品主要卖点是模拟面试,所以可以采用简单的方式,不用担心用户自己更改了传入的参数,而如果产品最终的用途是替代真正的面试官完成真实面试,那么考虑到安全性,可能我们就要用服务端的时间来计算具体的Timeline阶段了。
配套地,在前端我们通过实现一个UI组件来展示Timeline。
我们封装一个 Vue 组件。首先创建 src/components/Timeline.vue ,内容如下:
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch, onBeforeUnmount } from 'vue';
const props = defineProps<{
currentTime: number;
totalDuration: number;
started: boolean;
}>();
const emit = defineEmits(['updateTime']);
const timePoints = ref<number[]>([]);
const startTime = ref<number>(0);
const intervalId = ref<number | null>(null);
// 生成时间点数组,从0到总时长,每5分钟一个刻度
for (let i = 0; i <= props.totalDuration; i += 5) {
timePoints.value.push(i);
}
// 监听started状态变化
watch(() => props.started, (newValue) => {
if (newValue && !intervalId.value) {
// 记录开始时间
startTime.value = Date.now();
// 启动计时器,每秒更新一次时间
intervalId.value = window.setInterval(() => {
const elapsedMinutes = (Date.now() - startTime.value) / 60000;
// 确保不超过总时长
const newTime = Math.min(elapsedMinutes, props.totalDuration);
if (newTime !== props.currentTime) {
emit('updateTime', newTime);
}
}, 1000);
}
});
// 组件卸载时清除计时器
onBeforeUnmount(() => {
if (intervalId.value) {
clearInterval(intervalId.value);
}
});
</script>
<template>
<div class="timeline-container">
<div class="timeline">
<div
v-for="time in timePoints"
:key="time"
class="time-point"
:class="{ active: time === currentTime }"
>
<div class="time-marker"></div>
<div class="time-label">{{ time }}分钟</div>
</div>
<div
class="current-time-indicator"
:class="{ active: intervalId != null }"
:style="{ top: `${(currentTime / totalDuration) * 100}%` }"
></div>
</div>
</div>
</template>
<style scoped>
.timeline-container {
width: 160px;
height: 100%;
padding: 20px 0;
display: flex;
justify-content: center;
box-sizing: border-box;
}
.timeline {
position: relative;
height: 100%;
width: 2px;
background-color: #ddd;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.time-point {
position: relative;
cursor: pointer;
display: flex;
align-items: center;
}
.time-marker {
width: 10px;
height: 2px;
background-color: #ddd;
position: absolute;
left: 0;
}
.time-label {
position: absolute;
left: 15px;
font-size: 12px;
color: #666;
white-space: nowrap;
}
.time-point.active .time-marker {
background-color: #646cff;
}
.time-point.active .time-label {
color: #646cff;
font-weight: bold;
}
.current-time-indicator {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: brown;
left: -5px;
transform: translateY(-50%);
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.2);
}
.current-time-indicator.active {
background-color: #646cff;
}
</style>
在上面的代码中,我们创建了一个Timeline的UI,它通过开关控制是否开启计时,具体实现上是用watch方法监听props.started状态。
// 监听started状态变化
watch(() => props.started, (newValue) => {
if (newValue && !intervalId.value) {
// 记录开始时间
startTime.value = Date.now();
// 启动计时器,每秒更新一次时间
intervalId.value = window.setInterval(() => {
const elapsedMinutes = (Date.now() - startTime.value) / 60000;
// 确保不超过总时长
const newTime = Math.min(elapsedMinutes, props.totalDuration);
if (newTime !== props.currentTime) {
emit('updateTime', newTime);
}
}, 1000);
}
});
当计时开启后,Timeline 会启动计时器,即通过 window.setInterval 定时器动态更新时间,并且通过 updateTime 方法将更新的时间发给父级组件,这样父级组件调用AI对话的时候,就可以将具体的时间参数传给 Server 端。
在 UI 方面,通过更新元素的样式来控制小圆点在时间轴中的位置:
<div
class="current-time-indicator"
:class="{ active: intervalId != null }"
:style="{ top: `${(currentTime / totalDuration) * 100}%` }"
></div>
最终的UI效果如下图所示:

图里面的动态效果展示得不太明显,但是你可以看到随着候选人发送消息后,面试正式开始,左侧时间轴的蓝色小圆点就开始缓慢向下移动了。
这个页面的其他部分功能还没有实现,所以我发送消息后,AI 并没有回复,我们先不用管它,在后续章节里,我们再逐步完善,到时候也会详细讲解右侧这一部分的前端逻辑和实现。
记忆(memory)
现在我们先回过头来看一下记忆(memory)模块。
在 AI 智能体和应用的设计中,记忆(memory)是一个非常重要的特性,因为我们很多时候必须让 AI 能够记住之前发生的事情,从而进行下一步的动作。就拿面试官应用来说,如果 AI 没有记忆,那它就很可能问重复的问题,这就和真实的面试过程不符了。
一般来说,记忆又分为长期记忆和短期记忆。通常情况下,短期记忆是通过聊天上下文,由大模型自己管理的;而长期记忆,则是通过记录和整理文本(知识库)、向量数据库等方式实现,对于不同的应用,有不同的要求。
对我们的产品而言,如果不考虑多轮面试,那么最重要的还是短期记忆。不过我们不能依靠默认的聊天上下文,这是因为,面试过程比较长,对话上下文比较复杂,而且面试有比较严格的流程,只依赖单纯的聊天上下文,是达不到效果的。
所以我们用另外一种短期记忆的方式,即结构化短期记忆。
其实结构化短期记忆的实现非常简单,也就是我们不是简单记录聊天上下文,在下一次对话时提交之前的聊天记录,而是用一个负责记忆的智能体,来整理记录每轮聊天之后的内容,将其记录为 JSON 数据,这对应了我们前面说的 MoE 结构中信息分析记录的部分。
在这一节里,我们先不说它的具体逻辑实现,只定义它的数据结构。
我们将 Memory 定义如下,首先创建 lib/service/memory.ts ,内容如下。
export interface InterviewMemory {
sessionId: string;
conversationIndex: number, // 当前记忆的对话轮次,因为是异步更新的,需要用这个来匹配对话
lastConversation: string, // 上一轮对话内容
// 候选人的基本信息和自我介绍
candidateIntroduction: string;
// 面试官已经问过的问题(按顺序记录)
askedQuestions: string[];
// 对候选人各项能力的评价
candidateEvaluation: {
technicalSkills: string; // 技术能力评价
problemSolving: string; // 问题解决能力
communication: string; // 沟通表达能力
codingStyle?: string; // 可选:编码风格或代码质量
overallImpression: string; // 总体印象
};
// 面试摘要:总结整个过程,比如面试重点、表现亮点或不足
interviewSummary: string;
// 特别备注:如迟到、网络问题、态度问题、需进一步确认的信息等
additionalNotes: string[];
lastUpdateTime: number;
}
export function createInterviewMemory(sessionId: string): InterviewMemory {
return {
sessionId,
conversationIndex: 0,
lastConversation: '',
candidateIntroduction: '',
askedQuestions: [],
candidateEvaluation: {
technicalSkills: '',
problemSolving: '',
communication: '',
codingStyle: '',
overallImpression: '',
},
interviewSummary: '',
additionalNotes: [],
lastUpdateTime: Date.now(),
}
}
根据上面的代码,我们记忆的数据格式如下。
-
sessionId:会话 ID,在一场面试中唯一,同样的会话 ID 将同一场面试中的对话、记忆和思考关联起来。
-
conversationIndex:对话轮次,多轮对话中我们会异步更新记忆,所以当前记忆已经更新到第几轮对话了,需要记录下来,这样我们才能匹配记忆的“版本”,具体内容在后续章节中会详细说明。
-
lastCoversation:上一轮对话内容,我们记录下较少的历史聊天上下文,因为有了 memory 不需要记录太多轮。
-
candidateInstruction:候选人介绍,在自我介绍和项目讨论阶段主要更新这部分记忆。
-
askedQuestions:已经问过的问题,这个列表很重要,AI 要记住问过的问题,以免重复询问。
-
candidateEvaluation:候选人评价,通过几个维度对候选人做出评价,在每一轮面试中分析智能体会根据评价制定后续提问策略,面试结束后,负责给出评价的智能体也需要根据这部分内容给出最终的评价。
-
interviewSummary:面试摘要,每轮面试对话后都会更新摘要,记录面试过程里比较重要的信息。
-
additionalNotes;特别备注,面试过程中需要额外留意的信息,面试官的自我提醒。
-
lastUpdateTime:记忆最后更新时间。
以上就是整体的记忆表,在每一轮对话后,负责记录的智能体就会将需要更新的信息更新到该表中,智能体提问前,也会通过将记忆信息放入系统提示词中,控制下一轮的询问。
具体详细的流程我们后续的章节中会一一展开细说。
要点总结
这一节里,我们具体了解了时间线(Timeline)和记忆(Memory)的概念定义和数据结构,它们是后续实现核心工作流的重要对象。
另外,我们还实现了时间线的前端UI组件。下一节课,我们还会继续探索,看看如何用时间线搭配记忆,来实现节奏合理的高质量面试对话。
课后练习
这节课虽然没有涉及UI核心功能,但对于前端工程师来说,交互体验也特别重要。
时间线UI组件还有些细节值得优化,你可以考虑在它之上加入一些小功能,比如鼠标移动到时间线小圆点上时,具体显示对应的时间,以及对时间线上某个时间点设置一个倒计时,这样就可以在面试中候选人需要较长时间的思考时提醒对方(比如写代码环节里,设置一个10分钟的提醒)。
你可以动手试试看,将你的改进分享到评论区。
精选留言