Bài 4: Agentic Tool Design & Integration¶
Tổng quan¶
Chất lượng agent phụ thuộc rất nhiều vào chất lượng tool - tool thiết kế tồi khiến model gọi sai, gọi thừa, hoặc không biết khi nào dùng. Bài này bao gồm nguyên tắc thiết kế tool, chiến lược chọn tool khi có hàng trăm tool (bao gồm khủng hoảng tool sprawl và cách phòng thủ bằng progressive disclosure), bảo mật khi thực thi tool do LLM quyết định, và chuẩn MCP để chuẩn hoá tích hợp.
1. Tool Design Principles¶
Atomic vs Composite¶
Atomic tool làm đúng 1 việc. Composite tool gộp nhiều bước thành 1 lời gọi.
# Atomic - 3 tool riêng biệt
@tool
def get_customer(customer_id: str) -> dict:
"""Lấy thông tin khách hàng theo ID."""
...
@tool
def get_orders(customer_id: str) -> list:
"""Lấy danh sách đơn hàng của khách hàng."""
...
@tool
def get_payment_status(order_id: str) -> str:
"""Lấy trạng thái thanh toán của đơn hàng."""
...
# Composite - gộp 3 bước trên thành 1 tool
@tool
def get_customer_full_summary(customer_id: str) -> dict:
"""Lấy thông tin khách hàng + đơn hàng + trạng thái thanh toán - tất cả trong 1 lần."""
customer = get_customer(customer_id)
orders = get_orders(customer_id)
for order in orders:
order["payment_status"] = get_payment_status(order["id"])
return {"customer": customer, "orders": orders}
| Atomic | Composite | |
|---|---|---|
| Số lời gọi LLM | Nhiều hơn (model tự nối các bước) | Ít hơn (1 lời gọi = nhiều thao tác) |
| Linh hoạt | Cao - model tự quyết định thứ tự, có thể bỏ bước không cần | Thấp - luôn chạy full quy trình dù chỉ cần 1 phần |
| Độ trễ | Cao hơn (nhiều round-trip với LLM) | Thấp hơn (gộp trong 1 lần thực thi) |
| Dễ debug | Dễ hơn - mỗi tool 1 trách nhiệm rõ ràng | Khó hơn - lỗi có thể ở bất kỳ bước con nào |
Nguyên tắc chọn
Bắt đầu với atomic tools. Chỉ gộp thành composite khi đo được rằng model luôn gọi một chuỗi tool cố định theo cùng thứ tự - lúc đó composite giảm latency mà không mất linh hoạt thực tế.
Error Handling¶
Tool phải trả về lỗi dưới dạng model đọc hiểu được, không phải raise exception làm crash agent.
@tool
def get_weather(city: str) -> str:
"""Lấy thời tiết hiện tại của thành phố."""
try:
data = call_weather_api(city)
return f"{city}: {data['temp']}°C, {data['condition']}"
except CityNotFoundError:
return f"Lỗi: Không tìm thấy thành phố '{city}'. Hãy kiểm tra lại tên hoặc thử tên tiếng Anh."
except APITimeoutError:
return "Lỗi: Dịch vụ thời tiết đang chậm phản hồi, hãy thử lại sau."
Không để exception văng lên tận agent loop
Nếu tool raise exception không bắt, toàn bộ graph crash thay vì cho model cơ hội thử cách khác. Luôn bọc logic tool trong try/except và trả về message lỗi rõ ràng để model tự quyết định bước tiếp theo (thử lại, hỏi user, dùng tool khác).
Idempotency¶
Với tool có side effect (gửi email, tạo đơn hàng, xoá dữ liệu), cần đảm bảo gọi nhiều lần không gây hậu quả nhân đôi - vì model có thể gọi lại tool do retry hoặc do reasoning lặp.
import hashlib
@tool
def create_order(customer_id: str, items: list, idempotency_key: str = None) -> dict:
"""Tạo đơn hàng mới. idempotency_key nên được set cố định cho cùng 1 yêu cầu logic."""
key = idempotency_key or hashlib.sha256(
f"{customer_id}-{items}".encode()
).hexdigest()
existing = db.find_order_by_idempotency_key(key)
if existing:
return {"order_id": existing["id"], "status": "already_created"}
order = db.create_order(customer_id, items, idempotency_key=key)
return {"order_id": order["id"], "status": "created"}
Áp dụng cho: Mọi tool có side effect không thể hoàn tác dễ dàng - đặt hàng, gửi email/SMS, thanh toán, xoá dữ liệu.
2. Tool Selection¶
Khi agent có hàng chục đến hàng trăm tool, nhét toàn bộ schema vào mỗi lời gọi LLM vừa tốn token vừa khiến model dễ chọn nhầm tool.
Khủng hoảng Tool Sprawl¶
Kết nối agent với nhiều MCP server (Bài 1, Bài 4 mục 4) rất dễ - và đó chính là vấn đề. Việc dễ kết nối khiến người ta kết nối quá nhiều tool mà không chọn lọc, và hệ quả đo được rất rõ:
| Chỉ số | Số liệu thực tế |
|---|---|
| Context bị tool chiếm (3 MCP server) | 143K/200K token (72%) trước khi đọc tin nhắn user |
| GitHub MCP server mặc định | 91 tool definitions |
| Độ chính xác chọn tool (bộ tool gọn) | ~43% |
| Độ chính xác chọn tool (bộ tool phình to) | Dưới 14% (giảm 3 lần) |
| Ngưỡng chính xác thực tế | 5-7 tool cho độ chính xác ổn định |
Nhiều tool hơn không có nghĩa là agent giỏi hơn
Với > 20 tool, model dễ nhầm giữa các tool tên/mô tả tương tự (github_create_issue vs github_create_pull_request vs github_create_release...) và bắt đầu hallucinate tham số. Đây là nguyên nhân phổ biến khiến agent "thông minh" hoạt động tệ hơn khi được cấp thêm quyền truy cập tool.
Hierarchical Grouping¶
Chia tool thành nhóm theo domain, chỉ nạp nhóm liên quan dựa trên intent của user.
TOOL_GROUPS = {
"calendar": [check_calendar, create_event, cancel_event],
"finance": [get_stock_price, get_exchange_rate, calculate_tax],
"customer_support": [get_customer, get_orders, create_ticket],
}
def classify_intent(user_message: str) -> str:
"""Phân loại nhanh xem câu hỏi thuộc nhóm nào."""
classify_prompt = f"""Câu hỏi sau thuộc nhóm nào: calendar, finance, customer_support?
Chỉ trả về 1 từ.
Câu hỏi: {user_message}"""
return llm.invoke(classify_prompt).content.strip().lower()
def get_relevant_tools(user_message: str) -> list:
group = classify_intent(user_message)
return TOOL_GROUPS.get(group, [])
Dynamic Loading & Tool Retrieval¶
Với số lượng tool rất lớn (hàng trăm, vd: mỗi API endpoint là 1 tool), dùng embedding similarity để retrieve tool liên quan nhất - giống RAG nhưng cho tool thay vì tài liệu.
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
embedding = OpenAIEmbeddings(model="text-embedding-3-small")
# Index toàn bộ tool descriptions
tool_store = Chroma.from_texts(
texts=[t.description for t in ALL_TOOLS],
embedding=embedding,
metadatas=[{"tool_name": t.name} for t in ALL_TOOLS],
)
def retrieve_relevant_tools(query: str, k: int = 5) -> list:
"""Chỉ lấy top-k tool liên quan nhất thay vì nạp toàn bộ."""
results = tool_store.similarity_search(query, k=k)
tool_names = {r.metadata["tool_name"] for r in results}
return [t for t in ALL_TOOLS if t.name in tool_names]
def agent_node(state: AgentState) -> dict:
user_query = state["messages"][-1].content
relevant_tools = retrieve_relevant_tools(user_query)
llm_with_tools = llm.bind_tools(relevant_tools)
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
| Chiến lược | Phù hợp khi | Đánh đổi |
|---|---|---|
| Nạp toàn bộ tool | < 10-15 tool | Đơn giản, nhưng không scale |
| Hierarchical grouping | Tool có domain rõ ràng, số nhóm cố định | Cần bước classify trước, có thể phân loại sai |
| Tool retrieval (embedding) | Hàng trăm tool, domain đa dạng | Thêm độ trễ + chi phí embedding, có thể miss tool đúng nếu description mơ hồ |
Chất lượng description quyết định retrieval
Tool retrieval chỉ tốt bằng chất lượng description của tool. Viết description theo văn phong người dùng sẽ hỏi ("tìm nhà hàng theo khu vực"), không theo văn phong kỹ thuật ("query restaurant table by location_id").
Tool Search: Meta-tool thay vì nạp toàn bộ¶
Thay vì nạp tất cả schema vào mỗi request, cấp cho agent một tool duy nhất để tự tìm tool khi cần - cách tiếp cận mà Anthropic dùng trong Claude Code.
@tool
def tool_search(query: str, max_results: int = 5) -> list[dict]:
"""Tìm kiếm tool khả dụng theo mô tả. Dùng tool này trước khi
gọi bất kỳ tool nào bạn chưa từng dùng trong hội thoại này.
Ví dụ: tool_search("tạo pull request")
-> Trả về định nghĩa đầy đủ của tool github_create_pull_request
"""
matches = search_tool_index(query)
return matches[:max_results] # Định nghĩa được inject động vào context
Agent bắt đầu chỉ với tool_search, tự tìm và "khám phá" tool phù hợp, rồi mới gọi. Dữ liệu của Anthropic: giảm 85% token so với nạp toàn bộ. Đánh đổi là thêm 1 vòng round-trip LLM - nhưng vì mỗi lời gọi có context nhỏ hơn nhiều, tổng thời gian thường vẫn nhanh hơn do model xử lý input ngắn nhanh hơn đáng kể.
Progressive Disclosure 3 tầng (cho Skills)¶
Khi agent có nhiều skill (quy trình nhiều bước, không chỉ 1 tool đơn lẻ - xem MCP ở mục 4), áp dụng nguyên tắc: nạp metadata trước, hướng dẫn chi tiết sau, tài nguyên phụ trợ cuối cùng.
| Tầng | Nội dung | Khi nào nạp | Kích thước |
|---|---|---|---|
| Tầng 1 - Discovery | Tên + mô tả ngắn của mọi skill | Luôn có trong context | ~100 token/skill |
| Tầng 2 - Activation | Hướng dẫn đầy đủ (các bước, error handling) | Khi agent quyết định dùng skill đó | ~500-2000 token |
| Tầng 3 - Execution | File tham chiếu, script hỗ trợ | Khi một bước cụ thể cần đến | Theo nhu cầu |
# Tầng 1: catalog nhẹ, luôn trong context (~100 token/skill)
SKILL_CATALOG = """
| Skill | Mô tả |
|-------|-------|
| tao-github-pr | Tạo và quản lý pull request trên GitHub |
| deploy-production | Deploy ứng dụng lên production với rollback |
"""
@tool
def load_skill(skill_name: str) -> str:
"""Nạp hướng dẫn đầy đủ cho 1 skill cụ thể (Tầng 2)."""
return read_file(f"skills/{skill_name}/SKILL.md")
Với 100 skill, cách này giảm từ ~300.000 token (nạp hết) xuống còn ~19.000 token (nạp theo tầng) - tiết kiệm ~94%.
Nguyên tắc 5-Tool¶
Cách đơn giản và hiệu quả nhất: giới hạn cứng mỗi agent tối đa 5 tool, thiết kế mỗi agent như một chuyên gia hẹp thay vì một agent "biết tuốt".
# Mỗi agent là chuyên gia với tối đa 5 tool
agents = {
"explorer": Agent(tools=[grep, glob, read_file, list_dir, web_search]),
"coder": Agent(tools=[read_file, edit_file, write_file, run_command, run_tests]),
"deployer": Agent(tools=[git_push, create_pr, deploy, check_status, rollback]),
}
# Route đến đúng chuyên gia thay vì 1 agent ôm hết tool
Nguyên tắc này liên hệ trực tiếp với Multi-Agent Systems (Bài 6): chia nhỏ trách nhiệm thành nhiều agent chuyên biệt vừa giải quyết vấn đề tool sprawl, vừa là lý do kiến trúc chính đáng để dùng multi-agent.
3. Tool Security¶
Tool cho phép LLM hành động trong thế giới thực - đây là bề mặt tấn công lớn nhất của agent (prompt injection từ tài liệu/web có thể khiến model gọi tool ngoài ý muốn user).
Sandboxing¶
Tool thực thi code hoặc truy cập filesystem/network phải chạy trong môi trường cô lập.
import subprocess
@tool
def run_python_code(code: str) -> str:
"""Chạy code Python trong sandbox, trả về output. Không có quyền truy cập network/filesystem ngoài thư mục tạm."""
try:
result = subprocess.run(
["docker", "run", "--rm", "--network=none",
"--memory=256m", "--cpus=0.5",
"python-sandbox:latest", "python", "-c", code],
capture_output=True, text=True, timeout=10,
)
return result.stdout or result.stderr
except subprocess.TimeoutExpired:
return "Lỗi: Code chạy quá 10 giây, đã bị dừng."
Không bao giờ chạy code do LLM sinh ra trực tiếp trên host
exec() hay subprocess trực tiếp trên máy chủ ứng dụng là lỗ hổng nghiêm trọng - prompt injection có thể khiến model sinh code độc hại. Luôn dùng container cô lập (--network=none, giới hạn memory/CPU, không mount volume nhạy cảm).
Rate Limiting¶
Giới hạn số lần agent được gọi tool (đặc biệt tool tốn phí hoặc có side effect) trong 1 khoảng thời gian/session.
from collections import defaultdict
import time
class ToolRateLimiter:
def __init__(self, max_calls: int, window_seconds: int):
self.max_calls = max_calls
self.window = window_seconds
self.calls = defaultdict(list)
def check(self, tool_name: str, session_id: str) -> bool:
key = f"{session_id}:{tool_name}"
now = time.time()
self.calls[key] = [t for t in self.calls[key] if now - t < self.window]
if len(self.calls[key]) >= self.max_calls:
return False
self.calls[key].append(now)
return True
limiter = ToolRateLimiter(max_calls=5, window_seconds=60)
def guarded_tool_execution(tool_name: str, session_id: str, **kwargs):
if not limiter.check(tool_name, session_id):
return f"Lỗi: Vượt giới hạn gọi '{tool_name}' (tối đa 5 lần/phút)."
return execute_tool(tool_name, **kwargs)
Audit Logs¶
Ghi lại mọi tool call (ai, tool gì, tham số gì, kết quả gì, khi nào) - bắt buộc cho tool có side effect, hữu ích để debug và điều tra sự cố.
import logging
import json
from datetime import datetime
audit_logger = logging.getLogger("agent_audit")
def log_tool_call(session_id: str, tool_name: str, args: dict, result: str, user_id: str):
audit_logger.info(json.dumps({
"timestamp": datetime.utcnow().isoformat(),
"session_id": session_id,
"user_id": user_id,
"tool": tool_name,
"args": args,
"result_preview": result[:200],
}))
Audit log khác observability (Bài 5)
Audit log tập trung vào trách nhiệm giải trình (ai làm gì, khi nào, để điều tra sau) - thường là log bất biến, giữ lâu dài vì lý do compliance. Observability (Bài 5) tập trung vào hiệu năng và chất lượng (latency, cost, trajectory) - để cải thiện hệ thống. Hai mục đích khác nhau dù dữ liệu có thể overlap.
4. MCP: Standards, Server, Clients¶
MCP (Model Context Protocol) chuẩn hoá cách agent giao tiếp với tool/data source, đã giới thiệu khái niệm ở Bài 1. Phần này đi vào cách implement.
Kiến trúc¶
graph LR
subgraph "MCP Host (Agent App)"
AGENT[Agent / LangGraph]
CLIENT[MCP Client]
end
subgraph "MCP Servers"
S1[Filesystem Server]
S2[Vietnamese News Server]
S3[Database Server]
end
AGENT --> CLIENT
CLIENT -->|JSON-RPC| S1
CLIENT -->|JSON-RPC| S2
CLIENT -->|JSON-RPC| S3
MCP giao tiếp qua JSON-RPC, mỗi Server expose 3 loại primitive:
| Primitive | Vai trò | Tương đương |
|---|---|---|
| Tools | Hành động Server cho phép Client gọi | Function calling |
| Resources | Dữ liệu Server expose để đọc | Tài liệu / file |
| Prompts | Template prompt tái sử dụng được Server cung cấp | Prompt library |
Viết một MCP Server tối giản¶
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("vietnamese-news")
@mcp.tool()
def search_news(query: str, limit: int = 5) -> list[dict]:
"""Tìm kiếm tin tức tiếng Việt mới nhất theo từ khoá."""
results = call_news_api(query, limit=limit)
return [{"title": r["title"], "url": r["url"], "published": r["date"]} for r in results]
@mcp.resource("news://categories")
def list_categories() -> str:
"""Danh sách chuyên mục tin tức có sẵn."""
return "Thời sự, Kinh doanh, Thể thao, Công nghệ, Giải trí"
if __name__ == "__main__":
mcp.run(transport="stdio")
Kết nối Client (từ LangGraph agent)¶
from langchain_mcp_adapters.client import MultiServerMCPClient
mcp_client = MultiServerMCPClient({
"vietnamese_news": {
"command": "python",
"args": ["vietnamese_news_server.py"],
"transport": "stdio",
},
})
# Tool từ MCP Server dùng được y hệt tool định nghĩa bằng @tool
mcp_tools = await mcp_client.get_tools()
llm_with_tools = llm.bind_tools(mcp_tools)
MCP giá trị nhất khi tool được tái sử dụng ở nhiều agent/app
Nếu chỉ có 1 agent dùng 1 tool nội bộ, viết @tool trực tiếp (như Bài 2) đơn giản hơn. MCP đáng giá khi cùng 1 tool/data source cần phục vụ nhiều agent khác nhau (vd: news scraper dùng cho cả research agent lẫn chatbot khác) - viết 1 Server, mọi Client đều dùng lại được.
Tóm tắt¶
graph TD
A[Thiết kế tool mới] --> B{Model có luôn gọi<br/>chuỗi cố định không?}
B -->|Không| ATOMIC[Atomic tools]
B -->|Có| COMP[Composite tool]
ATOMIC --> ERR[Bọc try/except,<br/>trả lỗi dạng đọc được]
COMP --> ERR
ERR --> SIDE{Có side effect?}
SIDE -->|Có| IDEM[Thêm idempotency key<br/>+ audit log]
SIDE -->|Không| DONE[Sẵn sàng]
IDEM --> SEC{Thực thi code/<br/>truy cập hệ thống?}
SEC -->|Có| SANDBOX[Sandbox + rate limit]
SEC -->|Không| DONE
| Nguyên tắc | Ghi nhớ |
|---|---|
| Atomic vs Composite | Bắt đầu atomic, gộp composite khi đo được pattern cố định |
| Error handling | Trả lỗi dạng text model đọc được, không raise exception |
| Idempotency | Bắt buộc cho tool có side effect không hoàn tác được |
| Hierarchical grouping | Nhóm tool theo domain, classify intent trước khi nạp |
| Tool retrieval | Embedding similarity khi có hàng trăm tool |
| Tool sprawl | > 20 tool: độ chính xác chọn tool giảm mạnh, dễ hallucinate tham số |
| Tool search | Meta-tool duy nhất để agent tự khám phá tool khi cần, giảm ~85% token |
| Progressive disclosure | Nạp metadata trước, hướng dẫn chi tiết sau, tài nguyên cuối cùng |
| Nguyên tắc 5-Tool | Giới hạn cứng mỗi agent tối đa 5 tool - chuyên gia hẹp thay vì biết tuốt |
| Sandboxing | Không bao giờ exec code LLM sinh ra trực tiếp trên host |
| Rate limiting | Giới hạn theo session + tool, đặc biệt tool tốn phí |
| Audit logs | Bắt buộc cho side-effect tools, phục vụ compliance |
| MCP | Chuẩn hoá khi 1 tool cần phục vụ nhiều agent/app |