·

Trước khi tối ưu bất cứ thứ gì liên quan tới chi phí AI agent, bạn phải hiểu đúng đơn vị đang được tính tiền: không phải "chữ", không phải "câu" — mà là token. Ai hiểu sai đơn vị này sẽ tối ưu sai chỗ, dù có đọc bao nhiêu tài liệu về prompt engineering.

Rất nhiều kỹ sư, QA, product manager dùng ChatGPT hay Claude Code hàng ngày nhưng chưa bao giờ tự hỏi: khi mình gõ một câu, chuyện gì xảy ra bên trong trước khi model "hiểu" được câu đó? Câu trả lời là cả một pipeline xử lý — và mọi con số chi phí, mọi giới hạn context window, mọi độ trễ bạn thấy trên dashboard đều bắt nguồn từ một bước duy nhất: tokenization. Bài này đi từ gốc: token được tạo ra như thế nào, tại sao các model khác nhau lại đếm token khác nhau trên cùng một đoạn văn, model thực sự "xử lý" token ra sao qua attention, và cuối cùng — tiền của bạn đang chảy đi đâu, theo công thức nào.

Tokenization Hoạt Động Thế Nào — Từ Văn Bản Đến Integer ID

Về bản chất, một Large Language Model (LLM) không đọc được chữ. Nó chỉ làm được một việc: nhân ma trận trên các số thực. Vì vậy, trước khi bất kỳ văn bản nào tới được mạng neural, nó phải đi qua một bước biến đổi bắt buộc gọi là tokenization — quá trình cắt văn bản thô thành các đơn vị nhỏ hơn (token), rồi map mỗi đơn vị đó sang một số nguyên (integer ID) duy nhất trong một bảng tra cứu cố định gọi là vocabulary (bộ từ vựng). Pipeline đầy đủ trông như sau: văn bản thô → chuẩn hóa (normalization) → cắt thành token → tra ID trong vocabulary → embedding lookup → đưa vào transformer.

Thuật toán được dùng phổ biến nhất hiện nay cho bước cắt token là BPE (Byte Pair Encoding) hoặc các biến thể gần giống như SentencePiece. Ý tưởng của BPE rất đơn giản nhưng hiệu quả: bắt đầu từ tập ký tự (hoặc byte) đơn lẻ, sau đó lặp đi lặp lại việc tìm cặp ký tự/subword xuất hiện cạnh nhau thường xuyên nhất trong toàn bộ dữ liệu huấn luyện, gộp cặp đó thành một token mới, rồi lặp lại quá trình này hàng chục nghìn lần cho tới khi đạt kích thước vocabulary mục tiêu (thường 30.000-200.000 token). Kết quả cuối cùng là một bộ từ vựng "lai" — vừa có token là ký tự đơn lẻ (dùng cho từ hiếm/lạ), vừa có token là cả một từ thông dụng nguyên vẹn (như "the", "function"), vừa có token là subword — mảnh của một từ dài (như "token" + "ization" khi ghép thành "tokenization").

Hệ quả trực tiếp mà bất kỳ ai làm việc với AI agent cũng cần khắc cốt ghi tâm: 1 token không tương đương 1 từ, và cũng không tương đương 1 ký tự. Với văn bản tiếng Anh thông thường, tỷ lệ trung bình phổ biến là khoảng 1 token ≈ 0,75 từ (hay 100 token ≈ 75 từ). Với tiếng Việt, tỷ lệ này thường xấu hơn đáng kể: do hầu hết tokenizer được huấn luyện chủ yếu trên corpus tiếng Anh/Latin không dấu, một âm tiết tiếng Việt có dấu (ví dụ "được", "nghiệp") thường bị chẻ thành 2-3 token thay vì 1, khiến cùng một nội dung, prompt tiếng Việt "nặng" token hơn bản tiếng Anh tương đương từ 1,5 đến 2 lần. Với code lại có đặc thù khác: khoảng trắng thụt lề, dấu ngoặc lồng nhau, ký tự đặc biệt ({}, =>, ::) đều được tính là token riêng — một file JSON indent 4-space có thể tốn nhiều token hơn cùng nội dung minify không có khoảng trắng.

Hãy nhìn cụ thể một câu ngắn để thấy quá trình này diễn ra ra sao trên thực tế:

import tiktoken

