Học cách suy luận: Khi LLM dừng đoán chữ và bước vào kỷ nguyên Test-Time Compute

Học cách suy luận: Khi LLM dừng đoán chữ và bước vào kỷ nguyên Test-Time Compute

Mục lục

Tháng 9/2024, OpenAI công bố o1 với một câu mô tả ngắn: model này được train bằng reinforcement learning để suy luận, và nó sinh ra một chuỗi suy nghĩ nội bộ dài trước khi trả lời. Đọc qua thì giống một thông cáo sản phẩm bình thường. Nhưng có một chi tiết kỹ thuật đáng dừng lại lâu hơn phần headline: hiệu năng của o1 tăng khi bạn cho nó nghĩ lâu hơn, và cũng tăng khi bạn train nó lâu hơn. Hai trục compute hoàn toàn khác nhau, không phải một.

Đây là chỗ mà phần lớn bài viết về o1 trượt. Người ta đặt 74% AIME của o1 cạnh 12% của GPT-4o rồi kết luận “model mới mạnh hơn”. Đúng, nhưng vô nghĩa về mặt kỹ thuật. Câu hỏi thật sự là: compute được tiêu ở đâu, ai trả hoá đơn cho phần compute đó, và cái hoá đơn ấy thay đổi kiến trúc backend của bạn như thế nào.

Bài này đi vào ba thứ: đường cong scaling thứ hai nằm ở inference, vòng lặp RL bên trong model, và phần đau nhất khi đưa reasoning model vào production - latency tính bằng phút thay vì bằng mili giây.

Bức tường của pre-training không phải là tin đồn

Trước khi nói về test-time compute, phải nói rõ vì sao nó xuất hiện. Không phải vì ý tưởng hay, mà vì đường cũ bắt đầu hẹp.

Scaling law cổ điển (Kaplan et al., 2020) nói rằng loss giảm theo power law khi tăng tham số, dữ liệu và compute. Chinchilla (Hoffmann et al., 2022) chỉnh lại tỷ lệ: để compute-optimal, bạn cần khoảng 20 token cho mỗi tham số. Một model 70B cần cỡ 1,4 nghìn tỷ token. Model 400B cần cỡ 8 nghìn tỷ. Và đây là chỗ toán học gặp địa chất: lượng text chất lượng cao trên internet là hữu hạn. Các ước lượng nghiêm túc nhất rơi vào khoảng vài nghìn tỷ token sau khi lọc trùng và lọc rác, và bạn không thể lấy thêm bằng cách crawl nhanh hơn.

Hệ quả là muốn tăng compute gấp đôi theo cách cũ, bạn phải tìm thêm gấp đôi dữ liệu. Không có. Bạn có thể train nhiều epoch trên cùng dữ liệu, nhưng sau 4 epoch thì return giảm rất nhanh, thậm chí overfit. Bạn có thể dùng synthetic data, nhưng synthetic data từ model cũ có vấn đề riêng về distribution collapse.

Nói cách khác: trục “model to hơn, data nhiều hơn” vẫn còn hoạt động, nhưng ROI mỗi đô la bỏ vào đang tệ đi. Và đó chính là động lực để OpenAI thử một trục khác.

Đường cong thứ hai: trả tiền để model nghĩ lâu hơn

Ý tưởng của test-time compute không mới. Nó có từ self-consistency (Wang et al., 2022): sample nhiều câu trả lời rồi bầu chọn theo đa số. Nó có trong các bài về process reward model (Lightman et al., “Let’s Verify Step by Step”, 2023): chấm điểm từng bước suy luận thay vì chỉ chấm đáp án cuối. Và nó có trong bài của Snell et al. (2024) chỉ ra rằng với một số bài toán, tiêu thêm compute lúc inference còn hiệu quả hơn là tăng tham số model.

Cái mới của o1 là biến việc đó thành một năng lực nằm trong model, chứ không phải một kỹ thuật đắp bên ngoài.

graph TD
    subgraph "Đường cong 1 - Pre-training, train-time compute"
        P1["Tăng compute gấp đôi"] --> P2["Loss giảm theo power law"]
        P2 --> P3["Cần thêm gấp đôi token"]
        P3 --> P4["Data wall - gần hết text chất lượng cao"]
        P4 --> P5["ROI giảm dần, nhưng không về 0"]
    end

    subgraph "Đường cong 2 - Test-time compute, inference"
        T1["Tăng thinking token"] --> T2["Độ chính xác tăng trên bài toán khó"]
        T2 --> T3["Wall-clock tăng tuyến tính"]
        T3 --> T4["Chi phí output tăng"]
        T4 --> T5["Buộc phải thiết kế lại backend"]
    end

    P5 -.->|"Động lực chuyển dịch"| T1

