应用把 DeepSeek API 的调用封装成独立 adapter 之后,最需要管理的往往不是模型本身,而是出口请求的瞬时形状。后台任务可能同时产生几十个甚至上百个并发请求,如果没有客户端闸门,上游服务只要出现一点抖动,调用方就会看到成片超时和连续失败。

本文讨论的是客户端治理层。DeepSeek 官方对配额、请求上限、限流阈值等边界如何定义,请以官方 API 文档或实测结果为准;这里不预设任何标准限额。下面要解决的是 adapter 层的问题:在确定的上游边界之外,客户端如何排队、如何重试、如何快速失败,才能避免应用被瞬时流量拖垮。

并发数、速率和队列要分开控制

限流很容易被简化为“每秒只允许 N 个请求”,但生产环境至少需要区分三件事:

  • 并发数:同一时刻在途且尚未返回的请求数量。
  • 速率:单位时间内允许新发起的请求数量。
  • 等待上限:超过处理能力时,调用方最多愿意排队多久。

并发数过高时,慢请求会长期占用线程、协程或连接池;速率过高时,突发流量会在短时间内打满上游配额。只限制并发但不限制速率,16 个请求如果在几毫秒内完成,仍然可能在一秒内产生数百次新调用;只限制速率但不限制并发,高延迟请求又会积压大量资源。

Node.js 生态中的 bottleneck 可以同时设置 maxConcurrent 和 minTime:

const Bottleneck = require('bottleneck');

const limiter = new Bottleneck({
  maxConcurrent: 16,
  minTime: 80
});

async function callDeepSeek(payload) {
  return limiter.schedule(() => fetchDeepSeek(payload));
}

其中 maxConcurrent 表示同时最多 16 个请求在途,minTime 表示相邻两个任务的最小间隔为 80 毫秒。bottleneck 会把超出限制的调用放入内部队列,并按照给定节奏放行。

Python asyncio 场景通常用 Semaphore 控制最大并发:

import asyncio

sem = asyncio.Semaphore(16)

async def guarded_call(session, payload):
    async with sem:
        return await session.post(api_url, json=payload)

信号量本身不限制每秒速率。如果请求平均耗时很短,信号量释放很快,实际吞吐仍可能远超预期,因此还需要用令牌桶或时间窗口控制单位时间发起量。

Java 服务中常见的是 Guava RateLimiter:

import com.google.common.util.concurrent.RateLimiter;

public class DeepSeekAdapter {
    private final RateLimiter limiter = RateLimiter.create(30.0);

    public RpcResponse call(RpcRequest request) {
        limiter.acquire();
        return downstreamClient.invoke(request);
    }
}

RateLimiter 控制的是令牌补充速率,允许有限突发。它并不等于并发限制,如果下游调用很慢,仍然要配合线程池或 Semaphore 限制同时在途请求数。

上面代码中的 16、80、30 都只是占位值,不应直接复制到生产环境。真实阈值与账号类型、模型配置、服务端当前策略有关,只能通过官方文档和压测校准。

重试必须带退避,并且重新经过限流器

客户端收到上游过载类错误后,最简单但也最危险的做法是固定间隔重试。大量请求在同一时间窗口失败,又在同一时间点重试,会再次形成聚集流量。

比较稳妥的做法是指数退避加随机抖动:每次重试的等待时间逐步变长,同时加入少量随机量,避免多个调用方同时醒来。以下是一个可运行的骨架:

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function requestWithBackoff(call, maxAttempts = 4) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const result = await call();
      if (isOk(result) || !isRetryable(result)) {
        return result;
      }
    } catch (err) {
      if (!isRetryable(err)) {
        throw err;
      }
    }

    const delay =
      Math.min(8000, 200 * Math.pow(2, attempt)) + Math.random() * 100;
    await sleep(delay);
  }

  throw new Error('retry budget exhausted');
}

isRetryable 函数需要根据实际情况自行实现。网络中断、连接重置、超时和明确可恢复的上游过载错误可以重试;业务语义错误不应重试。具体哪些状态码可以重试,要以接入的 API 返回行为为准。

容易被忽略的是:重试请求也必须走同一个限流器。如果重试逻辑绕过 limiter,那么即使退避间隔设置合理,等待结束后所有重试仍可能同时涌向上游。更合理的组合是“限流器控制入口放行 + 退避控制失败节奏”。

队列必须设置超时上限

当突发流量超出处理能力时,调用会进入限流器的内部队列。这个队列如果无限增长,等待中的请求会一直占用内存和连接资源,最终在超时后集中失败。

客户端排队只应该吸收短时抖动,不应该吸收长时间的下游故障。建议在队列入口增加等待超时。等待超时后的失败要能被上层识别为“本地繁忙”,而不是被识别成上游错误后再次进入重试流程。

在并发模型里还需要关注慢请求占满槽位的问题。如果每个请求都很慢,16 个并发槽位可能很快耗尽,后续请求全部排队。此时应设置单次请求的超时,让异常请求快速失败并释放槽位,而不是无限占用。

多实例、多凭证要分别隔离

本地限流器只对当前进程生效。如果服务有多个副本,每个副本各自放行 16 个请求,总量就会随副本数线性放大,可能突破上游真实边界。此时需要在部署层面对整体预算做拆分。这个拆分并不精确,但好过没有任何本地闸门。

如果 adapter 同时服务多个租户或使用多个 API Key,不要把所有流量放进同一个限流器。一个租户的突发流量可能耗尽共享配额,导致其他租户失败。更好的方式是按 Key 隔离限流器,让不同凭证拥有独立的并发空间。

通过观测校准客户端参数

客户端限流参数不应靠一次猜测固定下来。建议在 adapter 中暴露下列指标:

  • 当前在途请求数;
  • 本地队列长度;
  • 请求在队列中的等待时间;
  • 上游限流错误计数;
  • 可重试错误与不可重试错误的分布。

当排队等待时间持续变长时,说明并发配额相对保守,或上游响应变慢;当上游限流错误占比升高时,说明已接近上游边界,需要降低速率或检查配额。把这两类信号分开,才能判断一次调整到底是在改善客户端排队,还是在进一步触发上游限制。

校准可以分阶段完成:先以较低并发运行,观察延迟和错误率;再逐步提高并发,直到限流错误开始出现。对应的临界值只代表当前环境的状态,账号状态、模型负载和官方策略变化后,这个边界还会移动,因此观测不能只在上线当天做。

上线前检查清单

最终检查建议覆盖以下问题:

  1. 所有对 DeepSeek API 的调用是否都经过同一个 adapter?是否存在绕过限流器的旁路请求?
  2. 并发限制和速率限制是否同时存在?
  3. 超出能力时,请求是进入有界队列,还是会被无限挂起?
  4. 队列等待超时后的失败是否能被上层识别?
  5. 重试是否经过限流器?是否带指数退避和随机抖动?
  6. 多实例部署时是否预估了总量放大?多 Key 场景是否做了隔离?
  7. 是否至少记录了在途请求数、队列长度、重试次数和上游限流错误数?

在客户端加上这些控制之后,DeepSeek API 调用方可以把上游暂时不可用变成一种有秩序的等待、退避和恢复,而不是让一批同时触发的重试请求反复制造新的压力。