newApi 网关流式运行时设计以及源码解读

1. 从传统网关到 LLM 网关

传统 API 网关处理的大多数请求,可以抽象成一次完整的请求响应:

flowchart LR C["客户端"] --> G["传统 API 网关"] G --> S["后端服务"] S -->|"完整响应"| G G -->|"完整响应"| C

网关主要关心:

  • 鉴权;
  • 路由;
  • 限流;
  • 超时;
  • 重试;
  • 熔断;
  • 请求和响应日志。

LLM 流式请求仍然需要这些能力,但请求进入 Provider 后,响应不再是一次性返回。
模型会持续产生 token,网关也要持续读取、转换和写出。

flowchart LR C["客户端"] -->|"stream=true"| G["LLM 网关"] G --> P["LLM Provider"] P -->|"chunk 1"| G P -->|"chunk 2"| G P -->|"chunk 3"| G P -->|"DONE / EOF"| G G -->|"实时 Flush"| C

一次请求因此变成一段持续运行的过程:

建立连接
  -> 等待首 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,再写给下游客户端。

flowchart LR C["客户端"] E["Gin / net/http 入口"] M["鉴权、校验、限流"] D["Distribute<br/>渠道选择"] R["Relay 编排<br/>预扣费、重试、结算"] A["Provider Adaptor<br/>URL、Header、Request"] T["HTTP Client / Transport"] P["上游 LLM Provider"] S["StreamScannerHandler<br/>流式运行时"] H["Provider Stream Handler<br/>解析与格式转换"] W["ResponseWriter + Flush"] B["usage、日志、指标"] C --> E --> M --> D --> R --> A --> T --> P P -->|"Response.Body"| S S -->|"data chunk"| H H -->|"统一流式响应"| W W --> C S -. "StreamStatus" .-> B H -. "usage" .-> B R -. "结算结果" .-> B

这个位置决定了它的职责边界:

前置模块决定“请求发给谁”
StreamScannerHandler 决定“一条流如何稳定运行”
Provider Handler 决定“chunk 表达什么”
后置模块决定“如何记录和结算”

3. 流式运行时的模块划分

从技术结构看,可以将这套实现分成七个模块。

flowchart TB subgraph M1["1. 上游连接模块"] U1["http.Client / Transport"] U2["http.Response.Body"] end subgraph M2["2. 流数据读取模块"] R1["bufio.Scanner"] R2["ScanLines"] R3["data: / [DONE] 识别"] end subgraph M3["3. Channel 数据协调模块"] Q1["dataChan"] Q2["有界缓冲"] Q3["顺序消费"] end subgraph M4["4. Provider 适配模块"] A1["dataHandler"] A2["协议解析"] A3["统一格式转换"] end subgraph M5["5. 下游输出模块"] W1["writeMutex"] W2["ResponseWriter"] W3["Flush"] W4["Ping"] end subgraph M6["6. 控制模块"] C1["Request Context"] C2["ctx.Done"] C3["stopChan"] C4["Timeout"] end subgraph M7["7. 状态与回收模块"] L1["StreamStatus"] L2["resp.Body.Close"] L3["ticker.Stop"] L4["WaitGroup"] end M1 --> M2 --> M3 --> M4 --> M5 M6 --> M2 M6 --> M3 M6 --> M4 M6 --> M5 M2 --> M7 M4 --> M7 M5 --> M7
模块 解决的网关问题 设计方式
上游连接 如何复用连接并持续读取 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 如何穿过网关

flowchart LR U["Provider<br/>Response.Body"] B["连续字节流"] S["Scanner Goroutine<br/>逐行读取"] F{"SSE 行判断"} I["忽略 comment / event / 空行"] D["提取 data payload"] Q[("dataChan<br/>buffer = 10")] H["Data Handler Goroutine"] A["Provider Handler<br/>解析与转换"] W["下游 Writer"] FL["Flush"] C["客户端"] E["DONE / EOF / Error"] U --> B --> S --> F F -->|"无关行"| I F -->|"data:"| D F -->|"[DONE]"| E D --> Q --> H --> A --> W --> FL --> C S -->|"读取结束"| E

