🔥小叶-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 的扩展性。