最近不少用 Python 接大模型接口的朋友,都被 SSE(Server-Sent Events)的消费逻辑绊过一跤。尤其换到 Responses API 之后,交互方式从“一次性拿到完整 JSON”变成了“服务端一个事件一个事件往下推”,增量文本、连接超时、请求取消这三个问题,瞬间变成躲不掉的日常操作。我最初接手这类需求的时候也吃了不少亏,单独看事件流、超时、取消每一个都不难,但把它们组合在一起,代码写出来不是卡在首 token 太慢上,就是任务取消了连接还挂着不放。
这篇文章我把自己的完整消费方案、踩过的坑、以及最终沉淀下来的代码结构一起整理出来。适合正在用 Python 对接 SSE 接口的开发者,不管你是刚开始接触事件流,还是已经在生产环境里被超时和取消搞得焦头烂额,这份内容应该都能给你省点时间。
1. 为什么 SSE 事件流让很多人不适应
1.1 事件流和普通 HTTP 返回的本质区别
普通接口返回的是“一整块完成的数据”,客户端发一个请求,服务端把答案算完,再一次性返回给你。这里的关键点是:在你的代码收到响应之前,服务端已经把所有工作做完了。
SSE 完全不同。连接一旦建立,服务端就开始“边说边写”,一边生成内容一边不停推送事件。整个过程不是一次性的数据交换,而是一条持续流动的数据通道。Responses API 在这条通道上会推送多种类型的事件,比如响应开始、进度变化、增量文本、响应完成、响应失败等等,每一种事件都有独立的 JSON 结构。
这个设计对客户端来说最大的影响是:不能再用res.json()一把梭。你拿到一部分数据的时候,整个结果还没成型,必须边收边处理。
我自己的体会:不理解“边收边处理”这五个字,后面所有代码都会写拧巴。
1.2 Responses API 与普通请求的关键差异
Responses API 的流式接口在设计上更像“事件总线”而不是“消息队列”。什么意思呢?消息队列是一条消息发给一个消费者,事件总线则是一堆事件不断生成,谁需要谁就自己去订阅、过滤。
落到代码上就是,你不能只关心“最终答案”。你需要关注事件的顺序、事件的类型、事件与事件之间的时间间隔。比如response.output_text.delta事件负责推送文本片段,而response.completed才代表全部内容生成完毕。如果代码里只处理 delta,忽略连接关闭信号,很容易在末尾丢掉最后一段内容。处理这些细节,才是消费 SSE 事件流真正的核心工作量。
2. 增量文本的完整消费方案
2.1 先看懂事件流里到底有什么
在写消费代码之前,先把事件结构吃透。Responses API 的 SSE 事件流,每一行都是一个data:前缀的 JSON 数据。按类型看,我实际使用中最常打交道的几个事件是下面这些:
| 事件类型 | 核心含义 | 典型处理策略 |
|---|---|---|
response.created | 响应对象已创建 | 初始化状态,标记开始 |
response.in_progress | 服务端开始处理请求 | 可用于设置超时计时起点 |
response.output_text.delta | 返回一段增量文本 | 立刻追加到缓冲区,并实时输出 |
response.output_text.done | 一段文本输出结束 | 触发一次校验或提交 |
response.completed | 整个响应完成 | 关闭连接,返回完整结果 |
response.failed | 处理失败 | 记录错误信息,走重试逻辑 |
response.cancelled | 响应被取消 | 做清理,保存已生成的文本 |
这些事件里,response.output_text.delta是核心中的核心,它就是你常听到的“增量文本”。delta 事件里通常带着一小段新的文本片段,需要你自己累积拼接,最终还原出完整的回复内容。
2.2 从流式行数据中提取增量文本
我用 HTTPX 比较多,因为它在流式场景下比 Requests 干净不少。下面是一段基础的事件读取与增量文本提取逻辑:
import json import httpx async def stream_responses(payload): url = "https://api.example.com/v1/responses" headers = { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json", } async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=60.0)) as client: async with client.stream("POST", url, json=payload, headers=headers) as response: buffer = [] async for line in response.aiter_lines(): if not line.startswith("data:"): continue data_str = line[len("data:"):].strip() if data_str == "[DONE]": break event = json.loads(data_str) if event.get("type") == "response.output_text.delta": delta = event.get("delta", "") if delta: buffer.append(delta) print(delta, end="", flush=True) return "".join(buffer)这段代码有两个关键点。
第一,用aiter_lines()而不是aiter_bytes()。这是我从一开始踩坑得来的经验。如果用aiter_bytes(),你需要自己维护一个跨行的 buffer,因为一个 JSON 事件可能被拆成多个字节片段。aiter_lines()帮你把行切分好了,解析成本低很多。
第二,[DONE]信号是流式接口的结束标识。看到这个信号,说明服务端不会再推送任何事件,break就好。但也要注意,[DONE]并不是所有接口都有,有些接口会直接关闭连接。所以更稳妥的写法是:既检查[DONE],也检查连接是否已经关闭。
2.3 增量文本的拼接策略与性能边界
文本拼接用buffer.append(delta)再"".join(buffer),比直接full_text += delta性能好。这个差异在短文本上感受不明显,但长回复积累到几千甚至上万 token 时,字符串反复拷贝的开销就不容忽视了。
如果对延迟特别敏感,建议加一个小的“刷新周期”逻辑,不要每次拿到 delta 就去写 UI 或发回调。一般的做法是把 delta 累积到一个临时队列,每 100 毫秒批量刷新一次输出。这个优化能让消费端的日志更稳定,也能避免高频刷新导致的下游压力。
增量文本还有一个容易忽略的地方:它并不保证在极端情况下不重复、不乱序。虽然现阶段极少遇到乱序情况,但生产环境最好还是加上一个简单的顺序校验,比如用sequence_number字段(如果有)或者用“已收到字符总量”做幂等去重。我自己的项目里就遇到过两次重复推送同一个 delta 的情况,排查了许久,最后只能靠前端去重来兜底。
3. 超时控制的完整方案
3.1 超时为什么这么难调
SSE 连接的超时策略和普通接口完全不同。普通接口下载一个文件,只要整体在可接受时间内完成就行。SSE 连接是长连接,可能持续几十秒甚至几分钟,中间还有等待服务端生成文本的“静默期”。
如果把超时时间设得太短(比如 10 秒),模型思考 30 秒没出第一个字,连接直接断掉。设得太长(比如 5 分钟),万一服务端真挂了,客户端要白等 5 分钟才能反应过来。
问题的本质是:SSE 场景下,连接“活着”不代表事件在持续产生。所以必须拆开时间窗口来看。
3.2 三个时间窗口分别处理
我现在的方案是把超时拆成三个独立时间窗:
| 超时类型 | 含义 | 推荐初始值 | 备注 |
|---|---|---|---|
| Connect timeout | TCP 连接建立 | 10秒 | 服务端连不上时快速失败 |
| Read timeout | 相邻两次数据间隔 | 60秒 | 必须长,给模型思考留足时间 |
| Write timeout | 请求体发送 | 10秒 | 通常不会触发,设置保底即可 |
HTTPX 里可以这样配置:
timeout = httpx.Timeout( connect=10.0, read=60.0, write=10.0, pool=10.0 )最关键的是read=60.0。它表示“两个数据包之间最多等 60 秒”。哪怕整个 SSE 连接维持了 10 分钟,只要每 50 秒就有一个事件推过来,就不会触发超时。这个语义恰好符合大模型流式输出的节奏。
3.3 真正值得警惕的是“弃用超时”行为
HTTPX 有一个容易让人迷惑的默认行为:Timeout里的read时间不是从连接建立开始算起的总时长,而是“等了多久没数据就断开”。也就是说,只要持续有数据到达,连接理论上可以无限期保持。
这一点对 SSE 来说其实是好事。真正需要警惕的是 HTTPX 5.x 之后调整过的“弃用”语义,部分场景下读超时并不严格等于“相邻数据间隔”。解决办法是永远不要依赖默认行为,在代码里显式传入Timeout对象,并且打日志时记录超时类型:
try: async with client.stream(...): ... except httpx.ReadTimeout: logger.warning("read timeout: no event for 60s") except httpx.ConnectTimeout: logger.warning("connect timeout: server unreachable")我踩过的坑:最初只设了一个总超时httpx.Timeout(30.0),结果所有字段都是 30 秒。一个稍复杂的请求,模型思考了 40 秒,第一个 token 还没出来,连接就被 HTTPX 的读超时掐断了。后来换成拆分配置,这个问题才算彻底解决。
3.4 首 token 超时的特殊处理
还有一个容易被忽略的指标:首 token 延迟。也就是从发起请求到收到第一个response.output_text.delta的时间。这个时间和整体读超时是两码事。
我建议把首 token 超时单独控制,因为用户能感知的“卡住”很大程度就是首 token 太久。做法是记录请求发出时的时间戳,在收到第一个 delta 事件时计算耗时:
full_start = time.monotonic() first_token_at = None async for line in response.aiter_lines(): ... if event.get("type") == "response.output_text.delta": if first_token_at is None: first_token_at = time.monotonic() logger.info(f"first token latency: {first_token_at - full_start:.2f}s")如果首 token 延迟超过了业务要求(比如 20 秒),不要干等,直接主动取消这次请求,换一个更快的模型或提示词策略。这个指标的监控价值远比整体耗时高,因为它直接反映服务端的“出字效率”。
4. 取消操作的实现细节
4.1 客户端取消与服务端取消的区别
很多人以为取消就是“客户端把连接关掉”,实际上还有更深的一层:服务端如何感知取消,以及取消后怎么处理已产生的部分内容。
SSE 是单向流,客户端关掉连接,服务端写数据时会收到连接重置的异常,从而停止生成。这个过程不需要额外发取消指令。但对于大模型接口来说,服务端停止生成之后,会尝试把“本次响应已被取消”这个结果同步到状态里。所以真正的取消处理,不只是断开 TCP,还包括处理服务端最后推来的response.cancelled事件。
4.2 取消动作的正确写法
用 HTTPX 做取消,核心是确保 response 的底层连接被关闭。最简单可靠的方式就是用async with包裹流上下文,在块内做取消分支判断:
should_cancel = False async with client.stream("POST", url, json=payload, headers=headers) as response: async for line in response.aiter_lines(): if should_cancel: break # 解析事件并处理增量文本 # 退出 async with 后,连接一定被关闭这里should_cancel可以来自外部任务的控制信号。比如你在跑一个交互式界面,用户点了“停止生成”,就把这个变量置为 True,然后等事件循环走到下一次迭代时自然退出去。
这里要注意一个细节:break退出循环后,不能直接 return,应该让async with上下文管理器去执行清理动作。如果你在循环内部手动调用await response.aclose()然后再 break,某种程度上是提前收工,但会让代码路径变得复杂。我推荐的做法是只设置标志位,让上下文管理器统一收尾。
4.3 异步任务取消的坑
如果你用asyncio和 Task 来管理流式消费,那取消操作就进入了一个更暗的角落。task.cancel()会在当前挂起点抛出asyncio.CancelledError,如果这个挂起点正巧是response.aiter_lines()的迭代,那么连接不一定能被正确清理。
我的经验是:不要在协程内部裸奔try/except CancelledError,那样容易掩盖未处理的资源释放。正确姿势是加一个finally,在取消发生的时候关闭连接和会话:
async def consume(task_status): async with httpx.AsyncClient(timeout=... ) as client: async with client.stream(...) as response: try: async for line in response.aiter_lines(): ... except asyncio.CancelledError: logger.warning("task cancelled, cleaning up") raise finally: # 确保连接释放 await response.aclose()raise是必要的,因为你不能吞掉CancelledError,否则任务会被继续执行,导致取消失效。这个坑我在第一版代码里就踩过,当时想着捕获后做个清理就完事,结果任务根本不取消,连接也一直挂着。
4.4 取消之后要不要保存已生成文本
业务决策就在这里出现分岔了。用户主动取消,已经生成的那部分文本要不要保留,取决于使用场景。
如果是“停止生成但保留草稿”,那就在循环里随时累积一个current_text缓冲区,取消时把它带出去,写进日志或数据库。如果是“取消即作废”,那直接丢弃即可。
我个人的建议是永远把已生成的文本先保存到内存,再根据业务规则决定是否持久化。因为 99% 的情况下,用户取消之后回溯日志时都会想看到“当时已生成了哪些内容”。先把文本带上有备无患,损失几乎为零。
5. 实测踩坑清单与排查思路
5.1 事件流解析常见的四个问题
第一个:有空行。部分服务端在事件中间会插入空行,如果代码里没有过滤空行,json.loads直接抛异常。建议所有的行读取后都先 strip 再判断是否以data:开头。
第二个:前后有多余空格。data: {"type":...}这行的冒号后面可能有空格,用line[len("data:"):].strip()处理是一种比较稳的做法,不要直接用line.split(":", 1)然后不处理空白。
第三个:网络抖动导致的行残片。aiter_lines()通常会正确处理压包拆包,但如果你自己实现读取逻辑,必须注意字节缓冲区的残留问题。我建议优先用 HTTPX 提供的行迭代器,省心不少。
第四个:中文乱码。原因是服务端分片可能把一个多字节 UTF-8 字符切到两个包里。处理方式就是不要在字节层做 decode,让行迭代器完整返回字符串后再拼接。
5.2 取消后连接释放的排查方法
如果你发现任务已经取消,但日志里没有response.cancelled事件,或者进程退出时有ResourceWarning,说明连接没有正确关闭。
排查方法很简单,在开发环境用lsof -p <pid>查看网络连接状态,如果取消后仍然有 ESTABLISHED 连接挂着,就说明清理逻辑有遗漏。另一个方式是在代码里注册退出回调,打印连接是否已显式关闭。
实际上,大多数连接泄漏不是因为没有aclose(),而是因为取消了错误的对象。你可能关闭了 HTTPX 的 client,但流迭代器还在某个地方挂着。记住一个原则:先关闭流,再关闭 client,最后销毁事件循环中的任务引用。
5.3 超时调参的实战建议
超时参数没有万能值,但我可以提供一套可以起步的配置:
| 场景 | connect | read | 首token阈值 | 备注 |
|---|---|---|---|---|
| 短文本摘要 | 10s | 30s | 10s | 响应快,超时收紧 |
| 常规对话 | 10s | 60s | 20s | 平衡体验与稳定性 |
| 长文生成 | 10s | 120s | 30s | 留足服务端思考时间 |
这些值不是拍脑袋定的,背后逻辑是:connect 超过 10 秒通常说明网络不通或服务端负载过高,没有必要继续等;read 至少 30 秒是因为大模型服务端在“思考”阶段可能停止推送事件;首 token 阈值主要从用户体感出发,超过这个时间用户基本开始不耐烦了。
5.4 一个稳定代码骨架的最终版
综合上面的经验,我现在项目里用的消费骨架是这样:
import asyncio import json import time import httpx async def consume_sse(payload, on_delta, cancel_event, max_wait=60): timeout = httpx.Timeout(10.0, read=max_wait, write=10.0, pool=10.0) buffer = [] started = time.monotonic() first_token = None async with httpx.AsyncClient(timeout=timeout) as client: async with client.stream( "POST", "https://api.example.com/v1/responses", json=payload, headers={"Authorization": "Bearer YOUR_API_KEY"}, ) as resp: async for line in resp.aiter_lines(): if cancel_event.is_set(): break if not line.startswith("data:"): continue data = line[5:].strip() if data == "[DONE]": break try: event = json.loads(data) except json.JSONDecodeError: continue if event.get("type") == "response.output_text.delta": delta = event.get("delta", "") if first_token is None: first_token = time.monotonic() print(f"[first token] {first_token - started:.2f}s") on_delta(delta) buffer.append(delta) elif event.get("type") == "response.cancelled": break return "".join(buffer)cancel_event用asyncio.Event控制,on_delta是回调。这个结构避开了 90% 的连接泄漏和超时问题,非常适合作为进一步封装的基底。
6. 写在最后的个人体会
SSE 事件流消费这个事,说难其实不难,但它是一个“时序敏感”的问题。顺序错了、超时短了、取消没清理,都会在线上以出乎意料的方式冒出来。我自己前后重构了三版,才稳定下来。期间最深的体会就是:不要把 SSE 当普通 HTTP 接口来写,它是带状态的实时通道,代码结构必须围绕“事件类型 + 时间窗口 + 生命周期”来设计。
如果现在让我给刚接触这个场景的人一句建议:先把事件类型表打印出来贴屏幕上,再动手写代码,比你边写边猜要快得多。后续如果你们也遇到类似情况,欢迎拿我这套骨架去改造,少走点弯路。