Go 调用大模型 API 的性能深坑:从连接池泄漏到流式响应的完整优化记录
·
Go 调用大模型 API 的性能深坑:从连接池泄漏到流式响应的完整优化记录

前言
上周线上告警:Go 服务调用 OpenAI API 的 P99 延迟从 500ms 飙到 8s,同时内存持续增长。
排查了两天,发现是连接池泄漏 + 流式响应处理不当,双杀。
把整个排查和优化过程记下来。踩过的坑,一个不留。
一、问题现场
1.1 告警数据
时间: 2025-05-20 14:32
现象: OpenAI API 调用 P99 延迟 8200ms(正常值 500ms)
内存占用从 200MB 持续增长到 2.1GB
goroutine 数量从 50 飙到 12000+
影响: 下游 AI 摘要服务超时率 35%
1.2 问题架构
graph LR
A[业务请求] --> B[Go API 网关]
B --> C[AI 摘要服务]
C --> D[OpenAI API]
D -->|流式 SSE| C
C -->|组装结果| B
B --> A
style C fill:#f66,stroke:#333
问题出在 AI 摘要服务这一层。往下看。
二、坑一:http.Client 连接池泄漏
2.1 问题代码
// ❌ 错误写法:每次请求都创建新 Client
func 调用大模型(prompt string) (string, error) {
// 每次 new 一个 Client,连接池根本没复用
client := &http.Client{
Timeout: 30 * time.Second,
}
req, _ := http.NewRequest("POST", "https://api.openai.com/v1/chat/completions", nil)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// 读取响应...
body, _ := io.ReadAll(resp.Body)
return string(body), nil
}
每次创建新 http.Client,等于每次都新建 TCP 连接 + TLS 握手。连接用完也没有被正确回收到池里。
2.2 修复:全局复用 http.Client
// ✅ 正确写法:全局单例 Client,精确配置连接池
var 大模型Client = &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
// 连接池最大空闲连接数
MaxIdleConns: 100,
// 单个 Host 的最大空闲连接数(OpenAI 只有一个 Host)
MaxIdleConnsPerHost: 50,
// 空闲连接超时(别设太短,TLS 握手很贵)
IdleConnTimeout: 120 * time.Second,
// TLS 握手超时
TLSHandshakeTimeout: 10 * time.Second,
// 开启 HTTP/2(OpenAI 支持)
ForceAttemptHTTP2: true,
},
}
💡 关键参数解释:
MaxIdleConnsPerHost设 50:因为所有请求都打到api.openai.com一个 HostIdleConnTimeout设 120s:TLS 握手一次要 50-80ms,复用连接能省掉这段ForceAttemptHTTP2:HTTP/2 支持多路复用,一条连接跑多个请求
2.3 效果对比
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 新建连接数/分钟 | 3200 | 12 |
| TLS 握手耗时占比 | 38% | 2% |
| P99 延迟 | 8200ms | 680ms |
| 内存占用 | 2.1GB | 220MB |
三、坑二:流式响应 Body 没读完
3.1 问题根源
OpenAI 的流式接口返回 SSE(Server-Sent Events)。如果你中途断开(比如超时或 panic),但 resp.Body 没读完,这条连接就没法归还给连接池。
// ❌ 错误写法:Body 没读完就 Close
func 流式调用(prompt string) error {
resp, err := 大模型Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close() // 这里有坑!
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if line == "data: [DONE]" {
break // 提前 break,Body 没读完
// 连接不会被复用,会被直接关闭
}
// 处理数据...
}
return nil
}
3.2 修复:确保 Body 被完全消费
// ✅ 正确写法:确保 Body 被彻底读完
func 流式调用(ctx context.Context, prompt string) (string, error) {
reqBody := map[string]interface{}{
"model": "gpt-4",
"stream": true,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
}
jsonBody, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(
ctx, "POST",
"https://api.openai.com/v1/chat/completions",
bytes.NewReader(jsonBody),
)
if err != nil {
return "", fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := 大模型Client.Do(req)
if err != nil {
return "", fmt.Errorf("请求失败: %w", err)
}
// 关键:用 defer 确保在任何情况下都正确清理
defer func() {
// 先把 Body 里剩余数据排干
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API 返回错误: %d", resp.StatusCode)
}
var result strings.Builder
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
// 跳过空行
if line == "" {
continue
}
// SSE 结束标志
if line == "data: [DONE]" {
break
}
// 去掉 "data: " 前缀
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
// 解析 JSON
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // 跳过解析失败的 chunk
}
if len(chunk.Choices) > 0 {
result.WriteString(chunk.Choices[0].Delta.Content)
}
}
return result.String(), nil
}
⚠️ 核心要点:io.Copy(io.Discard, resp.Body) 这一行是命脉。不管正常结束还是异常退出,都必须把 Body 里的剩余数据排干净。否则连接永远无法归还给连接池。
四、坑三:goroutine 泄漏
4.1 问题
没有用 context 控制超时,导致大模型响应慢时 goroutine 堆积。
// ❌ 错误写法:没有超时控制
func 处理请求(w http.ResponseWriter, r *http.Request) {
// 这个 goroutine 可能卡 60 秒
result, err := 流式调用(context.Background(), "请总结以下文章...")
// ...
}
4.2 修复:严格的超时控制
// ✅ 正确写法:链路级超时控制
func 处理请求(w http.ResponseWriter, r *http.Request) {
// 整条链路最多 30 秒
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
result, err := 流式调用(ctx, "请总结以下文章...")
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "AI 响应超时", http.StatusGatewayTimeout)
return
}
http.Error(w, "服务内部错误", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"摘要": result,
})
}
4.3 goroutine 数量对比
graph LR
subgraph 修复前
A["goroutine 数量"] --> B["持续增长到 12000+"]
B --> C["OOM Kill ❌"]
end
subgraph 修复后
D["goroutine 数量"] --> E["稳定在 50-80"]
E --> F["内存稳定 220MB ✅"]
end
五、完整的生产级封装
package openai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
)
// 大模型客户端(全局唯一)
type Client struct {
httpClient *http.Client
apiKey string
baseURL string
}
var (
defaultClient *Client
once sync.Once
)
// 获取全局客户端(懒加载 + 线程安全)
func GetClient() *Client {
once.Do(func() {
defaultClient = &Client{
httpClient: &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 50,
IdleConnTimeout: 120 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ForceAttemptHTTP2: true,
},
},
apiKey: os.Getenv("OPENAI_API_KEY"),
baseURL: "https://api.openai.com/v1",
}
})
return defaultClient
}
// 流式对话(生产级)
func (c *Client) Chat(ctx context.Context, prompt string) (string, error) {
body := map[string]interface{}{
"model": "gpt-4",
"stream": true,
"messages": []map[string]string{
{"role": "user", "content": prompt},
},
}
jsonBody, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("序列化请求体失败: %w", err)
}
req, err := http.NewRequestWithContext(
ctx, "POST",
c.baseURL+"/chat/completions",
bytes.NewReader(jsonBody),
)
if err != nil {
return "", fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("请求大模型失败: %w", err)
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("API 错误 %d: %s", resp.StatusCode, string(errBody))
}
var result strings.Builder
scanner := bufio.NewScanner(resp.Body)
// 防止单行过长导致 Scanner 报错
scanner.Buffer(make([]byte, 64*1024), 512*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" || line == "data: [DONE]" {
if line == "data: [DONE]" {
break
}
continue
}
if !strings.HasPrefix(line, "data: ") {
continue
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
data := strings.TrimPrefix(line, "data: ")
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
if len(chunk.Choices) > 0 {
result.WriteString(chunk.Choices[0].Delta.Content)
}
}
return result.String(), scanner.Err()
}
六、总结
三个坑,三条铁律:
- http.Client 必须复用:全局单例,连接池参数按场景精调
- resp.Body 必须读完:用
io.Copy(io.Discard, resp.Body)排干剩余数据 - context 必须带超时:没有超时的 goroutine 就是定时炸弹
优化后的效果:P99 延迟从 8200ms 回到 500ms,内存从 2.1GB 稳定在 220MB。
数据不骗人。先看数据,再讲故事。
更多推荐




所有评论(0)