encoding = tiktoken.get_encoding("o200k_base")  # bộ tokenizer của GPT-4o / GPT-4.1
text = "AI agent cần tối ưu token để giảm chi phí vận hành."

token_ids = encoding.encode(text)
tokens_as_text = [encoding.decode([tid]) for tid in token_ids]

print(f"Số ký tự gốc : {len(text)}")
print(f"Số token     : {len(token_ids)}")
for tid, piece in zip(token_ids, tokens_as_text):
    print(f"  {tid:>7} -> {piece!r}")

Chạy đoạn code trên, bạn sẽ thấy những từ tiếng Anh thông dụng như "AI", "agent", "to" gần như luôn là 1 token trọn vẹn, còn các âm tiết tiếng Việt có dấu ("vận", "hành", "phí") gần như luôn bị tách thành 2 token — một phần là "khung" ký tự Latin, một phần là dấu kết hợp (combining diacritics) được xử lý ở tầng byte-level UTF-8. Đây chính là con số vật lý quyết định bạn trả bao nhiêu tiền, không phải cảm giác "câu này ngắn mà".

Mẹo: Đừng bao giờ ước lượng chi phí prompt bằng cách đếm chữ hoặc ký tự bằng mắt. Luôn chạy thử qua một tokenizer thật (tiktoken cho họ GPT, hoặc endpoint đếm token của Anthropic được nói ở phần sau) trước khi đưa một prompt hay system prompt lớn vào production — với tiếng Việt, sai số ước lượng bằng mắt có thể lên tới 50-80%.

Vocabulary Của Tokenizer và Sự Khác Biệt Giữa Các Model

Một điều gây bối rối cho rất nhiều người mới: cùng một đoạn văn bản, gửi tới GPT-4o và gửi tới Claude, sẽ ra số token khác nhau. Nguyên nhân là mỗi nhà cung cấp huấn luyện tokenizer riêng, trên corpus dữ liệu riêng, với kích thước vocabulary riêng — nên cách "cắt" văn bản của họ khác nhau ngay từ gốc. OpenAI hiện dùng họ tokenizer cl100k_base (cho GPT-3.5/GPT-4 thế hệ cũ) và o200k_base (cho GPT-4o, GPT-4.1, o-series) với vocabulary khoảng 100.000-200.000 token. Anthropic không công bố công khai tokenizer/vocabulary thô của Claude — thay vào đó, họ cung cấp một token counting API chính thức trong Messages API để bạn hỏi trực tiếp "đoạn này tốn bao nhiêu token", đảm bảo con số luôn khớp với con số thực tế mà hệ thống billing sẽ dùng, dù bạn không truy cập được vocabulary nội bộ.

Sự khác biệt này có hệ quả thực tế quan trọng: bạn không thể lấy số token đếm được từ tiktoken để ước lượng chi phí cho một request gửi tới Claude, và ngược lại. Một prompt 1.000 token theo o200k_base có thể là 1.050 hoặc 950 token khi Claude đếm — chênh lệch thường không lớn (khoảng 5-15% với văn bản tiếng Anh thông thường) nhưng đủ để làm sai lệch một bài toán budget nếu bạn áp dụng chéo số liệu giữa hai hệ. Với tiếng Việt hoặc code có ký tự đặc biệt, chênh lệch này có thể lớn hơn đáng kể vì mỗi tokenizer xử lý byte UTF-8 và ký tự combining khác nhau.

Dưới đây là ví dụ so sánh trực tiếp hai cách đếm — một bằng tiktoken cho model họ OpenAI, một bằng token counting API của Anthropic cho Claude — trên cùng một input:

import tiktoken
import anthropic

text = "Review đoạn diff này, tập trung vào lỗi race condition và SQL injection."
system_prompt = "Bạn là senior code reviewer, trả lời ngắn gọn, chỉ liệt kê vấn đề nghiêm trọng."

enc = tiktoken.get_encoding("o200k_base")
openai_tokens = len(enc.encode(system_prompt)) + len(enc.encode(text))

client = anthropic.Anthropic()
resp = client.messages.count_tokens(
    model="claude-sonnet-5",
    system=system_prompt,
    messages=[{"role": "user", "content": text}],
)
claude_tokens = resp.input_tokens

print(f"OpenAI (o200k_base) ước tính : {openai_tokens} token")
print(f"Claude (count_tokens API)    : {claude_tokens} token")