这条路径有三个重要特征。

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 并写入客户端。两者耗时来源不同。

flowchart LR S["Scanner Goroutine<br/>网络读取速度"] Q[("dataChan<br/>容量 10")] H["Handler Goroutine<br/>解析和写出速度"] S -->|"dataChan <- data"| Q Q -->|"range dataChan"| H Q -. "缓冲未满<br/>读取阶段可以继续" .-> S Q -. "缓冲已满<br/>发送阻塞形成背压" .-> H

如果完全同步:

Scanner 读取一个 chunk
  -> 等待解析
  -> 等待客户端写出
  -> 才能继续读取上游

任何一次短暂的处理抖动都会直接阻塞上游读取。

如果使用无界队列:

上游持续产生数据
  -> 下游处理变慢
  -> chunk 不断积压
  -> 内存占用没有明确边界

有界 dataChan 提供了中间方案:

允许短期速度差
限制最大积压量
积压达到边界后向上游读取阶段传递压力

5.2 为什么保持单消费者

当前 Handler 按 range dataChan 顺序消费。单消费者避免了 chunk 并发处理后的重新
排序问题。

sequenceDiagram participant S as Scanner participant Q as dataChan participant H as Handler participant C as Client S->>Q: chunk 1 S->>Q: chunk 2 S->>Q: chunk 3 Q->>H: chunk 1 H->>C: response 1 Q->>H: chunk 2 H->>C: response 2 Q->>H: chunk 3 H->>C: response 3

LLM chunk 往往具有上下文依赖:

  • delta 需要按顺序拼接;
  • tool call 参数可能跨多个 chunk;
  • usage 通常出现在最后几个事件;
  • Responses API 事件存在生命周期顺序;
  • 下游客户端要求 SSE 事件顺序稳定。

因此,这里的设计重点放在顺序、可预测性和较低的状态管理复杂度,单条流内不再增加
多个并行消费者。

5.3 dataChan 的关闭所有权

Scanner 是唯一生产者,因此由 Scanner 负责关闭 dataChan:

flowchart LR S["Scanner<br/>唯一生产者"] -->|"发送 chunk"| Q[("dataChan")] S -->|"发送阶段结束"| X["close(dataChan)"] Q -->|"range"| H["Handler<br/>消费者"] X -->|"通知无后续数据"| H

这个规则保证:

生产者决定何时不会再发送
消费者通过 range 自然结束
消费者不需要猜测上游是否还有数据

6. 数据 Channel 和停止 Channel

dataChan 与 stopChan 看起来都是 Channel,但它们承担不同的协议。

flowchart TB subgraph DATA["dataChan:数据传递"] DS["Scanner"] -->|"每个 chunk 发送一次"| DC[("chan string<br/>buffer 10")] DC -->|"逐个接收"| DH["Data Handler"] end subgraph STOP["stopChan:停止广播"] SS["stop()"] -->|"sync.Once"| CL["close(stopChan)"] CL --> SC[("stopChan")] SC -->|"直接监听"| SG1["Scanner"] SC -->|"直接监听"| SG2["Ping"] SC -->|"主循环监听"| SG3["Main Handler"] SG1 -->|"退出时 close(dataChan)"| SG4["Data Handler 结束 range"] end
对比项 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 的流式协议存在明显差异,但连接生命周期问题高度相似。

flowchart LR R["统一 Stream Runtime<br/>读取、分发、取消、清理"] R --> O["OpenAI Handler"] R --> C["Claude Handler"] R --> G["Gemini Handler"] R --> D["Dify Handler"] R --> X["其他 Provider Handler"] O --> U["统一下游协议"] C --> U G --> U D --> U X --> U

7.1 公共运行时处理稳定能力

  • 持续读取上游;
  • Channel 数据分发;
  • 客户端取消;
  • 空闲超时;
  • Ping;
  • goroutine 退出;
  • 上游 Body 关闭;
  • StreamStatus 记录。

7.2 Provider Handler 处理协议差异

  • chunk JSON 结构;
  • 事件类型;
  • content 和 reasoning;
  • tool call;
  • usage;
  • Provider 错误;
  • 业务结束事件;
  • 下游格式转换。
