Usage, quota, rate limit
Tổng quan
Làm cho mức tiêu thụ đo được và giới hạn được. Ghi một dòng usage_records cho mỗi lần gọi model (kèm cache token và mục đích của lần gọi, để tách được chi phí của summarizer và subagent), tính giá từ một bảng giá do config điều khiển ra micro-cent dạng số nguyên, áp quota theo tháng cho từng tenant (kiểm tra trước khi chạy run và hạch toán sau khi run xong), và rate limit theo từng API key bằng token bucket trong tiến trình cộng một bộ dọn dẹp (sweeper) theo TTL. Chi phí đã tính được đưa vào cost_details của Langfuse để nhìn thấy chi phí ngay cạnh trace đã sinh ra nó.
Yêu cầu
Chức năng
- Phụ thuộc là `[9, 10, 12]`: purpose `subagent` đến từ phase 9, purpose `summarize` và đường compaction đến từ phase 10, còn model và khả năng của provider thứ hai đến từ phase 12.
usage_records: một dòng cho mỗi lần gọi provider, gồm tenant, agent, session, run, turn, provider, model, purpose (main|summarize|subagent), số token input/output/cache-read/reasoning, cache-write tách thành hai nhóm 5m và 1h, chi phí tính bằng micro-cent, vàcreated_at.- Bảng giá lấy từ config: với mỗi provider+model có giá input, output, cache-read, cache-write-5m và cache-write-1h trên mỗi triệu token (Anthropic tính giá hai loại TTL khác nhau; gộp chúng lại sẽ âm thầm tính sai giá của mọi run có cache), cùng hành vi
unknown modeltường minh (ghi lại số token, chi phí bằng 0, log một lần, tăng một counter). - Chi phí là micro-cent kiểu
bigint; không dùng số thực dấu phẩy động ở bất kỳ đâu trên đường đi của tiền. tenant_quotas: giới hạn token và chi phí theo tháng cùngmax_concurrent_runs. Được kiểm tra lúc tạo run: vượt quota thì trả 402 kèm mức dùng hiện tại; một run đã bắt đầu thì không bao giờ bị quota giết giữa chừng một turn.- Các endpoint tổng hợp usage:
GET /v1/usage?from=&to=&group_by=agent|model|purpose|day(trong phạm vi tenant) vàGET /v1/admin/usage?tenant_id=cho platform principal. - Rate limit: bộ giới hạn
x/time/ratetheo từng API key với rate và burst cấu hình được, một map chia shard có sweeper theo TTL để loại các entry nhàn rỗi, và các response 429 mang headerRetry-AftervàX-RateLimit-*. - Giới hạn đồng thời: một tenant không được vượt quá
max_concurrent_runsrun đang chạy; tạo run vượt mức trả 429 với lý do rõ ràng, khác với 429 của rate limit.
Phi chức năng
- Việc ghi usage nằm trên đường găng (critical path) của run nhưng không bao giờ được làm run thất bại: lỗi ghi được log, đếm và thử lại một lần, sau đó bỏ.
- Kiểm tra quota là một query tổng hợp có index duy nhất trên tháng hiện tại, cache theo tenant trong
HANGO_QUOTA_CACHE_TTL(mặc định 10s). - Rate limiter trong tiến trình là một giới hạn single-replica đã được ghi lại; tài liệu học nói chính xác cái gì hỏng khi có hai replica và cái gì thay thế nó.
- Test cô lập chéo tenant cho
usage_recordsvàtenant_quotas.
Kiến trúc
// internal/usage/record.go
type Purpose string // "main" | "summarize" | "subagent"
type Record struct {
TenantID, AgentID, SessionID, RunID uuid.UUID
Turn int
Provider string
Model string
Purpose Purpose
// Red team: F13 — cache writes are priced per TTL bucket.
Input, Output, CacheRead, CacheWrite5m, CacheWrite1h, Reasoning int
CostMicroCents int64
}
// internal/usage/pricing.go
type Price struct { // micro-cents per million tokens. Red team: F13
InputPerMTok, OutputPerMTok, CacheReadPerMTok int64
CacheWrite5mPerMTok, CacheWrite1hPerMTok int64
}
type Pricing struct{ table map[string]Price } // key "provider/model"
func (p *Pricing) Cost(r Record) (int64, bool) // false = unknown model
// internal/usage/quota.go
type Quota struct {
MonthlyTokens, MonthlyCostMicroCents int64
MaxConcurrentRuns int
}
type Checker struct{} // cached per-tenant month aggregate
func (c *Checker) Allow(ctx context.Context) (Decision, error) // called at run creation
type Decision struct {
Allowed bool
Reason string // "monthly_tokens" | "monthly_cost" | "max_concurrent_runs"
Used, Limit int64
}
// internal/server/ratelimit.go
type Limiter struct{} // sharded map[keyID]*entry{limiter, lastSeen}; TTL sweeper via safego
func (l *Limiter) Allow(keyID uuid.UUID) (ok bool, retryAfter time.Duration, remaining int)-- migrations/00009_usage_and_quotas.sql
create table usage_records (
id uuid primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
agent_id uuid not null references agents(id) on delete cascade,
session_id uuid not null references sessions(id) on delete cascade,
run_id uuid not null references runs(id) on delete cascade,
turn int not null default 0,
provider text not null,
model text not null,
purpose text not null check (purpose in ('main','summarize','subagent')),
input_tokens int not null default 0,
output_tokens int not null default 0,
cache_read_tokens int not null default 0,
cache_write_5m_tokens int not null default 0, -- Red team: F13
cache_write_1h_tokens int not null default 0, -- Red team: F13
reasoning_tokens int not null default 0,
cost_micro_cents bigint not null default 0,
created_at timestamptz not null default now()
);
create index usage_records_tenant_created_idx on usage_records (tenant_id, created_at desc);
create index usage_records_tenant_run_idx on usage_records (tenant_id, run_id);
create table tenant_quotas (
tenant_id uuid primary key references tenants(id) on delete cascade,
monthly_tokens bigint not null default 0, -- 0 = unlimited
monthly_cost_micro_cents bigint not null default 0, -- 0 = unlimited
max_concurrent_runs int not null default 4,
rate_per_minute int not null default 60,
burst int not null default 20,
updated_at timestamptz not null default now()
);Instrumentation
- Span
chatcó thêmhango.cost.micro_centsvàhango.usage.purpose;langfuse.observation.cost_detailsđược đặt từ bản ghi đã tính giá (dưới dạng JSON tường minh, vì việc Langfuse tự suy ra các key cache-token của semconv chưa được kiểm chứng) vàlangfuse.observation.usage_detailsđược đối chiếu với đúng các con số đó. - Span gốc
invoke_agentcủa run có thêm các tổng:hango.cost.micro_cents,hango.usage.input_tokens,hango.usage.output_tokens, vàlangfuse.trace.metadata.cost_micro_cents. - Span:
usage.record(INTERNAL, mỗi lần ghi) vàquota.check(INTERNAL, vớihango.quota.reason,hango.quota.used,hango.quota.limit,hango.quota.cache_hit). - Metric: counter
hango.usage.tokens(attributegen_ai.request.model,gen_ai.token.type,purpose), counterhango.usage.costtính bằng micro-cent (attributemodel,purpose), counterhango.quota.rejections(attributereason), counterhango.ratelimit.rejections, gaugehango.ratelimit.tracked_keys, counterhango.usage.record_failures, counterhango.pricing.unknown_model(attributemodel). - Trường log: các lần từ chối do quota và rate limit được log ở mức warn kèm tenant id, reason, used và limit — hai sự kiện mà người dùng sẽ hỏi đến nhiều nhất.
Các file liên quan
Tạo mới
internal/usage/record.go,internal/usage/pricing.go,internal/usage/pricing_test.go,internal/usage/quota.go,internal/usage/quota_test.go,internal/usage/recorder.go(đường ghi, có retry và metric).internal/server/ratelimit.go,internal/server/ratelimit_test.go(dùngtesting/synctestđể có timing bucket xác định).internal/store/pg/usage.go,internal/store/pg/quota.go,internal/store/pg/usage_test.go.internal/server/handlers_usage.go,internal/server/handlers_quotas.go(platform principal đặt quota).migrations/00009_usage_and_quotas.sql.docs/learning/13-usage-quotas-rate-limits.md.
Sửa
internal/agent/loop.go— sau mỗi lần gọi model, dựng mộtusage.Recordvới turn và purpose rồi giao cho recorder; thêm các attribute chi phí vào span.internal/agent/mwsummarize.go,internal/agent/mwsubagent.go— gắn đúngPurposecho các lần gọi provider của chúng qua context.internal/server/handlers_runs.go— kiểm tra quota và concurrency trước khi insert; các đường 402 và 429.internal/server/auth_middleware.go— áp limiter theo từng key sau khi xác thực, với rate/burst theo từng tenant lấy từtenant_quotas.internal/app/app.go— dựng pricing từ config, limiter cùng sweeper của nó, và quota checker.internal/config/config.go—HANGO_PRICING(bảng JSON),HANGO_QUOTA_CACHE_TTL,HANGO_RATELIMIT_DEFAULT_RPM,HANGO_RATELIMIT_DEFAULT_BURST,HANGO_RATELIMIT_ENTRY_TTL.internal/store/pg/tenancy_test.go— thêmusage_records,tenant_quotas.docs/system-architecture.md,docs/learning/README.md.
Các bước triển khai
- Viết
migrations/00009_usage_and_quotas.sqlvà các store usage và quota có scope, gồm query tổng hợp theo tháng (sum(input+output) tokens,sum(cost)), query tổng hợp theo từng run, và các biến thể group-by cho endpoint báo cáo. - Viết
internal/usage/pricing.go: parseHANGO_PRICINGdưới dạng{"anthropic/claude-x": {"input": 300, "output": 1500, "cache_read": 30, "cache_write_5m": 375, "cache_write_1h": 600}}tính bằng micro-cent trên mỗi triệu token ;Costtính chỉ bằng số nguyên (tokens * pricePerMTok / 1_000_000, làm tròn half-up); model không biết thì trảfalse. - Viết
internal/usage/recorder.go:Record(ctx, r)tính giá bản ghi, ghi vào DB, đặt các attribute span và metric; khi ghi lỗi thì log, tănghango.usage.record_failures, thử lại một lần rồi bỏ mà không làm run thất bại. - Truyền
Purposequa context để các child loop của summarizer và subagent tự ghi purpose của mình mà loop không cần biết ai gọi nó. - Viết
internal/usage/quota.go:Checkercache tổng hợp tháng theo từng tenant trong khoảng TTL, cộng một query đếm run đang chạy cho concurrency;Allowtrả về mộtDecisionchỉ rõ giới hạn nào đang chặn. - Nối việc kiểm tra vào lúc tạo run:
Decision.Allowed == falsetrả 402 cho các lý do quota và 429 cho concurrency, cả hai kèm thân JSON mang reason, used và limit. - Viết
internal/server/ratelimit.go:Nshard khóa theo key id đã băm, mỗi shard là mộtmap[uuid.UUID]*entryđược mutex bảo vệ;Allowdùnglimiter.Reserve()để suy raRetry-After; một sweeper chạy bằngsafegoloại các entry nhàn rỗi quá TTL và cập nhậthango.ratelimit.tracked_keys. Rate và burst theo từng tenant được đọc từtenant_quotasqua một cache ngắn. - Áp limiter trong auth middleware sau khi xác định principal (để giới hạn theo từng key chứ không theo IP) và đặt các header
X-RateLimit-Limit,X-RateLimit-RemainingvàRetry-After. Việc này là bổ sung cho, chứ không thay thế, limiter trước xác thực của phase 2, thứ bảo vệ đường đi chưa xác thực. - Viết các handler báo cáo usage và handler đặt quota của platform. Handler đặt quota gọi
SetAuditAction(c, "quota.update", "tenant_quota", tenantID)và ghi giới hạn cũ và mới vàodetail, để một tenant đột nhiên không còn bị rate limit sẽ có một nguyên nhân có tên. - Thêm
langfuse.observation.cost_detailsvà các attribute chi phí ở cấp run. - Test: toán số nguyên của pricing gồm làm tròn và cache token; model không biết thì ghi token với chi phí bằng 0 và tăng counter; tổng hợp usage theo run khớp với tổng các bản ghi (integration); từ chối quota lúc tạo run với đúng status và reason; một run đã bắt đầu không bị giết khi vượt quota giữa chừng (test khẳng định nó chạy xong); giới hạn concurrency; rate limiter dưới
testing/synctestkhẳng định burst, refill vàRetry-After; sweeper loại entry; các ca chéo tenant củausage_recordsvàtenant_quotas. - Cập nhật tài liệu và viết tài liệu học.
Dàn ý tài liệu học
docs/learning/13-usage-quotas-rate-limits.md phải có:
- Khái niệm: vì sao quota của LLM về bản chất là hạch toán sau (post-hoc) (số token chỉ có sau khi gọi xong) và điều đó có ý nghĩa gì với thiết kế thực thi.
- Lựa chọn thiết kế: một bản ghi cho mỗi lần gọi model kèm tag purpose, micro-cent dạng số nguyên, pricing do config điều khiển, kiểm tra trước run nhưng hạch toán sau, token bucket theo từng key với dọn dẹp theo TTL, ngữ nghĩa 402 và 429 tách biệt.
- Đánh đổi: reserve-then-reconcile (hình dạng của goclaw) so với check-then-record (của hango); limiter trong tiến trình so với bộ đếm Postgres so với Redis; giới hạn theo key so với theo tenant so với theo IP; cache tổng hợp quota so với query mỗi lần chạy.
- Quy tắc cho đường đi của tiền: không bao giờ dùng số thực, luôn dùng số nguyên, và vì sao micro-cent
bigintở cả DB lẫn Go là tổ hợp an toàn. - Giới hạn single-replica, nêu chính xác: hai replica làm rate limit hiệu dụng tăng gấp đôi và cả hai có thể cùng nhận một run ở sát ngưỡng quota; thứ thay thế là bộ đếm Postgres với
INSERT ... ON CONFLICThoặc một shared store. - So sánh với goclaw: catalog giá trong
internal/usagevớiUsageReserveRequest→UsageReservationResult→UsageReconcileRequest, tức là reserve-then-reconcile với một ước lượng token trước khi gọi, đòi hỏi một bộ đếm token trên đường găng. - So sánh với deepagents: hoàn toàn không có khái niệm usage, chi phí hay quota — tầng này thuần túy là việc của server, đó là lý do nó không có trong phần nghiên cứu harness.
- Cách debug tầng này:
hango.usage.costvàhango.usage.tokenstách theopurposetrả lời câu hỏi “vì sao tenant này tốn kém” (thường là các lần gọi summarizer hoặc subagent, đúng là lý do purpose là một chiều dữ liệu); các spanquota.checkvớiused/limitgiải thích một lỗi 402 cho người dùng chỉ bằng một ảnh chụp màn hình;hango.ratelimit.rejectionsso vớihango.quota.rejectionsphân biệt hai kiểu lỗi na ná 429; trong Langfuse,cost_detailstrên từng observation quy khoản chi về đúng lần generation. - Vì sao giá cache-write cần các nhóm TTL, và một trường gộp chung gây ra điều gì cho việc đối chiếu hóa đơn hằng tháng.
- Bài tập: đặt một quota rất nhỏ rồi chạm vào nó; đặt giới hạn 1 rpm rồi đọc các header; tính giá một model không biết và xem counter tăng.
- Ngân sách thời gian: 9h cài đặt + 2h tài liệu học.
Tiêu chí thành công
-
go build ./... && go vet ./... && go test ./...đều qua. - Test của pricing bao phủ làm tròn số nguyên, model không biết, và hai nhóm TTL cache-write được tính theo giá riêng của từng nhóm.
-
GET /v1/usage?group_by=purposecho một run có một lần tóm tắt và một lần gọi subagent hiển thị ba purpose khác nhau (integration test). - Tạo run vượt quota trả 402 kèm reason, used và limit; một run đang chạy dở vẫn hoàn tất bình thường (test).
- Giới hạn concurrency trả 429 với reason khác với 429 của rate limit (test).
- Test rate limiter dưới
testing/synctestkhẳng định burst, tốc độ refill vàRetry-After; sweeper loại các key nhàn rỗi. -
usage_recordsvàtenant_quotasnằm trong bộ test chéo tenant. - Đặt quota ghi một dòng
audit_eventsvới actionquota.updatemang giới hạn trước và sau trongdetail. - Trong Grafana, các series
hango.usage.costvàhango.usage.tokenstồn tại và tách theo model và purpose; trong Langfuse, generation hiển thịcost_detailskhớp với micro-cent đã ghi.
Đánh giá rủi ro
| Rủi ro | Khả năng x Tác động | Tín hiệu | Cách xử lý đã định sẵn |
|---|---|---|---|
| Ghi usage thất bại làm mất dữ liệu tính tiền | Trung bình x Cao | hango.usage.record_failures khác 0 | Thử lại một lần, log kèm toàn bộ bản ghi, đếm lại; metric này được đặt alert ở phase 14, và run không bao giờ thất bại vì lỗi ghi usage |
| Bảng giá lệch khỏi giá của provider | Cao x Trung bình | Báo cáo chi phí lệch với hóa đơn | Pricing là config chứ không phải code; hướng dẫn deploy ghi cách cập nhật, và hango.pricing.unknown_model bắt tên model mới ngay lập tức |
| Cache quota cho phép tenant vượt trần | Trung bình x Thấp | Usage cao hơn mức trần một chút | TTL 10s giới hạn mức vượt trong phạm vi các run đang chạy đồng thời; được ghi lại là hành vi chấp nhận |
| Limiter trong tiến trình sai khi nhiều replica | Chắc chắn x Thấp | Chỉ khi nhiều replica, vốn là non-goal | Ghi chính xác trong tài liệu học và docs/system-architecture.md; việc theo sau nằm trong plan.md |
| Số thực lọt vào đường đi của tiền | Trung bình x Cao | Chi phí lệch trong các phép tổng hợp | Micro-cent bigint từ đầu đến cuối; một test kiểu lint grep float trong internal/usage |
| Rate limit áp trước bước auth sẽ giới hạn theo IP | Thấp x Trung bình | Các tenant dùng chung NAT bị chặn chung | Limiter chạy sau khi xác định principal; được khẳng định bằng test thứ tự middleware |
Rollback
goose down một bước sẽ xóa usage_records và tenant_quotas. Tắt quota checker và limiter trong internal/app (cả hai đều chỉ là một lời gọi constructor) đưa server về hành vi của phase 12; việc ghi usage là phần cộng thêm và có thể để bật độc lập với việc thực thi giới hạn.