AI智能体对话平台开发实战(三):对话之心——多轮对话上下文管理
本文聚焦对话系统的核心设计,从数据结构选型入手,对比了“按条存储”与“按问答对存储”的优劣,最终基于“文件夹即智能体”理念确定了chat.json的存储格式。文章重点阐述了如何构建符合OpenAI格式的messages数组,将智能体人格prompt与历史对话记录融合为完整上下文,并引入上下文窗口管理策略——截取最近N轮对话以平衡记忆效果与token消耗。正式接入模力方舟API,处理API密钥动态读
📖 引言
在上一篇中,我们搭建了项目的地基,实现了智能体的动态发现。现在,当你访问 http://localhost:3002,已经能看到一排智能体卡片整齐排列。点击卡片,它会弹出一个提示框——“你选择了凌云拓界”。
然后呢?然后就没有然后了。
这显然不够。一个聊天平台的核心是什么?当然是聊天。用户点击智能体,应该进入对话界面,发送消息,接收回复,而且智能体应该记得之前说过什么——这才是真正的“对话”。
这一篇,我们将为MultiMind注入“对话之心”。你将学会如何设计对话数据结构、如何存储和读取聊天记录、如何维护多轮对话的上下文。更重要的是,我们将正式接入模力方舟API,让智能体真正具备对话能力。

🎯 本章目标
学完本篇,你将能够:
- 设计合理的对话数据结构
- 实现聊天记录的持久化存储
- 创建聊天界面并与后端交互
- 理解上下文窗口的管理策略
- 对接模力方舟API并处理流式响应
- 思考并发写入、数据一致性等进阶问题
💭 第一部分:对话系统的设计思考
从现实生活找灵感
想象一下你和朋友小明的对话:
你:小明,今天天气怎么样?
小明:挺好的,阳光明媚。
你:那下午要不要去打球?
小明:可以啊,叫上小李一起。
这段对话里,有几层信息?
- 消息本身:每个人说的话
- 对话顺序:先问天气,再约打球,小明记得刚聊过天气
- 身份标识:谁在和谁说话
如果让计算机来存储这段对话,应该怎么设计?
数据结构设计
最常见的做法有两种:
方案A:按条存储
{
"id": 1,
"role": "user",
"content": "小明,今天天气怎么样?",
"timestamp": "2024-01-01T10:00:00Z"
}
方案B:按问答对存储
{
"id": 1,
"user_message": "小明,今天天气怎么样?",
"agent_response": "挺好的,阳光明媚。",
"timestamp": "2024-01-01T10:00:00Z"
}
思考:两种方案各有什么优缺点?
方案A更灵活,可以单独插入任意消息(比如系统提示、错误消息),但需要额外维护消息的配对关系。方案B更直观,查询时直接拿到一组问答,但无法处理“AI正在思考中”这种中间状态。
考虑到我们后续需要把聊天记录作为上下文传给AI API(OpenAI格式要求是交替的user/assistant角色),方案A其实更贴合。但方案B的实现更简单,适合初期快速验证。
我的选择:初期用方案B快速实现,因为简单直观。等后续需要更复杂的功能(比如流式输出、消息编辑)时,再重构为方案A。
文件存储设计
按照“文件夹即智能体”的理念,每个智能体的聊天记录应该存放在自己的文件夹里:
助手小M/
├── 助手小M.png
├── 助手小M.txt
└── chat.json # 聊天记录
思考:为什么叫chat.json而不是助手小M_chat.json?因为文件夹已经标识了智能体身份,里面的文件就不需要重复命名了。这是“上下文”的体现——文件名在文件夹的上下文中,自然就知道属于谁。
📦 第二部分:模力方舟API接入准备
API选型思考
在AI API的选择上,我们面临几个问题:
- 选哪个厂商? 模力方舟、OpenAI、 Anthropic、百度文心、阿里通义…
- 选什么模型? DeepSeek、GPT、Claude、GLM…
- 怎么管理API密钥? 写死在代码里?配置文件?环境变量?
思考过程:
- 模力方舟提供了OpenAI兼容接口,这意味着我们可以用OpenAI的SDK来调用,代码通用性强。
- DeepSeek-V3.2是当前性价比很高的模型,上下文长128K,适合作为默认模型。
- API密钥绝对不能写死在代码里!否则上传GitHub就泄露了。配置文件是基本要求,如果能用环境变量更好。