Hai đường cong này không thay thế nhau. Chúng cộng vào. Một model dở vẫn dở dù nghĩ 10 phút, vì nó không có kiến thức nền để suy luận. Nhưng một model tốt bị giới hạn bởi một lượt forward pass thì có thể tiến bộ rất xa nếu được phép sinh thêm vài nghìn token suy luận.

Bảng eval trong phần appendix của bài công bố cho thấy độ dốc đó rõ đến mức khó tin:

BenchmarkGPT-4oo1-previewo1
AIME 2024, pass@19,344,674,4
AIME 2024, cons@6413,456,783,3
Codeforces, percentile11,062,089,0
GPQA Diamond, pass@150,673,377,3
MATH, pass@160,385,594,8
MMLU, pass@188,092,390,8

Nhìn dòng AIME. Đây là kỳ thi mà model cũ giải được trung bình 1,8/15 bài, tức khoảng 12% như OpenAI nêu trong bài - bảng appendix ghi 9,3 pass@1 và 13,4 cons@64, chênh lệch do cách lấy mẫu. o1 giải 11,1/15 với một sample duy nhất. Đẩy lên 64 sample rồi bầu chọn theo consensus thì được 12,5/15. Rerank 1.000 sample bằng một learned scoring function thì được 13,9/15, tức là vào top 500 học sinh toàn nước Mỹ và vượt cutoff của USA Mathematical Olympiad.

Ba con số đó tương ứng với ba mức chi phí rất khác nhau, và đây là chỗ cần tỉnh táo.

74% là con số của một lần gọi model, chỉ tốn thêm thinking token. 83% là 64 lần gọi. 93% là 1.000 lần gọi cộng thêm một verifier. Nếu bạn copy con số 93% vào slide pitch deck rồi thiết kế hệ thống dựa trên nó, bạn đang thiết kế cho một workload có chi phí gấp ba bậc so với cái bạn tưởng. Trong thực tế, 64 sample chỉ hợp lý khi bài toán có giá trị cao và không cần trả lời trong vài giây: định tuyến thuế, kiểm tra hợp đồng, một bài toán tối ưu chạy batch qua đêm. Với chatbot hỏi đáp, 1 sample là tất cả những gì bạn có thể biện minh.

Còn một chi tiết nhỏ nhưng đáng chú ý: dòng MMLU. o1-preview được 92,3, còn o1 được 90,8. Con số giảm. Reasoning training không miễn phí. Nó dồn năng lực của model vào các bài cần suy luận nhiều bước, và đôi khi trả giá bằng một chút ở các bài thiên về recall kiến thức thuần. o1 vẫn vượt GPT-4o trên 54/57 subcategory MMLU, nhưng việc nó thua chính bản preview ở một benchmark tổng hợp là lời nhắc rằng đây vẫn là trade-off, không phải phép màu.

Bên trong vòng lặp: RL dạy model cách tự sửa sai

Phần thú vị nhất của bài công bố là đoạn model tự nhận ra mình sai. OpenAI đưa một ví dụ giải mật mã thay thế, và chuỗi suy luận của o1-preview đọc như log debug của một người đang mò:

“First, what is going on here?”

“Wait a minute.”

“Hmm.”

“It seems that the ciphertext words are exactly twice as long as the plaintext words. (10 vs 5, 8 vs 4, 4 vs 2, 8 vs 4)”

“Idea: Maybe we need to take every other letter or rebuild the plaintext from the ciphertext accordingly.”

GPT-4o trên cùng prompt thì đếm được chữ, chia từ, rồi bỏ cuộc và hỏi lại người dùng xin thêm quy tắc. o1-preview thì thử một giả thuyết, thấy không khớp, quay lại, thử giả thuyết khác, và cuối cùng tìm ra quy tắc cộng hai chữ cái liền kề trong bảng chữ.

Những hành vi đó - chia nhỏ bước khó, nhận ra hướng đi sai, backtrack, thử cách khác - không được lập trình. Chúng mọc ra từ áp lực tối ưu.

