Bài 1 mô tả một incident: agent loop 100 lần vì file permission denied, đốt $12 trong 8 phút. Đó là bài học về max_iterations. Bài này đi sâu hơn: tại sao con số đó lại là $12, và làm thế nào để giảm xuống dưới $1 cho cùng workload.

Cost và latency trong agent không phải vấn đề thuần tuý về code. Đó là vấn đề về architecture: bạn gọi model nào, gọi ở bước nào, gửi bao nhiêu token, caching có được kích hoạt không, response có stream không. Mỗi quyết định đó có giá.

Phần 1: Tại sao token cost trong agent lại khác chatbot

Chatbot là request-response. Một lần gọi, một lần trả tiền. Agent là loop: mỗi iteration là một lần gọi API, mỗi lần gọi gửi lại toàn bộ history.

Với Claude Sonnet 4.6 (tháng 5/2026):

  • Input: $3/MTok (triệu token)
  • Output: $15/MTok

Haiku 4.5:

  • Input: $0.80/MTok
  • Output: $4/MTok

Nhìn vào một agent loop 5 iteration đơn giản:

IterationSystem promptHistory tích luỹTool schemasInput tokensOutput tokens
1500 tok100 tok300 tok900200
2500 tok600 tok300 tok1400150
3500 tok1050 tok300 tok1850180
4500 tok1380 tok300 tok2180120
5500 tok1680 tok300 tok2480250

Tổng: ~8810 input tokens + ~900 output tokens.

Chi phí Sonnet: $0.0264 input + $0.0135 output = $0.04 cho một run 5 bước.

Nghe rẻ. Nhưng nhân lên:

  • 1000 user requests/ngày: $40/ngày, $1200/tháng
  • Mỗi request loop 10 bước thay vì 5: gần gấp đôi
  • Bug khiến agent loop 50 bước: tăng 10x, $12000/tháng

Hai điều đập vào mắt từ bảng trên. Thứ nhất: input token dominant. Output của một bước (200-250 tokens) nhỏ hơn nhiều so với input (2000+ tokens). Thứ hai: input tăng theo từng bước vì history tích luỹ. Bước 5 gửi input gấp 2.7 lần bước 1.

Kết luận: chiến lược giảm cost phải tập trung vào input tokens, không phải output.

Đây cũng là lý do tại sao max_iterationsbài 3 về control loop nói đến không chỉ là safety net. Mỗi iteration thêm là tiền thật.

Phần 2: Prompt caching, cơ chế tiết kiệm lớn nhất

System prompt và tool schemas không thay đổi giữa các iteration. Trong ví dụ trên, 800 tokens (500 system + 300 tools) bị gửi lại 5 lần. Đó là 4000 tokens thừa.

Anthropic có prompt caching: khi phần đầu của prompt giống nhau giữa các request, Anthropic giữ phần đó trong KV cache. Request tiếp theo trả tiền theo cache read price, rẻ hơn nhiều.

Giá cache với Sonnet 4.6:

  • Cache write: $3.75/MTok (đắt hơn input thường một chút)
  • Cache read: $0.30/MTok (rẻ hơn 10 lần so với input)

TTL mặc định của cache là 5 phút. Nếu request tiếp theo đến sau 5 phút, cache miss, gửi lại từ đầu.

1-hour TTL

Anthropic có tính năng TTL 1 giờ, kích hoạt bằng env var:

ENABLE_PROMPT_CACHING_1H=1

Với agent chạy background task dài hoặc batch job qua đêm, TTL 5 phút không đủ. TTL 1 giờ giúp cache survive qua nhiều job.

Trong Claude Code và Anthropic SDK, nếu set biến này, cache tự động dùng TTL 1 giờ thay vì 5 phút. Không cần thay đổi code.

Cache trong code: cache_control

Để khai báo phần nào được cache, dùng cache_control marker:

import anthropic

client = anthropic.Anthropic()