flowchart TB N["新增一个 Provider"] N --> A["实现 Request Adaptor"] N --> H["实现 Stream dataHandler"] A --> R["复用渠道选择和 HTTP Transport"] H --> S["复用 Stream Runtime"] S --> C["获得统一取消、超时、心跳和清理能力"]

这样设计可以避免两种扩散:

Provider 分支扩散到公共运行时
生命周期代码复制到每个 Provider

8. 控制通路:一条流如何停止

流式请求存在多个停止来源。大部分结束路径通过 stop() 唤醒主循环,客户端断开和
空闲超时由主循环直接感知,最终都进入 cleanup()。

flowchart TB D1["收到 [DONE]"] D2["上游 EOF"] D3["Scanner Error"] D4["Handler Stop / Done"] D5["客户端断开"] D6["空闲超时"] D7["Ping goroutine panic"] D8["Scanner / Handler panic"] D1 --> E["设置 StreamStatus"] D2 --> E D3 --> E D4 --> E D5 --> E D6 --> E D7 --> E D8 --> E E --> S["触发 stop 或唤醒主循环"] S --> C["cleanup"]

8.1 三类取消信号

flowchart LR Client["客户端断开"] --> RC["c.Request.Context().Done()"] RC --> Main["Main Handler"] Main --> Cancel["cancel()"] Cancel --> IC["ctx.Done()"] StopEvent["DONE / Stop / Error"] --> Stop["stop()"] Stop --> SC["close(stopChan)"] IC --> Scanner["Scanner<br/>直接监听"] IC --> Ping["Ping<br/>直接监听"] SC --> Scanner SC --> Ping SC --> Main Scanner -->|"退出并 close(dataChan)"| Handler["Data Handler<br/>结束 range"]
信号 表达的含义
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 写数据。

flowchart LR H["Data Handler<br/>业务 chunk"] P["Ping Goroutine<br/>: PING"] M["writeMutex"] W["ResponseWriter"] C["Client"] H --> M P --> M M --> W -->|"Flush"| C

写锁保证一次只有一个 goroutine 操作下游 Writer,避免出现:

业务 SSE 写到一半
Ping 插入其中
下游收到无法解析的事件

当前实现是在持锁期间执行整个 dataHandler。它保证了顺序,但锁的范围也包含协议
解析和业务处理。后续可以评估将“解析”和“实际写出”拆开,只在写 ResponseWriter
时持锁。

9.2 Flush 决定客户端何时看到数据

只调用 Write 不一定意味着数据已经离开服务器缓冲区。每个 chunk 完成后调用
Flush(),才能形成真正的增量响应。

sequenceDiagram participant H as Provider Handler participant W as ResponseWriter participant F as http.Flusher participant C as Client H->>W: Write chunk W->>F: Flush() F-->>C: 客户端立即收到 chunk

9.3 Ping 维持长时间无 token 的连接

模型可能长时间没有新 token,中间代理也可能存在空闲连接超时。Ping Goroutine 定期
写入 SSE 注释:

: PING
sequenceDiagram participant U as Provider participant S as Scanner participant P as Ping Goroutine participant W as Writer participant C as Client U-->>S: 暂无业务 chunk P->>W: 写入 : PING W-->>C: Flush 心跳 U-->>S: 新业务 chunk S->>W: 写入业务事件 W-->>C: Flush 数据

Ping 属于连接控制数据,不进入 dataChan,也不参与 Provider 协议解析。

10. 生命周期收敛:为什么要关闭 Body 并等待 goroutine

10.1 清理时序

sequenceDiagram participant M as Main Handler participant C as Internal Context participant X as stopChan participant U as resp.Body participant S as Scanner Goroutine participant H as Handler Goroutine participant P as Ping Goroutine participant W as WaitGroup M->>C: cancel() M->>X: stop() / close(stopChan) M->>U: resp.Body.Close() U-->>S: 解除 scanner.Scan 阻塞 M->>M: ticker.Stop() M->>M: pingTicker.Stop() C-->>S: ctx.Done C-->>P: ctx.Done S->>S: close(dataChan) S-->>H: range dataChan 结束 S->>W: wg.Done() H->>W: wg.Done() P->>W: wg.Done() M->>W: wg.Wait() W-->>M: 所有 goroutine 已退出

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,但流仍可能以多种方式结束:

flowchart TB H["HTTP 200 已建立流"] H --> D["正常 [DONE]"] H --> E["上游 EOF"] H --> T["空闲超时"] H --> C["客户端断开"] H --> S["Scanner Error"] H --> X["Handler Stop"] H --> P["Panic / Ping Fail"] D --> ST["StreamStatus"] E --> ST T --> ST C --> ST S --> ST X --> ST P --> ST ST --> L["日志"] ST --> M["指标"] ST --> B["usage 与结算判断"]

StreamStatus 记录:

  • 结束原因;
  • 结束错误;
  • 软错误数量;
  • 部分错误详情。

RelayInfo 还记录:

  • 首响应时间;
  • 已接收 chunk 数;
  • 当前流状态;
  • Provider 和渠道上下文。

这些状态让网关能够区分:

连接建立成功
流处理成功
流完整结束
usage 信息完整
客户端实际消费完成

这些结论在传统短请求中经常可以合并判断,在 LLM 流式请求中需要分别记录。

12. 稳定性保障如何落到各个模块

flowchart TB R1["上游读取阻塞"] --> P1["resp.Body.Close"] R2["Handler 短暂变慢"] --> P2["dataChan 有界缓冲"] R3["Handler 持续变慢"] --> P3["buffer 满后背压"] R4["业务数据与 Ping 并发写"] --> P4["writeMutex"] R5["客户端提前断开"] --> P5["Request Context + 关闭上游"] R6["上游长期无数据"] --> P6["streaming timeout"] R7["多个模块同时停止"] --> P7["sync.Once"] R8["goroutine 未退出"] --> P8["WaitGroup"] R9["单次写入长期阻塞"] --> P9["Write Deadline"] R10["异常导致状态丢失"] --> P10["StreamStatus + panic recovery"]
风险 稳定性机制 保障范围
上游读取和 Handler 速度不一致 dataChan 有界缓冲 单条流内部
chunk 顺序错乱 单消费者顺序处理 单条流内部
Ping 与业务数据交错 writeMutex 单个下游连接
客户端断开后继续生成 Request Context、关闭 resp.Body 单条上下游链路
上游长时间无新数据 Streaming Timeout 单条流
慢客户端导致写入不返回 Write Deadline 单次下游写入
多个结束事件重复关闭 sync.Once 停止和清理动作
goroutine 泄漏或继续访问 Context WaitGroup 单条流生命周期
Scanner 单行过大 Scanner 最大缓冲配置 单个 SSE 行
结束原因不可判断 StreamStatus 日志、指标、结算上下文

这些机制主要保证单条流能够正确运行和退出。系统级稳定性还需要其他层共同完成:

flowchart LR L1["请求入口<br/>用户和模型限流"] L2["渠道调度<br/>优先级、权重、可用性"] L3["HTTP Transport<br/>连接池、HTTP/2、超时"] L4["Stream Runtime<br/>单条流稳定运行"] L5["Billing / Logging<br/>结算与观测"] L1 --> L2 --> L3 --> L4 --> L5

dataChan 的容量为 10,并不代表系统只允许 10 个请求;HTTP Transport 的空闲连接数
也不等于请求并发上限。LLM 网关的容量治理需要同时考虑:

  • 活跃流数量;
  • 每个用户的并发流;
  • 每个渠道的并发流;
  • Provider 的 HTTP/2 stream 限制;
  • goroutine 和文件描述符;
  • 长连接平均持续时间;
  • 下游慢客户端比例;
  • 单条响应的 chunk 大小。

13. 与传统网关实现方式的核心区别

flowchart TB subgraph T["传统网关"] T1["接收请求"] T2["选择后端"] T3["等待完整响应"] T4["返回状态码和响应体"] T1 --> T2 --> T3 --> T4 end subgraph L["LLM 流式网关"] L1["接收请求"] L2["选择 Provider / Channel / Key"] L3["建立长连接"] L4["逐 chunk 读取"] L5["协议转换"] L6["逐 chunk Flush"] L7["持续监控取消、超时和错误"] L8["结束后清理与结算"] L1 --> L2 --> L3 --> L4 --> L5 --> L6 L6 --> L4 L4 --> L7 --> L8 end

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:;
  • 空行作为事件结束边界。