graph TD
    Q["Bài toán đầu vào"] --> P["Policy LLM sau RL"]
    P --> S["Sinh bước suy luận tiếp theo"]
    S --> D{"Bước này có hợp lý không?"}
    D -->|"Ổn"| N{"Đủ căn cứ để trả lời chưa?"}
    D -->|"Sai hoặc lạc hướng"| B["Backtrack - bỏ nhánh, thử cách khác"]
    B --> S
    N -->|"Chưa"| S
    N -->|"Rồi"| A["Câu trả lời cuối"]
    A --> R["Verifier chấm điểm"]
    R --> U["Policy gradient cập nhật trọng số"]
    U -.->|"Vòng lặp train, offline"| P

Cơ chế cốt lõi gói trong một câu: policy sinh ra một trajectory gồm chuỗi suy luận và câu trả lời, verifier chấm điểm trajectory đó, rồi policy gradient đẩy xác suất của các trajectory dẫn tới đáp án đúng lên và hạ các trajectory sai xuống. Sau vài vòng, model không chỉ học đáp án đúng, nó học luôn hình dạng của quá trình dẫn tới đáp án đúng.

Vì sao SFT không làm được điều này

Supervised fine-tuning cần dữ liệu dạng cặp (input, output lý tưởng). Với reasoning, “output lý tưởng” là một chuỗi 5.000 token suy luận đúng. Không ai viết được hàng triệu chuỗi như vậy, và kể cả viết được thì chúng cũng chỉ là một trong vô số cách giải đúng. SFT trên đó sẽ dạy model bắt chước một phong cách suy luận cụ thể, không dạy nó tìm ra cách suy luận.

RL đảo ngược bài toán: bạn không cần biết quá trình đúng trông như thế nào, bạn chỉ cần biết kết quả đúng hay sai. Với toán thì so đáp án. Với code thì chạy unit test. Với một số bài logic thì kiểm tra ràng buộc. Đây là lý do RL hiệu quả ở những domain có verifier tự động, và cũng là lý do nó khó áp dụng cho văn bản mở, nơi “đúng” không định nghĩa được bằng một hàm.

Bài toán credit assignment

Nếu mọi thứ dễ như vậy thì ai cũng làm được. Vấn đề kỹ thuật thật sự nằm ở chỗ: reward chỉ có một, ở cuối chuỗi 5.000 token. Token thứ 2.317 có đóng góp gì vào việc đáp án cuối đúng? Không ai biết. Đây là long-horizon sparse reward, bài toán khó nhất của RL.

Ba hướng xử lý, và o1 dùng tổng hợp cả ba:

Một là process reward model, chấm điểm từng bước. Đắt vì phải gán nhãn từng bước, nhưng tín hiệu dày hơn nhiều.

Hai là giá trị tương đối giữa các sample trong cùng một batch: nếu 8 rollout của cùng một bài, 3 cái ra đáp án đúng, thì advantage của từng token trong các rollout đúng được đẩy lên. Đây là nền của các thuật toán kiểu GRPO, không cần critic network riêng, tiết kiệm VRAM đáng kể.

Ba là shaping: thưởng cho các hành vi trung gian có tương quan với thành công, ví dụ tự kiểm tra lại kết quả ở cuối chuỗi. Cái này nguy hiểm nhất vì dễ dẫn tới reward hacking: model học cách viết “let me double check” thật nhiều mà không thực sự kiểm tra gì.

Vì sao chain of thought bị ẩn

OpenAI chọn không hiển thị raw chain of thought, chỉ hiển thị bản tóm tắt do model tự sinh. Bài công bố đưa ba lý do: trải nghiệm người dùng, lợi thế cạnh tranh, và khả năng giám sát CoT trong tương lai.

Lý do thứ ba mới là lý do kỹ thuật đáng quan tâm. Nếu CoT được dùng để giám sát model - phát hiện dấu hiệu model đang thao túng người dùng, đang lách chính sách - thì CoT phải phản ánh trung thực những gì model nghĩ. Mà muốn trung thực thì không được train bất kỳ policy compliance nào lên nó, vì như vậy model sẽ học cách che giấu suy nghĩ thật. Kết hợp hai yêu cầu đó: CoT phải tự do, nhưng không thể cho người dùng xem.