# System prompt + tool schemas dài, cache lại
system_with_cache = [
    {
        "type": "text",
        "text": """
Bạn là một agent phân tích log Kubernetes.
Nhiệm vụ: đọc log từ các pod, xác định lỗi, đề xuất fix.
...
(500+ tokens của system prompt)
""",
        "cache_control": {"type": "ephemeral"}
    }
]

tools_with_cache = [
    {
        "name": "read_pod_logs",
        "description": "Read logs from a specific Kubernetes pod",
        "input_schema": {
            "type": "object",
            "properties": {
                "namespace": {"type": "string"},
                "pod_name": {"type": "string"},
                "tail_lines": {"type": "integer", "default": 100}
            },
            "required": ["namespace", "pod_name"]
        },
        # cache_control trên tool cuối cùng trong list
    },
    # ... nhiều tools khác ...
]

# Đặt cache_control trên tool cuối cùng để cache toàn bộ list
tools_with_cache[-1]["cache_control"] = {"type": "ephemeral"}

def agent_loop(user_input: str, max_iterations: int = 10):
    messages = [{"role": "user", "content": user_input}]
    total_cache_read = 0
    total_cache_write = 0
    total_input = 0
    total_output = 0

    for i in range(max_iterations):
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system=system_with_cache,
            tools=tools_with_cache,
            messages=messages,
        )

        # Track cache usage
        usage = response.usage
        total_cache_read += getattr(usage, "cache_read_input_tokens", 0)
        total_cache_write += getattr(usage, "cache_creation_input_tokens", 0)
        total_input += usage.input_tokens
        total_output += usage.output_tokens

        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            break

        if response.stop_reason == "tool_use":
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    result = execute_tool(block.name, block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": str(result),
                    })
            messages.append({"role": "user", "content": tool_results})

    # Cost breakdown
    cost_input = total_input * 3.0 / 1_000_000
    cost_cache_write = total_cache_write * 3.75 / 1_000_000
    cost_cache_read = total_cache_read * 0.30 / 1_000_000
    cost_output = total_output * 15.0 / 1_000_000
    total_cost = cost_input + cost_cache_write + cost_cache_read + cost_output

    print(f"Input: {total_input} tok (${cost_input:.4f})")
    print(f"Cache write: {total_cache_write} tok (${cost_cache_write:.4f})")
    print(f"Cache read: {total_cache_read} tok (${cost_cache_read:.4f})")
    print(f"Output: {total_output} tok (${cost_output:.4f})")
    print(f"Total: ${total_cost:.4f}")

Iteration đầu tiên: cache write, trả $3.75/MTok. Từ iteration 2 trở đi (trong 5 phút): cache read, trả $0.30/MTok. Tiết kiệm 92.5% trên phần được cache.

Pitfall: cache invalidation khi system prompt thay đổi 1 ký tự

Đây là bug tôi mất 3 giờ để debug.

Setup: agent chạy batch job, system prompt 2000 tokens, cache hit rate dự kiến 90%. Thực tế: 0% cache hit, bill tăng gấp đôi so với dự kiến.

Nguyên nhân: trong system prompt có dòng:

f"Ngày hôm nay: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"

Timestamp thay đổi mỗi giây. Mỗi request có timestamp khác nhau, Anthropic không nhận ra đây là cùng một prefix, cache miss 100%.

Anthropic cache là exact prefix match. Nếu bất kỳ ký tự nào trong phần được cache thay đổi, toàn bộ cache invalidated.

Các nguồn cache invalidation phổ biến:

NguồnFix
Timestamp trong system promptChuyển ra conversation turn, không đặt trong system
User ID, session ID trong system promptĐặt vào đầu messages thay vì system
Random seed, nonceKhông đặt trong cacheable prefix
Dynamic instruction thay đổi theo requestTách thành static prefix + dynamic suffix, chỉ cache prefix

