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:

  1. 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
  2. Agent restart: sau lỗi network, agent chạy lại từ checkpoint, tool bị gọi lần hai
  3. 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ốngDùng
Bước 2 phụ thuộc dữ liệu của bước 1Atomic
Bước 1 và 2 độc lập, mỗi bước có giá trị riêngPartial
Side effect không thể undo (email đã gửi, tiền đã charge)Atomic với compensation
Dữ liệu read-only hoặc soft-deletePartial 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ắcLàmKhông làm
Đặt têncancel_order, set_user_emailupdate_user, handle_request
Description”Use this when… Does NOT…""Updates X in database”
Required argsChỉ những gì tool không thể chạy thiếuOverspecify để an toàn
Enum vs stringEnum cho tập giá trị hữu hạnFree-text cho trạng thái cố định
ValidationPydantic trước khi thực thiTrust LLM output trực tiếp
IdempotencyKey do agent tạo, server check trước khi executeĐể LLM tự sinh key
Partial failureTrả partial success + message rõ ràngRollback toàn bộ khi bước phụ fail
ComposabilityOutput là ID/reference dùng được cho tool kếTrả full object, tool kế phải parse
Tool set sizeGiữ dưới 10, nhóm lại khi cầnLiệ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ớ:

  1. 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.
  2. Idempotency key không optional cho write operations trong production. Agent retry là chuyện thường, tool cần xử lý được.
  3. 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).