Bài 2 dạy bạn viết tool: JSON schema, function calling, error handling cơ bản. Bạn đã có agent chạy được.
Bài này đi xa hơn. Khi agent vào production, tool design trở thành điểm thắt. Tool viết sai không fail ngay, nó fail theo cách tinh vi: LLM gọi nhầm tool, truyền sai arg, retry và gây side effect kép, tạo partial state không thể rollback. Những lỗi này không xuất hiện trong dev, chỉ xuất hiện khi traffic thật.
Pitfall đầu tiên: hai tool tên gần giống
Tôi build một agent quản lý người dùng. Hai tool:
tools = [
{
"name": "update_user",
"description": "Update user account information",
"input_schema": {
"type": "object",
"properties": {
"user_id": {"type": "string"},
"email": {"type": "string"},
"phone": {"type": "string"},
},
"required": ["user_id"]
}
},
{
"name": "update_profile",
"description": "Update user profile details",
"input_schema": {
"type": "object",
"properties": {
"user_id": {"type": "string"},
"display_name": {"type": "string"},
"bio": {"type": "string"},
"avatar_url": {"type": "string"},
},
"required": ["user_id"]
}
}
]
Sau một tuần production: LLM gọi sai tool trong khoảng 28% các request cập nhật thông tin. User yêu cầu đổi bio, LLM gọi update_user thay vì update_profile. User yêu cầu đổi email, LLM đôi khi gọi update_profile. Không có exception, không có error log. Chỉ có dữ liệu không được cập nhật đúng chỗ.
Tại sao? Vì từ góc nhìn của LLM, hai tên này semantically gần nhau và description đều mơ hồ. “account information” và “profile details” không phân biệt rõ ràng từ ngữ cảnh tự nhiên. LLM phải đoán.
Fix đơn giản nhất: merge thành một tool. Fix tốt hơn: đặt tên và description theo nguyên tắc.
Nguyên tắc 1: Single Responsibility, một tool một action
Một tool nên làm đúng một việc, và tên tool phải nói lên việc đó.
Tên sai:
update_user(update cái gì trong user?)manage_data(manage là một động từ quá chung)process_request(process là gì?)
Tên đúng:
set_user_email(đặt email cho user)append_log_entry(thêm một dòng log)cancel_order(huỷ đơn hàng)
Quy tắc đặt tên: <động từ cụ thể>_<danh từ cụ thể>. Động từ nên là một trong các nhóm: get/list/search (read-only), create/add/insert (tạo mới), update/set/patch (sửa), delete/remove/cancel (xoá hoặc huỷ).
Khi bạn thấy mình muốn dùng động từ handle, process, manage: đó là dấu hiệu tool đang làm nhiều hơn một việc.
Nguyên tắc 2: Description viết cho LLM, không phải cho dev
Description là thứ LLM đọc để quyết định gọi tool nào. Hầu hết dev viết description như docstring, tức là giải thích cách tool hoạt động. Sai hướng.
LLM cần biết khi nào gọi tool này, không phải tool này làm gì.
Description sai:
"description": "Updates user profile details in the database"
Description đúng:
"description": "Use this when the user wants to change their display name, bio, or avatar. Does NOT handle email or password changes."
Cấu trúc description hiệu quả:
"Use this when [trigger condition]. [What it does]. Does NOT [adjacent case to exclude]."
Phần “Does NOT” quan trọng không kém phần mô tả chức năng. Nó giảm overlap giữa các tool, giúp LLM phân loại đúng edge case.
Ví dụ cho agent quản lý order:
tools = [
{
"name": "get_order_status",
"description": (
"Use this when the user asks about the current state of an order "
"(pending, processing, shipped, delivered). Returns order details and "
"tracking info. Does NOT handle refunds or cancellations."
),
...
},
{
"name": "cancel_order",
"description": (
"Use this when the user explicitly requests to cancel an order. "
"Only works for orders in 'pending' or 'processing' state. "
"Does NOT process refunds, call a separate refund tool for that."
),
...
},
{
"name": "request_refund",
"description": (
"Use this when the user wants money back for a delivered or cancelled order. "
"Requires order_id and reason. Does NOT cancel orders."
),
...
}
]
Ba tool, ba domain rõ ràng, không overlap. LLM không cần đoán.
Nguyên tắc 3: Schema design, required vs optional, enum vs free-text
Required vs optional
Chỉ đặt required cho args mà tool không thể chạy thiếu. Đừng overspecify.
# Quá nhiều required: agent phải thu thập đủ 4 trường mới gọi được
"required": ["user_id", "email", "phone", "address"]
# Đúng: chỉ cần user_id, còn lại optional
"required": ["user_id"]
"properties": {
"user_id": ...,
"email": ..., # optional: chỉ cập nhật nếu user muốn đổi
"phone": ..., # optional
"address": ..., # optional
}
Khi bạn đặt nhiều field là required, agent bị buộc phải hỏi user nhiều câu trước khi thực hiện, hoặc phải hallucinate giá trị. Cả hai đều tệ.
Nguyên tắc: required là precondition của tool execution, không phải precondition của tính năng.
Enum vs free-text
Dùng enum khi tập giá trị hữu hạn và quan trọng. Dùng string khi giá trị không thể liệt kê.
# Tốt: enum cho trạng thái
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "critical"],
"description": "Task priority level"
}
# Tốt: free-text cho nội dung
"message": {
"type": "string",
"description": "Notification message to send to user"
}
# Sai: free-text cho trường có tập giá trị cố định
"status": {
"type": "string",
"description": "Order status: pending, processing, shipped, or delivered"
# LLM có thể truyền "in_transit", "sent", "on the way"...
}
Enum không chỉ là validation. Enum còn là hint cho LLM: nó thấy danh sách giá trị hợp lệ và chọn đúng hơn so với đọc một câu description.
Validation layer với Pydantic
Sau khi LLM trả về tool call, trước khi thực thi tool, nên validate args. LLM có thể hallucinate giá trị không hợp lệ.
from pydantic import BaseModel, validator
from typing import Optional, Literal
import anthropic
client = anthropic.Anthropic()
class CancelOrderInput(BaseModel):
order_id: str
reason: Literal["customer_request", "out_of_stock", "payment_failed", "other"]
note: Optional[str] = None
@validator("order_id")
def order_id_format(cls, v):
if not v.startswith("ORD-"):
raise ValueError("order_id must start with 'ORD-'")
return v
def handle_tool_call(tool_name: str, tool_input: dict) -> str:
if tool_name == "cancel_order":
try:
validated = CancelOrderInput(**tool_input)
except Exception as e:
# Trả error string về cho LLM để nó tự sửa
return f"Validation error: {e}. Please check the arguments and retry."
return cancel_order_impl(validated)
return f"Unknown tool: {tool_name}"
def cancel_order_impl(input: CancelOrderInput) -> str:
# Logic thật ở đây
return f"Order {input.order_id} cancelled. Reason: {input.reason}"
Khi validation fail, trả error string về cho LLM thay vì raise exception. LLM nhận được message tường minh, thường tự sửa args và retry đúng. Nếu raise exception về tầng agent, agent phải handle, phức tạp hơn không cần thiết.
Nguyên tắc 4: Idempotency key
Đây là phần quan trọng nhất mà hầu hết agent tutorial bỏ qua.
Trong control loop của agent, tool có thể được gọi lại. Nhiều lý do:
- LLM retry: LLM không chắc tool đã thành công (không nhận được confirmation rõ ràng), gọi lại
- Agent restart: sau lỗi network, agent chạy lại từ checkpoint, tool bị gọi lần hai
- Human-in-the-loop: agent bị tạm dừng để user confirm, khi resume tool được gọi lại
Với tools read-only (get_order_status, search_products): retry không vấn đề.
Với tools có side effect (create_order, send_email, charge_card): retry hai lần là thảm hoạ.
Idempotency key là một ID do caller tạo ra, truyền vào mỗi tool call. Phía server dùng ID này để detect duplicate và trả về kết quả của lần đầu thay vì thực thi lần hai.
import uuid
from pydantic import BaseModel
from typing import Optional
import anthropic
client = anthropic.Anthropic()
class CreateOrderInput(BaseModel):
customer_id: str
product_id: str
quantity: int
idempotency_key: str # Bắt buộc cho mọi write operation
# Agent tạo idempotency key trước khi LLM gọi tool
def prepare_tool_call_context() -> str:
"""Generate a unique idempotency key for this tool call attempt."""
return str(uuid.uuid4())
# Phía server: check key trước khi thực thi
_idempotency_store: dict[str, dict] = {} # In production: Redis hoặc DB
def create_order_impl(input: CreateOrderInput) -> dict:
if input.idempotency_key in _idempotency_store:
# Duplicate detected: trả về kết quả cũ
return _idempotency_store[input.idempotency_key]
# Thực thi lần đầu
order_id = f"ORD-{uuid.uuid4().hex[:8].upper()}"
result = {
"order_id": order_id,
"status": "created",
"customer_id": input.customer_id,
}
# Lưu vào store trước khi trả về
_idempotency_store[input.idempotency_key] = result
return result
Một điểm tinh vi: idempotency key phải được tạo bên ngoài LLM. Nếu để LLM tự sinh key, nó có thể sinh key khác nhau mỗi lần retry, vô hiệu hoá cơ chế idempotency. Key phải được tạo ở tầng agent hoặc truyền từ phía user.
Pattern phổ biến trong production: tạo key dựa trên hash của (user_id + task_id + tool_name + core_args), không phải UUID ngẫu nhiên. Cách này đảm bảo cùng intent sẽ luôn có cùng key.
Nguyên tắc 5: Atomic vs partial failure
Khi một tool thực hiện nhiều bước, câu hỏi quan trọng là: nếu bước 2 fail sau khi bước 1 đã thành công, bạn làm gì?
Atomic tool: hoặc tất cả thành công hoặc không có gì thay đổi. Dùng transaction hoặc saga pattern.
Partial tool: trả về kết quả của các bước đã thành công, kèm thông báo bước nào fail. Agent (LLM) quyết định tiếp theo.
Không cái nào luôn đúng. Nguyên tắc chọn:
| Tình huống | Dùng |
|---|---|
| Bước 2 phụ thuộc dữ liệu của bước 1 | Atomic |
| Bước 1 và 2 độc lập, mỗi bước có giá trị riêng | Partial |
| Side effect không thể undo (email đã gửi, tiền đã charge) | Atomic với compensation |
| Dữ liệu read-only hoặc soft-delete | Partial OK |
Ví dụ tool create_and_notify_order không nên atomic:
def create_and_notify_order_impl(input) -> str:
# Bước 1: tạo order trong DB
try:
order = db.create_order(...)
except Exception as e:
return f"Failed to create order: {e}"
# Bước 2: gửi email notification (có thể fail)
try:
email_service.send(order.customer_email, order.id)
return f"Order {order.id} created and notification sent."
except Exception as e:
# Không rollback order, trả partial success về LLM
return (
f"Order {order.id} created successfully. "
f"Email notification failed: {e}. "
f"You may retry sending the notification separately."
)
LLM nhận được partial success, có thể tiếp tục bằng cách gọi tool gửi email riêng. Order không bị mất chỉ vì email fail.
Nguyên tắc 6: Tool composability
Tool set thiết kế tốt tạo ra chain tự nhiên: output của tool A là input của tool B.
search_products(query) -> [product_id, ...]
get_product_detail(product_id) -> {price, stock, ...}
add_to_cart(product_id, quantity) -> {cart_id, ...}
checkout(cart_id, payment_method) -> {order_id, ...}
Mỗi tool trả về ID hoặc reference mà tool kế tiếp có thể dùng. LLM học được pattern này qua description và ví dụ, tự tổng hợp multi-step workflow mà không cần bạn hardcode.
Dấu hiệu tool set composable kém:
- Tool A trả về full object nhưng tool B chỉ cần một field
- Tool B nhận composite key (
user_id + product_id) trong khi tool A chỉ trả vềuser_id - Phải gọi một tool “glue” ở giữa chỉ để transform output sang input
Khi thấy cần tool “glue”, thường có nghĩa schema của một trong hai tool cần được thiết kế lại.
Khi nào 5 tool nhỏ vs 1 tool to?
Câu hỏi thực tế nhất khi thiết kế tool set. Không có câu trả lời tuyệt đối, có framework để quyết định:
Tách ra 5 tool nhỏ khi:
- Mỗi action có thể hữu ích độc lập (LLM không phải gọi tất cả 5 cái mỗi lần)
- Actions có side effect khác nhau (một cái read-only, một cái write)
- Muốn retry từng bước một thay vì retry cả bundle
- Tool to vượt quá ~3-4 args bắt buộc
Dùng 1 tool to (composite tool) khi:
- Các bước luôn được gọi cùng nhau, không bao giờ tách
- Atomicity quan trọng hơn flexibility
- Số lượng tool trong tool set đã nhiều, cần giảm cognitive load cho LLM
Quy tắc thực tế: khi tool set vượt quá 10 tools, LLM bắt đầu confusion giữa các tool tương tự. Ở mức đó, xem xét nhóm tools vào composite tools hoặc tách ra thành sub-agent chuyên biệt.
Cheatsheet: tool design best practices
| Nguyên tắc | Làm | Không làm |
|---|---|---|
| Đặt tên | cancel_order, set_user_email | update_user, handle_request |
| Description | ”Use this when… Does NOT…" | "Updates X in database” |
| Required args | Chỉ những gì tool không thể chạy thiếu | Overspecify để an toàn |
| Enum vs string | Enum cho tập giá trị hữu hạn | Free-text cho trạng thái cố định |
| Validation | Pydantic trước khi thực thi | Trust LLM output trực tiếp |
| Idempotency | Key do agent tạo, server check trước khi execute | Để LLM tự sinh key |
| Partial failure | Trả partial success + message rõ ràng | Rollback toàn bộ khi bước phụ fail |
| Composability | Output là ID/reference dùng được cho tool kế | Trả full object, tool kế phải parse |
| Tool set size | Giữ dưới 10, nhóm lại khi cần | Liệt kê 20+ tools trong một lần call |
Tổng kết
Tool design quyết định nhiều hơn bạn nghĩ. Một agent có planning tốt, memory tốt nhưng tool set kém thì vẫn fail theo những cách khó debug. Lý do: LLM không fail loudly khi gọi nhầm tool. Nó fail silently, chọn tool gần đúng nhất theo hiểu biết của mình.
Ba điểm quan trọng nhất để nhớ:
- Description viết cho LLM, không phải cho dev. Trigger condition và exclusion quan trọng hơn mô tả chức năng.
- Idempotency key không optional cho write operations trong production. Agent retry là chuyện thường, tool cần xử lý được.
- Hai tool tên gần giống là bug tiềm ẩn. Nếu không thể đặt tên khác nhau rõ ràng, xem xét merge chúng.
Bài tiếp theo, Code execution sandbox: subprocess, Docker, e2b, sẽ đi vào loại tool nguy hiểm nhất: tool cho phép agent chạy code. Khi agent được phép execute arbitrary code, attack surface mở rộng đáng kể, và sandbox design trở thành yêu cầu bắt buộc.
Nếu bạn quan tâm đến chuẩn hoá tool layer để tools có thể tái sử dụng qua nhiều agent, đọc trước bài 15 về MCP (Model Context Protocol).