Danh sách bài viết

Bài 43: Định nghĩa tool schema (JSON Schema)

Bài 42 đã trả lời function calling là gì ở mức khái niệm. Bài này đi vào chi tiết viết tool schema — phần mô tả function để LLM hiểu khi nào gọi và truyền gì. Schema dùng chuẩn JSON Schema: type, properties, required, enum, description, các constraint cho string/number/array, và nested object. Bài cũng tách khác biệt giữa OpenAI (wrap type=function) và Anthropic (input_schema), cách sinh schema từ Pydantic, strict mode, validate input trước execute, cùng các best practice naming và mô tả.

25/05/2026
13 phút đọc
1 lượt xem
1

Mục tiêu bài học

Sau bài này, bạn cần:

  • Viết được tool schema cho cả OpenAI (parameters) và Anthropic (input_schema).
  • Dùng các keyword JSON Schema cơ bản: type, properties, required, enum, description, các constraint cho string/number/array, và nested object.
  • Sinh schema tự động từ Pydantic BaseModel qua model_json_schema().
  • Hiểu strict mode (OpenAI) và lý do nên dùng.
  • Validate input từ LLM trước khi execute để tránh crash do argument sai type.
  • Đặt tên và viết description sao cho LLM gọi đúng tool, đúng argument.

Bài này là phần thực hành ngay sau khái niệm ở Bài 42 và chuẩn bị cho workflow đầy đủ ở Bài 44.

2

Tool schema làm gì

LLM không gọi function thật — nó sinh ra text JSON mô tả ý định gọi. Để model biết có function nào, mỗi function cần một schema: tên, mô tả, và signature của tham số. Schema được gửi kèm trong request, model dùng làm context khi quyết định có gọi tool nào không.

Schema phục vụ ba việc:

  • Discovery: model biết những tool nào tồn tại và mỗi tool dùng để làm gì.
  • Argument generation: model điền argument theo đúng type, đúng tên field, đúng giá trị enum.
  • Server-side validation: app validate JSON model trả về dựa trên cùng schema trước khi gọi function thật.

Cùng một schema dùng cho cả ba bước, vì vậy viết chuẩn ngay từ đầu tiết kiệm rất nhiều giờ debug.

3

Ba thành phần chính

Mọi tool schema đều có ba thành phần:

  • name: định danh tool, snake_case, ≤ 64 ký tự, không trùng giữa các tool trong cùng request. LLM dùng tên này khi gọi.
  • description: câu mô tả ngắn cho LLM biết function làm gì và khi nào nên dùng. Đây là "system prompt" của tool.
  • parameters (OpenAI) hoặc input_schema (Anthropic): một đối tượng JSON Schema mô tả input. Bắt buộc là type: object ở cấp ngoài cùng, dù function không có argument nào.

Tool không có argument vẫn cần parameters: {"type": "object", "properties": {}} — để trống nhưng phải có cấu trúc.

4

JSON Schema — các type cơ bản

JSON Schema (draft 2020-12 là bản hiện hành) định nghĩa bảy type:

  • "object" — dict có properties bên trong.
  • "string" — chuỗi, có thể thêm minLength, maxLength, pattern, format.
  • "number" — số thực, có thể minimum, maximum.
  • "integer" — số nguyên, cùng constraint với number.
  • "boolean" — true/false.
  • "array" — danh sách, có items mô tả phần tử.
  • "null" — chỉ chấp nhận null. Thường dùng dạng "type": ["string", "null"] cho optional nullable.

Keyword phổ biến: properties (map field → schema), required (mảng tên field bắt buộc), enum (mảng các giá trị hợp lệ), description (free text cho field).

5

Ví dụ tối giản — get_weather

Hai tham số: city bắt buộc, unit tuỳ chọn (mặc định celsius), enum hai giá trị:

{
  "name": "get_weather",
  "description": "Lấy thời tiết hiện tại của một thành phố.",
  "parameters": {
    "type": "object",
    "properties": {
      "city": {
        "type": "string",
        "description": "Tên thành phố, ví dụ 'Hà Nội', 'Tokyo', 'New York'."
      },
      "unit": {
        "type": "string",
        "enum": ["celsius", "fahrenheit"],
        "description": "Đơn vị nhiệt độ. Mặc định celsius."
      }
    },
    "required": ["city"]
  }
}

