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 也跟着 cancel,http.Client会立即关闭到上游的连接。上游服务收到连接关闭,也就停止生成了。
这里有个坑我踩过:如果你用的是自己管理的http.Client并且设置了连接池,cancel 之后连接不一定会立刻关闭,可能会被复用。解决办法是在请求上游时给 context 加一个明确的超时,并且确保Transport的DisableKeepAlives在长连接场景下配置正确。另外,goroutine 泄漏是级联取消没做好的典型症状——客户端断了,但读上游的 goroutine 还阻塞在Read上。我的做法是每个转发 goroutine 都select监听ctx.Done(),一旦收到信号立即返回。
2.3 背压:让快的一方等一等慢的一方
背压是流式系统里最容易被忽略、出问题又最致命的一环。想象一个场景:上游模型生成速度是每秒 100 个 token,下游用户的网络只能每秒接收 20 个 token。如果没有背压,网关内存里会堆积越来越多的未发送数据,一个请求堆几 MB,一千个并发就是几个 GB,OOM 是迟早的事。
背压的核心思想是用阻塞传递压力。当缓冲区满了,写入方就应该阻塞,直到消费方腾出空间。在 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,单请求就是 512KB,1000 并发 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 | 透传会话相关 header |
| JSON 解析失败 | 换行符或 data 拼接问题 | 打印原始事件内容 | 统一换行符,多行 data 用 \n 连接 |
| 上游不停止生成 | 级联取消未生效 | 检查上游请求是否用了根 context | 用 NewRequestWithContext |
5.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 的细节处理决定了上限。别急着加功能,先把流的正确性做扎实,后面扩展起来才不痛苦。