Rule đơn giản: phần được đánh dấu cache_control phải giống nhau tuyệt đối giữa các request muốn share cache. Debug bằng cách log cache_creation_input_tokens vs cache_read_input_tokens. Nếu mỗi request đều có cache_creation_input_tokens > 0, đang bị cache miss.

Bài 4 về memory giải thích tại sao conversation prefix (toàn bộ history đến một điểm) cũng có thể được cache, không chỉ system prompt. Kỹ thuật này gọi là prefix caching và đặc biệt mạnh với agent có nhiều context tích luỹ.

Phần 3: Streaming để giảm latency cảm nhận

Streaming không giảm tổng số tokens hay tổng cost. Nó giảm Time To First Token (TTFT): thời gian từ lúc gửi request đến lúc user thấy chữ đầu tiên.

Với agent, streaming có ý nghĩa khi:

  1. Agent trả lời trực tiếp cho user (conversational agent, chatbot với tools)
  2. Task dài mà user cần biết tiến độ
  3. Debug session cần xem LLM đang “nghĩ” gì

Với batch processing job chạy background: streaming không giúp ích gì về UX, thêm code complexity không cần thiết.

import anthropic

client = anthropic.Anthropic()

def agent_stream(user_input: str):
    messages = [{"role": "user", "content": user_input}]

    with client.messages.stream(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        system="Bạn là assistant...",
        messages=messages,
    ) as stream:
        full_response = []

        for event in stream:
            if event.type == "content_block_delta":
                if event.delta.type == "text_delta":
                    # Stream text ra ngay lập tức
                    print(event.delta.text, end="", flush=True)
                    full_response.append(event.delta.text)

        print()  # newline sau khi xong
        final = stream.get_final_message()
        return "".join(full_response), final

Với FastAPI hoặc async context:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import anthropic

app = FastAPI()
client = anthropic.Anthropic()

@app.post("/agent/stream")
async def agent_stream_endpoint(user_input: str):
    def generate():
        with client.messages.stream(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system="Bạn là assistant...",
            messages=[{"role": "user", "content": user_input}],
        ) as stream:
            for text in stream.text_stream:
                yield f"data: {text}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

Lưu ý quan trọng: khi agent đang trong tool call loop (chưa trả kết quả cuối về user), streaming không có nhiều ý nghĩa vì token trả về là tool call JSON, không phải text. Bật streaming ở iteration cuối cùng (khi stop_reason == "end_turn" hoặc khi bạn biết LLM đang viết câu trả lời), không phải toàn bộ loop.

Phần 4: Parallel tool calls

Claude hỗ trợ parallel function calls: trong một response, LLM có thể quyết định gọi nhiều tool cùng lúc. Thay vì:

bước 1: gọi get_user_info → chờ → bước 2: gọi get_order_history → chờ → bước 3: gọi get_payment_status → chờ

Claude có thể trả về:

[
  {"type": "tool_use", "name": "get_user_info", "id": "t1", ...},
  {"type": "tool_use", "name": "get_order_history", "id": "t2", ...},
  {"type": "tool_use", "name": "get_payment_status", "id": "t3", ...}
]

Ba tools, một lần gọi LLM. Bạn chạy chúng song song, gửi cả ba kết quả về một lần.

import asyncio
import anthropic

client = anthropic.Anthropic()

async def execute_tool_async(name: str, args: dict) -> str:
    # Giả lập tool execution
    await asyncio.sleep(0.1)
    return f"Result of {name}"

async def agent_with_parallel_tools(user_input: str, max_iterations: int = 10):
    messages = [{"role": "user", "content": user_input}]

    for _ in range(max_iterations):
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            tools=TOOLS,
            messages=messages,
        )

        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            return next(
                (b.text for b in response.content if hasattr(b, "text")), ""
            )

        if response.stop_reason == "tool_use":
            tool_calls = [b for b in response.content if b.type == "tool_use"]

            # Chạy parallel nếu nhiều tool calls
            if len(tool_calls) > 1:
                tasks = [execute_tool_async(tc.name, tc.input) for tc in tool_calls]
                results = await asyncio.gather(*tasks)
            else:
                results = [await execute_tool_async(tool_calls[0].name, tool_calls[0].input)]

            tool_results = [
                {
                    "type": "tool_result",
                    "tool_use_id": tc.id,
                    "content": str(result),
                }
                for tc, result in zip(tool_calls, results)
            ]
            messages.append({"role": "user", "content": tool_results})

    return "Max iterations exceeded"

