// API 预连接 -- TCP+TLS 握手预热 + HTTP Transport 优化配置. // // 精妙之处(CLEVER): 首次 API 调用前发一个 fire-and-forget HEAD 请求, // TCP+TLS 握手(100-400ms)与初始化逻辑并行完成, // 正式请求直接复用 keep-alive 连接池中的热连接. // // 升华改进(ELEVATED): Go 的 http.Transport 是统一抽象-- // 不管有没有代理/mTLS,都通过同一个 Transport 走,连接池自然共享. // 不需要像早期方案那样检查 6 个环境变量决定是否跳过预连接. // // 替代方案:<原方案 Node.js 全局 fetch + SDK 自定义 dispatcher 是两个池, // 需要检查 proxy/mTLS/Unix socket 场景跳过预连接> package api import ( "context" "crypto/tls" "net" "net/http" "sync" "time" ) // ============================================================ // TransportConfig - HTTP Transport 配置 // ============================================================ // TransportConfig 集中管理 HTTP Transport 配置. // // 精妙之处(CLEVER): 所有连接池参数集中在此,不散落在各处. // 默认值针对 LLM API 场景优化: // - MaxIdleConnsPerHost=2: API 调用是顺序的(流式),同时最多 2 个连接够用 // - IdleConnTimeout=90s: 用户思考时间内保持热连接 // - TLSHandshakeTimeout=10s: 企业代理可能慢,给足时间 type TransportConfig struct { // MaxIdleConnsPerHost 每个 host 最大空闲连接数(默认 2) MaxIdleConnsPerHost int // MaxIdleConns 全局最大空闲连接数(默认 10) MaxIdleConns int // IdleConnTimeout 空闲连接超时时间(默认 90s) IdleConnTimeout time.Duration // TLSHandshakeTimeout TLS 握手超时(默认 10s) TLSHandshakeTimeout time.Duration // ResponseHeaderTimeout 等待响应头的超时(默认 0=不限制,由 context 控制) ResponseHeaderTimeout time.Duration // DisableKeepAlives 禁用 keep-alive(stale 连接自愈时使用) DisableKeepAlives bool // TLSConfig 自定义 TLS 配置(mTLS 客户端证书等) TLSConfig *tls.Config // DialTimeout TCP 连接超时(默认 30s) DialTimeout time.Duration // DNSResolver 自定义 DNS 解析器(可选,用于 DNS 预解析) DNSResolver *net.Resolver } // DefaultTransportConfig 返回针对 LLM API 优化的默认配置. func DefaultTransportConfig() *TransportConfig { return &TransportConfig{ MaxIdleConnsPerHost: 2, MaxIdleConns: 10, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 0, // SSE 流式响应不限制 DialTimeout: 30 * time.Second, } } // NewTransport 根据配置创建 http.Transport. // // 精妙之处(CLEVER): 返回的 Transport 应被 Client 和 Preconnector 共享-- // 同一个 Transport = 同一个连接池 = 预连接的热连接被正式请求复用. func NewTransport(cfg *TransportConfig) *http.Transport { if cfg == nil { cfg = DefaultTransportConfig() } dialTimeout := cfg.DialTimeout if dialTimeout <= 0 { dialTimeout = 30 * time.Second } dialer := &net.Dialer{ Timeout: dialTimeout, KeepAlive: 30 * time.Second, // TCP keep-alive 探测间隔 } // 如果有自定义 DNS 解析器,注入到 dialer if cfg.DNSResolver != nil { dialer.Resolver = cfg.DNSResolver } t := &http.Transport{ DialContext: dialer.DialContext, MaxIdleConns: cfg.MaxIdleConns, MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost, IdleConnTimeout: cfg.IdleConnTimeout, TLSHandshakeTimeout: cfg.TLSHandshakeTimeout, ResponseHeaderTimeout: cfg.ResponseHeaderTimeout, DisableKeepAlives: cfg.DisableKeepAlives, // 升华改进(ELEVATED): ForceAttemptHTTP2=true 让 Go 自动协商 HTTP/2. // HTTP/2 多路复用单连接,对 SSE 流式场景更友好(一个连接并行多流). // 替代方案:<不设置,默认 HTTP/1.1,每个请求独占连接> ForceAttemptHTTP2: true, } if cfg.TLSConfig != nil { t.TLSClientConfig = cfg.TLSConfig } return t } // ============================================================ // Preconnector - 连接预热器 // ============================================================ // Preconnector 负责 API 连接预热. // // 用法: // // transport := api.NewTransport(nil) // client := api.NewClient(key, url, api.WithHTTPClient(&http.Client{Transport: transport})) // preconn := api.NewPreconnector(url, transport) // preconn.Warmup(ctx) // fire-and-forget,不阻塞 type Preconnector struct { baseURL string transport *http.Transport timeout time.Duration once sync.Once } // NewPreconnector 创建预连接器. // transport 必须与 Client 共享同一个实例,否则预热的连接不会被复用. func NewPreconnector(baseURL string, transport *http.Transport) *Preconnector { return &Preconnector{ baseURL: baseURL, transport: transport, timeout: 10 * time.Second, } } // Warmup 异步预热 TCP+TLS 连接. // // 精妙之处(CLEVER): fire-and-forget 模式-- // 启动一个 goroutine 发 HEAD 请求,不阻塞调用者. // HEAD 请求无响应体,连接完成 TLS 握手后立即进入 keep-alive 池. // 失败静默忽略(预连接是优化不是功能,失败了正式请求照常握手). // // sync.Once 确保只预热一次,多次调用幂等. func (p *Preconnector) Warmup(ctx context.Context) { p.once.Do(func() { go p.doWarmup(ctx) }) } // doWarmup 执行实际的预热请求. func (p *Preconnector) doWarmup(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, p.timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, "HEAD", p.baseURL, nil) if err != nil { return // 静默忽略 } client := &http.Client{Transport: p.transport} resp, err := client.Do(req) if err != nil { return // 静默忽略--预连接失败不影响正式请求 } resp.Body.Close() } // ============================================================ // DNS 预解析 // ============================================================ // DNSCache 是简单的 DNS 解析缓存. // // 精妙之处(CLEVER): 企业内网 DNS 可能很慢(递归查询 100-500ms), // 提前解析并缓存,后续连接直接用 IP. // 缓存时间较短(5 分钟),避免 DNS 变更后长时间用旧 IP. type DNSCache struct { mu sync.RWMutex entries map[string]dnsCacheEntry ttl time.Duration } type dnsCacheEntry struct { addrs []string expires time.Time } // NewDNSCache 创建 DNS 缓存. func NewDNSCache(ttl time.Duration) *DNSCache { if ttl <= 0 { ttl = 5 * time.Minute } return &DNSCache{ entries: make(map[string]dnsCacheEntry), ttl: ttl, } } // Lookup 解析主机名,优先使用缓存. func (c *DNSCache) Lookup(ctx context.Context, host string) ([]string, error) { // 先查缓存 c.mu.RLock() if entry, ok := c.entries[host]; ok && time.Now().Before(entry.expires) { c.mu.RUnlock() return entry.addrs, nil } c.mu.RUnlock() // 缓存未命中,做真实解析 addrs, err := net.DefaultResolver.LookupHost(ctx, host) if err != nil { return nil, err } // 写入缓存 c.mu.Lock() c.entries[host] = dnsCacheEntry{ addrs: addrs, expires: time.Now().Add(c.ttl), } c.mu.Unlock() return addrs, nil } // Prefetch 异步预解析一个主机名(fire-and-forget). func (c *DNSCache) Prefetch(ctx context.Context, host string) { go func() { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() c.Lookup(ctx, host) //nolint:errcheck // fire-and-forget }() } // ============================================================ // Client 集成辅助 // ============================================================ // WithTransport 设置共享 Transport(用于预连接 + 正式请求共享连接池). func WithTransport(t *http.Transport) ClientOption { return func(c *Client) { c.httpClient = &http.Client{Transport: t} } }