Điểm khác biệt lớn nhất về cách dùng: tiktoken chạy hoàn toàn offline — bạn có thể đếm token hàng triệu request trong vòng vài giây mà không tốn một cent nào và không cần mạng, rất phù hợp để nhúng vào pipeline CI/CD kiểm tra độ dài prompt trước khi merge. Token counting API của Anthropic ngược lại yêu cầu một network round-trip thật (dù bản thân lệnh gọi này miễn phí, không tính vào bill), nên nếu bạn cần đếm token số lượng lớn (ví dụ audit hàng nghìn prompt lịch sử), cần cân nhắc rate limit và độ trễ mạng, hoặc dùng công thức ước lượng nhanh (khoảng 1 token ≈ 3,5-4 ký tự tiếng Anh) làm bước sàng lọc trước khi gọi API đếm chính xác.

Mẹo: Khi team dùng nhiều model từ nhiều nhà cung cấp (ví dụ Claude cho code review, GPT-4o cho classification nhanh), đừng dùng chung một con số "ước lượng token" cho cả hai. Viết một wrapper nhỏ tự động chọn đúng phương pháp đếm theo tên model, để mọi báo cáo chi phí trong team đều dựa trên số liệu chính xác của từng nhà cung cấp, tránh so sánh táo với cam.

Model Xử Lý Token Như Thế Nào — Cơ Chế Attention Của Transformer

Sau khi văn bản đã được cắt thành token và tra ra ID số nguyên, hành trình bên trong model mới thực sự bắt đầu. Mỗi token ID được tra vào một bảng embedding — một ma trận khổng lồ map mỗi ID sang một vector số thực nhiều chiều (ví dụ 4.096 hoặc 8.192 chiều với các model lớn), vector này mang "ý nghĩa" của token đó trong không gian toán học đã học được từ dữ liệu huấn luyện. Model cộng thêm thông tin vị trí (positional encoding) vào vector này để biết token đang ở đâu trong chuỗi, rồi đưa toàn bộ chuỗi vector qua nhiều tầng Transformer xếp chồng lên nhau (thường 32-100+ tầng với các model production hiện nay).

Trái tim của mỗi tầng Transformer là cơ chế attention (self-attention). Với mỗi token, model tính ra ba vector: Query (câu hỏi "tôi cần thông tin gì"), Key (nhãn "tôi mang loại thông tin gì"), và Value (nội dung thực tế token đó mang theo). Model so khớp Query của token hiện tại với Key của MỌI token khác đang có trong context (kể cả các token đứng trước nó, do kiến trúc causal/autoregressive chỉ cho nhìn về quá khứ), tính ra một trọng số liên quan (attention weight) cho từng cặp, rồi cộng gộp các Value theo trọng số đó để tạo ra biểu diễn ngữ cảnh cho bước tính tiếp theo. Các model hiện đại dùng multi-head attention — chạy song song nhiều "đầu" attention độc lập, mỗi đầu học cách chú ý vào một loại pattern khác nhau (một đầu chú ý vào quan hệ cú pháp gần, một đầu chú ý vào tham chiếu xa trong đoạn văn, v.v.), rồi ghép kết quả lại.

Điểm mấu chốt về mặt chi phí và hiệu năng mà kỹ sư cần hiểu: phép tính attention gốc có độ phức tạp tăng theo bậc hai (quadratic) với số token trong context — gấp đôi số token đầu vào không làm chi phí tính toán tăng gấp đôi, mà tăng gấp bốn. Đây là lý do kỹ thuật cốt lõi (bên cạnh lý do về giá) khiến context window dài luôn đi kèm độ trễ cao hơn và chi phí hạ tầng lớn hơn cho nhà cung cấp — dù các kỹ thuật tối ưu như sliding-window attention, sparse attention, hay Multi-Query/Grouped-Query Attention đã giảm đáng kể gánh nặng này trong các kiến trúc gần đây. Ngoài ra, quá trình sinh output luôn diễn ra theo kiểu autoregressive — model sinh một token, đưa token đó trở lại làm input, sinh token tiếp theo, lặp lại tuần tự — nghĩa là output không thể tính song song như input, đây chính là lý do tại sao token đầu ra luôn tốn thời gian và tiền hơn token đầu vào, điều sẽ được phân tích chi tiết ở phần tiếp theo.

