【AIaaS 全栈架构师】第 23 篇:Go 限流与计量——Token 维度的精细化管控
系列定位:AIaaS 全栈架构师教程,技术栈以 Go 为主。本篇深入 LLM 网关最独特的部分——Token 维度限流与计量。
本篇你将学到
- 理解 LLM 限流与普通 API 限流的关键差异,掌握多维限流(QPS/TPM/并发)的设计
- 用 Go 从零实现令牌桶与滑动窗口两种经典限流算法
- 在 SSE 流式响应中实时统计 output token,实现边推理边计量
- 使用 Redis + Lua 脚本实现分布式限流
限流和计量是 LLM API 网关区别于普通 API 网关最显著的两个能力。普通网关限流按请求数(QPS)就够了;LLM 网关必须按 Token 数(TPM)限流,因为一个"写一篇万字长文"的请求和一个"说 hi"的请求,消耗的算力差了成百上千倍。
一、LLM 限流的独特维度
1.1 为什么 QPS 不够用
先看一个具体场景。假设你的平台限额是"每秒 10 个请求"(10 QPS),看似合理。但如果:
- 用户 A 每次请求都让模型输出 8000 token(长文生成)
- 用户 B 每次请求只输出 50 token(简单问答)
两者消耗的 GPU 算力相差 160 倍,但在 QPS 维度上他们"平等"。结果就是:用户 A 用 10 QPS 就能跑满整张 GPU,用户 B 被挤到排队。QPS 无法反映真实资源消耗,必须引入 Token 维度。
1.2 三维限流模型
一个成熟的 LLM 限流系统通常是三维叠加的:
| 维度 | 全称 | 含义 | 保护目标 |
|---|---|---|---|
| QPS | Queries Per Second | 每秒请求数 | 防止连接/调度层过载 |
| TPM | Tokens Per Minute | 每分钟 Token 数 | 防止 GPU 算力被占满 |
| 并发数 | Concurrent Requests | 同时在执行的请求数 | 防止 KV Cache 显存溢出 |
注意第三个检查点——输入 Token 可以在请求前预估(因为请求体里有 messages),但输出 Token 只能在推理完成后才知道。所以 TPM 限流分两阶段:
- 请求前:检查历史已消耗 TPM + 本次预估 input token
- 请求后:根据实际 output token 补扣额度
1.3 限流 vs 计量 vs 计费
这三个概念容易混淆,先澄清边界:
| 概念 | 时机 | 目的 | 数据去向 |
|---|---|---|---|
| 限流 | 请求前 + 请求后 | 保护系统不被打挂 | 内存/Redis 计数器 |
| 计量 | 请求后 | 记录实际消耗 | 日志/消息队列 |
| 计费 | 离线/准实时 | 生成账单 | 数据库/账单系统 |
本篇聚焦限流和计量。计费留到第 36 篇专题讲解。
二、令牌桶算法 Go 实现
令牌桶(Token Bucket)是最常用的限流算法。核心思想:以固定速率往桶里放令牌,桶满了就丢弃;每个请求消耗一个令牌;没令牌就拒绝。
2.1 算法原理
令牌桶的两个关键参数:
- rate:令牌生成速率(个/秒),决定稳态吞吐
- burst:桶容量,决定突发流量上限
令牌桶的优势是允许突发——桶里攒了 100 个令牌,突然来 50 个请求可以瞬间放行,之后才按 rate 补充。这比"严格每秒 N 个"的计数器更贴合真实流量模式。
2.2 惰性计算实现
不需要真的起一个 goroutine 每秒往桶里加令牌。用一个时间戳记录"上次补充时间",每次取令牌时根据时间差一次性补上:
package main
import (
"sync"
"time"
)
// TokenBucket 令牌桶限流器(惰性计算版)
type TokenBucket struct {
mu sync.Mutex
rate float64 // 令牌生成速率(个/秒)
burst float64 // 桶容量
tokens float64 // 当前令牌数
lastUpdate time.Time // 上次补充时间
}
// NewTokenBucket 创建令牌桶
// rate: 每秒生成多少令牌;burst: 桶最大容量
func NewTokenBucket(rate, burst float64) *TokenBucket {
return &TokenBucket{
rate: rate,
burst: burst,
tokens: burst, // 初始满桶
lastUpdate: time.Now(),
}
}
// Allow 尝试消耗 n 个令牌,返回是否允许
func (tb *TokenBucket) Allow(n float64) bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
// 计算自上次以来应补充的令牌
elapsed := now.Sub(tb.lastUpdate).Seconds()
tb.lastUpdate = now
tb.tokens += elapsed * tb.rate
// 不能超过桶容量
if tb.tokens > tb.burst {
tb.tokens = tb.burst
}
if tb.tokens >= n {
tb.tokens -= n
return true
}
return false
}
// Tokens 返回当前可用令牌数(仅用于观察/调试)
func (tb *TokenBucket) Tokens() float64 {
tb.mu.Lock()
defer tb.mu.Unlock()
return tb.tokens
}
2.3 用于 QPS 限流
把令牌桶包装成 HTTP 中间件,针对每个租户单独限流:
package main
import (
"net/http"
"strconv"
"sync"
)
// RateLimiterMap 按租户维度管理限流器
type RateLimiterMap struct {
mu sync.RWMutex
limiters map[string]*TokenBucket // key: tenantID
rate float64 // 默认速率
burst float64 // 默认桶容量
}
func NewRateLimiterMap(rate, burst float64) *RateLimiterMap {
return &RateLimiterMap{
limiters: make(map[string]*TokenBucket),
rate: rate,
burst: burst,
}
}
// Get 获取或创建租户的限流器
func (m *RateLimiterMap) Get(tenantID string) *TokenBucket {
m.mu.RLock()
tb, ok := m.limiters[tenantID]
m.mu.RUnlock()
if ok {
return tb
}
m.mu.Lock()
defer m.mu.Unlock()
// 双检
if tb, ok := m.limiters[tenantID]; ok {
return tb
}
tb = NewTokenBucket(m.rate, m.burst)
m.limiters[tenantID] = tb
return tb
}
// RateLimitMiddleware QPS 限流中间件
func RateLimitMiddleware(limiterMap *RateLimiterMap, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenant := GetTenant(r.Context())
if tenant == nil {
next.ServeHTTP(w, r) // 未认证请求放行(认证中间件应已拦截)
return
}
tb := limiterMap.Get(tenant.ID)
if !tb.Allow(1) {
w.Header().Set("Retry-After", "1")
writeRateLimitError(w, "rate_limit_exceeded",
"QPS limit exceeded, please retry later")
return
}
next.ServeHTTP(w, r)
})
}
func writeRateLimitError(w http.ResponseWriter, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
resp := map[string]interface{}{
"error": map[string]interface{}{
"message": message,
"type": "rate_limit_error",
"code": code,
},
}
_ = json.NewEncoder(w).Encode(resp)
}
2.4 单元测试验证
用 Go 标准 testing 验证令牌桶行为:
package main
import (
"testing"
"time"
)
func TestTokenBucket_Basic(t *testing.T) {
// rate=10/s, burst=10
tb := NewTokenBucket(10, 10)
// 初始满桶,前 10 个请求应全部通过
for i := 0; i < 10; i++ {
if !tb.Allow(1) {
t.Fatalf("request %d should be allowed", i+1)
}
}
// 第 11 个应该被拒绝
if tb.Allow(1) {
t.Fatal("request 11 should be rejected")
}
}
func TestTokenBucket_Refill(t *testing.T) {
tb := NewTokenBucket(10, 10)
// 消耗全部令牌
for i := 0; i < 10; i++ {
tb.Allow(1)
}
// 立刻请求应该被拒
if tb.Allow(1) {
t.Fatal("should be rejected immediately after drain")
}
// 等待 0.2 秒应补充约 2 个令牌
time.Sleep(200 * time.Millisecond)
if !tb.Allow(1) {
t.Fatal("should be allowed after refill")
}
}
func TestTokenBucket_Burst(t *testing.T) {
// rate=1/s, burst=5 —— 低速率高突发
tb := NewTokenBucket(1, 5)
// 突发 5 个应全部通过
for i := 0; i < 5; i++ {
if !tb.Allow(1) {
t.Fatalf("burst request %d should pass", i+1)
}
}
// 第 6 个拒绝
if tb.Allow(1) {
t.Fatal("6th should be rejected")
}
}
// BenchmarkTokenBucket 并发基准测试
func BenchmarkTokenBucket_Concurrent(b *testing.B) {
tb := NewTokenBucket(1000000, 1000000) // 高速率避免成为瓶颈
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
tb.Allow(1)
}
})
}
在终端运行:
go test -v -run TestTokenBucket
go test -bench BenchmarkTokenBucket
三、滑动窗口限流
令牌桶适合"平滑限流",但有时你需要精确控制"每分钟不超过 N 个"。固定窗口算法(整点清零)有临界突发问题——59 秒和 1 秒内各来 N 个请求,1 秒窗口内有 2N 个。滑动窗口解决这个问题。
3.1 算法原理
滑动窗口记录每个请求的时间戳,实时淘汰窗口外的旧请求:
package main
import (
"sync"
"time"
)
// SlidingWindow 滑动窗口限流器
type SlidingWindow struct {
mu sync.Mutex
window time.Duration // 窗口大小(如 1 分钟)
limit int // 窗口内最大请求数
requests []time.Time // 窗口内请求时间戳列表
}
func NewSlidingWindow(window time.Duration, limit int) *SlidingWindow {
return &SlidingWindow{
window: window,
limit: limit,
}
}
// Allow 尝试获取许可
func (sw *SlidingWindow) Allow() bool {
sw.mu.Lock()
defer sw.mu.Unlock()
now := time.Now()
cutoff := now.Add(-sw.window)
// 淘汰窗口外的旧请求(从头部移除)
idx := 0
for i, t := range sw.requests {
if t.After(cutoff) {
idx = i
break
}
if i == len(sw.requests)-1 {
idx = len(sw.requests) // 全部过期
}
}
sw.requests = sw.requests[idx:]
if len(sw.requests) >= sw.limit {
return false
}
sw.requests = append(sw.requests, now)
return true
}
滑动窗口用 slice 存时间戳,在超高并发下 slice 移除头部元素开销大(O(n) 内存拷贝)。生产环境可用环形缓冲(ring buffer)或 Redis ZSET 优化。本段聚焦算法原理。
3.2 令牌桶 vs 滑动窗口选型
| 维度 | 令牌桶 | 滑动窗口 |
|---|---|---|
| 突发流量 | 允许(受 burst 限制) | 严格拒绝 |
| 内存 | O(1) | O(窗口内请求数) |
| 精度 | 平滑近似 | 精确 |
| 适用场景 | QPS/TPM 限流 | 精确配额控制 |
| 分布式友好 | 需改造 | Redis ZSET 天然适配 |
经验法则:QPS 用令牌桶,TPM 配额用滑动窗口。
四、Token 计数器:流式响应中实时统计
这是 LLM 限流/计量最技术密集的部分。难点在于:SSE 流式响应的 output token 是逐个 chunk 产生的,我们必须边接收边计数。
4.1 input token 预估
input token 在请求前就知道(messages 内容已知)。最简单的预估方法是用空格分词:
package main
import (
"strings"
"unicode/utf8"
)
// EstimateTokens 粗略估算文本的 token 数
// 经验值:英文约 1 token ≈ 4 字符;中文约 1 token ≈ 1.5 字符
func EstimateTokens(text string) int {
charCount := utf8.RuneCountInString(text)
// 检测中文字符比例
chineseChars := 0
for _, r := range text {
if r >= 0x4E00 && r <= 0x9FFF {
chineseChars++
}
}
if charCount == 0 {
return 0
}
chineseRatio := float64(chineseChars) / float64(charCount)
// 按中英文比例加权
return int(float64(charCount) * (0.25 + 0.4*chineseRatio))
}
// EstimateInputTokens 估算整个请求的 input token
func EstimateInputTokens(messages []Message) int {
total := 0
// 每条消息有约 4 token 的格式开销(role 标记等)
for _, msg := range messages {
total += EstimateTokens(msg.Content) + 4
}
return total + 2 // 结尾标记
}
生产环境应使用真实的 tokenizer(如
tiktoken-go库)精确计数。这里给出经验估算法用于演示原理。
4.2 流式 Token 计数器
在 SSE 流式转发过程中,每个 chunk 的 delta.content 是一段文本。我们逐 chunk 调用 EstimateTokens 累加:
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
)
// TokenCounter 流式 Token 计数器
type TokenCounter struct {
InputTokens int // input token(请求前确定)
OutputTokens atomic.Int64 // output token(流式中累加)
}
func NewTokenCounter(inputTokens int) *TokenCounter {
return &TokenCounter{InputTokens: inputTokens}
}
// CountChunk 处理一个 SSE chunk,提取 content 并计数
func (tc *TokenCounter) CountChunk(line string) {
if !strings.HasPrefix(line, "data: ") {
return
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
return
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return
}
for _, choice := range chunk.Choices {
if choice.Delta.Content != "" {
tc.OutputTokens.Add(int64(EstimateTokens(choice.Delta.Content)))
}
}
}
// Total 返回总 token 数
func (tc *TokenCounter) Total() int {
return tc.InputTokens + int(tc.OutputTokens.Load())
}
4.3 集成到流式代理
把 TokenCounter 嵌入到第 22 篇的 SSE 转发逻辑中。核心是在转发每个 chunk 时顺便计数:
// StreamProxyWithCount 带计数的流式代理
func StreamProxyWithCount(w http.ResponseWriter, r *http.Request,
backendURL string, bodyBytes []byte, counter *TokenCounter) error {
// 构造后端请求
req, err := http.NewRequestWithContext(r.Context(), "POST",
backendURL+"/v1/chat/completions", bytes.NewReader(bodyBytes))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 设置 SSE 响应头
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
// 关键:边转发边计数
counter.CountChunk(line)
// 转发给客户端
_, _ = fmt.Fprintf(w, "%s\n", line)
if line == "" {
if flusher != nil {
flusher.Flush()
}
}
}
return scanner.Err()
}
4.4 计量中间件完整实现
把限流和计量组装成中间件链:
// MeteringMiddleware 计量中间件:请求前预检 + 请求后扣减
func MeteringMiddleware(tpmLimiter *RateLimiterMap, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenant := GetTenant(r.Context())
if tenant == nil {
next.ServeHTTP(w, r)
return
}
// 读取请求体
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "read_error", "Failed to read body")
return
}
r.Body.Close()
// 解析请求,预估 input token
var req ChatRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "Invalid JSON")
return
}
inputTokens := EstimateInputTokens(req.Messages)
// TPM 预检:本次预估消耗 = input + max_tokens
estimatedCost := float64(inputTokens + req.MaxTokens)
tb := tpmLimiter.Get(tenant.ID)
if !tb.Allow(estimatedCost) {
w.Header().Set("Retry-After", "60")
writeRateLimitError(w, "token_limit_exceeded",
"Token quota exceeded for this minute")
return
}
// 用包装的 ResponseWriter 捕获流式 token
counter := NewTokenCounter(inputTokens)
// 如果是流式,需要特殊的 handler 处理
if req.Stream {
// 重新填充 body 给后续 handler
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// 将 counter 放入 context
ctx := context.WithValue(r.Context(), counterKey, counter)
next.ServeHTTP(w, r.WithContext(ctx))
// 流式结束后,记录实际 output token 到计量系统
log.Printf("tenant=%s model=%s input=%d output=%d total=%d",
tenant.ID, req.Model, counter.InputTokens,
counter.OutputTokens.Load(), counter.Total())
return
}
// 非流式:包装 ResponseWriter 捕获响应体
rw := &capturingResponseWriter{ResponseWriter: w}
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
ctx := context.WithValue(r.Context(), counterKey, counter)
next.ServeHTTP(rw, r.WithContext(ctx))
// 非流式响应中后端通常返回 usage,可从中提取精确 token 数
actualOutput := extractOutputTokens(rw.body)
if actualOutput > 0 {
counter.OutputTokens.Store(int64(actualOutput))
}
log.Printf("tenant=%s model=%s input=%d output=%d",
tenant.ID, req.Model, counter.InputTokens, counter.OutputTokens.Load())
})
}
// capturingResponseWriter 捕获非流式响应体(用于提取 usage)
type capturingResponseWriter struct {
http.ResponseWriter
body []byte
}
func (crw *capturingResponseWriter) Write(b []byte) (int, error) {
crw.body = append(crw.body, b...)
return crw.ResponseWriter.Write(b)
}
func extractOutputTokens(body []byte) int {
var resp ChatResponse
if err := json.Unmarshal(body, &resp); err != nil {
return 0
}
return resp.Usage.CompletionTokens
}
4.5 计量数据流全景
五、Redis 分布式限流
单机限流器(上面的 TokenBucket 和 SlidingWindow)只在单个网关实例内有效。生产环境网关通常多实例部署,必须用集中式存储协调。
5.1 架构
5.2 用 Redis 原子命令实现固定窗口
最简单的 Redis 限流用 INCR + EXPIRE:
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// RedisRateLimiter 基于 Redis 的分布式限流器(固定窗口版)
type RedisRateLimiter struct {
client *redis.Client
}
func NewRedisRateLimiter(addr string) *RedisRateLimiter {
return &RedisRateLimiter{
client: redis.NewClient(&redis.Options{Addr: addr}),
}
}
// Allow 检查是否允许(固定窗口:按分钟计数)
func (rl *RedisRateLimiter) Allow(ctx context.Context,
key string, limit int64, window time.Duration) (bool, error) {
// 用窗口起始时间作为 key 后缀,保证同一窗口内计数
windowKey := fmt.Sprintf("%s:%d", key, time.Now().UnixNano()/int64(window))
pipe := rl.client.Pipeline()
incr := pipe.Incr(ctx, windowKey)
pipe.Expire(ctx, windowKey, window+time.Second) // 多加 1 秒防止边界竞争
_, err := pipe.Exec(ctx)
if err != nil {
return false, err
}
return incr.Val() <= limit, nil
}
5.3 Lua 脚本实现令牌桶
Redis 的 INCR 方案有竞态问题(INCR 和 EXPIRE 之间可能中断)。用 Lua 脚本保证原子性,同时实现令牌桶:
// 令牌桶 Lua 脚本
// KEYS[1] = bucket key
// ARGV[1] = capacity(桶容量)
// ARGV[2] = rate(每秒生成令牌数)
// ARGV[3] = now(当前时间戳,秒)
// ARGV[4] = requested(请求消耗令牌数)
const tokenBucketLua = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
-- 读取当前状态
local data = redis.call('HMGET', key, 'tokens', 'timestamp')
local tokens = tonumber(data[1]) or capacity
local last_time = tonumber(data[2]) or now
-- 补充令牌
local delta = math.max(0, now - last_time)
tokens = math.min(capacity, tokens + delta * rate)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
-- 写回状态,设置过期时间防止冷数据
redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now)
redis.call('EXPIRE', key, 3600)
return allowed
`
// RedisTokenBucket 分布式令牌桶
type RedisTokenBucket struct {
client *redis.Client
script *redis.Script
}
func NewRedisTokenBucket(addr string) *RedisTokenBucket {
return &RedisTokenBucket{
client: redis.NewClient(&redis.Options{Addr: addr}),
script: redis.NewScript(tokenBucketLua),
}
}
func (rtb *RedisTokenBucket) Allow(ctx context.Context, key string,
capacity, rate float64, requested int) (bool, error) {
now := float64(time.Now().UnixNano()) / 1e9
result, err := rtb.script.Run(ctx, rtb.client,
[]string{key},
capacity, rate, now, requested,
).Int()
if err != nil {
return false, err
}
return result == 1, nil
}
5.4 Redis 限流注意事项
| 问题 | 解决方案 |
|---|---|
| Redis 故障导致限流失效 | 本地兜底限流(令牌桶降级),fail-open 策略 |
| 网关实例间时钟不同步 | 从 NTP 同步,或用 Redis TIME 命令取时间 |
| 单 key 成为热点 | 按 tenant 分片,或用 Redis Cluster |
| Lua 脚本阻塞 | 脚本保持简短,避免循环 |
六、多维限流组装
把 QPS + 并发 + TPM 三个维度组装到一起,形成完整的限流中间件链:
package main
import (
"sync"
"sync/atomic"
)
// ConcurrencyTracker 并发请求计数器(用于并发数限流)
type ConcurrencyTracker struct {
mu sync.Mutex
current map[string]*atomic.Int64 // tenantID -> 当前并发数
limit int64
}
func NewConcurrencyTracker(limit int64) *ConcurrencyTracker {
return &ConcurrencyTracker{
current: make(map[string]*atomic.Int64),
limit: limit,
}
}
func (ct *ConcurrencyTracker) TryAcquire(tenantID string) bool {
ct.mu.Lock()
c, ok := ct.current[tenantID]
if !ok {
c = &atomic.Int64{}
ct.current[tenantID] = c
}
ct.mu.Unlock()
current := c.Add(1)
if current > ct.limit {
c.Add(-1)
return false
}
return true
}
func (ct *ConcurrencyTracker) Release(tenantID string) {
ct.mu.Lock()
c, ok := ct.current[tenantID]
ct.mu.Unlock()
if ok {
c.Add(-1)
}
}
// MultiDimensionLimit 三维限流中间件
func MultiDimensionLimit(qpsMap, tpmMap *RateLimiterMap,
concurrency *ConcurrencyTracker, next http.Handler) http.Handler {
qpsLimiter := qpsMap // 如 10 QPS
tpmLimiter := tpmMap // 如 100000 TPM
// concurrency // 如 5 并发
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tenant := GetTenant(r.Context())
if tenant == nil {
next.ServeHTTP(w, r)
return
}
// 1. QPS 检查
if !qpsLimiter.Get(tenant.ID).Allow(1) {
writeRateLimitError(w, "qps_exceeded", "Too many requests per second")
return
}
// 2. 并发检查
if !concurrency.TryAcquire(tenant.ID) {
writeRateLimitError(w, "concurrency_exceeded",
"Too many concurrent requests")
return
}
defer concurrency.Release(tenant.ID) // 确保请求结束释放
// 3. TPM 检查在 MeteringMiddleware 中处理(因为要解析 body)
next.ServeHTTP(w, r)
})
}
6.1 中间件执行顺序
顺序设计原则:便宜的检查在前,昂贵的检查在后。认证和 QPS 只需读 header/计数器,开销极小;TPM 需要解析 body 预估 token,开销略大,放最后。
七、性能基准与生产经验
7.1 限流器性能对比
用 Go benchmark 测试不同限流器的吞吐:
func BenchmarkTokenBucket(b *testing.B) {
tb := NewTokenBucket(1000000, 1000000)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
tb.Allow(1)
}
})
}
func BenchmarkSlidingWindow(b *testing.B) {
sw := NewSlidingWindow(time.Minute, 1000000)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
sw.Allow()
}
})
}
func BenchmarkConcurrencyTracker(b *testing.B) {
ct := NewConcurrencyTracker(1000000)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
ct.TryAcquire("bench")
ct.Release("bench")
}
})
}
典型结果(8 核机器):
| 限流器 | 单次操作 | 吞吐(ops/s) |
|---|---|---|
| TokenBucket | ~50ns | ~20M |
| SlidingWindow | ~200ns(窗口满时) | ~5M |
| ConcurrencyTracker | ~80ns | ~12M |
可见单机限流开销极小,真正的瓶颈永远是 Redis 网络往返(~0.3ms/次)。
7.2 生产经验清单
- Redis 故障降级策略:Redis 不可用时,fail-open(放行 + 本地限流兜底)还是 fail-close(拒绝全部),取决于业务。多数平台选择 fail-open + 告警。
- Token 预估偏差:max_tokens 是上限,实际消耗通常远小于此。TPM 预扣太多会导致额度浪费。实践中的做法是预扣
input + max_tokens * 0.5。 - 计量数据异步化:Token 计数日志不要同步写数据库,用 channel + batch 异步写 Kafka,避免影响请求延迟。
- 冷启动保护:新租户首次请求时,令牌桶从满桶开始,可能瞬间突发。生产环境应让桶从 1/3 容量起步。
- 限流维度组合:除了 tenant 维度,还可以按 model 维度限流(保护热门模型)、按 IP 维度(防爬虫)。
本篇小结
| 知识点 | 核心内容 |
|---|---|
| LLM 限流三维模型 | QPS(连接保护)+ TPM(算力保护)+ 并发数(显存保护) |
| 令牌桶算法 | 惰性计算补充令牌,允许突发,适合 QPS 限流 |
| 滑动窗口 | 精确控制窗口内请求数,适合配额管理 |
| 流式 Token 计数 | 在 SSE 转发中逐 chunk 解析 delta.content 并累加 |
| Redis 分布式限流 | Lua 脚本保证原子性,INCR 固定窗口 / HMSET 令牌桶 |
| 中间件顺序 | 认证 → QPS → 并发 → TPM,便宜的在前 |
下篇预告
网关和限流讲完了,现在深入通信层。下一篇我们用 Protocol Buffers 定义推理服务接口,实现 Go gRPC 服务端和客户端,包括双向流式推理和拦截器(认证/日志/指标),并对比 gRPC 与 HTTP 在推理场景下的性能差异。
如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。

337

被折叠的 条评论
为什么被折叠?



