【自学AI】09 让 AI 记住你说过的话——多轮对话与会话管理实战

5392 字
27 分钟
【自学AI】09 让 AI 记住你说过的话——多轮对话与会话管理实战

上一篇,我们完成了简历分析功能:用户上传简历,AI 返回一份结构化的分析报告。 功能跑通了,感觉不错。

但有个问题,我当时没有提—— AI 其实什么都不记得。


开头:AI 的”失忆症”#

我们来还原一个真实的场景。

用户上传了简历,AI 分析完了,返回了报告。 用户看了一眼,觉得有些地方不明白,于是继续问:

用户:你说的"改进空间"是什么意思?能详细说一下吗?
AI:抱歉,我没有看到之前的对话内容。
请问您能把简历重新发一遍吗?

这就是没有对话历史的 AI。

它看到的只是”你说的改进空间是什么意思”这一句话。 它不知道之前分析了什么,不知道”改进空间”指的是哪些内容,只能让用户重新发一遍。

这就像你和一个朋友聊了半小时, 转头问他”你刚才说的那个问题怎么解决?” 他说:“不好意思,我们聊过吗?”

这样的体验,用一个词形容:割裂

用户明明刚刚和 AI 聊过,转头就被当成陌生人。 这在真实的面试系统里是不可接受的—— 用户需要和 AI 进行多轮对话,简历分析是第一步, 后面还有追问、优化建议、模拟面试…… 每一步都需要 AI 记住之前说过什么。

这一篇,我们就来解决这个问题: 怎样管理对话历史,让 AI 记住之前的对话。


一、为什么需要对话历史?#

问题场景#

先把问题说清楚。假设用户和 AI 进行了这样的对话:

用户:请分析一下我的简历。
我叫昇哥,工作 8 年,技术栈是 Vue全家桶熟练掌握、React全家桶熟悉 ... JS全栈开发
AI:根据你的简历,我发现了几个亮点:
1. 工作年限符合要求
2. 技术栈覆盖全面
但也有一些改进空间:缺少大型项目经验,架构设计经历较少。
用户:你说的改进空间,能详细说一下吗?

现在,AI 要回答最后这个问题。

如果 AI 没有对话历史,它看到的只是:

用户:你说的改进空间,能详细说一下吗?

它不知道”改进空间”是什么,不知道之前分析了什么简历, 只能回答”请问您指的是哪方面的改进空间?“——完全没有意义。

解决思路#

解决办法其实很简单:每次调用 AI 时,把之前的对话历史一起发过去。

第一次调用:
AI 看到:[用户的简历分析请求]
AI 返回:[分析报告]
第二次调用:
AI 看到:[用户的简历分析请求] + [AI 的分析报告] + [用户的追问]
AI 返回:[基于完整上下文的详细回答]

AI 之所以能理解”你说的改进空间”,是因为它看到了完整的对话过程。

问题的本质只有一句话: AI 没有天然的记忆, 所谓”记住”,是我们帮它把历史背在身上,每次一起带过去。

核心思想只有一句话:保存对话历史,每次调用 AI 时一起发送。


二、消息的数据结构#

在动手写代码之前,先把数据结构搞清楚。

Message 接口#

在 LangChain 和大多数 AI 框架里,对话历史由一条一条的”消息”组成。 每条消息有两个字段:谁说的,和说了什么

interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}

role 有三个取值:

role含义例子
system系统消息,给 AI 的角色定义”你是一个资深的 Java 面试官,有 15 年经验”
user用户说的话”请分析一下我的简历”
assistantAI 的回答”根据你的简历,我发现了几个亮点…”

为什么恰好是这三个角色? 因为一段对话里只有三种声音: 规则制定者(system)、提问者(user)、回答者(assistant)。 三者缺一不可——没有 system,AI 不知道自己是谁;没有历史的 user/assistant,AI 不知道聊过什么。

对话历史是一个数组#

一次完整的对话,就是把所有消息按顺序放在数组里:

const conversationHistory: Message[] = [
{
role: 'system',
content: '你是一个资深的简历分析官,有 15 年的经验...',
},
{
role: 'user',
content: '请分析一下我的简历。我叫昇哥,工作 8 年...',
},
{
role: 'assistant',
content: '根据你的简历,我发现了几个亮点:...',
},
{
role: 'user',
content: '你说的改进空间,能详细说一下吗?',
},
// 下一次调用 AI 时,把上面整个数组都发过去
];

⚠️ 关键点:不是只发最新的那条消息,而是整个数组都要发给 AI。 用户的第二个问题只有一句话,但 AI 能理解它,是因为看到了前面所有的上下文。


三、代码实现#

明确了思路和数据结构,现在一步一步写代码。

Step 1:定义类型#

创建 src/ai/interfaces/message.interface.ts

src/ai/interfaces/message.interface.ts
export interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface SessionData {
sessionId: string;
userId: string;
position: string;
messages: Message[];
createdAt: Date;
lastActivityAt: Date;
}

SessionData 是一个完整的会话对象,包含:

  • sessionId:会话唯一标识,用于区分不同用户的不同对话
  • messages:这次会话的所有消息历史
  • lastActivityAt:最后活跃时间,用于清理过期会话

Step 2:安装依赖#

我们需要 uuid 来生成唯一的会话 ID:

Terminal window
pnpm add uuid@13.0.0

Step 3:创建 SessionManager#

这是本篇最核心的服务。创建 src/ai/services/session.manager.ts

src/ai/services/session.manager.ts
import { Injectable, Logger } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { Message, SessionData } from '../interfaces/message.interface';
@Injectable()
export class SessionManager {
private readonly logger = new Logger(SessionManager.name);
// 用 Map 存储所有会话,key 是 sessionId
// 注意:这是内存存储,服务器重启会丢失
// 生产环境应该持久化到数据库(下一篇会讲)
private sessions: Map<string, SessionData> = new Map();
/**
* 创建新会话
*
* @param userId 用户 ID
* @param position 面试职位
* @param systemMessage AI 的角色定义(System Message)
* @returns 新会话的 sessionId
*/
createSession(
userId: string,
position: string,
systemMessage: string,
): string {
const sessionId = uuidv4();
const session: SessionData = {
sessionId,
userId,
position,
messages: [
{
role: 'system',
content: systemMessage,
},
],
createdAt: new Date(),
lastActivityAt: new Date(),
};
this.sessions.set(sessionId, session);
this.logger.log(`创建会话: ${sessionId},用户: ${userId}`);
return sessionId;
}
/**
* 向会话中添加一条消息
*
* @param sessionId 会话 ID
* @param role 消息角色
* @param content 消息内容
*/
addMessage(
sessionId: string,
role: 'user' | 'assistant',
content: string,
): void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`会话不存在: ${sessionId}`);
}
session.messages.push({ role, content });
session.lastActivityAt = new Date(); // 更新最后活跃时间
}
/**
* 获取会话的完整对话历史
*
* @param sessionId 会话 ID
* @returns Message 数组
*/
getHistory(sessionId: string): Message[] {
const session = this.sessions.get(sessionId);
return session?.messages || [];
}
/**
* 获取最近的 N 条消息(用于控制 Token 消耗)
*
* 为什么需要这个方法?
* 对话越长,每次发给 AI 的 token 就越多,成本越高。
* 所以我们只保留最近的几条消息,旧的消息可以丢弃。
*
* 但有一个例外:System Message(第一条)必须始终保留。
* 因为它定义了 AI 的角色,丢掉它 AI 就不知道自己是谁了。
*
* @param sessionId 会话 ID
* @param count 保留最近几条消息(不含 System Message)
*/
getRecentMessages(sessionId: string, count: number = 10): Message[] {
const history = this.getHistory(sessionId);
if (history.length === 0) {
return [];
}
// System Message 是第一条,必须保留
const systemMessage = history[0];
// 取最近 count 条消息
const recentMessages = history.slice(-count);
// 如果最近的消息里已经包含了 System Message,直接返回
if (recentMessages[0]?.role === 'system') {
return recentMessages;
}
// 否则,在最前面加上 System Message
return [systemMessage, ...recentMessages];
}
/**
* 结束会话,从内存中删除
*
* @param sessionId 会话 ID
*/
endSession(sessionId: string): void {
if (this.sessions.has(sessionId)) {
this.sessions.delete(sessionId);
this.logger.log(`结束会话: ${sessionId}`);
}
}
/**
* 清理超过 1 小时未活动的过期会话
*
* 在生产环境中,应该用 @Cron 装饰器定期调用这个方法,
* 防止内存无限增长。
*/
cleanupExpiredSessions(): void {
const now = new Date();
const expirationTime = 60 * 60 * 1000; // 1 小时
for (const [sessionId, session] of this.sessions.entries()) {
if (now.getTime() - session.lastActivityAt.getTime() > expirationTime) {
this.sessions.delete(sessionId);
this.logger.warn(`清理过期会话: ${sessionId}`);
}
}
}
}

