newApi 网关流式运行时设计以及源码解读
1. 从传统网关到 LLM 网关
传统 API 网关处理的大多数请求,可以抽象成一次完整的请求响应:
网关主要关心:
- 鉴权;
- 路由;
- 限流;
- 超时;
- 重试;
- 熔断;
- 请求和响应日志。
LLM 流式请求仍然需要这些能力,但请求进入 Provider 后,响应不再是一次性返回。
模型会持续产生 token,网关也要持续读取、转换和写出。
一次请求因此变成一段持续运行的过程:
建立连接
-> 等待首 token
-> 连续读取 chunk
-> Provider 协议转换
-> 连续写入客户端
-> 判断结束原因
-> 清理上下游连接
-> 统计 usage 并结算
1.1 两类网关的关注点差异
| 维度 | 传统 API 网关 | LLM 流式网关 |
|---|---|---|
| 响应形态 | 完整响应 | 持续输出 token 或事件 |
| 请求时长 | 通常较短 | 可能持续数十秒或数分钟 |
| 响应处理 | 整体转发 | 逐 chunk 读取、转换、写出 |
| 超时语义 | 请求总超时 | 首包超时、空闲超时、写超时 |
| 客户端断开 | 终止请求即可 | 还要主动停止上游生成 |
| Provider 差异 | HTTP 状态码和 JSON | SSE 事件、结束标识、usage、错误格式 |
| 资源占用 | 短期连接和内存 | 长连接、goroutine、ticker、缓冲区 |
| 计费依据 | 请求次数或响应结果 | token、usage、流是否完整结束 |
| 可观测性 | 延迟和状态码 | TTFT、chunk 数、结束原因、流错误 |
传统网关的“请求转发函数”很难完整覆盖这些问题。LLM 网关需要一个独立的流式
运行时,负责管理单条长连接从开始到结束的整个生命周期。
2. StreamScannerHandler 在网关中的位置
StreamScannerHandler 位于上游请求已经成功建立之后。它接管
http.Response.Body,将上游流式响应持续送入 Provider Handler,再写给下游客户端。
这个位置决定了它的职责边界:
前置模块决定“请求发给谁”
StreamScannerHandler 决定“一条流如何稳定运行”
Provider Handler 决定“chunk 表达什么”
后置模块决定“如何记录和结算”
3. 流式运行时的模块划分
从技术结构看,可以将这套实现分成七个模块。
| 模块 | 解决的网关问题 | 设计方式 |
|---|---|---|
| 上游连接 | 如何复用连接并持续读取 Provider 响应 | http.Client、Transport、Response.Body |
| 流数据读取 | 如何从连续字节中提取可处理的数据 | Scanner、行扫描、data: 和 [DONE] |
| Channel 数据协调 | 读取和处理速度不一致 | 有界 dataChan、顺序消费、局部背压 |
| Provider 适配 | 不同模型厂商的 chunk 格式不同 | 回调解析、协议转换、usage 提取 |
| 下游输出 | 如何实时、有序地返回客户端 | 写锁、ResponseWriter、Flush、Ping |
| 控制 | 如何处理取消、停止和超时 | Request Context、内部 Context、stopChan |
| 状态与回收 | 如何判断结果并完整释放资源 | StreamStatus、关闭 Body、停止 ticker、WaitGroup |
4. 主数据流:一个 token 如何穿过网关
这条路径有三个重要特征。
4.1 不等待完整响应
Scanner 从 Response.Body 持续读取,获得一个 chunk 后立即进入后续处理。网关不需要
将整个模型响应保存在内存中,客户端也能更早看到第一个 token。
4.2 Provider 解析位于独立阶段
公共运行时只负责将 data 交给回调。OpenAI、Claude、Gemini、Dify 等 Provider
可以在各自 Handler 中完成:
- JSON 解析;
- 事件类型识别;
- reasoning、content、tool call 转换;
- usage 提取;
- Provider 错误转换;
- 下游协议组装。
4.3 每次写入立即 Flush
数据写入 ResponseWriter 后调用 Flush(),避免服务器或中间层继续缓冲,从而保持
流式体验。
5. 为什么在读取和处理之间使用 Channel
5.1 两个阶段具有不同的速度
Scanner 的工作以网络读取为主,Provider Handler 还需要解析 JSON、转换协议、统计
usage 并写入客户端。两者耗时来源不同。
如果完全同步:
Scanner 读取一个 chunk
-> 等待解析
-> 等待客户端写出
-> 才能继续读取上游
任何一次短暂的处理抖动都会直接阻塞上游读取。
如果使用无界队列:
上游持续产生数据
-> 下游处理变慢
-> chunk 不断积压
-> 内存占用没有明确边界
有界 dataChan 提供了中间方案:
允许短期速度差
限制最大积压量
积压达到边界后向上游读取阶段传递压力
5.2 为什么保持单消费者
当前 Handler 按 range dataChan 顺序消费。单消费者避免了 chunk 并发处理后的重新
排序问题。
LLM chunk 往往具有上下文依赖:
- delta 需要按顺序拼接;
- tool call 参数可能跨多个 chunk;
- usage 通常出现在最后几个事件;
- Responses API 事件存在生命周期顺序;
- 下游客户端要求 SSE 事件顺序稳定。
因此,这里的设计重点放在顺序、可预测性和较低的状态管理复杂度,单条流内不再增加
多个并行消费者。
5.3 dataChan 的关闭所有权
Scanner 是唯一生产者,因此由 Scanner 负责关闭 dataChan:
这个规则保证:
生产者决定何时不会再发送
消费者通过 range 自然结束
消费者不需要猜测上游是否还有数据
6. 数据 Channel 和停止 Channel
dataChan 与 stopChan 看起来都是 Channel,但它们承担不同的协议。
| 对比项 | dataChan |
stopChan |
|---|---|---|
| 语义 | 业务数据队列 | 一次性停止广播 |
| 内容 | 上游 chunk 字符串 | 主要依赖关闭事件 |
| 发送与接收 | 一个 chunk 对应一次接收 | Scanner、Ping 和主循环感知关闭 |
| 关闭者 | Scanner Goroutine | stop() 通过 sync.Once |
| 目的 | 解耦数据阶段 | 终止多个并发阶段 |
sync.Once 用于保证多个结束路径同时发生时,只关闭一次 stopChan:
客户端断开
空闲超时
Handler Stop
Scanner 结束
Ping goroutine panic
Scanner 或 Data Handler panic
这些事件可能相互竞争。如果多个 goroutine 直接调用 close(stopChan),会出现
close of closed channel。通过统一的 stop() 和 sync.Once,停止动作成为幂等操作。
当前 stopChan 使用 chan bool,并通过 Context 传给公共 goroutine 池的 panic
handler。panic handler 可能向其中发送 true。这使它同时具有“关闭广播”和“异常值
发送”两种语义。后续抽象时,可以考虑将停止广播统一为 chan struct{} 或完全交给
context.CancelFunc,错误信息则进入独立的结果通道。
7. Provider 适配为什么放在回调中
不同 Provider 的流式协议存在明显差异,但连接生命周期问题高度相似。
7.1 公共运行时处理稳定能力
- 持续读取上游;
- Channel 数据分发;
- 客户端取消;
- 空闲超时;
- Ping;
- goroutine 退出;
- 上游 Body 关闭;
- StreamStatus 记录。
7.2 Provider Handler 处理协议差异
- chunk JSON 结构;
- 事件类型;
- content 和 reasoning;
- tool call;
- usage;
- Provider 错误;
- 业务结束事件;
- 下游格式转换。
这样设计可以避免两种扩散:
Provider 分支扩散到公共运行时
生命周期代码复制到每个 Provider
8. 控制通路:一条流如何停止
流式请求存在多个停止来源。大部分结束路径通过 stop() 唤醒主循环,客户端断开和
空闲超时由主循环直接感知,最终都进入 cleanup()。
8.1 三类取消信号
| 信号 | 表达的含义 |
|---|---|
c.Request.Context().Done() |
外部客户端或上层请求已经结束 |
ctx.Done() |
向 Scanner 和 Ping 直接传播取消 |
stopChan |
Scanner、Ping 和主循环直接感知内部停止 |
Data Handler 不直接监听 ctx.Done() 或 stopChan。Scanner 收到停止信号后退出并
关闭 dataChan,Data Handler 随后结束 range dataChan。这种关系使数据通道的关闭
仍由唯一生产者 Scanner 负责。
9. 下游输出:为什么需要写锁、Flush 和 Ping
9.1 写锁保证输出不会交错
业务 Handler 和 Ping Goroutine 都可能向同一个 ResponseWriter 写数据。
写锁保证一次只有一个 goroutine 操作下游 Writer,避免出现:
业务 SSE 写到一半
Ping 插入其中
下游收到无法解析的事件
当前实现是在持锁期间执行整个 dataHandler。它保证了顺序,但锁的范围也包含协议
解析和业务处理。后续可以评估将“解析”和“实际写出”拆开,只在写 ResponseWriter
时持锁。
9.2 Flush 决定客户端何时看到数据
只调用 Write 不一定意味着数据已经离开服务器缓冲区。每个 chunk 完成后调用
Flush(),才能形成真正的增量响应。
9.3 Ping 维持长时间无 token 的连接
模型可能长时间没有新 token,中间代理也可能存在空闲连接超时。Ping Goroutine 定期
写入 SSE 注释:
: PING
Ping 属于连接控制数据,不进入 dataChan,也不参与 Provider 协议解析。
10. 生命周期收敛:为什么要关闭 Body 并等待 goroutine
10.1 清理时序
10.2 为什么 cancel() 以后还要关闭 resp.Body
Scanner 可能阻塞在上游网络读取:
scanner.Scan()
内部 Context 取消不一定能直接让这个底层读取返回。主动关闭 resp.Body 可以中断
上游响应读取,使 Scanner 进入 defer 和退出流程。
这对 LLM 网关还有额外意义:
客户端已经离开
-> 关闭上游响应体
-> 尽快终止无消费方的生成
-> 减少连接占用和无效 token 消耗
10.3 为什么请求返回前要 wg.Wait()
Scanner、Handler 和 Ping Goroutine 都可能访问 gin.Context、RelayInfo 或
ResponseWriter。如果主 Handler 已经返回,而这些 goroutine 仍在运行:
- Gin Context 可能被框架复用;
- 后台 goroutine 可能写入已经结束的响应;
- 不同请求之间可能出现状态串扰;
- 资源泄漏和数据竞争更难定位。
wg.Wait() 将“请求函数返回”与“所有内部 goroutine 已退出”绑定在一起。
11. 状态模块:HTTP 200 不能代表流正常完成
上游可能已经返回 HTTP 200,但流仍可能以多种方式结束:
StreamStatus 记录:
- 结束原因;
- 结束错误;
- 软错误数量;
- 部分错误详情。
RelayInfo 还记录:
- 首响应时间;
- 已接收 chunk 数;
- 当前流状态;
- Provider 和渠道上下文。
这些状态让网关能够区分:
连接建立成功
流处理成功
流完整结束
usage 信息完整
客户端实际消费完成
这些结论在传统短请求中经常可以合并判断,在 LLM 流式请求中需要分别记录。
12. 稳定性保障如何落到各个模块
| 风险 | 稳定性机制 | 保障范围 |
|---|---|---|
| 上游读取和 Handler 速度不一致 | dataChan 有界缓冲 |
单条流内部 |
| chunk 顺序错乱 | 单消费者顺序处理 | 单条流内部 |
| Ping 与业务数据交错 | writeMutex |
单个下游连接 |
| 客户端断开后继续生成 | Request Context、关闭 resp.Body |
单条上下游链路 |
| 上游长时间无新数据 | Streaming Timeout | 单条流 |
| 慢客户端导致写入不返回 | Write Deadline | 单次下游写入 |
| 多个结束事件重复关闭 | sync.Once |
停止和清理动作 |
| goroutine 泄漏或继续访问 Context | WaitGroup |
单条流生命周期 |
| Scanner 单行过大 | Scanner 最大缓冲配置 | 单个 SSE 行 |
| 结束原因不可判断 | StreamStatus |
日志、指标、结算上下文 |
这些机制主要保证单条流能够正确运行和退出。系统级稳定性还需要其他层共同完成:
dataChan 的容量为 10,并不代表系统只允许 10 个请求;HTTP Transport 的空闲连接数
也不等于请求并发上限。LLM 网关的容量治理需要同时考虑:
- 活跃流数量;
- 每个用户的并发流;
- 每个渠道的并发流;
- Provider 的 HTTP/2 stream 限制;
- goroutine 和文件描述符;
- 长连接平均持续时间;
- 下游慢客户端比例;
- 单条响应的 chunk 大小。
13. 与传统网关实现方式的核心区别
13.1 传统网关关注一次调用
请求是否成功路由
后端是否返回
状态码是多少
总耗时是多少
13.2 LLM 网关关注一段持续运行的会话
多久收到首 token
chunk 是否按顺序到达
客户端是否仍然在线
上游是否长时间无数据
是否有 usage
流以什么原因结束
结束后上游是否仍在生成
所有 goroutine 是否已经退出
因此,LLM 网关的流式模块可以理解为一个轻量级运行时,Response Body 转发只是其中
的数据输入环节。
14. 当前边界与演进方向
14.1 SSE 解析边界
当前实现按行识别 data: 和 [DONE],适合 OpenAI 风格 SSE。更通用的 SSE 还可能
包含:
- 多行
data:组成一个事件; event:;id:;retry:;- 空行作为事件结束边界。
可以进一步拆成:
14.2 运行时与框架边界
当前运行时直接依赖:
gin.Context;http.Response;RelayInfo;- Provider 回调;
- 下游 Writer。
如果需要在更多协议或框架中复用,可以提炼独立的 StreamRuntime:
14.3 单条流稳定与系统级过载保护
当前结构重点解决单条流的正确性与回收。系统级稳定还需要增加或验证:
这些能力适合放在请求入口、渠道调度、连接治理和监控系统中,与
StreamScannerHandler 所负责的单条流运行时形成上下层关系。
14.4 Ping 异常的控制收敛
当前 Ping 写入失败时会记录 StreamEndReasonPingFail 并退出 Ping Goroutine,但普通
错误分支没有立即调用 stop()。主循环仍要等待上游结束、空闲超时或客户端断开后才会
进入清理。
从稳定性角度,可以进一步明确 Ping 失败的策略:
| 策略 | 适用判断 | 行为 |
|---|---|---|
| 立即终止流 | Ping 写失败通常说明下游连接不可写 | 设置状态后调用 stop() |
| 交给 Request Context | Writer 错误会可靠触发客户端 Context 取消 | 保持现状并补充验证 |
| 区分错误类型 | 临时错误和连接关闭需要不同处理 | 对错误分类后决定是否停止 |
无论选择哪种策略,都需要确保“记录了致命结束原因”和“触发运行时清理”保持一致,
避免状态已经标记失败,但上游仍继续生成。
15. 总结
StreamScannerHandler 展示的是 LLM 网关内部一条流如何被组织起来:
这套结构可以归纳成四条设计原则:
- 数据阶段化:读取、解析、转换和写出拥有明确边界。
- 控制信号分层:业务数据、停止广播、外部取消和定时事件分别表达。
- 生命周期收敛:所有结束路径最终进入统一的资源清理和 goroutine 等待。
- 单流与系统分层:运行时保证单条流稳定,入口和调度层负责系统级容量治理。
分析对象:
relay/helper/stream_scanner.go中的StreamScannerHandler
关联代码:relay/helper/stream_result.go、relay/common/stream_status.go
对于传统网关,响应转发通常在完整响应返回后结束;对于 LLM 网关,流式处理从上游
连接建立后才真正开始。能否稳定管理这段持续运行的生命周期,直接决定了首 token
延迟、下游体验、资源占用、异常定位和计费结果的一致性。

浙公网安备 33010602011771号