DEV Community

EME GUG
EME GUG

Posted on

Model Cascading in Practice: Let a Cheap LLM Handle 80% of Requests and Escalate Only When It's Unsure

Tuần này trên Hacker News có một bài khá thú vị với hơn 500 điểm: một model nhỏ, khi không chắc chắn thì "hỏi" model lớn, đạt độ chính xác gần bằng model lớn nhưng chi phí thấp hơn khoảng 4 lần. Nghe giống phép màu, nhưng thật ra đây là một pattern đã có từ lâu: model cascading (hay escalation routing). Mình đã áp dụng nó cho một hệ thống phân loại ticket support và trích xuất dữ liệu hóa đơn. Kết quả: hóa đơn API giảm hơn 60%, còn accuracy gần như không đổi. Bài này chia sẻ cách làm thực tế: đo "độ tự tin" của model, chọn threshold, và những cái bẫy mình đã dính.

Ý tưởng cốt lõi: không phải request nào cũng cần model đắt nhất

Trong một workload thực tế, phần lớn request khá dễ: phân loại email rõ ràng, trích xuất field từ hóa đơn có format chuẩn, trả lời FAQ. Chỉ một phần nhỏ thật sự khó. Nếu gửi tất cả lên model lớn nhất, bạn đang trả tiền "premium" cho cả những việc mà một model 7B–8B chạy local cũng làm tốt.

Cascade hoạt động như sau:

flowchart TD
    A[Request] --> B[Small model: 8B local / cheap API]
    B --> C{Confidence >= threshold?}
    C -- Yes --> D[Trả kết quả ngay]
    C -- No --> E[Large model: frontier API]
    E --> F[Trả kết quả]
    E --> G[Log case khó để fine-tune / eval]

Công thức chi phí rất đơn giản:

cost = cost_small + escalation_rate × cost_large

Nếu model nhỏ rẻ hơn 20 lần và chỉ 15% request bị escalate, chi phí trung bình còn khoảng 20% so với gọi thẳng model lớn. Mấu chốt nằm ở chỗ: làm sao biết model nhỏ đang "không chắc"?

Đo confidence: logprobs là cách rẻ nhất

Hầu hết server tương thích OpenAI API (vLLM 0.6+, llama.cpp llama-server, Ollama bản mới) đều trả về logprobs. Với các task có output ngắn (label, yes/no, một con số), xác suất của token đầu tiên là tín hiệu khá tốt.

Đầu tiên, chạy thử model nhỏ bằng llama.cpp:

# llama.cpp build gần đây, model Qwen2.5-7B-Instruct quantize Q4_K_M
./llama-server -m models/qwen2.5-7b-instruct-q4_k_m.gguf --port 8080 -c 8192 -ngl 99

# Kiểm tra logprobs cho một request phân loại
curl -s http://localhost:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{
  "messages": [{"role": "user", "content": "Phân loại ticket: Tôi bị trừ tiền 2 lần. Trả lời 1 từ: billing, bug, account, other"}],
  "max_tokens": 3, "temperature": 0, "logprobs": true, "top_logprobs": 3
}' | jq '.choices[0].logprobs.content[0].top_logprobs'
Enter fullscreen mode Exit fullscreen mode

Nếu token top-1 có logprob khoảng -0.02 (xác suất ~98%) thì model chắc chắn. Nếu top-1 và top-2 sát nhau, ví dụ billing 0.48 và account 0.41, đó là lúc nên hỏi "đàn anh".

Đây là router mình dùng trong production (đã rút gọn):

import math
from openai import OpenAI  # openai>=1.40

small = OpenAI(base_url='http://localhost:8080/v1', api_key='none')
large = OpenAI()  # frontier model qua API, key lấy từ env

LABELS = ['billing', 'bug', 'account', 'other']

def classify(ticket: str, threshold: float = 0.85) -> dict:
    prompt = f'Phân loại ticket sau. Chỉ trả lời 1 từ trong {LABELS}.\n\n{ticket}'
    resp = small.chat.completions.create(
        model='local',
        messages=[{'role': 'user', 'content': prompt}],
        max_tokens=3, temperature=0, logprobs=True, top_logprobs=5,
    )
    choice = resp.choices[0]
    label = choice.message.content.strip().lower()
    first = choice.logprobs.content[0]
    prob = math.exp(first.logprob)

    # Margin giữa top-1 và top-2: tín hiệu tốt hơn prob đơn thuần
    tops = sorted((math.exp(t.logprob) for t in first.top_logprobs), reverse=True)
    margin = tops[0] - (tops[1] if len(tops) > 1 else 0)

    if label in LABELS and prob >= threshold and margin >= 0.5:
        return {'label': label, 'source': 'small', 'conf': round(prob, 3)}

    big = large.chat.completions.create(
        model='YOUR_LARGE_MODEL',
        messages=[{'role': 'user', 'content': prompt}],
        max_tokens=5, temperature=0,
    )
    return {'label': big.choices[0].message.content.strip().lower(),
            'source': 'large', 'conf': None}
Enter fullscreen mode Exit fullscreen mode

Vài điểm quan trọng:

  • Validate output trước khi tin confidence. Nếu model nhỏ trả về label không có trong danh sách, escalate luôn, khỏi cần xem prob.
  • Dùng margin, không chỉ dùng prob. Một số model bị overconfident: prob 0.9 nhưng sai. Margin giữa top-1 và top-2 ổn định hơn.
  • Ép output về dạng ngắn, có cấu trúc. Logprob của một đoạn văn 300 token gần như vô nghĩa.

