📖 引言

在上一篇中,我们搭建了项目的地基,实现了智能体的动态发现。现在,当你访问 http://localhost:3002,已经能看到一排智能体卡片整齐排列。点击卡片,它会弹出一个提示框——“你选择了凌云拓界”。

然后呢?然后就没有然后了。

这显然不够。一个聊天平台的核心是什么?当然是聊天。用户点击智能体,应该进入对话界面,发送消息,接收回复,而且智能体应该记得之前说过什么——这才是真正的“对话”。

这一篇,我们将为MultiMind注入“对话之心”。你将学会如何设计对话数据结构、如何存储和读取聊天记录、如何维护多轮对话的上下文。更重要的是,我们将正式接入模力方舟API,让智能体真正具备对话能力。

获取项目源代码 Gitee MultiMind 雪豹同志

在这里插入图片描述


🎯 本章目标

学完本篇,你将能够:

  • 设计合理的对话数据结构
  • 实现聊天记录的持久化存储
  • 创建聊天界面并与后端交互
  • 理解上下文窗口的管理策略
  • 对接模力方舟API并处理流式响应
  • 思考并发写入、数据一致性等进阶问题

💭 第一部分:对话系统的设计思考

从现实生活找灵感

想象一下你和朋友小明的对话:

你:小明,今天天气怎么样?
小明:挺好的,阳光明媚。
你:那下午要不要去打球?
小明:可以啊,叫上小李一起。

这段对话里,有几层信息?

  1. 消息本身:每个人说的话
  2. 对话顺序:先问天气,再约打球,小明记得刚聊过天气
  3. 身份标识:谁在和谁说话

如果让计算机来存储这段对话,应该怎么设计?

数据结构设计

最常见的做法有两种:

方案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的选择上,我们面临几个问题:

  1. 选哪个厂商? 模力方舟、OpenAI、 Anthropic、百度文心、阿里通义…
  2. 选什么模型? DeepSeek、GPT、Claude、GLM…
  3. 怎么管理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格式的聊天补全接口,要求传入一个消息数组,每个消息包含rolecontent

[
  { 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的修改)

这就是经典的并发写入覆盖问题。

解决方案

  1. 文件锁:写入前创建锁文件,写入后释放
  2. 追加写入:不重写整个文件,只追加新内容(但JSON格式不支持)
  3. 分片存储:按时间分文件,减少冲突概率
  4. 数据库:改用真正的数据库(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消耗大,响应慢。可以优化:

  1. 只带最近N轮:假设N=10,大多数情况够用
  2. 摘要历史:定期把早期对话总结成一段话
  3. 关键信息提取:只保留用户身份、关键问题等

思考:对于聊天场景,用户往往只关心最近几轮对话。比如问完天气再问打球,这两轮有关联。但三小时前聊的电影,和现在聊的天气,就没必要关联。所以截取最近N轮是合理的。


🧪 第八部分:测试与验证

测试用例设计

  1. 正常对话:发送消息,看AI是否回复
  2. 上下文记忆:先告诉AI你的名字,再问“我叫什么”,看AI是否能记住
  3. 长对话:连续发送10条以上消息,观察响应时间
  4. 错误处理:不配API密钥发送消息,看错误提示
  5. 并发测试:同时打开两个窗口和同一个智能体聊天(可选)

常见问题排查

问题 可能原因 解决方案
API返回401 API密钥无效 检查配置文件
回复慢 模型大、网络慢 考虑用流式响应
上下文不连贯 历史记录没传对 检查messages构建逻辑
保存失败 文件权限问题 检查文件夹可写权限

🎯 本篇小结

在这一篇中,我们为MultiMind注入了“对话之心”:

任务 成果
对话数据结构设计 确定了问答对存储格式
聊天记录读写 实现了文件的读取和写入
模力方舟API接入 成功调用AI接口
上下文管理 实现了最近N轮对话的上下文
前端界面 完成了完整的聊天交互
错误处理 考虑了各种异常情况

核心收获

  1. 对话不仅是“一问一答”,还需要上下文记忆
  2. AI API调用需要精心构造messages数组
  3. 文件存储要考虑并发和一致性问题
  4. 用户体验需要加载状态和错误处理

🔮 下篇预告

第四篇:用户之心——多智能体身份绑定系统

在下一篇中,我们将实现身份管理功能:

  • 为每个智能体独立存储用户信息
  • 身份信息与对话上下文的融合
  • 前端身份编辑界面
  • 实现“千人千面”的对话体验

敬请期待!


📝 写在最后

对话系统是一个聊天平台的核心。我们用了三篇文章,终于让MultiMind真正“会说话”了。

从智能体发现,到对话管理,再到AI API集成,每一步都是深思熟虑后的选择。你可能已经发现,很多设计并没有标准答案——比如数据格式、上下文策略、并发处理,都需要根据实际情况权衡。

这正是架构设计的魅力所在:没有绝对的正确,只有适合当前场景的选择

下一篇,我们将继续完善这个系统,让智能体不仅会说话,还能记住“你是谁”。


思考题:如果你来设计一个聊天系统,你会选择什么数据格式?你会如何处理超长对话的上下文?欢迎在评论区分享你的思考。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