🔥小叶-duck:个人主页

 ❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《Linux系统从入门到实践》《Linux网络从入门到实践》
《Qt 方寸极境》 《MySQL》

✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

一、LLMManager 类设计初衷

  1.1 当前已完成的模型接入能力

  1.2 现有使用方式暴露出来的问题

  1.3 LLMManager 管理层的设计目标

  1.4 核心实现思路

二、LLMManager 类架构设计

  2.1 LLMManager 核心组件定义

    2.1.1 LLMManager 类定位与职责

    2.1.2 头文件基础保护与依赖引入

    2.1.3 私有成员变量设计

  2.2 ModelInfo 元信息结构体

    2.2.1 结构体作用

    2.2.2 结构体定义与字段解析

  2.3 公有接口声明与功能说明

    2.3.1 模型生命周期接口

registerProvider 注册接口

initModel 初始化接口

    2.3.2 模型查询接口

getAvailableModels 获取可用列表

isModelAvailable 可用性检查

    2.3.3 消息交互接口

sendMessage 同步非流式调用

sendMessageStream 流式调用

三、智能指针选型优化:unique_ptr 设计分析

  3.1 方案对比:从 shared_ptr 到 unique_ptr

    3.1.1 初始方案:shared_ptr 的短板

    3.1.2 优化选型:unique_ptr 独占所有权

  3.2 架构层面的思考总结

    3.2.1 业务上的唯一映射关系

    3.2.2 语义与安全层面的优势

    3.2.3 性能差异要点

四、公有接口定义实现细节

  4.1 注册与初始化逻辑

    4.1.1 registerProvider 实现

 核心要点:为什么必须用 std::move?

进阶思考:右值的生命周期特性

    4.1.2 initModel 实现

  4.2 查询逻辑实现

    4.2.1 getAvailableModels 实现

    4.2.2 isModelAvailable 实现

  4.3 消息转发机制

    4.3.1 sendMessage(全量响应)

    4.3.2 sendMessageStream(流式响应)

五、 LLMManager 管理层单元测试

  5.1 测试整体思路

  5.2 测试代码分步解析

  5.3 测试小结

结束语


前言

      到目前为止,我们已经完成了多款大模型 Provider 的独立开发,先后实现了 DeepSeek、ChatGPT、Gemini 以及 Ollama 本地模型的接入逻辑,每一个 Provider 都继承自 LLMProvider 抽象基类,对外统一暴露了非流式、流式两套消息交互接口。

      在之前的开发模式里,我们都是直接手动创建各个 Provider 实例,自行维护模型配置、调用模型接口。这种方式在模型数量少、测试场景简单的时候足够直观好用,但随着接入的模型越来越多,我们会发现缺少一层统一的调度管理层:模型实例的创建、查找、状态管理、调用分发都需要业务代码自己处理,业务侧需要感知每一个 Provider 的类型,模型的管理逻辑散落在业务代码中,不利于统一管控、扩展和维护。

      为了解决这个问题,本章我们将设计并实现LLMManager管理模块。它将作为整个 SDK 的调度中枢,负责统一管理所有注册进来的模型 Provider,对外提供一套统一的调用入口。业务代码只需要向 LLMManager 传入模型标识和请求参数,不用关心底层是云端模型还是本地 Ollama、是哪一家厂商的 API,由 LLMManager 完成模型查找、接口转发。

      本章我们会从设计初衷入手,一步步完成 LLMManager 的架构定义、数据结构设计、接口声明,同时针对智能指针选型做所有权层面的优化分析,最后落地全部接口的实现代码。在完成这一层封装之后,我们这套手写大模型接入 SDK 的整体架构会更加清晰,真正把 “多模型适配、统一调度” 的能力落地。