Đối với agentic engineering nói riêng, hệ quả thực dụng là: mỗi file code, mỗi tool schema, mỗi đoạn hội thoại cũ bạn giữ trong context không chỉ tốn tiền theo token đếm được — nó còn khiến MỌI phép attention ở MỌI tầng Transformer phải tính toán thêm với MỌI token khác đang có, làm agent phản hồi chậm hơn một cách có thể đo được, không phải cảm giác chủ quan.

Mẹo: Nếu agent của bạn phản hồi càng lúc càng chậm trong một session dài, đừng chỉ nghĩ tới rate limit hay network — hãy kiểm tra tổng số token trong context hiện tại trước. Do chi phí attention tăng theo bậc hai, một context đã phình to gấp 3 lần so với đầu session có thể khiến thời gian xử lý tăng gần 9 lần, và đó là dấu hiệu rõ ràng cần compact/summarize hội thoại.

Cơ Chế Tính Chi Phí Token — Billing Hoạt Động Thực Sự Ra Sao

Hầu hết nhà cung cấp LLM tính phí theo đơn vị "$/triệu token" (per million tokens, viết tắt MTok), tách riêng đơn giá cho input và output — và output luôn đắt hơn input đáng kể (thường 3-5 lần), vì hai lý do: (1) như đã nói ở phần trước, sinh output là quá trình tuần tự không parallelize được, tốn nhiều compute-giây hơn xử lý input; (2) về mặt kinh doanh, đây là cách nhà cung cấp khuyến khích người dùng thiết kế prompt/output ngắn gọn, súc tích.

Cơ chế quan trọng thứ hai cần hiểu kỹ là prompt caching. Khi bạn gửi đi một phần context giống hệt nhiều lần liên tiếp trong khoảng thời gian ngắn (điển hình: system prompt cố định, tool schema cố định, phần đầu một tài liệu dài dùng lại qua nhiều lượt hỏi), nhà cung cấp cho phép "cache" phần đó ở phía server. Lần đầu ghi vào cache (cache write) thường tính phí cao hơn giá input gốc một chút (ví dụ +25%) vì tốn thêm compute để lưu trạng thái; nhưng mọi lần đọc lại từ cache sau đó (cache read/cache hit) được tính phí thấp hơn nhiều — thường chỉ 10% giá input gốc. Cache có thời hạn sống (ví dụ 5 phút hoặc 1 giờ tùy tùy chọn), và mỗi request mới trong thời hạn đó tận dụng lại phần đã cache mà không phải trả giá đầy đủ.

Hãy tính một ví dụ cụ thể, đúng như bài toán mà một team xây dựng AI code-review agent nội bộ sẽ gặp thực tế. Giả sử mỗi lượt review gửi đi tổng cộng 8.000 token input, trong đó 6.000 token là phần cố định (system prompt hướng dẫn review + coding convention của team + tool schema) đã được cache và đọc lại (cache read), còn 2.000 token là phần mới thực sự thay đổi mỗi lần (đoạn diff cụ thể của PR); output — nhận định review — dài 1.200 token. Áp dụng đơn giá tham khảo của một model tầm trung hiện nay (input $3/MTok, output $15/MTok, cache read $0,30/MTok — tức 10% giá input, cache write $3,75/MTok):

Thành phần Số token Đơn giá ($/MTok) Chi phí
Input mới (uncached) 2.000 3,00 $0,0060
Input từ cache (cache read) 6.000 0,30 $0,0018
Output 1.200 15,00 $0,0180
Tổng mỗi lượt review $0,0258

Với 100 lượt review mỗi ngày (một team cỡ trung bình chạy CI trên nhiều PR), chi phí là $0,0258 × 100 ≈ $2,58/ngày, và tính theo tháng (khoảng 30 ngày làm việc/lịch) là ≈ $77,4/tháng — một con số hoàn toàn chấp nhận được cho giá trị mang lại. Nhưng con số này chỉ đạt được NHỜ caching. Nếu không dùng cache (gửi lại đầy đủ 8.000 token input ở giá gốc $3/MTok mỗi lần), chi phí mỗi lượt sẽ là (8.000 × 3 + 1.200 × 15) / 1.000.000 = $0,042/lượt — cao hơn gần 63%, tương đương ≈ $126/tháng ở cùng volume. Nói cách khác, một quyết định kỹ thuật đơn giản — giữ system prompt/tool schema ổn định để tận dụng cache thay vì render lại động mỗi lần — tiết kiệm gần 40% chi phí vận hành mà không đổi một dòng logic nghiệp vụ nào.

