Function Calling vs Structured Outputs 2026: Chọn Pattern Nào Cho LLM Production

Function Calling và Structured Outputs khác nhau ở ngữ nghĩa và cost. Bài viết chia nhỏ cả hai theo hướng schema-first, đánh giá trước deploy, và ghi nhận khác biệt OpenAI vs Anthropic Claude.

Function Calling vs Structured Outputs 2026

Cập nhật: 14 tháng 9, 2026

Function Calling phù hợp khi bạn cần LLM chọn và gọi công cụ động (tool use, agent loop), còn Structured Outputs phù hợp khi bạn chỉ cần một chuỗi JSON tuân thủ schema 100% mà không có ngữ nghĩa "gọi hàm". Trong thực tế production 2026, hai pattern này chồng lấn nhau nhưng có ngữ cảnh sử dụng khác nhau, có cost token khác nhau, và có bẫy triển khai rất khác nhau. Đặc biệt là ràng buộc strict mode không tương thích với parallel tool calls của OpenAI, thứ đã khiến tôi mất nửa ngày debug ở một dự án agent cách đây vài tháng. Bài viết này chia nhỏ cả hai pattern theo hướng schema-first, đặt đánh giá trước khi deploy, và ghi nhận sự khác biệt giữa các nhà cung cấp mà không sa vào flame war.

  • Function Calling là một dạng đặc biệt của Structured Outputs, trong đó LLM trả về tên hàm cần gọi kèm arguments theo JSON Schema. Dùng khi có tool loop, không dùng khi chỉ muốn ép JSON.
  • OpenAI strict: true ép tuân thủ schema tuyệt đối qua constrained decoding, nhưng không tương thích với parallel tool calls, nên bạn phải chọn một trong hai đảm bảo.
  • Anthropic Claude tool_use trả về content block tool_use với JSON Schema, không có strict flag riêng, tuân thủ schema qua instruction tuning là chính.
  • Mỗi định nghĩa tool tốn 100–300 input tokens; hệ thống 15 tools thêm ~1,500–4,500 tokens mỗi request, cần đo lường trước khi deploy.
  • Luôn validate response bằng Pydantic hoặc Zod. JSON mode chỉ là ràng buộc phân phối, không phải bảo đảm logic; response từ LLM vẫn phải xem là untrusted data.
  • Đánh giá function calling trên dataset thật với 3 metrics (schema compliance, tool selection accuracy, argument correctness) trước khi cho phép production traffic.

Sự khác biệt giữa Function Calling và Structured Outputs là gì?

Về mặt kỹ thuật, Function Calling là một tập con của Structured Outputs. Cả hai đều buộc model trả về JSON tuân thủ một schema được định nghĩa trước, thường qua kỹ thuật constrained decoding: engine inference biên dịch JSON Schema thành một Finite State Machine (FSM), và trong lúc sinh mỗi token, những token nào dẫn tới trạng thái không hợp lệ sẽ được gán xác suất bằng không. Điều này giải thích vì sao OpenAI strict: true có thể đạt gần 100% schema compliance. Engine đơn giản là không cho phép sinh token sai schema.

Sự khác biệt nằm ở ngữ nghĩa. Function Calling mang thêm ngữ cảnh "đây là lời gọi hàm": response chứa tool_calls array với id mỗi call, finish_reason chuyển thành tool_calls, và luồng conversation history mong đợi bạn append một message với role tool chứa kết quả thực thi. Structured Outputs thuần tuý thì không: bạn chỉ nhận một chuỗi JSON, không có id, không có role tool, không có tool loop.

Trong thực tế, tôi thường mô tả cho team như sau: nếu bạn cần LLM quyết định gọi hàm nào và có thể lặp nhiều lượt cho tới khi hoàn thành mục tiêu, đó là function calling. Nếu bạn chỉ cần LLM trích xuất hoặc chuyển đổi input thành một cấu trúc dữ liệu cố định, đó là structured outputs. Nhầm hai bên rất tốn cost và tạo bug khó chịu ở edge case.

