Lưu ý: Bài viết này không có mã nguồn minh họa. Giải pháp được đề cập vẫn đang trong quá trình review nội bộ nên mình chưa thể chia sẻ phần triển khai cụ thể. Đây là bản tóm tắt những gì mình tìm hiểu được trong tuần qua: hệ thống hiện tại hoạt động như thế nào, tại sao nó gặp lỗi, và một giải pháp phù hợp có thể trông ra sao.

Mình đang làm việc trên một ứng dụng chat AI sử dụng Next.js ở frontend, NestJS ở backend và GraphQL để giao tiếp giữa hai bên. Người dùng gửi câu hỏi tới các mô hình ngôn ngữ lớn (LLM) và quan sát câu trả lời được stream từng token theo thời gian thực.

Dưới đây là phần TL;DR:

Mỗi lần deploy phiên bản mới, tất cả các cuộc trò chuyện đang được tạo nội dung đều bị dừng giữa chừng.

Trạng thái stream hiện đang được lưu trong bộ nhớ (memory) của API server, và lời gọi tới LLM cũng chạy ngay trong process đó. Khi deploy, process bị khởi động lại, khiến toàn bộ state bị mất và lời gọi LLM bị hủy giữa chừng. Trình duyệt của người dùng tiếp tục chờ dữ liệu nhưng không nhận được gì, và phần nội dung đã được tạo ra trước đó cũng biến mất hoàn toàn.

Giải pháp là không lưu bất kỳ trạng thái quan trọng nào trong API server nữa.

Chuyển luồng token sang Redis Streams, chuyển việc gọi LLM sang một worker độc lập sử dụng BullMQ, đồng thời cho phép trình duyệt tự động reconnect và tiếp tục từ vị trí đã nhận cuối cùng. Sau khi thực hiện điều này, việc deploy trở nên "vô hình" với người dùng: quá trình sinh nội dung vẫn tiếp tục chạy và trình duyệt chỉ việc bắt kịp những gì đã bỏ lỡ.

Phần còn lại của bài viết sẽ giải thích chi tiết quá trình đi tới kết luận đó.


Cách hệ thống stream chat hiện tại hoạt động

Hệ thống hiện tại được thiết kế để xử lý hai tình huống:

Kịch bản A

Người dùng gửi tin nhắn và ngồi xem phản hồi được stream theo thời gian thực.

Kịch bản B

Người dùng refresh trang hoặc mở tab mới trong khi quá trình sinh nội dung vẫn đang diễn ra, và hệ thống cần gửi lại phần nội dung đã tạo trước đó.

Luồng xử lý chính (kịch bản A) như sau:

Trình duyệt                     API Server
   │                                │
   │  1. issueToken                 │
   │ ──────────────────────────────►│
   │ ◄──────────────────────────────│
   │                                │
   │  2. prepareBackground          │
   │ ──────────────────────────────►│
   │ ◄────────── historyId ─────────│
   │                                │
   │  3. startBackground            │
   │ ──────────────────────────────►│
   │ ◄────────── historyId ─────────│
   │                                │
   │  4. subscribe(continueStream)  │
   │ ──────────────────────────────►│
   │ ◄═══════ tokens stream   ══════│

Một số điểm quan trọng:

1. issueToken

Chỉ dùng để xác thực cuộc hội thoại. Chưa có dữ liệu nào được lưu.

2. prepareBackground

Tạo một bản ghi SessionHistory rỗng trong database và khởi tạo một đối tượng stateToken trong bộ nhớ.

stateToken là nhân vật chính của câu chuyện này:

  • Lưu toàn bộ lịch sử hội thoại hiện tại
  • Lưu các token đã stream
  • Có TTL 15 phút
  • Chỉ tồn tại trong RAM của API server

3. startBackground

Không chờ LLM trả kết quả.

Thay vào đó:

  • Xác thực token
  • Lấy distributed lock theo historyId:model
  • Gọi xử lý AI trong background thông qua setImmediate()
  • Trả về historyId ngay lập tức