可以进一步拆成:

flowchart LR B["Byte Reader"] --> L["Line Reader"] L --> E["SSE Event Assembler"] E --> P["Provider Decoder"] P --> U["Unified Stream Event"]

14.2 运行时与框架边界

当前运行时直接依赖:

  • gin.Context;
  • http.Response;
  • RelayInfo;
  • Provider 回调;
  • 下游 Writer。

如果需要在更多协议或框架中复用,可以提炼独立的 StreamRuntime:

flowchart LR I["Stream Input"] F["Frame Reader"] Q["Bounded Queue"] D["Ordered Dispatcher"] A["Provider Adaptor"] O["Stream Output"] R["Stream Runtime"] I --> F --> Q --> D --> A --> O R --> F R --> Q R --> D R --> O R --> C["Cancel / Timeout / Cleanup / Status"]

14.3 单条流稳定与系统级过载保护

当前结构重点解决单条流的正确性与回收。系统级稳定还需要增加或验证:

flowchart TB S["系统级流量治理"] S --> U["用户级 in-flight 限制"] S --> C["渠道级并发限制"] S --> P["Provider 连接和 stream 配额"] S --> Q["请求排队与拒绝策略"] S --> M["活跃流、TTFT、慢客户端指标"] S --> D["按结束原因统计异常"]

这些能力适合放在请求入口、渠道调度、连接治理和监控系统中,与
StreamScannerHandler 所负责的单条流运行时形成上下层关系。

14.4 Ping 异常的控制收敛

当前 Ping 写入失败时会记录 StreamEndReasonPingFail 并退出 Ping Goroutine,但普通
错误分支没有立即调用 stop()。主循环仍要等待上游结束、空闲超时或客户端断开后才会
进入清理。

flowchart LR P["PingData 写入失败"] --> S["记录 ping_fail"] S --> G["Ping Goroutine 退出"] G -. "当前没有直接 stop()" .-> M["Main Handler 继续等待"] M --> E["上游结束 / timeout / client gone"] E --> C["cleanup"]

从稳定性角度,可以进一步明确 Ping 失败的策略:

策略 适用判断 行为
立即终止流 Ping 写失败通常说明下游连接不可写 设置状态后调用 stop()
交给 Request Context Writer 错误会可靠触发客户端 Context 取消 保持现状并补充验证
区分错误类型 临时错误和连接关闭需要不同处理 对错误分类后决定是否停止

无论选择哪种策略,都需要确保“记录了致命结束原因”和“触发运行时清理”保持一致,
避免状态已经标记失败,但上游仍继续生成。

15. 总结

StreamScannerHandler 展示的是 LLM 网关内部一条流如何被组织起来:

flowchart LR U["持续上游数据"] R["逐步读取"] Q["Channel 有界协调"] A["Provider 协议适配"] W["有序写出与 Flush"] C["取消、超时、心跳"] L["资源清理与状态收敛"] U --> R --> Q --> A --> W C --> R C --> Q C --> A C --> W R --> L A --> L W --> L

这套结构可以归纳成四条设计原则:

  1. 数据阶段化:读取、解析、转换和写出拥有明确边界。
  2. 控制信号分层:业务数据、停止广播、外部取消和定时事件分别表达。
  3. 生命周期收敛:所有结束路径最终进入统一的资源清理和 goroutine 等待。
  4. 单流与系统分层:运行时保证单条流稳定,入口和调度层负责系统级容量治理。

分析对象:relay/helper/stream_scanner.go 中的 StreamScannerHandler
关联代码:relay/helper/stream_result.go、relay/common/stream_status.go

对于传统网关,响应转发通常在完整响应返回后结束;对于 LLM 网关,流式处理从上游
连接建立后才真正开始。能否稳定管理这段持续运行的生命周期,直接决定了首 token
延迟、下游体验、资源占用、异常定位和计费结果的一致性。

posted @ 2026-09-08 10:09  alin_qu  阅读(52)  评论(0)    收藏  举报