☰
从零实现 C++ AI 大模型接入 SDK(十):SessionManager 会话管理与多轮对话实现
2026/10/23 6:28:15 网站建设 项目流程

目录

前言

一、为什么模型调用之后还需要会话管理

1.1 单次模型请求不会自动拥有聊天记忆

1.2 LLMManager 和 SessionManager 分别解决什么

二、一个会话由什么组成

2.1 Message 表示一条消息

2.2 Session 表示一段连续对话

2.3 SessionManager 内部保存什么

三、创建会话:先生成 ID,再建立会话对象

3.1 为什么每段会话都需要唯一 ID

3.2 createSession() 实际做了两件事

3.3 为什么缓存的会话使用 shared_ptr

四、添加消息与恢复历史:多轮对话真正的核心

4.1 addMessage() 为什么先复制消息

4.2 获取历史为什么有两条路径

4.3 构造 SessionManager 时恢复什么

五、多线程下,为什么既要 mutex 又要 atomic

5.1 mutex 保护的是一组连续操作

5.2 atomic 只管 ID 计数器

六、会话不仅要能聊天,还要能维护和排序

6.1 getAllSessions() 返回的是 ID 列表

6.2 重命名与置顶怎样同步数据库

6.3 删除、清空和数量统计

七、先验证会话恢复,再接入完整聊天流程

7.1 核心测试:用同一个数据库重新构造管理器

7.2 运行测试前,要确认测试入口和断言行为

7.3 SessionManager 怎样接入下一次模型请求

写在最后


前言

系列:从零实现 C++ AI 大模型接入 SDK,第十篇

项目源码:

AI-CHAT-SDKhttps://gitee.com/kuang-zhenting/my_ai_cpp_project

前面我们已经完成四种 Provider 的接入,并在第九篇通过LLMManager把它们统一管理起来。现在,上层只需要给出模型名称,就能把一组Message交给正确的 Provider。

但有一个问题还没解决:模型知道这一轮该回答什么,却未必知道我们上一轮聊了什么。

例如,用户先说“我正在学习 C++”,下一轮再问“那我应该先学智能指针还是多线程?”如果第二次请求只发送后面这句话,模型就缺少前面的上下文。更进一步,用户可能同时打开好几个聊天窗口;每个窗口的历史不能混在一起,重新启动程序后也应该能找到之前的聊天记录。

这就不是模型管理的问题了。我们需要一个新的模块,专门保存“谁在和哪个模型聊天、已经说过什么、这些消息属于哪一段对话”。

这篇我们进入SessionManager:先建立会话、历史与生命周期的完整理解,再用一个不依赖真实模型服务的恢复测试检验它是否真正把聊天保存下来。

本篇重点是会话生命周期与消息历史的组织方式。当前项目的SessionManager已经连接 SQLite,因此我们也会讲清内存和数据库如何协作;至于 SQLite 建表、预编译语句和事务细节,则留到后面单独展开。


一、为什么模型调用之后还需要会话管理

1.1 单次模型请求不会自动拥有聊天记忆

前面介绍的 Provider 接口接受的是消息数组:

// 调用方提供本轮要发送的消息列表,其中可以包含历史上下文。 std::string sendMessage( const std::vector<Message>& messages, // 具体 Provider 负责解释温度、输出长度等请求参数。 const std::map<std::string, std::string>& request_param );

也就是说,调用方决定这次要发送哪些消息。Provider 负责把消息转换成具体模型的请求格式,再解析模型返回的结果;它并不负责替上层维护多个聊天窗口的完整生命周期。

设想下面两轮对话:

第一轮 user: 我叫小明,正在学习 C++。 assistant: 你好,小明! 第二轮 user: 你还记得我叫什么吗?

第二轮需要的不只是最后一句,而是包含前一轮信息的消息列表。

所以,多轮对话的关键并不是让模型“自己记住”先前请求,而是由应用管理历史,并在下一次调用时把相关历史再次交给模型。

如果同时存在两个聊天窗口,问题还要再多一层:

会话 A:与 deepseek-chat 讨论 C++ user -> assistant -> user -> assistant ... 会话 B:与 gpt-5.5 讨论数据库 user -> assistant -> user -> assistant ...

它们不仅模型可能不同,消息列表也必须互不混淆。因此,我们至少需要三个东西:会话 ID、会话绑定的模型名称,以及属于该会话的消息历史。

1.2 LLMManager 和 SessionManager 分别解决什么

这两个 Manager 的名字相似,但职责完全不同。

  • LLMManager:根据模型名称查找 Provider,负责模型初始化、状态查询和请求转发。

  • SessionManager:根据会话 ID 查找会话,负责创建会话、管理消息历史、维护会话列表和生命周期。