这里有几个细节值得注意:

getRecentMessages 里为什么要特殊处理 System Message?

System Message 是 AI 的”人设”——“你是一个资深的 Java 面试官”。 如果这条消息被截掉了,AI 就不知道自己的角色,回答会变得很奇怪。 所以不管截取多少条历史消息,System Message 必须始终在第一位。

你可以把 System Message 理解成 AI 的”出厂设置”。 历史消息可以截断,出厂设置不能丢。

为什么用 Map 而不是数组?

Map 的查找是 O(1),用 sessionId 直接取到会话,不需要遍历。 对话系统里每次收到消息都要查找会话,性能很重要。

Step 4:更新 AIModule#

SessionManager 加入 AI 模块,让其他模块可以注入它:

src/ai/ai.module.ts
import { Module } from '@nestjs/common';
import { AIModelFactory } from './services/ai-model.factory';
import { SessionManager } from './services/session.manager';
@Module({
providers: [AIModelFactory, SessionManager],
exports: [AIModelFactory, SessionManager], // 两个都导出
})
export class AIModule {}

Step 5:提取 Prompt 定义#

把所有 Prompt 集中到一个文件里管理。创建(或更新)src/interview/prompts/resume-analysis.prompts.ts

src/interview/prompts/resume-analysis.prompts.ts
/**
* 简历分析的 System Message
* 定义 AI 的角色,根据职位动态生成
*/
export const RESUME_ANALYSIS_SYSTEM_MESSAGE = (position: string): string => {
return `你是一个资深的 ${position} 面试官,有 15 年的招聘经验。你能快速从简历中识别候选人的核心能力。`;
};
/**
* 简历分析的主 Prompt
* 用于第一次分析简历,返回结构化的 JSON 报告
*/
export const RESUME_ANALYSIS_PROMPT = `
你已经拥有以下信息,要求你进行分析:
## 简历内容
{resume_content}
## 岗位要求
{job_description}
## 分析要求
1. 提取候选人的:
- 工作年限
- 主要技能
- 最近工作经历
- 教育背景
2. 评估匹配度(0-100)
3. 识别优势和不足
## 输出格式(JSON)
{{
"years_of_experience": 数字,
"skills": ["技能1", "技能2"],
"recent_position": "最近的职位",
"education": "学历",
"match_score": 数字(0-100),
"strengths": ["优势1", "优势2"],
"gaps": ["缺陷1", "缺陷2"],
"summary": "1-2 句总结"
}}
`;
/**
* 多轮对话继续的 Prompt
* 用于后续追问,基于已有的对话历史回答
*/
export const CONVERSATION_CONTINUATION_PROMPT = `基于以下对话历史,请回答最后一个问题。
对话历史:
{history}
请给出清晰、有逻辑的回答。`;

Step 6:创建 ResumeAnalysisService#

把简历分析的 Chain 逻辑单独提取成一个服务。 创建 src/interview/services/resume-analysis.service.ts