Với kỹ sư backend, hệ quả rất thực dụng: đừng xây feature dựa trên việc parse chain of thought. Nó có thể bị thay đổi, bị rút gọn, hoặc biến mất ở model sau. Nếu bạn cần trích xuất lý do, hãy yêu cầu model đưa lý do vào câu trả lời cuối, đừng đọc trộm phần nội bộ.

Hệ quả 1: suy luận là tuần tự, latency không scale ngang

Đây là phần mà mọi bài viết về o1 nên dành nhiều chỗ hơn.

Decode là autoregressive. Token thứ n+1 phụ thuộc token thứ n. Bạn không thể chia 6.000 reasoning token cho 8 GPU rồi lấy kết quả sau 1/8 thời gian. Không có parallel scan nào cứu được điều này ở mức một request đơn lẻ.

Nghĩa là wall-clock của một request xấp xỉ:

latency ≈ prefill_time + (reasoning_tokens + answer_tokens) / decode_speed

Với decode 100 token/giây và 6.000 reasoning token, sàn vật lý là 60 giây. Không phải “chậm”, mà là “không thể nhanh hơn”. Model càng nghĩ nhiều, thời gian càng dài, theo đường thẳng.

Có ba cách duy nhất để giảm con số đó, và cả ba đều có giá:

Giảm số reasoning token. Đây là knob hiệu quả nhất và về sau OpenAI bổ sung tham số reasoning_effort đúng cho mục đích này. Nhưng giảm thì mất độ chính xác, đúng cái bạn đang trả tiền để có.

Tăng decode speed. Cần phần cứng nhanh hơn hoặc batch nhỏ hơn. Batch nhỏ hơn nghĩa là throughput tổng giảm, tức là chi phí mỗi token tăng. Đây là đánh đổi trực tiếp giữa latency và $/token, không có bữa trưa miễn phí.

Chấp nhận và thay đổi UX. Đây là lựa chọn mà hầu hết hệ thống thực tế phải chọn.

Điểm quan trọng: batching cải thiện throughput, không cải thiện latency. Nếu bạn có 100 request cùng lúc, chạy batch 100 thì tổng thời gian gần bằng chạy 1, nhưng mỗi request vẫn chờ đủ 60 giây. Đây là lý do GPU utilization cao và p99 latency thấp là hai mục tiêu loại trừ nhau trong serving reasoning model.

Và đây là chỗ Little’s Law đấm vào mặt bạn:

Concurrency cần thiết = arrival_rate × latency

10 request/giây với latency 300 giây cần 3.000 request in-flight đồng thời. Ba nghìn kết nối đang mở, ba nghìn slot trong connection pool tới provider, ba nghìn bản ghi state đang sống. Nếu bạn đang nghĩ “cứ để HTTP request mở, chờ model trả lời”, con số đó cho bạn biết vì sao nó không hoạt động.

Thêm một tầng nữa: rate limit của provider thường tính theo token/phút chứ không theo request. 10 rps với 8.000 reasoning token mỗi request là 4,8 triệu TPM. Con số đó vượt quota của gần như mọi tier phổ thông, và nó giải thích vì sao reasoning workload cần xin quota riêng trước khi launch chứ không phải sau.

Hệ quả 2: reasoning token là output token, và bạn trả tiền cho token mình không thấy

Đây là cú sốc thứ hai. Reasoning token được tính vào output. Không hiển thị cho người dùng, nhưng có trong hoá đơn, và có trong usage.completion_tokens_details.reasoning_tokens.

Công thức chi phí:

cost ≈ prompt_tokens × p_in + (reasoning_tokens + answer_tokens) × p_out

Với một request điển hình: 2.000 token prompt, 8.000 token suy luận, 600 token trả lời. Tổng 10.600 token, trong đó 81% là suy luận. Nếu p_out gấp 3 đến 5 lần p_in - mức phổ biến ở mọi provider lớn - thì phần suy luận chiếm gần như toàn bộ chi phí.

Nhân lên: một triệu request mỗi tháng với 8.000 token suy luận là 8 tỷ output token. Bạn tự nhân với đơn giá của mình. Con số thường đủ lớn để giết một feature trước khi nó kịp có người dùng.

Hai hệ quả vận hành đi kèm.

Thứ nhất, với o1, chain of thought không được giữ lại giữa các lượt gọi API. Mỗi lượt là một lần suy luận lại từ đầu. Điều này khác hẳn chat model thường, nơi bạn gửi lại toàn bộ history và model chỉ cần sinh thêm vài chục token. Với reasoning model, chi phí hội thoại nhiều lượt tăng theo cấp số nhân chứ không tuyến tính. Nếu sản phẩm của bạn là chat, hãy cân nhắc nghiêm túc việc chỉ bật reasoning cho lượt đầu hoặc cho các câu hỏi được phân loại là khó.