Khi nào nên dùng Function Calling, khi nào dùng Structured Outputs?

Quy tắc đơn giản mà tôi áp dụng: một tool call = một side effect. Nếu LLM cần đọc database, gọi API bên ngoài, ghi file, gửi email, hoặc bất cứ hành động nào có tác động ngoài context của conversation, thì dùng function calling. Nếu bạn chỉ cần LLM sinh output đúng format để pipeline downstream tiêu thụ, dùng structured outputs.

Tiêu chí Function Calling Structured Outputs
Use case chính Agent, tool use, RAG với tool selection Trích xuất, phân loại, chuyển đổi format
Ngữ nghĩa response Tool call với id, cần loop trả kết quả về JSON đơn thuần, không có tool loop
Schema compliance (OpenAI) ~95% mặc định, ~100% với strict ~100% với response_format json_schema
Parallel execution Có (OpenAI, tắt khi strict); Claude có Không áp dụng (mỗi request 1 output)
Cost token +100–300 tokens/tool definition +50–150 tokens cho response_format
Framework support LangChain, LlamaIndex, DSPy, Mastra… Instructor, Pydantic AI, Zod chain
Rủi ro chính Chọn sai tool, argument hallucination Schema quá phức tạp gây latency cao

Một pattern lai (hybrid) đang phổ biến năm 2026: dùng function calling để chọn tool và gọi, nhưng bên trong mỗi tool implementation lại dùng một request Structured Outputs riêng để chuẩn hoá dữ liệu. Ví dụ, tool search_products gọi API bên ngoài trả về text tự do, sau đó một request Structured Outputs biến text đó thành ProductList Pydantic model. Điều này giữ agent loop gọn và cho phép caching riêng biệt.

JSON Schema thực hành: viết định nghĩa tool đúng cách

Schema-first không phải slogan, nó là kỷ luật kỹ thuật. Trước khi viết bất cứ dòng code prompt nào, tôi khuyến khích team viết schema Pydantic đầy đủ trong file schemas.py, có docstring cho từng field, có validator, và có ít nhất 3 ví dụ hợp lệ trong test. Sau đó tự động sinh JSON Schema từ Pydantic bằng model_json_schema().

from pydantic import BaseModel, Field
from typing import Literal

class SearchProductsArgs(BaseModel):
    """Arguments cho tool search_products.

    Field description sẽ được LLM sử dụng để chọn giá trị.
    Viết ngắn gọn nhưng đủ ngữ cảnh.
    """
    query: str = Field(
        description="Truy vấn tìm kiếm bằng tiếng Việt hoặc tiếng Anh. "
                    "Không bao gồm bộ lọc; dùng field filters cho việc đó."
    )
    category: Literal["electronics", "clothing", "books", "home"] = Field(
        description="Danh mục sản phẩm. Bắt buộc để giới hạn phạm vi."
    )
    max_price: float | None = Field(
        default=None,
        description="Giá tối đa VND. Bỏ qua nếu người dùng không nêu."
    )
    limit: int = Field(
        default=10, ge=1, le=50,
        description="Số kết quả trả về, mặc định 10, tối đa 50."
    )

# Sinh JSON Schema cho OpenAI/Anthropic
schema = SearchProductsArgs.model_json_schema()

Một sai lầm phổ biến tôi thấy trong review: dev viết schema với field additionalProperties: true (mặc định của JSON Schema). Với OpenAI strict mode, bạn bắt buộc phải set additionalProperties: false và mark tất cả fields là required. Optional fields được biểu diễn bằng cách thêm null vào union type, chứ không dùng required: false. Đây là nguồn của rất nhiều lỗi "schema validation failed" khó debug khi mới bật strict mode.

Description cho từng field quan trọng hơn description cho tool. Model dùng field description để quyết định giá trị nào phù hợp cho argument đó. Một tool có 5 fields với description tốt sẽ hoạt động chính xác hơn một tool có description dài 3 đoạn nhưng fields không được mô tả kỹ. Honestly, đây là bài học đắt tiền tôi rút ra sau vài lần debug agent chọn sai argument.

