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ĩa | Xử lý đúng |
|---|---|---|
end_turn | LLM quyết định xong, không cần tool nào nữa | Return text, kết thúc loop |
tool_use | LLM muốn gọi một hoặc nhiều tool | Execute tools, append results, loop tiếp |
max_tokens | Response bị cắt vì vượt max_tokens param | Raise error, KHÔNG tiếp tục loop |
stop_sequence | Gặp một trong các stop sequence bạn khai báo | Xử 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ứng | Fix |
|---|---|---|
| max_iterations quá lớn | Agent loop mãi, đốt tiền | Default 10, max 30, dùng token budget song song |
| Không track token | Không biết cost đến khi nhận bill | Cộng dồn usage.input_tokens + usage.output_tokens mỗi iteration |
| max_tokens truncate | JSON parse error, tool call rỗng | Check stop_reason == "max_tokens", raise ngay, đừng loop tiếp |
| Agent dừng sớm | Chỉ xử lý 1/N items | System prompt explicit về “xử lý hết”, hoặc thêm report_done tool |
| Loop vô hạn do error | Cùng tool, cùng args, lặp N lần | Detect fingerprint, raise sau 3 lần lặp |
| Error recoverable/unrecoverable | Không phân biệt, raise hết | Permission/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 sao | Token budget (max_tokens_budget), wall-clock timeout cả hai song song |
stop_reason | Ý nghĩa | Action |
|---|---|---|
end_turn | LLM tự kết thúc | Return, nhưng consider verify |
tool_use | LLM cần gọi tool | Execute tools, loop tiếp |
max_tokens | Response bị cắt | Raise, đừng loop tiếp |
stop_sequence | Hit stop sequence | Return 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.