一、LLMManager 类设计初衷

  1.1 当前已完成的模型接入能力

      我们已经基于LLMProvider抽象基类,完成了多套模型接入子类的开发,覆盖云端 API 与本地推理两条路线:

  • 云端模型接入:通过 HTTP API 对接 ChatGPT、Gemini、DeepSeek 等大模型服务,分别实现非流式、流式两套消息接口;
  • 本地模型接入:基于 Ollama 实现本地大模型的调用,适配 Ollama 独特的换行 JSON 分片流式协议;
  • 基础扩展逻辑:新增模型时,只需要派生新的 Provider 子类、实现基类纯虚接口,即可完成模型适配,底层调用逻辑由子类自行实现。

  1.2 现有使用方式暴露出来的问题

      虽然抽象基类已经解决了不同模型接口行为的差异化封装,但在上层业务调用侧,仍然存在明显的使用痛点:

  • 实例化逻辑重复:调用任意模型,都需要手动创建对应 Provider 智能指针、手动调用initModel初始化、再调用消息接口;不同模型的调用样板代码高度雷同;
  • 业务层强依赖具体子类:业务代码需要显式引入DeepSeekProvider、ChatGPTProvider等具体类型,切换模型时,需要修改实例化代码,模型越多,切换和维护成本越高;
  • 缺少统一的模型注册与查找机制:业务无法仅通过模型名称字符串来选择目标模型,没有集中的容器存放、管理所有已加载的模型实例;
  • 模型信息无法统一对外暴露:各个模型的描述信息、基础参数分散在各个子类内部,缺少统一容器给上层界面、日志模块读取展示模型列表。

  1.3 LLMManager 管理层的设计目标

      在 Provider 抽象层之上,再封装一层LLMManager统一管理器,借助哈希表完成模型名称到 Provider 实例的映射,实现下面核心目标:

  • 屏蔽各个 Provider 子类的实例化细节,上层业务只需要传入模型名字符串,即可获取对应的模型实例,不再直接依赖子类头文件;
  • 提供统一的模型注册、实例管理入口,用unordered_map保存模型名称和 Provider 实例的映射关系,支持按名字快速查找模型;
  • 配套维护模型信息哈希表,保存各个模型对应的ModelInfo描述,方便给前端、日志模块展示可用模型清单;
  • 遵循开闭原则:新增模型只需要新增 Provider 子类,管理器本身不需要改动,上层业务切换模型仅修改模型名字符串即可。

  1.4 核心实现思路

管理器内部维护两张哈希表:

  1. std::unordered_map<std::string, std::unique_ptr<LLMProvider>> _providers:key 为模型名称,value 为 Provider 实例,用来根据模型名拿到模型对象,调用对话接口;
  2. std::unordered_map<std::string, ModelInfo> _modelInfos:key 为模型名称,value 为模型描述信息,用于查询、展示模型基础信息。

      上层业务不再手动make_shared创建各个 Provider,全部交给 LLMManager 完成注册、管理、查找,业务仅通过模型名称完成交互。

二、LLMManager 类架构设计

  2.1 LLMManager 核心组件定义

    2.1.1 LLMManager 类定位与职责

      LLMManager 是整套 SDK 的顶层统一调度入口,负责托管所有 LLMProvider 实例的生命周期,完成模型注册、初始化状态维护、可用性校验、消息转发等工作。
      它在 Provider 抽象层之上,隔离上层业务与各个具体模型子类,业务侧只需要通过模型名称字符串完成交互。

    2.1.2 头文件基础保护与依赖引入

#ifndef LLM_MANAGER_H
#define LLM_MANAGER_H

#include "LLMProvider.h"
#include "common.h"

namespace ai_chat_sdk
{
class LLMManager
{
    // 成员声明
};
}
#endif // LLM_MANAGER_H
  • 使用#ifndef / #define / #endif传统头文件保护方案,规避头文件重复包含问题;
  • 仅依赖基类头文件LLMProvider.h和公共定义头文件common.h,不引入任何具体 Provider 子类头文件,实现业务层与模型实现类的解耦。

    2.1.3 私有成员变量设计