Một vài yếu tố billing khác dễ bị bỏ quên khi lập budget: (1) retry/lỗi vẫn tính tiền — nếu agent gọi tool thất bại và tự thử lại 3 lần, bạn trả tiền cho cả 3 lần input/output đó, không chỉ lần thành công cuối; (2) nhiều nhà cung cấp có batch API giá rẻ hơn (thường 50%) cho các tác vụ không cần phản hồi real-time — rất đáng cân nhắc cho các job review/phân loại chạy theo lô ban đêm; (3) giá liệt kê công khai luôn có thể thay đổi và khác nhau giữa các phiên bản model (model rẻ/nhanh luôn có input/output rẻ hơn model mạnh nhất), nên bất kỳ công thức tính chi phí trong code của bạn cũng cần tham số hóa theo model, không hard-code một mức giá duy nhất.

def estimate_review_cost(
    fresh_input_tokens: int,
    cached_input_tokens: int,
    output_tokens: int,
    price_per_mtok: dict,  # {"input": 3.00, "output": 15.00, "cache_read": 0.30}
) -> float:
    cost = (
        fresh_input_tokens * price_per_mtok["input"]
        + cached_input_tokens * price_per_mtok["cache_read"]
        + output_tokens * price_per_mtok["output"]
    ) / 1_000_000
    return round(cost, 6)


sonnet_pricing = {"input": 3.00, "output": 15.00, "cache_read": 0.30}
cost_per_review = estimate_review_cost(2_000, 6_000, 1_200, sonnet_pricing)
print(f"Chi phí mỗi review: ${cost_per_review}")
print(f"Chi phí/ngày (100 review): ${cost_per_review * 100:.2f}")
print(f"Chi phí/tháng (~30 ngày): ${cost_per_review * 100 * 30:.2f}")

Mẹo: Luôn tách riêng số liệu "input mới" và "input từ cache" trong mọi báo cáo chi phí nội bộ, đừng gộp chung thành "tổng input token". Gộp chung sẽ khiến bạn không nhìn thấy được cache hit-rate đang tệ đi theo thời gian — một nguyên nhân phổ biến khiến chi phí AI agent tăng dần "không rõ lý do" khi team vô tình thay đổi thứ tự hoặc nội dung phần đầu prompt, làm cache liên tục bị miss.

Các Loại Token Đặc Biệt — System, Tool và Token Vô Hình

Ngoài token của nội dung bạn thực sự gõ hoặc đọc thấy trên UI chat, mỗi request gửi tới LLM còn mang theo một lượng token "vô hình" — không hiện trên giao diện nhưng vẫn được đếm và tính tiền đầy đủ. Hiểu rõ các loại này giúp giải thích tại sao số token thực tế trong bill luôn cao hơn con số bạn tưởng khi chỉ đếm phần chữ mình gõ.

Nhóm đầu tiên là special/control token — các token đặc biệt trong vocabulary không đại diện cho ký tự ngôn ngữ, mà đóng vai trò đánh dấu ranh giới cấu trúc: token bắt đầu chuỗi (BOS — beginning of sequence), kết thúc chuỗi (EOS), padding, hoặc các marker theo chat template riêng của từng nhà cung cấp (ví dụ dạng <|im_start|>system, <|im_start|>assistant<|im_end|> ở một số hệ, hoặc cấu trúc role-based tương tự ở Claude/Anthropic Messages API). Khi bạn gửi một mảng messages có role: "system", role: "user", role: "assistant", hệ thống ở backend sẽ tự động chèn các marker này để model phân biệt "ai đang nói" — và mỗi marker đó tốn vài token thực sự, nhân với số lượt trong hội thoại.

Nhóm thứ hai, thường tốn kém nhất trong các AI agent hiện đại, là tool/function definition token. Mỗi tool bạn định nghĩa cho agent (tên, description, JSON schema tham số) được serialize thành text và gửi kèm MỌI request, không chỉ khi tool được gọi. Một tool schema có vài tham số lồng nhau, mô tả chi tiết, có thể tốn 200-800 token; một agent trang bị 15-20 tool (điều rất phổ biến với coding agent tích hợp nhiều MCP server) có thể "âm thầm" cộng thêm 5.000-10.000 token vào MỌI request, dù request đó chỉ cần dùng 1 trong 20 tool đó. Đây là một trong những nguồn lãng phí token lớn nhất và dễ bị bỏ qua nhất trong thực tế vận hành agent, sẽ được đi sâu ở module riêng về tool use optimization.