最终决定:
- 使用模力方舟API(OpenAI兼容接口)
- 默认模型选用DeepSeek-V3.2
- API密钥通过配置文件管理,后续可以扩展环境变量
配置文件设计
创建api-config.json:
{
"apiKey": "你的模力方舟API密钥",
"model": "DeepSeek-V3.2-Exp"
}
思考:为什么用JSON而不用.env?JSON对前端开发者更友好,解析方便,而且可以存放更多结构化配置。.env适合存放简单的键值对,各有优劣。
配置文件读写函数
const fs = require('fs');
const path = require('path');
function readApiConfig() {
const configPath = path.join(__dirname, 'api-config.json');
if (!fs.existsSync(configPath)) {
// 配置文件不存在,返回默认值
return { apiKey: '', model: 'DeepSeek-V3.2-Exp' };
}
try {
const data = fs.readFileSync(configPath, 'utf8');
return JSON.parse(data);
} catch (error) {
console.error('读取API配置失败:', error);
return { apiKey: '', model: 'DeepSeek-V3.2-Exp' };
}
}
function saveApiConfig(config) {
const configPath = path.join(__dirname, 'api-config.json');
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return true;
} catch (error) {
console.error('保存API配置失败:', error);
return false;
}
}
设计思考:
- 文件不存在时返回默认值,而不是报错。这样即使用户没配置,系统也能启动(虽然对话功能不能用)。
- 读写操作都用try-catch包裹,防止因文件权限等问题导致整个进程崩溃。
- 保存时用
null, 2格式化,生成可读性好的JSON,方便用户手动修改。
🤖 第三部分:OpenAI客户端初始化
安装依赖
npm install openai
openai是OpenAI官方提供的Node.js SDK,支持OpenAI格式的API调用。模力方舟兼容这个格式,所以我们可以直接用。
初始化客户端
const OpenAI = require('openai');
// 读取配置
const apiConfig = readApiConfig();
// 创建OpenAI客户端
const client = new OpenAI({
baseURL: "https://api.moark.com/v1", // 模力方舟的接口地址
apiKey: apiConfig.apiKey,
});
关键点:baseURL必须指定为模力方舟的地址,否则默认会走OpenAI官方地址。
动态更新配置的问题
上面的代码在服务器启动时读取一次配置。但如果用户后续通过前端修改了API密钥,内存中的client不会自动更新。
思考:怎么解决?
方案1:每次请求时重新创建client(简单但性能略差)
方案2:监听配置变化,重新初始化(复杂但性能好)
方案3:把client封装成函数,每次调用时检查配置是否变化
初期我们选方案1,简单可靠:
function getClient() {
const config = readApiConfig(); // 每次都重新读取
return new OpenAI({
baseURL: "https://api.moark.com/v1",
apiKey: config.apiKey,
});
}
🔄 第四部分:构建对话上下文
AI API的消息格式
OpenAI格式的聊天补全接口,要求传入一个消息数组,每个消息包含role和content:
[
{ role: "system", content: "你是助手小M..." },
{ role: "user", content: "你好" },
{ role: "assistant", content: "你好,我是助手小M" },
{ role: "user", content: "今天天气怎么样" }
]
其中:
system:系统提示,设定AI的角色和行为user:用户消息assistant:AI的回复
从聊天记录构建消息数组
我们有chat.json存储的是问答对格式:
[
{
"user_message": "你好",
"agent_response": "你好,我是助手小M"
},
{
"user_message": "今天天气怎么样",
"agent_response": "抱歉,我无法获取天气"
}
]
需要转换成API要求的格式:
const messages = [
{ role: "system", content: agentPrompt } // 从txt文件读取
];
// 把历史记录转换成user/assistant交替
chatHistory.forEach(item => {
messages.push({ role: "user", content: item.user_message });
messages.push({ role: "assistant", content: item.agent_response });
});
// 添加当前用户消息
messages.push({ role: "user", content: currentMessage });
思考:如果chat.json存储的就是API格式,是不是就省去了转换的麻烦?确实如此。但存储格式和传输格式分离是常见的设计——存储考虑的是易读易改,传输考虑的是接口要求。两者不一定需要一致。
上下文窗口管理
AI模型有上下文长度限制(token限制)。DeepSeek-V3.2支持128K token,但普通用户聊一年可能都超了。更常见的问题是:如果历史记录太长,怎么截断?
策略1:截取最近N条
const recentHistory = chatHistory.slice(-10); // 只取最近10轮
策略2:按token数截断
function truncateByToken(messages, maxTokens) {
let total = 0;
const result = [];
// 从最新的消息开始往前加
for (let i = messages.length - 1; i >= 0; i--) {
const tokens = estimateTokens(messages[i].content);
if (total + tokens > maxTokens) break;
total += tokens;
result.unshift(messages[i]); // 插到前面保持顺序
}
return result;
}
策略3:保留关键信息(更智能)
比如保留用户身份信息、保留问题相关的上下文、保留最近N轮对话。
思考:对于聊天场景,用户最关心的往往是最近几轮对话的内容。早期的寒暄、确认身份之类的信息,即使丢失了影响也不大。所以简单截取最近N轮是可行的。
🚀 第五部分:实现对话API
核心API实现
app.post('/api/chat', async (req, res) => {
const { agentName, message } = req.body;
try {
// 1. 读取智能体prompt
const promptPath = path.join(__dirname, agentName, `${agentName}.txt`);
const systemPrompt = fs.readFileSync(promptPath, 'utf8');
// 2. 读取聊天历史
const history = readChatHistory(agentName);
// 3. 构建消息数组
const messages = [
{ role: "system", content: systemPrompt }
];
// 取最近10轮对话作为上下文
const recentHistory = history.slice(-10);
recentHistory.forEach(item => {
messages.push({ role: "user", content: item.user_message });
messages.push({ role: "assistant", content: item.agent_response });
});
messages.push({ role: "user", content: message });
// 4. 调用AI API
const config = readApiConfig();
if (!config.apiKey) {
return res.status(400).json({
success: false,
error: '请先配置API密钥'
});
}
const client = new OpenAI({
baseURL: "https://api.moark.com/v1",
apiKey: config.apiKey,
});
const response = await client.chat.completions.create({
model: config.model,
messages: messages,
temperature: 0.7, // 控制随机性
max_tokens: 2000, // 限制回复长度
});
const agentResponse = response.choices[0].message.content;
// 5. 保存到聊天记录
const newMessage = {
id: history.length + 1,
agent_name: agentName,
user_message: message,
agent_response: agentResponse,
timestamp: new Date().toISOString()
};
history.push(newMessage);
writeChatHistory(agentName, history);
// 6. 返回结果
res.json({ success: true, response: agentResponse });
} catch (error) {
console.error('对话API调用失败:', error);
res.status(500).json({
success: false,
error: error.message || '对话失败'
});
}
});
关键点解析
1. 为什么每次请求都重新创建client?
如前面讨论,为了支持动态更新API密钥。如果追求性能,可以加一层缓存,检测到配置变化时才重新创建。
2. temperature参数的作用
控制AI回复的随机性:
- 0.0:几乎每次都输出相同结果(确定性)
- 1.0:创造力强,但可能跑题
- 0.7:平衡创造力和稳定性
对于聊天场景,0.7是个不错的起点。
3. 错误处理
API调用可能失败的原因:
- 网络问题
- API密钥无效
- 模型不存在
- 触发了内容审核
- 超时
需要把这些错误都捕获,返回友好的错误信息。
💾 第六部分:前端集成与状态管理
核心状态设计
前端需要维护的状态:
let currentAgent = null; // 当前选中的智能体
let chatHistory = []; // 当前对话历史(用于渲染)
let isLoading = false; // 是否正在等待回复
发送消息流程
async function sendMessage() {
const input = document.getElementById('messageInput');
const message = input.value.trim();
if (!message || !currentAgent || isLoading) return;
// 清空输入框
input.value = '';
// 显示用户消息
addMessage('user', message);
// 设置加载状态
isLoading = true;
addMessage('agent', '正在输入...', 'loading'); // 临时消息
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentName: currentAgent.name,
message: message
})
});
const data = await response.json();
// 移除加载中的消息
removeLoadingMessage();
if (data.success) {
addMessage('agent', data.response);
} else {
addMessage('agent', `出错了:${data.error || '未知错误'}`);
}
} catch (error) {
removeLoadingMessage();
addMessage('agent', '网络错误,请检查连接');
} finally {
isLoading = false;
scrollToBottom();
}
}
设计思考:
- 加载状态:显示“正在输入…”让用户知道系统在响应
- 错误处理:区分API错误和网络错误,给出不同的提示
- 防重复提交:通过
isLoading防止连续点击发送