Mẫu này đủ để model gọi get_weather(city="Hà Nội", unit="celsius"). Field unit không trong required nên model có thể bỏ qua nếu user không yêu cầu cụ thể.

6

OpenAI format vs Anthropic format

Cùng một function, hai provider yêu cầu wrapper khác nhau.

OpenAI wrap trong {"type": "function", "function": {...}}, dùng key parameters:

openai_tool = {
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Lấy thời tiết hiện tại của một thành phố.",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string"},
                "unit": {"type": "string",
                          "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["city"],
        },
    },
}

Anthropic phẳng hơn, dùng key input_schema:

anthropic_tool = {
    "name": "get_weather",
    "description": "Lấy thời tiết hiện tại của một thành phố.",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string"},
            "unit": {"type": "string",
                      "enum": ["celsius", "fahrenheit"]},
        },
        "required": ["city"],
    },
}

Nội dung schema bên trong (type, properties, required) giống nhau. Khác biệt chỉ ở vỏ. Trong code production, viết schema gốc một lần và build hai wrapper.

7

String — minLength, maxLength, pattern

String hỗ trợ ba constraint phổ biến:

{
  "type": "string",
  "minLength": 1,
  "maxLength": 100,
  "pattern": "^[A-Z]{2,3}-[0-9]{4}$",
  "description": "Order ID dạng VN-1234 hoặc USA-5678."
}
  • minLength / maxLength: giới hạn độ dài ký tự.
  • pattern: regex (ECMA 262). Hữu ích cho ID, mã sản phẩm, định dạng cố định.

Lưu ý: pattern không luôn được validate strict bởi LLM — model thường tôn trọng nếu có description rõ và example. Tốt nhất vẫn validate phía app sau khi nhận argument.

8

Number và integer — minimum, maximum

{
  "type": "integer",
  "minimum": 1,
  "maximum": 100,
  "description": "Số sản phẩm tối đa trả về mỗi lần (1-100)."
}
  • "integer" dùng cho số nguyên (count, ID, page). "number" dùng cho float (giá, weight, ratio).
  • minimum / maximum kèm exclusiveMinimum / exclusiveMaximum (boolean) nếu cần loại bỏ biên.
  • multipleOf: ép giá trị là bội của một số (ví dụ multipleOf: 0.01 cho price).

Nhắc model về đơn vị trong description: "Số tiền tính bằng VND." rõ ràng hơn để tránh model điền USD.

9

Boolean

{
  "type": "boolean",
  "description": "Có gửi email xác nhận sau khi tạo đơn hàng không."
}

Boolean rất nhạy với cách đặt tên: nên là động từ khẳng định (send_email, is_active, force_refresh) thay vì phủ định (do_not_send) để model điền đúng polarity. Phủ định kép trong description thường gây sai.

10

Enum — choice cố định

{
  "type": "string",
  "enum": ["pending", "shipped", "delivered", "cancelled"],
  "description": "Trạng thái đơn hàng."
}

Enum là cách hiệu quả nhất để ép model chọn trong tập cố định — model gần như không bao giờ phát ra giá trị ngoài enum (đặc biệt với strict mode). Vài lưu ý:

  • Giá trị nên là string lowercase, snake_case nếu là code; hoặc giá trị canonical mà downstream chấp nhận.
  • Không nên có quá nhiều giá trị (>30): model bắt đầu confuse. Tách thành tool con hoặc dùng search trước.
  • Có thể combine: "type": "string", "enum": [...] trong array để mỗi phần tử là enum.
11

Array — items, minItems, maxItems

{
  "type": "array",
  "items": {
    "type": "string",
    "minLength": 1
  },
  "minItems": 1,
  "maxItems": 10,
  "description": "Danh sách email gửi (1-10 địa chỉ)."
}
  • items: schema cho mỗi phần tử. Có thể là object phức tạp.
  • minItems / maxItems: giới hạn độ dài array.
  • uniqueItems: true: cấm trùng (so sánh giá trị JSON).

Khi items là object, đặc tả như object thường: có properties, required riêng. Ví dụ một mảng line_items trong tool create_order.

12

Nested object