src/interview/services/resume-analysis.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PromptTemplate } from '@langchain/core/prompts';
import { JsonOutputParser } from '@langchain/core/output_parsers';
import { AIModelFactory } from '../../ai/services/ai-model.factory';
import { RESUME_ANALYSIS_PROMPT } from '../prompts/resume-analysis.prompts';
@Injectable()
export class ResumeAnalysisService {
private readonly logger = new Logger(ResumeAnalysisService.name);
constructor(private aiModelFactory: AIModelFactory) {}
async analyze(resumeContent: string, jobDescription: string): Promise<any> {
const prompt = PromptTemplate.fromTemplate(RESUME_ANALYSIS_PROMPT);
const model = this.aiModelFactory.createDefaultModel();
const parser = new JsonOutputParser();
const chain = prompt.pipe(model).pipe(parser);
try {
this.logger.log('开始分析简历...');
const result = await chain.invoke({
resume_content: resumeContent,
job_description: jobDescription,
});
this.logger.log('简历分析完成');
return result;
} catch (error) {
this.logger.error('简历分析失败:', error);
throw error;
}
}
}

Step 7:创建 ConversationContinuationService#

为多轮对话创建专门的服务。 创建 src/interview/services/conversation-continuation.service.ts

src/interview/services/conversation-continuation.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PromptTemplate } from '@langchain/core/prompts';
import { AIModelFactory } from '../../ai/services/ai-model.factory';
import { Message } from '../../ai/interfaces/message.interface';
import { CONVERSATION_CONTINUATION_PROMPT } from '../prompts/resume-analysis.prompts';
@Injectable()
export class ConversationContinuationService {
private readonly logger = new Logger(ConversationContinuationService.name);
constructor(private aiModelFactory: AIModelFactory) {}
/**
* 基于对话历史继续对话
*
* @param history 当前会话的消息历史(Message 数组)
* @returns AI 的回答文本
*/
async continue(history: Message[]): Promise<string> {
const prompt = PromptTemplate.fromTemplate(CONVERSATION_CONTINUATION_PROMPT);
const model = this.aiModelFactory.createDefaultModel();
// 注意:这里不需要 JsonOutputParser
// 多轮对话的回答是自然语言,不是结构化 JSON
const chain = prompt.pipe(model);
try {
this.logger.log(`继续对话,历史消息数: ${history.length}`);
const response = await chain.invoke({
// 把 Message 数组转成文本格式,发给 AI
history: history.map((m) => `${m.role}: ${m.content}`).join('\n\n'),
});
const aiResponse = response.content as string;
this.logger.log('对话继续完成');
return aiResponse;
} catch (error) {
this.logger.error('继续对话失败:', error);
throw error;
}
}
}

Step 8:更新 InterviewService#

现在 InterviewService 变得非常干净—— 它只负责会话管理和流程编排,不关心具体的 AI 调用细节:

src/interview/services/interview.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { SessionManager } from '../../ai/services/session.manager';
import { AIModelFactory } from '../../ai/services/ai-model.factory';
import { ResumeAnalysisService } from './resume-analysis.service';
import { ConversationContinuationService } from './conversation-continuation.service';
import { RESUME_ANALYSIS_SYSTEM_MESSAGE } from '../prompts/resume-analysis.prompts';
@Injectable()
export class InterviewService {
private readonly logger = new Logger(InterviewService.name);
constructor(
private sessionManager: SessionManager,
private aiModelFactory: AIModelFactory,
private resumeAnalysisService: ResumeAnalysisService,
private conversationContinuationService: ConversationContinuationService,
) {}
/**
* 分析简历(第一轮对话)
* 创建新会话,调用 AI 分析,保存历史
*/
async analyzeResume(
userId: string,
position: string,
resumeContent: string,
jobDescription: string,
) {
try {
// 第一步:创建新会话,写入 System Message
const systemMessage = RESUME_ANALYSIS_SYSTEM_MESSAGE(position);
const sessionId = this.sessionManager.createSession(
userId,
position,
systemMessage,
);
this.logger.log(`创建会话: ${sessionId}`);
// 第二步:调用简历分析服务
const result = await this.resumeAnalysisService.analyze(
resumeContent,
jobDescription,
);
// 第三步:把这轮对话保存到会话历史
this.sessionManager.addMessage(
sessionId,
'user',
`简历内容:${resumeContent}`,
);
this.sessionManager.addMessage(
sessionId,
'assistant',
JSON.stringify(result),
);
this.logger.log(`简历分析完成,sessionId: ${sessionId}`);
return { sessionId, analysis: result };
} catch (error) {
this.logger.error(`分析简历失败: ${error}`);
throw error;
}
}
/**
* 继续对话(多轮)
* 基于已有会话,追加新消息,调用 AI 回答
*/
async continueConversation(
sessionId: string,
userQuestion: string,
): Promise<string> {
try {
// 第一步:把用户的新问题加入历史
this.sessionManager.addMessage(sessionId, 'user', userQuestion);
// 第二步:取最近 10 条消息(含 System Message)
const history = this.sessionManager.getRecentMessages(sessionId, 10);
this.logger.log(
`继续对话,sessionId: ${sessionId},历史消息数: ${history.length}`,
);
// 第三步:调用对话继续服务
const aiResponse =
await this.conversationContinuationService.continue(history);
// 第四步:把 AI 的回答也保存到历史
this.sessionManager.addMessage(sessionId, 'assistant', aiResponse);
this.logger.log(`对话继续完成,sessionId: ${sessionId}`);
return aiResponse;
} catch (error) {
this.logger.error(`继续对话失败: ${error}`);
throw error;
}
}
}

Step 9:更新 Module 和 Controller#

更新 interview.module.ts,注册新增的服务:

src/interview/interview.module.ts
import { Module } from '@nestjs/common';
import { AIModule } from '../ai/ai.module';
import { InterviewController } from './interview.controller';
import { InterviewService } from './services/interview.service';
import { ResumeAnalysisService } from './services/resume-analysis.service';
import { ConversationContinuationService } from './services/conversation-continuation.service';
@Module({
imports: [AIModule],
providers: [
InterviewService,
ResumeAnalysisService,
ConversationContinuationService,
],
controllers: [InterviewController],
})
export class InterviewModule {}

更新 interview.controller.ts,添加两个接口:

src/interview/interview.controller.ts
// 接口 1:分析简历(需要登录)
@Post('/analyze-resume')
@UseGuards(JwtAuthGuard)
async analyzeResume(
@Body() body: { position: string; resume: string; jobDescription: string },
@Request() req: any,
) {
const result = await this.interviewService.analyzeResume(
req.user.userId,
body.position,
body.resume,
body.jobDescription,
);
return { code: 200, data: result };
}
// 接口 2:继续对话(多轮)
@Post('/continue-conversation')
@UseGuards(JwtAuthGuard)
async continueConversation(
@Body() body: { sessionId: string; question: string },
) {
const result = await this.interviewService.continueConversation(
body.sessionId,
body.question,
);
return { code: 200, data: { response: result } };
}

四、测试接口#

代码写完了,来验证一下 AI 是否真的记住了上下文。

第一步:登录,获取 Token#

Terminal window
curl -X POST http://localhost:3000/user/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "123456"
}'

从返回结果里取出 token 字段,后面要用。

第二步:分析简历,创建会话#

Terminal window
curl -X POST http://localhost:3000/interview/analyze-resume \
-H "Content-Type: application/json" \
-H "Authorization: Bearer 替换成你的token" \
-d '{
"position": "Java 后端开发工程师",
"resume": "姓名:昇哥\n工作年限:8年\n技术栈:熟练掌握Vue全家桶,熟悉React全家桶,\n最近工作:高级前端开发\n主要项目:电商系统、报价系统\n教育背景:计算机本科,普通二本",
"jobDescription": "职位:AI Agent开发\n工作年限:3-5年\n技能要求:TypeScript, React, Vue, MySQL, Redis\n岗位职责:设计高并发,高性能,交互流畅的系统 ..."
}'

