云计算百科
云计算领域专业知识百科平台

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

 

🔥小叶-duck:个人主页

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

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


目录

前言

一、DeepSeek 流式响应接口实现

  1.1 接口定义与职责边界

    1.1.1 方法签名

    1.1.2 回调契约

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

    1.2.1 模型可用性校验

    1.2.2 构造并获取请求参数

    1.2.3 组装 messages 对话数组

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

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

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

    1.2.7 构建 httplib::Request 请求对象

    1.2.8 response_handler:响应头校验回调

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

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

    补充 client.send 原理分析

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

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

    1.3.1 错误现象

    1.3.2 根因分析

    1.3.3 修复方案

    1.3.4 小结

二、流式响应单元测试

  2.1 初始化与配置

  2.2 入口函数

  2.3 运行结果展示

  2.4 引出疑问

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

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

    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 方法签名

namespace ai_chat_sdk {
class DeepSeekProvider {
public:
/**
* @brief 发送消息并接收流式增量响应
* @param messages 历史对话消息列表
* @param requestParam 请求参数映射(temperature、max_tokens等)
* @param callback 增量数据回调函数,由调用方实现业务逻辑
* @return std::optional<std::string> 完整的响应文本(所有增量拼接结果)
*/
std::optional<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 模型可用性校验

if (!IsAvailable())
{
ERR("DeepSeekProvider sendMessageStream model not available");
return std::nullopt;
}

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

    1.2.2 构造并获取请求参数

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

// 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 数组,携带上下文实现多轮对话。

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 流式推送。

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 事件流。

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 解析的状态容器:

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

    1.2.7 构建 httplib::Request 请求对象

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 层面错误。

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 事件的缓存、切分、解析,也是处理半包粘包的核心位置。

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;
};

核心设计要点:

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

    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 流式接收回调:

  • 普通阻塞模式(无 content_receiver) send 会阻塞直到服务端把全部响应报文传输完毕,返回的 result 里面携带完整响应体、状态码;返回结果代表整次请求交互是否全部完成,适合普通非流式接口调用。
  • 流式非阻塞接收模式(挂载 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 流式响应接口完整代码(附注释分析)

    // 发送消息:增量调用,实时返回响应
    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 类型参数,提示类型不匹配、格式化失败。       触发代码(原始报错写法):

    ERR("DeepSeekProvider sendMessageStream send request ERROR: {}", result.error());

        1.3.2 根因分析

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

  • 普通enum是无作用域枚举,会隐式转为整型;但enum class(强类型枚举)默认禁止隐式类型转换,编译器不允许它自动转为 int、字符串等类型。
  • spdlog 的格式化库没有对httplib::Error这个强枚举做自动特化适配,无法识别该枚举类型,直接传入会触发编译失败。
  • 虽然 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 修复方案

    // 修复前:编译失败
    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 初始化与配置

    #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 入口函数

    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 运行结果展示

    # 进入构建目录
    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等待下次数据到来进行追加”

          这个过程就表现为:

  • 数据来了,无脑塞进 buffer。

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

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

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

  • 瞬间凑齐,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 的扩展性。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 《C++ 项目:手写大模型接入 SDK》DeepSeek接口设计(下):流式响应实现和测试
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!