Khi tool call là I/O bound (API call, DB query, file read), parallel execution giảm latency đáng kể. Ba API call mỗi cái mất 200ms: sequential là 600ms, parallel là 200ms.

Khi nào Claude tự quyết định parallel: khi tool descriptions độc lập với nhau và không có dependency rõ ràng. Nếu tool B cần output của tool A, Claude sẽ gọi tuần tự (A trước, B sau). Giúp Claude nhận ra tool nào độc lập bằng cách viết description rõ ràng: “Lấy thông tin user từ DB” rõ ràng hơn “Query DB”.

Phần 5: Model tier mixing

Không phải mọi bước trong agent loop đều cần model xịn nhất.

Ví dụ: agent phân tích log Kubernetes với flow:

  1. Plan: xác định cần đọc log từ namespace nào, pod nào (cần reasoning)
  2. Execute: gọi tool đọc log, parse output (cần accuracy)
  3. Summarize: tóm tắt kết quả thành report ngắn (không cần reasoning phức tạp)

Bước 1 dùng Sonnet 4.6 ($3/MTok input). Bước 3 dùng Haiku 4.5 ($0.80/MTok input): rẻ hơn 3.75 lần, latency thấp hơn, đủ dùng cho task summarize.

import anthropic

client = anthropic.Anthropic()

def planning_step(context: str) -> str:
    """Dùng Sonnet cho bước cần reasoning."""
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system="Bạn là planner. Phân tích context và liệt kê các bước cần thực hiện.",
        messages=[{"role": "user", "content": context}],
    )
    return response.content[0].text

def summarize_step(raw_results: str) -> str:
    """Dùng Haiku cho bước summarize đơn giản."""
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=512,
        system="Tóm tắt kết quả sau thành 3-5 điểm chính, ngắn gọn.",
        messages=[{"role": "user", "content": raw_results}],
    )
    return response.content[0].text

def tiered_agent(user_input: str) -> str:
    # Bước plan: Sonnet
    plan = planning_step(user_input)

    # Bước execute: Sonnet với tools (cần accuracy cao)
    results = execution_loop(plan, model="claude-sonnet-4-6")

    # Bước summarize: Haiku (tiết kiệm cost)
    summary = summarize_step(results)

    return summary

Cost saving thực tế phụ thuộc vào token distribution giữa các bước. Nếu summarize chiếm 30% tổng token và dùng Haiku thay Sonnet, tiết kiệm được khoảng 30% × (3.0 - 0.8) / 3.0 = 22% tổng cost input.

Không lớn bằng caching, nhưng dễ implement và không có risk cache invalidation.

Phần 6: Token budget tracking trong loop

Bài 3 về control loop đề cập max_iterations như safety net. Token budget là safety net thứ hai, cụ thể hơn.

from dataclasses import dataclass, field

@dataclass
class TokenBudget:
    max_input_tokens: int = 50_000
    max_output_tokens: int = 10_000
    input_used: int = 0
    output_used: int = 0
    cache_read: int = 0
    cache_write: int = 0

    def update(self, usage) -> None:
        self.input_used += usage.input_tokens
        self.output_used += usage.output_tokens
        self.cache_read += getattr(usage, "cache_read_input_tokens", 0)
        self.cache_write += getattr(usage, "cache_creation_input_tokens", 0)

    def is_exceeded(self) -> bool:
        return (
            self.input_used > self.max_input_tokens
            or self.output_used > self.max_output_tokens
        )

    def cost_usd(self) -> float:
        return (
            self.input_used * 3.0 / 1_000_000
            + self.cache_write * 3.75 / 1_000_000
            + self.cache_read * 0.30 / 1_000_000
            + self.output_used * 15.0 / 1_000_000
        )