Nhóm thứ ba là token của quá trình suy luận ẩn (reasoning/thinking token). Với các model có khả năng "extended thinking" hay chuỗi suy luận nội bộ trước khi trả lời, phần suy luận này — dù không luôn hiển thị đầy đủ cho người dùng cuối — vẫn là token thực sự được sinh ra và tính phí như output thông thường (đôi khi theo mức giá riêng). Với các bài toán phức tạp, phần suy luận ẩn này có thể chiếm phần lớn tổng token output, khiến chi phí thực tế cao hơn nhiều so với độ dài câu trả lời cuối cùng bạn nhìn thấy.

Cuối cùng, đừng quên các token "định dạng" âm thầm phình to: JSON lồng sâu nhiều cấp với khoảng trắng đẹp mắt (pretty-print) tốn nhiều token hơn JSON minify cùng dữ liệu; markdown syntax (dấu #, **, bảng biểu) trong output đều là token thật; và nếu agent xử lý ảnh, dữ liệu ảnh được encode base64 gắn trong context có thể quy đổi thành hàng trăm đến hàng nghìn token tùy độ phân giải, dù bạn không "gõ" một chữ nào cho phần đó.

Mẹo: Định kỳ (ví dụ mỗi sprint) export một request thật gửi tới model của agent đang chạy production, chạy qua token counter, và so sánh breakdown giữa "nội dung nghiệp vụ thật" (câu hỏi, code, dữ liệu) với "chi phí cấu trúc" (system prompt, tool schema, chat template marker). Nếu phần cấu trúc chiếm trên 40-50% tổng token, đó là dấu hiệu rõ ràng cần review lại thiết kế tool/prompt trước khi nghĩ tới việc đổi model rẻ hơn.

Hands-On Exercise — Xây Dựng Một Token Profiler

Lý thuyết chỉ có giá trị khi bạn có công cụ để nhìn thấy số liệu thật trên hệ thống của mình. Bài thực hành này hướng dẫn xây dựng một Token Profiler — công cụ nhỏ, tái sử dụng được, giúp "chụp X-quang" bất kỳ request nào trước khi gửi tới LLM: đếm token theo từng thành phần (system prompt, tool schema, hội thoại), ước tính chi phí theo đúng đơn giá của model đang dùng, và phân biệt phần token nào đến từ cache. Đây là công cụ nền tảng mà bất kỳ team nghiêm túc về chi phí AI agent cũng nên có trong bộ công cụ nội bộ, thay vì chỉ dựa vào con số tổng trên dashboard billing (thường có độ trễ vài giờ tới một ngày).

Bước 1 — Cài dependency.

pip install tiktoken anthropic

Bước 2 — Định nghĩa bảng giá tham chiếu và class profiler chính.

"""
token_profiler.py
Công cụ đếm token theo từng thành phần của request (system, tools,
conversation) và ước tính chi phí, hỗ trợ cả model họ OpenAI (qua tiktoken)
và Claude (qua Anthropic token counting API).
"""

import json
import tiktoken
import anthropic

PRICING = {
    "gpt-4o": {"input": 2.50, "output": 10.00, "cache_read": 1.25, "cache_write": None},
    "claude-sonnet-5": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75},
    "claude-haiku-4-5-20251001": {"input": 0.80, "output": 4.00, "cache_read": 0.08, "cache_write": 1.00},
}