private:
    // 模型实例映射表:key=模型名称,value=Provider独占智能指针
    std::unordered_map<std::string, std::unique_ptr<LLMProvider>> _providers;
    // 模型元信息映射表:key=模型名称,value=ModelInfo元数据
    std::unordered_map<std::string, ModelInfo> _modelInfos;
  • _providers:持有全部模型实例的所有权,依托基类指针 + C++ 多态特性统一调用子类接口;采用unique_ptr自动管理内存,生命周期由管理器控制;
  • _modelInfos:和_providers一一配对,存放模型对外展示的元信息;上层只能读取这份元数据,不会直接拿到 Provider 实例,实现内部对象隔离;
  • 容器选用unordered_map哈希表,相比有序 map,模型名称查找的平均访问效率更高。

  2.2 ModelInfo 元信息结构体

    2.2.1 结构体作用

      ModelInfo 是标准化的模型元数据载体,用来统一描述模型基础属性,专门用于给上层展示模型列表、查看模型基础信息,不保存 API Key 这类敏感配置。

    2.2.2 结构体定义与字段解析

struct ModelInfo
{
    std::string _model_name;     // 模型唯一标识名称
    std::string _model_desc;     // 模型功能描述文本
    std::string _base_url;       // 模型服务端点地址
    std::string _provider;       // 模型厂商类型,如deepseek、openai、ollama
    bool _isAvailable = false;   // 模型可用标记,true代表初始化完成可调用

    // 构造函数,一次性填充基础元信息
    ModelInfo(const std::string &model_name = "", const std::string &model_desc = "",
              const std::string &base_url = "", const std::string &provider = "")
        : _model_name(model_name), _model_desc(model_desc), _base_url(base_url), _provider(provider)
    {
    }
};
  • _model_name:和管理器哈希表的 key 保持一致,是模型查找匹配的唯一标识;
  • _isAvailable:状态标记,和 Provider 内部IsAvailable()状态对齐,用来快速给前端展示模型是否就绪;
  • 构造函数支持默认参数,方便注册模型时快速构造元信息对象。

  2.3 公有接口声明与功能说明

    2.3.1 模型生命周期接口

registerProvider 注册接口
bool registerProvider(const std::string &modelName, std::unique_ptr<LLMProvider> provider);
  • 功能:将具体模型 Provider 实例注册到管理器容器,是模型接入统一管理的入口;
  • 参数:
    • modelName:模型唯一标识名称,作为后续调用查找的索引键;
    • provider:模型提供者实例独占指针,必须是LLMProvider子类实例;
initModel 初始化接口
bool initModel(const std::string &modelName, const std::unordered_map<std::string, std::string> &modelParams);
  • 功能:根据模型名称查找对应 Provider 实例,调用底层initModel完成配置加载与可用性校验;
  • 参数:
    • modelName:目标模型注册名称;
    • modelParams:模型初始化参数字典,包含 api-key、baseUrl 等配置项;
  • 返回值:初始化成功返回 true,失败返回 false;
  • 约束:传入的模型名称未注册时,直接返回失败。

    2.3.2 模型查询接口

getAvailableModels 获取可用列表
std::vector<ModelInfo> getAvailableModels();
  • 功能:返回所有已注册、且状态为可用的模型元信息集合;
  • 应用场景:客户端新建会话时的模型选择界面展示;
  • 返回值:仅收集_isAvailable = true的模型信息。
isModelAvailable 可用性检查
bool isModelAvailable(const std::string &modelName);
  • 功能:校验指定名称的模型是否存在、并且初始化完成处于可用状态;
  • 返回值:模型不存在或者未就绪,均返回 false;可用则返回 true;
  • 作用:消息发送前的前置校验,避免无效的模型调用。

    2.3.3 消息交互接口

sendMessage 同步非流式调用
std::optional<std::string> sendMessage(const std::string &modelName,
                                      const std::vector<Message> &messages,
                                      const std::unordered_map<std::string, std::string> &requestParams);
  • 功能:向指定模型同步发送对话消息,阻塞等待完整回复生成后返回;
  • 参数:
    • modelName:目标模型名称;
    • messages:对话历史消息列表,包含 role 角色与 content 内容;
    • requestParams:单次请求参数,如 temperature、max_tokens 等;
  • 返回值:std::optional,调用成功存放完整回复文本,失败为空。
