Token Limit Flow: Từ click của user đến từng byte trong hệ thống
Bài trước mình đã thiết kế hệ thống token limit. Bài này đi sâu hơn: vẽ toàn bộ flow từ lúc user bấm Submit đến lúc token được deduct — cả từ góc UX lẫn góc kỹ thuật.
Podcast · 3 giọng đọc
Trang
Host
Sơn
Curious Coder
Linh
Chuyên gia
Trang · Host · đoạn 1 / 26
Timeline · 26 lượt nói
Token Limit Flow: Từ click của user đến từng byte trong hệ thống
Bài trước mình đã phân tích thiết kế hệ thống token limit — data model, sliding window counter, Redis Lua script. Nhưng có một câu hỏi mà nhiều bạn hỏi lại: "Vậy cụ thể khi user bấm Submit, chuyện gì xảy ra theo đúng thứ tự?"
Bài này trả lời câu hỏi đó. Mình sẽ vẽ toàn bộ flow theo hai góc nhìn song song: những gì user nhìn thấy, và những gì xảy ra bên trong hệ thống tương ứng với từng khoảnh khắc đó.
Toàn cảnh: 5 trạng thái của một user
Trước khi đi vào từng flow chi tiết, cần hiểu một user trong hệ thống token limit sẽ trải qua 5 trạng thái:
[NORMAL] → [WARNING] → [EXHAUSTED] → [OVERAGE] → [BLOCKED]
↑ |
└──────────── (mua credits / reset kỳ mới) ─────────┘
- NORMAL: Còn quota, dùng bình thường.
- WARNING: Còn ~20% quota — hệ thống bắt đầu cảnh báo.
- EXHAUSTED: Hết quota trong plan.
- OVERAGE: Đang dùng purchased credits.
- BLOCKED: Hết cả quota lẫn credits — không thể tiếp tục.
Mỗi trạng thái có UX và kỹ thuật xử lý khác nhau.
Flow 1: Request bình thường (NORMAL state)
Góc UX
User gõ prompt → bấm Submit → thấy spinner → stream response xuất hiện
→ response hoàn tất → usage bar cập nhật nhẹ
Không có gì đặc biệt. Đây là happy path.
Góc kỹ thuật (Sequence Diagram)
User Frontend API Gateway Quota Service LLM Proxy Model
│ │ │ │ │ │
│── Submit ──▶ │ │ │ │ │
│ │── POST /chat ─▶│ │ │ │
│ │ │── checkAuth ──▶│ │ │
│ │ │ │── getQuota() │ │
│ │ │ │ (Redis GET) │ │
│ │ │ │◀── {used, limit, remaining} ───│
│ │ │ │ │ │
│ │ │◀── {allowed: true, remaining: 85%} ────────────│
│ │ │ │ │ │
│ │ │── proxy req ──▶│ │ │
│ │ │ │── callModel ──▶│ │
│ │ │ │ │── stream ────▶│
│ │ │ │ │◀── chunks ────│
│ │◀── SSE stream──│◀── SSE ────────│◀── SSE ────────│ │
│◀── render ─────│ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │◀── [DONE] ────│
│ │ │ │ │ usage: { │
│ │ │ │ │ input: 512 │
│ │ │ │ │ output: 348 │
│ │ │ │ │ cached: 128 │
│ │ │ │ │ } │
│ │ │ │── logUsage() ──│ │
│ │ │ │ (Postgres INSERT) │
│ │ │ │── deductQuota()│ │
│ │ │ │ (Redis INCR) │ │
│ │◀── usage delta─│◀── push event──│ │ │
│◀── bar update──│ │ │ │ │
Điểm quan trọng:
- Quota check xảy ra trước khi gọi model — fail fast nếu đã hết.
- Token count lấy từ response usage field của model — không tự đếm.
- Deduct xảy ra sau khi stream hoàn tất — post-flight billing.
- Frontend cập nhật usage bar qua push event (SSE hoặc WebSocket) — không phải polling.
Flow 2: Tiệm cận giới hạn (WARNING state)
Góc UX
Usage bar chuyển màu vàng → tooltip: "Còn ~15% quota tuần này"
→ Banner nhỏ xuất hiện: "Bạn đang dùng nhiều. Reset vào thứ 2."
→ Mỗi response xong: badge nhỏ hiện "–240 credits"
User vẫn dùng được bình thường, nhưng hệ thống nudge nhẹ để họ aware.
Góc kỹ thuật
// Sau mỗi deduct, check threshold và emit event nếu cần
async function deductAndNotify(userId: string, credits: number) {
const { remaining, limit } = await deductCredits(userId, credits);
const percentRemaining = remaining / limit;
if (percentRemaining <= 0.20 && percentRemaining > 0.05) {
await notificationQueue.push({
type: 'QUOTA_WARNING',
userId,
percentRemaining,
resetAt: await getNextResetTime(userId),
});
}
// Push real-time update về frontend
await pubsub.publish(`usage:${userId}`, {
remaining,
limit,
percentRemaining,
level: percentRemaining <= 0.20 ? 'warning' : 'normal',
});
}
Notification đi qua queue (không gọi thẳng) để tránh làm chậm main flow. User nhận thông báo async — có thể qua in-app, email, hoặc cả hai tùy preference.
Flow 3: Hết quota — Chuyển sang Overage (EXHAUSTED → OVERAGE)
Đây là flow phức tạp nhất và quan trọng nhất về mặt UX.
Góc UX — Scenario A: Hết giữa chừng một task
User đang stream response dài (refactor cả file) →
response đột ngột dừng ở giữa →
banner đỏ xuất hiện: "Bạn đã dùng hết quota tuần này."
→ Hai nút: [Dùng credits (còn 5,200)] | [Nâng plan]
→ User bấm "Dùng credits" →
response tiếp tục stream từ chỗ dừng (hoặc generate lại)
Góc UX — Scenario B: Hết trước khi bắt đầu
User gõ prompt → bấm Submit →
request bị block ngay lập tức →
modal: "Quota tuần này đã hết. Reset vào thứ 2 (còn 3 ngày)"
→ Hai nút: [Dùng credits] | [Nâng plan] | [Đợi reset]
Góc kỹ thuật
┌─────────────────────────────────────┐
│ Quota Check Layer │
│ │
Request ─────────▶│ getQuota(userId) │
│ ┌──────────────────────────────┐ │
│ │ planQuota: 1,000,000 credits │ │
│ │ used: 1,000,000 credits │ │
│ │ remaining: 0 │ │
│ │ purchasedCredits: 5,200 │ │
│ └──────────────────────────────┘ │
│ │
│ remaining == 0 → check overage │
│ purchasedCredits > 0 → ALLOW │
│ flag: billing_source = 'credits' │
└─────────────────────┬───────────────┘
│
▼
┌─────────────────────────────────────┐
│ LLM Proxy (bình thường) │
└─────────────────────┬───────────────┘
│
▼
┌─────────────────────────────────────┐
│ Post-flight Billing │
│ │
│ billing_source == 'credits' │
│ → deduct from purchasedCredits │
│ → log với flag: overage=true │
│ → emit OVERAGE_USAGE event │
└─────────────────────────────────────┘
Key insight: Hệ thống không phân biệt request từ plan hay từ credits ở LLM layer — chỉ khác ở billing layer. LLM Proxy không cần biết nguồn tiền, chỉ cần biết "allowed hay không".
Flow 4: Mua thêm Credits
Góc UX
User bấm "Mua credits" →
Modal: chọn gói (1,000 / 5,000 / 20,000 credits) →
Redirect sang Stripe Checkout →
Payment thành công →
Redirect về app →
Toast: "5,000 credits đã được thêm vào tài khoản" →
Usage bar cập nhật tức thì
Góc kỹ thuật
User App Stripe Webhook Handler Database
│ │ │ │ │
│─ buy ──────▶│ │ │ │
│ │─ createSession▶ │ │
│ │◀── sessionUrl ─│ │ │
│◀── redirect ─│ │ │ │
│─────────────────────────────▶│ │ │
│ (payment form) │ │ │
│─ submit ────────────────────▶│ │ │
│ │── checkout. │ │
│ │ session. │ │
│ │ completed ────▶│ │
│ │ │── verify sig │
│ │ │── addCredits()│
│ │ │──────────────▶│
│ │ │ │── UPDATE
│ │ │ │ subscriptions
│ │ │ │ SET purchased_credits
│ │ │ │ += 5000
│ │◀── 200 OK ───────│ │
│◀── redirect to app ──────────│ │ │
│ │── push event ──────────────────────────────────── │
│◀── toast ───│ │ │ │
Lưu ý quan trọng: Credits chỉ được add sau khi Webhook từ Stripe xác nhận thành công — không phải sau khi user redirect về. Redirect có thể fail (user đóng tab), nhưng Webhook thì không. Đây là pattern chuẩn của payment flow.
Flow 5: Reset quota định kỳ
Góc UX
Sáng thứ 2 →
User mở app →
Banner xanh: "Quota tuần mới đã reset! Bạn có 1,000,000 credits." →
Usage bar trở về 0%
Đơn giản với user — nhưng phía backend cần xử lý cẩn thận.
Góc kỹ thuật
// Cron job chạy mỗi ngày 00:00 UTC
async function processQuotaResets() {
// Tìm tất cả subscriptions có period_end <= now
const expired = await db
.from('subscriptions')
.select('*')
.lte('period_end', new Date().toISOString())
.eq('status', 'active');
for (const sub of expired) {
await db.transaction(async (trx) => {
// 1. Archive kỳ cũ (không xóa — audit trail)
await trx.from('subscription_history').insert({
user_id: sub.user_id,
period_start: sub.period_start,
period_end: sub.period_end,
quota_used: sub.quota_used_credits,
purchased_credits_used: sub.purchased_credits_consumed,
});
// 2. Tạo kỳ mới
const newPeriodStart = sub.period_end;
const newPeriodEnd = addWeeks(newPeriodStart, 1); // hoặc addMonths
await trx.from('subscriptions').update({
period_start: newPeriodStart,
period_end: newPeriodEnd,
quota_used_credits: 0,
// purchased_credits KHÔNG reset — carry over
}).eq('user_id', sub.user_id);
// 3. Xóa Redis cache cho user này
await redis.del(`quota:${sub.user_id}:*`); // clear all buckets
});
// 4. Notify user
await notificationQueue.push({
type: 'QUOTA_RESET',
userId: sub.user_id,
newQuota: await getPlanQuota(sub.plan_id),
});
}
}
Purchased credits không reset — đây là điểm khác biệt quan trọng với plan quota. Credits là tiền đã mua, carry over sang kỳ sau.
Tổng hợp: State Machine đầy đủ
┌──────────────────────────────────────────┐
│ │
┌───────▼──────┐ quota > 20% ┌──────────┴──────┐
│ BLOCKED │◀──────────────────────│ NORMAL │
└───────┬──────┘ └────────┬────────┘
│ │ quota ≤ 20%
buy │ ▼
credits │ ┌────────────────┐
│ │ WARNING │
│ └────────┬───────┘
│ │ quota = 0
│ ▼
│ ┌────────────────┐
└──────────────────────────────▶│ EXHAUSTED │
└────────┬───────┘
│ has credits
▼
┌────────────────┐
│ OVERAGE │◀─── buy credits
└────────┬───────┘
│ credits = 0
▼
┌────────────────┐
│ BLOCKED │
└────────────────┘
│ reset or buy
▼
NORMAL
Checklist khi implement
Trước khi ship hệ thống này ra production, đây là những điều cần verify:
UX side:
- Usage bar cập nhật real-time sau mỗi response
- Warning notification ở 80% và 95% quota
- Không bao giờ block giữa chừng stream mà không explain lý do
- Credits balance luôn visible trên dashboard
- Projected overage hiển thị nếu burn rate cao
Technical side:
- Quota check là atomic (Lua script hoặc transaction)
- Token count lấy từ model response, không tự đếm
- Credits chỉ add sau Webhook confirm, không sau redirect
- Purchased credits carry over khi reset kỳ mới
- Cron job reset có idempotency (chạy 2 lần không bị double reset)
- Tất cả billing events là append-only trong Postgres
Kết luận
Flow của token limit system không phức tạp ở từng bước riêng lẻ — nó phức tạp ở sự phối hợp giữa các lớp: UX phải phản ánh đúng state kỹ thuật, billing phải chính xác dù network fail, và reset phải idempotent dù cron job chạy trễ.
Nếu bạn đang xây AI SaaS, hãy vẽ state machine này ra trước khi viết một dòng code. Nhiều bug billing nghiêm trọng đến từ việc không nghĩ đủ các edge case ở giai đoạn thiết kế.
Bài tiếp theo mình sẽ đi vào cách test hệ thống này — đặc biệt là cách simulate concurrent sessions và verify không có race condition trong quota enforcement.