{
  "name": "create_order",
  "description": "Tạo đơn hàng mới.",
  "parameters": {
    "type": "object",
    "properties": {
      "customer": {
        "type": "object",
        "properties": {
          "name": {"type": "string"},
          "email": {"type": "string", "format": "email"}
        },
        "required": ["name", "email"]
      },
      "items": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "sku": {"type": "string"},
            "quantity": {"type": "integer", "minimum": 1}
          },
          "required": ["sku", "quantity"]
        },
        "minItems": 1
      }
    },
    "required": ["customer", "items"]
  }
}

Nested object hợp lệ ở mọi độ sâu, nhưng nên giữ ≤ 3 cấp. Sâu hơn, model dễ phát ra structure thiếu field. Khi schema quá phức tạp, cân nhắc tách thành nhiều tool.

13

Required vs optional

  • Trong JSON Schema, tất cả field đều optional theo mặc định. Field chỉ bắt buộc nếu tên có trong mảng required của object cha.
  • Nếu argument tuyệt đối cần để function chạy đúng (ví dụ city cho get_weather), phải đưa vào required. Bỏ qua, model có thể không truyền và app raise TypeError.
  • Optional field nên có default rõ ràng trong description: "Mặc định celsius nếu không truyền.". Model thường tôn trọng default này.
  • Với OpenAI strict mode, mọi field phải có trong required — optional làm bằng cách "type": ["string", "null"].
14

Description tốt — viết cho LLM

Description là phần model đọc nhiều nhất. Hai mức:

  • Function description: trả lời "Tool này làm gì? Khi nào nên gọi?". Một câu ngắn cho "what", một câu cho "when". Tránh marketing.
    • Yếu: "Get weather data."
    • Đủ: "Lấy thời tiết hiện tại của một thành phố. Dùng khi user hỏi nhiệt độ, mưa, độ ẩm cụ thể của địa điểm."
  • Parameter description: nêu format, ví dụ, đơn vị, default.
    • Yếu: "The city."
    • Đủ: "Tên thành phố tiếng Việt hoặc tiếng Anh. Ví dụ: 'Hà Nội', 'Tokyo'. Không kèm tên quốc gia."

Quy tắc: tốn 2 câu description để tiết kiệm hàng giờ debug do model điền sai field.

15

Strong typing — date-time, email, uri

JSON Schema có keyword format cho string, gợi ý semantic cho LLM:

{
  "scheduled_at": {
    "type": "string",
    "format": "date-time",
    "description": "Thời điểm chạy, ISO 8601: '2026-05-25T14:00:00+07:00'."
  },
  "contact_email": {
    "type": "string",
    "format": "email"
  },
  "callback_url": {
    "type": "string",
    "format": "uri"
  }
}

Các format phổ biến: date-time (ISO 8601), date, time, email, uri, uuid, ipv4, ipv6. Provider không luôn validate format chặt, nhưng model gpt-4o/claude tôn trọng khá tốt khi description đủ rõ. App vẫn nên validate lại bằng pydantic sau khi nhận.

16

Pydantic sinh JSON Schema

Viết schema bằng tay dễ sai. Pydantic v2 sinh JSON Schema từ class, kèm type-check khi validate:

from pydantic import BaseModel, Field
from typing import Literal

class GetWeatherArgs(BaseModel):
    city: str = Field(..., description="Tên thành phố, ví dụ 'Hà Nội'.")
    unit: Literal["celsius", "fahrenheit"] = Field(
        "celsius", description="Đơn vị nhiệt độ."
    )

print(GetWeatherArgs.model_json_schema())

Output (rút gọn):

{
  "type": "object",
  "properties": {
    "city": {"type": "string", "description": "..."},
    "unit": {"enum": ["celsius", "fahrenheit"],
              "type": "string", "default": "celsius"}
  },
  "required": ["city"]
}

Literal → enum, Field(...) → required, default value → optional. Type hint Python được dịch tự động sang JSON Schema.

17

OpenAI helper — pydantic_function_tool

SDK openai ≥ 1.40 có helper convert thẳng Pydantic class sang tool object:

import openai

tool = openai.pydantic_function_tool(
    GetWeatherArgs,
    name="get_weather",
    description="Lấy thời tiết hiện tại của một thành phố.",
)

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "Thời tiết Hà Nội?"}],
    tools=[tool],
)

