<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Tpm on Wilson Wu</title><link>https://wilsonwu.me/zh/tags/tpm/</link><description>Recent content in Tpm on Wilson Wu</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Mon, 17 Aug 2026 00:00:00 -0400</lastBuildDate><atom:link href="https://wilsonwu.me/zh/tags/tpm/index.xml" rel="self" type="application/rss+xml"/><item><title>使用 Envoy Ratelimit 构建支持 RPM/TPM 的分布式 AI 限流服务器</title><link>https://wilsonwu.me/zh/blog/2026/envoy-ai-ratelimit/</link><pubDate>Mon, 17 Aug 2026 00:00:00 -0400</pubDate><guid>https://wilsonwu.me/zh/blog/2026/envoy-ai-ratelimit/</guid><description>&lt;p&gt;&lt;img alt="概览" loading="lazy" src="https://wilsonwu.me/zh/blog/2026/envoy-ai-ratelimit/1-overview.png"&gt;&lt;/p&gt;
&lt;p&gt;在普通 HTTP API 中，我们通常只需要限制“每分钟请求数”。但对于大模型接口，仅限制请求数量远远不够：一次请求可能只消耗几十个 Token，也可能消耗数万个 Token。&lt;/p&gt;</description><content:encoded><![CDATA[<p><img alt="概览" loading="lazy" src="/zh/blog/2026/envoy-ai-ratelimit/1-overview.png"></p>
<p>在普通 HTTP API 中，我们通常只需要限制“每分钟请求数”。但对于大模型接口，仅限制请求数量远远不够：一次请求可能只消耗几十个 Token，也可能消耗数万个 Token。</p>
<p>因此，一个实用的 AI 网关通常需要同时支持：</p>
<ul>
<li><strong>RPM（Requests Per Minute）</strong>：每分钟请求数。</li>
<li><strong>TPM（Tokens Per Minute）</strong>：每分钟 Token 数。</li>
<li>按租户、API Key、模型或套餐分别计费。</li>
<li>多个 Envoy 实例共享同一份额度。</li>
<li>在高并发条件下避免各实例独立计数造成额度放大。</li>
<li>返回统一的 <code>429 Too Many Requests</code> 和剩余额度信息。</li>
</ul>
<p>本文介绍如何使用 <a href="https://github.com/envoyproxy/ratelimit">envoyproxy/ratelimit</a>、Redis 和 Envoy 构建一套分布式 AI 限流系统。</p>
<h2 id="为什么不能只使用-envoy-local-rate-limit">为什么不能只使用 Envoy Local Rate Limit</h2>
<p>Envoy 提供了 Local Rate Limit Filter，但它的计数器默认位于当前 Envoy 进程中。</p>
<p>假设我们有 4 个 Envoy 实例，并为某个租户配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>RPM = 100
</span></span></code></pre></div><p>如果每个 Envoy 都独立维护 100 RPM，那么整个集群实际上可能放行：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>4 × 100 = 400 RPM
</span></span></code></pre></div><p>这显然不是我们想要的结果。</p>
<p>Envoy 的 Global Rate Limit Filter 可以把限流请求发送给外部 Rate Limit Service。<code>envoyproxy/ratelimit</code> 正是 Envoy 官方生态中常用的全局限流服务实现，它将计数器存放在 Redis 中。</p>
<p>整体架构如下：</p>
<p><img alt="架构" loading="lazy" src="/zh/blog/2026/envoy-ai-ratelimit/2-architect.png"></p>
<p>这里增加了一个很薄的 <strong>AI Quota Adapter</strong>，主要用于解决两个问题：</p>
<ol>
<li>从请求体中解析模型、租户和 Token 数量。</li>
<li>分别使用不同的 <code>hits_addend</code> 检查 RPM 和 TPM。</li>
</ol>
<h2 id="为什么-ai-tpm-需要额外的适配层">为什么 AI TPM 需要额外的适配层</h2>
<p>Envoy 的普通限流规则通常把一次 HTTP 请求视为一次命中：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>hits_addend = 1
</span></span></code></pre></div><p>这适合 RPM，但不适合 TPM。</p>
<p>例如下面两个请求：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>请求 A：消耗 200 Token
</span></span><span style="display:flex;"><span>请求 B：消耗 20,000 Token
</span></span></code></pre></div><p>如果两者都只增加一次计数，TPM 就没有意义。TPM 必须让一次请求按照 Token 数增加计数器：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>hits_addend = token_cost
</span></span></code></pre></div><p>例如：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>RPM 请求：hits_addend = 1
</span></span><span style="display:flex;"><span>TPM 请求：hits_addend = 3200
</span></span></code></pre></div><p>Rate Limit Service 的 <code>hits_addend</code> 通常作用于整个 <code>RateLimitRequest</code>，因此 RPM 和 TPM 需要使用两次限流调用，或者由自定义适配服务组合处理。</p>
<p>推荐的处理顺序是：</p>
<ol>
<li>检查并增加 RPM，<code>hits_addend=1</code>。</li>
<li>RPM 允许后，再检查并增加 TPM，<code>hits_addend=token_cost</code>。</li>
<li>任意一个返回 <code>OVER_LIMIT</code>，适配层向 Envoy 返回 429。</li>
</ol>
<p>这种模式下，每个维度都由 Redis 原子计数，多个 Envoy 实例不会各自放大额度。</p>
<p>需要注意：两次调用并不是跨计数器事务。RPM 已增加后，TPM 可能拒绝请求。因此要提前明确计数语义：</p>
<ul>
<li>RPM 统计所有进入限流流程的请求尝试；或者</li>
<li>自行扩展服务，使用一段 Redis Lua 脚本同时检查 RPM 和 TPM。</li>
</ul>
<p>多数系统接受第一种语义，因为它更简单，也能避免恶意用户通过大量超大请求免费消耗网关资源。</p>
<h2 id="设计限流-descriptor">设计限流 Descriptor</h2>
<p>限流维度通常包括：</p>
<ul>
<li><code>tenant</code>：租户、组织或 API Key。</li>
<li><code>model</code>：模型名称。</li>
<li><code>metric</code>：<code>rpm</code> 或 <code>tpm</code>。</li>
</ul>
<p>最终生成的 Descriptor 可以表示为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>tenant = tenant-a
</span></span><span style="display:flex;"><span>model  = gpt-4.1
</span></span><span style="display:flex;"><span>metric = rpm
</span></span></code></pre></div><p>以及：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>tenant = tenant-a
</span></span><span style="display:flex;"><span>model  = gpt-4.1
</span></span><span style="display:flex;"><span>metric = tpm
</span></span></code></pre></div><p>这样可以实现：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>tenant-a + gpt-4.1 -&gt; 60 RPM
</span></span><span style="display:flex;"><span>tenant-a + gpt-4.1 -&gt; 100,000 TPM
</span></span></code></pre></div><p>如果不希望按模型拆分额度，也可以只保留：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>tenant + metric
</span></span></code></pre></div><p>例如所有模型共享同一份租户额度。</p>
<h2 id="配置-envoyproxyratelimit">配置 envoyproxy/ratelimit</h2>
<p>创建 <code>config/config.yaml</code>：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">domain</span>: <span style="color:#ae81ff">ai-gateway</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">tenant</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">model</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">value</span>: <span style="color:#ae81ff">rpm</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">60</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">value</span>: <span style="color:#ae81ff">tpm</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>              <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">100000</span>
</span></span></code></pre></div><p>这里 <code>tenant</code> 和 <code>model</code> 没有指定固定值，可用于根据请求中的 Descriptor 值进行分桶。实际部署前，应使用所选 ratelimit 版本提供的配置校验工具检查配置。</p>
<p>适配层发送 RPM Descriptor：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>[
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;tenant&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;tenant-a&#34;</span>},
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;model&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;gpt-4.1&#34;</span>},
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;metric&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;rpm&#34;</span>}
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p>发送 TPM Descriptor：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>[
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;tenant&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;tenant-a&#34;</span>},
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;model&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;gpt-4.1&#34;</span>},
</span></span><span style="display:flex;"><span>  {<span style="color:#f92672">&#34;key&#34;</span>: <span style="color:#e6db74">&#34;metric&#34;</span>, <span style="color:#f92672">&#34;value&#34;</span>: <span style="color:#e6db74">&#34;tpm&#34;</span>}
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p>Descriptor 的顺序必须与配置中的嵌套路径一致。</p>
<h3 id="不同套餐额度">不同套餐额度</h3>
<p>如果不同租户拥有不同额度，可以增加套餐维度：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>tenant -&gt; plan -&gt; model -&gt; metric
</span></span></code></pre></div><p>例如：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">domain</span>: <span style="color:#ae81ff">ai-gateway</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">tenant</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">plan</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">value</span>: <span style="color:#ae81ff">standard</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">model</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">value</span>: <span style="color:#ae81ff">rpm</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">60</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">value</span>: <span style="color:#ae81ff">tpm</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">100000</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">plan</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">value</span>: <span style="color:#ae81ff">enterprise</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">model</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">descriptors</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">value</span>: <span style="color:#ae81ff">rpm</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">key</span>: <span style="color:#ae81ff">metric</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">value</span>: <span style="color:#ae81ff">tpm</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">rate_limit</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">requests_per_unit</span>: <span style="color:#ae81ff">2000000</span>
</span></span></code></pre></div><p>请求中的 <code>tenant</code> 用于隔离计数器，<code>plan</code> 用于选择额度。</p>
<p>不要只使用 <code>plan</code> 作为计数维度，否则所有 Standard 用户可能共享同一个计数器。</p>
<h2 id="五使用-docker-compose-部署">五、使用 Docker Compose 部署</h2>
<p>下面是一个简化示例：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">redis</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">redis:7-alpine</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">command</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">redis-server</span>
</span></span><span style="display:flex;"><span>      - --<span style="color:#ae81ff">appendonly</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;yes&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">redis-data:/data</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;6379:6379&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">ratelimit</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">image</span>: <span style="color:#ae81ff">envoyproxy/ratelimit:&lt;固定版本号&gt;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">command</span>: <span style="color:#ae81ff">/bin/ratelimit</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">environment</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">LOG_LEVEL</span>: <span style="color:#ae81ff">info</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">REDIS_SOCKET_TYPE</span>: <span style="color:#ae81ff">tcp</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">REDIS_URL</span>: <span style="color:#ae81ff">redis:6379</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">RUNTIME_ROOT</span>: <span style="color:#ae81ff">/data</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">RUNTIME_SUBDIRECTORY</span>: <span style="color:#ae81ff">ratelimit</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">RUNTIME_WATCH_ROOT</span>: <span style="color:#e6db74">&#34;false&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">USE_STATSD</span>: <span style="color:#e6db74">&#34;false&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">./config:/data/ratelimit</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">ports</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;8081:8081&#34;</span>
</span></span><span style="display:flex;"><span>      - <span style="color:#e6db74">&#34;6070:6070&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">depends_on</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#ae81ff">redis</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">volumes</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">redis-data</span>:
</span></span></code></pre></div><p>生产环境不要直接使用 <code>master</code> 或浮动标签，应固定到经过验证的 release 版本。</p>
<p>不同版本的镜像端口和环境变量可能存在差异，因此部署时应以对应版本仓库中的示例配置为准。</p>
<p>启动：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d
</span></span></code></pre></div><p>查看日志：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose logs -f ratelimit
</span></span></code></pre></div><p>生产环境可以运行多个 ratelimit 副本：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ratelimit-1 ─┐
</span></span><span style="display:flex;"><span>ratelimit-2 ─┼── Redis
</span></span><span style="display:flex;"><span>ratelimit-3 ─┘
</span></span></code></pre></div><p>ratelimit 服务本身可以保持无状态，只要所有副本：</p>
<ul>
<li>使用相同的配置。</li>
<li>指向同一个 Redis 逻辑集群。</li>
<li>使用一致的 Redis Key 前缀或命名空间。</li>
</ul>
<h2 id="ai-quota-adapter-的核心逻辑">AI Quota Adapter 的核心逻辑</h2>
<p>适配层可以实现为 Envoy <code>ext_authz</code> 服务。它接收 Envoy 转发过来的请求信息，完成身份识别、Token 计算和额度检查。</p>
<p>下面是简化后的 Go 伪代码：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">CheckAIQuota</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ctx</span> <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">Context</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">tenant</span> <span style="color:#66d9ef">string</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">model</span> <span style="color:#66d9ef">string</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">tokenCost</span> <span style="color:#66d9ef">uint32</span>,
</span></span><span style="display:flex;"><span>) <span style="color:#66d9ef">error</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 第一次调用：RPM</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">rpmResp</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">rateLimitClient</span>.<span style="color:#a6e22e">ShouldRateLimit</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">ctx</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&amp;</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitRequest</span>{
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Domain</span>: <span style="color:#e6db74">&#34;ai-gateway&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Descriptors</span>: []<span style="color:#f92672">*</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitDescriptor</span>{
</span></span><span style="display:flex;"><span>                {
</span></span><span style="display:flex;"><span>                    <span style="color:#a6e22e">Entries</span>: []<span style="color:#f92672">*</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitDescriptor_Entry</span>{
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;tenant&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#a6e22e">tenant</span>},
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;model&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#a6e22e">model</span>},
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;metric&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#e6db74">&#34;rpm&#34;</span>},
</span></span><span style="display:flex;"><span>                    },
</span></span><span style="display:flex;"><span>                },
</span></span><span style="display:flex;"><span>            },
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">HitsAddend</span>: <span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">err</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">rpmResp</span>.<span style="color:#a6e22e">OverallCode</span> <span style="color:#f92672">==</span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitResponse_OVER_LIMIT</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">ErrRPMExceeded</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 第二次调用：TPM</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">tpmResp</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">rateLimitClient</span>.<span style="color:#a6e22e">ShouldRateLimit</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">ctx</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">&amp;</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitRequest</span>{
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Domain</span>: <span style="color:#e6db74">&#34;ai-gateway&#34;</span>,
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">Descriptors</span>: []<span style="color:#f92672">*</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitDescriptor</span>{
</span></span><span style="display:flex;"><span>                {
</span></span><span style="display:flex;"><span>                    <span style="color:#a6e22e">Entries</span>: []<span style="color:#f92672">*</span><span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitDescriptor_Entry</span>{
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;tenant&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#a6e22e">tenant</span>},
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;model&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#a6e22e">model</span>},
</span></span><span style="display:flex;"><span>                        {<span style="color:#a6e22e">Key</span>: <span style="color:#e6db74">&#34;metric&#34;</span>, <span style="color:#a6e22e">Value</span>: <span style="color:#e6db74">&#34;tpm&#34;</span>},
</span></span><span style="display:flex;"><span>                    },
</span></span><span style="display:flex;"><span>                },
</span></span><span style="display:flex;"><span>            },
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">HitsAddend</span>: <span style="color:#a6e22e">tokenCost</span>,
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">err</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">tpmResp</span>.<span style="color:#a6e22e">OverallCode</span> <span style="color:#f92672">==</span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">ratelimitv3</span>.<span style="color:#a6e22e">RateLimitResponse_OVER_LIMIT</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">ErrTPMExceeded</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">nil</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>实际代码中的 protobuf 包名和字段类型应以项目锁定的 Envoy API 版本为准。</p>
<p>适配层的完整处理流程如下：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>1. 从 Authorization/API Key 中识别 tenant
</span></span><span style="display:flex;"><span>2. 从 JSON 请求体中读取 model
</span></span><span style="display:flex;"><span>3. 使用模型对应的 tokenizer 计算 input_tokens
</span></span><span style="display:flex;"><span>4. 根据策略计算 token_cost
</span></span><span style="display:flex;"><span>5. 调用 RLS 检查 RPM
</span></span><span style="display:flex;"><span>6. 调用 RLS 检查 TPM
</span></span><span style="display:flex;"><span>7. 都允许：返回 OK
</span></span><span style="display:flex;"><span>8. 任意超限：返回 429
</span></span></code></pre></div><h2 id="在-envoy-中接入适配层">在 Envoy 中接入适配层</h2>
<p>如果适配层实现 Envoy 的 <code>ext_authz</code> gRPC 协议，可以在 Envoy 中配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">http_filters</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">envoy.filters.http.ext_authz</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">typed_config</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;@type&#34;: </span><span style="color:#ae81ff">type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">transport_api_version</span>: <span style="color:#ae81ff">V3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">grpc_service</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">envoy_grpc</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">cluster_name</span>: <span style="color:#ae81ff">ai_quota_adapter</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">timeout</span>: <span style="color:#ae81ff">0.</span><span style="color:#ae81ff">5s</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">with_request_body</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">max_request_bytes</span>: <span style="color:#ae81ff">1048576</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">allow_partial_message</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">pack_as_bytes</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">failure_mode_allow</span>: <span style="color:#66d9ef">false</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">envoy.filters.http.router</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">typed_config</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;@type&#34;: </span><span style="color:#ae81ff">type.googleapis.com/envoy.extensions.filters.http.router.v3.Router</span>
</span></span></code></pre></div><p>对应的 Cluster：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">clusters</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">ai_quota_adapter</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">type</span>: <span style="color:#ae81ff">STRICT_DNS</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">connect_timeout</span>: <span style="color:#ae81ff">0.</span><span style="color:#ae81ff">25s</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">http2_protocol_options</span>: {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">load_assignment</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">cluster_name</span>: <span style="color:#ae81ff">ai_quota_adapter</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">endpoints</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#f92672">lb_endpoints</span>:
</span></span><span style="display:flex;"><span>            - <span style="color:#f92672">endpoint</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">address</span>:
</span></span><span style="display:flex;"><span>                  <span style="color:#f92672">socket_address</span>:
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">address</span>: <span style="color:#ae81ff">ai-quota-adapter</span>
</span></span><span style="display:flex;"><span>                    <span style="color:#f92672">port_value</span>: <span style="color:#ae81ff">9000</span>
</span></span></code></pre></div><p>因为 Token 计算需要读取 JSON 请求体，所以需要配置 <code>with_request_body</code>。</p>
<p>注意限制最大请求体，避免攻击者提交超大 Prompt 导致 Envoy 或适配层内存压力。</p>
<h2 id="tpm-应该如何计算">TPM 应该如何计算</h2>
<p>RPM 的定义比较简单，一次请求增加 1。</p>
<p>TPM 更复杂，因为请求发出时通常只知道输入 Token，尚不知道模型最终会产生多少输出 Token。</p>
<h3 id="方案一只计算输入-token">方案一：只计算输入 Token</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>token_cost = input_tokens
</span></span></code></pre></div><p>优点：</p>
<ul>
<li>请求开始前就能精确计算。</li>
<li>实现最简单。</li>
<li>非流式和流式接口都适用。</li>
</ul>
<p>缺点：</p>
<ul>
<li>无法限制输出 Token。</li>
<li>用户可能通过很短的 Prompt 请求大量输出。</li>
</ul>
<p>适合将 TPM 定义为“输入 Token 配额”的系统。</p>
<h3 id="方案二预留最大输出-token">方案二：预留最大输出 Token</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>token_cost = input_tokens + requested_max_output_tokens
</span></span></code></pre></div><p>例如：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>input_tokens = 1200
</span></span><span style="display:flex;"><span>max_tokens   = 4096
</span></span><span style="display:flex;"><span>token_cost   = 5296
</span></span></code></pre></div><p>优点：</p>
<ul>
<li>在调用模型前就能做严格准入控制。</li>
<li>不会因为输出 Token 未知而突破额度。</li>
<li>适合流式响应。</li>
</ul>
<p>缺点：</p>
<ul>
<li>实际只输出 300 Token 时仍预留了 4096 Token。</li>
<li>如果不实现退款，额度利用率会偏低。</li>
</ul>
<p>这是最容易实现“严格不超额”的方案。</p>
<p>网关必须对 <code>max_tokens</code> 设置服务端上限，不能完全信任客户端：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>effective_max_tokens = min(client_max_tokens, server_model_limit)
</span></span></code></pre></div><p>否则客户端可以提交异常大的 <code>max_tokens</code>，让自己或整个租户迅速耗尽配额。</p>
<h3 id="方案三先预留再按实际用量结算">方案三：先预留，再按实际用量结算</h3>
<p>完整思路是：</p>
<ol>
<li>请求开始时预留：</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>input_tokens + max_output_tokens
</span></span></code></pre></div><ol start="2">
<li>响应结束后得到实际输出 Token。</li>
<li>退还未使用部分：</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>refund = max_output_tokens - actual_output_tokens
</span></span></code></pre></div><p>但 <code>envoyproxy/ratelimit</code> 的标准限流调用主要面向正向增加计数，并不天然提供完整的“预留—提交—退款”事务模型。</p>
<p>如果业务必须支持精确退款，通常需要：</p>
<ul>
<li>在适配层实现额外的 Redis Lua 脚本。</li>
<li>使用独立的预留记录和过期时间。</li>
<li>请求结束后进行 commit/refund。</li>
<li>使用请求 ID 保证幂等。</li>
</ul>
<p>此时 <code>envoyproxy/ratelimit</code> 仍可负责 RPM 或基础 TPM，但结算逻辑已经属于定制配额服务。</p>
<h3 id="方案四流式增量扣费">方案四：流式增量扣费</h3>
<p>对于流式输出，可以：</p>
<ol>
<li>请求开始时扣除输入 Token。</li>
<li>每生成一批输出 Token，再增加一次 TPM 计数。</li>
<li>一旦超过额度，终止上游流。</li>
</ol>
<p>例如每 32 或 128 个 Token 扣一次。</p>
<p>这种方式需要能观察响应流，因此仅靠请求阶段的 <code>ext_authz</code> 不够，需要：</p>
<ul>
<li>Envoy <code>ext_proc</code>；</li>
<li>Wasm Filter；</li>
<li>自定义 HTTP Filter；</li>
<li>或由 AI Gateway 自身代理并读取流式响应。</li>
</ul>
<p>增量批次越小，控制越精确，但 gRPC 和 Redis 调用次数也越多。</p>
<h2 id="多-envoy-实例如何保证共享额度">多 Envoy 实例如何保证共享额度</h2>
<p>假设一个租户拥有：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>RPM = 100
</span></span><span style="display:flex;"><span>TPM = 200,000
</span></span></code></pre></div><p>请求可能随机落到不同 Envoy：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Request 1 -&gt; Envoy A
</span></span><span style="display:flex;"><span>Request 2 -&gt; Envoy B
</span></span><span style="display:flex;"><span>Request 3 -&gt; Envoy C
</span></span></code></pre></div><p>但所有限流请求最终都会访问同一套 Redis 计数器：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ai-gateway / tenant-a / gpt-4.1 / rpm
</span></span><span style="display:flex;"><span>ai-gateway / tenant-a / gpt-4.1 / tpm
</span></span></code></pre></div><p>因此额度是整个集群共享的，而不是每个 Envoy 各有一份。</p>
<p>可以横向扩展的部分包括：</p>
<ul>
<li>Envoy。</li>
<li>AI Quota Adapter。</li>
<li>envoyproxy/ratelimit。</li>
</ul>
<p>不能随意拆分的是同一个配额 Key 的计数状态。如果使用多个完全独立的 Redis，则必须确保相同租户、模型和指标始终路由到同一个分片，否则额度又会被放大。</p>
<h2 id="返回标准限流响应">返回标准限流响应</h2>
<p>发生超限时，适配层应向 Envoy 返回 429：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#66d9ef">HTTP</span><span style="color:#f92672">/</span><span style="color:#ae81ff">1.1</span> <span style="color:#ae81ff">429</span> <span style="color:#a6e22e">Too Many Requests</span>
</span></span><span style="display:flex;"><span>Content-Type<span style="color:#f92672">:</span> <span style="color:#ae81ff">application/json</span>
</span></span><span style="display:flex;"><span>Retry-After<span style="color:#f92672">:</span> <span style="color:#ae81ff">12</span>
</span></span></code></pre></div><p>响应体可以采用 OpenAI 风格：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;error&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;message&#34;</span>: <span style="color:#e6db74">&#34;Rate limit exceeded for tenant tenant-a&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;type&#34;</span>: <span style="color:#e6db74">&#34;rate_limit_error&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;code&#34;</span>: <span style="color:#e6db74">&#34;tpm_limit_exceeded&#34;</span>
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>建议分别暴露 RPM 和 TPM 信息：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Limit-Requests: 60
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Remaining-Requests: 12
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Reset-Requests: 18
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Limit-Tokens: 100000
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Remaining-Tokens: 24580
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-RateLimit-Reset-Tokens: 18
</span></span></span></code></pre></div><p>为了避免客户端形成同步重试风暴，SDK 应使用带随机抖动的指数退避：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>sleep = random(0, min(cap, base × 2^retry_count))
</span></span></code></pre></div><h2 id="精准限流需要明确的边界">“精准限流”需要明确的边界</h2>
<p>使用 Redis 共享计数器可以解决多实例额度放大的问题，但“精准”仍然需要明确具体含义。</p>
<h3 id="1-它通常是固定时间窗">1. 它通常是固定时间窗</h3>
<p>配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">unit</span>: <span style="color:#ae81ff">minute</span>
</span></span></code></pre></div><p>通常表示分钟级固定时间窗，不等同于严格的滑动窗口。</p>
<p>用户可能在一个窗口结束前发送 100 次请求，并在新窗口开始后立刻再发送 100 次。从任意连续 60 秒观察，流量可能接近配置值的两倍。</p>
<p>因此，该方案能够保证：</p>

<blockquote>
  <p>所有 Envoy 实例共享同一个固定时间窗计数器。</p>

</blockquote>
<p>但它不自动等于：</p>

<blockquote>
  <p>任意连续 60 秒都绝不超过限制。</p>

</blockquote>
<p>如果业务要求严格滑动窗口，需要使用 Redis Sorted Set、滑动窗口计数器或专门的配额算法。</p>
<h3 id="2-输出-token-在请求开始时未知">2. 输出 Token 在请求开始时未知</h3>
<p>如果 TPM 包含输出 Token，则无法在生成之前知道最终消耗量。</p>
<p>所谓严格控制通常意味着：</p>
<ul>
<li>使用 <code>max_tokens</code> 预留；或者</li>
<li>在流式生成过程中增量扣费。</li>
</ul>
<p>单纯在响应结束后上报实际 Token，只能用于计费和统计，不能阻止当前请求突破 TPM。</p>
<h3 id="3-rpm-和-tpm-不是一个原子事务">3. RPM 和 TPM 不是一个原子事务</h3>
<p>标准 RLS 请求中的 <code>hits_addend</code> 是统一的，而 RPM 和 TPM 需要不同增量，因此通常需要两次调用。</p>
<p>如果必须保证“RPM 和 TPM 同时检查、同时增加、任意失败都不修改”，需要定制服务并使用单个 Redis Lua 脚本完成事务。</p>
<h2 id="生产环境中的关键问题">生产环境中的关键问题</h2>
<h3 id="1-请求重试和重复扣费">1. 请求重试和重复扣费</h3>
<p>Rate Limit Service 的计数调用通常不是业务级幂等操作。</p>
<p>如果适配层因为超时重试同一个请求：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>第一次调用已经写入 Redis，但响应丢失
</span></span><span style="display:flex;"><span>第二次调用再次增加计数
</span></span></code></pre></div><p>就会产生重复扣费。</p>
<p>建议：</p>
<ul>
<li>限流调用不要盲目自动重试。</li>
<li>使用 Envoy 的 <code>x-request-id</code> 或业务请求 ID。</li>
<li>对严格计费场景，在适配层中实现请求幂等记录。</li>
<li>幂等记录过期时间至少覆盖限流窗口和最大请求时长。</li>
</ul>
<h3 id="2-故障时-fail-open-还是-fail-closed">2. 故障时 Fail Open 还是 Fail Closed</h3>
<p>当 Redis 或 ratelimit 服务不可用时，可以选择：</p>
<p><strong>Fail Open：</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>限流系统不可用时继续放行
</span></span></code></pre></div><p>优点是可用性高，缺点是可能导致成本失控。</p>
<p><strong>Fail Closed：</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>限流系统不可用时拒绝请求
</span></span></code></pre></div><p>优点是保护成本，缺点是限流组件故障会影响业务。</p>
<p>AI 推理通常成本较高，面向外部用户的付费 API 更适合 Fail Closed。内部低风险环境可以考虑短时间 Fail Open，并配合熔断和告警。</p>
<h3 id="3-tokenizer-必须与模型匹配">3. Tokenizer 必须与模型匹配</h3>
<p>不同模型的 Tokenizer 可能不同。不能使用简单的字符数除以固定比例来实现严格 TPM。</p>
<p>应该维护：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>model -&gt; tokenizer
</span></span></code></pre></div><p>例如：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>gpt-family       -&gt; cl100k/o200k 类 tokenizer
</span></span><span style="display:flex;"><span>llama-family     -&gt; 对应 SentencePiece/BPE tokenizer
</span></span><span style="display:flex;"><span>自定义模型        -&gt; 模型实际 tokenizer.json
</span></span></code></pre></div><p>如果 tokenizer 不可用，可以做保守估算，但必须预留误差：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>estimated_tokens = ceil(raw_estimate × safety_factor)
</span></span></code></pre></div><h3 id="4-规范化模型名称">4. 规范化模型名称</h3>
<p>客户端可能使用：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>gpt-4.1
</span></span><span style="display:flex;"><span>gpt-4.1-2025-04-14
</span></span><span style="display:flex;"><span>my-gpt-alias
</span></span></code></pre></div><p>限流前应将它们映射到统一的配额名称：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>my-gpt-alias -&gt; gpt-4.1
</span></span></code></pre></div><p>否则用户可能通过切换别名绕过同一模型的额度。</p>
<h3 id="5-不要信任客户端提供的-token-数">5. 不要信任客户端提供的 Token 数</h3>
<p>不能让客户端直接提交：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-Token-Cost: 1
</span></span></span></code></pre></div><p>然后按该值扣费。</p>
<p>Token 数必须由可信组件计算，或者由上游模型返回后进行校验。客户端声明只能作为参考，不能作为计费依据。</p>
<h3 id="6-配置发布必须一致">6. 配置发布必须一致</h3>
<p>如果多个 ratelimit 副本加载了不同配置，可能出现：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>副本 A：RPM 100
</span></span><span style="display:flex;"><span>副本 B：RPM 200
</span></span></code></pre></div><p>即使 Redis 相同，结果也会变得不可预测。</p>
<p>生产环境应：</p>
<ul>
<li>使用固定版本配置。</li>
<li>进行启动前校验。</li>
<li>滚动发布时保持向后兼容。</li>
<li>为配置添加版本号和监控指标。</li>
</ul>
<h2 id="监控指标">监控指标</h2>
<p>至少应监控：</p>
<h3 id="envoy">Envoy</h3>
<ul>
<li>ext_authz 调用次数。</li>
<li>ext_authz 超时和失败次数。</li>
<li>HTTP 429 数量。</li>
<li>上游请求数和并发数。</li>
<li>请求体缓冲失败次数。</li>
</ul>
<h3 id="ai-quota-adapter">AI Quota Adapter</h3>
<ul>
<li>RPM 允许和拒绝次数。</li>
<li>TPM 允许和拒绝次数。</li>
<li>Token 计算耗时。</li>
<li>RLS gRPC 延迟。</li>
<li>Tokenizer 加载失败次数。</li>
<li>按模型和租户聚合的 Token 消耗。</li>
<li>重复请求或幂等命中次数。</li>
</ul>
<h3 id="ratelimit">Ratelimit</h3>
<ul>
<li><code>OK</code>、<code>OVER_LIMIT</code>、错误数量。</li>
<li>Redis 请求延迟。</li>
<li>Redis 连接池使用率。</li>
<li>配置加载状态。</li>
<li>各限流规则命中次数。</li>
</ul>
<h3 id="redis">Redis</h3>
<ul>
<li>CPU 和内存。</li>
<li>命令延迟。</li>
<li>连接数。</li>
<li>Key 数量。</li>
<li>Key 过期速率。</li>
<li>主从复制延迟。</li>
<li>Failover 次数。</li>
</ul>
<p>高基数租户 ID 不适合直接作为 Prometheus Label。否则租户数量较多时会产生严重的指标基数问题。按租户的详细用量更适合写入日志、ClickHouse 或专门的计量系统。</p>
<h2 id="推荐的最终实现">推荐的最终实现</h2>
<p>对于大多数 AI API 网关，可以采用下面的组合：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Envoy
</span></span><span style="display:flex;"><span>  └── ext_authz
</span></span><span style="display:flex;"><span>        └── AI Quota Adapter
</span></span><span style="display:flex;"><span>              ├── 解析租户和模型
</span></span><span style="display:flex;"><span>              ├── 使用可信 tokenizer 计算 input_tokens
</span></span><span style="display:flex;"><span>              ├── 规范化并限制 max_tokens
</span></span><span style="display:flex;"><span>              ├── RPM：RLS hits_addend = 1
</span></span><span style="display:flex;"><span>              └── TPM：RLS hits_addend =
</span></span><span style="display:flex;"><span>                       input_tokens + max_output_tokens
</span></span><span style="display:flex;"><span>                        └── envoyproxy/ratelimit
</span></span><span style="display:flex;"><span>                              └── Redis
</span></span></code></pre></div><p>这一方案具备以下特征：</p>
<ul>
<li>多 Envoy 实例共享统一额度。</li>
<li>RPM 和 TPM 分别计数。</li>
<li>支持按租户、模型、套餐隔离。</li>
<li>在请求进入模型服务之前完成准入控制。</li>
<li>通过预留最大输出 Token，保证不会因为流式输出突破 TPM。</li>
<li>Envoy、适配层和 ratelimit 服务都可以横向扩展。</li>
</ul>
<p>如果还需要按照实际输出 Token 精确结算，则在此基础上增加：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>预留记录 + 响应用量采集 + commit/refund + 请求幂等
</span></span></code></pre></div><p>此时系统已经从“限流服务”演进为一套完整的“分布式 AI 配额与计量服务”。</p>
<h2 id="总结">总结</h2>
<p><code>envoyproxy/ratelimit</code> 非常适合解决多 Envoy 实例之间的全局共享限流问题。它通过 Redis 集中维护计数器，避免每个网关实例独立计算额度。</p>
<p>对于 AI 场景，需要在标准 HTTP 限流之上增加 Token 感知能力：</p>
<ul>
<li>RPM 使用 <code>hits_addend=1</code>。</li>
<li>TPM 使用 <code>hits_addend=token_cost</code>。</li>
<li>使用租户、模型和指标构建 Descriptor。</li>
<li>请求阶段通过输入 Token或最大输出 Token预留完成准入。</li>
<li>多 Envoy、多个适配层和多个 RLS 副本共享同一个 Redis 计数空间。</li>
</ul>
<p>需要特别注意的是，分布式共享计数、固定时间窗、RPM/TPM 组合事务以及实际输出 Token 结算是四个不同问题。理解这些边界，才能构建一套既能保护模型成本、又能在高并发环境中稳定运行的 AI 限流系统。</p>
]]></content:encoded></item></channel></rss>