sendMessageStream 流式调用
std::optional<std::string> sendMessageStream(const std::string &modelName,
                                             const std::vector<Message> &messages,
                                             const std::unordered_map<std::string, std::string> &requestParams,
                                             std::function<void(const std::string &, bool)> callback);
  • 功能:向指定模型发送流式请求,通过回调函数逐块接收模型返回的增量文本;
  • 回调参数:
    • 第一个参数:当前返回的文本片段 chunk;
    • 第二个 bool 参数:是否是最后一块数据,true 代表流式响应结束;
  • 返回值:std::optional,保存拼接完成的完整回复文本;
  • 统一契约:所有对外接口第一个入参均为modelName,管理器根据名字自动匹配对应的 Provider 实例;消息接口参数和底层LLMProvider保持一致,上层业务不需要关心底层是哪一个模型;返回值、回调契约完全复用基类定义,保证调用风格统一。

三、智能指针选型优化:unique_ptr 设计分析

  3.1 方案对比:从 shared_ptr 到 unique_ptr

    3.1.1 初始方案:shared_ptr 的短板

      早期设计考虑使用 std::shared_ptr<LLMProvider> 托管 Provider 实例,放到本项目的场景下并不适配,存在多处不合理之处:

  • 所有权场景不匹配:所有LLMProvider实例只在LLMManager内部使用,业务侧不会直接持有 Provider 对象,不存在多地方共享实例的业务需求。
  • 额外性能开销:shared_ptr内部维护引用计数,拷贝、销毁时会触发原子操作,带来内存与运行时的额外损耗。
  • 存在安全隐患:共享指针允许外部拷贝持有,一旦外部代码保留指针,有可能延长对象生命周期,甚至多线程并发操作同一个 Provider,引发状态竞争、逻辑错乱等难以复现的 bug,违背管理器独占管控实例的设计意图。

    3.1.2 优化选型:unique_ptr 独占所有权

最终选用 std::unique_ptr<LLMProvider>,和当前 SDK 架构的设计目标高度契合:

  • 遵循 RAII 独占语义:LLMManager是 Provider 实例唯一持有者,实例的创建、初始化、销毁全部由管理器统一管控。
  • 几乎零运行开销:编译后的运行效率和裸指针接近,没有引用计数、控制块的额外负担。
  • 编译期防护:C++ 语法层面直接禁用拷贝,从源头杜绝多处共享同一个实例的可能性,规避意外共享带来的生命周期风险。

  3.2 架构层面的思考总结

    3.2.1 业务上的唯一映射关系

      在管理器的实例映射表中,一个 modelName 键固定对应唯一的 Provider 实例。哪怕是同一个厂商下不同模型,也会注册成互相独立的 Provider 对象,各自持有独立的配置、会话状态,互不干扰。
      上层业务只和 LLMManager 交互,通过模型名字调用接口,不直接触碰底层 Provider,天然不需要共享所有权。

    3.2.2 语义与安全层面的优势

      unique_ptr 代表独占所有权,刚好匹配 LLMManager 作为模型资源 “大管家” 的定位。
      对比 shared_ptr,它直接从语法上阻断外部随意持有实例的可能,避免因为外部代码误操作,引发对象生命周期失控、多线程竞争问题,降低后期调试难度。

    3.2.3 性能差异要点

      shared_ptr 需要维护控制块,包含强引用、弱引用计数,为保证线程安全,增减计数要执行原子指令,持续带来开销。
      而 unique_ptr 没有引用计数机制,编译后没有额外运行时负担,在大量模型频繁注册销毁的场景下收益更明显。

四、公有接口定义实现细节

  4.1 注册与初始化逻辑

    4.1.1 registerProvider 实现

