你跑起来的每一个 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 强制执行。
配额层不只是给成本装个盖子——它是你的产品在跑和你的产品成了租户能反过来对准你的武器之间的边界。它能防住的故障形态比大多数人以为的更广:
429。你的 SLA 一夜蒸发。配额层对这五种场景只有一个统一的回答:在任何一次 completion 之前,先看这个租户还有没有配额。如果有,先扣再放行;如果没有,返回 429,并通知到人。
限位分两种,而把它们混为一谈是最常见的错误:
你需要两者皆有。没有配额就只有限流,等于一场注定要发生的洪水;没有限流就只有配额,租户可以在 60 秒内把整个月度预算烧光。令牌桶负责"速度";每日/每月账本负责"预算"。两者都住在 PostgreSQL 里。
429 是一个速率信号(你的每秒发送量太大了)。你的配额层是一个预算信号(这个租户已经把他当天的份额花光了)。供应商永远没法告诉你第二件事,能告诉你的人只有你自己——因为只有你知道每个租户的允许额度。tenant_id 各不相同,这样在观察一个租户撞到上限时不会阻塞另两个两张表、一张视图、一个函数。这就是整套配额引擎的全部:
$ 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;
rate_buckets 上的 FOR UPDATE 把按租户的决策串行化,这样上限才是诚实的。插入三个限额差异很大的租户,方便你观察系统强制执行的样子:
$ 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
把函数接进 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
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
limit_req 在 PostgreSQL 看到请求之前先把工作进程池挡住突发;中间件在突发被吸收之后执行按租户的策略。两者合在一起:sun-port 返回 503 的意思是"我们过载了",中间件返回 429 的意思是"你的租户用光了"。两个不同的信号,对应两种不同的响应。中间件接受 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 的租户,依然按他实际用的付费,而不是按他"承诺"的付费。
硬上限是地板——软告警才是天花板。当租户跨过 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 分钟就会被@一次,直到午夜。凌晨三点感觉不对劲时,你要跑的就是这一条 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 是下一个要盯的对象。
把 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 表,在信任任何其他指标之前先把这件事弄清楚。你可以把这一层配额零停机地部署到一个正在跑的网关上。三步,不需要重启 /chat:
CREATE IF NOT EXISTS,视图和函数是 CREATE OR REPLACE。在生产 psql -f quota-schema.sql 是安全的。X-Quota-Enforce: v2 的租户把请求指向新中间件。老租户继续走老路径。imbot send + 一行仪表盘横幅,一次切一个租户。24 小时之后,再把老路径彻底移除。这样做之所以可行,是因为 schema 是加性的——既没有重命名已有表,也没有丢弃任何列。一次跑偏的迁移也可以靠 DROP TABLE quota_usage CASCADE 一键回滚;最差的情形是"这一分钟内上限没生效",而不是"网关挂了"。
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——和密钥管理教程里的边界一致。SELECT tokens FROM rate_buckets 和 UPDATE 不在同一个事务里,两个并发请求可以同时通过校验并各自扣费——你悄悄越过了上限。一个函数、一个事务。last_refill_at——函数在拿行锁时算一次就行,再单独跑一个 refill cron 是浪费 CPU,而且会和函数抢锁。tenant-free 和 tenant-pro 绝不应该共享同一个 daily_completions。建 tenant_limits 表的意义正是让它们各自拥有。limit_req 忘记带 key。如果忘了写 $http_x_tenant_id,所有请求会被塞进同一个共享 zone——你的免费版会被专业版的流量一起拖慢。/check 调用。记得让 logger 把 POSTGRES_DSN 遮蔽掉——和密钥教程里的规则一样。limit_req 吸收网关级的突发;PostgreSQL 函数执行按租户的策略。sun-port 的 503 和中间件的 429 是两种不同的信号。SELECT FROM v_quota_status 就是"现在谁有事"的答案。CREATE IF NOT EXISTS + 一条按头部路由的迁移路径,让你能在不掉一个请求的前提下把它推到一个正在跑的网关上。.env 里。一层配额,就是把一支 Agent 舰队从"产品"变成"负债"的差别。一个函数、一张视图、一个 cron 探测、一行 sun-port 指令——然后你就可以安心睡去,知道一次失控循环能造成的最坏后果,也不过是撞上一道硬上限,然后在第二天早上如实告诉你。