资讯动态

从零实现 C++ AI 大模型接入 SDK(十):SessionManager 会话管理与多轮对话实现

发布时间:2026/10/23 6:28:29 来源:尧图企业网站定制
目录前言一、为什么模型调用之后还需要会话管理1.1 单次模型请求不会自动拥有聊天记忆1.2 LLMManager 和 SessionManager 分别解决什么二、一个会话由什么组成2.1 Message 表示一条消息2.2 Session 表示一段连续对话2.3 SessionManager 内部保存什么三、创建会话先生成 ID再建立会话对象3.1 为什么每段会话都需要唯一 ID3.2 createSession() 实际做了两件事3.3 为什么缓存的会话使用 shared_ptr四、添加消息与恢复历史多轮对话真正的核心4.1 addMessage() 为什么先复制消息4.2 获取历史为什么有两条路径4.3 构造 SessionManager 时恢复什么五、多线程下为什么既要 mutex 又要 atomic5.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::vectorMessage messages, // 具体 Provider 负责解释温度、输出长度等请求参数。 const std::mapstd::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 表示一段连续对话同一文件中还定义了Sessionstruct Session { std::string _id; // 会话标识用来查找和操作这段对话。 std::string _model_name; // 当前会话绑定的模型名称。 std::vectorMessage _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::vectorMessage因为聊天历史本来就是由多条消息构成的有序序列。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_ptrSession getSession(const std::string session_id); bool addMessage(const std::string session_id, const Message message); std::vectorMessage getHistoryMessages(const std::string session_id) const; std::vectorstd::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_mapstd::string, std::shared_ptrSession _sessions; // 保护方法中明确划定的临界区不自动保护外部对 Session 的访问。 mutable std::mutex _mutex; // 只负责本实例的 ID 计数不是当前会话数量。 std::atomicstd::int64_t _session_counter{0}; // 持久化成员负责 SQLite 读写。 DataManager _data_manager; };这里暂时抓住四个核心成员_sessions用会话 ID 查找对象。用unordered_map是因为我们经常按 ID 查找会话而不是按创建顺序遍历。std::shared_ptrSession让管理器和调用方能够持有同一个会话对象不需要每次获取都复制整段历史。_mutex和_session_counter分别负责保护容器的复合操作、生成会话 ID 所需的计数。两者不是替代关系。_data_manager是一个DataManager成员负责与 SQLite 交互。管理器从构造开始就会与持久化层协作。这里先关注它怎样组织会话数据库表结构和 SQL 实现会在下一篇展开。三、创建会话先生成 ID再建立会话对象3.1 为什么每段会话都需要唯一 ID如果应用只有一个聊天窗口存一个std::vectorMessage似乎也能工作。但一旦用户打开多个会话业务层需要一个稳定的标识告诉管理器我现在要操作的是哪一段对话这正是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_ptrSession session; { // 内存阶段把生成 ID、创建对象和插入缓存放在同一临界区。 std::lock_guardstd::mutex lock(_mutex); session_id generateSessionId(); const std::time_t now std::time(nullptr); session std::make_sharedSession(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_sharedSession(model_name) _sessions.emplace(...) ↓ 持久化阶段 _data_manager.insertSession(*session)为什么返回std::string而不是返回Session因为后续上层只需要保存 ID就能通过管理器找到对应会话避免每个业务入口都直接持有完整的消息容器。不过理解源码时还要留意它的返回约定即使数据库插入失败当前函数仍会记录错误并返回已经创建的会话 ID。因此“返回非空 ID”说明内存创建流程走到了末尾不能单独作为数据库成功写入的证据。3.3 为什么缓存的会话使用 shared_ptr管理器内部保存的是// 管理器与调用方共享 Session 的生命周期共享指针不自动保护对象字段。 std::unordered_mapstd::string, std::shared_ptrSession _sessions;getSession()返回的也是std::shared_ptrSession调用方获取后可以直接读取它的_model_name或消息列表而不必复制完整Session。与第九篇LLMManager持有的unique_ptrILLMProvider不同这里选择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_guardstd::mutex lock(_mutex); const auto it _sessions.find(session_id); if (it _sessions.end()) { return false; } std::shared_ptrSession 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_guardstd::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; }整个流程可以拆成四步查找目标会话找不到就返回false。为消息生成 ID、必要时补时间追加到_message同步更新会话时间。释放_mutex后让DataManager将同一份stored_message写入数据库。数据库方法返回失败时尝试删除内存尾部刚插入的那条消息并恢复之前的时间戳。这里有一个值得注意的细节写内存和写数据库使用同一份带有_id、_timestamp的stored_message避免两边分别生成 ID 或时间戳造成对应不上。这里需要分清两层保证。DataManager::insertMessage()已经使用 SQLite 事务把“插入消息”和“更新会话时间”放在同一次提交中任一步骤失败都会进入回滚处理。这解决的是数据库内部两条 SQL 的原子性。而SessionManager的内存追加发生在调用数据库之前SQLite 事务并不能直接撤销 C 容器里的修改。数据库方法返回失败后管理器还要单独检查尾部消息的 ID再尝试恢复内存。数据库内部有事务不等于内存与数据库已经成为同一个不可分割的操作。尤其在并发操作交错时只撤销匹配的末尾消息仍不足以保证所有场景的一致性。事务的具体 SQL 写法留到下一篇展开。4.2 获取历史为什么有两条路径添加完消息真正调用模型之前需要拿到该会话的完整消息列表。getHistoryMessages()的实现并不复杂std::vectorMessage SessionManager::getHistoryMessages( const std::string session_id ) const { { std::lock_guardstd::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); }先查内存。如果已经找到非空消息列表就直接返回一个vectorMessage副本如果找不到会话或者找到的消息列表仍然为空则到 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_guardstd::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或一份vectorMessage就可能出现数据竞争。5.1 mutex 保护的是一组连续操作例如addMessage()必须连续完成查找会话、生成消息序号、追加消息、更新时间戳。这几步依赖同一份共享状态所以代码使用{ // 进入作用域时加锁离开作用域时由 lock_guard 自动解锁。 std::lock_guardstd::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::atomicstd::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::vectorstd::string getAllSessions() const;内部不是直接遍历unordered_map就返回因为哈希表本身没有按时间排序的保证。实现先从_data_manager.getAllSessions()读取数据库元数据再复制一份_sessions到临时容器把数据库里存在但临时容器缺少的会话加入进去。然后把会话指针放入vector排序最后只提取会话 ID。它的主要比较规则如下std::sort(ordered_sessions.begin(), ordered_sessions.end(), [](const std::shared_ptrSession lhs, const std::shared_ptrSession 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; });读这个比较函数时按三层判断就够了置顶与未置顶的会话相比置顶排前面。两个会话都置顶且置顶时间不同时最近置顶的排前面。其他情况按_updated_at从新到旧排序。最后返回vectorstring让调用方拿到一组可继续查询的 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_guardstd::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_TYPEDebug 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_filterSessionManagerTest.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专门拆解这些持久化细节。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