bool LLMManager::registerProvider(const std::string &modelName, std::unique_ptr<LLMProvider> provider)
{
    // 判断模型提供器是否为空
    if (!provider)
    {
        ERR("can not register nullptr provider, modelName: {}", modelName);
        return false;
    }
    if (_providers.find(modelName) == _providers.end())
    {
        // 模型未注册,注册模型提供器
        // unique_ptr防拷贝,只能通过move将provider的资源转移到_providers中
        _providers[modelName] = std::move(provider); // 移动语义,将provider的所有权转移到_providers中
    }
    if (_modelInfos.find(modelName) == _modelInfos.end())
    {
        // 模型信息未注册,注册模型信息
        // 这里只注册模型名称,其他信息如模型描述、模型是否可用等参数需要在initModel中初始化
        _modelInfos[modelName] = ModelInfo(model_name: modelName);
    }
    // 打印模型注册成功信息
    INFO("register provider success, modelName: {}", modelName);
    return true;
}
  • 实现要点:
    • 空指针校验:优先判断传入的unique_ptr是否为空,空则直接打错误日志并返回 false,避免后续非法访问。
    • 容器判重:分别对_providers、_modelInfos两个容器做查找判断,避免重复插入。
    • 移动语义重点:unique_ptr禁止拷贝,不能直接赋值传递,必须使用std::move把 provider 的资源所有权转移到_providers容器内。std::move本质是把左值强制转为右值引用,触发移动赋值,转移之后原 provider 会变成nullptr,后续不能再使用。
    • 数据分离:注册阶段只完成 Provider 实例存入、ModelInfo 基础占位,不会执行模型初始化,可用状态、模型描述等信息,延后到initModel阶段填充。
    • 返回值:使用 bool 返回值,方便上层判断注册是否成功。
 核心要点:为什么必须用 std::move?

1. 为什么直接“传”(拷贝)不行?

  • 左值特性:参数 provider 本身是一个具名的左值(lvalue)。

  • unique_ptr 的设计原则:它的名字叫独占(unique),设计初衷就是禁止拷贝。如果允许拷贝,就会出现两个指针指向同一块堆内存,析构时会导致双重释放(Double Free),程序直接崩溃。

  • 因此,标准库源码中,unique_ptr 的拷贝构造函数和拷贝赋值运算符被显式标记为 = delete。直接赋值会引发编译错误。

2. 为什么“变成右值”就可以?

  • 移动语义:C++11 引入了移动语义,允许“转移”资源所有权。

  • std::move 的本质:它仅仅是一个 static_cast<T&&>,把左值强转成了右值。它本身不移动任何数据,但告知编译器:“我保证以后不再使用这个变量了,你可以放心大胆地把它掏空”。

  • 函数匹配:转为右值后,代码会匹配到 unique_ptr 的移动赋值运算符(接收右值引用的函数),从而合法、高效地完成指针所有权的交接。

3. 被 Move 之后会发生什么?

  • 底层操作:移动赋值内部会执行 provider.release(),将原指针交出,并把原对象(provider)置为 nullptr。

  • 后续使用:如果你在 std::move(provider) 之后又使用了它(比如 provider->init()),会触发空指针解引用,导致程序直接崩溃(Segmentation Fault)。

  • C++ 标准保证:被移动后的 unique_ptr 必定是 nullptr。如果需要再用,只能重新给它赋值。

进阶思考:右值的生命周期特性

关于“右值(临时对象)生命很短,转瞬即逝”的误区:

  1. 真正的临时对象(纯右值):比如 std::make_unique<...>(),它的生命周期由 C++ 语言语法规定,在当前行(完整表达式)结束后自动销毁。移动构造只是在这个对象临死前,把它的资源“合法地抢劫”出来。

  2. 被 std::move 的具名变量(将亡值):它的生命周期并没有变短,内存依然在栈上,直到离开作用域才释放。只是内部的资源(指针)被移动构造转移走了。

  3. 结论:不是移动构造让右值变短命,而是移动构造专门为即将销毁或主动放弃资源的对象设计的高效接管机制。移动操作改变的是对象的状态(State),而非对象的生命周期(Lifetime)。

补充:shared_ptr 的循环引用回顾

    4.1.2 initModel 实现

