速率限制与并发控制:给 Agent 套上“缰绳“

上个月我把 daily-report-agent 从单仓库扩展到 10 个仓库。第一天跑全量分析------10 个 Agent 同时调 DeepSeek API。

结果:429 错误。不是几个,是一百多个。

每个 Agent 都很无辜------它自己每秒只调 1 次,1 QPS 很克制。但 10 个 Agent 乘以 1 QPS = 10 QPS,DeepSeek V4 Pro 的免费额度是 5 QPS。

单个 Agent 克制不等于整体克制。并发控制要站在全局视角做。

这篇给你一个 Token Bucket 限流器,以及多 Agent 场景下的并发编排方法。


Token Bucket:最简单的限流,最实用

原理一句话:桶里有 N 个 Token,每个请求消耗 1 个 Token,Token 以固定速率补充。桶空了就拒绝请求。

比我见过的任何"智能限流"都实用------因为 LLM API 的限流规则本身就是 Token Bucket(每分钟 N 次 = 速率 = Token 补充速率)。

go 复制代码
// internal/middleware/ratelimit.go
package middleware

import (
    "context"
    "fmt"
    "sync"
    "time"
)

// TokenBucket 令牌桶限流器
type TokenBucket struct {
    mu         sync.Mutex
    capacity   int           // 桶容量(允许的最大突发请求数)
    tokens     float64       // 当前 Token 数
    rate       float64       // Token 补充速率(个/秒)
    lastRefill time.Time     // 上次补充时间
}

// NewTokenBucket 创建令牌桶
// capacity: 桶容量,例如 5(允许瞬时 5 个请求)
// rate: 每秒补充 Token 数,例如 2.0(每秒补充 2 个,即 120 次/分钟)
func NewTokenBucket(capacity int, rate float64) *TokenBucket {
    return &TokenBucket{
        capacity:   capacity,
        tokens:     float64(capacity), // 初始满桶
        rate:       rate,
        lastRefill: time.Now(),
    }
}

// Allow 尝试获取一个 Token
// 返回 true 表示放行,false 表示限流
func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    // 补充 Token
    now := time.Now()
    elapsed := now.Sub(tb.lastRefill).Seconds()
    tb.tokens += elapsed * tb.rate
    if tb.tokens > float64(tb.capacity) {
        tb.tokens = float64(tb.capacity)
    }
    tb.lastRefill = now

    // 尝试消费 1 个 Token
    if tb.tokens >= 1 {
        tb.tokens -= 1
        return true
    }
    return false
}

