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 一个 Host
  • IdleConnTimeout 设 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()
}

六、总结

三个坑,三条铁律:

  1. http.Client 必须复用:全局单例,连接池参数按场景精调
  2. resp.Body 必须读完:用 io.Copy(io.Discard, resp.Body) 排干剩余数据
  3. context 必须带超时:没有超时的 goroutine 就是定时炸弹

优化后的效果:P99 延迟从 8200ms 回到 500ms,内存从 2.1GB 稳定在 220MB。

数据不骗人。先看数据,再讲故事。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