bool LLMManager::initModel(const std::string &modelName, const std::unordered_map<std::string, std::string> &modelParams)
{
    auto it = _providers.find(modelName);
    if (it == _providers.end())
    {
        ERR("model provider not found, must register provider first, modelName: {}", modelName);
        return false;
    }
    // 多态调用子类模型提供器的初始化接口,初始化模型
    bool ret = it->second->initModel(modelParams);
    if (ret)
    {
        // 初始化模型成功,更新模型信息
        _modelInfos[modelName]._model_desc = it->second->getModelDesc();
        _modelInfos[modelName]._isAvailable = it->second->IsAvailable();
        INFO("init model success, modelName: {}", modelName);
    }
    return ret;
}
  • 多态原理:基类指针指向子类实例,调用子类重写的initModel虚函数,实现同一个接口适配 DeepSeek、ChatGPT、Ollama 等不同模型。
  • 状态同步:初始化成功之后,主动从 Provider 中读取模型描述、可用性标记,同步更新到_modelInfos元信息容器;初始化失败则不修改状态,保留不可用标记。
  • 容错设计:找不到 provider 直接打错误日志返回 false,不抛出异常,上层可以自主处理失败场景。

  4.2 查询逻辑实现

    4.2.1 getAvailableModels 实现

std::vector<ModelInfo> LLMManager::getAvailableModels()
{
    std::vector<ModelInfo> availableModels;
    // 遍历所有模型信息,筛选出所有可用模型,初始化失败的模型无需展示
    for(auto &pair : _modelInfos)
    {
        if(pair.second._isAvailable)
        {
            availableModels.push_back(pair.second);
        }
    }
    return availableModels;
}
  • 实现逻辑:遍历_modelInfos元信息容器,过滤出_isAvailable=true的模型,返回模型信息列表,一般用于前端界面展示模型选择列表。
  • 设计原则:只读取_modelInfos,完全不访问_providers,做到信息隔离,对外只暴露模型元数据,隐藏底层 Provider 实例。

    4.2.2 isModelAvailable 实现

bool LLMManager::isModelAvailable(const std::string &modelName)
{
    // 检查模型信息是否已注册
    if(_modelInfos.find(modelName) == _modelInfos.end())
    {
        return false;
    }
    // 检查模型是否已初始化
    return _modelInfos[modelName]._isAvailable;
}
  • 容错处理:先判断模型是否注册,不存在直接返回 false;存在则返回记录的可用性标记。
  • 用途:作为消息发送前的前置校验工具,统一收口可用性判断逻辑,减少重复代码。

  4.3 消息转发机制

      LLMManager 本身不处理 HTTP 请求、JSON 封装、SSE 解析等业务逻辑,只做路由转发、前置校验,所有和模型相关的业务逻辑下沉到各个 Provider 子类。

    4.3.1 sendMessage(全量响应)

std::optional<std::string> LLMManager::sendMessage(const std::string &modelName,
                                                   const std::vector<Message> &messages,
                                                   const std::unordered_map<std::string, std::string> &requestParams)
{
    // 检查模型是否可用(包括了检测模型是否已注册以及初始化)
    if(!isModelAvailable(modelName))
    {
        ERR("model not available, modelName: {}", modelName);
        return std::nullopt;
    }
    return _providers[modelName]->sendMessage(messages, requestParams);
}
  • 职责边界:
    • 调用isModelAvailable做前置校验,模型不可用直接返回std::nullopt,并打印错误日志;
    • 校验通过后,直接转发调用对应 provider 的sendMessage;
    • 请求参数、消息列表原样透传给 provider,LLMManager 不感知不同厂商 API 的参数差异。

    4.3.2 sendMessageStream(流式响应)

std::optional<std::string> LLMManager::sendMessageStream(const std::string &modelName,
                                                         const std::vector<Message> &messages,
                                                         const std::unordered_map<std::string, std::string> &requestParams,
                                                         std::function<void(const std::string &, bool)> callback)
{
    // 检查模型是否可用(包括了检测模型是否已注册以及初始化)
    if(!isModelAvailable(modelName))
    {
        ERR("model not available, modelName: {}", modelName);
        return std::nullopt;
    }
    return _providers[modelName]->sendMessageStream(messages, requestParams, callback);
}
  • 实现逻辑:和全量消息接口框架一致,额外把回调函数透传给底层 Provider;
  • 回调作用:底层 Provider 收到流式分片内容时,通过这个回调把片段逐层抛给上层业务,实现逐字输出效果。

五、 LLMManager 管理层单元测试

      说明:本测试属于接口可用性验证测试,不做复杂业务校验,目标是验证 LLMManager 对外暴露的注册、初始化、消息发送接口流程能否正常跑通、无崩溃、接口调用链路正常,提前捕获内存、多态、参数传递等底层异常,为后续业务层使用管理层提供基础保障。

  5.1 测试整体思路

