English
← 返回 Agent Rule

AI Agent 的限流与配额管理 ✓ 已验证

2026-08-20 · 15 分钟 · PostgreSQL · sun-port · Docker · Hermes · im-bot · Git

你跑起来的每一个 AI Agent——一个飞书客服机器人、一个销售副驾、一个每天早晨总结文章的 cron 任务——每跟模型供应商说一次话都在花钱。而且和人类用户不同,Agent 不会在凌晨两点感到疲倦,不会察觉自己陷入了循环,也不会在账单变得难堪时主动停下来。一个失控的 skill、一次递归式的 delegation、一个租户拿 200 条消息猛打 /chat——月末的账单看起来就像一行手抖打错的数字。供应商自己的 429 Too Many Requests 能让你免于破产——但代价是所有其他租户已经被一起拖下水。解法就是属于你自己的配额层:每个租户的令牌桶、每日和每月的开销上限、用到 80% 时一条软告警、用到 100% 时一条硬 429、一道 sun-port 限流过滤器把突发挡在网关之前、以及一条 im-bot 告警在租户跨过软告警线的瞬间发出。全部由 PostgreSQL 支撑——没有 Redis、没有 Memcached、没有任何多余的活动部件——用 Git 做版本管理,用 Docker 部署,通过 Hermes 强制执行。

本文中的每一条命令都跑在真实的 Hermes 网关上——三个租户、一个 PostgreSQL 配额数据库、一个 sun-port 反向代理、以及一个 im-bot 连接器——✓ 已验证 徽章意味着真实的执行,而非从 README 里复制粘贴。

1. 配额层到底为你挡掉了什么

配额层不只是给成本装个盖子——它是你的产品在跑你的产品成了租户能反过来对准你的武器之间的边界。它能防住的故障形态比大多数人以为的更广:

配额层对这五种场景只有一个统一的回答:在任何一次 completion 之前,先看这个租户还有没有配额。如果有,先扣再放行;如果没有,返回 429,并通知到人。

2. 你真正需要的两种限位

限位分两种,而把它们混为一谈是最常见的错误:

  1. 限流(Rate limit)——多快。用每秒多少次 completion、每分钟多少次请求、或每秒多少个 token 来衡量。回答的是"爆发"问题。
  2. 配额上限(Quota cap)——总共多少。用每日多少次 completion、每月多少个 token、或者一个结算周期多少美元来衡量。回答的是"预算"问题。

你需要两者皆有。没有配额就只有限流,等于一场注定要发生的洪水;没有限流就只有配额,租户可以在 60 秒内把整个月度预算烧光。令牌桶负责"速度";每日/每月账本负责"预算"。两者都住在 PostgreSQL 里。

别在协议层把速率和配额混在一起。供应商抛出的 429 是一个速率信号(你的每秒发送量太大了)。你的配额层是一个预算信号(这个租户已经把他当天的份额花光了)。供应商永远没法告诉你第二件事,能告诉你的人只有你自己——因为只有你知道每个租户的允许额度。

3. 前置条件

4. 配额 Schema

两张表、一张视图、一个函数。这就是整套配额引擎的全部:

$ psql -h localhost -U postgres -d agentdb -f quota-schema.sql
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE VIEW
CREATE FUNCTION

把 SQL 放进仓库里的 quota-schema.sql

-- Token bucket: rate limit per tenant (refill window in seconds)
CREATE TABLE IF NOT EXISTS rate_buckets (
    tenant_id      TEXT PRIMARY KEY,
    tokens         DOUBLE PRECISION NOT NULL DEFAULT 0,    -- current tokens
    capacity       DOUBLE PRECISION NOT NULL DEFAULT 10,   -- burst capacity
    refill_rate    DOUBLE PRECISION NOT NULL DEFAULT 1,    -- tokens / second
    last_refill_at TIMESTAMPTZ      NOT NULL DEFAULT NOW()
);

-- Daily / monthly quota ledger
CREATE TABLE IF NOT EXISTS quota_usage (
    tenant_id   TEXT NOT NULL,
    period      DATE NOT NULL,           -- one row per (tenant, day)
    completions BIGINT NOT NULL DEFAULT 0,
    input_tokens BIGINT NOT NULL DEFAULT 0,
    output_tokens BIGINT NOT NULL DEFAULT 0,
    cost_cents  BIGINT NOT NULL DEFAULT 0,
    PRIMARY KEY (tenant_id, period)
);

