🔥小叶-duck:个人主页
❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《Linux系统从入门到实践》《Linux网络从入门到实践》
《Qt 方寸极境》 《MySQL》
✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游
目录
前言
一、HTTP 普通响应与流式 (Chunked) 响应基础
1.1 HTTP 普通响应
1.2 HTTP 流式 Chunked 响应
1.3 普通响应与流式响应核心差异
1.4 HTTP Chunk 数据块 vs SSE Event 事件块(代码实现的关键)
1.4.1 层级关系:包裹与被包裹
1.4.2 核心认知:两种“完整性”并不对齐
1.4.3 客户端的加工厂:如何让 Chunk 变回 SSE
1.4.4 特殊情况:SSE 心跳包
二、httplib Request 结构体详解
2.1 Request 结构体完整定义
2.2 通用成员参数解析
2.2.1 method
2.2.2 path
2.2.3 Params params(查询参数 / Query 参数)
2.2.4 Headers headers
2.2.5 std::string body
2.2.6 path_params 路径参数(路由参数)
2.3 客户端专用回调参数(流式响应核心)
2.3.1 response_handler:奠定基调的“开场白”
2.3.2 content_receiver:剖析数据流的“手术刀”
2.4 结合模拟五大场景的实战推演
场景一:HTTP Chunk 不完整
场景二:半包(HTTP Chunk 过小 SSE 被切断)
场景三:粘包(多个 SSE 挤在一个 Chunk 里)
场景四:注释心跳包(带内容的 :)
场景五:致命的“混合双打”(最极端的粘包)
场景实战推演完整流程图:
2.5 总结:SSE 解析的三大核心法则
2.6 发送/接收流程图总览
结束语
前言
在前序章节里,我们已经完成了 DeepSeek 大模型非流式接口的代码实现与联调测试,能够一次性拿到模型返回的完整应答报文。但在对话场景中,我们更常见的是打字机式逐字输出的流式效果,这种能力依托 HTTP 分块传输与 SSE 事件推送来实现,也是我们接下来实现流式 Provider 必须啃下的理论基础。
很多同学在写 SSE 解析代码的时候,容易混淆 HTTP Chunk 分块和 SSE Event 事件块这两层封装,常常遇到半包、粘包、心跳包处理异常等问题,分不清数据在哪一层切割、在哪一层解析,写出的解析逻辑边界 case 缺失,在网络波动时极易崩溃。
本章就从底层 HTTP 协议出发,先区分普通响应与 Chunk 流式响应的差异,再厘清 Chunk 块和 SSE 事件块之间包裹与被包裹的层级关系。我们会结合 httplib 客户端请求结构体,重点拆解流式接收时的回调接口,并且通过 5 种典型的网络场景推演,带你看清流式数据传输里半包、粘包、心跳包等各类边界情况,最后提炼出稳定解析 SSE 的核心准则,为后面手写 DeepSeek 流式消息接口打好扎实的协议基础。
一、HTTP 普通响应与流式 (Chunked) 响应基础
1.1 HTTP 普通响应
报文结构
协议版本 状态码 状态描述 响应头字段: 值 响应头字段: 值 <空行> 完整响应数据工作机制
服务端完成全部业务处理后,一次性返回完整响应体。
客户端接收完整响应报文之后,才可以读取响应体数据。
响应体长度由
Content-Length响应头明确标识。
1.2 HTTP 流式 Chunked 响应
报文结构
协议版本 状态码 状态描述 响应头字段: 值 Transfer-Encoding: chunked <空行> chunk块 chunk块 ... 0\r\n\r\n # chunk结束标记工作机制
优先返回完整响应头,响应头不携带
Content-Length,使用Transfer-Encoding: chunked标记分块传输。业务数据尚未完全生成时,服务端把数据切分为若干 chunk 数据块,分块逐一向客户端发送。
客户端收到一块数据即可处理一块,不需要等待全部响应完成。
最后发送长度为 0 的 chunk 代表流式传输结束。
1.3 普通响应与流式响应核心差异
普通响应:响应头 + 单个完整响应体,全部数据就绪后一次性下发。
流式响应:响应头 + 多个 chunk 分块,数据边生成边下发。
客户端行为:流式场景需要预先注册回调函数,每收到一块 chunk 就执行回调处理。
1.4 HTTP Chunk 数据块 vs SSE Event 事件块(代码实现的关键)
在上篇文章《C++ 项目:手写大模型接入 SDK》SSE 协议原理与流式通信剖析-CSDN博客
中我们对SSE协议进行了初步的学习讲解,就是为了让我们更好地去了解流式响应的整套过程,这样也是方便我们更清晰更容易的写出流式响应的接口。
如果有还没了解SSE协议的一定要先去学习一下,否则下面的内容可能不好理解。
基于上述的流式(Chunked)机制,大模型(如 DeepSeek)对外提供服务时,通常使用SSE(Server-Sent Events)协议。那么,HTTP Chunk(网络传输层面的数据块)与SSE Event(业务层面的逻辑事件块)之间到底是什么关系?
最核心的一句话就是:它们是“快递盒”与“里面的商品”的关系,且两者是相互独立、解耦的。
1.4.1 层级关系:包裹与被包裹
HTTP Chunk(外层包装):是 HTTP/1.1 传输层的物理结构,解决的是“TCP 字节流边界”的问题。它的唯一目的是告诉客户端:“接下来的 N 个字节是一个完整的数据块”。
SSE Event(内层业务):是应用层的业务数据,解决的是“大模型生成的一句话”的逻辑边界。它的目的是告诉客户端:“遇到
\n\n,说明一段完整的业务 JSON 组装完毕了”。
1.4.2 核心认知:两种“完整性”并不对齐
这是理解流式解析最容易踩坑也是最复杂的地方。HTTP Chunk 完整,绝不代表里面的 SSE Event 完整。
Chunk 由网络/服务器框架决定:可能 50 字节切一个 Chunk,也可能 100 字节切一个 Chunk。
SSE 由大模型业务决定:根据 JSON 结构,一个完整的 SSE 可能有些只有不到 50 字节,但也可能有些长达 200 字节。
结论:因此,一个 HTTP Chunk 中可能包含1 个完整的 SSE、多个完整的 SSE,或者只有半个SSE(剩下的半截在下一个 Chunk 里)。
1.4.3 客户端的加工厂:如何让 Chunk 变回 SSE
既然两者不对齐,客户端库(如httplib)和你的业务代码就要扮演两个车间的角色:
底层车间(剥离 HTTP 壳):
httplib收到完整的 Chunk 后,会剥掉长度前缀(如0E\r\n)和尾部的\r\n,把纯粹的、不知道完不完整的业务字节流递交给content_receiver回调(待会会详细讲解)。上层车间(寻找 SSE 边界):你的业务代码接收这些碎片,放入Buffer,用while 循环寻找\n\n( 这也就是SSE event 事件块的是否完整的判断条件)。
找到了,就证明在这个 Chunk中,我们成功组装出了一个完整的 SSE Event;如果没有找到也就说明这个SSE event 事件块是不完整的,那就等待下一个报文发送过来后将事件块追加到 Buffer 当中。
1.4.4 特殊情况:SSE 心跳包
在 HTTP 流式响应中,有时服务器为了防止连接超时(尤其是经过 Nginx 等代理时),可能会发送一个 HTTP Chunk,里面装的数据是一个以冒号:开头的注释行,例如: ping\n\n或: keep-alive\n\n。
协议特性:根据 SSE 规范,任何以
:开头的行都是注释。这属于“有完整的 Chunk 和完整的 SSE 事件,但里面没有业务数据”的特殊情况。客户端解析时应当直接忽略。处理方式:客户端解析时,切出
\n\n之后,判断该事件字符串的首字符是否为:。如果是,则直接忽略,绝不能把它塞进 JSON 解析器,否则会导致程序崩溃。
二、httplib Request 结构体详解
httplib::Request结构体分为通用参数、服务端专用参数、客户端专用参数三大类。
在这里我们着重需要讲解的就是进行回调的两个参数:response_handler和content_receiver。我们能不能把这两个参数的调用机制以及处理机制搞清楚直接影响了我们后续实现流式响应代码中对这两个参数的设计。
2.1 Request 结构体完整定义
struct Request { // ========== 通用参数 ========== std::string method; // 请求方法:GET / POST / PUT / DELETE std::string path; // 资源路径,域名之后的部分:/api/users std::string matched_route; // 匹配到的路由模板 Params params; // 查询参数, multimap<std::string, std::string> Headers headers; // 请求头, multimap<std::string, std::string> Headers trailers; std::string body; // 请求体, POST/PUT 载荷存放位置 std::string remote_addr; int remote_port = -1; std::string local_addr; int local_port = -1; // ========== 仅服务端使用参数 ========== std::string version; std::string target; MultipartFormData form; Ranges ranges; Match matches; std::function<bool()> is_connection_closed = []() { return true; }; std::unordered_map<std::string, std::string> path_params; // 路由路径参数(用于动态解析 URL 中的变量) // ========== 仅客户端使用参数(流式响应核心) ========== std::vector<std::string> accept_content_types; ResponseHandler response_handler; // 完整响应头接收回调 ContentReceiverWithProgress content_receiver; // 流式分块接收回调 DownloadProgress download_progress; UploadProgress upload_progress; };2.2 通用成员参数解析
2.2.1 method
含义:HTTP 请求方法。如
GET(获取数据)、POST(提交数据)、PUT、DELETE等。
2.2.2 path
含义:资源路径,URL 域名后面的部分。
示例:
https://xxx.com/api/user→path = /api/user。
2.2.3 Params params(查询参数 / Query 参数)
底层类型:
using Params = std::multimap<std::string, std::string>;含义:附加在 URL 末尾,用于向服务器传递额外信息。以键值对格式组织,多个查询参数之间以
&间隔。示例:请求
GET /api/goods?sort=price&order=asc。httplib会自动解析?之后的键值对,存放到params容器中。即:params["sort"] = "price",params["order"] = "asc"。
2.2.4 Headers headers
底层类型:
using Headers = std::multimap<std::string, std::string>;含义:存储全部 HTTP 请求头。如
Content-Type(数据类型)、Authorization(认证信息)等。
2.2.5 std::string body
含义:请求体。
GET 请求:一般不使用 body,参数放置在 URL 查询参数(
params)中。POST/PUT 请求:业务 JSON 载荷、表单数据、二进制数据存放于此。
2.2.6 path_params 路径参数(路由参数)
底层类型:
std::unordered_map<std::string, std::string>含义:路由匹配捕获 URL 路径内的动态变量。
(注:与 Query 参数不同,它是 URL 本身的一部分,而不是?后面的内容)。示例:假设服务端定义了路由
/api/users/:user_id/posts/:post_id。
当客户端访问/api/users/123/posts/456时,httplib在服务端会自动解析出:path_params["user_id"] = "123"path_params["post_id"] = "456"。
2.3 客户端专用回调参数(流式响应核心)
这是客户端发起请求、接收服务端响应时的核心机制。
2.3.1 response_handler:奠定基调的“开场白”
核心定位:HTTP 响应头(Headers)的专属处理者。
类型:
std::function<bool(const Response &response)>触发时机:完整的 HTTP 响应头(Headers)接收完成之后调用一次。它只在底层库成功拼装出
\r\n\r\n,解析出完整的 HTTP 响应头时触发一次。入参:
const Response&,可以读取状态码(如 200、404)、响应头(如 Content-Type)。使用场景:状态码校验、响应头校验。一般在此处检查是否 200 OK。
返回值:;它的返回值(
bool)决定了后续的流式传输是否继续。如果返回false(例如检查到状态码不是 200,或者鉴权失败),httplib会立刻掐断 TCP 连接,后续的content_receiver永远不会被调用;为true才允许继续接收后续数据与 Body 无关:在流式响应中,你不需要在这里处理任何业务数据。它的使命就是确认“连接通了,格式对了”,然后把舞台交给
content_receiver。
2.3.2 content_receiver:剖析数据流的“手术刀”
核心定位:HTTP 响应体(Body)的搬运工与剥离者。
类型:
std::function<bool(const char *data, size_t data_length, size_t offset, size_t total_length)>触发时机:每当底层接收到并拼装出一个“完整的 HTTP 数据块(Chunk)”时触发回调,会被多次调用。
底层机制:如果底层网络发生拆包,导致 HTTP Chunk 不完整,
httplib会扣押数据不予回调。只有当数据长度满足 Chunk 头部的长度前缀要求时,才会剥离 HTTP 协议壳,将纯净数据传给回调。
参数详解:
data:当前接收到的纯净业务数据块的内存起始指针。data_length:当前业务数据块的字节长度。offset:当前块在整个响应体中的字节偏移量。total_length:响应体总字节长度(对应手写笔记右侧的total概念)。注意:在chunked流式传输时,由于服务端边生成边发送,总长度未知,该值通常为 0 或默认值。
返回值:
true继续接收;false立刻终止接收,断开连接。关键特性:
设置该回调后,
httplib不会等待全部响应接收完毕,不会把完整报文存入response.body。收到分片就直接交给回调处理,是实现 SSE / 大文件下载 / LLM 流式输出的核心。
业务层必须注意:传给
data的虽然是完整的 HTTP 块,但里面装的可能是多个完整的 SSE Event,也可能只是一个 Event 的“半包”。必须在业务代码内自建 Buffer,通过\n\n拼接切分。
2.4 结合模拟五大场景的实战推演
场景一:HTTP Chunk 不完整
在业务代码接触到数据之前,底层的httplib已经帮你处理了这个场景:
为了让客户端知道每次发过来的数据有多长,服务器给 Body 加上了“长度前缀”包装(类似快递盒上的标签)。
判断依据:十六进制长度前缀(如
0E\r\n,代表后面跟着 14 个字节)。动作:
半包(不够数):如果缓冲区的水不够 14 字节,
httplib扣押数据,绝不调用content_receiver,等下一次 TCP 报文到达凑齐。完整(凑齐了):剥离掉首尾的
0E\r\n和\r\n协议壳,只把中间 14 字节的纯净业务数据交给content_receiver。
核心要点:只要
content_receiver被调用了,说明在 HTTP 协议层面上,这个 Chunk 绝对是完整的。但它内部包裹的 SSE 业务数据,依然可能是残缺的!
场景二:半包(HTTP Chunk 过小 SSE 被切断)
现象:
content_receiver传进来的data只是半截 JSON。
比如data: {"content":"你。深刻理解:这是因为服务端的“HTTP 切块”没有对齐“SSE 边界”。
httplib尽职尽责地把 Chunk 完整卸货,但你的业务while循环找不到\n\n。应对:不要在找不到
\n\n时报错,将其留在buffer中。下一次回调数据到达时,append拼接即可(如你的代码图所示,buffer先变成data: {"content":"你好"},最终加上\n\n被成功切出)。
场景三:粘包(多个 SSE 挤在一个 Chunk 里)
现象:
content_receiver传进来的data包含了A\n\nB\n\nC\n\n。深刻理解:服务端为了提高网络吞吐,一口气发了一大块。此时你的
while循环大显神威,连续执行三次,把A、B、C依次切出来解析,buffer最终清空。这也印证了必须用while而不是if的原因。
场景四:注释心跳包(带内容的:)
现象:收到
: keep-alive\n\n或: ping...\n\n。深刻理解:需要过滤。不能只判断
if (event == ":")会漏掉带内容的。
正确处理:提取首字符。只要event[0] == ':',一律视为心跳/注释,直接忽略。
场景五:致命的“混合双打”(最极端的粘包)
现象:底层的
data传来了: ping...\n\ndata: {...}\n\ndata: {...}(既有心跳,又有完整数据,还有半包)。深刻理解:这是最考验代码健壮性的场景:将
buffer切分出的chunk(即 event),当成一个独立的文本块,按行(\n)遍历判断。如果是:开头的行,跳过;如果是data:开头的行,解析。
场景实战推演完整流程图:
2.5 总结:SSE 解析的三大核心法则
底层靠库:
httplib负责把 TCP 字节流变成完整的 HTTP Chunk,没凑齐长度绝不回调。上层靠自己:无论
content_receiver递过来的是完整的、残缺的、还是多个合并的数据,一律append到本地 Buffer,用while循环按\n\n切分。协议是嵌套的:
\r\n\r\n是 HTTP 头边界,0E\r\n是 HTTP 体 Chunk 边界,\n\n是 SSE 业务边界,各司其职,互不干扰。切出的业务块必须按\n遍历单行进行清洗!
2.6 发送/接收流程图总览
服务器发送 HTTP 流式数据: HTTP 头部 Transfer-Encoding: chunked <空行> [Chunk 1] 长度前缀 + "data: {"content":"你"}\n\n" + \r\n [Chunk 2] 长度前缀 + "data: {"content":"好"}\n\ndata: {"con" + \r\n [Chunk 3] 长度前缀 + "tent":"世界"}\n\n" + \r\n 0\r\n\r\n =================================== 客户端接收与解析视角: 1. 底层的 httplib 收到 Chunk 1: -> 验证长度,剥离协议外壳。 -> 触发 content_receiver,传给业务: "data: {"content":"你"}\n\n" -> 你的 buffer 找到 \n\n,成功切出完整的 SSE Event(业务字:你) 2. 底层的 httplib 收到 Chunk 2: -> 验证长度,剥离协议外壳。 -> 触发 content_receiver,传给业务: "data: {"content":"好"}\n\ndata: {"con" -> 你的 buffer 切出完整的 SSE Event(业务字:好),剩下半截 "data: {"con" 留在 buffer。 3. 底层的 httplib 收到 Chunk 3: -> 验证长度,剥离协议外壳。 -> 触发 content_receiver,传给业务: "tent":"世界"}\n\n" -> 你的 buffer 把上次残留的拼起来,找到 \n\n,切出完整的 SSE Event(业务字:世界)。 4. 收到 0\r\n\r\n:流结束。结束语
到这里,我们已经把 HTTP 分块传输、SSE 事件结构,以及 httplib 流式回调的底层逻辑全部梳理完毕。 我们需要记住:Chunk 是 HTTP 传输层面的分片,SSE 事件是业务语义层面的消息,二者并不对齐,半包、粘包、心跳包都是流式解析里常态化的问题,不能假设一个 Chunk 刚好等于一条 SSE 事件。
本章总结的 SSE 解析三大核心法则、五种异常场景的推演,就是后续编写流式接口解析代码的依据。理解了这些边界情况,我们才能写出鲁棒性足够强的解析逻辑,而不是只能在网络稳定的理想环境下跑通。