09 · Vận hành agent production — chi phí, độ trễ, độ tin cậy

LLM API là một dependency rất khác database hay REST nội bộ: mỗi lời gọi mất vài giây tới vài phút, tính tiền theo token, bị giới hạn tốc độ theo tổ chức, và trả kết quả khác nhau mỗi lần. Đặt nó vào một hệ Laravel + PHP-FPM mà giữ thói quen "gọi đồng bộ trong request" là cách nhanh nhất để có sự cố lúc 3h sáng. Bài này là phần "SRE cho AI": retry, timeout, streaming, capacity, rate limit, chi phí — phần lớn có lab bằng SDK PHP thật.

Đã verify gì, chưa verify gì. Lab chạy thật với SDK anthropic-ai/sdk 0.52.0 (PHP 8.4.19, Guzzle 8.2.0) gọi mock server Messages API có thể trả 529/429, retry-after, treo, và stream SSE: hành vi retry, timeout, TTFT đã đo. Đọc mã nguồn SDK để xác nhận chính sách retry. Chưa verify: giá trị rate limit thật, header rate limit, Batch API, fallback server-side, fast mode — lấy từ tài liệu tham chiếu Claude API (cache 2026-09-25). Con số capacity/chi phí là tính toán minh hoạ. Không có Context7.

Câu trả lời 60 giây

"Tôi coi mỗi lời gọi LLM là một job chậm, tính tiền, không đáng tin: chạy trong queue chứ không trong request FPM, có timeout ở tầng transport, retry đúng một tầng, ngân sách theo task, và stream để người dùng thấy chữ ngay. Lab của tôi với SDK PHP chính thức cho thấy ba bẫy: tham số timeout của SDK không được áp — nó phụ thuộc HTTP client, Guzzle mặc định chờ vô hạn, server treo 20 giây thì request chờ đủ 20 giây; khi đặt timeout 3 giây ở Guzzle thì SDK retry 2 lần, server nhận 3 request cho một lời gọi; và SDK tuân theo retry-after không giới hạn — 429 với retry-after 12 giây là worker bị khoá 12 giây. Về capacity, Little's law: 20 request/giây × 8 giây mỗi lượt là 160 kết nối đồng thời — không thể để FPM giữ chúng. Về chi phí, đòn bẩy theo thứ tự: prompt cache, chọn model/effort theo route, giới hạn output, Batch API cho việc không gấp."

Thuật ngữ

Thuật ngữNghĩa ngắn
RPM / ITPM / OTPMGiới hạn request / token input / token output mỗi phút, theo tổ chức và theo model.
429 / 529429: bạn vượt rate limit. 529 (overloaded_error): nhà cung cấp đang quá tải.
retry-afterHeader server báo nên chờ bao lâu trước khi thử lại.
jitterThêm ngẫu nhiên vào thời gian chờ retry để các client không cùng thử lại một lúc.
circuit breakerNgắt tạm lời gọi tới dependency đang lỗi để không dồn thêm tải và trả lỗi nhanh.
Little's lawSố việc đang xử lý đồng thời L = tốc độ đến λ × thời gian xử lý W.
Batch APIGửi lô request xử lý bất đồng bộ (tối đa 24 giờ), giá bằng 50%.
FinOpsQuản trị chi phí hạ tầng như một chỉ số sản phẩm: đo, phân bổ, đặt ngân sách.

1. Bài toán gốc và bối cảnh ra đời

Kiến trúc web PHP truyền thống giả định dependency trả lời trong mili-giây: request vào FPM, query DB 5 ms, trả HTML. LLM phá giả định đó theo bốn chiều:

ChiềuDB / REST nội bộLLM APIHệ quả
Độ trễ1–50 ms1–60 s mỗi lời gọi; agent nhiều phútKhông giữ worker FPM được; cần stream và queue
Chi phíCố định theo serverTheo token, tăng theo mức dùngMột vòng lặp lỗi là hoá đơn lỗi; cần ngân sách
Giới hạnConnection pool của bạnRate limit theo tổ chức, chia chung mọi tính năngMột tính năng batch làm tính năng chat bị 429
Độ tin cậyBạn kiểm soátNhà cung cấp quá tải (529), model bị ngừng hỗ trợCần fallback và kế hoạch nâng cấp