Thứ hai, không có logprobs. Reasoning model không trả về xác suất của token, không cho chỉnh temperature. Nghĩa là bạn không thể dùng confidence score để route. Router phải dựa trên loại task, hoặc trên một verifier độc lập chạy sau khi có kết quả, chứ không thể “tin vào model khi nó tự tin”.

Khi nào không nên dùng reasoning model

Rất nhiều task trông có vẻ cần suy luận nhưng thực chất chỉ cần tra cứu hoặc format lại. Tóm tắt email. Phân loại ticket. Trích xuất field từ PDF. Những việc này mà đẩy qua model nghĩ 60 giây thì bạn vừa chậm gấp hai mươi lần, vừa đắt gấp ba mươi lần, mà chất lượng không khác.

Quy tắc thực dụng: reasoning model có giá trị khi bài toán có nhiều bước phụ thuộc nhau và có đáp án kiểm chứng được. Toán, code, lập luận logic, planning nhiều ràng buộc, debug. Ngoài phạm vi đó, model thường rẻ hơn và nhanh hơn thắng.

Kiến trúc phổ biến nhất trong production là cascade: một model nhỏ xử lý mọi request, chỉ escalate lên reasoning model khi gặp tín hiệu cụ thể - classifier nói task thuộc loại khó, hoặc unit test fail, hoặc verifier báo đáp án không nhất quán. Cascade này cắt được 70-90% chi phí so với việc đẩy tất cả qua model lớn, và nó là lý do các bài benchmark luôn trông đẹp hơn hoá đơn thật.

Kiến trúc backend cho một API cần 5 phút để trả lời

Đến phần thực dụng nhất. Bạn có một endpoint gọi reasoning model, p50 là 45 giây, p99 là 4 phút. Kiến trúc HTTP request-response thông thường sẽ vỡ ở đâu?

Nó vỡ ở tầng load balancer trước tiên. Idle timeout mặc định của nhiều LB là 60 giây, của một số CDN là 100 giây. Request của bạn bị cắt trước khi model trả lời. Sau đó client retry, và vì bạn chưa kịp lưu gì, mỗi lần retry là một lần gọi model mới. Ba lần retry là ba lần đốt tiền cho cùng một câu hỏi. Đây là retry storm, và nó là cách nhanh nhất để biến một sự cố latency thành một sự cố hoá đơn.

Nó vỡ ở tầng deploy: mỗi lần rolling deploy, toàn bộ connection đang chờ bị cắt. Với workload 5 phút, bạn vừa giết một lượng lớn công việc đã trả tiền.

Nó vỡ ở tầng autoscaling: bạn scale theo số request đang xử lý, nhưng với workload này mỗi instance giữ rất ít request đồng thời vì mỗi cái chiếm một slot suốt 5 phút. Số đo đúng là độ sâu queue, không phải RPS.

Giải pháp là tách hẳn vòng đời công việc khỏi vòng đời HTTP request. Pattern quen thuộc: async job, nhưng lần này bắt buộc chứ không phải tối ưu.