我们基于 GTest 编写LLMManagerTest.sendMessage用例,一条测试用例串联完整链路:

  1. 构造LLMManager管理实例
  2. 向管理器注册不同厂商的 Provider(DeepSeekProvider、GeminiProvider、OllamaLLMProvider),借助移动语义完成 unique_ptr 所有权移交
  3. 对每一个已注册模型,传入对应配置(api-key、base_url)执行initModel初始化
  4. 分别组装各模型专属的请求参数(temperature、max_tokens 等)
  5. 构造统一格式的消息数组,调用 LLMManager 的sendMessage,传入模型名称、消息列表、请求参数
  6. 断言返回值有效,打印返回结果,验证整个调用链路没有崩溃、空指针、参数解析异常

  5.2 测试代码分步解析

TEST(LLMManagerTest, sendMessage)
{
    // 创建LLMManager对象
    LLMManager llmManager;
    bool res;

    // ========== 1. 注册各个模型对应的Provider ==========
    res = llmManager.registerProvider("deepseek-flash", std::make_unique<DeepSeekProvider>());
    // 1.1 检查deepseek-flash模型是否注册成功
    ASSERT_TRUE(res);
    res = llmManager.registerProvider("gemini-3.6-flash", std::make_unique<GeminiProvider>());
    // 1.2 检查gemini-3.6-flash模型是否注册成功
    ASSERT_TRUE(res);
    // 1.2 检查本地deepseek-r1:7b模型是否注册成功
    res = llmManager.registerProvider("deepseek-r1:7b", std::make_unique<OllamaLLMProvider>());
    // 1.3 检查deepseek-r1:7b模型是否注册成功
    ASSERT_TRUE(res);
  • 知识点:registerProvider接收std::unique_ptr<LLMProvider>,这里make_unique生成临时右值,触发移动语义,所有权转移给 LLMManager,避免拷贝、内存泄漏;
  • 断言ASSERT_TRUE(res):注册失败直接终止当前测试,快速发现注册逻辑、map 存储的问题。
    // ========== 2. 逐个初始化模型(传入模型配置:api-key、base_url) ==========
    // 2.1 初始化deepseek-flash模型
    std::unordered_map<std::string, std::string> modelConfig;
    modelConfig["api_key"] = getenv("deepseek_apikey") ? getenv("deepseek_apikey") : "";
    modelConfig["base_url"] = "https://api.deepseek.com";
    res = llmManager.initModel("deepseek-flash", modelConfig);
    ASSERT_TRUE(res);

    // 2.2 初始化gemini-3.6-flash模型
    modelConfig["api_key"] = getenv("gemini_apikey") ? getenv("gemini_apikey") : "";
    modelConfig["base_url"] = "https://generativelanguage.googleapis.com";
    res = llmManager.initModel("gemini-3.6-flash", modelConfig);
    ASSERT_TRUE(res);

    // 2.3 初始化deepseek-r1:7b模型
    modelConfig["modelName"] = "deepseek-r1:7b";
    modelConfig["base_url"] = "http://192.168.159.1:11434";
    modelConfig["modelDesc"] = "本地部署deepseek-r1:7b模型, 是一个参数规模为7000万的预训练语言模型, 能够提供强大的文本生成能力和多任务处理能力";
    res = llmManager.initModel("deepseek-r1:7b", modelConfig);
    ASSERT_TRUE(res);
  • 区分【模型配置】和【请求参数】:模型配置属于 Provider 全局配置(api-key、base_url),在initModel阶段一次性设置;
  • 环境变量读取密钥:避免硬编码密钥,适合单元测试环境;
  • 每个模型独立初始化,不同模型互不干扰,验证 LLMManager 按 modelName 隔离各个 Provider 实例。
    // ========== 3. 组装每个模型独立的请求参数(单次请求参数) ==========
    // 3.1 构建deepseek-flash模型请求参数配置
    std::unordered_map<std::string, std::string> deepseekReqConfig;
    deepseekReqConfig["model"] = "deepseek-flash";
    deepseekReqConfig["temperature"] = "0.7";
    deepseekReqConfig["max_tokens"] = "2048";
    deepseekReqConfig["thinking_type"] = "disabled";
    deepseekReqConfig["reasoning_effort"] = "high";

    // 3.2 构建gemini-3.6-flash模型请求参数配置
    std::unordered_map<std::string, std::string> geminiReqConfig;
    geminiReqConfig["model"] = "gemini-3.6-flash";
    geminiReqConfig["temperature"] = "0.7";
    geminiReqConfig["max_tokens"] = "2048";

    // 3.3 构建deepseek-r1:7b模型请求参数配置
    std::unordered_map<std::string, std::string> ollamaConfig;
    ollamaConfig["temperature"] = "0.7";
    ollamaConfig["max_tokens"] = "2048";

    // 统一对话消息,所有模型共用同一个用户提问
    std::vector<Message> messages;
    messages.push_back({"user", "你好, 请简单介绍一下你自己"});
  • 请求参数是每次调用 sendMessage 时动态传入,不属于模型初始化阶段;
  • 不同厂商 API 支持的参数不一样,这里也验证 LLMManager 可以透传参数给对应 Provider,,由各个 Provider 自行解析。
    // ========== 5. 调用LLMManager统一发送消息接口,获取返回结果 ==========
    // // 5.1 调用deepseek-flash模型发送消息
    // auto response = llmManager.sendMessage("deepseek-flash", messages, deepseekReqConfig);
    // // 5.2 调用gemini-3.6-flash模型发送消息
    // auto response = llmManager.sendMessage("gemini-3.6-flash", messages, geminiReqConfig);
    // 5.3 调用deepseek-r1:7b模型发送消息
    auto response = llmManager.sendMessage("deepseek-r1:7b", messages, ollamaConfig);

    ASSERT_TRUE(response.has_value());
    // INFO("deepseek-flash response: {}", response.value());
    // INFO("gemini-3.6-flash response: {}", response.value());
    INFO("deepseek-r1:7b response: {}", response.value());
}
  • sendMessage是 LLMManager 对外统一入口,上层业务不需要感知底层是哪一个 Provider、是云端还是本地 Ollama;
  • 断言只判断has_value(),只要拿到返回值就代表链路正常,不校验返回文本是否符合预期,契合 “接口可用性测试” 定位;
  • 日志打印用于人工核验,运行时可以看到网络请求、服务返回内容,方便调试。