Bài này không có "công nghệ ra đời" — nó là tập hợp mẫu vận hành cho một loại dependency mới. Mục tiêu tối ưu: trải nghiệm người dùng (TTFT), chi phí trên mỗi nhiệm vụ hoàn thành, và khả năng chịu lỗi. Không nhằm: giảm latency tổng xuống mức mili-giây (không làm được với LLM lớn).

2. Mô hình tư duy cốt lõi

Mỗi lời gọi LLM là một job từ xa: chậm, có đồng hồ tính tiền, và có thể thất bại. Đối xử với nó như gọi cổng thanh toán hay xử lý video: chạy nền, timeout rõ, retry idempotent một tầng, ngân sách, quan sát được. Latency ≈ TTFT + số token output ÷ tốc độ decode. Chi phí ≈ Σ token × đơn giá. Capacity ≈ λ × W.
flowchart LR
  U["Trình duyệt"] -->|"POST /chat"| C["Controller (FPM): tạo run, dispatch job, trả run_id ngay"]
  C --> Q[("Queue llm (Redis/Horizon)")]
  Q --> W["Worker: gateway LLM (timeout, retry 1 tầng, ngân sách, trace)"]
  W -->|"stream token"| B["Broadcast / SSE (Reverb, process riêng)"]
  B --> U
  W --> API["Messages API"]
  W --> DB[("agent_runs: checkpoint, usage, chi phí")]

3. Internals và quyết định thiết kế

3.1 Retry của SDK — lab

Mã nguồn SDK PHP (BaseClient::shouldRetry, retryDelay): mặc định maxRetries = 2; retry khi lỗi kết nối, 408, 409, 429, ≥500; tôn trọng header x-should-retry; thời gian chờ = retry-after-ms hoặc retry-after nếu server gửi, không thì 0,5 × 2^n giây (tối đa 8 s) trừ ngẫu nhiên tới 25%. Lab với mock server:

=== R1 529 hai lần đầu   control={"fail_first":2,"fail_status":529}   mode=default
OK sau 1.43s, stop_reason=end_turn
    server nhận request #1 t=+ 0.00s retry_count=0 -> 529
    server nhận request #2 t=+ 0.50s retry_count=1 -> 529
    server nhận request #3 t=+ 1.41s retry_count=2 -> 200
=== R2 529 ba lần liền   control={"fail_first":3,"fail_status":529}   mode=default
EXCEPTION sau 1.32s: Anthropic\Core\Exceptions\InternalServerException — Anthropic Internal Server Exception
    server nhận request #1 t=+ 0.00s retry_count=0 -> 529
    server nhận request #2 t=+ 0.40s retry_count=1 -> 529
    server nhận request #3 t=+ 1.31s retry_count=2 -> 529
=== R3 429 + retry-after: 12   control={"fail_first":1,"fail_status":429,"retry_after":12}   mode=default
OK sau 12.01s, stop_reason=end_turn
    server nhận request #1 t=+ 0.00s retry_count=0 -> 429
    server nhận request #2 t=+12.00s retry_count=1 -> 200

3.2 Timeout — lab: tham số timeout của SDK không được áp

=== R4 server treo 20s, mặc định   control={"hang_seconds":20}   mode=default
OK sau 20.01s, stop_reason=end_turn
    server nhận request #1 t=+ 0.00s retry_count=0 -> treo 20s
=== R5 treo 20s, requestOptions timeout=3   control={"hang_seconds":20}   mode=opt-timeout
OK sau 20.01s, stop_reason=end_turn
    server nhận request #1 t=+ 0.00s retry_count=0 -> treo 20s
=== R6 treo 20s, Guzzle timeout=3   control={"hang_seconds":20}   mode=guzzle-timeout
EXCEPTION sau 10.45s: Anthropic\Core\Exceptions\APIConnectionException — Anthropic API Connection Error
    server nhận request #1 t=+ 0.00s retry_count=0 -> treo 20s
    server nhận request #2 t=+ 3.44s retry_count=1 -> treo 20s
    server nhận request #3 t=+ 7.44s retry_count=2 -> treo 20s
