《C++ 项目:手写大模型接入 SDK》DeepSeek接口设计(上):初始化和全量响应

🔥小叶-duck:个人主页

❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》

《Linux系统从入门到实践》《Linux网络从入门到实践》

《Qt 方寸极境》 《MySQL》

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


目录

前言

[一、DeepSeekProvider 子类接口设计](#一、DeepSeekProvider 子类接口设计)

[1.1 基类回顾:LLMProvider 抽象契约](#1.1 基类回顾:LLMProvider 抽象契约)

[1.2 DeepSeekProvider.h:子类头文件与接口分析](#1.2 DeepSeekProvider.h:子类头文件与接口分析)

[1.3 DeepSeekProvider.cpp:成员函数实现及解析](#1.3 DeepSeekProvider.cpp:成员函数实现及解析)

[1.3.1 initModel 初始化函数](#1.3.1 initModel 初始化函数)

[1.3.2 IsAvailable 可用性判断接口](#1.3.2 IsAvailable 可用性判断接口)

[1.3.3 getModelDesc 获取模型描述接口](#1.3.3 getModelDesc 获取模型描述接口)

[1.3.4 sendMessage 非流式消息接口实现(重点)](#1.3.4 sendMessage 非流式消息接口实现(重点))

[步骤 1:判断模型是否有效](#步骤 1:判断模型是否有效)

[步骤 2:构造并获取请求参数](#步骤 2:构造并获取请求参数)

[步骤 3:构造请求体 JSON 对象](#步骤 3:构造请求体 JSON 对象)

[步骤 4:对 requestBody 进行序列化(Json 对象 -> string 字符串)](#步骤 4:对 requestBody 进行序列化(Json 对象 -> string 字符串))

[步骤 5:使用 cpp-httplib 构造 HTTP 客户端、设置请求头](#步骤 5:使用 cpp-httplib 构造 HTTP 客户端、设置请求头)

[步骤 6、向指定服务器发送 POST 请求](#步骤 6、向指定服务器发送 POST 请求)

[cpp-httplib 封装的 Client::Post 接口](#cpp-httplib 封装的 Client::Post 接口)

[Client::Post 接口返回值类型:Result](#Client::Post 接口返回值类型:Result)

[知识点补充:operator bool ()](#知识点补充:operator bool ())

[步骤 7:解析响应体 -- JSON 反序列化](#步骤 7:解析响应体 -- JSON 反序列化)

[步骤 8:从 responseBody 中提取响应内容](#步骤 8:从 responseBody 中提取响应内容)

[完整 sendMessage 全量接口代码](#完整 sendMessage 全量接口代码)

[1.4 GTest 全量响应代码测试(TestLLM.cpp)](#1.4 GTest 全量响应代码测试(TestLLM.cpp))

[1.4.1 CMakeLists.txt 简要说明](#1.4.1 CMakeLists.txt 简要说明)

[1.4.2 CMakeLists.txt](#1.4.2 CMakeLists.txt)

[1.4.3 运行报错:引出httplib问题](#1.4.3 运行报错:引出httplib问题)

报错现象

根本原理

正确的修复方案

测试结果展示

结束语


前言

在上一篇内容中,我们完整梳理了 DeepSeek 大模型 API 的接口规范、请求参数、无状态上下文机制以及响应报文结构。我们搞清楚了调用接口时每一个字段的含义、服务端的通信逻辑,理解了云端大模型依靠 messages 数组携带对话历史来维持会话连续性的原理。

理论的学习最终是为了落地实现。本篇我们将基于前面定义好的 LLMProvider 抽象基类,开始编写 DeepSeekProvider 子类,动手实现非流式消息发送接口 sendMessage。

我们会一步一步拆解整条调用链路:从模型有效性校验、请求 JSON 报文组装、序列化,再到借助 cpp-httplib 发起 HTTPS POST 请求,处理返回结果、JSON 反序列化,最终提取模型回复内容。同时搭配 GTest 编写测试用例验证功能,在调试过程中,我们也会遇到 httplib HTTPS 相关的经典坑点,学习 CMake 中编译宏、OpenSSL 依赖的正确配置方式。

一、DeepSeekProvider 子类接口设计

在调用 DeepSeek 的对话接口之前,需要先完成 Provider 的初始化,DeepSeek 的云端 API 依赖一系列配置参数才能正常通信。

我们在这里做了职责上的拆分:初始化阶段只保存全局公共连接配置,每次发请求时再单独传入本次对话的请求参数。

像 api_key、base_url 属于所有大模型服务商通用的认证、服务地址信息,生命周期和 Provider 对象保持一致,所以直接放在顶层基类 LLMProvider 的保护成员里,子类继承后可以复用。

而 model、temperature、max_tokens 这类参数,虽然几乎所有模型都支持,但它属于单次请求可动态修改 的配置:同一个 Provider 实例,可以切换不同模型、调整采样参数。因此这类参数不归入初始化配置,而是放到每次调用 sendMessage / sendMessageStream 的入参 requestParams 中。

简单总结分工:

  • modelConfig(初始化配置):api_key、base_url,Provider 初始化时加载,全局生效
  • requestParams(单次请求参数):model、temperature、max_tokens 等,每次发消息可以独立配置、灵活覆盖

本小节基于抽象基

类 LLMProvider 进行扩展,DeepSeekProvider 作为派生类,必须实现基类全部纯虚函数,遵守 SDK 统一调用契约。

1.1 基类回顾:LLMProvider 抽象契约

LLMProvider 是所有模型接入层的顶层抽象,定义统一的能力接口,不绑定任何厂商 API 细节:

  • **纯虚接口约定:**初始化、可用性检测、获取模型描述、全量消息、流式消息
  • 受保护成员: _apikey、_baseUrl、_isAvailable,存放全局连接信息与就绪标记,给子类直接访问
  • 虚析构,保证基类指针释放子类对象时析构安全
  • 提前依赖 httplib、jsoncpp、日志组件,为后续 HTTP 通信、JSON 报文组装打下基础

1.2 DeepSeekProvider.h:子类头文件与接口分析

cpp 复制代码
#ifndef DEEPSEEK_PROVIDER_H
#define DEEPSEEK_PROVIDER_H

#include "LLMProvider.h"

namespace ai_chat_sdk
{
// DeepSeek模型提供者类:实现DeepSeek模型的具体调用逻辑
class DeepSeekProvider : public LLMProvider
{
  public:
    // 初始化模型:根据配置初始化模型,只接收全局连接配置(api_key、base_url)
    bool initModel(const std::unordered_map<std::string, std::string> &modelConfig) override;
    // 判断模型是否有效
    bool IsAvailable() override;
    // 获取模型描述
    std::string getModelDesc() override;

    // 发送消息:全量调用,返回完整响应
    // requestParams:本次请求专属参数,包含model、temperature、max_tokens等
    std::optional<std::string> sendMessage(const std::vector<Message> &messages,
                                           const std::unordered_map<std::string, std::string> &requestParams) override;

    // 发送消息:增量调用,实时返回响应
    // requestParams:本次请求专属参数,包含model、temperature、max_tokens等
    std::optional<std::string> sendMessageStream(const std::vector<Message> &messages,
                                                 const std::unordered_map<std::string, std::string> &requestParams,
                                                 std::function<void(const std::string &response, bool isEnd)> callback) override;

    ~DeepSeekProvider() = default;
};
} // namespace ai_chat_sdk

#endif

接口逐点解读:

1、initModel

cpp 复制代码
bool initModel(const std::unordered_map<std::string, std::string> &modelConfig) override;

初始化函数,入参 modelConfig 仅包含 api_key、base_url。

函数内部做合法性校验,把这两个值存入继承自基类的 _apikey、_baseUrl,校验无误后将 _isAvailable 设置为 true。

重点:这里不会读取、保存 model /temperature 这类请求参数,初始化只负责打通服务连接,不绑定模型和生成参数。

2、IsAvailable

cpp 复制代码
bool IsAvailable() override;

外暴露就绪状态查询接口,读取 _isAvailable 标记。上层在发起对话前,可以用这个接口判断是否具备调用条件。

3、getModelDesc

cpp 复制代码
std::string getModelDesc() override;

返回 Provider 自身的标识与说明,用于日志、调试展示。

4、sendMessage(非流式接口)

cpp 复制代码
std::optional<std::string> sendMessage(
        const std::vector<Message> &messages,
        const std::unordered_map<std::string, std::string> &requestParams
) override;

messages 是会话上下文数组,存放多轮对话;

requestParams 是单次请求参数 ,由调用方传入 model、temperature、max_tokens 等字段。

实现内部会读取这一组参数,组装进 JSON 请求体,发起 HTTPS 调用;std::optional 用来承载失败场景(鉴权错误、网络异常、参数非法)。

5、sendMessageStream(流式接口)

cpp 复制代码
std::optional<std::string> sendMessageStream(
        const std::vector<Message> &messages,
        const std::unordered_map<std::string, std::string> &requestParams,
        std::function<void(const std::string &response, bool isEnd)> callback
) override;

流式 SSE 接口,参数规则和上面完全一致。

requestParams 同样携带本次要使用的 model 与生成参数,回调函数持续接收分片内容,isEnd 标记分片是否结束。

1.3 DeepSeekProvider.cpp:成员函数实现及解析

在本篇文章我们先实现 initModel、IsAvailable、getModelName、getModelDesc、sendMessage 这几个基础接口,流式接口 sendMessageStream 依赖 SSE 协议,我们放到后面学习完 SSE 协议之后再实现;非流式 sendMessage 实现完成后,就可以先配合之前的 GTest 用例完成非流式响应的功能测试。

在实现我们的函数之前,我们需要包含子类的头文件:

cpp 复制代码
#include "../include/DeepSeekProvider.h"

namespace ai_chat_sdk
{

} // namespace ai_chat_sdk

1.3.1 initModel 初始化函数

cpp 复制代码
namespace ai_chat_sdk
{

// 初始化模型:根据配置初始化模型
// 输入:modelConfig,包含模型的配置参数(如modelName、apikey、base_url等)
// 输出:bool,初始化成功返回true,失败返回false
bool DeepSeekProvider::initModel(const std::unordered_map<std::string, std::string> &modelConfig)
{
    // deepseek模型的初始化流程:
    // 1、初始化模型_apikey:从modelConfig获取模型apikey,对_apikey进行赋值
    auto it = modelConfig.find("api_key");
    if (it == modelConfig.end() || it->second == "")
    {
        ERR("DeepSeekProvider initModel api_key not found or not config_error");
        return false;
    }
    else
    {
        _apikey = it->second;
    }

    // 2、初始化模型_baseUrl:从modelConfig获取模型base_url,对_baseUrl进行赋值
    it = modelConfig.find("base_url");
    if (it == modelConfig.end())
    {
        ERR("DeepSeekProvider initModel base_url not found");
        return false;
    }
    else
    {
        _baseUrl = it->second;
    }

    // 3、将_isAvailable设置为true
    _isAvailable = true;
    INFO("DeepSeekProvider initModel success, apikey: {}, base_url: {}", _apikey, _baseUrl);
    return true;
}

} // namespace ai_chat_sdk

代码分析:

  1. 入参 modelConfig 严格遵守我们的设计,只承载全局固定连接参数 api_key、base_url,不会在这里读取 model、temperature、max_tokens 这类单次请求参数。
  2. 合法性校验: api_key 不仅要存在,还不能是空字符串;base_url 必须配置,否则无法发起 HTTP 请求。
  3. 成员赋值: _apikey、_baseUrl、_isAvailable 都是从基类 LLMProvider 继承来的 protected 成员,子类可以直接访问。
  4. 状态标记: 全部校验成功之后,_isAvailable = true,代表这个 Provider 实例已经就绪,可以对外提供对话调用;初始化失败直接返回 false,上层可以拿到结果做判断。
  5. **日志输出:**使用我们之前封装好的 spdlog 日志组件,打印初始化成功信息,方便后期排查问题。

1.3.2 IsAvailable 可用性判断接口

cpp 复制代码
namespace ai_chat_sdk
{

// 判断模型是否有效
bool DeepSeekProvider::IsAvailable()
{
    return _isAvailable;
}

} // namespace ai_chat_sdk

代码分析:

  • 这个接口逻辑非常简单,直接返回继承自基类的 _isAvailable 布尔标记。
  • 作用: 给上层调用方提供状态查询能力。上层在调用 sendMessage 发送对话请求之前,可以先调用这个接口判断 Provider 是否初始化成功,避免在未就绪状态发起无效网络请求。
  • 注意: 这个标记只代表初始化参数配置完成 ,不代表网络一定连通、apikey 一定有效 ;apikey 合法性、网络连通性 ,要等到真正发起 HTTP 请求的时候,由 DeepSeek 服务端返回状态码校验。

1.3.3 getModelDesc 获取模型描述接口

cpp 复制代码
namespace ai_chat_sdk
{

// 获取模型描述
std::string DeepSeekProvider::getModelDesc()
{
    return "deepseek-flash模型: 一款轻量极速、主打低延迟的对话助手,适合高并发与日常交互";
}

} // namespace ai_chat_sdk

代码分析:

  • 该接口用来返回当前 Provider 实现对应的模型说明文本,用于日志打印、前端展示、调试信息输出。
  • 这里要区分开:这个描述是Provider 的静态说明 ,和 requestParams 里面动态传入的 model 不是一回事。就算本次请求切换成 deepseek 的其他模型,这个描述文本也不会自动变化;如果需要支持多模型动态描述,后续可以再扩展。

1.3.4 sendMessage 非流式消息接口实现(重点)

大模型接口设计当中,全量响应 和流式响应是整个接口设计最重要也是知识点考察以及代码逻辑上最复杂的两个接口,我们会将这两个接口通过多个步骤进行代码拆分讲解,让大家能够非常清晰的理解发送消息给大模型的完整逻辑链。

步骤 1:判断模型是否有效
cpp 复制代码
    // 1、判断模型是否有效
    if (!IsAvailable())
    {
        ERR("DeepSeekProvider sendMessage model not available");
        // 模型无效,返回空值nullopt
        return std::nullopt;
    }

代码分析 :

IsAvailable () 是基类 LLMProvider 预留的虚函数,用来校验当前 Provider 是否已经完成初始化、api-key、baseUrl是否配置到位。

如果模型没有就绪,直接打印错误日志,返回std::nullopt,上层调用方通过std::optional就可以识别本次调用失败。

步骤 2:构造并获取请求参数

设计回顾: 基类只保存通用配置 api-key、baseUrl;单次请求相关参数(model、temperature、max_tokens 等)由调用方在 requestParams 传入。

我们给 temperature、max_tokens、thinking_type、reasoning_effort 设置默认值;model 模型名称不设置默认,强制调用方传入。

该图是当前 deepseek 模型发送消息所需要的所有请求参数,就像上面说的,请求参数在模型的不断版本优化更新时也可能发生变化,甚至有些请求参数会被历史丢弃,所以这些请求参数我们就不会设计到基类当中,也不会作为子类的成员变量进行调用,而是用户根据当前模型所需要的请求参数自主进行设定。

cpp 复制代码
    // 2、构造并获取请求参数
    // 2.1 构造默认请求参数:如果requestParams中没有指定temperature、maxTokens、thinking_type、reasoning_effort,则使用默认值
    double temperature = 0.7;
    int max_tokens = 2048;
    std::string thinking_type = "disabled";
    std::string reasoning_effort = "high";
    std::string model; // 请求参数model无法默认值,必须用户手动配置
    // 2.2 从requestParams中获取temperature、maxTokens、thinking_type、reasoning_effort参数:如果requestParams中指定了,则使用指定值
    // 注意:请求参数model在不同的模型中是必填项,但是这个参数由于模型版本的更新,可能会有不同的字段名称,所以就不能使用默认值了,需要用户自行指定正确的model
    auto it = requestParams.find("temperature");
    if (it != requestParams.end())
    {
        temperature = std::stod(it->second);
    }
    it = requestParams.find("max_tokens");
    if (it != requestParams.end())
    {
        max_tokens = std::stoi(it->second);
    }
    it = requestParams.find("thinking_type");
    if (it != requestParams.end())
    {
        thinking_type = it->second;
    }
    it = requestParams.find("reasoning_effort");
    if (it != requestParams.end())
    {
        reasoning_effort = it->second;
    }
    // 请求参数model必须用户手动配置,如果没有配置则直接返回空值nullopt报错告诉用户
    it = requestParams.find("model");
    if (it != requestParams.end())
    {
        model = it->second;
    }
    else
    {
        ERR("model name not found in requestParams, must config it");
        return std::nullopt;
    }
    Json::Value Type;
    Type["type"] = thinking_type;

    // 2.3 构造历史消息:获取请求体中的 messages 这个Json数组
    Json::Value messageArray(Json::arrayValue);
    for (auto &message : messages)
    {
        Json::Value messageObject;
        // 这里的字段名称必须与deepseek-flash模型的接口文档中要求的一致
        messageObject["role"] = message._role;
        messageObject["content"] = message._content;
        messageArray.append(messageObject);
    }

代码分析:

  1. requestParams是调用方传入的键值对,用 find 查询,存在就覆盖默认值;
  2. model 是强制参数 ,不提供就直接报错,防止请求非法;
  3. 循环遍历传入的vector<Message>,组装成 API 要求的messages数组结构,role和content字段严格对齐 DeepSeek 接口规范。
步骤 3:构造请求体 JSON 对象
cpp 复制代码
    // 3、构造请求体
    //  和历史消息构造一样,请求体的字段名称必须与deepseek-flash模型的接口文档中要求的一致
    Json::Value requestBody;
    requestBody["model"] = model;
    requestBody["messages"] = messageArray;
    requestBody["temperature"] = temperature;
    requestBody["max_tokens"] = max_tokens;
    requestBody["reasoning_effort"] = reasoning_effort;
    requestBody["thinking"] = Type;

代码分析 :

把前面组装好的 model、消息数组、采样参数、推理配置全部填入 JSON 根对象,这个requestBody就是后续要发给 DeepSeek 服务端的载荷。

步骤 4:对 requestBody 进行序列化(Json 对象 -> string 字符串)
cpp 复制代码
    // 4、对requestBody进行序列化
    Json::StreamWriterBuilder writebuilder;
    // 写法1: 直接调用writeString方法
    std::string requestBodyStr = Json::writeString(writebuilder, requestBody);
    // // 写法2: 使用StreamWriter进行序列化
    // std::unique_ptr<Json::StreamWriter> writer(writebuilder.newStreamWriter());
    // std::ostringstream requestBodyStrStream;
    // int ret = writer->write(requestBody, &requestBodyStrStream);
    // if(ret != 0)
    // {
    //     ERR("DeepSeekProvider sendMessage write requestBody failed, ret: {}", ret);
    //     return std::nullopt;
    // }
    INFO("DeepSeekProvider sendMessage requestBody: {}", requestBodyStr);

代码分析 :

借助 JsonCpp 的StreamWriterBuilder将 Json::Value 序列化为字符串,序列化后的字符串就是 HTTP POST 要发送的 body 内容。

同时打 INFO 日志,方便我们调试,查看实际发出去的报文长什么样。

步骤 5:使用 cpp-httplib 构造 HTTP 客户端、设置请求头
cpp 复制代码
    // 5、使用cpp-httplib构建HTTP客户端
    httplib::Client client(_baseUrl);
    // 5.1设置超时时间
    client.set_read_timeout(60, 0);       // 读取超时时间60秒
    client.set_connection_timeout(20, 0); // 连接超时时间20秒
    // 5.2 构建请求头
    httplib::Headers headers = {
        {"Authorization", "Bearer " + _apikey},
        {"Content-Type", "application/json"}};

代码分析:

  1. _baseUrl、_apiKey是基类 LLMProvider 的成员,所有大模型都需要这两个公共配置;
  2. 设置超时防止程序无限阻塞等待;
  3. 请求头携带鉴权 Token,并且声明 body 格式为 json,这是 OpenAI 兼容格式接口的标准写法。

针对请求头字段,还有一个 Accept,这个字段代表的就是:告诉服务端,客户端期望接收的响应体格式是什么样子的。该请求头字段不设定则默认就是 applocation/json,也就是 JSON 格式,所代表的就是全量响应(非流式响应)。

所以这里我们不对 Accept 请求头字段进行设定,也不会有影响;但是对于后续我们将要实现的流式响应,这里就必须手动对 Accept 请求头字段设定为 text/event-stream,表明用户需要模型流式的发送响应内容,并且还需要手动设置请求参数 stream 为 true,后续我们还会对流式响应接口进行详细讲解。

步骤 6、向指定服务器发送 POST 请求
cpp 复制代码
    // 6、向指定服务器发送POST请求
    auto resp = client.Post("/chat/completions", headers, requestBodyStr, "application/json");
    // 6.1、检查响应是否成功:判断响应是否为空( 使用转换函数operator bool() ),以及状态码是否为200
    // !resp: 触发了 Result 类的 operator bool() 隐式转换,并且执行函数内部的逻辑返回bool类型
    // 利用||的短路求值特性:如果 !resp 为 true(说明请求彻底失败或没拿到响应),右侧的 resp->status 不会被执行,从而防止了对空指针的解引用导致程序崩溃
    if (!resp || resp->status != 200)
    {
        ERR("DeepSeekProvider sendMessage failed, status: {}, body: {}", resp->status, resp->body);
        return std::nullopt;
    }
    INFO("DeepSeekProvider sendMessage success, status: {}, body: {}",  resp->status, resp->body);

代码分析:

cpp-httplib 封装的 Client::Post 接口
cpp 复制代码
inline Result Client::Post(const std::string &path, const Headers &headers,
                           const std::string &body,
                           const std::string &content_type) {
  return cli_->Post(path, headers, body, content_type);
}
  • 参数含义:

    • path: 请求的URL 路径 (如 /chat/completions),不包含域名 (域名在初始化的 _baseUrl 中)。

    • headers: 之前构建好的请求头集合(包含 Authorization 和 Accept 等)。

    • body: 请求体内容,这里通常是 JSON 序列化后的字符串(包含 prompt、model 等参数)。

    • content_type: 内容类型,标准固定为 "application/json"。

  • 底层原理 :cli_ 是底层真实 httplib::Client 对象的智能指针。这个封装方法直接将参数透传给底层。注意:这里的 Post 是同步阻塞的,它会一直等待服务器返回完整的响应,期间线程会被挂起。

Client::Post 接口返回值类型:Result

源代码中有一行非常关键:

cpp 复制代码
operator bool() const { return res_ != nullptr; }
知识点补充:operator bool ()
  • 什么是转换运算符(Conversion Operator)?

这是一个隐式类型转换函数 。它没有返回值类型(因为函数名就是类型 bool),但它允许你把一个 Result 类型的对象当作 bool 类型来使用。

  • 内部在做什么?

当编译器遇到需要判断 Result 真假的地方(比如 if 语句、逻辑非 !),它会自动调用这个函数。

函数内部执行 res_ != nullptr,意思就是:只要底层智能指针不为空(说明成功拿到了服务器的响应),就返回 true;否则(如超时、断网)返回 false。

  • 为什么使用 explicit 更好?

虽然 Result 源码没有加 explicit,但在现代 C++ 中,这种转换通常建议声明为 explicit operator bool() const 。

这可以防止编译器在不经意间把 Result 隐式转换成整型 (比如 int x = resp; 会把 true 变成 1),从而避免潜在的类型混乱。

cpp 复制代码
if (!resp || resp->status != 200)
  • !resp 触发隐式转换:

    • !resp 等价于 !(resp.operator bool())。

    • 如果发生网络超时(底层 res_ 为 nullptr),operator bool() 返回 false,取反后变成 true。

  • 逻辑或(||)的短路特性(Short-Circuiting):

    • C++ 保证在 A || B 中,如果 A 为 true,则绝对不会去执行 B。

    • 因此,如果 !resp 为 true(网络彻底失败),程序绝不会 去执行右侧的 resp->status。

    • 如果不这样做,会发生什么? 如果 res_ 是空的,你去调用 resp->status,就会对空指针解引用,程序直接 Segmentation Fault 崩溃。这行代码就能避免程序崩溃。

步骤 7:解析响应体 -- JSON 反序列化
cpp 复制代码
    // 7、解析响应体 -- 反序列化
    Json::CharReaderBuilder readbuilder;
    // 写法1: 直接调用parseFromStream方法
    std::istringstream responceStream(resp->body); // 从响应体中构造输入流
    Json::Value responceBody;
    std::string parse_err;
    bool ret = Json::parseFromStream(readbuilder, responceStream, &responceBody, &parse_err); // 从输入流中解析JSON
    // 写法2: 使用CharReader进行反序列化
    // std::unique_ptr<Json::CharReader> reader(readbuilder.newCharReader());
    // Json::Value responceBody;
    // std::string parse_err;
    // bool ret = reader->parse(resp->body.c_str(), resp->body.c_str() + resp->body.size(), &responceBody, &parse_err);
    if (!ret)
    {
        ERR("DeepSeekProvider sendMessage parse responceBody failed, err: {}", parse_err);
        return std::nullopt;
    }

代码分析 :

拿到服务端返回的 JSON 字符串,使用 JsonCpp 的 CharReader 从流中反序列化成Json::Value对象;如果 JSON 格式非法、解析失败,打印错误日志并且返回失败。

步骤 8:从 responseBody 中提取响应内容
cpp 复制代码
    // 8、从responceBody中提取响应内容
    //  模型返回的响应中的choices字段是一个Json数组,数组中每一个元素包含message字段,message字段中包含content字段,content字段就是模型生成的文本
    // 8.1 先检查 choices 是否存在且是数组且不为空 -- 保证我们能获取到有数据的choices字段
    if (responceBody.isMember("choices") && responceBody["choices"].isArray() && !responceBody["choices"].empty())
    {
        Json::Value firstChoice = responceBody["choices"][0];
        // 8.2 检查choices数组中是否存在message字段且message字段中是否存在content
        if (firstChoice.isMember("message") && firstChoice["message"].isMember("content"))
        {
            std::string generatedText = firstChoice["message"]["content"].asString();
            INFO("DeepSeekProvider sendMessage generatedText: {}", generatedText);
            return generatedText;
        }
        else
        {
            ERR("DeepSeekProvider: message or content field missing in response.");
            return std::nullopt;
        }
    }
    else
    {
        ERR("DeepSeekProvider: choices array is empty or not found in response.");
        return std::nullopt;
    }

代码分析:

严格按照 DeepSeek 接口返回结构逐层判断字段 :先判断 choices 数组,再取第一个 choice,再读取message.content。

每一层都做存在性判断,防止返回报文结构和预期不一致的时候直接越界崩溃。解析成功后,把模型生成的文本包装进std::optional<std::string>返回给上层调用。

完整 sendMessage 全量接口代码
cpp 复制代码
// 发送消息:全量调用,返回完整响应
std::optional<std::string> DeepSeekProvider::sendMessage(const std::vector<Message> &messages,
                                                         const std::unordered_map<std::string, std::string> &requestParams)
{
    // 1、判断模型是否有效
    if (!IsAvailable())
    {
        ERR("DeepSeekProvider sendMessage model not available");
        // 模型无效,返回空值nullopt
        return std::nullopt;
    }

    // 2、构造并获取请求参数
    // 2.1 构造默认请求参数:如果requestParams中没有指定temperature、maxTokens、thinking_type、reasoning_effort,则使用默认值
    double temperature = 0.7;
    int max_tokens = 2048;
    std::string thinking_type = "disabled";
    std::string reasoning_effort = "high";
    std::string model; // 请求参数model无法默认值,必须用户手动配置
    // 2.2 从requestParams中获取temperature、maxTokens、thinking_type、reasoning_effort参数:如果requestParams中指定了,则使用指定值
    // 注意:请求参数model在不同的模型中是必填项,但是这个参数由于模型版本的更新,可能会有不同的字段名称,所以就不能使用默认值了,需要用户自行指定正确的model
    auto it = requestParams.find("temperature");
    if (it != requestParams.end())
    {
        temperature = std::stod(it->second);
    }
    it = requestParams.find("max_tokens");
    if (it != requestParams.end())
    {
        max_tokens = std::stoi(it->second);
    }
    it = requestParams.find("thinking_type");
    if (it != requestParams.end())
    {
        thinking_type = it->second;
    }
    it = requestParams.find("reasoning_effort");
    if (it != requestParams.end())
    {
        reasoning_effort = it->second;
    }
    // 请求参数model必须用户手动配置,如果没有配置则直接返回空值nullopt报错告诉用户
    it = requestParams.find("model");
    if (it != requestParams.end())
    {
        model = it->second;
    }
    else
    {
        ERR("model name not found in requestParams, must config it");
        return std::nullopt;
    }
    Json::Value Type;
    Type["type"] = thinking_type;

    // 2.3 构造历史消息:获取请求体中的 messages 这个Json数组
    Json::Value messageArray(Json::arrayValue);
    for (auto &message : messages)
    {
        Json::Value messageObject;
        // 这里的字段名称必须与deepseek-flash模型的接口文档中要求的一致
        messageObject["role"] = message._role;
        messageObject["content"] = message._content;
        messageArray.append(messageObject);
    }

    // 3、构造请求体
    //  和历史消息构造一样,请求体的字段名称必须与deepseek-flash模型的接口文档中要求的一致
    Json::Value requestBody;
    requestBody["model"] = model;
    requestBody["messages"] = messageArray;
    requestBody["temperature"] = temperature;
    requestBody["max_tokens"] = max_tokens;
    requestBody["reasoning_effort"] = reasoning_effort;
    requestBody["thinking"] = Type;

    // 4、对requestBody进行序列化
    Json::StreamWriterBuilder writebuilder;
    // 写法1: 直接调用writeString方法
    std::string requestBodyStr = Json::writeString(writebuilder, requestBody);
    // // 写法2: 使用StreamWriter进行序列化
    // std::unique_ptr<Json::StreamWriter> writer(writebuilder.newStreamWriter());
    // std::ostringstream requestBodyStrStream;
    // int ret = writer->write(requestBody, &requestBodyStrStream);
    // if(ret != 0)
    // {
    //     ERR("DeepSeekProvider sendMessage write requestBody failed, ret: {}", ret);
    //     return std::nullopt;
    // }
    INFO("DeepSeekProvider sendMessage requestBody: {}", requestBodyStr);

    // 5、使用cpp-httplib构建HTTP客户端
    httplib::Client client(_baseUrl);
    // 5.1设置超时时间
    client.set_read_timeout(60, 0);       // 读取超时时间60秒
    client.set_connection_timeout(20, 0); // 连接超时时间20秒
    // 5.2 构建请求头
    httplib::Headers headers = {
        {"Authorization", "Bearer " + _apikey},
        {"Content-Type", "application/json"}};

    // 6、向指定服务器发送POST请求
    auto resp = client.Post("/chat/completions", headers, requestBodyStr, "application/json");
    // 6.1、检查响应是否成功:判断响应是否为空( 使用转换函数operator bool() ),以及状态码是否为200
    // !resp: 触发了 Result 类的 operator bool() 隐式转换,并且执行函数内部的逻辑返回bool类型
    // 利用||的短路求值特性:如果 !resp 为 true(说明请求彻底失败或没拿到响应),右侧的 resp->status 不会被执行,从而防止了对空指针的解引用导致程序崩溃
    if (!resp || resp->status != 200)
    {
        ERR("DeepSeekProvider sendMessage failed, status: {}, body: {}", resp->status, resp->body);
        return std::nullopt;
    }
    INFO("DeepSeekProvider sendMessage success, status: {}, body: {}",  resp->status, resp->body);

    // 7、解析响应体 -- 反序列化
    Json::CharReaderBuilder readbuilder;
    // 写法1: 直接调用parseFromStream方法
    std::istringstream responceStream(resp->body); // 从响应体中构造输入流
    Json::Value responceBody;
    std::string parse_err;
    bool ret = Json::parseFromStream(readbuilder, responceStream, &responceBody, &parse_err); // 从输入流中解析JSON
    // 写法2: 使用CharReader进行反序列化
    // std::unique_ptr<Json::CharReader> reader(readbuilder.newCharReader());
    // Json::Value responceBody;
    // std::string parse_err;
    // bool ret = reader->parse(resp->body.c_str(), resp->body.c_str() + resp->body.size(), &responceBody, &parse_err);
    if (!ret)
    {
        ERR("DeepSeekProvider sendMessage parse responceBody failed, err: {}", parse_err);
        return std::nullopt;
    }

    // 8、从responceBody中提取响应内容
    //  模型返回的响应中的choices字段是一个Json数组,数组中每一个元素包含message字段,message字段中包含content字段,content字段就是模型生成的文本
    // 8.1 先检查 choices 是否存在且是数组且不为空 -- 保证我们能获取到有数据的choices字段
    if (responceBody.isMember("choices") && responceBody["choices"].isArray() && !responceBody["choices"].empty())
    {
        Json::Value firstChoice = responceBody["choices"][0];
        // 8.2 检查choices数组中是否存在message字段且message字段中是否存在content
        if (firstChoice.isMember("message") && firstChoice["message"].isMember("content"))
        {
            std::string generatedText = firstChoice["message"]["content"].asString();
            INFO("DeepSeekProvider sendMessage generatedText: {}", generatedText);
            return generatedText;
        }
        else
        {
            ERR("DeepSeekProvider: message or content field missing in response.");
            return std::nullopt;
        }
    }
    else
    {
        ERR("DeepSeekProvider: choices array is empty or not found in response.");
        return std::nullopt;
    }
}

1.4 GTest 全量响应代码测试(TestLLM.cpp)

前置:GTest 第三方库在前面的文章中我们已经详细进行了讲解,感兴趣了解的可以看我之前的文章,这里是基于 TEST 宏写的单元测试,自动输出测试成功 / 失败信息。

《C++ 工具库实战》GTest 单元测试:断言、全局环境、局部夹具与底层原理_gtest框架原理-CSDN博客

cpp 复制代码
#include "../SDK/include/DeepSeekProvider.h"
#include "../SDK/include/util/MyLog.h"
#include <gtest/gtest.h>
#include <stdlib.h>
#include <vector>

using namespace ai_chat_sdk;

TEST(DeepSeekProviderTest, sendMessage)
{
    // 1、创建DeepSeekProvider对象
    std::shared_ptr<LLMProvider> deepseekProvider = std::make_shared<DeepSeekProvider>();
    // 1.1 检查DeepSeekProvider是否初始化成功
    ASSERT_TRUE(deepseekProvider != nullptr);

    // 2、构建模型配置
    std::unordered_map<std::string, std::string> modelConfig;
    modelConfig["api_key"] = getenv("deepseek_apikey") ? getenv("deepseek_apikey") : "";
    modelConfig["base_url"] = "https://api.deepseek.com";

    // 3、初始化DeepSeekProvider
    deepseekProvider->initModel(modelConfig);
    // 3.1 检查模型是否有效
    ASSERT_TRUE(deepseekProvider->IsAvailable());

    // 4、构建请求参数配置(不写则使用默认值)
    std::unordered_map<std::string, std::string> requestConfig;
    requestConfig["model"] = "deepseek-flash";
    requestConfig["temperature"] = "0.7";
    requestConfig["max_tokens"] = "2048";
    requestConfig["thinking_type"] = "disabled";
    requestConfig["reasoning_effort"] = "high";

    // 5、构建消息列表
    // 默认形式
    std::vector<Message> messages;
    // // messages.push_back({"user", "你好, 请简单介绍一下你自己"});

    // // 带上角色设定
    // messages.push_back({"system", "你是一个有着十年经验的C++开发工程师以及AI开发工程师"});
    // messages.push_back({"user", "基于大模型api的流式响应, 简单讲解一下SSE事件是什么?"});

    // 带上历史信息
    // 第一轮用户提问
    messages.push_back({"user", "我叫张三,你是谁?"});
    // 填入上一轮大模型返回的assistant回答,形成历史上下文
    messages.push_back({"assistant", "你好张三!我是一个人工智能助手,很高兴为你服务。"});
    // 新一轮用户提问:模型需要结合上面两条历史一起理解上下文,知道"我叫什么"
    messages.push_back({"user", "我叫什么?"});

    // 6、调用sendMessage发送消息
    auto response = deepseekProvider->sendMessage(messages, requestConfig);
    // 6.1 检查响应是否为空
    ASSERT_TRUE(response.has_value());
}

int main(int argc, char **argv)
{
    // 初始化Google Test框架
    testing::InitGoogleTest(&argc, argv);

    // 初始化日志系统
    Logger::logger_init("testLLM", "stdout", spdlog::level::debug);

    // 运行所有测试用例
    return RUN_ALL_TESTS();
}

测试代码核心设计(和我们之前 SDK 设计保持一致)

  1. modelConfig(初始化配置) :api-key、base-url,属于这个 Provider 实例固定不变的属性,initModel阶段一次性加载;
  2. requestConfig(请求参数):model 名称、temperature、max_tokens 等,每次调用 sendMessage 都可以独立传参,同一个 Provider 实例可以用不同参数发请求;
  3. messages 数组 :维护会话上下文,体现大模型无状态特性,我们自己在 SDK 侧拼接历史消息,传给接口,模型依靠 messages 数组理解多轮对话;
  4. 返回值是std::optional<std::string>:optional 区分「正常拿到回答」和「网络失败 / 报文解析失败 / 接口报错」两种场景,has_value()用来判断是否成功。

问题:为什么不直接硬编码 API Key?

API 密钥属于敏感凭证,如果直接写死在代码里,代码上传仓库会造成密钥泄露 。工程上常用环境变量的方式注入密钥。 操作步骤:

bash 复制代码
vim ~/.bashrc
# 在文件末尾添加
export deepseek_apikey="你的deepseek密钥"
# 生效配置
source ~/.bashrc
  • 执行source ~/.bashrc之后,当前终端以及后续新开终端都能读取到这个环境变量;
  • getenv("deepseek_apikey")就是 C 标准库函数,读取系统环境变量的值;
  • 优势: 代码里不存放密钥,不同模型的 key 可以分开配置,切换模型时只改环境变量即可。

注意: getenv读取不到环境变量时会返回nullptr,代码里做了兜底:取不到就赋值为空字符串,后续initModel内部会校验 key 合法性,测试直接失败。

cpp 复制代码
    // 1、初始化模型_apikey:从modelConfig获取模型apikey,对_apikey进行赋值
    auto it = modelConfig.find("api_key");
    if (it == modelConfig.end() || it->second == "")
    {
        ERR("DeepSeekProvider initModel api_key not found or not config_error");
        return false;
    }
    else
    {
        _apikey = it->second;
    }

1.4.1 CMakeLists.txt 简要说明

CMake 脚本用来帮我们自动生成 Makefile,后续所有 SDK 的测试程序都会统一采用 CMake 方式编译构建,不用手写复杂 g++ 编译命令。

问题:为什么使用 CMakeLists.txt 生成 Makefile,而不是手写 Makefile

手写 Makefile 本身可以完成编译、链接工作,但是在我们这个 SDK 项目场景下,会存在很多短板,CMake 就是用来解决这些痛点的:

  1. 跨平台、跨编译器兼容: 手写 Makefile 是 Linux GNU make 专属写法,语法和规则强绑定 GNU 工具链。如果后续代码需要迁移到 Windows(MinGW/MSVC)、macOS,整套 Makefile 基本要全部重写。 CMake 是上层抽象脚本,一份 CMakeLists.txt,可以根据当前系统,自动生成 Makefile、Ninja 构建文件、VS 的 sln 工程、Xcode 工程,一套脚本多处使用。
  2. 第三方库查找变得简单: 我们项目依赖 gtest、spdlog、jsoncpp、OpenSSL 等多个第三方库。 手写 Makefile 需要我们手动去找到每一个库的头文件路径、库文件路径,手动写 -I、-l 参数,一旦库升级、换台机器安装路径不一样,Makefile 就要改。 CMake 的 find_package() 可以自动检索系统中已经安装好的库,自动拿到头文件目录、链接目标,不用我们手动维护路径。
  3. 大型工程扩展能力更强: 随着我们 SDK 开发推进,后面会新增 GeminiProvider、OpenAIProvider、更多工具组件、更多测试用例,还会拆分多个子模块。 手写 Makefile 随着文件增多,依赖关系、文件列表会越来越臃肿,很容易漏加源码、头文件; CMake 支持子目录 add_subdirectory,方便做模块化拆分,管理多子项目,结构清晰。
  4. **自动处理依赖、编译缓存:**CMake 会帮我们分析源码和头文件之间的依赖关系,配合 Make/Ninja 实现增量编译:只编译改动过的文件。手写 Makefile 如果依赖规则写的不严谨,经常出现改了头文件但是代码不重新编译的问题,引发诡异 bug。
  5. 统一构建规范,适配现代 C++ 工程: C++17、C++20 这类标准设置、编译选项、Debug/Release 编译模式切换,在 CMake 中只需要一行配置; 手写 Makefile 需要手动维护 -std=c++17、-g、-O2 这类编译标记,项目一多很容易各个编译目标配置不一致。

1.4.2 CMakeLists.txt

bash 复制代码
# 设置Cmake最低版本要求
cmake_minimum_required(VERSION 3.10)

# 项目名称
project(testLLM)

# 设置C++标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) # 要求必须使用C++17标准

# 设置构建类型为Debug
set(CMAKE_BUILD_TYPE Debug)

# 添加可执行文件
add_executable(testLLM TestLLM.cpp ../SDK/src/DeepSeekProvider.cpp ../SDK/src/util/MyLog.cpp)

#设置生成可执行文件的输出目录
set(EXECUTABLE_OUTPUT_PATH ${CMAKE_BINARY_DIR})
# ${CMAKE_BINARY_DIR} 表示生成可执行文件的所在目录,即cmake指令执行所在目录

# 添加头文件搜索路径
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../SDK/include)
# ${CMAKE_CURRENT_SOURCE_DIR} 表示当前CMakeLists.txt所在目录
# ${CMAKE_SOURCE_DIR} 表示项目根目录,即所有CMakeLists.txt所在路径中最外层目录

# 查找第三方库:确保在使用前已经通过find_package命令找到并配置了这些库
find_package(GTest REQUIRED)
find_package(fmt REQUIRED)
find_package(spdlog REQUIRED)
find_package(jsoncpp REQUIRED)
find_package(OpenSSL REQUIRED)

# 链接第三方库
target_link_libraries(testLLM gtest fmt spdlog jsoncpp pthread OpenSSL::SSL OpenSSL::Crypto)

要点梳理:

  1. 指定 C++17 标准,Debug 模式方便调试;
  2. add_executable:把测试代码、我们自己写的 Provider 源码、日志组件源码一起参与编译;
  3. find_package自动查找系统装好的第三方库;
  4. target_link_libraries把所有依赖链接进可执行程序;
  5. 使用流程: 在 CMakeLists.txt 所在路径下新建一个 build 目录,用于专门存放Cmake生成的相关编译文件,进入 build 目录下,cmake ..生成构建文件 → make编译 → 运行./testLLM执行测试用例。

测试执行逻辑

运行程序之后,GTest 会自动执行DeepSeekProviderTest.sendMessage这个用例:依次执行对象创建 → 加载密钥初始化 → 组装请求参数 + 上下文消息 → 调用 sendMessage 访问 DeepSeek 云端 API → 校验返回结果。

所有ASSERT_TRUE断言一旦不满足,GTest 会直接终止当前测试用例,打印出错位置,方便定位 SDK 里的 bug。

1.4.3 运行报错:引出httplib问题

当我们调用 DeepSeek 接口测试时,编译正常通过、但是程序运行阶段抛出异常 :"https" scheme is not supported. 这也是使用cpp-httplib访问 HTTPS 接口高频遇到的问题。

报错现象
bash 复制代码
C++ exception with description "'https' scheme is not supported." thrown in the test body.

GTest 测试用例直接标记失败。

我们的请求地址是https://api.deepseek.com,程序识别到 https 协议,但是 httplib 告知不支持 https。

注意区分:编译没有报错,只有运行时报错 ,说明 OpenSSL 库链接已经成功 ,只是 httplib 内部没有开启 HTTPS 支持的代码分支。

根本原理

cpp-httplib 对 HTTPS 的支持是可选编译分支 ,依靠宏 CPPHTTPLIB_OPENSSL_SUPPORT 进行预处理控制:

  • 在httplib.h源码内部,使用#ifdef CPPHTTPLIB_OPENSSL_SUPPORT包裹了所有 OpenSSL 相关的头文件引入、HTTPS 通信逻辑;
  • C/C++ 预处理器是从上到下顺序展开 :只有在 #include <httplib.h> 这一行之前 定义这个宏,预处理器才会进入这个分支,引入<openssl/ssl.h>等 SSL 头文件、编译 HTTPS 相关代码;
  • 如果#include <httplib.h>已经执行完成之后,再去定义这个宏,#ifdef判断已经执行完毕,不会再次生效,HTTPS 功能不会被编译进程序。

重点误区: 不是随便在代码任意位置写 #define CPPHTTPLIB_OPENSSL_SUPPORT 就可以生效!放在 include httplib.h 之后,这个宏完全无效。

正确的修复方案

方案 1:源码顶部宏定义(代码层面)

在包含 httplib.h 之前,第一行就定义宏,顺序不能颠倒

cpp 复制代码
// ✅ 正确:先定义宏,再引入头文件
#define CPPHTTPLIB_OPENSSL_SUPPORT
#include "httplib.h"

// ❌ 错误:先引入头文件,后面再定义宏,完全不起作用
// #include "httplib.h"
// #define CPPHTTPLIB_OPENSSL_SUPPORT

方案 2:CMake 全局添加编译宏

直接在CMakeLists.txt中添加编译定义,不需要改动每一处源码,统一生效:

bash 复制代码
# 给目标添加预定义宏,编译的时候传给编译器
target_compile_definitions(testLLM PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT)

这个方式是在编译命令中增加 -DCPPHTTPLIB_OPENSSL_SUPPORT 参数,编译器在展开所有头文件之前就知道该宏,全局生效,适合 SDK 工程。

测试结果展示

返回结果的关键点:

  • 上下文生效: 我们传入了历史对话消息,模型记住了前面的信息,回复 "你叫张三",证明messages 上下文传递机制正常工作,无状态服务依靠 messages 数组维持会话记忆;
  • 参数生效 :thinking.type = disabled 关闭思考链输出,返回内容直接是最终回答,没有附带 reasoning 思考内容;
  • Token 统计信息返回: usage 字段正常返回,后续 SDK 可以基于这个字段做 token 计数、计费统计功能;
  • 网络链路: httplib 的 HTTPS 分支成功启用,CPPHTTPLIB_OPENSSL_SUPPORT 宏配置生效,https 请求不再抛出异常。

这一次测试通过,代表我们第一阶段目标达成:我们封装的 DeepSeekProvider,已经可以按照约定的 API 规范,完成和 DeepSeek 云端大模型的对话调用,基类接口、JSON 序列化、httplib HTTPS 网络通信、响应解析整条链路跑通。

结束语

到这里,我们已经完成了 DeepSeekProvider 中非流式 sendMessage 接口的全部实现与验证。 我们依托 LLMProvider 抽象契约完成子类开发,按步骤走完了参数校验、报文组装、序列化、HTTPS 请求、响应解析的整条链路;同时在调试中弄懂了 cpp‑httplib 开启 HTTPS 的原理,掌握了 OpenSSL 与编译宏在 CMake 里的规范写法,并且通过 GTest 测试用例,真实验证了上下文消息传递、模型调用、结果返回的整套能力。

非流式接口虽然可以完成基础对话,但是也存在明显局限:必须等待模型全部推理完成之后才一次性返回内容,无法做到逐字输出的流式交互。下一章我们将学习 SSE 协议,基于这套 Provider 框架继续实现流式消息接口,给 SDK 增加实时打字效果的对话能力。

相关推荐
fpcc1 小时前
跟我学c++中级篇—streambuf在glog中的应用分析
开发语言·c++
吾与谁归in1 小时前
C#脚本工具,支持智能提示自动补全(WPF + Roslyn + AvalonEdit)
开发语言·c#·wpf
码匠许师傅1 小时前
【C++三方组件】OpenCV:计算机视觉的事实标准
c++
昂好的day1 小时前
WPF 从零入门(上):WPF 是什么、XAML 语法、布局与样式全解
开发语言·c#·wpf
飞浪纪元[FWC–FE]1 小时前
Qt 构建排错实录:从 MSB 1009 到生成器选型
开发语言·数据库·qt
fly-iot1 小时前
【FDE知识库】(1):AI 编程时代开发语言选型分析,云使用go,企业用java,算法python,底层c
开发语言·人工智能·golang
云边有个稻草人1 小时前
Prometheus Pushgateway教程:离线任务指标上报、Shell与Python示例、远程访问
开发语言·python·prometheus
暴走约伯2 小时前
【VFX工具链源码拆解 01】总体架构:大型项目特效流程的程序化落地
开发语言·经验分享·3d·unity·架构·游戏引擎
Yang96112 小时前
鼎讯信通热剥钳实测:大芯径光纤如何做到无损剥除
网络