先说这篇解决什么问题
问题:Agent 一旦失控(死循环重试、规划跑偏),烧的是真金白银的 token,所以每个 Agent 后端都要装"刹车"——限流器。Java 里自己写一个不超过 30 行:令牌桶、滑动窗口日志、固定窗口三选一。这篇把三个 30 行实现放到 200 线程、10 秒、1.07~2.96 亿次抢额请求下对拍,结果:写错一个 volatile,配额 100/s 的限流器实际放行了 1898/s;而两个"写对"的限流器,也有线程在 10 秒里颗粒无收。
适合谁读:给 Agent 调 LLM/工具的链路加配额与熔断、用自研限流器(或想搞清 Guava RateLimiter 这类实现为什么长那样)的 Java 后端。
能收获什么:三种限流器在同一压力下的放行总量、逐秒放行形状、线程公平性、阻塞式排队延迟四组对比数据;一个"非 volatile 检查-后-行动"双向失真的完整案例(超发 19~21 倍与永久饿死在同一个实现里先后出现,两套 JDK 复现);以及 2 亿次/10 秒的无锁尝试下,全程加锁方案的吞吐参考值。
近期背景
CSDN 10-28 的《智能Token工厂》一文(厂商视角,PPIO 在 AICC 算力大会的提法,属第三方材料,其中"吞吐提升 3~5 倍"等数字本文不采信)把 Agent 时代的计量与限流列为基础设施四道工序之一:"Agent 容易失控——一个死循环可能瞬间烧掉大量 Token,平台需要有熔断和配额机制"。结合 10-24 前后清华/港中文 AKV 缓存工程把 Agent 成本管理推上论文日榜,"给 Agent 装刹车"已经从最佳实践变成默认动作。刹车本身写得对不对,就是这篇的主题。
一、实测环境与口径
| 项 | 实测值 |
|---|---|
| JDK(主) | 17.0.18(17.0.18+8-LTS-264) |
| JDK(对拍) | 21.0.10(21.0.10+8-LTS-217) |
| os.name / version / arch | Windows 11 / 10.0 / amd64,16 核 |
| 被测对象 | 三种自研限流器,同配置:limit=100 permits/s,burst=100 |
| 压力 | 200 线程无间隔抢额 10 秒,总尝试 1.07~2.96 亿次(实现而异) |
| 场景 | 非阻塞 tryAcquire / 阻塞 acquire(最长等 500ms) / 首秒突发(只跑 1 秒) |
换环境会变:尝试总数(锁吞吐)、各线程分到的额度;不变:放行总量相对配额的倍数关系、超发/饿死这两个方向性失败(两套 JDK 已复现)。复现判定见第九节。
二、非阻塞抢额:固定窗口超发 19~21 倍,另两个精确踩线
200 线程、10 秒、无思考间隔地抢(每秒真实尝试 1000 万~3000 万次):
| 限流器 | 放行总数(10s) | 折算速率 | JDK 21 复测 |
|---|---|---|---|
| 令牌桶(100/s,桶容 100) | 1097 | ≈110/s | 1098(≈110/s) |
| 滑动窗口日志(100/s) | 1028 | ≈103/s | 1000(≈100/s) |
| 固定窗口(100/s) | 18983 | ≈1898/s | 21364(≈2136/s) |
令牌桶与滑动日志在 2 亿次争抢下精确守住了配额(多出的 3%~10% 是首秒突发与窗口边界,见第三节)。固定窗口超发 19 倍(JDK 21 上 21 倍)——限流器不是"大概齐",写错可见性就是 20 倍的账单差异。它的病根在第六节拆。
三、突发形状:令牌桶的首秒 199 是特性,固定窗口的 890 是事故
只跑 1 秒、同样 200 线程(JDK 17):
| 限流器 | 首秒放行 | 说明 |
|---|---|---|
| 令牌桶(桶容 100) | 199 | 预存 100 个令牌 + 1 秒内又补充约 99 个,突发容量是设计特性 |
| 滑动窗口日志 | 100 | 滚动窗口没有"预存"概念,天然零突发 |
| 固定窗口 | 890 | 配额 100,放行 890 |
选型时这是一笔必须摊开算的账:令牌桶允许"攒着一起花"(对 LLM API 的 RPM 限制通常正合适),滑动窗口适合"任一滚动秒都不许超",而固定窗口在没写对之前连基本配额都保不住。顺带一个反直觉细节:滑动日志在 JDK 21 复测的逐秒放行形状里出现了"某日历秒放行 200、相邻秒为 0"——这不是 bug,滚动窗口约束的是"任意滚动 1 秒",日历秒只是它的一个切片,切片可以横跨两个窗口的尾部和头部。看监控选对时间桶,否则会把正确的限流器误判成超发。
四、公平性:两个"写对"的限流器,都有线程 10 秒颗粒无收
10 秒争抢结束时,每个线程分到的放行次数:
| 限流器 | 单线程最少 | 单线程最多 |
|---|---|---|
| 令牌桶 | 0 | 61 |
| 滑动窗口日志 | 0 | 86 |
| 固定窗口 | 6 | 253 |
令牌桶和滑动日志在 200 线程的抢额里出现了"零配额线程":先到先得 + 单把锁,谁抢到归谁,慢线程在10 秒里一次都没成功过。固定窗口反而因为自身超发"雨露均沾"(6~253)。结论:限流器默认不做公平性。如果你的业务是"每个 Agent 任务都必须推进",要么在线程间引入轮转/队列,要么接受"有的任务就是抢不到"并在上层重试——这两条路的成本结构完全不同,得提前选。
五、阻塞式排队:令牌桶等待 p50 15ms,滑动日志 1ms,固定窗口 3778 次超时
同一批限流器换成阻塞式acquire(最多等 500ms)(200 线程、10 秒,JDK 17):
| 限流器 | 成功获取(含等待>0) | 等待 p50 / p99 / max | 超时(>500ms) |
|---|---|---|---|
| 令牌桶 | 1295(≈130/s) | 15 / 26 / 34 ms | 0 |
| 滑动窗口日志 | 1199(≈120/s) | 1 / 1 / 500 ms | 13 |
| 固定窗口 | 15(≈1.5/s) | 1 / 498 / 500 ms | 3778 |
两个"写对"的限流器在阻塞模式下依然精确放行(120~130/s),排队延迟 p50 都在毫秒级——阻塞式比拒绝-重试对调用方友好得多,代价是 200 个调用线程都被挂住。固定窗口在阻塞模式下暴露出第二张面孔:10 秒只放行 15 次、3778 次等到超时——与非阻塞模式的 19 倍超发正好相反。
六、固定窗口的双向失真:同一个实现,先超发 19 倍,再饿死 10 秒
我的固定窗口实现是教科书式的"检查-后-行动":
// bucketStart 与 count 都是普通字段(非 volatile) long sec = System.nanoTime() / 1_000_000_000L; if (sec != bucketStart) { synchronized (this) { if (sec != bucketStart) { bucketStart = sec; count = new AtomicInteger(); } } } return count.incrementAndGet() <= limit ? 0 : -1;复位逻辑在同步块里做了双重检查,看起来"该有的都有了",但bucketStart和count本身是普通字段:读到的可能是过期引用。实测它在两个方向同时失真——非阻塞模式超发 19~21 倍,阻塞模式把 200 个线程里的绝大多数饿到超时。超发方向的具体交错路径(哪些线程拿到了哪个旧计数器、复位为什么被跳过或重复生效)我没有完全定位出来,这一点如实列入第八节;但失真的类别(共享可变状态无可见性保障)与修复方向是确定的:要么全程持锁(本文的令牌桶/滑动日志就是这么写的,两套 JDK 都精确守住了配额),要么把(bucketStart, count)打包成单个 volatile 引用整体替换。限流器的正确性 budget 比它的性能 budget 贵得多——这两个方向在生产上的名字分别叫"超支烧钱"和"服务不可用"。
七、锁的代价:2 亿次尝试 / 10 秒,就是单锁的吞吐上限
非阻塞模式下每个线程不休息地抢,三个限流器 10 秒内各自承受了 1.07 亿 / 1.18 亿 / 2.03 亿次尝试(JDK 21 为 1.48 亿 / 1.57 亿 / 2.96 亿)。这就是单把 synchronized 锁在 16 核 200 线程下的实际吞吐量级:每秒 1000 万~3000 万次判定。对"每个 Agent 任务每秒最多几十次工具调用"的真实负载,这余量巨大;但如果你把限流器放进每请求路径的高 QPS 网关,这就是要换成 Striped/分段锁或无因果锁结构的信号。反过来,第五节阻塞模式下滑动日志 p50 只有 1ms——睡眠轮询的阻塞式抢额对锁的压力小两个数量级。
八、适用边界与风险提示
- 全部实验在本机 Windows 11、16 核、单 JVM 进程内,限流器是进程内实现;分布式限流(Redis+Lua 等)的失败模式不同,本文结论不直接迁移。
- 尝试总数、各线程分配额度受线程调度影响,逐次运行会波动;放行总量的倍数关系与超发/饿死两个方向在两套 JDK 上均复现。
- 固定窗口超发方向的精确交错路径未完全定位(已知类别是共享 (bucket,counter) 无可见性保障;饿死方向由"线程长期持有旧计数器引用"解释)。我把它当作"这一类写法不可用"的证据,而不是对该实现每一行汇编码级的证明。
- 阻塞式实验的测台有一处口径修正,见第九节判定标准前的说明:阻塞模式下"等待>0 的成功"最初被计成了拒绝,靠"timeouts=0 却有 1294 次拒绝"的自相矛盾抓出来——先怀疑测台这条纪律又一次生效。
- 未单独实验、只作方向提示:Guava RateLimiter 的突发平滑语义、令牌桶预存容量的取值对冷启动的影响、多 key 场景下锁分段与公平性的组合。
AI 辅助说明:Exp4 代码与全部数据均为本机 JDK 17.0.18 / 21.0.10 实跑输出,数字取自 stdout 留档;第六节引用的限流器代码就是实验中被测的那份实现,未做任何事后修改。
九、复现命令与判定标准
"D:/installSoftware/jdk-17.0.18/bin/javac" -encoding UTF-8 -d out17 src/Exp4.java "D:/installSoftware/jdk-17.0.18/bin/java" -XX:+PerfDisableSharedMem -cp out17 Exp4判定标准(对不上先怀疑测台):① 令牌桶 10 秒放行必须落在 1050~1150 区间(100/s × 10s + 首秒突发约 100),固定窗口必须数倍于 1000;② 滑动日志的逐秒形状里允许出现"相邻日历秒 200/0",但任意滚动 1 秒内不得超过 100;③ 阻塞模式下令牌桶与滑动日志的 timeouts 必须接近 0,固定窗口必然出现大量超时;④ 若你的复测里固定窗口恰好守住了 100/s,先检查编译的是不是同一份源码(bucketStart/count是否被顺手改成了 volatile)——那等于换了被测对象。
十、带走三条判断
第一,限流器先审可见性,再谈性能:一个非 volatile 的检查-后-行动,能同时产出"超发 20 倍"和"全员饿死"两种事故,而修好它只要全程持锁——先正确,再优化。
第二,突发是设计参数不是 bug:令牌桶首秒 199 对 LLM API 是刚需,滑动日志的零突发是另一种刚需;监控时用滚动窗口口径看数据,别拿日历秒误判。
第三,默认不公平:先到先得的限流器必然产生零配额线程,"每个任务都必须推进"的业务要在上层补排队或轮转,这不是限流器的锅但会算在它头上。
一个具体自检动作:翻出你项目里的限流器实现,找(窗口起点, 计数器)这对字段——只要其中一个不是 volatile 或不在同一把锁内读写,就按第六节的双向失真对待,压测一轮再看数字。
两个我还没想透的问题:你们给 Agent 任务的限流是按"每任务"还是按"每用户"?前者会不会让大任务饿死小任务?另外阻塞式抢额你们设的最大等待是多少,超时后走降级还是直接失败?欢迎评论区聊聊。
如果你正要给 Agent 平台补配额与熔断,第二节那张三实现对比表和第六节那个双向失真的案例值得一起收藏——刹车装错方向的代价,比没有刹车更贵。