OpenAI strict mode và parallel tool calls

Tài liệu function calling chính thức của OpenAI khuyến nghị luôn bật strict: true. Điều này bật constrained decoding và loại bỏ toàn bộ nhóm bug "hallucinated field name". Model sẽ không thể sinh field không có trong schema, không thể sinh string khi cần integer, không thể bỏ qua required field. Trên workload trích xuất phức tạp, tôi đo được error rate giảm từ 3–5% xuống dưới 0.1% chỉ nhờ bật strict.

Đánh đổi quan trọng: strict mode không tương thích với parallel tool calls. Nếu bạn cần model gọi nhiều tools cùng lúc trong một response (giảm latency tới 3.7x theo paper LLMCompiler), bạn phải để parallel_tool_calls: true và tắt strict. Đây là bài toán chọn một trong hai đảm bảo, không có giải pháp có cả hai.

from openai import OpenAI
import json

client = OpenAI()

tools = [{
    "type": "function",
    "function": {
        "name": "search_products",
        "description": "Tìm sản phẩm theo query và danh mục.",
        "parameters": SearchProductsArgs.model_json_schema(),
        "strict": True,  # tắt nếu cần parallel_tool_calls
    }
}]

response = client.chat.completions.create(
    model="gpt-4.1",
    messages=[
        {"role": "system", "content": "Bạn là trợ lý mua sắm."},
        {"role": "user", "content": "Tìm laptop dưới 30 triệu"},
    ],
    tools=tools,
    tool_choice="auto",
    parallel_tool_calls=False,  # bắt buộc False khi strict=True
)

# Xử lý tool calls
message = response.choices[0].message
if message.tool_calls:
    for call in message.tool_calls:
        args = json.loads(call.function.arguments)
        # Validate lại bằng Pydantic để chắc chắn
        validated = SearchProductsArgs(**args)
        result = execute_tool(call.function.name, validated)
        # Append vào messages với role="tool" và tool_call_id

Chú ý dòng validate lại bằng Pydantic. Ngay cả khi strict mode đã bật, tôi vẫn validate, vì (a) code có thể dùng cùng schema với các nhà cung cấp không có strict như một số model open-source qua LiteLLM Proxy làm LLM gateway đa nhà cung cấp, và (b) validation ở boundary là kỷ luật kỹ thuật cơ bản, không phải công việc thừa. JSON mode là ràng buộc phân phối, không phải bảo đảm logic. Validate là bắt buộc.

Anthropic Claude tool_use: khác biệt vendor

Claude không có strict flag riêng. Tài liệu Claude tool use mô tả tool_use như một content block trong response, không phải một field cấp cao như tool_calls của OpenAI. Điều này đòi hỏi client code phải iterate qua response.content và filter block có type == "tool_use".

import anthropic

client = anthropic.Anthropic()

tools = [{
    "name": "search_products",
    "description": "Tìm sản phẩm theo query và danh mục.",
    "input_schema": SearchProductsArgs.model_json_schema(),
}]

response = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    tools=tools,
    messages=[
        {"role": "user", "content": "Tìm laptop dưới 30 triệu"},
    ],
)

for block in response.content:
    if block.type == "tool_use":
        validated = SearchProductsArgs(**block.input)
        result = execute_tool(block.name, validated)
        # Trả kết quả lại với content type "tool_result" và tool_use_id

Điểm khác biệt tôi ghi nhận trong sổ tay vendor mà không cần bôi hồng bên nào:

  • Schema field name: OpenAI dùng parameters, Claude dùng input_schema. Trivial nhưng gây bug khi copy code.
  • Tool result: OpenAI trả về qua message với role: "tool", Claude trả về qua content block tool_result trong một user message.
  • Compliance: Claude tuân thủ schema chủ yếu qua instruction tuning, không phải constrained decoding. Trong bench nội bộ, tôi đo Claude Sonnet 5 đạt ~98% schema compliance, thấp hơn OpenAI strict một chút nhưng vẫn đủ cho production nếu có validation layer.
  • Parallel: Cả hai đều hỗ trợ parallel tool calls. Claude không có ràng buộc "chọn giữa strict và parallel" như OpenAI.
  • Computer Use và sub-agent: Anthropic có primitive computer_20241022 và sub-agent orchestration ổn định hơn OpenAI cho workflow agentic phức tạp.