🔄 第七部分:进阶思考
思考1:流式响应 vs 非流式
目前的实现是一次性返回完整回复。如果AI思考时间长,用户会一直等待。流式响应可以逐步显示回复内容,体验更好。
实现思路:
// 前端使用EventSource或fetch的stream
const response = await fetch('/api/chat-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentName, message })
});
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解析chunk,逐步更新界面
}
思考:流式响应的挑战是什么?
- 前端需要处理增量更新
- 保存聊天记录时要等完整回复
- 错误处理更复杂
思考2:并发写入问题
如果两个用户同时和同一个智能体聊天,都调用writeChatHistory,会发生什么?
用户A读取文件 -> 得到historyA
用户B读取文件 -> 得到historyB
用户A写入文件 -> 文件变成historyA + 新消息
用户B写入文件 -> 文件变成historyB + 新消息(覆盖了A的修改)
这就是经典的并发写入覆盖问题。
解决方案:
- 文件锁:写入前创建锁文件,写入后释放
- 追加写入:不重写整个文件,只追加新内容(但JSON格式不支持)
- 分片存储:按时间分文件,减少冲突概率
- 数据库:改用真正的数据库(SQLite等)
对于个人项目,这个问题几乎不会发生。但如果要支持多用户,就必须考虑。
思考3:身份信息注入
在第二篇中我们预留了身份信息的功能。现在可以把它集成到system prompt中:
// 读取用户身份信息
const identityPath = path.join(__dirname, agentName, 'identity.json');
if (fs.existsSync(identityPath)) {
const identity = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
let identityText = '用户信息:';
if (identity.name) identityText += `姓名${identity.name},`;
if (identity.age) identityText += `年龄${identity.age},`;
// ...
systemPrompt = identityText + '\n' + systemPrompt;
}
思考:身份信息应该放在system prompt的开头还是结尾?开头可以让AI从一开始就知道用户是谁,结尾可能会被历史对话冲淡。一般放在开头更合理。
思考4:上下文长度优化
每次请求都带上所有历史记录,token消耗大,响应慢。可以优化:
- 只带最近N轮:假设N=10,大多数情况够用
- 摘要历史:定期把早期对话总结成一段话
- 关键信息提取:只保留用户身份、关键问题等
思考:对于聊天场景,用户往往只关心最近几轮对话。比如问完天气再问打球,这两轮有关联。但三小时前聊的电影,和现在聊的天气,就没必要关联。所以截取最近N轮是合理的。
🧪 第八部分:测试与验证
测试用例设计
- 正常对话:发送消息,看AI是否回复
- 上下文记忆:先告诉AI你的名字,再问“我叫什么”,看AI是否能记住
- 长对话:连续发送10条以上消息,观察响应时间
- 错误处理:不配API密钥发送消息,看错误提示
- 并发测试:同时打开两个窗口和同一个智能体聊天(可选)
常见问题排查
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| API返回401 | API密钥无效 | 检查配置文件 |
| 回复慢 | 模型大、网络慢 | 考虑用流式响应 |
| 上下文不连贯 | 历史记录没传对 | 检查messages构建逻辑 |
| 保存失败 | 文件权限问题 | 检查文件夹可写权限 |
🎯 本篇小结
在这一篇中,我们为MultiMind注入了“对话之心”:
| 任务 | 成果 |
|---|---|
| 对话数据结构设计 | 确定了问答对存储格式 |
| 聊天记录读写 | 实现了文件的读取和写入 |
| 模力方舟API接入 | 成功调用AI接口 |
| 上下文管理 | 实现了最近N轮对话的上下文 |
| 前端界面 | 完成了完整的聊天交互 |
| 错误处理 | 考虑了各种异常情况 |
核心收获:
- 对话不仅是“一问一答”,还需要上下文记忆
- AI API调用需要精心构造messages数组
- 文件存储要考虑并发和一致性问题
- 用户体验需要加载状态和错误处理
🔮 下篇预告
第四篇:用户之心——多智能体身份绑定系统
在下一篇中,我们将实现身份管理功能:
- 为每个智能体独立存储用户信息
- 身份信息与对话上下文的融合
- 前端身份编辑界面
- 实现“千人千面”的对话体验
敬请期待!
📝 写在最后
对话系统是一个聊天平台的核心。我们用了三篇文章,终于让MultiMind真正“会说话”了。
从智能体发现,到对话管理,再到AI API集成,每一步都是深思熟虑后的选择。你可能已经发现,很多设计并没有标准答案——比如数据格式、上下文策略、并发处理,都需要根据实际情况权衡。
这正是架构设计的魅力所在:没有绝对的正确,只有适合当前场景的选择。
下一篇,我们将继续完善这个系统,让智能体不仅会说话,还能记住“你是谁”。
思考题:如果你来设计一个聊天系统,你会选择什么数据格式?你会如何处理超长对话的上下文?欢迎在评论区分享你的思考。
更多推荐




所有评论(0)