=== R7 treo 20s, Guzzle timeout=3, maxRetries=0   control={"hang_seconds":20}   mode=guzzle-timeout-noretry
EXCEPTION sau 3.01s: Anthropic\Core\Exceptions\APIConnectionException — Anthropic API Connection Error
    server nhận request #1 t=+ 0.00s retry_count=0 -> treo 20s
// Đã chạy trong lab (labs/ai/php/retry_timeout.php, mode guzzle-timeout)
$client = new Anthropic\Client(apiKey: env('ANTHROPIC_API_KEY'), requestOptions: [
    'transporter' => new GuzzleHttp\Client(['timeout' => 60.0, 'connect_timeout' => 5.0]),
    'maxRetries'  => 0,        // trong queue job: để job retry (release với backoff), không retry 2 tầng
]);

Chọn timeout theo độ dài output: output 2 000 token ở ~50–100 token/s là 20–40 s chưa kể thinking. Request dài hoặc max_tokens lớn → dùng streaming (timeout áp lên khoảng lặng giữa các chunk thay vì cả request) — tài liệu SDK khuyên stream khi output dài để tránh timeout HTTP.

3.3 Streaming và TTFT — lab

non-stream : chữ đầu tiên hiện ra sau 3.88s, xong sau 3.88s
stream     : chữ đầu tiên hiện ra sau 0.09s, xong sau 3.88s (48 delta)

Tổng thời gian như nhau; thời gian người dùng nhìn màn hình trống giảm từ 3,9 s xuống 0,09 s. Với Laravel: stream từ worker qua broadcast (Reverb/Pusher) hoặc SSE; nếu SSE đi qua nginx thì tắt buffering (X-Accel-Buffering: no), nếu không nginx gom lại và trả một cục (chưa verify trong lab này). Lưu ý: SSE trực tiếp từ FPM giữ một worker suốt thời gian stream — capacity như mục 3.4.

3.4 Capacity: Little's law

L = λ × W. Ví dụ (minh hoạ): giờ cao điểm 20 lượt chat/giây, mỗi lượt 8 giây (TTFT + stream) → 160 lượt đồng thời. Nếu mỗi lượt giữ một worker FPM, cần 160 worker chỉ cho chat — trong khi pool thường 20–50 worker/máy. Một agent 30 giây/lượt ở cùng tải là 600 đồng thời. Kết luận: phần chờ LLM phải nằm ở tiến trình rẻ khi chờ (queue worker, process event-loop như Octane/Swoole/Node, hoặc broadcast server), còn FPM chỉ nhận request và trả run_id. Queue worker cho LLM tách riêng (queue: llm) để không làm nghẽn job gửi mail/đơn hàng; số worker = giới hạn song song mong muốn (cũng là cách chặn bão 429).

3.5 Rate limit

3.6 Đòn bẩy chi phí — theo thứ tự nên làm

#Đòn bẩyTác động điển hìnhĐánh đổi
1Prompt cache (bài 03)Lab: phiên 30 lượt Opus 5.5 $6,03 → $0,86Gần như không — chỉ cần tiền tố ổn định
2Dọn input: tool result gọn, bớt tool không dùng, system prompt tiếng Anh nếu dàiTuỳ, thường 20–50% inputCông sức
3Vòng lặp: max_iter, phát hiện lặp, ngân sách taskChặn đuôi chi phí (task lỗi đắt nhất)Một số task dừng sớm
4Output: max_tokens hợp lý, yêu cầu ngắn gọn, structured outputsOutput đắt 5× inputCắt cụt nếu quá thấp
5Batch API cho việc không gấp (phân loại review hằng đêm, sinh mô tả sản phẩm)−50%Kết quả trong tới 24 giờ, thứ tự bất kỳ (khớp bằng custom_id)
6Model/effort theo route (router)Chênh 10× giữa Haiku và FableChất lượng; phải eval từng route; cache tách theo model

Ngược lại, fast mode (Opus 5.5, beta, chỉ Claude API) chạy nhanh hơn tới ~2,5 lần với giá $8/$40 (gấp đôi) — chỉ đáng cho luồng mà tốc độ có giá trị kinh doanh.