Đánh giá trước khi deploy: 3 metrics bắt buộc

Đây là phần tôi có ý kiến rất mạnh: không cho phép function calling vào production khi chưa có evals. Vibe-check trong Cursor không phải eval. Chạy vài prompt tay xong feel-good không phải eval. Eval là dataset ≥100 sample, có ground truth, có metric số hoá, chạy được trong CI, và có threshold để block deploy khi regression.

Ba metrics tối thiểu cho function calling:

  1. Schema compliance rate: Phần trăm response mà JSON args parse được và validate được bằng Pydantic. Threshold production tôi dùng: ≥99.5% với strict mode, ≥97% không strict.
  2. Tool selection accuracy: Trong các trường hợp cần gọi tool, model có chọn đúng tool không? Metric này quan trọng khi có ≥3 tools. Threshold: ≥95% cho customer-facing agent.
  3. Argument correctness: Ngay cả khi chọn đúng tool và schema valid, arguments có đúng ngữ nghĩa không? Ví dụ, user hỏi "dưới 30 triệu", model có set max_price=30000000 không? Metric này phải chấm bằng LLM-as-judge hoặc rule-based, không có shortcut.

Cho evals nói chung, các framework như DeepEval và RAGAS đã trưởng thành trong 2026, bạn có thể xem thêm về đánh giá RAG với RAGAS và DeepEval cho production. Cho function calling cụ thể, tôi khuyến nghị viết custom harness đơn giản với pytest và pydantic, không cần framework nặng:

import pytest
from pydantic import ValidationError

@pytest.mark.parametrize("case", load_eval_dataset("function_calling.jsonl"))
def test_schema_compliance(case):
    response = call_llm(case["prompt"], tools=TOOLS)
    tool_call = extract_tool_call(response)

    if case["expected_tool"] is None:
        assert tool_call is None, "Model gọi tool khi không nên"
        return

    assert tool_call.name == case["expected_tool"], \
        f"Tool sai: {tool_call.name} vs {case['expected_tool']}"

    try:
        args = SCHEMAS[tool_call.name](**tool_call.args)
    except ValidationError as e:
        pytest.fail(f"Schema invalid: {e}")

    for key, expected in case["expected_args"].items():
        actual = getattr(args, key)
        assert actual == expected, f"Arg {key}: {actual} vs {expected}"

Chạy suite này trên nightly CI và mỗi khi bump model version. Regression 2 điểm phần trăm trên schema compliance là red flag đáng block deploy, không phải "sẽ fix sau".

Chi phí token và tối ưu hoá production

So, let's nói thẳng: cost của function calling thường bị đánh giá thấp cho tới khi hoá đơn OpenAI về. Con số cần nhớ: mỗi định nghĩa tool tốn 100–300 input tokens, tuỳ độ dài description và độ phức tạp schema. Hệ thống 15 tools thêm ~1,500–4,500 tokens mỗi request; nhân với volume production, con số này không nhỏ.

Ba kỹ thuật tối ưu tôi áp dụng thường xuyên:

  • Tool routing hai tầng: Request đầu chỉ có 3–5 "meta tools" phân loại intent (search, transaction, help…). Request thứ hai load subset tools chi tiết theo intent đã phân loại. Cắt được 40–60% token cho request có nhiều tools.
  • Prompt caching: Anthropic có cache_control, OpenAI có automatic prompt caching. Định nghĩa tool là candidate hoàn hảo cho cache vì hiếm thay đổi. Với Claude, đánh dấu tools và system prompt là cache breakpoint tiết kiệm 90% cost cho phần đó.
  • Model tiering: Dùng model nhỏ (Haiku, gpt-4.1-mini) cho tool selection ban đầu, escalate lên model lớn chỉ khi cần reasoning phức tạp. Với đúng eval, tôi thấy Haiku 4.5 đạt tool selection accuracy 97%+ trên nhiều workload, cost thấp hơn Sonnet 5x.

