一家供应商挂了,全站问答跟着挂
知识库问答服务要同时对接 OpenAI、Azure、VLLM、HuggingFace,外加 DeepSeek、通义、智谱、Kimi、百川、文心一堆国内供应商,还有私有化部署的 Ollama。每家的配额、QPS、TPM、错误码、重试语义都不一样。上线初期踩了一堆坑:某家偶发 429,我们无脑重试火上浇油;另一家流式接口断连,整条对话直接挂掉;最狠的一次是某供应商整体不可用,全站问答跟着挂。
后来我牵头做了一层统一的 LLM 适配层,把限流、重试、降级作为横切能力沉进去,业务代码只面对一个 Chat 接口。
三件事,分开做
三件事都沉在适配层里:
- 限流:客户端侧按供应商 + 模型维度做令牌桶,保守地按供应商给的配额打八折配置,避免真的触发对方 429。
- 重试:只对明确可重试的错误(429、500、502、503、连接错误、EOF)做指数退避重试;400/401/403 这类业务错误立即抛。流式请求已经吐过 token 的不重试,不然前端会重复看到字。
- 降级:当某个供应商错误率超过阈值或熔断器打开,自动把请求路由到备用模型;非关键场景(比如标题生成、摘要)甚至可以直接返回降级文案。

限流器、重试、熔断器
限流器我们用的是 golang.org/x/time/rate,每个供应商+模型一个 Limiter,用一个 map 管起来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| type ModelKey struct {
Provider string
Model string
}
type RateLimitManager struct {
mu sync.RWMutex
limiters map[ModelKey]*rate.Limiter
configs map[ModelKey]LimiterConfig
}
func (m *RateLimitManager) Wait(ctx context.Context, key ModelKey) error {
m.mu.RLock()
lim, ok := m.limiters[key]
m.mu.RUnlock()
if !ok { return fmt.Errorf("no rate config for %s/%s", key.Provider, key.Model) }
// Wait 会按令牌桶速率阻塞,直到拿到一个 token
return lim.Wait(ctx)
}
|
重试封装用一个通用的 Do 函数,指数退避加抖动:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
| func isRetryable(err error, resp *http.Response) bool {
if err != nil {
// 网络层错误、EOF、超时都重试
return true
}
if resp == nil { return false }
switch resp.StatusCode {
case 429, 500, 502, 503, 504:
return true
}
return false
}
func (c *Client) doWithRetry(ctx context.Context, req *http.Request, maxAttempt int) (*http.Response, error) {
var resp *http.Response
var err error
for attempt := 0; attempt < maxAttempt; attempt++ {
// 等令牌
if werr := c.rateLimit.Wait(ctx, c.key); werr != nil {
return nil, werr
}
resp, err = c.http.Do(req)
if !isRetryable(err, resp) {
return resp, err
}
// 流式请求一旦开始吐字节,就不能重试
if resp != nil && resp.Header.Get("Content-Type") == "text/event-stream" {
return resp, err
}
// 优先用 Retry-After,否则指数退避
wait := backoff(attempt, resp)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
return resp, err
}
func backoff(attempt int, resp *http.Response) time.Duration {
if resp != nil {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if sec, err := strconv.Atoi(ra); err == nil {
return time.Duration(sec) * time.Second
}
}
}
base := time.Duration(1<<attempt) * time.Second
jitter := time.Duration(rand.Int63n(int64(500 * time.Millisecond)))
return base + jitter
}
|
熔断器用的是 sony/gobreaker,当某个供应商连续失败触发开路,直接走备用路由:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| type FallbackRoute struct {
Primary ModelKey
Fallback ModelKey
}
func (r *Router) Chat(ctx context.Context, req ChatRequest) (*Response, error) {
route := r.pick(req)
resp, err := r.callWithBreaker(ctx, route.Primary, req)
if err == nil { return resp, nil }
// 主路失败,尝试备用
if route.Fallback != (ModelKey{}) {
r.logger.Warn("primary failed, fallback",
zap.String("primary", route.Primary.Model),
zap.Error(err))
return r.callWithBreaker(ctx, route.Fallback, req)
}
return nil, err
}
func (r *Router) callWithBreaker(ctx context.Context, key ModelKey, req ChatRequest) (*Response, error) {
cb := r.breakers.Get(key)
res, err := cb.Execute(func() (interface{}, error) {
return r.clients.Get(key).Chat(ctx, req)
})
if err != nil { return nil, err }
return res.(*Response), nil
}
|
五个坑
第一个坑是 429 重试风暴。某家供应商偶发限流时,我们所有并发请求一起退避、一起重试,反而把下一秒的配额瞬间打爆。后来加了抖动(jitter),把重试时刻打散,并且尊重 Retry-After 头,问题才缓解。
第二个坑是非幂等重试。聊天接口如果请求体里带了 request_id 之类的字段,重试一般安全;但有些供应商不支持,重试会产生重复扣费。我们的做法是给每次对话生成业务侧的 idempotency_key,能传就传;不能传的供应商就只在网络错误阶段重试,一旦拿到 HTTP 响应就收手。
第三个坑是流式的半成功状态。SSE 流可能已经吐了几个 token 才断开,前端已经渲染出来了,这时重试用户会看到重影。策略:第一个 chunk 到达之前断开,可以安全重试;一旦有 chunk 输出,错误直接抛给前端,让用户自己点"输出中断,点此重试"。
第四个坑是备用模型不能随便选。主模型是强推理模型,降级到小模型可能答非所问。我们按"能力档位"配置路由:主路不可用时选同档位的另一家,而不是无条件降到最便宜的模型。
第五个坑是熔断器太敏感。一开始阈值设太严,偶发一次 500 就开路,误伤严重。最后调成连续 5 次失败、或 60 秒内错误率超 50% 才开路,半开探测只放 1 个请求,稳定多了。
后来
这套东西上线之后,任何一家供应商出问题,知识库问答服务都能"带病工作",业务侧基本无感。回头看,LLM 集成的稳定性问题,绝大多数不在模型本身,而在它周边的网络和配额。
封面图:wbaiv / Flickr · CC BY-SA 2.0