返回结果里有一个 sessionId把它记下来,下一步要用:

{
"code": 200,
"data": {
"sessionId": "3f4d27a6-c7eb-40c3-995f-220c2543fed1",
"analysis": {
"years_of_experience": 5,
"skills": ["Java", "Spring Boot", "MySQL", "Redis"],
"match_score": 85,
"strengths": ["技术栈高度匹配", "有大型项目经验"],
"gaps": ["缺少消息队列经验", "未提及架构设计经历"],
"summary": "候选人技术栈与岗位匹配度高,建议进入技术面试。"
}
}
}

第三步:继续追问,验证 AI 的记忆#

Terminal window
curl -X POST http://localhost:3000/interview/continue-conversation \
-H "Content-Type: application/json" \
-H "Authorization: Bearer 替换成你的token" \
-d '{
"sessionId": "3f4d27a6-c7eb-40c3-995f-220c2543fed1",
"question": "请问我的名字是什么?我有几年工作经验?"
}'

如果一切正常,AI 会回答:

{
"code": 200,
"data": {
"response": "根据您之前提供的简历,您的名字是昇哥,拥有 8 年的工作经验,主要技术栈包括 Vue全家桶,React全家桶 。。。 balabala 。"
}
}

API 调用成功
API 调用成功

AI 记住了。你可以继续追问,它会一直记得这次会话里发生的所有对话。


五、架构优势(选读)#

你可能会问:为什么要拆成这么多层?直接在 InterviewService 里写 Prompt 和 Chain 不行吗?

可以,但会有问题。

代码能跑,不代表代码好。 好的代码,是三个月后的你看到,不会骂自己的代码。

这里列出 5 个好处,你感受一下。

优势 1:关注点分离#

现在每一层只做一件事:

Prompt 定义(prompts/)
AI 调用逻辑(ResumeAnalysisService / ConversationContinuationService)
会话管理 + 流程编排(InterviewService)
HTTP 接口(InterviewController)

改 Prompt 只动 prompts/ 文件,改 AI 调用逻辑只动对应的 Service,改接口只动 Controller。互不干扰。

优势 2:易于扩展#

将来要加新功能(比如编程题分析),只需要:

// 1. 新建 coding-question.prompts.ts
// 2. 新建 CodingQuestionService
// 3. 在 InterviewService 里调用它
async analyzeCodingQuestion(code: string, language: string) {
const result = await this.codingQuestionService.analyze(code, language);
// 保存到会话历史...
return result;
}

现有的代码一行不用改。

优势 3:易于测试#

每一层都可以单独测试,用 mock 替换依赖:

// 测试 InterviewService 时,不需要真实调用 AI
jest.spyOn(resumeAnalysisService, 'analyze').mockResolvedValue({
years_of_experience: 5,
match_score: 85,
// ...
});
const result = await interviewService.analyzeResume(...);
expect(result.sessionId).toBeDefined(); // 只测会话逻辑,不测 AI

优势 4:易于修改 Prompt#

想让 AI 多输出一个”缺少的技能”字段?只改 prompts 文件:

// 只改这一个文件,其他代码完全不动
export const RESUME_ANALYSIS_PROMPT = `
...
## 输出格式(JSON)
{{
...
"missing_skills": ["技能1", "技能2"], // 新增这一行
...
}}
`;

优势 5:易于切换模型#

从 DeepSeek 换成 OpenAI?只改 AIModelFactory 一个文件:

// 改这里,所有 Service 自动用上新模型
createDefaultModel(): ChatOpenAI {
return new ChatOpenAI({
apiKey: this.configService.get<string>('OPENAI_API_KEY'),
model: 'gpt-4o',
});
}

InterviewServiceResumeAnalysisServiceConversationContinuationService 的代码全部不用动。

