Tôi từng nghĩ control loop là phần nhàm nhất trong agent. LLM mới là trái tim, tools là tay chân. Loop chỉ là cái khung while True bao ngoài.

Nghĩ vậy cho đến khi agent đốt $12 trong 8 phút vì max_iterations=100 và một lỗi permission denied mà LLM cứ retry mãi. Loop không nhàm. Loop là phần dễ sai nhất, và sai ở đây không phải sai logic, mà sai tiền thật.

Bài này đi sâu vào control loop: cấu trúc, stop conditions, ba cách đặt budget, cách detect vòng lặp vô hạn, và pitfall mà mọi người đều gặp ít nhất một lần. Nếu chưa đọc bài 1 thì nên xem qua phần “Control loop” ở đó trước vì bài này build trực tiếp từ pseudocode đó.

Phần 1: Anatomy của control loop

Hãy nghĩ về control loop như một vòng lặp dispatcher trong một message queue processor. Bạn poll() message, xử lý, ack, poll tiếp. Khi nào không còn message, không còn gì để process, hoặc queue error, mới dừng.

Agent cũng vậy. Thay vì message, bạn gọi LLM. Thay vì “còn message không”, bạn hỏi “LLM muốn làm gì tiếp theo”. Thay vì ack, bạn append tool result vào messages.

Cái khung đầy đủ nhất trông như thế này:

import anthropic

client = anthropic.Anthropic()

def run_agent(
    user_input: str,
    tools: list,
    max_iterations: int = 10,
    max_tokens_budget: int = 50_000,
) -> str:
    messages = [{"role": "user", "content": user_input}]
    total_tokens_used = 0
    last_tool_calls: list[tuple[str, str]] = []  # (tool_name, args_hash)

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

        # Track cumulative token usage
        total_tokens_used += response.usage.input_tokens + response.usage.output_tokens

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

        # --- Stop conditions ---
        if response.stop_reason == "end_turn":
            # LLM tự bảo xong
            text_blocks = [b for b in response.content if b.type == "text"]
            return text_blocks[-1].text if text_blocks else ""

        if response.stop_reason == "max_tokens":
            # Response bị cắt ngay giữa chừng
            raise RuntimeError(
                f"Response truncated at iteration {iteration}. "
                f"Increase max_tokens or reduce input context."
            )

        if total_tokens_used > max_tokens_budget:
            raise RuntimeError(
                f"Token budget exceeded: {total_tokens_used} > {max_tokens_budget}"
            )

        if response.stop_reason != "tool_use":
            # stop_sequence hay stop_reason không xác định
            raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason}")

        # --- Execute tools ---
        tool_results = []
        current_tool_calls = []

        for block in response.content:
            if block.type != "tool_use":
                continue

            import hashlib, json
            args_hash = hashlib.md5(
                json.dumps(block.input, sort_keys=True).encode()
            ).hexdigest()[:8]
            current_tool_calls.append((block.name, args_hash))

            try:
                result = execute_tool(block.name, block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": str(result),
                })
            except Exception as exc:
                # Trả error về cho LLM thay vì raise ngay
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": f"Error: {exc}",
                    "is_error": True,
                })

        # Detect loop: cùng tool + cùng args liên tiếp 3 lần
        if _is_looping(last_tool_calls, current_tool_calls, threshold=3):
            raise RuntimeError(
                f"Detected repeated tool calls (possible loop): {current_tool_calls}"
            )
        last_tool_calls = current_tool_calls

        messages.append({"role": "user", "content": tool_results})

    raise RuntimeError(f"Max iterations ({max_iterations}) exceeded")

Đây là khoảng 70 dòng. Hầu hết agent production không phức tạp hơn nhiều ở lõi.

Phần 2: stop_reason và ý nghĩa của từng giá trị

Claude Sonnet 4.6 trả về bốn giá trị stop_reason chính. Mỗi cái yêu cầu xử lý khác nhau:

stop_reasonÝ nghĩaXử lý đúng
end_turnLLM quyết định xong, không cần tool nào nữaReturn text, kết thúc loop
tool_useLLM muốn gọi một hoặc nhiều toolExecute tools, append results, loop tiếp
max_tokensResponse bị cắt vì vượt max_tokens paramRaise error, KHÔNG tiếp tục loop
stop_sequenceGặp một trong các stop sequence bạn khai báoXử lý như end_turn, hoặc parse theo use-case

Điểm hay bị bỏ qua nhất: max_tokens không phải lỗi bình thường. Khi response bị truncate, block cuối cùng trong response.content có thể là một tool_use block bị cắt nửa chừng. Nếu bạn cố parse nó, bạn sẽ nhận được JSON parse error hoặc tool call với args rỗng. Đừng loop tiếp. Raise lên, log, và tăng max_tokens trong lần sau.

Một điểm nữa: end_turn không đồng nghĩa với “task đã thật sự xong”. LLM có thể bảo “Tôi đã hoàn thành” trong khi thực tế nó mới đọc file đầu tiên trong một task cần đọc 10 file. Cách phòng tránh là trong system prompt, bảo LLM verify task completion trước khi nói end_turn. Hoặc dùng thêm một verifier call riêng. Sẽ nói thêm ở bài 6 khi đào sâu ReAct.

Phần 3: Ba cách đặt budget

Đây là phần ít được discuss nhất nhưng quan trọng nhất cho production.

Cách 1: max_iterations (đơn giản nhất, dễ sai nhất)

for iteration in range(max_iterations):
    ...
raise RuntimeError("Max iterations exceeded")

max_iterations=10 là safe default cho agent đơn giản. Vấn đề: cost mỗi iteration không đồng đều. Iteration 1 có thể tốn 500 tokens. Iteration 10 có thể tốn 8000 tokens vì history đã dài. max_iterations không phải cost budget, chỉ là số lần vòng lặp.

Nếu dùng max_iterations một mình, thêm giá trị nhỏ: 10 cho task nhỏ (đọc 1-2 file, query 1 API), 20-30 cho task trung bình (refactor một module), không quá 50 cho bất kỳ task nào vì bạn chưa hiểu tại sao nó cần nhiều vậy.

Cách 2: Token budget (chính xác nhất, phức tạp hơn một chút)

total_tokens_used = 0
max_tokens_budget = 50_000  # Khoảng $0.20 với Sonnet 4.6

while True:
    response = client.messages.create(...)
    total_tokens_used += response.usage.input_tokens + response.usage.output_tokens

    if total_tokens_used > max_tokens_budget:
        raise RuntimeError(f"Budget exceeded: {total_tokens_used} tokens")

    # ... rest of loop

Ưu điểm: bạn biết chính xác sẽ tốn bao nhiêu trong worst case. 50_000 tokens × ($3/MTok input + $15/MTok output) / 2 ≈ $0.45 cho một agent run.

Lưu ý quan trọng: input_tokens tăng theo cấp số cộng mỗi iteration vì bạn append history. Iteration 1: 1000 tokens input. Iteration 5: có thể 5000 tokens input chỉ vì history. Đây là lý do token budget chính xác hơn iteration count.

Cách 3: Wall-clock timeout (cho async/production)

import time

start_time = time.time()
timeout_seconds = 120  # 2 phút

while True:
    if time.time() - start_time > timeout_seconds:
        raise RuntimeError(f"Timeout after {timeout_seconds}s")
    
    response = client.messages.create(...)
    # ...

Dùng khi agent chạy trong web request (FastAPI, Django) và bạn cần đảm bảo response time. Hoặc khi tool execution có thể block lâu (browser automation, code execution sandbox).

Recommendation cho production: dùng cả ba cùng lúc. max_iterations như safety net thô, token budget như cost control, wall-clock như SLA enforcement.

def run_agent(
    user_input: str,
    tools: list,
    max_iterations: int = 15,
    max_tokens_budget: int = 80_000,
    timeout_seconds: int = 180,
) -> str:
    start_time = time.time()
    total_tokens = 0
    # ...

Phần 4: Detect loop

Đây là phần tôi thêm vào sau khi bị đốt $12.

Tình huống: agent được giao task “tạo file /tmp/output.txt với nội dung XYZ”. File đó đang bị permission denied vì user chạy agent thiếu quyền. LLM không biết điều này, chỉ thấy error, cố gắng retry. Mỗi lần retry là một iteration, một lần gọi API.