测试运行结果:

  5.3 测试小结

作用:冒烟测试、接口可用性测试

  • 验证:注册、初始化、消息转发、多态调用、参数透传、内存管理是否正常
  • 不做:模型回答正确性、token 数量、业务逻辑校验
  • 价值:后续新增模型、修改 LLMManager 调度逻辑、修改 Provider 代码时,跑这个测试可以快速发现空指针、所有权转移错误、参数漏传、http 崩溃等问题,防止管理层接口损坏。

      重点:这套测试不校验大模型返回内容语义是否正确,只校验 SDK 接口流程是否正常、无异常退出。

结束语

      本章我们完成了 LLMManager 管理层的完整设计与代码落地,从最初发现多模型直接调用带来的管理痛点出发,搭建起 SDK 的统一调度中枢。

      我们梳理了 LLMManager 的定位职责、配套的 ModelInfo 元信息结构,并且针对智能指针的所有权模型做了选型优化,选用 unique_ptr 明确 Provider 实例的独占语义,规避共享指针带来的生命周期隐患。随后依次实现了模型注册、初始化、状态查询、消息转发等全部对外接口,所有底层 Provider 的差异都被 Manager 层屏蔽,业务侧只需要面向统一接口进行开发。

      到这里,我们 SDK 的整体分层架构就完整成型了:抽象基类 LLMProvider 定义接入契约,各个厂商 Provider 负责对接各自的 API 规范,LLMManager 统一管控所有模型实例并分发请求。后续新增模型时,我们只需要新增对应的 Provider 子类,注册到 Manager 中即可直接使用,不需要改动上层业务调用代码,充分体现了策略模式带来的扩展性优势。

Logo

更多推荐