当前位置:首页 > 逆向工程 > 正文

SSE 流式协议怎么逆:一条消息 18 个字节,两个换行结尾

约 4559 字 · 预计阅读 12 分钟
一句话总结:SSE 是现在大模型流式输出最常用的协议,抓包看到 text/event-stream 就是它。它比 WebSocket 简单得多——纯 HTTP、单向推送、没有掩码、没有帧头。我起了个 socket 服务抓真实字节:一条 data: {"t":"hi"} 消息正好 18 字节,字段名加空格 6 字节,载荷 10 字节,两个换行 2 字节。

抓流式接口的包,经常会看到一堆这样的东西:

event: delta
id: 42
data: {"t":"hi"}

data: {"t":"end"}

: keep-alive

第一次看到会以为是什么自定义协议。其实不是,这是 W3C 的 SSE(Server-Sent Events)规范,2011 年就有了,比 WebSocket 还早。

它简单到有点过分——整个协议就一个规则:用换行分隔字段,用空行分隔事件。

一、先看响应头:认出这是 SSE

逆向任何协议,第一步都是认出口。

SSE 的响应头有两个特征:

HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8

text/event-stream 这个 MIME 类型就是它的身份证。看到它,基本可以确定后面是 SSE。

另外要注意 Cache-Control: no-cache——流不能缓存,否则第二次请求会拿到旧的完整内容,看起来像"一次性全吐出来"。

还有一个细节:如果是 HTTP/1.1,响应会带 Transfer-Encoding: chunked;如果是 HTTP/2,没有这个头(HTTP/2 本身支持流)。内容完全一样,别因为少了 chunked 就以为换了协议。

二、一条 SSE 消息 18 个字节

我起了个本地 socket 服务,发了一组真实数据,然后把字节逐个打出来。

SSE 流式协议怎么逆:一条消息 18 个字节,两个换行结尾

图 1:字段名 5 字节,空格 1 字节,载荷 10 字节,两个换行 2 字节

实测的一条消息:

b'data: {"t":"hi"}\n\n'

展开成字节:

d  a  t  a  :  sp {  "  t  "  :  "  h  i  "  }  LF LF
64 61 74 61 3a 20 7b 22 74 22 3a 22 68 69 22 7d 0a 0a

18 个字节,没有长度前缀,没有校验和,没有掩码。

这一点和 WebSocket 帧完全不同——WS 帧有 6 字节起的头部、要解掩码、要处理分片。SSE 就是纯文本,用一个 \n\n 划边界。

代价是文本传输比二进制低效,但胜在人眼可读、抓包直接能看明白。

三、SSE 的五个字段名

规范里只有五个字段:

字段 含义 备注
data消息内容可以出现多行,按顺序拼接
event事件类型不给就是 message
id事件 ID重连时用于续传
retry重连间隔(毫秒)控制客户端重试节奏
: xxx注释冒号开头,常用来发心跳

三条容易踩的规则:

冒号后面的空格是可选且要被吃掉的。 data: hello 和 data:hello 的载荷都是 hello,不是 " hello"。解析时如果不去掉前导空格,JSON 解析就会失败。

多个 data 行要拼起来,中间加换行。 这是长文本分块的机制:

data: {"t":"第一段
data: 第二段"}

拼出来是 {"t":"第一段\n第二段"}。

冒号开头的行是注释,不是数据。 服务端靠这个发心跳保活——:\n\n 每 30 秒发一次,防止中间代理把连接判成空闲断掉。解析时应该跳过,别当空消息处理。

四、解析器怎么写

我给的文件级解析器,三十行不到:

def parse_sse(text):
    events = []
    for block in text.split("\n\n"):        # 空行切事件
        if not block.strip():
            continue
        evt = {"event": "message", "data": "", "id": None}
        for line in block.split("\n"):
            if line.startswith(":"):         # 注释行
                evt["comment"] = line[1:].strip()
                continue
            if ":" not in line:
                continue
            field, _, value = line.partition(":")
            if value.startswith(" "):        # 吃掉可选空格
                value = value[1:]
            if field == "data":
                evt["data"] += value + "\n"  # 多行拼接
            elif field in ("event", "id", "retry"):
                evt[field] = value
        evt["data"] = evt["data"].rstrip("\n")
        events.append(evt)
    return events

拿刚才那三个事件跑一遍:

{'event': 'delta', 'data': '{"t":"hi"}', 'id': '42'}
{'event': 'message', 'data': '{"t":"end"}', 'id': None}
{'event': 'message', 'data': '', 'id': None, 'comment': 'keep-alive'}

第三个是心跳,data 是空的,靠 comment 字段能认出来。

注意这是离线解析整个响应体。 真要做流式处理,得用增量解析——按 \n\n 累积缓冲区,收到完整块才解析,不然半个 JSON 会解析失败。

五、断线重连看 Last-Event-ID

SSE 有个自动重连机制,浏览器原生支持。

连接断了之后,客户端会自动重连,并且在请求头里带上:

Last-Event-ID: 42

服务端看到这个头,就知道该从哪个事件之后继续推。

这个设计的实用价值在长任务场景:一个 10 分钟的生成任务,中间网络抖了一下,重连之后不用从头再来。

不过要说明:这要求服务端自己实现"按 ID 续传"。 协议只规定了客户端怎么带这个头,服务端怎么用是自由的。很多实现根本不理这个头,直接重头开始。

六、坑清单

按踩坑概率排。

1. 换行可能是 \r\n。 规范只认 \n,但有些服务端或代理会把行尾改写成 \r\n。这时候 split("\n\n") 切不开,因为实际是 \r\n\r\n。稳妥做法是先统一换行。

2. 空格处理错一位,JSON 就崩。 data: {"t":"hi"} 里冒号后有一个空格,去掉它之后才是正确的 JSON。少去一个字符,解析就报错。

3. 心跳包被当成空消息。 如果只判断 data 不为空才处理,心跳会被静默丢掉——这没问题。但如果判断 block 非空就处理,心跳会产生空事件,下游可能崩。

4. 增量解析要有缓冲区。 网络分片不会按事件边界来。你可能收到半行 data: {"t":,下一片才是 "hi"}。必须缓存到出现 \n\n 再解析。

5. 别用 read() 等全部读完。 SSE 的本质是流,用 requests.get().text 会一直阻塞到连接关闭。要用 stream=True 逐块读。

七、边界

这篇讲的都是明文 HTTP 下的 SSE。如果流量走了 TLS,抓包看到的是密文,得先解决证书或做中间人解密,那是另一层的事。

另外我只测了文本事件。规范里还支持 retry 控制重连间隔,我这次没在真实服务上验证它的行为。

还想说一句:SSE 之所以在大模型场景流行,不是因为先进,而是因为够简单。一个 HTTP 连接、一个纯文本格式,任何语言十行代码就能解析。相比 WebSocket 要处理握手、掩码、分片、ping/pong,SSE 的协议开销几乎为零。

message threads那篇讲的是另一个方向的优化——把重复传输砍掉。两者叠加起来,就是现在流式对话的完整链路:少发、边发、边解析。

参考资料

目录导航
  • 一、先看响应头:认出这是 SSE
  • 二、一条 SSE 消息 18 个字节
  • 三、SSE 的五个字段名
  • 四、解析器怎么写
  • 五、断线重连看 Last-Event-ID
  • 六、坑清单
  • 七、边界
    • 参考资料
  • 相关文章:

  • 代码库是怎么烂掉的:7 个脚本,2 个已经过了线
  • 从零写一个真能用的 Skill:目录、脚本、验证,外加一次被自己工具打脸的迭代
  • WebSocket 帧怎么还原:18 个字节里,6 个是头,12 个被掩码过
  • 照着 DeepSeek 的 Super-agent 写个最小版:Router 分流、两级过滤、Worker 并行
  • 从乱码里逆出 XOR 密钥:256 次爆破的实战,和一条会骗人的捷径
  • 交付前先测 Agent 的三件坏事:越权、编出处、谎报完成
  • 主动型 Agent 怎么自己写:定时唤醒、草案入队、审批后才动手
  • 协议逆向入门:从抓包到重放,把接口的签名还原出来
  • 发表评论

    ◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。