Pattern này: cùng tool, cùng args, nhiều lần liên tiếp.

def _is_looping(
    last_calls: list[tuple[str, str]],
    current_calls: list[tuple[str, str]],
    threshold: int = 3,
) -> bool:
    if not last_calls or not current_calls:
        return False
    # So sánh sorted để không phụ thuộc thứ tự tool call
    if sorted(last_calls) != sorted(current_calls):
        # Khác nhau, không loop
        return False

    # Đếm số lần liên tiếp bằng cách check rolling window
    # (Simplified: nếu last == current, tăng counter ở ngoài)
    return True  # Caller tự track counter

Trong thực tế, bạn cần maintain một rolling counter bên ngoài:

repeat_count = 0
last_fingerprint = None

for iteration in range(max_iterations):
    # ... gọi LLM, execute tools ...

    current_fingerprint = frozenset(current_tool_calls)
    if current_fingerprint == last_fingerprint:
        repeat_count += 1
        if repeat_count >= 3:
            raise RuntimeError(
                f"Loop detected: same tool calls repeated {repeat_count} times. "
                f"Calls: {current_tool_calls}"
            )
    else:
        repeat_count = 0

    last_fingerprint = current_fingerprint

Threshold 3 là safe default. Một số task hợp lệ cần gọi cùng tool nhiều lần (pagination: list_files(offset=0), list_files(offset=100), list_files(offset=200)), nhưng args khác nhau nên không trigger. Chỉ khi cùng args thì mới là loop.

Phần 5: Recoverable vs unrecoverable errors

Không phải mọi error đều nên raise ngay lên trên. Agent có khả năng tự recover khá tốt nếu bạn truyền error về đúng cách.

Recoverable (truyền error về cho LLM):

try:
    result = execute_tool(block.name, block.input)
    tool_results.append({
        "type": "tool_result",
        "tool_use_id": block.id,
        "content": str(result),
    })
except FileNotFoundError as exc:
    # LLM có thể tự chọn path khác hoặc tạo file mới
    tool_results.append({
        "type": "tool_result",
        "tool_use_id": block.id,
        "content": f"Error: File not found: {exc}. Suggestion: check if path exists first.",
        "is_error": True,
    })
except requests.HTTPError as exc:
    if exc.response.status_code in (429, 503):
        # Rate limit hoặc service unavailable, trả về cho LLM để nó wait rồi retry
        tool_results.append({
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": f"Error: {exc}. Please wait and retry.",
            "is_error": True,
        })
    else:
        raise  # 4xx khác không recoverable

Unrecoverable (raise ngay lên trên):

except PermissionError as exc:
    # LLM sẽ retry mãi, không fix được, raise ngay
    raise RuntimeError(f"Permission denied: {exc}. Agent cannot proceed.") from exc

except json.JSONDecodeError as exc:
    # Tool schema bị lỗi, không phải runtime error, raise ngay
    raise ValueError(f"Tool returned invalid JSON: {exc}") from exc

Rule of thumb: nếu error có thể được giải quyết bằng cách thử khác (khác path, khác args, khác approach), truyền về cho LLM. Nếu error là điều kiện cố định không thể thay đổi tại runtime (permission, authentication, schema mismatch), raise ngay.

Lý do không nên truyền mọi error về LLM: LLM sẽ cố gắng fix, đôi khi theo cách sáng tạo không mong muốn. Một agent bị PermissionError khi ghi vào /etc/hosts có thể được LLM “sáng kiến” thêm sudo vào command nếu bạn để nó tự xử lý. Đó là một lỗ hổng security, không phải feature.

Phần 6: Pitfall agent dừng sớm

Tôi đã đề cập ngắn về điều này ở trên, nhưng xứng đáng một phần riêng vì nó subtle hơn tưởng.

end_turn không đồng nghĩa task đã xong. Hãy xem ví dụ này:

User: "Đọc toàn bộ file trong /project và tóm tắt mỗi file"