class TokenProfiler:
    def __init__(self, model: str):
        if model not in PRICING:
            raise ValueError(f"Chưa khai báo giá cho model: {model}")
        self.model = model
        self.is_claude = model.startswith("claude")
        self._client = anthropic.Anthropic() if self.is_claude else None
        self._encoding = None if self.is_claude else tiktoken.encoding_for_model(model)

    def count_tokens(self, text: str) -> int:
        """Đếm token thật của một đoạn text theo đúng tokenizer của model đang xét."""
        if not text:
            return 0
        if self._encoding is not None:
            return len(self._encoding.encode(text))
        resp = self._client.messages.count_tokens(
            model=self.model,
            messages=[{"role": "user", "content": text}],
        )
        return resp.input_tokens

    def profile_request(self, system: str, tools: list[dict], user_message: str) -> dict:
        """Trả về breakdown token theo từng thành phần của một request."""
        tool_schema_text = json.dumps(tools, ensure_ascii=False) if tools else ""
        breakdown = {
            "system_prompt": self.count_tokens(system),
            "tool_schemas": self.count_tokens(tool_schema_text),
            "user_message": self.count_tokens(user_message),
        }
        breakdown["total_input"] = sum(breakdown.values())
        return breakdown

    def estimate_cost(self, fresh_input: int, output: int, cached_input: int = 0) -> float:
        """Ước tính chi phí ($) cho một request, có tách riêng phần đọc từ cache."""
        price = PRICING[self.model]
        cost = (
            fresh_input * price["input"]
            + cached_input * price["cache_read"]
            + output * price["output"]
        ) / 1_000_000
        return round(cost, 6)


if __name__ == "__main__":
    profiler = TokenProfiler("claude-sonnet-5")

    system_prompt = (
        "Bạn là senior code reviewer. Chỉ trả lời bằng tiếng Việt, tập trung "
        "vào lỗi bảo mật (SQL injection, race condition) và performance. "
        "Không nhận xét về style/formatting."
    )
    tools = [
        {
            "name": "run_static_analysis",
            "description": "Chạy static analysis (semgrep) trên một file diff",
            "input_schema": {
                "type": "object",
                "properties": {"file_path": {"type": "string"}},
                "required": ["file_path"],
            },
        },
    ]
    user_message = (
        "Review đoạn diff sau trong file payment_service.py: "
        "hàm charge_customer() gọi trực tiếp raw SQL với f-string nối chuỗi "
        "user_id vào query, và không có lock khi trừ balance."
    )

    result = profiler.profile_request(system_prompt, tools, user_message)
    print("Token breakdown:")
    print(json.dumps(result, indent=2, ensure_ascii=False))

    # Giả sử system_prompt + tool_schemas đã được cache từ lượt trước
    cached = result["system_prompt"] + result["tool_schemas"]
    fresh = result["user_message"]
    cost = profiler.estimate_cost(fresh_input=fresh, output=350, cached_input=cached)
    print(f"\nChi phí ước tính lượt review này: ${cost}")

Chạy file trên sẽ in ra một breakdown dạng JSON tương tự:

Token breakdown:
{
  "system_prompt": 58,
  "tool_schemas": 47,
  "user_message": 62,
  "total_input": 167
}

Chi phí ước tính lượt review này: $0.006345

Bước 3 — Mở rộng bài tập (tự làm thêm để hiểu sâu hơn):

  1. Thêm phương thức profile_conversation(messages: list[dict]) để cộng dồn token qua nhiều lượt hội thoại, phát hiện lượt nào đang khiến tổng token phình to bất thường (ví dụ tool result trả về nguyên một file log dài).
  2. Thêm cờ alert_threshold — nếu total_input vượt một ngưỡng cấu hình (ví dụ 20.000 token), profiler tự in cảnh báo kèm gợi ý thành phần nào nên cắt giảm trước.
  3. Ghi log breakdown mỗi request ra CSV/JSON theo timestamp, để dựng biểu đồ xu hướng "chi phí trung bình mỗi request theo ngày" — nền tảng cho phần token usage analytics ở module sau của khóa học này.
  4. Thử chạy cùng một system_promptuser_message qua cả "gpt-4o""claude-sonnet-5" trong profiler, so sánh trực tiếp số token và chi phí — bài tập này giúp bạn cảm nhận rõ sự khác biệt giữa các tokenizer đã nói ở phần trước, bằng số liệu thật trên chính nội dung bạn hay dùng, không phải ví dụ trừu tượng.

Mẹo: Đừng viết profiler này rồi bỏ quên trong một script riêng lẻ. Tích hợp nó vào ngay bước "trước khi gọi API thật" trong code của agent (middleware/decorator), để mọi request production đều tự động ghi lại breakdown token — bạn sẽ có dữ liệu thật để tối ưu dần qua từng module tiếp theo của khóa học này, thay vì đoán mò dựa trên cảm giác "prompt này chắc là dài".