3.7 Fallback và suy giảm có kiểm soát

4. Đảm bảo và giới hạn

Khía cạnhBạn cóBạn không có
Độ trễStream để giảm TTFT cảm nhậnSLA latency cứng; latency dao động theo tải nhà cung cấp
Chi phíusage chính xác mỗi response; ngân sách do bạn ápChi phí đoán trước cho agent (số vòng do model quyết định)
RetrySDK retry an toàn cho lời gọi model (không side effect)Exactly-once cho tool có side effect (bài 02)
TimeoutKhi bạn tự cấu hình transportMặc định an toàn (lab R4)
Sẵn sàngStatus page, nhiều nền tảng phân phốiModel sống mãi — model cũ bị ngừng theo lịch

5. Failure modes

LỗiHệ quảPhát hiệnXử lý
Không timeout (lab R4/R5)Worker treo, pool cạnSố worker busy, request > X giâyTimeout ở transport; stream cho output dài
retry-after dài trong FPM (lab R3)Pool FPM đứng khi có 429 hàng loạtp99 latency nhảy, 429 tăngKhông gọi LLM trong FPM; trong job: release với delay
Retry nhân chồng (lab R6)1 lời gọi = 3 request × job tries × agent tự thử lạiĐếm request/taskMột tầng retry cho mỗi loại lỗi
Bão 429 khi fan-out (bài 05)43% request bị từ chối ở 20 workerTỉ lệ 429Semaphore tập trung, jitter
Vòng lặp tốn tiềnMột task $5 thay vì $0,05Chi phí p99 theo task, alert theo giờmax_iter, ngân sách, kill switch
Regression cacheHoá đơn tăng 5× sau một deployCache hit ratioTest CI cho cache (bài 03)
Nhà cung cấp sự cố529 diện rộngTỉ lệ 529, status pageCircuit breaker, fallback, suy giảm
Model bị ngừng hỗ trợRequest lỗi từ một ngày cụ thểLịch deprecationKế hoạch nâng cấp + eval định kỳ
Buffer của proxyStream "không chạy", người dùng chờ cả cụcTTFT phía client = tổng thời gianTắt buffering ở nginx/CDN cho route stream

6. Vận hành trong production

Dashboard tối thiểu

Cấu hình nên tường minh

Cấu hìnhChat realtimeAgent nềnBatch đêm
Timeout transportStream; idle timeout 30–60 s120–300 s hoặc streamBatch API
Retry0–1, hỏng nhanh, suy giảmJob-level, backoff + jitter, trần delayTheo kết quả batch (errored/expired)
Song songTheo trafficGiới hạn số workerTheo lô
Ngân sáchToken/lượtToken + thời gian/taskNgân sách/lô

Runbook lúc 3h sáng

  1. Status page nhà cung cấp có sự cố không? Tỉ lệ 529 tăng? → bật suy giảm/fallback.
  2. 429 tăng? Tính năng nào đang dùng nhiều bất thường (batch chạy sai giờ, vòng lặp)? → giảm song song/tắt tính năng đó.
  3. Chi phí/giờ tăng? Cache hit ratio tụt sau deploy? Task nào p99 chi phí cao? → rollback prompt, kill task.
  4. Latency tăng mà không lỗi? Prompt dài ra (TTFT), output dài ra (max_tokens hit), hay queue đợi lâu?

Nâng cấp model

Theo dõi lịch ngừng hỗ trợ; mỗi quý chạy eval với model mới; đọc migration guide (breaking change API như effort mặc định, tool_choice, thinking); canary; so chi phí — model mới có thể dùng tokenizer khác (Opus 4.7+ sinh ~1–1,35× token so với đời trước trên cùng văn bản).

7. So sánh và ma trận quyết định