一段聊天真正发起请求时,可以这样理解:

session_id | v SessionManager:找到会话、取得 model_name 与消息历史 | v LLMManager:根据 model_name 找到对应 Provider | v 具体 Provider:发送消息给目标模型并取得回复 | v SessionManager:把回复加入原来的会话

这里最重要的是:会话负责保存上下文,模型管理层负责执行调用。两者互相配合,但不要把两种职责混进同一个类里。


二、一个会话由什么组成

本篇主要涉及:

SDK/ ├── include/ │ ├── common.h │ ├── SessionManager.h │ └── DateManager.h └── src/ ├── SessionManager.cpp └── DateManager.cpp

注意实际文件名是DateManager.h/.cpp,其中定义和实现的类叫DataManager。我们讨论类时使用DataManager,引用文件路径时则遵守工程中的真实命名。

2.1 Message 表示一条消息

在common.h中,消息结构为:

struct Message { std::string _id; // 进入会话时,由 SessionManager 生成消息 ID。 std::string _role; // 消息角色,例如 user、assistant。 std::string _content; // 消息正文。 std::time_t _timestamp; // 消息时间;构造时先记录当前时间。 // 调用方只需提供角色和内容,此时 _id 仍为空。 Message(const std::string &amp;role = "", const std::string &amp;content = "") : _role(role) , _content(content) , _timestamp(std::time(nullptr)) { } };

四个字段回答了四个问题:

字段作用
_id区分不同消息,供存储和查询使用
_role谁说的,例如user、assistant
_content消息正文
_timestamp消息时间

创建消息时,我们通常只需要传角色与内容:

// 创建一条用户消息;此时不需要由业务代码自行生成消息 ID。 Message userMessage("user", "请解释一下 std::shared_ptr");

此时构造函数会记录时间,但_id仍然为空。等消息真正进入会话,SessionManager::addMessage()才会为它生成消息 ID。这样调用方不必自己维护消息编号。

2.2 Session 表示一段连续对话

同一文件中还定义了Session:

struct Session { std::string _id; // 会话标识,用来查找和操作这段对话。 std::string _model_name; // 当前会话绑定的模型名称。 std::vector<Message> _message; // 本会话的消息历史。 std::time_t _created_at; // 会话创建时间。 std::time_t _updated_at; // 会话最近更新时间,参与列表排序。 std::string _title; // 空字符串表示尚未命名。 bool _is_pinned = false; // 是否置顶;新会话默认不置顶。 std::time_t _pinned_at = 0; // 置顶时间;未置顶时为 0。 // 先确定本会话使用哪个模型,ID 和历史由管理器继续维护。 Session(const std::string &model) : _model_name(model) , _created_at(std::time(nullptr)) , _updated_at(std::time(nullptr)) { } };

可以把它理解成一个聊天窗口:

  • _id表示窗口本身的身份。

  • _model_name表示这段会话选定的模型。

  • _message保存用户消息与模型回复,是多轮对话的上下文来源。

  • _created_at、_updated_at用来记录会话创建和最近更新时间。

  • _title、_is_pinned、_pinned_at用来支持会话重命名、置顶和列表排序。

其中_message使用std::vector<Message>,因为聊天历史本来就是由多条消息构成的有序序列。Message是一条记录,Session才是一段完整对话;不要把它们当成同一个概念。

2.3 SessionManager 内部保存什么

SessionManager.h对外提供的主要操作如下:

class SessionManager { public: // 打开指定数据库,并在构造阶段恢复会话元数据。 SessionManager(const std::string &db_name = "chat.db"); // 会话与消息操作:通过会话 ID 组织同一段对话。 std::string createSession(const std::string &model_name); std::shared_ptr<Session> getSession(const std::string &session_id); bool addMessage(const std::string &session_id, const Message &message); std::vector<Message> getHistoryMessages(const std::string &session_id) const; std::vector<std::string> getAllSessions() const; // 会话维护:删除、重命名、置顶及时间更新。 bool deleteSession(const std::string &session_id); bool updateSessionTimestamp(const std::string &session_id); bool renameSession(const std::string &session_id, const std::string &title); bool setSessionPinned(const std::string &session_id, bool pinned); // 清空会同时处理缓存和数据库;数量接口统计当前缓存。 void clearAllSessions(); std::size_t getSessionCount() const; private: // 生成当前实现使用的 ID;不代表跨进程的全局唯一序列。 std::string generateSessionId(); std::string generateMessageId( const std::string &session_id, std::size_t message_index ); // 按 ID 保存共享会话对象,调用方可以持有同一个对象。 std::unordered_map<std::string, std::shared_ptr<Session>> _sessions; // 保护方法中明确划定的临界区,不自动保护外部对 Session 的访问。 mutable std::mutex _mutex; // 只负责本实例的 ID 计数;不是当前会话数量。 std::atomic<std::int64_t> _session_counter{0}; // 持久化成员负责 SQLite 读写。 DataManager _data_manager; };

这里暂时抓住四个核心成员:

