从零实现 C++ AI 大模型接入 SDK(九):LLMManager 统一模型管理与请求路由

目录

前言

[一、Provider 已经统一,为什么还需要 LLMManager](#一、Provider 已经统一,为什么还需要 LLMManager)

[1.1 统一接口不等于统一管理](#1.1 统一接口不等于统一管理)

[1.2 LLMManager 的职责边界](#1.2 LLMManager 的职责边界)

[二、先看 LLMManager 的接口和内部结构](#二、先看 LLMManager 的接口和内部结构)

[2.1 头文件提供哪些能力](#2.1 头文件提供哪些能力)

[2.2 为什么需要两张 map](#2.2 为什么需要两张 map)

[三、注册 Provider:为什么使用 unique_ptr 和 std::move](#三、注册 Provider:为什么使用 unique_ptr 和 std::move)

[3.1 unique_ptr 表示谁负责对象的生命周期](#3.1 unique_ptr 表示谁负责对象的生命周期)

[3.2 registerProvider() 怎样完成注册](#3.2 registerProvider() 怎样完成注册)

[3.3 Ollama 为什么需要带模型名的构造函数](#3.3 Ollama 为什么需要带模型名的构造函数)

四、初始化与模型可用状态:注册成功还不够

[4.1 initModel() 为什么仍然只需要模型名称](#4.1 initModel() 为什么仍然只需要模型名称)

[4.2 什么叫"可用"](#4.2 什么叫“可用”)

五、查询模型状态:两张表怎样配合工作

[5.1 获取所有可用模型](#5.1 获取所有可用模型)

[5.2 检查指定模型是否可用](#5.2 检查指定模型是否可用)

六、全量和流式请求怎样统一路由

[6.1 sendMessage() 只做必要的检查与转发](#6.1 sendMessage() 只做必要的检查与转发)

[6.2 sendMessageStream() 为什么同样不需要协议判断](#6.2 sendMessageStream() 为什么同样不需要协议判断)

[6.3 一次请求真正经过了哪些层](#6.3 一次请求真正经过了哪些层)

七、用现有测试验证管理层的调用链

[7.1 全量测试:同样的 Provider,不同的调用入口](#7.1 全量测试:同样的 Provider,不同的调用入口)

[7.2 流式测试:模型路由不变,只是多了回调](#7.2 流式测试:模型路由不变,只是多了回调)

写在最后


前言

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

项目源码:

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

前面几篇,我们分别完成了四种模型后端的接入:

  • DeepSeekProvider → DeepSeek 云端接口;
  • ChatGPTProvider → OpenAI Responses API;
  • GeminiProvider → Gemini 兼容接口;
  • OllamaLLMProvider → 本地 Ollama 服务。

虽然它们使用的认证方式、请求字段、响应格式和流式协议不尽相同,但对外已经遵守同一个 ILLMProvider 接口。

这说明我们解决了第一个问题:无论底层接的是哪一种模型,都能通过同一组函数发起请求。

不过,真正准备让业务层同时使用这四种模型时,又会遇到新的麻烦。

假如用户在页面上选中了 gpt-5.5,上层代码还得知道对应的是 ChatGPTProvider;切换到 deepseek-chat,又要找到 DeepSeekProvider。初始化、检查状态、发送消息时,都要做类似的选择。

我们总不能每增加一个 Provider,就在业务代码里再写一组 if/else。

第八篇结尾已经留下了这个问题:四个 Provider 都会调用了,接下来由谁统一保存它们、管理它们,并把请求送到正确的模型?

这一篇,我们就在 ILLMProvider 之上增加一层 LLMManager。

它不重新实现 HTTP,也不重新解析 SSE 或 NDJSON。它要解决的是另一件事:

cpp 复制代码
模型名称
    ↓
找到对应 Provider
    ↓
确认已经初始化、当前可用
    ↓
通过统一接口转发全量 / 流式请求

一、Provider 已经统一,为什么还需要 LLMManager

1.1 统一接口不等于统一管理

回顾前面已经定义好的 ILLMProvider。它要求具体 Provider 实现这些能力:

cpp 复制代码
virtual bool initModel(
    const std::map<std::string, std::string> model_config
) = 0;

virtual bool isAvailable() = 0;
virtual std::string getModelName() const = 0;
virtual std::string getModelDesc() const = 0;

virtual std::string sendMessage(
    const std::vector<Message>& messages,
    const std::map<std::string, std::string>& request_param
) = 0;

virtual std::string sendMessageStream(
    const std::vector<Message>& messages,
    const std::map<std::string, std::string>& request_param,
    std::function<void(const std::string&, bool)> callback
) = 0;

有了这些虚函数,不管基类指针实际指向哪一个 Provider,我们调用的函数形式都一样。

但是这只解决了怎么调用一个已经找到的 Provider,并没有回答:

  • 多个 Provider 对象放在哪里?

  • 给出一个模型名称,怎么找到对应对象?

  • 一个 Provider 被创建后,是否就已经能调用?

  • 用户想查看当前可用的模型列表,应该向谁查询?

如果把这些工作都交给业务层,逻辑可能会变成:

cpp 复制代码
// 以下只是说明问题的伪代码,不是项目中的实际实现。
if (model_name == "deepseek-chat") {
    return deepseek->sendMessage(messages, params);
} else if (model_name == "gpt-5.5") {
    return chatgpt->sendMessage(messages, params);
} else if (model_name == "gemini-3.5-flash") {
    return gemini->sendMessage(messages, params);
} else if (model_name == "deepseek-r1:1.5b") {
    return ollama->sendMessage(messages, params);
}

这种分支在全量请求里写一遍,流式请求、初始化、状态检查里可能还要再写一遍。新增模型时,许多位置都需要跟着修改。

我们真正希望业务层表达的是:

cpp 复制代码
std::string reply = manager.sendMessage(
    model_name,
    messages,
    request_param
);

这里的 model_name 是模型的身份,messages 是要发送的消息,request_param 是这一轮调用的参数。至于应当进入哪一个 Provider,由管理层负责。

1.2 LLMManager 的职责边界

LLMManager 不是第五种 Provider。

它既不继承 ILLMProvider,也不向 DeepSeek、ChatGPT、Gemini、Ollama 重新发送一份自己构造的 HTTP 请求。

这层设计把两类变化隔开了:

  • **具体 Provider 处理协议差异:**它们负责各自的初始化配置、请求构造、网络通信、响应解析与流式事件处理。
  • **LLMManager 处理模型管理问题:**它负责保存 Provider、建立模型名索引、维护模型状态,以及把调用转交给选定的 Provider。

因此,增加一个 Provider 时,不需要为了选择它而在 LLMManager::sendMessage() 里添加新的模型类型判断。

这正是统一接口配合多态真正发挥价值的地方。


二、先看 LLMManager 的接口和内部结构

本篇直接围绕当前工程的两个文件展开:

cpp 复制代码
SDK/
├── include/
│   └── LLMManager.h
└── src/
    └── LLMManager.cpp

2.1 头文件提供哪些能力

LLMManager.h 的核心接口如下:

cpp 复制代码
namespace ai_chat_sdk {

class LLMManager {
public:
    bool registerProvider(
        std::unique_ptr<ILLMProvider> provider
    );

    bool initModel(
        const std::string& model_name,
        const std::map<std::string, std::string>& model_config
    );

    std::vector<ModelInfo> getAvailableModels() const;

    bool isModelAvailable(
        const std::string& model_name
    ) const;

    std::string sendMessage(
        const std::string& model_name,
        const std::vector<Message>& messages,
        const std::map<std::string, std::string>& request_param
    );

    std::string sendMessageStream(
        const std::string& model_name,
        const std::vector<Message>& messages,
        const std::map<std::string, std::string>& request_param,
        std::function<void(const std::string&, bool)> callback
    );

private:
    std::map<std::string, std::unique_ptr<ILLMProvider>> _providers;
    std::map<std::string, ModelInfo> _models;
};

} // namespace ai_chat_sdk

可以把这六个公开接口分成三组:

阶段 接口 解决的问题
准备 registerProvider()、initModel() 谁负责这个模型,以及怎样初始化
查询 getAvailableModels()、isModelAvailable() 当前哪些模型可以被调用
使用 sendMessage()、sendMessageStream() 这次请求应当转发给谁

这里有一个值得留意的变化:registerProvider() 没有要求外部额外传一个模型名称 。管理层会直接向 Provider 询问 getModelName(),以它返回的字符串作为索引。

同一个模型名称因此应当代表同一个被注册的 Provider。

2.2 为什么需要两张 map

继续看私有成员:

cpp 复制代码
std::map<std::string, std::unique_ptr<ILLMProvider>> _providers;
std::map<std::string, ModelInfo> _models;

两张表的 key 都是 std::string 类型的模型名称,但保存的内容不同。

_providers 保存的是可以真正执行请求的对象:

cpp 复制代码
"deepseek-chat"     → DeepSeekProvider
"gpt-5.5"           → ChatGPTProvider
"gemini-3.5-flash"  → GeminiProvider
"deepseek-r1:1.5b"  → OllamaLLMProvider

表的 value 是 std::unique_ptr<ILLMProvider>,所以这里能存入不同的具体 Provider,同时统一通过基类接口调用。

_models 则保存便于查询和展示的模型信息 。这些信息已经在 common.h 中定义:

cpp 复制代码
struct ModelInfo {
    std::string _model_name;
    std::string _desc;
    std::string _provider;
    std::string _endpoint;
    bool _isAvailable;
ModelInfo(
    const std::string&amp; model_name,
    const std::string&amp; desc = "",
    const std::string&amp; provider = "",
    const std::string&amp; endpoint = ""
)
    : _model_name(model_name)
    , _desc(desc)
    , _provider(provider)
    , _endpoint(endpoint)
    , _isAvailable(false)
{}
};

也就是说,_providers 更关心"这个模型由哪个对象执行",_models 更关心"这个模型叫什么、怎么描述、是否被标记为可用"。

为什么不在查询列表时每次都去遍历 Provider、重新拼装信息?

当然可以设计成那样,但当前工程选择了两张映射表:行为对象与对外模型信息分开保存 。这样获取可用列表时只需要查看 _models,发送请求时再查 _providers。

需要注意两点:

第一,当前 registerProvider() 只填入模型名称、描述和初始可用状态,_provider、_endpoint 虽然存在于 ModelInfo 中,但管理层没有自动填充这两个字段。不能因为结构体定义了字段,就以为接口已经提供了完整的服务商和地址元数据。

第二,状态存放在另一张表,就意味着管理层必须主动同步状态 。否则,实际 Provider 的状态和 _models 中缓存的状态可能不一致。这个问题在后面的初始化实现里会再次出现。


三、注册 Provider:为什么使用 unique_ptr 和 std::move

LLMManager 需要长期保存四种 Provider。如果对象由上层创建,却一直留在上层,那么模型管理的所有权仍然没有真正交给管理层。

当前实现采用 std::unique_ptr,让管理层独占这些 Provider 对象。

3.1 unique_ptr 表示谁负责对象的生命周期

std::unique_ptr<T> 是一个独占所有权的智能指针。同一时刻,它所管理的对象只能由一个 unique_ptr 拥有;当这个智能指针销毁时,对象会被自动释放。

例如:

cpp 复制代码
std::unique_ptr<ILLMProvider> provider =
    std::make_unique<DeepSeekProvider>();

这里创建的是具体的 DeepSeekProvider,但变量类型使用统一的 ILLMProvider 基类指针。因为基类提供了虚析构函数:

cpp 复制代码
virtual ~ILLMProvider() = default;

所以通过基类智能指针释放对象时,可以正确执行派生类的析构过程。

注册时再调用:

cpp 复制代码
manager.registerProvider(std::move(provider));

unique_ptr 不能复制;std::move 让这里的智能指针能够转移所有权。std::move 本身不移动底层 Provider 对象,更不是重新创建一个 Provider。

这里的意义不是为了少写一次 delete,而是把 Provider 的生命周期统一交给 Manager。从此,上层无需分别管理四种 Provider 对象什么时候释放。

对于成功转移的调用,原变量 provider 不再拥有对象,后面不应该继续通过它解引用。要再次使用模型,应当从 Manager 的公开接口进入。

3.2 registerProvider() 怎样完成注册

当前 LLMManager.cpp 的注册逻辑可以按五步来看:

cpp 复制代码
bool LLMManager::registerProvider(
    std::unique_ptr<ILLMProvider> provider
)
{
    if (!provider) {
        ERR("cannot register a null provider");
        return false;
    }

    const std::string model_name = provider->getModelName();
    if (model_name.empty()) {
        ERR("cannot register provider with an empty model name");
        return false;
    }

    if (_providers.find(model_name) != _providers.end()) {
        ERR("model already registerad:{}", model_name);
        return false;
    }

    ModelInfo model_info(model_name);
    model_info._desc = provider->getModelDesc();
    model_info._isAvailable = false;
    _models.emplace(model_name, model_info);

    _providers.emplace(model_name, std::move(provider));
    INFO("model registerd:{}", model_name);
    return true;
}

先看前面的检查:

  • 指针为空,说明根本没有可注册的 Provider,直接拒绝。

  • 模型名称为空,就无法建立模型名与对象的对应关系,直接拒绝。

  • _providers 中已经有相同名称,拒绝重复注册,避免覆盖已有对象。

通过检查以后,代码先建立 ModelInfo,并且明确写入:

cpp 复制代码
model_info._isAvailable = false;

注册成功只表示 Manager 已经认识了这个模型,不表示 Provider 已完成初始化。

最后再执行:

cpp 复制代码
_providers.emplace(model_name, std::move(provider));

此时 Manager 接管 Provider 对象,后续就可以通过模型名称查找它。

这里还存在一个容易忽略的细节:参数本身是按值接收的 unique_ptr。如果调用者写的是 std::move(provider),所有权会在进入函数时就转入形参;

即使函数随后因为空名称或重复名称而返回 false,调用者的那个原始智能指针也通常已经失去所有权,形参销毁时对象随之释放。所以不能把 false 理解为"原 Provider 对象还在调用者手里"。

3.3 Ollama 为什么需要带模型名的构造函数

第八篇有一个特意留下的接口:

cpp 复制代码
explicit OllamaLLMProvider(const std::string& model_name);

原因到这里就明白了。

DeepSeek、ChatGPT、Gemini 的 getModelName() 在当前代码中返回固定的模型名称;但 Ollama 可以接入不同的本地模型,它的名称由对象配置决定。

如果我们先使用没有模型名的默认构造函数:

cpp 复制代码
std::make_unique<OllamaLLMProvider>()

然后马上注册,getModelName() 可能返回空字符串,Manager 就会拒绝它。

因此在当前注册流程中,需要先把模型身份交给 Provider:

cpp 复制代码
manager.registerProvider(
    std::make_unique<OllamaLLMProvider>("deepseek-r1:1.5b")
);

还要保证后续 initModel() 配置中的 model_name 与注册时的名字保持一致。因为 Manager 已经用最初的名称建立了索引,而当前代码不会在 Provider 初始化后重新建立这个索引。

这就是"先有模型身份,再能进行统一管理"的实际含义。


四、初始化与模型可用状态:注册成功还不够

注册只是建立了模型名与 Provider 的关系。想真正调用模型,还要给具体 Provider 配置 API Key、服务地址或本地模型参数。

4.1 initModel() 为什么仍然只需要模型名称

当前 initModel() 的实现如下:

cpp 复制代码
bool LLMManager::initModel(
    const std::string& model_name,
    const std::map<std::string, std::string>& model_config
)
{
    auto provider_it = _providers.find(model_name);
    if (provider_it == _providers.end()) {
        ERR("model is not registered: {}", model_name);
        return false;
    }

    const bool success =
        provider_it->second->initModel(model_config);

    if (!success) {
        ERR("model initialization failed: {}", model_name);
        return false;
    }

    auto info_it = _models.find(model_name);
    if (info_it != _models.end()) {
        info_it->second._desc =
            provider_it->second->getModelDesc();
        info_it->second._isAvailable =
            success && provider_it->second->isAvailable();
    }

    INFO("model initialized: {}", model_name);
    return true;
}

这里不再需要知道具体的 Provider 类名,而是:

cpp 复制代码
provider_it->second->initModel(model_config);

通过 ILLMProvider 的虚函数机制,实际执行的仍然是 DeepSeek、ChatGPT、Gemini 或 Ollama 各自的初始化逻辑。

管理层只知道"我要初始化这个名字对应的模型",具体配置内容由对应 Provider 解释。

对于云端模型,配置可能是:

cpp 复制代码
std::map<std::string, std::string> model_config{
    {"api_key", api_key}
};

对于 Ollama,则可以是:

cpp 复制代码
std::map<std::string, std::string> model_config{
    {"model_name", "deepseek-r1:1.5b"},
    {"model_desc", "本地 Ollama 模型"},
    {"endpoint", "http://127.0.0.1:11434"}
};

这两份配置格式不同,但都通过同一个 manager.initModel(model_name, model_config) 入口提交。

4.2 什么叫"可用"

在当前设计里,模型状态至少要区分三个阶段:

  • 没有注册 → Manager 找不到这个 model_name;
  • 注册成功,但尚未初始化 → _modelsmodel_name._isAvailable = false;
  • 初始化成功,并且 Provider 报告可用 → _modelsmodel_name._isAvailable = true。

这个 true 有明确的项目语义:Provider 已通过自己的初始化检查,并被 Manager 标记为可用。它不意味着此刻已经进行过一次真实请求,更不能保证远端服务始终在线、密钥长期有效或本地模型随时能加载。

此外,initModel() 存在两个值得按真实代码理解的边界:

第一,如果具体 Provider 的 initModel() 返回 true,但随后的 isAvailable() 返回 false,Manager 会把缓存标记为 false;不过当前 LLMManager::initModel() 仍然返回 true 。因此需要检查实际可用性时,还应调用 isModelAvailable()。

第二,如果一个已经成功初始化的模型之后再次初始化失败,当前实现会提前 return false,不会主动把 _models 中旧的 _isAvailable 清零 。因此这一版的 ModelInfo 是由初始化过程维护的缓存,不是每次查询都重新探测 Provider 状态。在业务使用上,不应该把它理解为实时健康检查。

理解这些边界,比简单记住"true 就是成功"更重要。


五、查询模型状态:两张表怎样配合工作

当页面准备展示模型选择列表,或者业务准备给某个模型发消息时,都需要知道现在能使用哪些模型,所以LLMManager 提供了两个查询函数。

5.1 获取所有可用模型

cpp 复制代码
std::vector<ModelInfo> LLMManager::getAvailableModels() const
{
    std::vector<ModelInfo> result;
    result.reserve(_models.size());

    for (const auto& pair : _models) {
        const ModelInfo& info = pair.second;

        if (info._isAvailable) {
            result.push_back(info);
        }
    }

    return result;
}

代码没有遍历 _providers 去重复调用每一个 Provider 的 isAvailable(),而是直接读取 _models 保存的状态。

它做了三件事:

  1. 为返回结果预留最多 _models.size() 个元素的空间,减少扩容次数。

  2. 遍历模型信息表,只收集 _isAvailable == true 的模型。

  3. 返回 std::vector<ModelInfo>,由调用方用于展示或进一步处理。

由于底层使用 std::map,遍历时按模型名称的键顺序访问,而不是按照注册时间或用户偏好排序。后面若需要自定义列表顺序,应由另外的展示逻辑负责。

返回的是 ModelInfo 的副本,而不是 Manager 内部表项的可修改引用。因此上层修改返回列表里的字段,不会直接改写 Manager 保存的状态。

5.2 检查指定模型是否可用

cpp 复制代码
bool LLMManager::isModelAvailable(
    const std::string& model_name
) const
{
    auto it = _models.find(model_name);
    if (it == _models.end()) {
        ERR("model is not registered or not initialized: {}", model_name);
        return false;
    }
    if (!it->second._isAvailable) {
        ERR("model is not available: {}", model_name);
    }
    return it->second._isAvailable;
}

先查名称是否存在,再读取缓存的可用标记。

这个函数不会在调用时主动发网络请求,也不会再次执行具体 Provider 的初始化。它的职责就是依据当前模型信息表判断是否允许向下转发。

这也是为什么我们要在注册时把模型设为不可用,在初始化后再更新它。两个阶段如果混为一谈,业务就可能向一个尚未准备好的 Provider 发送请求。


六、全量和流式请求怎样统一路由

模型已经注册、初始化、状态也能查询。接下来就要让业务层真正不再判断"这次用的是哪个具体 Provider"。

这部分也是 LLMManager 最直接的价值:同一个模型名选择逻辑,既服务全量请求,也服务流式请求。

6.1 sendMessage() 只做必要的检查与转发

cpp 复制代码
std::string LLMManager::sendMessage(
    const std::string& model_name,
    const std::vector<Message>& messages,
    const std::map<std::string, std::string>& request_param
)
{
    auto provider_it = _providers.find(model_name);
    if (provider_it == _providers.end()) {
        ERR("model is not registered: {}", model_name);
        return "";
    }

    if (!isModelAvailable(model_name)) {
        ERR("model is not available: {}", model_name);
        return "";
    }

    return provider_it->second->sendMessage(
        messages, request_param
    );
}

相比前几篇一个 Provider 内部可能有很长的网络处理流程,这个函数反而很短。

原因正是它没有越界做别人的工作:

cpp 复制代码
查 _providers,确认模型存在
    ↓
查 _models,确认当前标记为可用
    ↓
调用 ILLMProvider::sendMessage(...)
    ↓
具体 Provider 完成 HTTP / JSON / 业务响应解析
    ↓
返回 std::string

因此,路由到 ChatGPT 时会进入 ChatGPTProvider::sendMessage();路由到 Ollama 时会进入 OllamaLLMProvider::sendMessage()。管理层不需要知道一个使用 Responses API,另一个使用 /api/chat。

对于不存在或当前被标记为不可用的模型,当前实现返回空字符串,同时记录错误日志。空字符串并不是一种可以区分所有失败原因的结构化错误类型:调用方如果需要判断失败原因,还必须结合日志或后续更明确的错误设计。

6.2 sendMessageStream() 为什么同样不需要协议判断

流式转发的实现与全量几乎平行:

cpp 复制代码
std::string LLMManager::sendMessageStream(
    const std::string& model_name,
    const std::vector<Message>& messages,
    const std::map<std::string, std::string>& request_param,
    std::function<void(const std::string&, bool)> callback
)
{
    auto provider_it = _providers.find(model_name);
    if (provider_it == _providers.end()) {
        ERR("model is not registered: {}", model_name);
        return "";
    }

    if (!isModelAvailable(model_name)) {
        ERR("model is not available: {}", model_name);
        return "";
    }

    return provider_it->second->sendMessageStream(
        messages, request_param, std::move(callback)
    );
}

增加的参数是:

cpp 复制代码
std::function<void(const std::string&, bool)> callback

前面已经多次接触过这个形式。std::string 负责传递这一段增量文本,bool 表示当前回调通知的结束状态。

LLMManager 在这里并不直接执行 callback(text, finished),而是把它交给具体 Provider。底层 Provider 如何判断结束,仍然由它自己的协议处理逻辑负责。

例如:

  • DeepSeekProvider → 解析 SSE 和 DONE;
  • ChatGPTProvider → 解析 Responses API 事件;
  • GeminiProvider → 解析兼容 SSE 响应;
  • OllamaLLMProvider → 解析 NDJSON 与 done=true。

它们把协议差异整理成统一的回调形状,再交给上层。LLMManager 不负责在这里把 SSE 转 NDJSON,也不会直接拼接网络 chunk。

需要特别区分两件事:

  • sendMessageStream() 可以在一次请求执行过程中持续触发回调;

  • 当前管理层仍然同步调用具体 Provider 的方法,并最终返回该方法返回的字符串。

存在回调不代表当前 Manager 自动创建了异步任务或后台线程。 同样,std::move(callback) 只是将当前回调对象向下传递的方式,并不会改变网络协议本身。

6.3 一次请求真正经过了哪些层

把前面的代码连起来,业务要调用本地 Ollama,可以这样理解:

cpp 复制代码
上层传入 "deepseek-r1:1.5b"
    ↓
LLMManager::_providers.find(model_name)
    ↓
LLMManager::isModelAvailable(model_name)
    ↓
ILLMProvider::sendMessageStream(...)
    ↓ 运行时多态
OllamaLLMProvider::sendMessageStream(...)
    ↓
本地 POST /api/chat
    ↓
NDJSON 解析、增量 callback、最终字符串
    ↓
返回上层

换成 gpt-5.5,Manager 的查找流程仍然一样;只是在多态调用之后,进入了 ChatGPTProvider 的具体实现。

模型选择与模型协议从此成为两个独立的问题。


七、用现有测试验证管理层的调用链

理解一个管理类,最直观的办法就是从使用方把注册、初始化、查询、发送连起来看一遍。

项目中的测试代码位于:

cpp 复制代码
TEST/test_LLM.cpp

其中已经提供:

cpp 复制代码
TEST(LLMManagerTest, SendMessage)
TEST(LLMManagerTest, SendMessageStream)

这两个测试使用 ChatGPTProvider 来验证 Manager 的全量与流式转发。

7.1 全量测试:同样的 Provider,不同的调用入口

测试中首先创建管理器并注册 Provider:

cpp 复制代码
ai_chat_sdk::LLMManager manager;
ASSERT_TRUE(manager.registerProvider(
    std::make_unique<ai_chat_sdk::ChatGPTProvider>()
));

接着读取 API Key 并初始化模型。项目现有测试使用的环境变量名为 CHATGPT_KEY_API:

cpp 复制代码
const char* api_key = std::getenv("CHATGPT_KEY_API");
ASSERT_NE(api_key, nullptr);
std::map<std::string, std::string> model_config;
model_config["api_key"] = api_key;
model_config["base_url"] = "https://www.nodapi.com";
ASSERT_TRUE(manager.initModel("gpt-5.5", model_config));
ASSERT_TRUE(manager.isModelAvailable("gpt-5.5"));

这里的 base_url 是当前测试代码写入的地址,使用时需要自行确认该服务地址和 API 凭据确实可访问;它并不是 SDK 固定只能使用的服务器。

然后查询可用模型:

cpp 复制代码
auto models = manager.getAvailableModels();
ASSERT_FALSE(models.empty());
EXPECT_EQ(models[0]._model_name, "gpt-5.5");

最后构造消息并通过管理层发送:

cpp 复制代码
std::vector<ai_chat_sdk::Message> messages;
messages.emplace_back("user", "你是谁?");
std::map<std::string, std::string> params;
params["temperature"] = "0.7";
params["max_output_tokens"] = "2048";
std::string reply = manager.sendMessage(
    "gpt-5.5", messages, params
);
EXPECT_FALSE(reply.empty());

注意这里展示的是现有测试代码中的参数写法 。当前 ChatGPTProvider::sendMessage() 实际读取的通用请求参数键是 max_tokens,再把它映射成上游协议中的 max_output_tokens;所以上面测试里的 params["max_output_tokens"] 并不会被当前 Provider 按该键读取,它会使用默认的最大输出值。自己编写新的调用代码时应使用:

cpp 复制代码
params["max_tokens"] = "2048";

这个细节也说明 Manager 只负责透传 request_param,它不负责替具体 Provider 纠正参数键名。

7.2 流式测试:模型路由不变,只是多了回调

另一个测试仍然先注册和初始化同一个 gpt-5.5,然后准备多条消息,再调用:

cpp 复制代码
std::string streamedText;

auto writeChunk = [&](const std::string& chunk, bool isDone) {
    if (!chunk.empty()) {
        streamedText += chunk;
        INFO("chunk: {}", chunk);
    }

    if (isDone) {
        INFO("[DONE]");
    }
};

const std::string fullData = manager.sendMessageStream(
    "gpt-5.5",
    messages,
    requestParam,
    writeChunk
);

EXPECT_FALSE(fullData.empty());
EXPECT_FALSE(streamedText.empty());
EXPECT_EQ(fullData, streamedText);

这里有三个不同的观察点:

  • chunk:流式处理期间收到的增量文本。

  • streamedText:测试代码自己把这些增量文本拼接起来的结果。

  • fullData:具体 Provider 的 sendMessageStream() 最后返回的完整字符串。

最后用 EXPECT_EQ 比较两个完整结果,目的就是检查:通过统一管理层转发以后,回调累计的文本与 Provider 返回的文本是否一致。

这个比较发生在实际请求之后。只有网络请求和具体 Provider 的流式实现真实执行了,才能用结果证明这条链路工作正常。

cpp 复制代码
./build-test/test_LLM --gtest_filter=LLMManagerTest.SendMessageStream


写在最后

到这里,LLMManager 已经把前面四个相互独立的 Provider 收到了同一个入口下。

回头看这篇的核心变化,其实就是两件事:

  • 原来:业务代码自己保存 Provider,再按类型分支调用;
  • 现在:业务只给出 model_name,LLMManager 负责查找和转发。

为了实现这件事,我们分别解决了 Provider 所有权、模型名索引、注册与初始化的阶段区分、可用状态查询,以及全量/流式调用的统一路由。

因此,增加模型时最重要的不是向业务层追加新的分支,而是为新模型准备一个遵守 ILLMProvider 的实现,并让它进入管理层。

但现在还差一层。

LLMManager 能回答"这次消息交给哪个模型",却不负责回答"这条消息属于哪个会话""上一轮聊过什么""重新打开页面以后怎样找回聊天记录"。

一次请求可以携带 Message[],但谁创建这些消息、谁维护多轮对话历史、谁给每段对话分配 ID,不是模型管理层的职责。

所以下一篇,我们开始进入 SessionManager:从模型调用走向真正可以连续对话的会话管理,并结合当前项目的数据持久化实现,理解会话生命周期与历史消息是如何保存和恢复的。

相关推荐
会周易的程序员3 小时前
STVM OSAL 层架构设计文档
c++·物联网·嵌入式·虚拟机·iot·软plc·iec61131
.YM.Z3 小时前
C++——【红黑树】原理详解:定义、性质、插入变色与旋转实现
开发语言·c++
无名猿4 小时前
constexpr 能力扩张:从 C++11 到 C++20 的编译期计算
c++·性能优化·现代c++·编译期
007张三丰4 小时前
C/C++ 内存管理详解:从内存分布到 new/delete 底层原理
java·c语言·c++·内存管理
三月微暖寻春笋5 小时前
【和春笋一起学C++】(七十二)类模板
开发语言·c++·实例·类模板·使用类模板·数组模板
xlq223225 小时前
全面复习4
c++
殷色玫瑰5 小时前
C++ STL:map 与 set 详解——从底层红黑树到实际应用
c语言·数据结构·c++·算法·visualstudio·rpc
Tri_Function5 小时前
背包:从入门到实战
c++
j7~5 小时前
【C++ 标准项目】发布订阅消息队列 (篇七):项目创建与基础工具模块搭建
开发语言·c++·log4j·uuid·protobuf·helper工具·消息定义类型和交换机类型定义