// Wait 等待直到获取 Token 或 context 取消
func (tb *TokenBucket) Wait(ctx context.Context) error {
    for {
        if tb.Allow() {
            return nil
        }

        // 计算需要等待多久
        tb.mu.Lock()
        waitTime := time.Duration((1 - tb.tokens) / tb.rate * float64(time.Second))
        if waitTime < 10*time.Millisecond {
            waitTime = 10 * time.Millisecond
        }
        tb.mu.Unlock()

        select {
        case <-time.After(waitTime):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
}

包装成中间件

用上一篇文章里的中间件模式,无损接入:

go 复制代码
// internal/middleware/ratelimit_middleware.go
package middleware

import (
    "context"
    "fmt"

    "agent-project/internal/agent"
)

// RateLimitMiddleware 速率限制中间件
type RateLimitMiddleware struct {
    next    agent.Client
    bucket  *TokenBucket
}

func RateLimit(next agent.Client, qps float64, burst int) agent.Client {
    return &RateLimitMiddleware{
        next:   next,
        bucket: NewTokenBucket(burst, qps),
    }
}

func (rl *RateLimitMiddleware) Chat(
    ctx context.Context, messages []agent.Message,
) (*agent.Response, error) {
    // 等待获取令牌(或超时)
    if err := rl.bucket.Wait(ctx); err != nil {
        return nil, fmt.Errorf("限流等待被取消: %w", err)
    }

    return rl.next.Chat(ctx, messages)
}

用起来很简单:

go 复制代码
// 每个 Agent 实例配置自己的限流参数
llmClient := llm.NewClient(cfg.LLM)

// DeepSeek V4 Pro 免费版: 5 QPS,突发 2
llmClient = middleware.RateLimit(llmClient, 5, 2)

全局限流器:多个 Agent 共享一个桶

限流器作用于单个 Agent 不够。10 个 Agent 共享一个 API Key,需要全局限流:

go 复制代码
// 全局共享的 TokenBucket
var globalBucket = NewTokenBucket(5, 5.0) // 5 QPS

// 所有 Agent 共用这个桶
func NewGlobalRateLimitedClient(cfg Config) agent.Client {
    client := llm.NewClient(cfg)
    return &rateLimitedClient{
        client: client,
        bucket: globalBucket, // 同一个桶
    }
}

这样不管你有几个 Agent 实例,整体 QPS 不会超过 5。


Worker Pool:控制并发 Agent 数量

Token Bucket 控制了每秒请求数,但如果 10 个 Agent 同时起 10 个 goroutine,虽然每个都在等 Token,但内存和连接数会爆。

加一层 Worker Pool:

go 复制代码
// internal/middleware/worker_pool.go
package middleware

import (
    "context"
    "fmt"
    "sync"
)

// Job Agent 执行任务
type Job struct {
    ID   string
    Task string
    Ctx  context.Context
}

// WorkerPool Agent 工作池
type WorkerPool struct {
    maxWorkers int
    jobQueue   chan Job
    wg         sync.WaitGroup
    handler    func(context.Context, string) (string, error)
}

func NewWorkerPool(maxWorkers int, handler func(context.Context, string) (string, error)) *WorkerPool {
    wp := &WorkerPool{
        maxWorkers: maxWorkers,
        jobQueue:   make(chan Job, maxWorkers*2), // 缓冲队列
        handler:    handler,
    }

    // 启动固定数量的 Worker
    for i := 0; i < maxWorkers; i++ {
        wp.wg.Add(1)
        go wp.worker(i)
    }

    return wp
}

func (wp *WorkerPool) worker(id int) {
    defer wp.wg.Done()
    for job := range wp.jobQueue {
        fmt.Printf("[Worker-%d] 开始处理任务 %s\n", id, job.ID)
        result, err := wp.handler(job.Ctx, job.Task)
        if err != nil {
            fmt.Printf("[Worker-%d] 任务 %s 失败: %v\n", id, job.ID, err)
        } else {
            fmt.Printf("[Worker-%d] 任务 %s 完成: %s\n", id, job.ID, result[:min(50, len(result))])
        }
    }
}

func (wp *WorkerPool) Submit(job Job) error {
    select {
    case wp.jobQueue <- job:
        return nil
    default:
        return fmt.Errorf("任务队列已满,拒绝任务 %s", job.ID)
    }
}

func (wp *WorkerPool) Shutdown() {
    close(wp.jobQueue)
    wp.wg.Wait()
}

用起来:

go 复制代码
// 最多 3 个 Agent 同时运行
pool := NewWorkerPool(3, func(ctx context.Context, task string) (string, error) {
    return agent.Run(ctx, task)
})

for _, repo := range repos {
    pool.Submit(Job{
        ID:   repo.Name,
        Task: fmt.Sprintf("分析仓库 %s 的昨日提交并生成日报", repo.Name),
    })
}

pool.Shutdown()

真实场景:分析 10 个仓库的完整流程

把 Token Bucket + Worker Pool 串起来:

go 复制代码
func AnalyzeRepos(repos []string) {
    // 1. 全局限流:5 QPS(匹配 DeepSeek 免费配额)
    globalLimiter := NewTokenBucket(5, 5.0)

    // 2. Worker Pool:最多 3 个 Agent 并发
    pool := NewWorkerPool(3, func(ctx context.Context, task string) (string, error) {
        // 等 Token
        if err := globalLimiter.Wait(ctx); err != nil {
            return "", err
        }
        // 执行
        return agentInstance.Run(ctx, task)
    })

    // 3. 提交任务
    for i, repo := range repos {
        pool.Submit(Job{
            ID:   fmt.Sprintf("repo-%d", i),
            Task: fmt.Sprintf("分析 %s 的昨日提交", repo),
        })
    }

    pool.Shutdown()
}

执行效果:

复制代码
[Worker-0] 开始处理 repo-0
[Worker-1] 开始处理 repo-1
[Worker-2] 开始处理 repo-2
(等待 Token...)
[Worker-0] 完成 repo-0
(获取 Token...)
[Worker-0] 开始处理 repo-3
...

3 个 Agent 并发,5 QPS 全局限流。既不打爆 API,也不会让任务排太久。


一段真实数据

我统计了 10 个仓库跑一轮全量日报的耗时对比:

策略 耗时 429 错误 CPU 峰值
无限制 32 秒 17 次 85%
只限流(5 QPS) 58 秒 0 次 45%
限流 + 3 并发 42 秒 0 次 35%
限流 + 5 并发 38 秒 0 次 55%

最佳实践:限流 + 3-5 并发。既快又稳。

下一篇给 Agent 装"水表"------按调用计费 + Prometheus 成本监控,让你花的每一分钱都有据可查。