Đo cost thật với usage.input_tokens và usage.cache_read_input_tokens trong response, đừng đoán. Nếu bạn đang xây MCP server bằng Python với FastMCP, cân nhắc expose ít tools per server và để client route qua meta-tool.

Lỗi thường gặp và cách xử lý

Một số footgun tôi đã đạp trong 2 năm gần đây, ghi lại cho ai đang cân nhắc lên production:

  • Không loop qua tất cả tool_calls: response.choices[0].message.tool_calls là list. Code chỉ xử lý tool_calls[0] sẽ mất calls khi parallel bật. Tôi đã đạp bug này lúc ship phiên bản đầu, log ra mới thấy có 3 tool_calls nhưng handler chỉ đọc phần tử đầu.
  • Quên retry logic: OpenAI SDK không tự retry. Wrap trong tenacity với exponential backoff. Rate limit và 500 errors sẽ xảy ra trong production, không tránh được.
  • Trust JSON không validate: Ngay cả với strict mode, có thể xảy ra edge case (model hết token giữa chừng, JSON bị cut). Validate bằng Pydantic ở boundary. Bug im lặng đắt hơn crash sớm.
  • Description tool chứa ví dụ dài: Ví dụ trong description ngốn token đáng kể. Giữ description ngắn gọn (1–2 câu), đặt ví dụ vào system prompt nếu cần, cache system prompt.
  • Schema quá lồng ghép: Object lồng object 4–5 tầng làm compliance giảm và latency tăng. Flatten khi có thể; schema-first không có nghĩa schema phức tạp.

Nguyên tắc chốt: coi mọi response từ LLM là untrusted data. Function calling không thay đổi điều đó, nó chỉ thay đổi format của untrusted data. Validate, log, và có fallback path cho trường hợp tool call fail.

Câu hỏi thường gặp

Structured Outputs có thay thế Function Calling không?

Không. Function Calling giữ ngữ nghĩa tool call (tool id, tool loop, role tool trong history) mà Structured Outputs không có. Structured Outputs thay thế Function Calling chỉ khi bạn không cần side effect và không cần agent loop; ngoài trường hợp đó, hai pattern giải quyết bài toán khác nhau.

Claude có hỗ trợ strict mode như OpenAI không?

Không, Anthropic không có flag strict tương đương. Claude tuân thủ schema qua instruction tuning; trong thực tế đạt ~98% compliance, thấp hơn strict mode một chút nhưng đủ cho production nếu có validation layer bằng Pydantic hoặc Zod.

Có thể dùng parallel tool calls với strict mode không?

Không, đây là đánh đổi bắt buộc của OpenAI. Bật strict: true yêu cầu parallel_tool_calls: false. Nếu cần cả hai, cân nhắc dùng Claude, nhà cung cấp này không có ràng buộc tương tự. Với OpenAI, tôi thường ưu tiên strict và chấp nhận sequential.

Nên viết JSON Schema bằng tay hay sinh từ Pydantic?

Sinh từ Pydantic (hoặc Zod cho TypeScript). Sinh tự động giúp bạn có validator sẵn cho response, có type hint trong IDE, và bảo đảm schema đúng chuẩn OpenAI/Claude yêu cầu. Viết tay dễ sai additionalProperties hoặc quên set field required khi strict mode.

Function Calling tốn bao nhiêu token cho mỗi request?

Mỗi định nghĩa tool tốn 100–300 input tokens tuỳ độ dài description và schema. Hệ thống 15 tools thêm khoảng 1,500–4,500 tokens mỗi request. Bật prompt caching cho tool definitions và system prompt sẽ giảm 90% cost cho phần này khi hit cache.

Daichi Watanabe
Về Tác Giả Daichi Watanabe

LLM integration specialist with a strong opinion about function calling and an even stronger one about evaluations.