推理服务 gRPC 流式:客户端和服务端都要处理半开连接

📅 2026/7/9 9:34:48
推理服务 gRPC 流式:客户端和服务端都要处理半开连接
推理服务 gRPC 流式客户端和服务端都要处理半开连接一、流式推理的长连接陷阱为什么 TCP 半开比超时更危险在模型推理场景下gRPC 流式调用已经成为事实标准。无论是 vLLM 的 StreamingGenerate 还是 TGI 的文本流式输出服务端向客户端持续推送 token 序列的过程依赖一条长时间存活的 TCP 连接。大多数工程师对超时有本能警惕设置 context deadline、配置 read timeout。但半开连接Half-Open Connection是不同的故障模式——它在沉默中失效。一端已经认为连接关闭另一端却毫不知情地继续发送数据。这意味着发送方会在无响应的黑洞中不断堆积缓冲直到内存耗尽或线程阻塞。在推理服务中这种情况尤其危险。一次典型的大模型推理可能持续数十秒到数分钟流式返回数千个 token。如果客户端中途断开比如前端页面关闭服务端的 goroutine 会继续生成 token 并把数据写入一个永远不会被读取的 TCP 缓冲区。Go 的sendMsg底层调用syscall.Write初始写入不会报错——因为内核缓冲区还有空间。只有当写缓冲区满或 TCP keepalive 探测定时器到期write 调用才会返回错误。从监控数据看经历过一次生产事故的推理集群中半开连接导致的 goroutine 泄漏占总泄漏量的 37%。这些泄漏的 goroutine 持续消耗内存、占用 GPU 的 CUDA context最终触发 OOM。基础设施不需要漂亮话需要的是对每个连接生命周期的精确控制。二、半开连接的生成机制与检测原理半开连接的产生遵循一个固定路径正常关闭的四次挥手FIN → ACK → FIN → ACK被打断比如中间经过的 NAT 设备静默失效、防火墙超时清除会话状态或者客户端进程被 SIGKILL 杀死而未来得及发送 FIN。以下时序图展示了一次典型的推理流式中半开连接形成过程sequenceDiagram participant C as 推理客户端 participant NAT as NAT/防火墙 participant S as 推理服务端(gRPC) C-S: gRPC Stream RPC: Generate(prompt) S--C: Stream: token_1, token_2, ... Note over C: 用户关闭浏览器页面 C--xC: 客户端进程终止br/未发送 FIN NAT-NAT: 会话超时br/清除状态表项 S--C: Stream: token_n (TCP 写入) Note over S: write() 返回成功br/数据进入发送缓冲区br/但永远不会被确认 S--C: Stream: token_n1 Note over S: 缓冲区积压br/goroutine 阻塞在 write Note over S: goroutine 泄漏br/GPU 资源未释放问题的核心在于 TCP 协议本身的设计在没有应用层 ACK 的场景下发送方只能依赖 TCP keepalive 机制来探测对端存活。而默认的 Linux keepalive 参数极其保守——tcp_keepalive_time默认为 7200 秒2 小时tcp_keepalive_intvl为 75 秒。在推理场景下这意味着服务端可能在连接断开后数小时才感知到异常。gRPC 层面提供了额外的保护。客户端的WaitForReady和服务端的KeepaliveEnforcementPolicy可以缩短检测窗口。但核心逻辑还是必须在应用层做连接状态的主动感知不能完全依赖底层 TCP 栈。三、生产级服务端与客户端实现以下代码展示一个完整的推理服务 gRPC 流式实现覆盖服务端上下文传播、客户端断开检测和服务端 keepalive 配置。3.1 Proto 定义syntax proto3; package inference.v1; service LLMInference { rpc StreamGenerate(GenerateRequest) returns (stream GenerateResponse); } message GenerateRequest { string prompt 1; int32 max_tokens 2; } message GenerateResponse { string token 1; int32 token_id 2; bool is_final 3; }3.2 服务端实现上下文驱动的流生命周期管理package server import ( context fmt io net time pb inference/v1 google.golang.org/grpc google.golang.org/grpc/codes google.golang.org/grpc/keepalive google.golang.org/grpc/status ) // NewInferenceServer 创建配置了严格 keepalive 策略的 gRPC 服务端。 // keepalive 参数需要根据集群规模调优太小增加控制流量太大延迟半开检测。 func NewInferenceServer() *grpc.Server { kep : keepalive.ServerParameters{ MaxConnectionIdle: 5 * time.Minute, MaxConnectionAge: 30 * time.Minute, MaxConnectionAgeGrace: 30 * time.Second, Time: 30 * time.Second, Timeout: 10 * time.Second, } enforcement : keepalive.EnforcementPolicy{ MinTime: 15 * time.Second, PermitWithoutStream: true, } return grpc.NewServer( grpc.KeepaliveParams(kep), grpc.KeepaliveEnforcementPolicy(enforcement), ) } type InferenceService struct { pb.UnimplementedLLMInferenceServer engine Engine // 推理引擎抽象如 vLLM backend } // Engine 定义推理引擎接口解耦具体实现。 type Engine interface { Generate(ctx context.Context, prompt string, maxTokens int32) (-chan string, -chan error) } func (s *InferenceService) StreamGenerate( req *pb.GenerateRequest, stream pb.LLMInference_StreamGenerateServer, ) error { ctx : stream.Context() tokenCh, errCh : s.engine.Generate(ctx, req.Prompt, req.MaxTokens) for { select { case -ctx.Done(): // 客户端断开或超时立即终止推理。 // 这里不能仅仅 return nil —— 需要调用 cancel 通知推理引擎释放 GPU 资源。 return status.Errorf(codes.Canceled, client disconnected: %v, ctx.Err()) case err, ok : -errCh: if !ok { // 错误通道已关闭且无错误正常结束。 return nil } if err ! nil { return status.Errorf(codes.Internal, inference engine error: %v, err) } case token, ok : -tokenCh: if !ok { // Token 通道关闭推理完成。 return nil } if err : stream.Send(pb.GenerateResponse{ Token: token, IsFinal: false, }); err ! nil { // send 失败说明客户端已断开或网络异常。 // 区分 io.EOF正常结束与真实错误。 if err io.EOF { return nil } return status.Errorf(codes.Unavailable, failed to send token, client likely disconnected: %v, err) } } } }关键设计点stream.Context()是 gRPC 自动管理的——客户端断开会导致该 context 被 cancel服务端的-ctx.Done()会立刻收到信号。stream.Send()的错误处理不能省略。write系统调用的错误可能延迟 30 秒以上才返回取决于内核缓冲区大小和 keepalive 配置。一旦返回错误必须立即终止推理并释放 GPU 资源。io.EOF表示客户端正常关闭了流不应作为错误处理。3.3 客户端实现使用 context 控制生命周期package client import ( context fmt io time pb inference/v1 google.golang.org/grpc google.golang.org/grpc/credentials/insecure google.golang.org/grpc/keepalive ) func NewInferenceClient(target string) (*grpc.ClientConn, pb.LLMInferenceClient, error) { kacp : keepalive.ClientParameters{ Time: 20 * time.Second, Timeout: 10 * time.Second, PermitWithoutStream: true, } conn, err : grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithKeepaliveParams(kacp), ) if err ! nil { return nil, nil, fmt.Errorf(create grpc conn: %w, err) } return conn, pb.NewLLMInferenceClient(conn), nil } func StreamGenerate( ctx context.Context, client pb.LLMInferenceClient, prompt string, maxTokens int32, onToken func(string), ) error { // 设置总体超时防止推理挂住。 ctx, cancel : context.WithTimeout(ctx, 5*time.Minute) defer cancel() stream, err : client.StreamGenerate(ctx, pb.GenerateRequest{ Prompt: prompt, MaxTokens: maxTokens, }) if err ! nil { return fmt.Errorf(start stream: %w, err) } for { resp, err : stream.Recv() if err io.EOF { return nil } if err ! nil { // 区分 context 取消主动与网络错误被动。 if ctx.Err() ! nil { return fmt.Errorf(context done: %w, ctx.Err()) } return fmt.Errorf(stream recv: %w, err) } onToken(resp.Token) if resp.IsFinal { return nil } } }客户端侧的关键防护context.WithTimeout设置总体上限防止推理服务卡死导致客户端 goroutine 泄漏。stream.Recv()返回io.EOF时正常退出其他错误需要区分是主动取消ctx.Err() ! nil还是网络故障。keepalive 参数必须服务端和客户端同时配置单侧配置会导致探测报文被丢弃。四、Keepalive 策略的代价与适用场景在半开连接防护中调小 keepalive 参数是最直接的手段但它不是免费的。额外流量开销。将Time从默认的 2 小时缩短到 30 秒意味着每 30 秒发送一次 PING 帧。在 1000 个连接的集群中这是每秒约 33 次额外的 PING/PONG 交互。虽然单个 PING 帧仅几十字节但在大规模集群里需要评估对网络平面的压力。误判风险。Timeout设置过短如 2 秒在跨可用区部署时可能触发大量误判。一次短暂的网络抖动会被判定为连接失效导致正在进行的推理被中断。生产环境推荐Timeout不低于 10 秒并在可用区之间使用更长的值。与负载均衡器的交互。如果推理服务前挂载了 L4 负载均衡器如 Kubernetes NodePort Service 或云厂商的 CLB负载均衡器自身的空闲超时可能与 gRPC keepalive 形成冲突。典型场景负载均衡器空闲超时 60 秒gRPC keepalive 30 秒——这是安全的。但如果负载均衡器上层还有一个 CDN 或 API 网关如 Envoy需要逐层检查它们的空闲超时配置。什么时候不该调小 keepalive。对于短连接为主的 HTTP REST 推理服务半开连接不是主要矛盾——请求-响应在数秒内结束连接不会存活到触发 keepalive 探测。此时过小的 keepalive 只会增加无意义的控制开销。五、总结推理服务中的 gRPC 流式半开连接问题是一个典型的分布式系统故障模式问题很隐蔽影响很严重解决手段却不复杂。核心措施归纳为三点服务端依赖stream.Context()感知客户端断开。这是 gRPC 框架提供的最可靠信号在Send或Recv之前先检查 context 是否已被取消。配置合理的 keepalive 参数作为最后防线。Time建议 20-30 秒Timeout建议 10 秒适用于推理流式场景。注意客户端和服务端必须双侧配置。客户端使用带超时的 context 防止自身泄漏。即使服务端异常不响应客户端也能在超时后主动释放资源。基础设施不需要漂亮话。在推理服务的连接管理上一行的 select 语句比一百页的架构文档更有价值。