Kiến trúc gọi LLMĐộ phức tạpChịu tảiUXDùng khi
Đồng bộ trong FPM, không streamThấp nhấtKém (giữ worker)Chờ trắng màn hìnhCông cụ nội bộ ít người, lời gọi ngắn
Đồng bộ trong FPM, SSE streamThấpKémTốtTraffic nhỏ
Queue + pollingTBTốtTrung bìnhTác vụ dài, không cần thấy từng chữ
Queue + broadcast/SSE từ process riêngTB–caoTốtTốtChat/agent cho khách hàng — mặc định khuyến nghị
Octane/Swoole (event loop)Cao (đổi mô hình runtime)Rất tốtTốtTeam đã quen long-running PHP
Batch APITBRất tốtKhông realtimeXử lý hàng loạt, rẻ 50%
Kênh phân phối modelƯuNhược
Claude API trực tiếpTính năng mới sớm nhất (beta), đủ tính năngHợp đồng/hoá đơn riêng
Bedrock / Vertex AI / FoundryHợp đồng cloud sẵn có, IAM, vị trí dữ liệu, dự phòngMột số tính năng đến muộn/khác; model id và giá khác
Gateway đa nhà cung cấpĐổi model/hãng bằng cấu hình, fallbackMẫu số chung thấp nhất; thêm một hop; che tính năng riêng

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

Điều người vận hành lâu năm đều biết: sự cố AI tốn kém nhất thường không phải "API sập" (mọi người đều thấy) mà là "chi phí tăng im lặng" hoặc "chất lượng giảm im lặng" sau một thay đổi nhỏ. Metric chi phí/task và eval định kỳ là hệ thống báo động cho hai thứ đó.

10. Câu hỏi phụ interviewer hay hỏi tiếp

429 khác 529 thế nào, xử lý khác nhau ra sao?
429: bạn vượt hạn mức của mình — giảm tốc, xếp hàng, tôn trọng retry-after. 529: nhà cung cấp quá tải — retry có backoff ngắn, sau đó fallback/suy giảm; không phải lỗi của bạn nhưng là vấn đề của bạn.
Tại sao không gọi LLM trong request PHP-FPM?
Little's law: lời gọi vài chục giây nhân với tải là hàng trăm worker bị giữ; kèm retry-after và timeout mặc định vô hạn, pool FPM cạn. Đưa vào queue, stream qua process riêng.
Giảm chi phí LLM thế nào mà không giảm chất lượng?
Prompt cache trước (lab: 7×), dọn input, chặn vòng lặp, giới hạn output, Batch API cho việc không gấp; chỉ đổi model rẻ hơn khi eval chứng minh đủ tốt ở route đó.
Làm sao dự báo chi phí?
Đo token/task thật (p50, p99) × số task dự kiến × giá, cộng hệ số cho retry và vòng lặp; theo dõi hằng ngày vì phân phối đổi khi prompt/tool đổi.
Streaming có làm tổng thời gian nhanh hơn không?
Không (lab: cùng 3,88 s). Nó giảm thời gian chờ chữ đầu tiên (3,88 s → 0,09 s) — thứ quyết định cảm nhận của người dùng.

Phụ lục: lab đã chạy

Môi trường: PHP 8.4.19, anthropic-ai/sdk 0.52.0, guzzlehttp/guzzle 8.2.0, mock server Python 3.11. Output gốc: labs/ai/out/02-retry-timeout.txt, labs/ai/out/09-stream-ttft.txt.

cd labs/ai/php && composer install && cd ..
bash php/run_retry_lab.sh                                  # R1–R7: retry, retry-after, timeout (~2 phút)
./mockctl.sh start echo '{"stream_delay":0.08}'
php php/stream_ttft.php                                    # TTFT stream vs không stream
./mockctl.sh stop

# Đọc chính sách retry trong mã nguồn SDK:
grep -n "function shouldRetry" -A30 php/vendor/anthropic-ai/sdk/src/Core/BaseClient.php
grep -n "DEFAULT_" php/vendor/anthropic-ai/sdk/src/RequestOptions.php

Chưa verify: hạn mức và header rate limit thật; việc request bị client bỏ ngang có bị tính tiền không; Batch API; fallback server-side; fast mode; buffering của nginx. Nguồn: mã nguồn anthropic-ai/sdk PHP 0.52.0; tài liệu tham chiếu Claude API (cache 2026-09-25) về rate limit, Batch, fast mode, fallback; OWASP LLM10 (Unbounded Consumption).

Bài trước: 08 · Bảo mật agent · Về đầu series: 00 · Lộ trình.