Helper tự bật strict mode, wrap đúng {"type": "function", "function": {...}}, và set additionalProperties: false theo yêu cầu của strict mode. Không cần viết schema bằng tay.

18

Anthropic từ Pydantic

SDK anthropic chưa có helper riêng. Build tool object thủ công:

def to_anthropic_tool(model_cls, name, description):
    return {
        "name": name,
        "description": description,
        "input_schema": model_cls.model_json_schema(),
    }

tool = to_anthropic_tool(
    GetWeatherArgs,
    "get_weather",
    "Lấy thời tiết hiện tại của một thành phố.",
)

Anthropic chấp nhận schema có thêm $defs, title (Pydantic sinh sẵn) — không cần dọn. Trường hợp model sinh argument thừa field, Anthropic không strict bằng OpenAI; app nên validate lại bằng chính GetWeatherArgs(**args).

19

LangChain @tool decorator

LangChain dùng decorator @tool để gộp function thật và schema:

from langchain_core.tools import tool

@tool
def get_weather(city: str, unit: str = "celsius") -> dict:
    """Lấy thời tiết hiện tại của một thành phố.

    Args:
        city: Tên thành phố, ví dụ 'Hà Nội'.
        unit: Đơn vị nhiệt độ ('celsius' hoặc 'fahrenheit').
    """
    return {"city": city, "temp": 28, "unit": unit}

print(get_weather.name)        # 'get_weather'
print(get_weather.description) # docstring
print(get_weather.args_schema.model_json_schema())
  • Tên hàm → tool name.
  • Docstring dòng đầu → description; phần Args: → parameter description.
  • Type hint → JSON Schema type.
  • Default value → optional.

Phù hợp khi viết nhiều tool nhanh; tradeoff là schema bị ràng theo docstring format — phải cẩn thận khi refactor.

20

Strict mode — 100% schema compliance

OpenAI giới thiệu Structured Outputs cho tool calling vào 2024 (gpt-4o-2024-08-06 trở đi). Bật strict: true trong function object:

tool = {
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "...",
        "strict": True,
        "parameters": {
            "type": "object",
            "additionalProperties": False,
            "properties": {
                "city": {"type": "string"},
                "unit": {"type": "string",
                          "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["city", "unit"],
        },
    },
}

Strict mode đảm bảo:

  • JSON model phát ra luôn parse được — không thiếu dấu, không kèm text dư.
  • Mọi field có trong required đều xuất hiện.
  • Không có field lạ — vì vậy phải additionalProperties: false.

Yêu cầu phụ: tất cả field phải nằm trong required (không có optional thuần). Optional ép qua "type": ["string", "null"]. Strict mode đắt hơn một chút (cache schema lần đầu) nhưng đáng cho production — giảm gần hết lỗi parse JSON. Anthropic không có flag tương đương; Claude 4 vốn rất tuân theo schema nên ít khi phát ra JSON sai.

21

Validate input trước execute

Dù bật strict mode, app vẫn nên validate input lần cuối bằng Pydantic trước khi gọi function thật:

from pydantic import ValidationError

def safe_execute(tool_call):
    args_raw = json.loads(tool_call.function.arguments)
    try:
        args = GetWeatherArgs(**args_raw)
    except ValidationError as e:
        return {"error": "invalid_arguments", "detail": e.errors()}
    return get_weather(args.city, args.unit)

Khi validation fail, trả error về cho LLM dưới dạng tool result — model thường tự sửa argument ở turn tiếp. Đừng raise exception lên user — đó là lỗi internal, LLM xử lý được.

22

Giới hạn số lượng tool

Cả OpenAI và Anthropic cho phép tối đa ~100-128 tool trong một request, nhưng chất lượng giảm rõ rệt khi vượt 20-30:

  • Token cost tăng — mỗi tool ngốn 50-200 token cho schema; 100 tool = 5-20k token input mỗi turn.
  • Model confuse — nhiều tool có name/description gần giống, model dễ gọi nhầm.
  • Latency tăng — context dài, LLM chậm hơn.

Pattern thực tế khi cần nhiều tool: router. Tầng đầu LLM chọn nhóm tool phù hợp (search, finance, calendar); tầng sau load schema chi tiết của nhóm đó. Giảm 100 tool xuống còn 5-10 cho mỗi turn.

23

Best practice

  • Đặt tên bằng action verb + object: get_weather, search_products, send_email, create_order. Tránh tên mơ hồ kiểu handler, process.
  • Dùng snake_case, ≤ 64 ký tự, không dấu/space/dấu chấm.
  • Description function: 1-2 câu, nêu whatwhen use.
  • Description parameter: nêu format, đơn vị, ví dụ. Thêm "Ví dụ: ..." tăng tỉ lệ điền đúng.
  • Bật strict mode cho OpenAI khi có thể.
  • Generate schema từ Pydantic thay vì viết tay — giữ schema và validation cùng nguồn.
  • Test isolated: gọi từng tool một với prompt cố tình ambiguous, kiểm tra model có gọi đúng và điền đủ argument.
  • Khi tool nhiều, dùng router; không vứt 50 tool vào cùng một request.
  • Đừng đưa thông tin nhạy cảm (API key, internal ID) vào schema description — toàn bộ schema gửi lên provider.
24

Code Python — bốn cách định nghĩa

(a) Raw dict — OpenAI:

openai_tool = {
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Lấy thời tiết hiện tại của một thành phố.",
        "strict": True,
        "parameters": {
            "type": "object",
            "additionalProperties": False,
            "properties": {
                "city": {"type": "string",
                          "description": "Tên thành phố."},
                "unit": {"type": "string",
                          "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["city", "unit"],
        },
    },
}

(b) Raw dict — Anthropic:

anthropic_tool = {
    "name": "get_weather",
    "description": "Lấy thời tiết hiện tại của một thành phố.",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string"},
            "unit": {"type": "string",
                      "enum": ["celsius", "fahrenheit"]},
        },
        "required": ["city"],
    },
}

