拓冰建站拓冰建站
首页 / 资讯中心 / 正文

从零构建LLM推理网关:SSE流式转发、级联取消与背压控制实战

1. 为什么我要自己造一个 LLM 推理网关1.1 从一次线上事故说起去年年底我在帮一个内部工具做 AI 能力接入。架构很简单前端一个聊天框后端一个 Go 服务收到请求后转发给上游的大模型接口拿到流式响应再吐回给浏览器。上线第一周没什么问题第二周开始陆续有用户反馈“回答到一半就卡住了”“刷新之后重新问一遍才行”。我去翻日志发现两类报错反复出现。一类是stream disconnected before completion: idle timeout waiting for SSE另一类是上游返回400: {type:missing_session_id,message:error from provider}。前者说明流式连接在传输过程中被静默掐断了后者说明会话状态在转发链路上丢了。这两个问题单独看都不复杂但它们暴露的是同一个根因我把“转发”这件事想得太简单了。一个真正能扛住生产流量的 LLM 推理网关至少要解决四件事SSE 流式转发的正确性、客户端断开时的级联取消、上游生产速度超过下游消费速度时的背压控制、以及会话与错误状态的一致性维护。这四件事在普通 HTTP 转发里几乎不用考虑但在 LLM 场景下全部变成了一等公民。这篇文章就是我把这套网关从零重写一遍的完整记录包含设计取舍、关键代码结构、参数计算和踩过的坑。1.2 这个网关到底解决什么问题先把定位说清楚。它不是模型推理框架不负责加载权重、不做 batch 调度它站在客户端和上游推理服务之间干的是“流量治理”的活。你可以把它理解成一个专门为流式 LLM 响应优化的反向代理层。它要解决的核心痛点有这么几个。第一LLM 响应是逐 token 生成的一个请求可能持续几十秒甚至几分钟传统的“收完再转发”模式完全不可用必须边收边转。第二用户随时可能关掉页面或点“停止生成”这时候如果网关不主动取消上游请求上游会继续烧算力成本直接翻倍。第三上游生成速度快、下游网络慢的时候如果没有背压机制内存里的缓冲区会无限膨胀最后 OOM。第四SSE 协议本身对连接中断很敏感任何中间环节的超时设置不当都会导致“流断在半路”。适合读这篇的人正在做 AI 应用后端、需要自己搭一层网关的 Go 开发者被 SSE 断流问题折磨过的同学以及想理解“级联取消”和“背压”这两个词在真实系统里到底怎么落地的人。不需要你是 Go 专家但至少要写过 HTTP 服务知道 goroutine 和 channel 大概是怎么回事。1.3 技术选型为什么是 Go为什么是 SSE选 Go 的理由很直接。这个网关的本质是高并发 I/O 密集型转发每个请求都要维持一条长连接同时可能有成百上千个请求在飞。Go 的 goroutine 在这种场景下几乎是降维打击——每个连接一个 goroutine写起来像同步代码跑起来是异步性能内存占用还低。换成 Java 要处理线程池和回调地狱换成 Node 单线程又怕 CPU 密集的解析拖垮事件循环。Go 的net/http标准库对流式响应的支持也很成熟Flusher接口直接就能把缓冲区刷给客户端。选 SSE 而不是 WebSocket是因为 LLM 的场景是单向流式推送。客户端发一次请求服务端持续推 token不需要双向实时通信。SSE 基于普通 HTTP天然穿透各种代理和负载均衡实现成本低浏览器端EventSource或者fetch加流读取都能处理。WebSocket 虽然更灵活但要处理握手升级、心跳保活、帧解析对这个场景来说是过度设计。热搜词里有人问“web socket 和 sse”的区别一句话总结需要服务端主动推、且只需要单向推选 SSE需要双向低延迟交互选 WebSocket。2. 整体架构与核心设计思路2.1 三层结构接入层、转发层、上游适配层我把网关拆成三层每层职责单一方便单独测试和替换。接入层负责和客户端打交道解析请求、校验参数、建立 SSE 响应头、管理客户端连接的生命周期。这一层最关键的是拿到一个context.Context它会在客户端断开时被 cancel这个 context 会一路往下传成为级联取消的源头。转发层是核心负责从上游读取流、解析 SSE 事件、写入下游。它要同时监听两个方向上游的数据到达、下游的写入完成。这里我用一个io.Pipe或者带缓冲的 channel 来解耦读写速率背压逻辑就藏在这个缓冲区的大小和阻塞行为里。上游适配层负责和具体的推理服务对接拼装请求体、处理鉴权、解析不同厂商的响应格式。因为不同上游的 SSE 事件格式不一样有的用data:有的用event:加data:这一层做归一化把上游格式转成统一的内部事件结构再交给转发层。这样分层的好处是换上游只需要改适配层背压和取消逻辑完全不用动。我实测下来接一个新的上游服务适配层大概 100 行代码就能搞定。2.2 级联取消一个 context 串起整条链路级联取消这个词听起来玄乎本质就是一个取消信号沿着调用链往下传每一层都响应它。在 Go 里就是context.Context的 cancel 机制。具体到我的网关客户端发起请求接入层用r.Context()作为根 context。这个 context 在客户端断开连接时会被 HTTP server 自动 cancel。转发层拿着这个 context 去请求上游用的是http.NewRequestWithContext。于是当客户端断开根 context 被 cancel上游请求的 context 也跟着 cancelhttp.Client会立即关闭到上游的连接。上游服务收到连接关闭也就停止生成了。这里有个坑我踩过如果你用的是自己管理的http.Client并且设置了连接池cancel 之后连接不一定会立刻关闭可能会被复用。解决办法是在请求上游时给 context 加一个明确的超时并且确保Transport的DisableKeepAlives在长连接场景下配置正确。另外goroutine 泄漏是级联取消没做好的典型症状——客户端断了但读上游的 goroutine 还阻塞在Read上。我的做法是每个转发 goroutine 都select监听ctx.Done()一旦收到信号立即返回。2.3 背压让快的一方等一等慢的一方背压是流式系统里最容易被忽略、出问题又最致命的一环。想象一个场景上游模型生成速度是每秒 100 个 token下游用户的网络只能每秒接收 20 个 token。如果没有背压网关内存里会堆积越来越多的未发送数据一个请求堆几 MB一千个并发就是几个 GBOOM 是迟早的事。背压的核心思想是用阻塞传递压力。当缓冲区满了写入方就应该阻塞直到消费方腾出空间。在 Go 里带缓冲的 channel 天然支持这个语义ch - data在缓冲满时会阻塞当前 goroutine。我把上游读到的每个 SSE 事件写进一个容量固定的 channel下游从 channel 里读并写回客户端。channel 满了上游读取 goroutine 就阻塞自然就“背压”到了上游——因为我不再从上游 socket 读数据TCP 接收窗口会逐渐缩小最终上游的发送也会被阻塞。缓冲区容量怎么定这是个需要计算的参数。我按“单事件平均大小 × 期望缓冲事件数”来估。实测一个 token 事件大约 200 字节到 1KB我取 512 字节均值。如果希望缓冲 64 个事件容量就是 32KB 左右。但 channel 存的是事件对象不是字节所以我按事件个数设容量64 到 128 之间比较合适。太小会导致频繁阻塞、吞吐下降太大则失去背压意义、内存风险上升。3. SSE 流式转发的核心实现细节3.1 SSE 协议要点与常见误区SSE 的协议格式其实很简单但细节坑很多。一个标准的事件长这样event: message data: {content: 你好} data: {content: 世界}注意几个点。第一每个字段后面跟一个冒号和空格然后才是值。第二事件之间用空行分隔也就是连续两个换行符。第三data:可以出现多次会被拼接成多行数据。第四注释行以冒号开头常用来做心跳保活。最常见的误区是换行符处理。SSE 规范要求用\n但有些上游用\r\n如果你解析时不兼容就会把\r当成数据的一部分导致 JSON 解析失败。我的做法是解析前统一把\r\n替换成\n。另一个误区是忘记 flush。Go 的http.ResponseWriter默认会缓冲你不调用Flusher.Flush()数据就卡在缓冲区里客户端一直收不到表现就是“流不动”。每个事件写完后必须 flush这是硬性要求。还有一个容易被忽略的点响应头必须设置Content-Type: text/event-stream并且要禁用各种中间层的缓冲。我一般还会加上Cache-Control: no-cache和X-Accel-Buffering: no后者是给 Nginx 看的告诉它不要缓冲这个响应。热搜里那个curl sse的调试方法很实用用curl -N就能看到实时的流式输出-N是禁用 curl 自己的缓冲。3.2 上游读取逐行扫描与事件组装从上游读 SSE 流我用的是bufio.Scanner逐行读。为什么不一次性ReadAll因为流是持续的ReadAll会一直等到连接关闭那就完全失去流式意义了。扫描的逻辑是读一行如果是空行说明一个事件结束把累积的data字段组装成完整事件发出去如果是以data:开头去掉前缀存起来如果是event:开头记录事件类型其他行忽略。这里要注意bufio.Scanner默认的单行长度上限是 64KB如果上游某个事件特别大比如返回了很长的 JSON会报token too long。解决办法是用scanner.Buffer把上限调大我一般设成 1MB。组装事件时有个细节多个data:行要用\n连接这是规范要求。我见过有人直接用拼接不加换行结果 JSON 里少了换行导致解析出错。另外上游可能在流中间插入心跳注释行以:开头这些要直接跳过不能当成数据。3.3 下游写入错误处理与优雅收尾往下游写的时候最怕的是写了一半客户端断了。这时候Write会返回错误你必须立即停止并且触发取消上游。我的处理是每次Write都检查 error一旦非 nil就 cancel 根 context 并 return。同时Flush也可能出错同样要检查。优雅收尾指的是流正常结束时要发一个明确的结束事件。有些客户端依赖特定的结束标记来判断“生成完成”。我一般会发一个event: done加空 data或者直接关闭连接。但要注意关闭连接前要确保所有缓冲数据都 flush 出去了否则最后几个 token 会丢。我踩过一次坑上游发完最后一个 token 后立即关闭连接我的扫描循环退出但最后一个事件还在 channel 里没被下游消费结果用户看到的是“回答少了一个字”。解决办法是扫描循环退出后先关闭 channel让下游把剩余事件消费完再结束响应。4. 级联取消与背压的落地实现4.1 取消信号的传播路径与 goroutine 管理一个请求进来我至少会起两个 goroutine一个读上游一个写下游。这两个 goroutine 通过 channel 通信。取消信号要从根 context 传到这两个 goroutine。读上游的 goroutine 里scanner.Scan()是阻塞的它不响应 context。所以我不能只靠select ctx.Done()因为 Scan 卡住的时候 select 根本没机会执行。解决办法是在单独的 goroutine 里做 Scan主循环 select 监听结果 channel 和 ctx.Done()。或者更简单给上游请求设置 context当 context cancel 时底层的resp.Body会被关闭Scan()会返回 false循环自然退出。这是最干净的做法依赖http.Client对 context 的支持。写下游的 goroutine 里Write和Flush也可能阻塞下游网络慢。这时候如果客户端断开Write会返回错误goroutine 退出。但如果它阻塞在 Write 上context cancel 不会直接中断它。所以我在写之前会先select检查一次 ctx写完再检查一次尽量缩短阻塞窗口。极端情况下依赖 HTTP server 的连接关闭来中断 Write。goroutine 泄漏的排查我用go tool pprof看 goroutine profile如果发现某个函数对应的 goroutine 数量持续增长基本就是泄漏了。常见原因是 channel 没人读导致发送方永久阻塞或者 context 没传到位。4.2 背压缓冲区的容量计算与动态调整前面说了用带缓冲 channel 做背压容量我定在 64。但这个值不是拍脑袋来的我做了个简单的计算。假设单个事件平均 512 字节64 个事件就是 32KB。单请求内存占用 32KB 缓冲 goroutine 栈约 8KB 其他开销算 50KB。1000 并发就是 50MB完全可控。如果容量设成 1024单请求就是 512KB1000 并发 512MB风险就大了。所以 64 是个内存和吞吐的平衡点。但固定容量有个问题不同上游的生成速度差异很大。有的模型每秒吐 200 个 token有的只有 10 个。对慢上游64 的缓冲绰绰有余对快上游可能频繁触发背压。我的优化思路是根据上游响应头里的速率提示或者前 N 个事件的到达间隔动态调整容量。不过实测下来固定 64 在大多数场景已经够用动态调整的复杂度收益比不高我就没上。如果你要接的上游特别快可以调到 128 或 256但要同步监控内存。还有一个细节channel 关闭的时机。读上游的 goroutine 结束时关闭 channel写下游的 goroutine 用for range ch消费channel 关闭后循环自动退出。这样不需要额外的 done 信号逻辑很干净。但要注意关闭 channel 的只能是发送方多个发送方时要用sync.Once或者 WaitGroup 保证只关一次。4.3 超时策略idle timeout 与总超时的配合热搜里那个idle timeout waiting for SSE报错根因就是超时设置不当。LLM 流式响应有个特点首 token 可能等很久但 token 之间间隔很短。如果你设一个总超时 30 秒那长回答直接被砍如果你设一个 idle 超时多久没数据就断那首 token 等待期可能误伤。我的策略是双超时。一个 idle timeout比如 60 秒只要 60 秒内有任何数据到达就重置一个总超时比如 10 分钟兜底防止请求永远挂着。idle timeout 用time.Timer实现每次读到数据就Reset。总超时直接用 context 的WithTimeout。这里有个坑time.Timer的Reset在 Go 1.23 之前有竞态问题需要先Stop再 drain channel。Go 1.23 之后Reset语义修正了可以直接用。如果你用的是老版本记得处理这个细节。另外idle timeout 触发后要发一个明确的错误事件给客户端而不是直接断连这样前端能给出友好提示。5. 常见问题排查与避坑实录5.1 流断在半路从现象到根因的排查路径“流断在半路”是我遇到最多的问题排查起来有一套固定路径。第一步确认是上游断还是下游断。看日志里最后一条成功转发的事件如果上游还在发但下游没收到问题在下游写入或网络如果上游本身就不发了问题在上游或网关到上游的连接。第二步检查超时配置。idle timeout和中间层Nginx、负载均衡的超时都要看。Nginx 默认proxy_read_timeout是 60 秒如果你的 idle timeout 也是 60 秒两者叠加可能提前触发。我一般把网关的 idle timeout 设得比中间层小让网关先感知、先处理。第三步看连接是否被复用。HTTP/1.1 的 keep-alive 在长连接场景下可能导致连接被错误复用尤其是上游返回了不完整的响应时。我一般对上游请求设置Connection: close或者禁用 keep-alive牺牲一点性能换稳定性。第四步抓包确认。用tcpdump或者 Go 的httptrace看 TCP 层的 FIN/RST 是谁先发的。这一步能定位到具体是哪一端的连接被关闭。5.2 会话丢失与 400 错误的处理热搜里那个missing_session_id的 400 错误本质是会话状态在转发链路上没有正确传递。LLM 服务通常用 session id 来关联多轮对话的上下文。如果网关在转发时丢了 header 或者 cookie上游就找不到会话。我的处理是接入层把所有和会话相关的 header比如X-Session-Id、Authorization、Cookie原样透传到上游不做任何过滤。同时如果上游返回 400 且错误信息里提到 session我会在网关层记录一条明确的日志包含请求 id 和原始 header方便排查。还有一种情况是上游要求 session id 在请求体里而不是 header 里。这时候适配层要做转换从 header 提取出来塞进 body。这个逻辑因上游而异我把它做成可配置的用配置文件描述“从哪取、放到哪”。5.3 常见问题速查表现象可能原因排查方法解决方向流不动客户端无数据忘记 Flush检查写入后是否调用 Flush每个事件后强制 Flush流断在半路idle timeout 或中间层超时对比网关和 Nginx 超时配置网关超时设小先感知回答少最后一个字channel 关闭时机不对检查扫描退出后是否消费完剩余事件先关 channel 再结束响应内存持续增长背压失效或 goroutine 泄漏pprof 看 goroutine 和 heap检查 channel 容量和 context 传递400 missing session会话 header 丢失对比客户端和上游收到的 header透传会话相关 headerJSON 解析失败换行符或 data 拼接问题打印原始事件内容统一换行符多行 data 用 \n 连接上游不停止生成级联取消未生效检查上游请求是否用了根 context用 NewRequestWithContext5.4 几个我踩过的独家坑第一个坑bufio.Scanner的默认缓冲太小。上游返回一个大的 JSON 事件时直接报错退出表现就是流突然断。调大scanner.Buffer的 max 到 1MB 解决。第二个坑http.Flusher不是所有 ResponseWriter 都支持。如果你用了某些中间件包装了 ResponseWriter可能拿不到 Flusher。我的做法是在接入层用类型断言检查拿不到就报错绝不静默降级。第三个坑context 传递时被意外覆盖。有一次我在适配层里用context.Background()新建了请求结果级联取消完全失效客户端断了上游还在跑。排查了半天才发现是这里。教训是任何地方新建 context 都要问自己“这个 context 的父级是谁”。第四个坑channel 容量设太大反而更慢。我一度把容量调到 1024 想提升吞吐结果发现延迟反而上升了因为数据在缓冲区里排队客户端要等更久才看到第一个 token。背压缓冲区不是越大越好它影响的是延迟和内存的权衡。6. 性能验证与后续可扩展方向6.1 压测方法与关键指标验证网关性能我用的是自己写的一个压测工具模拟 N 个并发客户端每个客户端发一个请求并持续读取流直到结束。关键指标有三个首 token 延迟TTFT、吞吐每秒完成请求数、内存占用。TTFT 反映的是网关引入的额外延迟。理想情况下网关的 TTFT 应该和直连上游差不多额外开销在毫秒级。我实测下来网关引入的 TTFT 增加在 5ms 以内可以接受。吞吐方面单机 8 核跑 1000 并发CPU 占用在 40% 左右瓶颈主要在上游的生成速度而不是网关本身。这说明网关的转发逻辑没有成为瓶颈。内存占用是重点观察对象。1000 并发下RSS 稳定在 200MB 左右没有持续增长说明背压和 goroutine 管理是有效的。如果看到内存曲线一直往上走那一定是哪里泄漏了。6.2 可以继续做的优化第一个方向是连接池优化。目前对上游用的是默认 Transport连接复用策略可以调优比如设置MaxIdleConnsPerHost来匹配并发量减少建连开销。第二个方向是多上游负载均衡。现在只接一个上游如果要做高可用需要加一层选择逻辑根据上游的健康状态和负载来分发请求。这块可以结合健康检查和加权轮询。第三个方向是可观测性增强。目前只有基础日志可以加 Prometheus 指标暴露活跃连接数、背压触发次数、取消次数等方便做容量规划和告警。第四个方向是请求级别的限流。防止单个用户占用过多并发影响其他人。可以用令牌桶算法按用户维度限流。这套网关我从零写到现在大概迭代了三个版本第一版只做了基本转发第二版加了取消和背压第三版做了分层重构和错误处理完善。每一版都是被真实问题逼出来的。如果你也在做类似的东西我的建议是先把级联取消和背压这两个基础打牢它们决定了系统的下限SSE 的细节处理决定了上限。别急着加功能先把流的正确性做扎实后面扩展起来才不痛苦。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门