Mọi tính năng Rails có dùng LLM mà tôi từng ship đều sớm muộn đụng cùng một bức tường: model bạn chọn bị sập, bị rate-limit, hoặc qua một đêm bỗng đắt gấp ba lần. Bạn viết Model.chat(...) gọi tới một nhà cung cấp duy nhất, và giờ chính nhà cung cấp đó trở thành một điểm chết (single point of failure) được nối thẳng vào một request mà người dùng đang chờ. Bài này nói về cái lớp giải quyết chuyện đó — định tuyến qua OpenRouter với một chuỗi fallback rõ ràng và một giới hạn ngân sách, viết thuần Rails. Tôi sẽ đưa bạn một bộ tiêu chí để xếp thứ tự chuỗi, những cái bẫy âm thầm đốt tiền, và một ví dụ mẫu mang tính khái niệm để bạn tự chỉnh theo nhu cầu. Không cần gem. Toàn bộ chỉ là một service object và một file config — và đó chính là điểm mấu chốt.
”Routing” ở đây thực ra là gì
Routing là quyết định model nào xử lý một request cụ thể, được đưa ra ngay tại thời điểm gọi chứ không cứng nhắc nhúng sẵn trong code. OpenRouter là nền tảng tốt cho việc này vì nó phơi ra hàng trăm model sau một API tương thích OpenAI duy nhất và một API key duy nhất. Bạn đổi một chuỗi string, là đổi được model.
Có ba mối quan tâm sống trong lớp này, và tách bạch chúng ra là đáng:
- Selection — với task này tôi muốn model nào?
- Fallback — khi lựa chọn đầu tiên thất bại thì tôi thử gì?
- Budget — khi nào tôi dừng chi tiêu, bất kể đã chọn gì?
Đa số tutorial “AI trong Rails” chỉ nói về selection rồi dừng. Hai cái còn lại mới là chỗ production cắn bạn. Một model tạm thời không khả dụng không phải là trường hợp hiếm gặp; nó là chuyện thường ngày.
Phạm vi của bài này: các lời gọi đồng bộ, một lượt hoặc vài lượt ngắn, xuất phát từ một request Rails hoặc một background job. Streaming và điều phối multi-agent là chuyện hoàn toàn khác.
Cơ chế cốt lõi: một chuỗi có thứ tự kèm cổng ngân sách
Mô hình là một danh sách các ứng viên được thử theo thứ tự, mỗi lời gọi được bọc lại sao cho khi thất bại thì rơi xuống cái kế tiếp, và có một bước kiểm tra chi tiêu trước khi toàn bộ chạy. Chuỗi là dữ liệu, không phải code.
Xếp thứ tự chuỗi theo bộ tiêu chí này. Mỗi tầng trả lời một câu hỏi khác nhau:
| Tầng | Câu hỏi nó trả lời | Lựa chọn điển hình |
|---|---|---|
| Primary | Cái gì cho kết quả tốt nhất cho task này? | Một model cỡ vừa/lớn đủ mạnh |
| Secondary | Cái gì gần tốt bằng nếu cái đầu tiên sập? | Một model tương đương của một vendor khác |
| Floor | Cái gì luôn luôn trả lời được, và rẻ? | Một model nhỏ, rẻ, nhanh |
Quy tắc ít hiển nhiên: secondary của bạn nên là một vendor khác với primary. Nếu primary của bạn là một model Anthropic và upstream Anthropic của OpenRouter đang chập chờn, thì fallback sang một model Anthropic khác chẳng mua được gì cho bạn. Fallback xuyên vendor (cross-vendor) chính là toàn bộ giá trị của cái bảo hiểm này.
Một router tối giản đọc lên là config cộng thêm một vòng lặp:
class LlmRouter
CHAINS = {
default: %w[
anthropic/claude-sonnet-4.5
google/gemini-2.5-flash
meta-llama/llama-3.3-70b-instruct
]
}.freeze
def initialize(chain: :default, budget:)
@models = CHAINS.fetch(chain)
@budget = budget
end
def call(messages:)
raise BudgetExceeded if @budget.exhausted?
@models.each_with_index do |model, i|
return request(model, messages)
rescue Faraday::Error, OpenRouter::ServerError => e
Rails.logger.warn("[llm] #{model} failed: #{e.class}, falling through")
next if i < @models.size - 1
raise
end
end
end
Bước kiểm tra BudgetExceeded ở trên cùng chính là cái cổng. Phần rescue/next chính là cái chuỗi. Mọi thứ còn lại chỉ là chi tiết.
Những cái bẫy âm thầm móc túi bạn
Retry những lỗi không nên retry. Một lỗi 429 hay 503 thì đáng để fallback. Một lỗi 400 — request sai định dạng, context quá dài, nội dung bị lọc — sẽ thất bại y hệt trên mọi model trong chuỗi của bạn. Đi hết cả chuỗi vì một request hỏng làm độ trễ và tỉ lệ lỗi của bạn tăng gấp ba mà chẳng được gì. Hãy rẽ nhánh theo loại lỗi trước khi cho nó rơi xuống bước kế tiếp.
Coi ngân sách như một con số toàn cục duy nhất. Một bộ đếm cho cả app nghĩa là một background job mất kiểm soát có thể vét sạch ngân sách của khung chat tương tác. Hãy scope ngân sách theo đúng cách bạn scope mọi thứ khác trong một app Rails multi-tenant — theo từng org, từng tính năng, từng môi trường. (Nếu bạn đang nghĩ về ranh giới tenant nói chung, row-scoping so với schema so với database-per-tenant là cuộc trò chuyện dài hơn.)
Fallback âm thầm mà không có tín hiệu nào. Nếu bạn tụt từ primary xuống model floor mà người dùng không hề hay biết còn bạn thì không log lại, thì bạn vừa giấu một cú tụt chất lượng sau một dấu tích xanh. Hãy log mọi bước nhảy kèm lý do. Phát ra một metric. Bạn muốn nhận ra khi primary của mình đã sập suốt cả tiếng đồng hồ.
Đặt chuỗi vào đường nóng (hot path) của một web request. Ba lời gọi model tuần tự, mỗi cái timeout ở 30 giây, là một request 90 giây giữ chặt một Puma thread làm con tin. Việc LLM dài hoặc dễ thất bại thì thuộc về một job. Đánh đổi Sidekiq so với Solid Queue càng quan trọng hơn một khi lời gọi LLM trở thành loại job chiếm phần lớn — chúng chậm và chúng hay thất bại, gây áp lực lên hàng đợi theo một kiểu khác hẳn so với một con mailer.
Mô hình này trông ra sao trong thực tế
Hình dung một tính năng SaaS soạn nháp một câu trả lời cho tin nhắn của khách hàng. Đây là cách tôi sẽ đấu nối phần routing, ở mức khái niệm — không client, không con số bịa ra, chỉ là cái hình hài.
Tính năng gọi một job, không gọi thẳng model. Job khởi tạo router với một ngân sách được scope theo tenant và chuỗi :default:
class DraftReplyJob < ApplicationJob
def perform(message_id)
message = Message.find(message_id)
budget = LlmBudget.for(message.organization)
router = LlmRouter.new(chain: :default, budget: budget)
draft = router.call(messages: build_prompt(message))
message.update!(suggested_reply: draft.content)
budget.charge!(draft.cost_usd)
end
end
Ba thứ ở đây xứng đáng với chỗ đứng của chúng.
Ngân sách được lấy theo từng organization, nên một tenant ồn ào không thể vét cạn của một tenant yên tĩnh. LlmBudget.for chỉ là một dòng (row) với một mức trần hằng tháng và một tổng đang chạy — một bộ đếm Postgres, chẳng có gì cao siêu.
Router trừ ngân sách bằng chi phí đã được giải quyết lấy từ response sau khi lời gọi thành công, khép lại cái vòng mà Callout phía trên đã cảnh báo.
Và toàn bộ chạy trong một job, nên một fallback chậm chỉ làm giảm chất lượng một bản nháp chạy nền, chứ không làm chậm việc tải trang. Người dùng thấy chữ “đang soạn…” lâu hơn một chút, chứ không phải một cái spinner treo cứng trình duyệt.
Router là cái bảo hiểm rẻ nhất tôi từng viết. Nó là một mảng config và một mệnh đề rescue, và nó là khác biệt giữa “tính năng AI đang chết” với “tính năng AI hơi ngơ đi một tiếng đồng hồ”.
Nếu bạn đang thêm cái này vào một app vốn đã có các lời gọi LLM rải rác khắp nơi, thì việc di trú mang tính cơ học: tìm mọi lời gọi nhà cung cấp trực tiếp, định tuyến chúng qua một service object duy nhất, xóa đám logic retry bị lặp lại. Checklist của tôi cho việc đưa AI vào một app Rails sẵn có bao quát phần việc xung quanh.
”Xong” trông như thế nào
Bạn có một router đáng để ship khi tất cả những điều này đều đúng:
- Mọi lời gọi model trong app đều đi qua một object duy nhất. Không còn một cú
HTTP.postlạc loài tới một nhà cung cấp nằm trong controller. - Chuỗi trải qua ít nhất hai vendor, để một sự cố upstream đơn lẻ không thể quật ngã cả tính năng.
- Một bước kiểm tra chi tiêu chạy trước lời gọi, được scope theo một đơn vị hợp lý (tenant, tính năng), và ngân sách được trừ theo chi phí đã giải quyết sau đó.
- Mọi bước nhảy fallback đều được log kèm lý do và được phơi ra dưới dạng một metric mà bạn thực sự sẽ nhìn vào.
- Các lỗi không nên retry (4xx mà không phải 429) bị ngắt mạch ngay thay vì đi hết cả chuỗi.
- Toàn bộ chạy ngoài đường nóng của web request khi độ trễ hoặc thất bại là điều có khả năng xảy ra.
Để ý là không cái nào trong số này đòi hỏi một gem, một framework, hay một sự lệ thuộc vào vendor nào sâu hơn mức “chúng ta dùng OpenRouter làm cổng vào”. Đó là chủ ý. Router đủ nhỏ để đọc hết trong một lần ngồi, nghĩa là nó cũng đủ nhỏ để debug lúc 2 giờ sáng.
Khi nào nên bỏ qua chuyện này
Nếu bạn chỉ gọi LLM một lần, trong một công cụ admin nội bộ, và thất bại chỉ đồng nghĩa với việc bạn tự retry bằng tay — thì đừng dựng chuỗi làm gì. Một lời gọi đơn kèm một timeout là đủ. Lớp routing này chỉ kiếm được chỗ đứng khi lời gọi hướng tới người dùng, lặp lại thường xuyên, hoặc chạy không giám sát trong một job.
Tương tự, nếu bạn đã cam kết gắn với hệ sinh thái của một vendor vì những lý do vượt ngoài chất lượng model — chủ quyền dữ liệu (data residency), một hợp đồng, một tính năng chỉ riêng họ có — thì fallback xuyên vendor là vô nghĩa, và OpenRouter chỉ là một bước nhảy thừa. Cứ route thẳng. Mô hình này là bảo hiểm; hãy bỏ qua nó khi chẳng có gì để mà bảo hiểm.
Phần có thể kiểm chứng
Đây là một khẳng định bạn có thể đem ra kiểm thử ngay trên app của mình: trong số những lỗi LLM bạn sẽ thấy qua một tháng chạy production, phần lớn sẽ là lỗi thoáng qua — rate limit và timeout upstream — và một chuỗi fallback xuyên vendor sẽ hấp thụ chúng mà không ai phải để ý. Số ít không được hấp thụ chính là các lỗi 4xx bad-request, và đó là bug của bạn, không phải của nhà cung cấp. Nếu error budget của bạn đang bị các lỗi 400 ngốn sạch, thì chẳng chuỗi nào cứu được bạn đâu, và việc nên làm là đi đọc lại đám prompt của mình.