Agent gọi list_dir("/project"), nhận về 20 files. Gọi read_file cho file đầu tiên. Tóm tắt xong file đó, LLM trả về text “Tôi đã đọc xong file đầu tiên: main.py. Nội dung bao gồm…”. stop_reason = "end_turn".

Loop kết thúc. Agent bảo xong. Nhưng chỉ có 1/20 files được đọc.

Tại sao LLM dừng sớm? Vì system prompt không nói rõ “đọc toàn bộ, đến khi không còn file nào chưa đọc”. LLM đọc nghĩa “đọc và tóm tắt” theo cách hợp lý nhất với context hiện tại, không biết bạn muốn tất cả 20 files.

Ba cách fix:

Fix 1: System prompt rõ ràng hơn

system = """
Khi được yêu cầu xử lý nhiều item, tiếp tục loop cho đến khi xử lý hết TẤT CẢ item.
Không dừng sau item đầu tiên. Chỉ return khi không còn item nào chưa xử lý.
"""

Fix 2: Structured output để verify

Thêm một tool report_done mà LLM phải gọi trước khi kết thúc:

tools.append({
    "name": "report_done",
    "description": "Call this when ALL tasks are complete. Include summary of what was done.",
    "input_schema": {
        "type": "object",
        "properties": {
            "files_processed": {"type": "integer"},
            "summary": {"type": "string"},
        },
        "required": ["files_processed", "summary"],
    }
})

Agent phải gọi report_done trước khi end_turn. Nếu loop kết thúc mà không có report_done, raise error.

Fix 3: Verifier call (đắt nhất, chắc nhất)

Sau khi loop kết thúc, gọi LLM một lần nữa để verify:

verify_response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=512,
    messages=messages + [{
        "role": "user",
        "content": "Is the original task fully complete? Answer yes or no with brief reason."
    }]
)
if "no" in verify_response.content[0].text.lower():
    # Tiếp tục loop
    ...

Verifier call tốn thêm token nhưng cho tasks quan trọng (migration, batch processing, file surgery) thì xứng đáng.

Cheatsheet

Vấn đềTriệu chứngFix
max_iterations quá lớnAgent loop mãi, đốt tiềnDefault 10, max 30, dùng token budget song song
Không track tokenKhông biết cost đến khi nhận billCộng dồn usage.input_tokens + usage.output_tokens mỗi iteration
max_tokens truncateJSON parse error, tool call rỗngCheck stop_reason == "max_tokens", raise ngay, đừng loop tiếp
Agent dừng sớmChỉ xử lý 1/N itemsSystem prompt explicit về “xử lý hết”, hoặc thêm report_done tool
Loop vô hạn do errorCùng tool, cùng args, lặp N lầnDetect fingerprint, raise sau 3 lần lặp
Error recoverable/unrecoverableKhông phân biệt, raise hếtPermission/auth errors: raise ngay. FileNotFound/rate-limit: truyền về LLM
Vượt ngân sách$X trong Y phút, không biết tại saoToken budget (max_tokens_budget), wall-clock timeout cả hai song song
stop_reasonÝ nghĩaAction
end_turnLLM tự kết thúcReturn, nhưng consider verify
tool_useLLM cần gọi toolExecute tools, loop tiếp
max_tokensResponse bị cắtRaise, đừng loop tiếp
stop_sequenceHit stop sequenceReturn hoặc parse theo use-case

Lời kết

Control loop là nơi mọi agent đều bắt đầu đơn giản và dần dần tích lũy những guard clause. Iteration counter. Token tracker. Loop detector. Verifier call. Mỗi cái được thêm vào sau một incident cụ thể.

Bài tiếp theo, Memory cho agent: context window, scratchpad, summarization, sẽ tập trung vào phần memory của agent. History tăng mỗi vòng lặp, sớm muộn cũng chạm context window limit. Cách handle: truncate, summarize, hay scratchpad. Ba approach khác nhau, trade-off khác nhau.

Nếu muốn đào sâu hơn về ReAct pattern (thought/action/observation cycle mà control loop ở đây implement ngầm), xem bài 6. Nếu muốn đi thẳng vào cost và token budget với số liệu cụ thể cho Claude Sonnet 4.6, xem bài 22.