《C++ 项目:手写大模型接入 SDK》DeepSeek接口设计(下):流式响应实现和测试

🔥小叶-duck:个人主页

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

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

《Qt 方寸极境》 《MySQL》

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


目录

前言

[一、DeepSeek 流式响应接口实现](#一、DeepSeek 流式响应接口实现)

[1.1 接口定义与职责边界](#1.1 接口定义与职责边界)

[1.1.1 方法签名](#1.1.1 方法签名)

[1.1.2 回调契约](#1.1.2 回调契约)

[1.2 完整实现流程与代码逐步解析](#1.2 完整实现流程与代码逐步解析)

[1.2.1 模型可用性校验](#1.2.1 模型可用性校验)

[1.2.2 构造并获取请求参数](#1.2.2 构造并获取请求参数)

[1.2.3 组装 messages 对话数组](#1.2.3 组装 messages 对话数组)

[1.2.4 构造请求体,开启流式开关](#1.2.4 构造请求体,开启流式开关)

[1.2.5 JSON 序列化 + 构造 httplib 客户端与请求头](#1.2.5 JSON 序列化 + 构造 httplib 客户端与请求头)

[1.2.6 定义全局缓存与状态标记](#1.2.6 定义全局缓存与状态标记)

[1.2.7 构建 httplib::Request 请求对象](#1.2.7 构建 httplib::Request 请求对象)

[1.2.8 response_handler:响应头校验回调](#1.2.8 response_handler:响应头校验回调)

[1.2.9 content_receiver:SSE 分片解析核心回调](#1.2.9 content_receiver:SSE 分片解析核心回调)

[1.2.10 client.send 发起请求 + 结果判断](#1.2.10 client.send 发起请求 + 结果判断)

[补充 client.send 原理分析](#补充 client.send 原理分析)

[1.2.11 流式响应接口完整代码(附注释分析)](#1.2.11 流式响应接口完整代码(附注释分析))

[1.3 补充:编译问题定位与修复](#1.3 补充:编译问题定位与修复)

[1.3.1 错误现象](#1.3.1 错误现象)

[1.3.2 根因分析](#1.3.2 根因分析)

[1.3.3 修复方案](#1.3.3 修复方案)

[1.3.4 小结](#1.3.4 小结)

二、流式响应单元测试

[2.1 初始化与配置](#2.1 初始化与配置)

[2.2 入口函数](#2.2 入口函数)

[2.3 运行结果展示](#2.3 运行结果展示)

[2.4 引出疑问](#2.4 引出疑问)

[2.4.1 先解释:服务器的"贴心",大概率发送完整的 SSE](#2.4.1 先解释:服务器的“贴心”,大概率发送完整的 SSE)

[2.4.2 现实的"残酷":防人之心不可无](#2.4.2 现实的“残酷”:防人之心不可无)

[2.4.3 结论:日志里看不到半包 ≠ 不需要 buffer + while 兜底](#2.4.3 结论:日志里看不到半包 ≠ 不需要 buffer + while 兜底)

总结:

结束语


前言

前面章节我们已经完成了 DeepSeek 非流式接口的封装、调试与单元测试,理解了如何通过 httplib 发起 HTTP 请求、解析 JSON 返回结果。但大模型对话产品最常用、体验更好的流式输出能力,依赖 SSE 协议实现,这也是我们本章要攻克的核心内容。

流式接口的实现远比一次性返回的全量响应复杂,它不再是一次请求、一次应答的简单模式,而是依靠分段回调持续接收服务端推送的数据。这里存在两层容易混淆的数据边界:HTTP Chunk 分片边界和 SSE 事件边界,半包解析、数据缓存、事件过滤都是开发中极易踩坑的地方。

本章将从接口职责定义入手,一步步拆解 DeepSeek 流式接口的完整实现流程:从参数组装、请求构建,到响应头校验、content_receiver分片解析回调的核心编码;同时结合我们实际跑通的测试日志,解答一个很关键的疑问:测试时每次收到的 Chunk 里 SSE 事件都是完整的,为什么代码仍然必须做 Buffer 缓存与循环切分的防御性解析。

除此之外,我们也会记录开发途中遇到的编译报错问题,梳理对应的定位思路与修复方案,最后借助 GTest 完成流式接口的单元测试,验证整套流式 SDK 链路的可用性。读完本章,你将掌握在 C++ 中基于 httplib 实现 SSE 流式大模型调用的完整工程写法,理解流式解析背后的协议细节与防御性编码思想。

一、DeepSeek 流式响应接口实现

在前一章我们吃透了 HTTP Chunk、SSE 事件块、httplib 流式回调的底层规则与各类边界异常场景,本章就落地编码,实现sendMessageStream流式增量接口,完成大模型打字机逐字输出效果。

1.1 接口定义与职责边界

1.1.1 方法签名

cpp 复制代码
namespace ai_chat_sdk {
class DeepSeekProvider {
public:
    /**
     * @brief 发送消息并接收流式增量响应
     * @param messages 历史对话消息列表
     * @param requestParam 请求参数映射(temperature、max_tokens等)
     * @param callback 增量数据回调函数,由调用方实现业务逻辑
     * @return std::string 完整的响应文本(所有增量拼接结果)
     */
    std::string sendMessageStream(
        const std::vector<Message>& messages,
        const std::map<std::string, std::string>& requestParam,
        std::function<void(const std::string &response, bool isEnd)> callback
    );
};
}

这个接口作为上层对外调用入口,返回值 是拼接完成后的完整回答内容;核心交互依靠传入的回调函数向外推送分片数据。

1.1.2 回调契约

回调函数 **std::function<void(const std::string &response, bool isEnd)>**是流式 SDK 解耦的关键,约定如下:

  • 第一个入参:模型返回的增量文本片段,仅携带本次新生成的内容,不是完整回复;
  • 第二个入参:流结束标记。true代表所有增量数据推送完毕,流正常结束;false代表还有后续分片;
  • 职责划分:SDK 只负责完成 HTTP 请求、SSE 协议解析、提取增量文本并触发回调;打印、界面渲染、持久化存储这类业务逻辑全部交给上层调用者实现,底层 Provider 不耦合业务。

1.2 完整实现流程与代码逐步解析

1.2.1 模型可用性校验

cpp 复制代码
if (!IsAvailable())
{
    ERR("DeepSeekProvider sendMessageStream model not available");
    return std::nullopt;
}

第一步做基础状态检查,判断当前 Provider 是否已经完成初始化、配置好api-key与baseUrl,未就绪直接返回,提前拦截非法调用。

1.2.2 构造并获取请求参数

我们把参数分为两类:一类是固定在基类的连接配置,另一类是每次请求可变的请求参数,放在requestParam中传入。

cpp 复制代码
// 2.1 设置参数默认值
double temperature = 0.7;
int max_tokens = 2048;
std::string thinking_type = "disabled";
std::string reasoning_effort = "high";
std::string model;

// 2.2 从入参map覆盖默认配置
auto it = requestParam.find("temperature");
if (it != requestParam.end())
{
    temperature = std::stod(it->second);
}
it = requestParam.find("max_tokens");
if (it != requestParam.end())
{
    max_tokens = std::stoi(it->second);
}
it = requestParam.find("thinking_type");
if (it != requestParam.end())
{
    thinking_type = it->second;
}
it = requestParam.find("reasoning_effort");
if (it != requestParam.end())
{
    reasoning_effort = it->second;
}

// model 模型名称为必填项,调用方必须传入
it = requestParam.find("model");
if (it != requestParam.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;

参数设计逻辑:temperature、max_tokens等提供默认值,调用方按需覆盖;而model是每次对话请求必须指定,不提供默认值,缺失直接报错返回。

1.2.3 组装 messages 对话数组

循环遍历传入的历史消息列表,按照 DeepSeek 接口文档要求,组装role和content字段,构造 JSON 数组,携带上下文实现多轮对话。

cpp 复制代码
Json::Value messageArray = Json::arrayValue;
for (auto& message : messages)
{
    Json::Value messageObject;
    messageObject["role"] = message._role;
    messageObject["content"] = message._content;
    messageArray.append(messageObject);
}

1.2.4 构造请求体,开启流式开关

重点:stream:true是开启增量流式返回的核心标识,不配置这个字段,接口会直接返回一次性全量应答,不会走 SSE 流式推送。

cpp 复制代码
Json::Value requestBody;
requestBody["model"] = model;
requestBody["messages"] = messageArray;
requestBody["temperature"] = temperature;
requestBody["max_tokens"] = max_tokens;
requestBody["thinking"] = thinking_type;
requestBody["reasoning_effort"] = reasoning_effort;
requestBody["stream"] = true;

1.2.5 JSON 序列化 + 构造 httplib 客户端与请求头

序列化 JSON 结构体得到字符串,同时配置流式请求必须的 HTTP 请求头:Accept: text/event-stream告诉服务端客户端可以接收 SSE 事件流。

cpp 复制代码
Json::StreamWriterBuilder writerbuilder;
std::string requestBodyStr = Json::writeString(factory(writerbuilder), requestBody);

httplib::Client client(_baseUrl);
client.set_connection_timeout(20, 0); // 连接超时时间20秒
client.set_read_timeout(300, 0); // 作为流式响应,读取时间相对较长,设置超时时间为300秒

httplib::Headers headers = {
    {"Accept", "text/event-stream"},
    {"Content-Type", "application/json"},
    {"Authorization", "Bearer " + _apikey}
};

1.2.6 定义全局缓存与状态标记

这一组变量用来解决上篇文章重点讲解的半包、粘包 问题,是SSE 解析的状态容器:

cpp 复制代码
std::string buffer;       // 缓存未处理完的分片数据,拼接半包
std::string fullResponse; // 累加所有增量,最终作为函数返回值
bool streamEnd = false;   // 流结束标记
bool isError = false;     // 全局错误标记
std::string errorMsg;     // 用于存储错误描述信息

1.2.7 构建 httplib::Request 请求对象

cpp 复制代码
httplib::Request request;
request.method = "POST";
request.path = "/v1/chat/completions";
request.headers = headers;
request.body = requestBodyStr;

这一步只是把静态的请求信息填充完毕,还没有发起网络请求 。填充完基础成员之后,我们紧接着给这个 request 对象挂载response_handler和content_receiver两个流式回调,这也是流式请求和普通全量请求最大的区别。

1.2.8 response_handler:响应头校验回调

这个回调只会在收到 HTTP 响应头部时执行一次 ,用来校验状态码,捕获连接建立之后的 HTTP 层面错误。

cpp 复制代码
request.response_handler = [&](const httplib::Response &res) -> bool
{
    if (res.status != 200)
    {
        isError = true;
        ERR("DeepSeekProvider sendMessageStream status not 200, status: {}, body: {}", res.status, res.body);
        return false;
    }
    return true;
};

返回false代表终止本次请求,不再接收后续 chunk 数据;返回true代表正常接收流式 body 分片,进入content_receiver。

1.2.9 content_receiver:SSE 分片解析核心回调

该回调会在每收到一个完整 HTTP Chunk 时进行触发,我们在这里完成 SSE 事件的缓存、切分、解析,也是处理半包粘包的核心位置。

cpp 复制代码
request.content_receiver = [&](const char *data, size_t data_length,
    uint64_t offset, uint64_t total_length) -> bool
{
    if (isError)
    {
        return false;
    }
    // 1. 先把新收到的数据追加到缓冲区,解决半包问题
    buffer.append(data, data_length);

    // 2. 循环切割完整SSE事件块,分隔符是 \n\n
    size_t pos;
    while ((pos = buffer.find("\n\n")) != std::string::npos)
    {
        std::string event = buffer.substr(0, pos);
        buffer.erase(0, pos + 2);

        // 3. 逐行解析单条SSE事件,过滤注释行、空行
        std::istringstream eventStream(event);
        std::string line;
        std::string modeldata;
        while (std::getline(eventStream, line))
        {
            // 跳过空行、SSE注释心跳行(以:开头)
            if (line.empty() || line[0] == ':')
            {
                continue;
            }
            // 提取data: 后面的JSON字符串
            if (line.compare(0, 6, "data: ") == 0)
            {
                modeldata = line.substr(6);
            }
        }

        // 4. 判断流结束标记 [DONE]
        if (modeldata == "[DONE]")
        {
            streamEnd = true;
            callback("", true);
            return true;
        }

        // 5. 反序列化data后的json,提取delta增量内容
        Json::Value modeldataJson;
        Json::CharReaderBuilder readerbuilder;
        std::istringstream modeldataStream(modeldata);
        bool ret = Json::parseFromStream(readerbuilder, modeldataStream, &modeldataJson, &errorMsg);
        if (!ret)
        {
            WARN("DeepSeekProvider sendMessageStream parseFromStream error, errorMsg: {}", errorMsg);
            // 单条事件解析失败不阻断整个流,继续处理下一个事件
            continue;
        }

        // 6. 取出choices[0].delta.content增量文本
        if (modeldataJson.isMember("choices") && modeldataJson["choices"].isArray() && !modeldataJson["choices"].empty())
        {
            auto& firstChoice = modeldataJson["choices"][0];
            if (firstChoice.isMember("delta") && firstChoice["delta"].isMember("content"))
            {
                std::string datacontent = firstChoice["delta"]["content"].asString();
                fullResponse += datacontent;
                // 向外推送增量分片,结束标记false
                callback(datacontent, false);
            }
        }
    }
    return true;
};

核心设计要点:

  1. 缓冲区持续追加数据,保证跨 Chunk 的 SSE 事件可以拼接完整;
  2. 用\n\n切分独立 SSE 事件,处理粘包(一个 Chunk 包含多条 SSE 事件);
  3. 过滤心跳注释行,识别[DONE]结束标识;
  4. 单条 JSON 解析异常只告警、不中断整条流,提升鲁棒性。

1.2.10 client.send 发起请求 + 结果判断

cpp 复制代码
auto result = client.send(request);
// send返回值只代表请求是否成功发出去,只检查连接阶段错误
if (!result)
{
    ERR("DeepSeekProvider sendMessageStream send request ERROR: {}", httplib::to_string(result.error()));
    return "";
}

// 兜底:服务端异常断开,没有返回[DONE]结束标记
if (!streamEnd)
{
    WARN("DeepSeekProvider stream end used without [DONE] marker");
    callback("\n\n[系统提示: 连接意外中断,请重试]", true);
}
return fullResponse;
补充 client.send 原理分析

cpp-httplib 的 send 存在两种工作模式,区分的核心条件就是是否挂载了 content_receiver 流式接收回调:

  1. 普通阻塞模式(无 content_receiver) send 会阻塞直到服务端把全部响应报文传输完毕,返回的 result 里面携带完整响应体、状态码;返回结果代表整次请求交互是否全部完成,适合普通非流式接口调用。
  2. 流式非阻塞接收模式(挂载 content_receiver,也就是我们当前 SSE 场景)

注意: 这里说的非阻塞不是指套接字非阻塞 IO ,是指send函数不会等待全部数据返回才返回。

  • send 函数的返回值 result 仅负责校验连接前置阶段的错误:DNS 解析失败、TCP 连接建立失败、连接超时这类还没和服务端正常握手的问题。
  • TCP 连接成功、请求报文成功发出去之后,send 就会返回;后续分片的数据、HTTP 状态码、SSE 的 data 流,全部交给 response_handler 和 content_receiver 回调去处理。
  • HTTP 401、404、429、500 这类状态码,属于连接建立之后的业务层错误,不会由 send 的返回值捕获 ,需要在 response_handler 里面判断 res.status。

两层错误边界小结:

  • send 返回值:连接建立前 / 建立阶段的底层网络错误(DNS、TCP 连接失败)
  • response_handler:连接成功之后的 HTTP 状态码、鉴权、限流等业务错误
  • streamEnd 标记兜底:连接成功,但 SSE 流中途异常断开,没有收到 DONE 结束包

1.2.11 流式响应接口完整代码(附注释分析)

cpp 复制代码
// 发送消息:增量调用,实时返回响应
std::optional<std::string> DeepSeekProvider::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)
{
    // 1、检查模型是否有效
    if (!IsAvailable())
    {
        ERR("DeepSeekProvider sendMessageStream model not available");
        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["thinking"] = Type;
    requestBody["reasoning_effort"] = reasoning_effort;
    // 关键:流式响应需要设置stream为true(否则是全量响应会返回完整的响应体,而不是增量响应)
    requestBody["stream"] = true;

    // 4、对requestBody进行序列化
    Json::StreamWriterBuilder writerbuilder;
    std::string requestBodyStr = Json::writeString(writerbuilder, requestBody);
    INFO("DeepSeekProvider sendMessage requestBody: {}", requestBodyStr);

    // 5、使用cpp-httplib构建HTTP客户端
    // 5.1设置超时时间
    httplib::Client client(_baseUrl);
    client.set_connection_timeout(20, 0); // 连接超时时间20秒
    client.set_read_timeout(300, 0);      // 作为流式响应,读取时间相对较长,设置超时时间为300秒
    // 5.2 构建请求头(关键:相较于非流式响应,流式响应需要设置Accept为text/event-stream)
    httplib::Headers headers = {
        {"Accept", "text/event-stream"},
        {"Content-Type", "application/json"},
        {"Authorization", "Bearer " + _apikey}};

    // 到这里我们能像全量响应那样,直接将序列化后的请求POST到模型的接口吗?不能
    // 因为流式响应是增量返回,我们作为用户需要自己去逐行解析每一个响应体,那我们如何解析?在哪解析?
    // 答案:我们需要先创建一个Request对象,并且设置一下相关参数,
    // 而其中 response_handler 以及 content_receiver 这两个参数就是用来处理响应的回调函数,我们需要自己手动实现这两个函数,在其中解析响应体。
    // 在构建请求对象之前,我们可以先设置一些流式处理变量,用于处理回调过程中的相关逻辑。

    std::string buffer;       // 用于存储每次读取到的Chunk数据块中的内容
    std::string fullResponce; // 用于累积存储获取到的完整的响应体
    bool streamEnd = false;   // 用于标记流式响应是否结束
    bool isError = false;     // 用于标记是否发生错误
    std::string errorMsg;     // 用于存储错误描述信息

    // 6、构建请求对象
    httplib::Request request;
    request.method = "POST";
    request.path = "/v1/chat/completions";
    request.headers = headers;
    request.body = requestBodyStr;

    // 6.1 构建响应处理器回调函数:response_handler
    // 关键:response_handler 函数会在收到响应头时被调用,我们可以在其中判断响应是否正常(该回调函数只调用一次),
    // 如果响应异常,我们可以直接返回false,结束请求。
    // 如果响应正常,我们可以返回true,继续处理响应体。
    request.response_handler = [&](const httplib::Response &res) -> bool
    {
        // 如果响应状态码不是200,说明响应异常,我们可以直接返回false,结束请求。
        if (res.status != 200)
        {
            isError = true;
            ERR("DeepSeekProvider sendMessageStream status not 200, status: {}, body: {}", res.status, res.body);
            return false;
        }
        return true; // 如果响应状态码是200,说明响应正常,我们可以返回true,继续处理响应体。
    };

    // 6.2 构建数据接收处理器回调函数:content_receiver
    // 关键:content_receiver 函数会在收到完整Chunk数据块时被调用,我们可以在其中解析响应体(该回调函数会被多次调用),
    // 如果解析过程中发生严重错误,我们可以直接返回false,结束请求。
    // 如果解析过程正常,我们可以返回true,继续处理下一个数据块。
    request.content_receiver = [&](const char *data, size_t data_length,
                                   uint64_t offset, uint64_t total_length) -> bool
    {
        // 1、验证前面的响应头是否出错,出错了,直接返回false结束请求,不需要再接收数据
        if (isError)
        {
            return false;
        }

        // 2、追加当前所有的事件块到缓冲区,保证每次处理的事件块都是完整的
        buffer.append(data, data_length);
        INFO("DeepSeekProvider sendMessageStream data: {}", buffer);

        // 3、解析缓冲区中的事件块,判断是否为完整的 SSE 事件块
        size_t pos;
        while ((pos = buffer.find("\n\n")) != std::string::npos)
        {
            // 3.1 截取当前找到的事件块
            std::string event = buffer.substr(0, pos);
            buffer.erase(0, pos + 2); // 移除已处理的事件块,避免后续解析时重复处理

            // 3.2 关键处理:解析当前事件块中的每一行
            // 注意:不能直接对event进行解析,并且利用event判断是否为空行或者以":"开头的注释行,
            // 因为当前的事件块中可能不仅仅只有空行或者以":"开头的注释行,可能连带着后续部分的"data: "行一起发送过来了
            // 如果直接对event进行解析,并且判断第一个字符':',就会误判为只有注释行而进行continue,
            // 就会导致后续的"data: "行连同注释行一起被删除了,从而导致data数据部分丢失
            // 所以我们需要借助输入流通过std::getline()函数逐行解析当前事件块,这样就能确保每个事件行都是独立的,不会被错误解析
            std::istringstream eventStream(event);
            std::string line;
            std::string modeldata;
            while (std::getline(eventStream, line))
            {
                // 过滤空行和注释行
                if (line.empty() || line[0] == ':')
                {
                    continue;
                }
                // 判断是否为data行,提取data数据部分
                if (line.compare(0, 6, "data: ") == 0)
                {
                    // 直接从第6个字符截取到最后即可,line为一行的数据,不会将data换行符之后的无效数据包含进来
                    modeldata = line.substr(6);
                    BUG("DeepSeekProvider sendMessageStream eventType: {}", modeldata);
                }
            }

            // 检测是否为结束标记
            if (modeldata == "[DONE]")
            {
                streamEnd = true;
                callback("", true);
                return true;
            }
            // 3.3 解析当前事件块中的Json字符串 -- 反序列化
            Json::Value modeldataJson;
            Json::CharReaderBuilder readerbuilder;
            std::istringstream modeldataStream(modeldata);
            bool ret = Json::parseFromStream(readerbuilder, modeldataStream, &modeldataJson, &errorMsg);
            if (!ret)
            {
                WARN("DeepSeekProvider sendMessageStream parseFromStream error, errorMsg: {}", errorMsg);
                // 解析失败,只能说当前事件块中包含了无效的Json数据,直接跳出当前事件块的数据解析,继续处理下一个事件块
                // 不能直接返回false,否则就会导致后续的事件块都无法解析,从而导致请求失败
                continue;
            }

            // 从modeldataJson中提取响应内容中content字段
            // 模型返回的响应中的choices字段是一个Json数组,数组中每一个元素包含delta字段,delta字段中包含content字段,content字段就是模型生成的回复
            // 先检查 choices 是否存在且是数组且不为空 -- 保证我们能获取到有数据的choices字段
            if (modeldataJson.isMember("choices") && modeldataJson["choices"].isArray() && !modeldataJson["choices"].empty())
            {
                Json::Value firstChoice = modeldataJson["choices"][0];
                // 再检查choices数组中是否存在delta字段且delta字段中是否存在content
                if (firstChoice.isMember("delta") && firstChoice["delta"].isMember("content"))
                {
                    std::string datacontent = firstChoice["delta"]["content"].asString();
                    // 将获取到的模型回复的datacontent追加到fullResponce
                    fullResponce += datacontent;

                    // 最后将本次当前解析得到的事件块中的content内容传给调用sendMessageStream的用户查看 -- 回调处理
                    callback(datacontent, streamEnd);
                }
            }
        }
        // 到这,说明当前数据块chunk中的所有事件块已经处理完,继续等待后续数据块的到来
        return true;
    };
    // 写到这一定要注意:上面我们所写的处理过程只是针对Resquest对象设置参数,我们还没有发送请求,
    // 以我们需要调用client.send(request)发送请求,才能获取到服务器的响应

    // 7、发送请求,获取服务器响应
    auto result = client.send(request);
    // 关键点:Post的返回值和send的返回值虽然都是httplib::Result类型,但是含义完全不一样
    // send的返回值针对的是请求发送阶段,而Post的返回值针对的是响应接收阶段

    // 所以,我们并不是拿着send的返回值获取服务器响应,我们也不需要在这里获取,为什么?
    // 因为服务器在一次次发送数据块的时候,会调用上面我们所写的content_receiver回调函数,
    // 而在回调函数中,我们已经将每次接收到的数据块中的content内容拼接起来,完成了响应内容的获取
    // 所以,send的返回值只是用来判断我们的请求发送是否是成功的,也就是说如果是成功,我们自然也就获取到了响应内容

    // 7.1 判断请求发送是否成功
    if (!result)
    {
        // 发送请求失败,可能出现网络问题,比如DNS解析失败,连接超时等等
        ERR("DeepSeekProvider sendMessageStream send request error: {}", httplib::to_string(result.error()));
        // ERR("DeepSeekProvider sendMessageStream send request error: {}", static_cast<int>(result.error()));
        // 这个写法也可以,上面是直接使用httplib中实现的to_string函数直接将Error枚举值转换为字符串
        // 这个写法是通过显式转换成int类型
        // ERR("DeepSeekProvider sendMessageStream send request error: {}", result.error()); //error
        // error()返回类型是Error,是一个enum class,enum class 不同于普通的enum,enum可以隐式类型转换,而enum class不能
        return std::nullopt;
    }

    // 特殊情况:streamEnd为false,也就是说我们获取完响应内容,但是没有收到[DONE]结束标记
    // 这种情况可能是过程中出现了一些问题,但是我们已经获取到了响应内容,所以我们只能警告一下用户,告诉用户可能会存在一些问题
    // 注意:当我们给用户一个警告后,必须再次进行回调处理,手动将第二个参数设置成true,否则上面用户层的循环会继续运行,导致无限等待
    if (!streamEnd)
    {
        WARN("DeepSeekProvider stream ended without [DONE] marker");
        // 手动将第二个参数设置成true,告诉用户我们已经获取到了响应内容,但是没有收到[DONE]结束标记
        callback("\n\n[系统提示: 连接意外中断,请重试]", true);
    }

    return fullResponce;
}

1.3 补充:编译问题定位与修复

1.3.1 错误现象

编译报错:spdlog 日志格式化宏无法直接格式化 httplib::Error 类型参数,提示类型不匹配、格式化失败。

触发代码(原始报错写法):

cpp 复制代码
ERR("DeepSeekProvider sendMessageStream send request ERROR: {}", result.error());

1.3.2 根因分析

result.error() 返回的 httplib::Error 是 C++11 强类型枚举 enum class,这是报错的核心根源:

  1. 普通enum是无作用域枚举,会隐式转为整型;但enum class(强类型枚举)默认禁止隐式类型转换,编译器不允许它自动转为 int、字符串等类型。
  2. spdlog 的格式化库没有对httplib::Error这个强枚举做自动特化适配,无法识别该枚举类型,直接传入会触发编译失败。
  3. 虽然 cpp-httplib 库内部已经提供了配套的 httplib::to_string(Error err) 转换函数,也重载了operator<<,但 spdlog 格式化逻辑不会自动调用这两个接口,必须由开发者显式调用。

拓展理解: static_cast<int>(err) 也可以显式把 enum class 类型转为底层整数。

static_cast 的含义不是 "保证转换一定正确",而是标记这里是程序员主动承担类型转换风险。

编译器放开转换权限的底层逻辑:enum class 在内存底层本质就是整型存储;标准只是禁止隐式转换 ,并没有禁止我们主动显式转换 。

两种修复方案对比:

  • httplib::to_string(result.error()):优先推荐,直接拿到可读性强的错误文本(比如ConnectionFailed),日志便于排查问题。
  • static_cast<int>(result.error()):仅拿到原始枚举数字码,可读性差,只适合只需要错误编号的场景。

1.3.3 修复方案

cpp 复制代码
// 修复前:编译失败
ERR("DeepSeekProvider sendMessageStream send request ERROR: {}", result.error());

// 方案1【推荐】:调用httplib自带to_string,转为可读字符串
ERR("DeepSeekProvider sendMessageStream send request ERROR: {}", httplib::to_string(result.error()));

// 方案2:static_cast显式转为int(仅输出错误码)
// ERR("DeepSeekProvider sendMessageStream send request ERROR: {}", static_cast<int>(result.error()));

1.3.4 小结

  • 报错本质:强类型枚举 enum class 禁止隐式转换 + spdlog 没有内置该类型格式化支持
  • 不要直接裸传 enum class 给 spdlog 格式化宏,必须手动做类型转换
  • 工程上优先使用库自带的to_string,日志可读性更强,方便定位网络类错误;static_cast<int>仅作为备选方案

二、流式响应单元测试

2.1 初始化与配置

cpp 复制代码
#include "../SDK/include/DeepSeekProvider.h"
#include <gtest/gtest.h>
#include <spdlog/common.h>

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、调用ssendMessageStreame发送消息
    auto response = deepseekProvider->sendMessageStream(
        messages, requestConfig,
        [&](const std::string &chunk, bool isEnd)
        {
            INFO("chunk: {}", chunk);
            if (isEnd)
            {
                INFO("[DONE]");
            }
        });
    // 6.1 检查响应是否为空
    ASSERT_TRUE(response.has_value());
    INFO("response: {}", response.value());
}

2.2 入口函数

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

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

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

2.3 运行结果展示

cpp 复制代码
# 进入构建目录
cd test/build

# 生成构建文件
cmake ..

# 清理并编译
make clean
make

# 运行测试程序
./testLLM

这是内容拼接完的最终完整样子,那流式的效果是什么样的呢?如下面所示:

2.4 引出疑问

看到上面的日志打印结果想必大家心里一定会存在一个问题:

DeepSeek 返回的每一次 content_receiver 回调拿到的 HTTP Chunk 里面,包裹的全部是完整、可直接分割的 SSE 事件 ,没有出现半截、截断的 SSE 报文,这是为什么?

不管是前面我们学习SSE协议还是上篇文章我们对 httplib Request 参数进行讲解,都提到了服务器发送过来的报文所携带的HTTP chunk数据块内的SSE事件块可能存在残缺的情况,但是日志打印的结果完全没有出现这种情况,难道是我们前面讲错了吗?

2.4.1 先解释:服务器的"贴心",大概率发送完整的 SSE

DeepSeek 这种大模型服务端,在底层发送流式数据时,通常是由其 Web 框架(比如 Python 的 FastAPI、Go 的 Gin 等)控制的。

服务端在 yield 数据时,通常是按逻辑块吐出的。网络协议栈在打包成 HTTP Chunk 时,往往(在绝大多数网络状况良好的情况下)会把一个完整的 data: {...}\n\n 作为一个整体塞进 Chunk 里。这大大减轻了客户端的解析压力。

2.4.2 现实的"残酷":防人之心不可无

尽管服务端会尽量发完整的,但网络传输是物理行为,不可控因素太多了:

  • 网络中间设备(代理、网关):有时候 Nginx 等中间件会对流进行重新切分。

  • TCP 的 Nagle 算法:可能把包合并或者拆分。

  • 极端的长文本 :如果某次模型吐出的单字特别长(或者包含极大的 Base64 图片数据),导致一个 SSE Event 的长度超过了一个 HTTP Chunk 的最大限制,服务端就不得不把它切开发送。

也就是说:服务端只是 "尽量按完整 SSE 打包" ,协议层面没有任何承诺保证 SSE 不会跨 chunk。

2.4.3 结论:日志里看不到半包 ≠ 不需要 buffer + while 兜底

正因为上述的**"不确定性"** ,我们所设计的代码就绝不能赌"服务端每次发的都是完整的"。服务端可以帮我们减少半包概率,但是客户端不能把业务正确性寄托在服务端和网络 "好心不拆包" 上。

所以,最标准、最无懈可击的处理方案:

"将残留的SSE保存到buffer等待下次数据到来进行追加"

这个过程就表现为:

  1. 数据来了,无脑塞进 buffer。

  2. while 找 \n\n。找到了切出来,处理完并删除;找不到就不管。

  3. 如果这次是个"半包",buffer 里就会留下类似 data: {"content": "问我 这样的残留。

  4. 下次 content_receiver 被触发,新的数据 "}}\n\n 追加进 buffer。

  5. 瞬间凑齐,while 成功切出完整的 data: {"content": "问我"}\n\n,清理 buffer。

总结:

服务端负责"尽量发得完整",客户端负责"不管你怎么发,我都能拼完整"。

我们本次测试观测到所有 HTTP chunk 内 SSE 都是完整的,是 DeepSeek 服务框架在良好网络下的优化打包行为;HTTP 协议本身不约束 SSE 事件不能跨 HTTP chunk,弱网、长内容、反向代理场景下一定会产生 SSE 半包,因此 buffer 缓存、循环切分的解析逻辑是必须保留的。

结束语

到这里,DeepSeek 流式响应接口的代码实现、问题排错、单元测试与协议层面的思考就全部完成了。

我们顺着目录一步步落地了 SSE 流式调用链路,不仅写出了可运行的 C++ 代码,更厘清了 HTTP Chunk 和 SSE 事件两套独立的分片边界。从实际测试日志中我们看到理想环境下服务端常常返回完整 SSE 事件,但也明白了这只是服务框架带来的便利,不能作为编码的前提,buffer + while的半包处理是面向公网、弱网、反向代理场景必不可少的防御逻辑。同时我们也记录了开发过程里遇到的编译问题,把踩坑经验沉淀下来。

流式接口是大模型 SDK 里复杂度很高的一环,也是聊天类业务的核心能力。掌握这部分之后,我们的 SDK 已经同时支持非流式、流式两种调用模式。后续我们可以基于当前这套 Provider 抽象,继续接入其他厂商的大模型服务,进一步完善整个 SDK 的扩展性。

相关推荐
AI源码1 小时前
基于 Python 的短视频用户行为分析系统
开发语言·python
其实防守也摸鱼1 小时前
信创安全竞赛知识总结与题库
大数据·网络·人工智能·算法·安全·php
万联WANFLOW1 小时前
异地厂区视频监控远程运维:从组网到安全的实施要点
运维·网络·安全·企业组网·sdwan
旖旎夜光2 小时前
【C++ 基础入门】类和对象(上):封装、对象模型与 this 指针
开发语言·c++·笔记·学习
揽星河..2 小时前
【C语言】结构体
c语言·开发语言
衍玊2 小时前
深入剖析 TCP/IP 网络通信原理与内核实现
服务器·网络·tcp/ip
2601_966949652 小时前
如何封装 qd.klines.batch(),让批量 K 线查询函数在多个策略中复用?
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
.Bryce.2 小时前
从零到一开发技术名词大全:从需求分析到 AI Agent 工程
java·网络·人工智能·agent·需求分析·agent评测
小超人诞生日记3 小时前
C++ 智能指针的使用及其原理详解
c++·算法