(c) Pydantic — convert cho cả hai provider:

from pydantic import BaseModel, Field
from typing import Literal
import openai

class GetWeatherArgs(BaseModel):
    city: str = Field(..., description="Tên thành phố.")
    unit: Literal["celsius", "fahrenheit"] = Field("celsius")

openai_tool = openai.pydantic_function_tool(
    GetWeatherArgs,
    name="get_weather",
    description="Lấy thời tiết hiện tại của một thành phố.",
)

anthropic_tool = {
    "name": "get_weather",
    "description": "Lấy thời tiết hiện tại của một thành phố.",
    "input_schema": GetWeatherArgs.model_json_schema(),
}

(d) Validate input từ LLM:

import json
from pydantic import ValidationError

def handle_tool_call(tool_call):
    raw = json.loads(tool_call.function.arguments)
    try:
        args = GetWeatherArgs(**raw)
    except ValidationError as e:
        return {"error": "invalid_args", "detail": e.errors()}

    return {
        "city": args.city,
        "temp_c": 28 if args.unit == "celsius" else 82,
        "unit": args.unit,
    }

Pattern (c) + (d) kết hợp: sinh schema và validate input từ chung một class Pydantic, đảm bảo schema gửi cho LLM khớp đúng với type app dùng.

25

Bài tập

  1. Viết schema cho 3 tool: search_web(query, max_results), send_email(to, subject, body), calculate(expression). Mỗi field có description rõ và constraint phù hợp (max_results 1-10, to format email).
  2. Convert 3 tool ở bài 1 sang cả format OpenAI (type=function) và Anthropic (input_schema). Viết hàm to_openai(spec)to_anthropic(spec) nhận chung input.
  3. Định nghĩa lại 3 tool bằng Pydantic BaseModel. So sánh output model_json_schema() với schema viết tay ở bài 1 — chỗ nào khác? Field nào Pydantic thêm tự động (title, $defs)?
  4. Bật strict mode cho cả 3 tool OpenAI. Sửa schema cho phù hợp (additionalProperties: false, mọi field vào required, optional dùng ["string", "null"]). Test với prompt thiếu thông tin, xem model có hỏi lại không.
  5. Test invalid input: cho LLM prompt "send email to abc" (thiếu @). Validate bằng Pydantic, trả error về LLM, quan sát model tự sửa ở turn tiếp.
  6. (Tuỳ chọn) Build router 2 tầng: tầng 1 LLM chọn nhóm (search / email / math); tầng 2 expose chỉ tool của nhóm đó. So token cost với expose cả 3 nhóm cùng lúc.