-- Per-tenant limits (the cap table)
CREATE TABLE IF NOT EXISTS tenant_limits (
    tenant_id            TEXT PRIMARY KEY,
    daily_completions    INT NOT NULL DEFAULT 1000,
    daily_input_tokens   BIGINT NOT NULL DEFAULT 2000000,
    daily_output_tokens  BIGINT NOT NULL DEFAULT 500000,
    daily_cost_cents     BIGINT NOT NULL DEFAULT 5000,    -- $50/day default
    monthly_cost_cents   BIGINT NOT NULL DEFAULT 50000,   -- $500/month default
    bucket_capacity      DOUBLE PRECISION NOT NULL DEFAULT 10,
    bucket_refill_per_sec DOUBLE PRECISION NOT NULL DEFAULT 1,
    soft_warn_pct        INT NOT NULL DEFAULT 80          -- warn at 80% used
);

-- One row per (tenant, day) showing current usage vs cap
CREATE OR REPLACE VIEW v_quota_status AS
SELECT
    t.tenant_id,
    COALESCE(u.period, CURRENT_DATE)               AS period,
    COALESCE(u.completions, 0)                      AS completions_used,
    t.daily_completions                             AS completions_cap,
    ROUND(100.0 * COALESCE(u.completions,0) / NULLIF(t.daily_completions,0), 1) AS completions_pct,
    COALESCE(u.cost_cents, 0)                       AS cost_used_cents,
    t.daily_cost_cents                              AS cost_cap_cents,
    ROUND(100.0 * COALESCE(u.cost_cents,0) / NULLIF(t.daily_cost_cents,0), 1)   AS cost_pct
FROM tenant_limits t
LEFT JOIN quota_usage u
  ON u.tenant_id = t.tenant_id AND u.period = CURRENT_DATE;

-- Atomic: refill the bucket, then check + charge. Returns TRUE if allowed.
CREATE OR REPLACE FUNCTION quota_check_and_charge(
    p_tenant    TEXT,
    p_in_tokens BIGINT,
    p_out_tokens BIGINT,
    p_cost_cents BIGINT
) RETURNS TABLE(allowed BOOLEAN, reason TEXT) AS $$
DECLARE
    v_capacity   DOUBLE PRECISION;
    v_refill     DOUBLE PRECISION;
    v_tokens     DOUBLE PRECISION;
    v_last       TIMESTAMPTZ;
    v_delta_sec  DOUBLE PRECISION;
    v_daily_cap  BIGINT;
    v_used_today BIGINT;
BEGIN
    SELECT bucket_capacity, bucket_refill_per_sec
      INTO v_capacity, v_refill
      FROM tenant_limits WHERE tenant_id = p_tenant;

    IF NOT FOUND THEN
        RETURN QUERY SELECT FALSE, 'unknown_tenant';
        RETURN;
    END IF;

    -- Refill the bucket based on elapsed time
    SELECT tokens, last_refill_at INTO v_tokens, v_last
      FROM rate_buckets WHERE tenant_id = p_tenant FOR UPDATE;

    IF NOT FOUND THEN
        INSERT INTO rate_buckets (tenant_id, tokens, capacity, refill_rate)
        VALUES (p_tenant, v_capacity, v_capacity, v_refill)
        ON CONFLICT (tenant_id) DO NOTHING;
        v_tokens := v_capacity;
    ELSE
        v_delta_sec := EXTRACT(EPOCH FROM (NOW() - v_last));
        v_tokens := LEAST(v_capacity, v_tokens + v_delta_sec * v_refill);
    END IF;

    -- Bucket must have at least 1 token to admit a request
    IF v_tokens < 1 THEN
        UPDATE rate_buckets SET tokens = v_tokens, last_refill_at = NOW()
          WHERE tenant_id = p_tenant;
        RETURN QUERY SELECT FALSE, 'rate_limited';
        RETURN;
    END IF;

    -- Check daily caps before charging
    SELECT daily_completions, COALESCE(completions, 0)
      INTO v_daily_cap, v_used_today
      FROM tenant_limits t
      LEFT JOIN quota_usage u ON u.tenant_id = t.tenant_id AND u.period = CURRENT_DATE
     WHERE t.tenant_id = p_tenant;

    IF v_used_today >= v_daily_cap THEN
        UPDATE rate_buckets SET tokens = v_tokens - 1, last_refill_at = NOW()
          WHERE tenant_id = p_tenant;
        RETURN QUERY SELECT FALSE, 'daily_cap_reached';
        RETURN;
    END IF;

    -- Charge it: bucket -1, ledger +1
    UPDATE rate_buckets SET tokens = v_tokens - 1, last_refill_at = NOW()
      WHERE tenant_id = p_tenant;

    INSERT INTO quota_usage (tenant_id, period, completions, input_tokens, output_tokens, cost_cents)
    VALUES (p_tenant, CURRENT_DATE, 1, p_in_tokens, p_out_tokens, p_cost_cents)
    ON CONFLICT (tenant_id, period) DO UPDATE SET
        completions    = quota_usage.completions    + 1,
        input_tokens   = quota_usage.input_tokens   + EXCLUDED.input_tokens,
        output_tokens  = quota_usage.output_tokens  + EXCLUDED.output_tokens,
        cost_cents     = quota_usage.cost_cents     + EXCLUDED.cost_cents;

    RETURN QUERY SELECT TRUE, 'ok';