这 5 个优势,说到底是同一件事: 改动不扩散。 改一个地方,只影响一个地方。 这是好架构最朴素的标准。


六、常见问题#

Q1:什么时候应该结束会话?#

会话不主动结束,就是内存泄漏的开始。

会话应该在以下情况下结束:

  • 用户主动点击”结束面试”
  • 超过 1 小时未活动(cleanupExpiredSessions 自动处理)
  • 服务器重启(内存清空)
// 主动结束
this.sessionManager.endSession(sessionId);
// 定期自动清理(配合 @Cron 装饰器使用)
@Cron('0 * * * *') // 每小时执行一次
handleCleanup() {
this.sessionManager.cleanupExpiredSessions();
}

Q2:一个用户可以同时有多个会话吗?#

可以。每个 sessionId 是独立的宇宙,互不干扰。

比如用户同时进行”简历分析”和”编程题”两个面试:

// 两个会话完全独立
const sessionId1 = sessionManager.createSession(userId, 'Java 开发', msg1);
const sessionId2 = sessionManager.createSession(userId, 'Python 开发', msg2);
sessionManager.addMessage(sessionId1, 'user', '...'); // 只影响会话 1
sessionManager.addMessage(sessionId2, 'user', '...'); // 只影响会话 2

Q3:对话历史应该保存多久?#

这是一个安全性和性能的权衡问题,没有标准答案,只有适合你场景的答案。

目前的实现是内存存储,服务器重启就会丢失。 生产环境应该持久化到数据库。保存策略有三种:

策略方式优点缺点
实时保存每条消息立即写库最安全,不丢数据数据库写入频繁,性能差
定时保存每 N 条消息写一次折中方案崩溃时可能丢失最近几条
批量保存会话结束时统一写库性能最好崩溃时整个会话丢失

实际项目中,通常用定时保存——每 5-10 条消息写一次库,在安全性和性能之间取得平衡。

Q4:网络中断后如何恢复会话?#

用户不应该因为断网就丢失整个面试进度。

从数据库恢复到内存即可:

async reconnectSession(sessionId: string): Promise<SessionData> {
// 先查内存
let session = this.sessions.get(sessionId);
if (!session) {
// 内存没有,从数据库恢复
session = await this.conversationRepository.findOne({ sessionId });
if (!session) {
throw new Error(`会话不存在: ${sessionId}`);
}
// 恢复到内存,后续操作走内存
this.sessions.set(sessionId, session);
}
return session;
}

用户无论何时重连,都能接着之前的对话继续。


结尾#

这篇文章我们学了四件事:

  • 为什么需要对话历史:AI 没有天然的记忆,需要我们把历史消息一起发给它
  • 消息的数据结构role + content,三种角色,整个数组都要发送
  • SessionManager 的实现:创建会话、追加消息、获取历史、清理过期
  • 分层架构的价值:每一层只做一件事,改 Prompt 不影响业务,换模型不影响接口

现在我们的系统可以进行真正的多轮对话了。


但细心的你可能已经发现了一个新问题:

对话越来越长,Token 消耗越来越多。

第 1 轮对话,发给 AI 的是 1 条消息。 第 10 轮对话,发给 AI 的是 10 条消息。 第 20 轮对话,发给 AI 的是 20 条消息。

每一轮的成本,都是前一轮的累加。 一次完整的面试对话下来,Token 消耗可能是第 1 轮的 10-20 倍

这不只是成本问题—— 每个模型都有 Token 上限,超过了直接报错。 而且对话越长,AI 越容易”忘记”开头说的事—— 记得太多,反而记不住重要的。

下一篇,我们来解决这个问题: 如何在不丢失关键信息的前提下,控制对话历史的长度。


昇哥 · 2026年3月 全栈开发 × AI 学习途中,把踩过的坑写下来

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【自学AI】09 让 AI 记住你说过的话——多轮对话与会话管理实战
https://blog.fridolph.top/posts/2026-02-16__ai-dev/
作者
Fridolph
发布于
2026-02-16
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录