4. continueStream

Đây là GraphQL Subscription.

Khi client kết nối:

  1. Server gửi lại toàn bộ token đang được lưu trong stateToken
  2. Sau đó stream các token mới nhận được qua Redis Pub/Sub

Nhờ cơ chế này, client có thể kết nối lại bất kỳ lúc nào mà không bị mất nội dung đã tạo trước đó.


Refresh giữa chừng hoạt động như thế nào?

Khi người dùng refresh trang:

  1. Client gọi checkActiveStreams

  2. Nếu stateToken vẫn còn trong memory:

    • Server trả về danh sách model đang chạy
    • Trả về phần nội dung đã được tạo
  3. Client subscribe lại với isReconnecting=true

  4. Server replay toàn bộ token trong stateToken

  5. Sau đó tiếp tục stream dữ liệu mới

Thiết kế này khá gọn gàng.

Nhưng toàn bộ cơ chế phụ thuộc vào việc stateToken còn tồn tại trong memory.

Và đó chính là thứ bị xóa khi deploy.


Vấn đề thực sự

Tóm tắt bằng một câu:

stateToken nằm trong bộ nhớ API server, và job gọi LLM cũng chạy trong chính process đó. Khi deploy, cả hai cùng mất.

┌─────────────┐    ┌─────────────────────────────────────────┐    ┌─────────────────────┐
│   Browser   │    │              API Server                 │    │       Redis         │
│             │    │                                         │    │  (separate server)  │
│             │    │  ┌───────────────────────────────────┐  │    │                     │
│             │    │  │          Server Memory            │  │    │  Pub/Sub channel    │
└──────┬──────┘    │  │  · Token history (in-memory)      │  │    │  (broadcast only,   │
       │           │  │  · AI job (calls LLM, gets tokens)│  │    │   no persistence)   │
       │           │  │  · WebSocket connections          │  │    │                     │
       │ 1. Send   │  └───────────────┬───────────────────┘  │    └──────────┬──────────┘
       │──────────►│                  │                      │               │
       │           │   2. Calls LLM, receives tokens,        │               │
       │           │      stores each token in memory        │               │
       │           │                  │                      │               │
       │           │                  │ 3. Publish token ────┼──────────────►│
       │ 4. Token  │◄─────────────────┼──────────────────────┼───────────────│
       │◄──────────│                  │                      │
       │           └─────────────────────────────────────────┘

Hiện tại API server đang đảm nhiệm đồng thời ba vai trò:

  1. Lưu trạng thái stream trong memory
  2. Gọi LLM và xử lý token
  3. Quản lý kết nối WebSocket/GraphQL Subscription
Trình duyệt
   │
   ▼
API Server
 ├─ StateToken (memory)
 ├─ AI Job (LLM call)
 └─ Kết nôi WebSocket
   │
   ▼
 Redis Pub/Sub

Khi deploy:

Deploy mới
   │
   ├─ API server khởi động lại
   │
   ├─ stateToken bị xóa
   ├─ LLM job bị kill
   └─ WebSocket bị ngắt

Kết quả:

  • Trình duyệt vẫn chờ dữ liệu
  • Không có token nào tới nữa
  • Người dùng phải refresh thủ công
  • Phần nội dung đã sinh ra bị mất hoàn toàn

Giải pháp đề xuất

Ý tưởng không có gì quá mới.

Theo nguyên lý của 12-Factor App:

  • Process nên stateless
  • Tiến trình chạy lâu nên được xử lý bởi background worker

Hiện tại API server đang vi phạm cả hai nguyên tắc.

Giải pháp đề xuất gồm:

BullMQ + Redis Streams + State lưu trong Redis + Client Auto-Reconnect

Kiến trúc mới:

┌─────────────┐         ┌─────────────────┐        ┌──────────────────────┐
│   Browser   │         │   API Server    │        │   BullMQ Worker Pod  │
│             │         │  (stateless)    │        │   (independent)      │
└──────┬──────┘         └────────┬────────┘        └──────────┬───────────┘
       │                         │                            │
       │  1. Send chat message   │                            │
       │ ───────────────────────►│                            │
       │                         │  2. Add job to BullMQ      │
       │                         │ ──────────────────────────►│
       │  3. Return historyId    │                            │  4. Call LLM API
       │ ◄───────────────────────│                            │ ──────────►
       │                         │                            │
       │  5. Subscribe to stream │                            │  6. Receive tokens
       │ ───────────────────────►│                            │ ◄──────────
       │                         │                            │
       │                         │              ┌─────────────▼─────────────┐
       │                         │              │           Redis           │
       │                         │              │  Stream: conv:{historyId} │
       │                         │              │  1000-1 "The "            │
       │                         │              │  1000-2 "capital "        │
       │                         │              │  1000-3 "of "  ◄─ Worker  │
       │                         │◄─────────────│         writes here       │
       │◄────────────────────────│              │  API reads from here      │
       │  7. Receive tokens live │              └───────────────────────────┘

Vai trò của từng thành phần

1. BullMQ

BullMQ là job queue dựa trên Redis.

Thay vì API server gọi LLM trực tiếp:

API
  ↓
LLM

ta chuyển thành:

API
  ↓
Queue
  ↓
Worker
  ↓
LLM

Một phép so sánh dễ hiểu:

  • API Server = nhân viên phục vụ
  • Worker = đầu bếp

Nhân viên chỉ nhận order rồi chuyển xuống bếp.

Nhân viên không phải là người nấu ăn.

Nếu khu vực phục vụ gặp sự cố, nhà bếp vẫn tiếp tục hoạt động.


2. Worker Pod

Worker là process độc lập chuyên:

  • gọi LLM
  • nhận token
  • ghi token vào Redis Stream

Worker không phụ thuộc API Server.

Do đó:

Deploy API
   ↓
Worker vẫn chạy

Nếu triển khai nhiều worker:

  • worker A khởi động lại
  • worker B vẫn xử lý tiếp

Tăng khả năng chịu lỗi đáng kể.


3. Redis Streams

Redis Stream là một append-only log.
Redis stream thay thế cho Redis pub/sub để đơn giản hoá logic khi stream lại từ đoạn bị đứt giữa chừng.

Mỗi token được ghi thành một bản ghi có ID tăng dần:

1000-1  "The "
1000-2  "capital "
1000-3  "of "
1000-4  "France "
1000-5  "is "
1000-6  "Paris"

Điểm quan trọng:

Redis Stream:

  • không thuộc API Server
  • không thuộc Worker

Nó tồn tại độc lập.

Vì vậy dữ liệu không bị mất khi một trong hai thành phần kia khởi động lại.


4. Auto-Reconnect và lastId

Trình duyệt lưu ID cuối cùng đã nhận:

lastId = "1000-47"

trong localStorage.

Khi mất kết nối:

  1. Trình duyệt tự reconnect
  2. Gửi lại lastId
  3. Server đọc Redis Stream từ vị trí đó
  4. Gửi lại những token bị bỏ lỡ

Cơ chế replay hoạt động ra sao?

Trường hợp bình thường

lastId = 0

1000-1
1000-2
1000-3
...

Browser cập nhật lastId liên tục.


Đóng tab rồi mở lại

Tab đóng tại 1000-3

localStorage:
lastId = 1000-3

Mở lại:

resume from 1000-3

Server gửi tiếp:

1000-4
1000-5
1000-6

Người dùng thấy cuộc hội thoại tiếp tục như chưa từng gián đoạn.


Deploy giữa lúc stream

lastId = 1000-47

API restart

Trong lúc đó:

Worker vẫn chạy

1000-48
1000-49
1000-50
...

được ghi tiếp vào Redis Stream.

Sau khi API mới lên:

Browser reconnect
lastId = 1000-47