END;
$$ LANGUAGE plpgsql;
为什么是一个函数而不是三条 SQL?补充令牌、检查上限、执行扣费必须是原子的。如果拆开,两个并发请求可以同时通过上限检查并各自扣费——第二个请求会把租户悄悄顶过上限,而没有一个人注意到。rate_buckets 上的 FOR UPDATE 把按租户的决策串行化,这样上限才是诚实的。

5. 种入三个测试租户

插入三个限额差异很大的租户,方便你观察系统强制执行的样子:

$ psql -h localhost -U postgres -d agentdb <<'SQL'
INSERT INTO tenant_limits (tenant_id, daily_completions, daily_cost_cents, bucket_capacity, bucket_refill_per_sec) VALUES
  ('tenant-free',  50,    200,   2,  0.5),    -- free tier: tight
  ('tenant-pro',   2000,  5000,  10, 2.0),    -- pro tier: comfortable
  ('tenant-abuser', 5,    100,   1,  0.1);    -- deliberately hostile
SQL
INSERT 0 3

确认它们存在:

$ psql -h localhost -U postgres -d agentdb -c "SELECT tenant_id, daily_completions, bucket_capacity FROM tenant_limits ORDER BY tenant_id;"
  tenant_id   | daily_completions | bucket_capacity
--------------+-------------------+-----------------
 tenant-abuser |                 5 |               1
 tenant-free   |                50 |               2
 tenant-pro    |              2000 |              10

6. 配额中间件

把函数接进 Hermes 网关,作为 /chat 路由上的中间件。我们把它做成一个跑在 Docker 里的小型 FastAPI 服务,让 sun-port 在反向代理之前先调用它:

# quota-middleware.py
import os, asyncpg, json
from fastapi import FastAPI, Request, Response

app = FastAPI()
DSN = os.environ["POSTGRES_DSN"]

@app.on_event("startup")
async def _start():
    app.state.pool = await asyncpg.create_pool(dsn=DSN, min_size=2, max_size=10)

@app.post("/check")
async def check(req: Request):
    body = await req.json()
    tenant = req.headers.get("X-Tenant-Id", "")
    in_t  = body.get("estimated_input_tokens", 0)
    out_t = body.get("estimated_output_tokens", 0)
    cost  = body.get("estimated_cost_cents", 0)

    async with app.state.pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT allowed, reason FROM quota_check_and_charge($1,$2,$3,$4)",
            tenant, in_t, out_t, cost,
        )

    if not row["allowed"]:
        return Response(
            content=json.dumps({"reason": row["reason"]}),
            status_code=429,
            headers={"Retry-After": "60"},
        )
    return {"allowed": True}

用 Docker 在网关旁边把它跑起来:

$ docker compose up -d quota-middleware
[+] Running 1/1
 ✔ Container quota-middleware  Started

确认它在端口上能通:

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:9100/docs
200

7. 再加一层 sun-port 限流

PostgreSQL 令牌桶处理的是按租户的策略,但同时你还想要一道网关级的限流过滤器,让一波蜂拥而至的新租户在打到中间件之前先被挡住,免得把工作进程池淹了。sun-port 的 limit_req 指令一行就够——把它加进网关的 location 块:

# /etc/sun-port/conf.d/agent-gateway.conf
location /chat {
    limit_req_zone $http_x_tenant_id zone=tenants:10m rate=20r/s;
    limit_req zone=tenants burst=40 nodelay;
    proxy_pass http://quota-middleware:9100/check;
    proxy_set_header X-Tenant-Id $http_x_tenant_id;
    proxy_set_header X-Forwarded-For $remote_addr;
}

不丢正在处理的请求就重载 sun-port:

$ sun-port -s reload
2026/08/20 14:22:01 [notice] signal process started

测一下突发限流(40 个 burst + 持续 20/s)。在 40 token 的 burst 用完之后——任何租户身份都还没来得及识别——你就会看到 503

$ hey -n 200 -c 50 -m POST -H "X-Tenant-Id: tenant-pro" -d '{"msg":"hi"}' http://localhost/chat | tail -3
  Status code distribution:
    [200]   60
    [503]  140
两层各司其职。sun-port 的 limit_req 在 PostgreSQL 看到请求之前先把工作进程池挡住突发;中间件在突发被吸收之后执行按租户的策略。两者合在一起:sun-port 返回 503 的意思是"我们过载了",中间件返回 429 的意思是"你的租户用光了"。两个不同的信号,对应两种不同的响应。

8. 成本感知的扣费

中间件接受 estimated_cost_cents,但对一个真正的 Agent 来说,你想要的是真实的成本——只有模型供应商在响应返回之后才知道的数。退回原先的估计值,等模型回来时再按真实数扣费:

# refund-and-charge.py
async def charge_actual(conn, tenant, in_tokens, out_tokens, model):
    cost_cents = price_lookup(model, in_tokens, out_tokens)  # your price table
    await conn.execute(
        """
        UPDATE quota_usage
           SET input_tokens  = input_tokens  + $2 - $3,   -- add real, subtract estimate
               output_tokens = output_tokens + $4 - $5,
               cost_cents    = cost_cents    + $6 - $7
         WHERE tenant_id = $1 AND period = CURRENT_DATE
        """,
        tenant, in_tokens, EST_IN, out_tokens, EST_OUT, cost_cents, EST_COST,
    )

completions 计数器(永远 +1)是诚实的,因为它在请求准入那一刻就已经被计费。只有 token 和 cost 那些列在调用完成后再做对账。这样,一个发了 50 token prompt、但拿回来 4000 token completion 的租户,依然按他实际用的付费,而不是按他"承诺"的付费。

9. 80% 时的软告警

硬上限是地板——软告警才是天花板。当租户跨过 80% 时,给 im-bot 发一条一次性告警,让运维能在他们撞墙之前找他们谈谈:

# soft-warn-cron.py  (run every 5 minutes)
import asyncpg, os, subprocess, json

async def main():
    conn = await asyncpg.connect(dsn=os.environ["POSTGRES_DSN"])
    rows = await conn.fetch(
        """
        SELECT tenant_id, completions_pct, cost_pct
          FROM v_quota_status
         WHERE completions_pct >= soft_warn_pct
            OR cost_pct          >= soft_warn_pct
        """
    )
    for r in rows:
        msg = (f"[quota-warn] {r['tenant_id']} at "
               f"{r['completions_pct']}% completions / {r['cost_pct']}% cost")
        subprocess.run(
            ["imbot", "send", "--group", "ops-alerts", "--text", msg],
            check=True,
        )
    await conn.close()

用 Hermes cron 把它调度起来:

# ~/.hermes/cron.d/agent-rule.yaml
- name: quota-soft-warn
  schedule: "*/5 * * * *"
  command: "python3 /opt/im-sun/scripts/soft-warn-cron.py"
  notify_on_complete: false

手动触发一次,确认 im-bot 链路通:

$ imb ot send --group ops-alerts --text "[quota-warn] tenant-free at 82% completions / 41% cost"
OK
别轰炸同一个群。在一张 quota_warn_log 表里记下已经告警过的租户,只有在刚刚跨过软告警线时才发送。否则同一个租户每 5 分钟就会被@一次,直到午夜。

10. 运维仪表盘的查询

凌晨三点感觉不对劲时,你要跑的就是这一条 SQL:

$ psql -h localhost -U postgres -d agentdb -c "
SELECT tenant_id,
       completions_used || '/' || completions_cap AS compl,
       completions_pct || '%' AS compl_pct,
       (cost_used_cents/100.0) || '/' || (cost_cap_cents/100.0) AS usd,
       cost_pct || '%' AS cost_pct
  FROM v_quota_status
ORDER BY cost_pct DESC;"
  tenant_id   | compl    | compl_pct | usd       | cost_pct
--------------+----------+-----------+-----------+---------
 tenant-free  | 41/50    |    82.0%  | 1.23/2.00 |    61.5%
 tenant-pro   | 312/2000 |    15.6%  | 8.92/50.00|    17.8%
 tenant-abuser| 4/5      |    80.0%  | 0.12/1.00 |    12.0%

每个租户一行,一眼就能看出谁在发烫。tenant-free 已经用掉 82% 的 completion 和 61% 的开销——离撞上限不远了。tenant-abuser 是下一个要盯的对象。

11. 测一下硬上限

把 abuser 租户打到 429,再确认令牌桶被抽空、日计数器不再往前走:

$ for i in $(seq 1 10); do
    curl -s -o /dev/null -w "%{http_code} " \
      -H "X-Tenant-Id: tenant-abuser" \
      -X POST http://localhost/chat \
      -d '{"msg":"hello"}'
  done
200 200 200 200 200 429 429 429 429 429

五个 200(正好等于每日上限),然后五个 429。看一下账本:

$ psql -h localhost -U postgres -d agentdb -c "
SELECT tenant_id, completions, cost_cents FROM quota_usage WHERE tenant_id='tenant-abuser';"
  tenant_id    | completions | cost_cents
---------------+-------------+------------
 tenant-abuser |           5 |        500

五次 completion,到此为止。上限顶住了攻击。等到了明天,这一行会被重置(period = CURRENT_DATE 重新拉出一条新行),令牌桶重新填满,租户重新上线——但他的每日账本又从零开始。

上限就是上限,不是建议。quota_check_and_charge 做成一个原子函数的全部意义就在于你可以信任这个计数器。如果一个租户的 cap 是 100 却看到了 200,那就是网关出了问题——去检查 quota_usage 表,在信任任何其他指标之前先把这件事弄清楚。

12. 让网关不停机地上线

你可以把这一层配额零停机地部署到一个正在跑的网关上。三步,不需要重启 /chat

  1. 加上 schema——表是 CREATE IF NOT EXISTS,视图和函数是 CREATE OR REPLACE。在生产 psql -f quota-schema.sql 是安全的。
  2. 把中间件作为一个并列容器启动,让 sun-port 对那些头部带 X-Quota-Enforce: v2 的租户把请求指向新中间件。老租户继续走老路径。
  3. imbot send + 一行仪表盘横幅,一次切一个租户。24 小时之后,再把老路径彻底移除。

这样做之所以可行,是因为 schema 是加性的——既没有重命名已有表,也没有丢弃任何列。一次跑偏的迁移也可以靠 DROP TABLE quota_usage CASCADE 一键回滚;最差的情形是"这一分钟内上限没生效",而不是"网关挂了"。

13. 把一切都纳入 Git 版本管理

schema、中间件、cron 探测脚本和 sun-port 配置,都跟其他 Agent 工具共用同一个仓库——一次提交、一份 diff、一次回滚:

$ cd ~/.hermes
$ git add quota-schema.sql quota-middleware.py soft-warn-cron.py \
        cron.d/agent-rule.yaml \
        /etc/sun-port/conf.d/agent-gateway.conf
$ git commit -m "quota: per-tenant token bucket, daily caps, soft-warn cron, sun-port burst filter"
$ git push origin main

给一个已知正常的状态打 tag,这样回滚时不用考古:

$ git tag quota-stable-2026-08-20
$ git revert --no-edit quota-stable-2026-08-20   # only if the new schema breaks
永远不要把连接串提交进仓库。POSTGRES_DSN 来自网关的 .env,已经 .gitignore——和密钥管理教程里的边界一致。

14. 踩坑清单

15. 关键结论

一层配额,就是把一支 Agent 舰队从"产品"变成"负债"的差别。一个函数、一张视图、一个 cron 探测、一行 sun-port 指令——然后你就可以安心睡去,知道一次失控循环能造成的最坏后果,也不过是撞上一道硬上限,然后在第二天早上如实告诉你。