  • _sessions用会话 ID 查找对象。用unordered_map,是因为我们经常按 ID 查找会话,而不是按创建顺序遍历。
  • std::shared_ptr<Session>让管理器和调用方能够持有同一个会话对象,不需要每次获取都复制整段历史。
  • _mutex和_session_counter分别负责保护容器的复合操作、生成会话 ID 所需的计数。两者不是替代关系。
  • _data_manager是一个DataManager成员,负责与 SQLite 交互。管理器从构造开始就会与持久化层协作。这里先关注它怎样组织会话;数据库表结构和 SQL 实现会在下一篇展开。


三、创建会话:先生成 ID,再建立会话对象

3.1 为什么每段会话都需要唯一 ID

如果应用只有一个聊天窗口,存一个std::vector<Message>似乎也能工作。但一旦用户打开多个会话,业务层需要一个稳定的标识告诉管理器:我现在要操作的是哪一段对话?

这正是session_id的作用。创建、取历史、添加消息、重命名、删除,后面都会围绕它展开。

当前generateSessionId()的实现是:

std::string SessionManager::generateSessionId() { // fetch_add 返回自增前的值,加 1 后使本实例的编号从 1 开始。 const std::int64_t count = _session_counter.fetch_add(1) + 1; const std::time_t now = std::time(nullptr); std::ostringstream oss; // 拼接秒级时间与计数值;计数不足 8 位时在左侧补 0。 oss << "session_" << now << "-" << std::setw(8) << std::setfill('0') << count; return oss.str(); }

ID 由三部分组成:固定前缀、当前秒级时间戳,以及八位宽度的计数器。fetch_add(1)是原子自增,std::setw(8)和std::setfill('0')用于统一数字格式。

消息 ID 则由另一个函数生成:

std::string SessionManager::generateMessageId( const std::string &session_id, std::size_t message_index ) { const std::time_t now = std::time(nullptr); std::ostringstream oss; // 在消息 ID 中带上所属会话,便于区分不同会话里的消息。 oss << session_id << "_message_" << now << "_" // 调用方传入当前消息数量,因此本条消息的序号使用下标加 1。 << std::setw(8) << std::setfill('0') << (message_index + 1); return oss.str(); }

消息 ID 包含所属会话的 ID,便于把消息与会话联系起来;消息序号取自当前会话的消息数量。

需要注意,这两种 ID 都是当前工程采用的生成格式:会话计数器属于某个SessionManager实例,并不是跨进程共享的全局序列;消息序号也依赖当前内存列表。因此,不能把它们当成任意并发、任意重启场景都绝对不会碰撞的全局 ID 算法。

3.2 createSession() 实际做了两件事

createSession()接受模型名称并返回会话 ID。它的核心流程可以概括为:

std::string SessionManager::createSession(const std::string &model_name) { // 这两个变量需要在锁释放后继续用于数据库写入和返回。 std::string session_id; std::shared_ptr<Session> session; { // 内存阶段:把生成 ID、创建对象和插入缓存放在同一临界区。 std::lock_guard<std::mutex> lock(_mutex); session_id = generateSessionId(); const std::time_t now = std::time(nullptr); session = std::make_shared<Session>(model_name); session->_id = session_id; session->_created_at = now; session->_updated_at = now; _sessions.emplace(session_id, session); } // 离开上面的作用域后已释放内存锁,再把会话写入 SQLite。 if (!_data_manager.insertSession(*session)) { ERR("session persistence failed: {}", session_id); } INFO("session created, id: {}, model: {}", session_id, model_name); // 当前实现即使写库失败也会返回 ID,不能仅据此认定持久化成功。 return session_id; }

注意函数中间那对额外的大括号:它给锁限定了一个很短的作用域。内存中完成 ID 生成、对象创建和容器插入后,锁就会释放,随后再调用DataManager::insertSession()写入 SQLite。

也就是说,创建会话分为两个阶段:

内存阶段 generateSessionId() make_shared<Session>(model_name) _sessions.emplace(...) ↓ 持久化阶段 _data_manager.insertSession(*session)

为什么返回std::string而不是返回Session?因为后续上层只需要保存 ID,就能通过管理器找到对应会话,避免每个业务入口都直接持有完整的消息容器。

不过,理解源码时还要留意它的返回约定:即使数据库插入失败,当前函数仍会记录错误并返回已经创建的会话 ID。因此“返回非空 ID”说明内存创建流程走到了末尾,不能单独作为数据库成功写入的证据。

3.3 为什么缓存的会话使用 shared_ptr

管理器内部保存的是:

// 管理器与调用方共享 Session 的生命周期;共享指针不自动保护对象字段。 std::unordered_map<std::string, std::shared_ptr<Session>> _sessions;

getSession()返回的也是std::shared_ptr<Session>,调用方获取后可以直接读取它的_model_name或消息列表,而不必复制完整Session。

与第九篇LLMManager持有的unique_ptr<ILLMProvider>不同,这里选择shared_ptr,是因为一个会话对象可能同时被管理器和外部业务代码引用。它解决的是对象生命周期的共享所有权,不是自动保护对象字段的并发读写。线程安全还得靠后面的锁以及调用约束。


四、添加消息与恢复历史:多轮对话真正的核心

4.1 addMessage() 为什么先复制消息

接下来用户发来一句新问题,我们要把它放进正确的会话:

Message userMessage("user", "解释一下 C++ 左值引用"); // 管理器内部会复制消息并补充 ID,不会改写上面的 userMessage 对象。 const bool ok = sessionManager.addMessage(sessionId, userMessage);

addMessage()接受的是const Message&,因此它不会直接改写调用方传入的原对象,而是创建一份stored_message,为它补齐用于内部保存的字段。

关键代码如下,保留了影响正确性的主要分支:

bool SessionManager::addMessage( const std::string &session_id, const Message &message ) { // 复制调用方消息,之后补充 ID 时不修改原对象。 Message stored_message = message; // 保存追加消息前的更新时间,供写库失败时尝试恢复内存。 std::time_t previous_updated_at = 0; { // 查找会话、计算消息序号、追加消息和更新时间需要连续执行。 std::lock_guard<std::mutex> lock(_mutex); const auto it = _sessions.find(session_id); if (it == _sessions.end()) { return false; } std::shared_ptr<Session> &session = it->second; stored_message = message; // 统一生成用于内存和数据库的消息 ID。 stored_message._id = generateMessageId( session_id, session->_message.size() ); const std::time_t now = std::time(nullptr); // 已有时间戳继续保留;只有为 0 时才补上当前时间。 if (stored_message._timestamp == 0) { stored_message._timestamp = now; } previous_updated_at = session->_updated_at; session->_message.push_back(stored_message); session->_updated_at = stored_message._timestamp; } // 此时内存锁已释放;同一份消息交给数据库层,在其事务中写入。 if (!_data_manager.insertMessage(session_id, stored_message)) { std::lock_guard<std::mutex> lock(_mutex); const auto it = _sessions.find(session_id); // 重新加锁后,只撤销仍处于末尾且 ID 匹配的消息,避免误删后续消息。 if (it != _sessions.end() && !it->second->_message.empty() && it->second->_message.back()._id == stored_message._id) { it->second->_message.pop_back(); it->second->_updated_at = previous_updated_at; } // 并发交错时上述条件可能不成立,因此这里只能算有条件的缓存撤销。 ERR("message rollback from memory, id: {}", stored_message._id); return false; } return true; }

整个流程可以拆成四步:

  1. 查找目标会话,找不到就返回false。

  2. 为消息生成 ID、必要时补时间,追加到_message,同步更新会话时间。

  3. 释放_mutex后,让DataManager将同一份stored_message写入数据库。

  4. 数据库方法返回失败时,尝试删除内存尾部刚插入的那条消息,并恢复之前的时间戳。

这里有一个值得注意的细节:写内存和写数据库使用同一份带有_id、_timestamp的stored_message,避免两边分别生成 ID 或时间戳,造成对应不上。

这里需要分清两层保证。DataManager::insertMessage()已经使用 SQLite 事务,把“插入消息”和“更新会话时间”放在同一次提交中;任一步骤失败都会进入回滚处理。这解决的是数据库内部两条 SQL 的原子性。

而SessionManager的内存追加发生在调用数据库之前,SQLite 事务并不能直接撤销 C++ 容器里的修改。数据库方法返回失败后,管理器还要单独检查尾部消息的 ID,再尝试恢复内存。数据库内部有事务,不等于内存与数据库已经成为同一个不可分割的操作。尤其在并发操作交错时,只撤销匹配的末尾消息仍不足以保证所有场景的一致性。事务的具体 SQL 写法留到下一篇展开。

4.2 获取历史为什么有两条路径

添加完消息,真正调用模型之前,需要拿到该会话的完整消息列表。

getHistoryMessages()的实现并不复杂:

std::vector<Message> SessionManager::getHistoryMessages( const std::string &session_id ) const { { std::lock_guard<std::mutex> lock(_mutex); const auto it = _sessions.find(session_id); // 缓存已有非空历史时,在锁内复制出本轮请求要使用的消息列表。 if (it != _sessions.end() && !it->second->_message.empty()) { return it->second->_message; } } // 缓存缺失或消息列表为空时,锁外查询 SQLite;本方法直接返回结果。 return _data_manager.getSessionMessages(session_id); }

先查内存。如果已经找到非空消息列表,就直接返回一个vector<Message>副本;如果找不到会话,或者找到的消息列表仍然为空,则到 SQLite 查询历史。

为什么不直接返回对内部vector的引用?因为调用方之后可能发网络请求,持续持有一个可被其他操作修改的内部容器并不合适。这里返回副本,让本轮模型请求使用一份确定的消息列表。

这个行为也提醒我们:空向量在当前实现里既可能代表“确实没有消息”,也可能代表“还没有从数据库加载消息”。所以代码没有简单地把空缓存认定为完整历史。

4.3 构造 SessionManager 时恢复什么

管理器构造时,先通过DataManager读取已有会话:

// 数据管理成员先完成数据库初始化,随后才进入构造函数体。 SessionManager::SessionManager(const std::string &db_name) : _data_manager(db_name) { // 这里只读取会话元数据,不会一次加载全部会话的历史消息。 const auto stored_sessions = _data_manager.getAllSessions(); for (const auto &session : stored_sessions) { if (session != nullptr) { // 把已有会话放回缓存,具体历史在后续读取时再加载。 _sessions.emplace(session->_id, session); } } INFO("SessionManager initialized with database: {}", db_name); }

它先构造DataManager,然后读取数据库中的会话元数据,把这些会话对象放回_sessions。但这里的getAllSessions()并不会为每个会话加载完整消息历史。

这样设计可以避免程序刚启动时就把所有历史消息一起装进内存。

等业务真正调用getSession(session_id)时,管理器会再次处理消息加载:

  • 内存命中:通过_data_manager.getSessionMessages(session_id)读取数据库消息;重新加锁后,再检查会话是否仍然存在,存在时才回填历史并返回会话,否则返回nullptr。

  • 内存未命中:调用_data_manager.getSession(session_id),从数据库恢复完整会话;如果找到,再放入_sessions。

  • 数据库也找不到:返回nullptr。

命中缓存后的关键分支如下,省略了正常返回前的日志:

// 查询数据库期间不持有 SessionManager 的互斥锁。 auto stored_messages = _data_manager.getSessionMessages(session_id); // 重新加锁后再查一次容器,确认查询期间会话没有被删除。 std::lock_guard<std::mutex> lock(_mutex); const auto it = _sessions.find(session_id); if (it == _sessions.end()) { ERR("session deleted concurrently: {}", session_id); return nullptr; } // 会话仍存在时,把查询出的历史移动回缓存,返回共享会话对象。 it->second->_message = std::move(stored_messages); return it->second;

为什么数据库查询后还要再查一次_sessions?因为查询期间没有持有管理器的_mutex,另一个线程可能已经删除了这个会话。第一次命中缓存,只代表当时存在;回填时必须重新确认,不能继续解引用已经失效的容器迭代器。

因此,这里的getSession()不能理解为“只要缓存命中,就绝对不访问 SQLite”。当前代码即使查到缓存对象,也仍然会重新读取对应的数据库历史消息。这一点与纯内存版本的会话管理有明显区别。

另外,getSession()会把读取出的历史写入共享的Session对象,而getHistoryMessages()返回的是消息列表副本。两者用途不同:前者适合获取会话元数据与对象,后者适合为下一次模型调用准备上下文。

还要分清“数据库里没有会话”和“数据库初始化失败”。DataManager负责打开数据库并初始化表结构,失败时会关闭连接;但它没有把初始化结果作为单独的公共状态提供给SessionManager。后者直接读取会话列表,所以空列表和一条管理器初始化日志,都不足以单独证明持久化层可用。数据库初始化与表结构兼容的具体处理,留到下一篇展开。


五、多线程下,为什么既要 mutex 又要 atomic

一个聊天应用里,创建会话、写入消息、删除会话和获取列表可能来自不同的请求。如果多个线程同时修改同一个unordered_map或一份vector<Message>,就可能出现数据竞争。

5.1 mutex 保护的是一组连续操作

例如addMessage()必须连续完成查找会话、生成消息序号、追加消息、更新时间戳。这几步依赖同一份共享状态,所以代码使用:

{ // 进入作用域时加锁,离开作用域时由 lock_guard 自动解锁。 std::lock_guard<std::mutex> lock(_mutex); // 查找 _sessions // 修改 Session 的 _message 和 _updated_at }

std::lock_guard是 RAII 风格的锁管理器,进入作用域时加锁,离开作用域时自动释放锁。即使中途return,也不会因为忘记手动解锁而一直占着互斥锁。

getHistoryMessages()、getAllSessions()、deleteSession()等涉及_sessions的操作也使用了这把_mutex。但需要看清每个函数实际加锁的范围:例如getAllSessions()只在锁内复制会话映射,其中的shared_ptr仍指向原来的会话对象,后续排序在锁外读取对象字段。这并不是一份完整的、与并发修改隔离的会话快照,因此不能把“函数中用过锁”理解为整个调用过程都具备并发一致性。

其中最值得学习的是锁范围:像数据库读写这样可能较慢的操作,源码在一些路径里特意放到了 SessionManager 的锁外,减少其他线程等待内存锁的时间。但这也意味着内存修改与数据库写入不是一个不可分割的原子步骤。

5.2 atomic 只管 ID 计数器

生成会话 ID 时,代码使用的是:

// 原子变量只保证此计数值的操作安全,不会顺带保护会话容器。 std::atomic<std::int64_t> _session_counter{0}; // 本实例内以原子方式取得下一序号;不表示跨进程共享计数。 const std::int64_t count = _session_counter.fetch_add(1) + 1;

fetch_add(1)保证对这个计数器的自增是原子的。但它不会顺便保护_sessions,也不会让整份Session的消息列表自动变得线程安全。

可以简单记住:

atomic:保护“这一个计数值怎样改变” mutex:保护“这一串需要一起完成的读写操作”

还有一个容易混淆的地方:shared_ptr的引用计数管理并不等于所指对象的字段读写安全。getSession()把共享指针返回给调用方后,调用方如果直接修改session->_message,并不会自动获得SessionManager::_mutex的保护。因此,更稳妥的使用方式是通过管理器提供的方法完成消息增删,而不是绕开管理器随意修改内部状态。


六、会话不仅要能聊天,还要能维护和排序

一个真正的聊天列表,不能只支持“创建”和“追加消息”。用户还需要查看历史、重命名、置顶和删除。

6.1 getAllSessions() 返回的是 ID 列表

当前接口为:

// 返回按置顶和时间规则排序的会话 ID,不是完整 Session 对象列表。 std::vector<std::string> getAllSessions() const;

内部不是直接遍历unordered_map就返回,因为哈希表本身没有按时间排序的保证。

实现先从_data_manager.getAllSessions()读取数据库元数据,再复制一份_sessions到临时容器,把数据库里存在但临时容器缺少的会话加入进去。然后把会话指针放入vector排序,最后只提取会话 ID。

它的主要比较规则如下:

std::sort(ordered_sessions.begin(), ordered_sessions.end(), [](const std::shared_ptr<Session> &lhs, const std::shared_ptr<Session> &rhs) { // 第一层:置顶会话排在未置顶会话前面。 if (lhs->_is_pinned != rhs->_is_pinned) { return lhs->_is_pinned > rhs->_is_pinned; } // 第二层:都置顶且置顶时间不同时,最近置顶的排在前面。 if (lhs->_is_pinned && rhs->_is_pinned && lhs->_pinned_at != rhs->_pinned_at) { return lhs->_pinned_at > rhs->_pinned_at; } // 第三层:其余情况按最近更新时间排序;完全相同的键不额外按 ID 排序。 return lhs->_updated_at > rhs->_updated_at; });

读这个比较函数时,按三层判断就够了:

  1. 置顶与未置顶的会话相比,置顶排前面。

  2. 两个会话都置顶且置顶时间不同时,最近置顶的排前面。

  3. 其他情况按_updated_at从新到旧排序。

最后返回vector<string>,让调用方拿到一组可继续查询的 ID。如果要展示标题、模型、创建时间,可以再通过getSession(id)取得对应元数据。

同一排序键完全相同时,比较器没有额外的 ID 次序规则,所以不要假定这类会话的前后顺序一定固定。

6.2 重命名与置顶怎样同步数据库

目前还提供了两个很实用的接口:

// 修改标题,当前实现不会因此主动刷新会话更新时间。 bool renameSession(const std::string &session_id, const std::string &title); // 置顶时记录当前时间,取消置顶时把置顶时间清零。 bool setSessionPinned(const std::string &session_id, bool pinned);

两者都会先通过getSession()确认目标会话存在。重命名时先修改内存中的_title,再调用_data_manager.updateSessionTitle()。

置顶时会用当前时间填充_pinned_at;取消置顶时将_pinned_at设为0,随后调用_data_manager.updateSessionPinned()保存相应字段。

这里的实现还有两点需要明确:重命名并不会主动更新_updated_at;这两种操作都是先修改内存再写数据库,如果数据库更新失败,函数会返回失败,但当前代码并没有再把内存字段回滚到旧值。

6.3 删除、清空和数量统计

删除单个会话调用:

// 先删除缓存,再删除数据库记录;关联消息由 SQLite 外键级联删除。 bool deleteSession(const std::string &session_id);

它先尝试从_sessions删除目标 ID。内存中找不到时直接返回false;找到后调用_data_manager.deleteSession()删除数据库中的会话记录。数据库启用了外键约束,关联消息的级联删除由 SQLite 负责。若数据库删除失败,当前代码不会自动恢复已擦除的内存记录。

清空所有会话调用clearAllSessions():先清空内存,再调用数据库层的deleteAllSessions()。

而getSessionCount()返回的是:

std::size_t SessionManager::getSessionCount() const { std::lock_guard<std::mutex> lock(_mutex); // 统计缓存中的会话数量,不是查询数据库 COUNT(*),也不是 ID 累计值。 return _sessions.size(); }

这说明它统计的是当前缓存中保存的会话数量,不是某个历史自增计数器,也不是每次都执行一次数据库COUNT(*)。

updateSessionTimestamp()则只更新_sessions中目标会话的_updated_at。需要把它与addMessage()区分开:后者写消息时,会通过DataManager::insertMessage()同时执行数据库会话更新时间更新;而updateSessionTimestamp()本身没有再调用数据库更新方法。


七、先验证会话恢复,再接入完整聊天流程

前面讲了创建、添加、恢复和维护会话。对于这一篇来说,最能检验SessionManager职责的,不是模型回答得是否正确,而是会话对象销毁后,消息能否从 SQLite 再次恢复。因此先做独立于云端 API 的持久化验证,最后再把它放回多轮聊天调用链。

7.1 核心测试:用同一个数据库重新构造管理器

仓库的TEST/test_LLM.cpp已提供:

// Google Test 用例名称;该用例只验证保存与恢复,不调用模型服务。 TEST(SessionManagerTest, SessionPersistenceAndRestore)

这个用例不需要 API Key,也没有向 DeepSeek、ChatGPT 等模型发送请求。测试把程序“重启”拆成了两个 C++ 作用域:

第一次创建 SessionManager("session_test.db") ↓ createSession("deepseek-chat") ↓ addMessage(user) + addMessage(assistant) ↓ 第一个 manager 离开作用域,数据库连接关闭 ↓ 第二次创建 SessionManager("session_test.db") ↓ getSession(之前保存的 session_id) ↓ 检查恢复后的 _message.size() == 2

这里的"deepseek-chat"只是写入会话的模型名称,这个测试并不初始化DeepSeekProvider。模型服务是否可用,与这次 SQLite 恢复验证无关。

源码中的关键步骤如下(摘取自同一个测试用例):

// 把 ID 保存在两个作用域之外,第二个管理器才能继续查找原会话。 std::string session_id; { // 第一阶段:创建会话并保存两条消息;模型名在这里仅作为元数据。 SessionManager manager("session_test.db"); session_id = manager.createSession("deepseek-chat"); Message user_message; user_message._role = "user"; user_message._content = "请介绍一下 SQLite 外键。"; // 原测试把写入放在 assert 中:必须保留断言,否则这个调用也会被移除。 assert(manager.addMessage(session_id, user_message)); Message assistant_message; assistant_message._role = "assistant"; assistant_message._content = "SQLite 外键用于维护表之间的引用关系。"; // 助手回复也保存到同一会话,恢复时应读到完整的两条历史。 assert(manager.addMessage(session_id, assistant_message)); } // 第一个管理器及其数据库成员析构,原内存缓存随之释放。 { // 第二阶段仍在同一测试进程中,重新打开相同数据库。 SessionManager restored_manager("session_test.db"); // 构造时只恢复元数据;这里进一步读取并填充历史消息。 auto restored_session = restored_manager.getSession(session_id); // 先确认找到会话,再检查两条消息是否恢复。 assert(restored_session != nullptr); assert(restored_session->_message.size() == 2); }

以上展示的是实际测试的关键逻辑,省略了日志输出和命名空间限定。第一段结束时SessionManager以及它内部的DataManager一起析构;第二段创建了全新的对象,仍能取回原有消息,才能说明历史没有只停留在原来的_sessions内存缓存中。

这里还有一个需要认真区分的源码细节:SessionManager启动时只恢复会话元数据;getSession(session_id)才会进一步读取并填充历史消息。因此第二段调用getSession(),而不是仅检查getSessionCount(),是验证消息恢复不可缺少的一步。

7.2 运行测试前,要确认测试入口和断言行为

最新版TEST/test_LLM.cpp的main()是标准 Google Test 入口:

int main(int argc, char **argv) { myLOG::Logger::init_logger("test_LLM", "stdout", spdlog::level::info); // 解析 --gtest_filter 等参数,决定本次需要运行哪些测试。 testing::InitGoogleTest(&argc, argv); // 运行匹配过滤条件的用例,并把测试结果作为程序退出码返回。 return RUN_ALL_TESTS(); }

这个入口会解析 Google Test 的命令行参数,因此可以使用--gtest_filter选择本篇用例。仓库的TEST/CMakeLists.txt将构建类型设为Debug。安装齐备 Google Test、spdlog、fmt、jsoncpp、SQLite3、OpenSSL 等测试依赖后,在项目根目录运行:

cmake -S TEST -B build-test -DCMAKE_BUILD_TYPE=Debug cmake --build build-test -j2 # 先确认测试名称在可执行文件中 ./build-test/test_LLM --gtest_list_tests # 用新的工作目录隔离 session_test.db,避免复用已有测试数据 REPO_DIR="$(pwd)" TEST_RUN_DIR="$(mktemp -d)" ( cd "$TEST_RUN_DIR" "$REPO_DIR/build-test/test_LLM" \ --gtest_filter=SessionManagerTest.SessionPersistenceAndRestore )

测试使用相对路径session_test.db。把测试执行目录与构建目录分开,可以避免旧数据库干扰结果,也不会误删业务数据。新工作目录在TEST_RUN_DIR中;测试完成后可自行检查其中生成的 SQLite 文件。

这里还有一个不能漏掉的技术边界:原测试使用的不是全部 Google Test 断言,而是多处标准assert(...),并且把真正的写入调用直接放在表达式里:

// 定义 NDEBUG 时整行表达式都会被移除,addMessage 也不会执行。 assert(manager.addMessage(session_id, user_message));

如果构建中定义了NDEBUG,标准assert会把整个表达式移除,addMessage()也不会执行。这不是“少检查了一次结果”,而是测试行为本身发生变化。所以运行原测试时必须保证断言没有被禁用;不能以--gtest_filter能筛选到用例就推断消息一定执行了写入。对于更稳健的测试代码,应把写入和断言分开,但这不代表当前仓库已经修改成那种写法。

观察真实结果时,应同时确认恢复输出中的会话 ID、messages: 2,以及 Google Test 的实际状态。没有运行就不能把示意流程当作测试通过。

7.3 SessionManager 怎样接入下一次模型请求

上述测试用于验证会话能否保存与恢复;多轮模型调用还要把这些历史真正传给模型。当前ChatSDK::sendMessage()使用的顺序是:

根据 session_id 获取会话 → 得到 model_name ↓ addMessage(session_id, user) 先记录本轮用户输入 ↓ getHistoryMessages(session_id) 读取“旧历史 + 新问题” ↓ LLMManager::sendMessage(...) 交给选定的 Provider ↓ addMessage(session_id, assistant) 保存完整回复 ↓ updateSessionTimestamp(session_id)

这段调用链最值得记住的是两次addMessage()的位置:用户消息必须在取历史之前加入,助手消息必须在下次请求之前保存。不然第二轮拿到的历史就不完整。

项目还提供了IntegrationTest.SessionManagerWithLLMManager和IntegrationTest.SessionManagerWithLLMManagerStream。它们会把SessionManager与真实ChatGPTProvider、LLMManager连起来,并且需要有效的CHATGPT_KEY_API、可用端点和网络;两个测试实际都调用了sendMessageStream(),并不是一组“全量与流式”的对照测试。因此这里仅把它们作为扩展集成验证,不让外部模型服务决定本篇会话恢复功能是否完成。


写在最后

模型管理层已经解决“把请求交给哪个模型”,本篇则让“这段对话属于哪个会话、过去聊过什么”有了明确归属。

SessionManager用 ID 找到会话,维护Message历史与会话列表,并借助DataManager将消息写入 SQLite;即便管理器被销毁,只要数据库仍然存在,新的管理器仍能重新加载历史。跨实例恢复测试所检查的,正是这个模块真正承担的保存与恢复职责。

接下来还需要理解数据库内部究竟怎样工作:session和message两张表如何对应?预编译 SQL 怎样绑定参数、加载记录并级联删除?下一篇继续进入DataManager,专门拆解这些持久化细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询