Server đọc từ:

1000-48

và gửi tiếp.

Người dùng chỉ thấy khoảng dừng khoảng 1 giây.


Sau khi triển khai, một lần deploy sẽ diễn ra thế nào?

Deploy
   │
   ├─ API server khởi động lại
   │
   ├─ Worker vẫn chạy
   ├─ LLM vẫn chạy
   ├─ Redis Stream vẫn ghi token
   │
   └─ Browser reconnect
         ↓
      gửi lastId
         ↓
      tiếp tục stream

Người dùng chỉ thấy một khoảng dừng rất ngắn thay vì mất toàn bộ cuộc hội thoại.


Khả năng mở rộng

Theo số liệu production:

44,885 thực thi/ngày

Giả sử 80% lưu lượng xảy ra trong 4 giờ cao điểm

44,885 * 0.8 / 4 / 60 ≈150 requests/phút

Thời gian stream trung bình:
1–2 phút

Thì streams đồng thời:
150–300

Các job này chủ yếu là:

IO-bound

vì phần lớn thời gian chỉ đang chờ LLM trả token.

BullMQ đặc biệt phù hợp với loại workload này.

Theo tài liệu của BullMQ:

Concurrency 100–300

trên một worker là hoàn toàn khả thi với các tác vụ IO-heavy.

Ước tính:

Concurrent Streams Đánh giá
~300 Dễ dàng xử lý
500 Không vấn đề
5,000 Scale ngang bằng cách thêm worker
50,000+ Cần xem xét Kafka hoặc giải pháp khác

Trước khi BullMQ trở thành nút thắt cổ chai, rất có thể giới hạn của nhà cung cấp LLM hoặc chi phí API sẽ xuất hiện trước. Gọi cùng lúc 50k request thì khả năng cao phía LLM sẽ báo lỗi 429 too many request trước.


Chi phí hạ tầng

Gần như không đáng kể.

Thành phần Chi phí
Redis Không đổi
API Server Nhẹ hơn hiện tại
2 Worker Pods Khoảng 20–50 USD/tháng

Kế hoạch triển khai

Bước 1

Chuyển token sang Redis Streams.

  • Khắc phục mất dữ liệu khi refresh
  • LLM vẫn chạy trong API

Bước 2

Thêm BullMQ Worker.

  • Tách LLM khỏi API
  • Generation sống sót qua deploy

Bước 3

Thêm Auto-Reconnect + lastId.

  • Không cần refresh thủ công
  • Trải nghiệm liền mạch

So sánh ba giai đoạn

Hiện tại Chỉ Redis Streams Giải pháp đầy đủ
Token còn sau deploy
Generation còn sau deploy
Tự động khôi phục
Hỗ trợ phản hồi 25 phút Rủi ro cao Vẫn rủi ro
Chi phí thêm 0 0 ~20–50 USD/tháng

Kết luận

Điều quan trọng nhất rút ra từ quá trình điều tra không phải là BullMQ hay Redis Streams.

Mà là nhận ra nguyên nhân gốc rễ:

API Server đang gánh quá nhiều trách nhiệm cùng lúc.

Nó vừa:

  • phục vụ request
  • chạy LLM
  • lưu trạng thái stream

Ba chức năng này bị gắn chặt vào cùng một process nên cũng thất bại cùng nhau.

Một lần deploy đáng lẽ chỉ nên ảnh hưởng tới việc phục vụ request, chứ không nên giết chết một tác vụ AI đang chạy.

Dù giải pháp cuối cùng có giống hệt đề xuất này hay không, hướng đi đúng vẫn là:

  1. Đưa state ra khỏi process.
  2. Tách job runner khỏi API server.
  3. Để client tự theo dõi vị trí của mình trong stream.

Mỗi bước đều giúp hệ thống ổn định hơn, và khi kết hợp lại, chúng biến một lỗi khó chịu trong production thành một sự kiện mà người dùng gần như không nhận ra.

Tham khảo