graph TD
    C["Client - web, mobile, service nội bộ"]

    subgraph "API Layer - stateless"
        API["API Gateway"]
        H["POST /v1/reasoning-jobs"]
        S["GET /v1/jobs/:id/stream - SSE"]
    end

    subgraph "State Layer"
        DB[("`Postgres - jobs, outbox,
        idempotency key`")]
        Q[["`Redis Streams
        job queue`"]]
    end

    subgraph "Worker Tier"
        W1["`Worker pool - Go hoặc Python
        deadline budget, heartbeat lease`"]
    end

    subgraph "Model Boundary"
        LLM["`Reasoning model API
        30 giây đến 10 phút`"]
    end

    C -->|"1 - tạo job"| H
    H -->|"2 - ghi DB, trả 202 kèm job_id"| DB
    H -->|"3 - enqueue"| Q
    Q -->|"4 - consume"| W1
    W1 -->|"5 - gọi model, giữ lease"| LLM
    LLM -->|"6 - trả kết quả"| W1
    W1 -->|"7 - ghi kết quả"| DB
    DB -->|"8 - đẩy trạng thái"| S
    S -->|"9 - SSE"| C
    W1 -->|"webhook ký HMAC"| C

Bốn quyết định thiết kế trong sơ đồ trên, và lý do của từng cái.

202 thay vì 200. POST chỉ ghi một dòng vào DB rồi trả về ngay. Client có job_id trong vòng 50ms. Không có timeout nào ở tầng LB có thể giết nó.

Idempotency key bắt buộc. Client retry là chuyện bình thường. Không có key thì retry tạo ra hai job, hai lần gọi model, gấp đôi hoá đơn. Khoá này nên là unique index ở tầng DB, không phải check-then-insert ở tầng application, vì check-then-insert thua trong điều kiện race.

Heartbeat lease thay vì timeout cố định. Job đang chạy 8 phút không được để reaper thu hồi và giao lại cho worker khác. Worker phải gia hạn lease định kỳ. Timeout của reaper phải lớn hơn khoảng cách heartbeat ít nhất ba lần, nếu không một lần GC pause là job bị cướp.

Webhook có chữ ký, và polling là fallback. Không phải client nào cũng nhận webhook được, và webhook thì mất. Kết quả nên nằm trong DB ít nhất 24 giờ để client polling muộn vẫn lấy được.

Code Go: enqueue idempotent và worker có lease

package reasoning

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"time"
)

var ErrDuplicate = errors.New("duplicate job")

// Enqueue tạo job theo kiểu idempotent. request_hash có unique index trong
// Postgres, nên client retry sẽ nhận lại đúng job cũ thay vì đốt thêm tiền.
func (s *Service) Enqueue(ctx context.Context, tenantID, prompt string) (*Job, bool, error) {
	sum := sha256.Sum256([]byte(tenantID + "\x00" + prompt))
	hash := hex.EncodeToString(sum[:])

	job := &Job{
		ID:          newULID(),
		TenantID:    tenantID,
		RequestHash: hash,
		Prompt:      prompt,
		Status:      StatusQueued,
		Deadline:    time.Now().Add(12 * time.Minute),
	}

	if err := s.store.InsertJob(ctx, job); err != nil {
		if isUniqueViolation(err) {
			existing, getErr := s.store.GetJobByHash(ctx, tenantID, hash)
			return existing, true, getErr
		}
		return nil, false, err
	}

	// Outbox: enqueue nằm trong cùng transaction với insert ở tầng store.
	if err := s.queue.Publish(ctx, job.ID); err != nil {
		return job, false, err
	}
	return job, false, nil
}
const (
	modelTimeout   = 10 * time.Minute
	leaseTTL       = 45 * time.Second
	heartbeatEvery = 15 * time.Second
	maxInflight    = 64 // chặn trên số kết nối đồng thời tới provider
)

func (w *Worker) process(parent context.Context, id string) {
	// Deadline của job phải nhỏ hơn timeout HTTP client,
	// và timeout HTTP client phải nhỏ hơn lease TTL cộng dồn.
	// Đây là ba tầng deadline lồng nhau, đừng để chúng bằng nhau.
	ctx, cancel := context.WithTimeout(parent, modelTimeout)
	defer cancel()

	job, err := w.store.Claim(ctx, id, leaseTTL)
	if err != nil {
		return // worker khác đã claim, bỏ qua, không phải lỗi
	}

	stop := w.heartbeat(ctx, job.ID, heartbeatEvery)
	defer stop()

	res, err := w.llm.Reason(ctx, job.Prompt, w.model)
	if err != nil {
		if isRetryable(err) && job.Attempts < 3 {
			_ = w.store.Release(ctx, job.ID)
			_ = w.queue.RetryLater(ctx, job.ID, backoff(job.Attempts))
			return
		}
		_ = w.store.Fail(ctx, job.ID, err.Error())
		_ = w.notify.Failed(ctx, job)
		return
	}

	_ = w.store.Complete(ctx, job.ID, res, res.Usage.ReasoningTokens)
	_ = w.notify.Success(ctx, job) // best effort, có retry riêng
}

func (w *Worker) heartbeat(ctx context.Context, id string, every time.Duration) func() {
	t := time.NewTicker(every)
	done := make(chan struct{})
	go func() {
		for {
			select {
			case <-done:
				t.Stop()
				return
			case <-t.C:
				// Gia hạn lease. Lỗi ở đây chỉ log, không kill job,
				// vì một lần lỗi mạng không có nghĩa là job đã chết.
				_ = w.store.ExtendLease(ctx, id, leaseTTL)
			}
		}
	}()
	return func() { close(done) }
}

Code Python: endpoint tạo job và SSE stream

import asyncio
import hashlib
import json

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

app = FastAPI()


class JobRequest(BaseModel):
    prompt: str


@app.post("/v1/reasoning-jobs", status_code=202)
async def create_job(body: JobRequest, request: Request):
    tenant = request.headers["x-tenant-id"]

    # Idempotency-Key do client gửi. Fallback về hash payload để client
    # không có key vẫn không tạo ra job trùng khi retry.
    key = request.headers.get("idempotency-key") or hashlib.sha256(
        (tenant + "\x00" + body.prompt).encode()
    ).hexdigest()

    existing = await jobs.get_by_key(tenant, key)
    if existing is not None:
        return {"job_id": existing.id, "status": existing.status, "replayed": True}

    job = await jobs.create(tenant=tenant, key=key, prompt=body.prompt)
    await queue.put(job.id)
    return {"job_id": job.id, "status": "queued"}


@app.get("/v1/jobs/{job_id}/stream")
async def stream(job_id: str, request: Request):
    async def gen():
        last_seen = None
        while True:
            if await request.is_disconnected():
                return

            job = await jobs.get(job_id)
            if job is None:
                yield 'event: error\ndata: {"error":"not_found"}\n\n'
                return

            # Chỉ đẩy metadata. Không đẩy chain of thought: nó bị ẩn,
            # và có thể đổi định dạng giữa các phiên bản model.
            marker = (job.status, job.elapsed_ms // 2000)
            if marker != last_seen:
                last_seen = marker
                payload = {"status": job.status, "elapsed_ms": job.elapsed_ms}
                yield f"event: status\ndata: {json.dumps(payload)}\n\n"

            if job.status in ("succeeded", "failed"):
                yield f'event: done\ndata: {{"status":"{job.status}"}}\n\n'
                return

            await asyncio.sleep(2)

    return StreamingResponse(
        gen(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )
import asyncio

async def worker(sem: asyncio.Semaphore) -> None:
    while True:
        job_id = await queue.get()          # BRPOP / XREADGROUP
        async with sem:                      # chặn trên số request đồng thời
            job = await jobs.claim(job_id, lease_s=45)
            if job is None:
                continue                     # worker khác đã nhận

            hb = asyncio.create_task(heartbeat(job_id, every_s=15))
            try:
                # timeout phải lớn hơn p99 của model, và nhỏ hơn lease
                # cộng dồn. Ba tầng deadline, đừng để bằng nhau.
                result = await asyncio.wait_for(
                    call_reasoning_model(job.prompt), timeout=600
                )
                await jobs.complete(job_id, result)
                await notify_webhook(job, result)
            except asyncio.TimeoutError:
                await jobs.fail(job_id, "model_timeout")
            except Exception as exc:
                await jobs.fail(job_id, repr(exc))
            finally:
                hb.cancel()

Hai chi tiết nhỏ trong code trên nhưng hay bị bỏ qua. X-Accel-Buffering: no là bắt buộc nếu có nginx phía trước, không có nó thì SSE sẽ bị buffer và người dùng không thấy gì cho tới khi stream kết thúc. Và request.is_disconnected() phải được kiểm tra trong vòng lặp, nếu không mỗi lần user đóng tab là một goroutine hoặc task bị leak cho tới khi job xong.

Bảng timeout, và lý do phải lồng nhau

TầngGiá trịLý do
POST tạo job5 giâychỉ ghi DB, không chờ model
HTTP client gọi provider10 phúttrên p99 của model
Lease TTL45 giây, heartbeat 15 giâyreaper không thu hồi job đang chạy
Kết quả trong DB24 giờclient polling muộn vẫn lấy được
Webhook retry5 lần, backoff 1 đến 300 giâytổng khoảng 10 phút trước khi bỏ

Nguyên tắc: mỗi tầng phải lớn hơn tầng trong nó ít nhất hai lần. Nếu timeout HTTP và timeout của job bằng nhau, bạn sẽ không bao giờ nhận được error message có nghĩa - cái ngoài cùng luôn thắng và luôn thắng bằng một lỗi chung chung.

Những thứ sẽ làm bạn đau đầu trong production

Cost amplification qua retry. Một job 4 phút fail ở giây thứ 239. Nếu retry policy của bạn là “thử lại 3 lần”, bạn vừa trả tiền cho 12 phút compute và không có gì trong tay. Reasoning workload cần retry có ngân sách: đếm tổng reasoning token đã tiêu cho một job, dừng khi vượt trần, không đếm số lần thử.

Circuit breaker cho tiền, không chỉ cho lỗi. Hệ thống circuit breaker thông thường mở khi error rate cao. Với reasoning model, bạn cần một breaker mở khi chi phí mỗi phút vượt ngưỡng, kể cả khi mọi request đều thành công. Một bug ở client gửi lặp request là chuyện xảy ra hàng tuần, và nó không tạo ra error nào cả.

Observability phải đếm reasoning token. Metric quan trọng nhất không phải latency, mà là phân bố reasoning_tokens theo tenant và theo loại task. Một thay đổi nhỏ ở prompt có thể làm model nghĩ gấp ba lần. Không có metric này, bạn chỉ phát hiện ra vào cuối tháng.

Đừng log prompt và output mà không kiểm soát. Reasoning workload thường chứa dữ liệu nhạy cảm vì người ta chỉ đưa bài toán khó cho nó. Kèm theo đó là chi phí lưu trữ: một job có thể sinh ra 10KB log.

Đừng dựa vào việc model luôn trả lời được. o1 giỏi suy luận nhưng vẫn sai, và sai một cách tự tin. Với các domain có hậu quả thật, verifier bên ngoài không phải là tối ưu mà là yêu cầu. Với code thì là unit test. Với số liệu thì là kiểm tra ràng buộc. Với văn bản thì là người review.

Điều đáng nhớ nhất

Test-time compute không thay thế pre-training. Nó là một trục thứ hai, và nó có giới hạn riêng: nghĩ lâu hơn giúp model khai thác kiến thức nó đã có, nhưng không tạo ra kiến thức mới. o1 vẫn không biết giá vàng hôm nay, và nghĩ thêm 10 phút cũng không giúp.

Cái thực sự thay đổi là cách chúng ta phân bổ compute. Trước đây compute dồn hết vào một lần, ở trung tâm dữ liệu, trong vài tháng, để tạo ra một artifact cố định. Bây giờ một phần compute chuyển sang mỗi request, ở rìa hệ thống, trong vài chục giây, và trả tiền theo lần dùng. Về mặt kinh tế, đó là chuyển từ capex sang opex. Về mặt kỹ thuật, đó là chuyển từ bài toán “train model tốt hơn” sang bài toán “thiết kế hệ thống chịu được một dependency có latency tính bằng phút và chi phí biến đổi theo độ khó của câu hỏi”.

Với dân backend, phần khó không nằm ở chỗ gọi API. Nó nằm ở chỗ chấp nhận rằng có một loại công việc trong hệ thống của bạn không thể hoàn thành trong một HTTP request, và thiết kế cho sự thật đó thay vì chống lại nó.

Chia sẻ :

Bài viết liên quan

Lore: Epic Games mở mã nguồn VCS sinh ra để thay Git ở thế giới binary khổng lồ

Lore: Epic Games mở mã nguồn VCS sinh ra để thay Git ở thế giới binary khổng lồ

Repo game không giống repo web. Trong engine cỡ Unreal, phần lớn dung lượng là asset binary: texture, mesh, world partition nặng hàng trăm MB đến vài GB, đổi liên tục bởi cả nghìn artist lẫn developer. Đẩy vào Git là chuỗi ngày đau khổ: Git LFS là thứ bolt-on, artist export texture 2 GB là cả pipeline kéo full file, dedup binary bị giới hạn bởi pack-delta heuristic, partial clone cộng sparse checkout còn experimental, mất mạng là fail.

Đọc thêm
DevOps Is Bullshit: Ảo tưởng 'You build it, you run it' và sự trỗi dậy của Platform Engineering

DevOps Is Bullshit: Ảo tưởng 'You build it, you run it' và sự trỗi dậy của Platform Engineering

Một kỹ sư backend cần IAM role để service của mình đọc được bucket S3 chứa hoá đơn. Anh ta mở Jira, chọn template “Infrastructure Request”, điền 12 trường, gắn label đúng như README hướng dẫn, rồi bấm Create. Ba ngày sau role mới xuất hiện.

Đọc thêm