def agent_with_budget(user_input: str, budget: TokenBudget) -> str:
    messages = [{"role": "user", "content": user_input}]

    for i in range(20):  # max_iterations vẫn cần
        if budget.is_exceeded():
            raise RuntimeError(
                f"Token budget exceeded after {i} iterations. "
                f"Cost so far: ${budget.cost_usd():.4f}"
            )

        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            messages=messages,
        )

        budget.update(response.usage)
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            return next(
                (b.text for b in response.content if hasattr(b, "text")), ""
            )

        # handle tool_use...

    return "Max iterations exceeded"

Token budget và max_iterations không redundant. max_iterations bắt số vòng lặp vô hạn. Token budget bắt vòng lặp với token per iteration cao bất thường (ví dụ: tool trả về 10KB log thay vì 1KB như dự kiến, nhân lên 20 iteration là vấn đề khác hoàn toàn).

Cheatsheet optimization

Kỹ thuậtImpactComplexityKhi nào dùng
Prompt caching (system + tools)Cost -70 đến -90% trên phần được cacheThấpLuôn, ngay từ đầu
TTL 1 giờ (ENABLE_PROMPT_CACHING_1H=1)Cache survive > 5 phútRất thấp (env var)Batch job, background task
max_iterations nhỏCost cappedThấpLuôn
Token budget trackingCost predictableThấpProduction, billing-sensitive
Model tier mix (Sonnet plan, Haiku summarize)Cost -15 đến -25%Trung bìnhKhi có bước đơn giản rõ ràng
Parallel tool callsLatency -50 đến -70% trên I/OTrung bìnhKhi tools độc lập nhau
StreamingTTFT giảm, không giảm costThấpConversational agent
History truncationCost capped trên long-running agentTrung bìnhAgent chạy > 10 iteration thường xuyên

Pitfall tổng hợp: ba cái bẫy hay gặp nhất

Bẫy 1: Cache miss vì dynamic system prompt. Đã mô tả ở trên. Debug: log cache_creation_input_tokens mỗi iteration. Nếu luôn > 0 từ iteration 2 trở đi, đang bị miss.

Bẫy 2: Output token cost bị underestimate. Output rẻ hơn input trên giá/token, nhưng LLM có thể generate nhiều hơn bạn nghĩ khi dùng tool với nhiều arguments phức tạp. Một tool call JSON với nested args dài 500 tokens. Nhân với 20 iteration là 10k tokens output, $0.15. Không lớn đơn lẻ, nhưng trên 10k requests là $1500.

Bẫy 3: Streaming bật trên toàn bộ loop thay vì chỉ final response. Tool call response là JSON, không cần stream. Bật stream trên toàn bộ loop thêm connection overhead mà không giúp UX. Chỉ stream ở iteration cuối, khi bạn biết LLM đang trả lời user trực tiếp.

Lời kết

Cost và latency trong agent là hệ quả trực tiếp của architecture. Ba quyết định lớn nhất:

  1. Caching: implement ngay từ đầu, đừng để “sau tối ưu”. Khó thêm sau vì đòi hỏi refactor system prompt structure.
  2. max_iterations và token budget: cả hai, không chỉ một. Mỗi cái bắt một loại runaway khác nhau.
  3. Model tier: không cần Sonnet ở mọi bước. Haiku 4.5 đủ cho 30-40% workload nếu bạn thiết kế đúng.

Bài tiếp theo, Failure modes: hallucination, infinite loop, hijacking, mở rộng phần cost và latency theo hướng khác: khi agent không chỉ đắt mà còn sai. Hallucination trong tool call, infinite loop ẩn không bị bắt bởi max_iterations, và prompt injection qua tool results là ba failure mode production-grade cần biết trước khi ship.