Khi không có logprobs: self-consistency

Với output dài hơn (trích xuất JSON, sinh SQL), logprob trung bình không còn đáng tin. Cách thay thế: sample model nhỏ N lần với temperature > 0, nếu các lần trả lời khớp nhau thì chấp nhận, còn nếu lệch thì escalate.

sequenceDiagram
    participant App
    participant Small as Small model
    participant Large as Large model
    App->>Small: sample x3 (temperature=0.7)
    Small-->>App: 3 câu trả lời
    alt Đồng thuận >= 2/3
        App-->>App: dùng kết quả đa số
    else Bất đồng
        App->>Large: gửi request + 3 bản nháp làm context
        Large-->>App: kết quả cuối
    end
import json
from collections import Counter

def extract_invoice(text: str, n: int = 3) -> dict:
    resp = small.chat.completions.create(
        model='local', n=n, temperature=0.7, max_tokens=300,
        response_format={'type': 'json_object'},
        messages=[{'role': 'user', 'content': f'Trích xuất JSON gồm vendor, total, date:\n{text}'}],
    )
    answers = []
    for c in resp.choices:
        try:
            d = json.loads(c.message.content)
            # Chuẩn hóa để so sánh: chỉ so field quan trọng
            answers.append((str(d.get('vendor', '')).strip().lower(),
                            str(d.get('total', '')).replace(',', ''),
                            str(d.get('date', ''))))
        except json.JSONDecodeError:
            continue

    if answers:
        best, votes = Counter(answers).most_common(1)[0]
        if votes >= 2:
            return {'vendor': best[0], 'total': best[1], 'date': best[2], 'source': 'small'}

    drafts = '\n'.join(c.message.content for c in resp.choices)
    big = large.chat.completions.create(
        model='YOUR_LARGE_MODEL', temperature=0,
        response_format={'type': 'json_object'},
        messages=[{'role': 'user', 'content':
            f'Trích xuất JSON gồm vendor, total, date:\n{text}\n\nCác bản nháp (có thể sai):\n{drafts}'}],
    )
    return {**json.loads(big.choices[0].message.content), 'source': 'large'}
Enter fullscreen mode Exit fullscreen mode

Mẹo nhỏ: gửi kèm các bản nháp của model nhỏ cho model lớn. Model lớn thấy ngay chỗ "tranh cãi" nên thường xử lý nhanh và chính xác hơn. Lưu ý là n=3 với llama.cpp sẽ tốn thêm compute local. Nếu GPU yếu thì giảm xuống 2 và yêu cầu khớp 100%.

Chọn threshold bằng dữ liệu, đừng đoán

Đây là bước mọi người hay bỏ qua, và cũng là bước quyết định thành bại. Quy trình của mình:

  1. Lấy 300–500 sample thật, có label đúng (có thể nhờ model lớn gán nhãn rồi review tay).
  2. Chạy model nhỏ trên toàn bộ tập, lưu lại prob, margin và việc nó đúng hay sai.
  3. Sweep threshold từ 0.5 đến 0.99, với mỗi mức tính escalation_rate và accuracy tổng thể (giả định model lớn đúng với các case bị escalate).
  4. Chọn điểm "khuỷu tay": tăng threshold thêm nữa thì accuracy gần như không tăng, còn chi phí lại tăng vọt.

Với bài toán ticket của mình, threshold 0.85 kèm margin 0.5 cho escalation rate khoảng 18%, accuracy chỉ kém 1.2% so với gọi toàn bộ lên model lớn. Lên 0.95 thì accuracy tăng thêm 0.4% nhưng escalation rate nhảy lên 41%, không đáng.

Những cái bẫy mình đã dính:

  • Data drift: threshold calibrate tháng trước có thể sai tháng này, ví dụ khi có sản phẩm mới khiến ticket "lạ" tăng. Hãy theo dõi escalation rate trên dashboard. Nếu nó tăng đột ngột, đó là tín hiệu sớm của drift.
  • Đổi quantization là phải calibrate lại. Q4 và Q8 của cùng một model cho phân bố logprob khác nhau, giống như bài trending về Gemma trả lời khác nhau trên laptop và trên T4.
  • Latency cộng dồn: case bị escalate sẽ chịu latency của cả hai model. Nếu SLA chặt, đặt timeout cho model nhỏ (ví dụ 800ms), quá thời gian thì gọi thẳng model lớn.

Kết luận

Model cascading không phải trick học thuật. Đây là cách thực dụng nhất để cắt chi phí LLM mà không phải hy sinh chất lượng. Những việc nên làm ngay:

  • Audit workload: xem bao nhiêu % request thực ra là task ngắn, có cấu trúc (classification, extraction). Đó là ứng viên số một cho cascade.
  • Bắt đầu với logprobs + margin cho output ngắn, và self-consistency cho output có cấu trúc dài hơn.
  • Luôn validate output (label hợp lệ, JSON parse được) trước khi xét confidence.
  • Calibrate threshold trên 300+ sample có nhãn, chọn điểm khuỷu tay của đường cong cost/accuracy.
  • Log toàn bộ case bị escalate. Đây là dataset quý để fine-tune model nhỏ, giúp escalation rate giảm dần theo thời gian.
  • Monitor escalation rate như một metric production, vì nó vừa phản ánh chi phí vừa là cảnh báo sớm của data drift.

Một model nhỏ biết lúc nào mình không biết có giá trị hơn nhiều so với một model lớn được dùng cho mọi thứ.

Top comments (0)