上个月我把 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 成本监控,让你花的每一分钱都有据可查。