· llm / openai / anthropic

Structured outputs từ LLMs: JSON mode, Zod và tool use

Grammar-constrained sampling là primitive LLM đáng tin cậy duy nhất. Cách OpenAI, Anthropic, Zod và Vercel AI SDK v6 so sánh — và điểm nào vẫn có thể thất bại.

Bởi

3.237 từ · 17 phút đọc

Pipeline LLM của bạn chạy tốt trong môi trường test. Rồi lúc 3 giờ sáng, một dấu ngoặc đóng bị thiếu trong JSON response của model khiến job processor crash. Schema validator ném exception, không có gì bắt được nó, và queue bị đình trệ suốt sáu tiếng.

JSON mode không ngăn chặn điều này. Nó chỉ đảm bảo JSON hợp lệ về mặt cú pháp — không đảm bảo JSON khớp với schema của bạn. Structured Outputs thì có. Hiểu rõ sự khác biệt này, và biết implementation của từng provider còn thiếu sót ở đâu, là ranh giới giữa một pipeline đáng tin cậy và một cuộc gọi lúc 3 giờ sáng.

Dành cho ai

Bạn đang xây dựng ứng dụng LLM trên production. Bạn biết Zod schema là gì. Bạn muốn hiểu các đảm bảo cơ chế — không phải một bài hướng dẫn “JSON là gì?”.

Phân biệt cốt lõi: JSON mode và structured outputs

JSON mode (response_format: { type: "json_object" }) yêu cầu model tạo ra JSON hợp lệ về mặt cú pháp. Chỉ vậy thôi. Cấu trúc, tên field, và kiểu dữ liệu vẫn không bị ràng buộc — model đoán dựa trên prompt của bạn.

Structured Outputs sử dụng grammar-constrained sampling ở tầng decoder. Bộ sampler bị ràng buộc chỉ tạo ra các chuỗi token tương ứng với output hợp lệ theo schema. Đây không phải là validation sau hậu kỳ, cũng không phải prompt engineering. Ràng buộc này mang tính cấu trúc.

Thông báo của OpenAI:

“While both ensure valid JSON is produced, only Structured Outputs ensure schema adherence.”

Tài liệu của Anthropic mô tả cơ chế tương tự:

“constraining the model’s token sampling to schema-valid outputs (a technique called grammar-constrained sampling)”

Cả hai implementation đều dẫn đến cùng một kết luận: với production pipeline cần parse output của model thành các struct có kiểu, JSON mode là primitive sai. Structured Outputs mới là thứ bạn cần.

JSON mode: nó là gì và tại sao không đủ

JSON mode tồn tại như một đảm bảo nhẹ nhàng. Nó hữu ích khi thử nghiệm và khi code downstream có thể xử lý bất kỳ shape JSON hợp lệ nào. Pipeline production hiếm khi dễ tính đến vậy.

Các failure mode mang tính định tính — không có benchmark đáng tin cậy nào qua được quá trình adversarial verification về tỷ lệ lỗi của JSON mode. Điều được ghi nhận: model có thể tạo ra JSON hợp lệ cú pháp nhưng thiếu required field, dùng sai kiểu dữ liệu, hoặc có các key bạn không yêu cầu. Validator của bạn sẽ bắt được điều này, nhưng chỉ khi bạn có một cái. Nhiều pipeline không có.

Dùng JSON mode khi: bạn đang prototype, schema đơn giản và prompt-adherence là đủ, hoặc bạn đang dùng model không hỗ trợ Structured Outputs.

Dùng Structured Outputs khi: output được đưa vào typed data pipeline, vi phạm schema sẽ gây lỗi ở downstream, hoặc bạn đang tiếp cận bất kỳ thứ gì gần với production scale.

Function calling: cơ chế của OpenAI và Anthropic

Function calling (tool use) là cơ chế ra đời sớm hơn để tạo structured response. Hai provider triển khai nó theo cách khác nhau.

OpenAI function calling

API function-calling của OpenAI cho phép bạn định nghĩa các tool với input JSON Schema. Model trả về mảng tool_calls khi quyết định gọi một tool. Caller thực thi function và trả về kết quả.

import OpenAI from "openai";

const client = new OpenAI();

const response = await client.chat.completions.create({
  model: "gpt-4o",
  messages: [{ role: "user", content: "What is the weather in Paris?" }],
  tools: [
    {
      type: "function",
      function: {
        name: "get_weather",
        description: "Returns weather data for a city",
        parameters: {
          type: "object",
          properties: {
            city: { type: "string" },
            unit: { type: "string", enum: ["celsius", "fahrenheit"] },
          },
          required: ["city"],
        },
      },
    },
  ],
});

Function calling không có strict: true vẫn không đảm bảo schema adherence — model có thể bỏ qua required properties hoặc dùng sai kiểu. Để có đảm bảo đó, bạn cần Structured Outputs API (phần bên dưới).

Anthropic tool use

API tool use của Anthropic đạt GA vào ngày 30 tháng 5 năm 2024. Mỗi định nghĩa tool cần có name, description, và input_schema (JSON Schema). Khi Claude gọi một tool, API trả về stop_reason: "tool_use".

Tham số tool_choice điều khiển hành vi gọi tool:

ModeHành viMặc định?
autoModel tự quyết định có gọi tool hay khôngCó, khi có tools
anyPhải gọi một trong các tool được cung cấpKhông
toolBuộc gọi một tool cụ thể theo tênKhông
noneKhông được dùng toolCó, khi không có tools

Một hành vi cần lưu ý: khi tool_choiceany hoặc tool, API prefill assistant message. Điều này loại bỏ mọi phần mở đầu ngôn ngữ tự nhiên trước các tool_use block — dù bạn có yêu cầu trong prompt. Hãy tính đến điều này khi thiết kế UI.

OpenAI Structured Outputs API

OpenAI Structured Outputs đạt GA vào ngày 6 tháng 8 năm 2024. API sử dụng response_format: { type: "json_schema" } với strict: true. Cả Python SDK và Node.js SDK đều đi kèm helper zodResponseFormat để chuyển đổi Zod schema sang định dạng cần thiết và trả về kết quả đã parse có kiểu.

import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";

const client = new OpenAI();

const CalendarEventSchema = z.object({
  name: z.string(),
  date: z.string(),
  participants: z.array(z.string()),
});

const completion = await client.beta.chat.completions.parse({
  model: "gpt-4o-2024-08-06",
  messages: [
    { role: "user", content: "Alice and Bob are going to a science fair on Friday." }
  ],
  response_format: zodResponseFormat(CalendarEventSchema, "event"),
});

const event = completion.choices[0].message.parsed;
// event có kiểu { name: string; date: string; participants: string[] }

Vào tháng 5 năm 2025, OpenAI mở rộng strict mode để hỗ trợ parallel tool calling và bổ sung các tính năng JSON Schema mới: string validation patterns (email, uri, date-time), và range constraints cho số và mảng.

Những gì đảm bảo không bao gồm

Ba failure mode có thể vượt qua đảm bảo schema trên mọi implementation Structured Outputs:

  1. Safety refusal — model từ chối tạo output vì lý do chính sách. Response không có structured output; code của bạn phải xử lý message.refusal.
  2. Bị cắt ngắn do token limit — response bị cắt tại max_tokens. Python SDK ném LengthFinishReasonError khi finish_reason == "length". Output không đầy đủ và không hợp lệ theo schema.
  3. Content filter block — output bị chặn sau khi tạo.

Schema adherence chỉ được đảm bảo cho các completion bình thường. Hãy xử lý rõ ràng cả ba trường hợp.

const completion = await client.beta.chat.completions.parse({ ... });
const message = completion.choices[0].message;

if (message.refusal) {
  throw new Error(`Model refused: ${message.refusal}`);
}
if (completion.choices[0].finish_reason === "length") {
  throw new Error("Response truncated at max_tokens");
}

const data = message.parsed; // an toàn ở đây

Anthropic strict tool use

Strict tool use của Anthropic thêm grammar-constrained sampling vào function calling. Khi đặt strict: true trên định nghĩa tool kết hợp với tool_choice: { type: "any" }, bạn có hai đảm bảo: một tool sẽ được gọi VÀ các input của nó sẽ khớp chính xác với schema.

Strict tool use thoát khỏi public beta vào ngày 29 tháng 1 năm 2026 — không cần beta header sau ngày đó. Tính năng này có sẵn trên Claude Sonnet 4.5, Claude Opus 4.5, Claude Haiku 4.5, và tất cả các model Claude API về sau.

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const tools = [
  {
    name: "get_weather",
    description: "Returns weather data for a city",
    input_schema: {
      type: "object" as const,
      properties: {
        city: { type: "string", description: "City name" },
        unit: { type: "string", enum: ["celsius", "fahrenheit"] },
      },
      required: ["city"],
    },
    strict: true,
  },
];

const response = await client.messages.create({
  model: "claude-sonnet-4-6",
  max_tokens: 1024,
  tools,
  tool_choice: { type: "any" },
  messages: [{ role: "user", content: "What is the weather in Paris?" }],
});

// response.stop_reason === "tool_use"
const toolUseBlock = response.content.find((b) => b.type === "tool_use");

Ba failure mode tương tự ở phần OpenAI cũng áp dụng ở đây — safety refusal, cắt ngắn, và content filter block đều có thể vượt qua đảm bảo.

Claude Mythos Preview không hỗ trợ forced tool use. Các request với tool_choice: { type: "any" } hoặc tool_choice: { type: "tool", name: "..." } trả về lỗi 400 trên model đó. Nếu bạn đang dùng Mythos Preview, hãy dùng tool_choice: auto và dựa vào prompting — ràng buộc schema strict: true vẫn áp dụng cho bất kỳ tool nào model chọn gọi.

Giới hạn schema của Anthropic

Structured outputs của Anthropic không hỗ trợ:

  • Recursive schemas
  • Ràng buộc số (minimum, maximum, multipleOf)
  • Ràng buộc độ dài chuỗi (minLength, maxLength)
  • additionalProperties đặt thành giá trị khác false

Các SDK chính thức loại bỏ các ràng buộc không được hỗ trợ ở phía client và chuyển chúng vào các trường description. Việc validation range số và độ dài chuỗi phải diễn ra trong application code của bạn. Đây là giới hạn đáng kể nếu schema của bạn dựa vào các ràng buộc này để đảm bảo tính đúng đắn — một z.number().min(0).max(100) âm thầm trở thành plain number field ở tầng API.

Liệu Vercel AI SDK có xử lý các giới hạn schema của Anthropic một cách hợp lý khi dùng Claude models hay không vẫn là câu hỏi mở. Hành vi ở tầng SDK chưa được xác minh — hãy test với schema cụ thể của bạn trước khi dùng trong production.

Zod để parse có kiểu an toàn

Zod là lớp validation de facto trong TypeScript LLM pipeline. Hai method cần biết:

.parse() ném exception khi thất bại. Trả về bản sao deep clone có kiểu mạnh khi thành công. Dùng khi vi phạm schema luôn là lỗi của developer.

.safeParse() không bao giờ ném exception. Trả về discriminated union: { success: true; data: T } | { success: false; error: ZodError }. Dùng cho LLM output khi response không đầy đủ hoặc không hợp lệ là điều có thể xảy ra.

import { z } from "zod";

const ArticleSchema = z.object({
  title: z.string(),
  tags: z.array(z.string()),
  publishedAt: z.string().datetime(),
});

// Option A: ném exception — dùng khi vi phạm schema luôn là lỗi của developer
const article = ArticleSchema.parse(llmOutput);

// Option B: discriminated union — dùng cho LLM output
const result = ArticleSchema.safeParse(llmOutput);
if (!result.success) {
  console.error(result.error.issues);
  // e.g. [{ path: ["publishedAt"], code: "invalid_string", message: "Invalid datetime" }]
} else {
  const article = result.data; // có kiểu đầy đủ
}

// Option C: async schema cần dùng variant async
const articleAsync = await ArticleSchema.safeParseAsync(llmOutputStream);

Khi có lỗi, dùng result.error.issues (không phải errors — alias errors đã bị xóa trong Zod v4). Đã xác minh trên v4.4.3.

Vercel AI SDK v6: generateText với structured output

Vercel AI SDK v6 là một breaking change. generateObjectstreamObject đã deprecated và sẽ bị xóa. Phiên bản thay thế là generateTextstreamText với tham số output.

Khái niệmv5 (deprecated)v6 (hiện tại)
Object có cấu trúcgenerateObject({ schema })generateText({ output: Output.object({ schema }) })
StreamingstreamObject({ schema })streamText({ output: Output.object({ schema }) })
Partial streampartialObjectStreampartialOutputStream
Import{ generateObject } from 'ai'{ generateText, Output } from 'ai'

Nhiều tutorial vẫn dùng cú pháp v5. Nếu bạn copy-paste ví dụ SDK từ các bài viết trước cuối năm 2025, rất có thể bạn đang xem code đã deprecated.

Migration tự động: npx @ai-sdk/codemod upgrade v6

Ví dụ code v6

import { generateText, streamText, Output } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const ArticleSchema = z.object({
  summary: z.string().describe("One-paragraph summary"),
  temperature: z.number(),
  recommendation: z.string(),
});

// generateText với structured output
const { output } = await generateText({
  model: anthropic("claude-sonnet-4-6"),
  output: Output.object({ schema: ArticleSchema }),
  prompt: "Analyze the weather in San Francisco for a developer blog post.",
});
// output có kiểu z.infer<typeof ArticleSchema>

// Phiên bản streaming
const { partialOutputStream } = streamText({
  model: anthropic("claude-sonnet-4-6"),
  output: Output.object({ schema: ArticleSchema }),
  prompt: "...",
});
for await (const partial of partialOutputStream) {
  console.log(partial);
}

v6 cung cấp năm output mode thông qua Output.*:

ModeTrường hợp dùng
Output.object({ schema })Object có cấu trúc đơn lẻ với schema validation
Output.array({ element })Mảng typed objects; validation per-element
Output.choice({ options })Phân loại từ tập string cố định
Output.json()JSON không có cấu trúc, không có schema enforcement
Output.text()Plain text (mặc định)

Một thay đổi kiến trúc từ v5: structured output giờ được tính là một bước trong vòng lặp multi-step tool-calling. Nếu bạn kết hợp tools với structured output, hãy cấu hình stopWhen rõ ràng. Kiểu lỗi khi thất bại là AI_NoObjectGeneratedError, giữ lại text, response, usage, và cause để debug.

Zod, Valibot, Arktype, và bất kỳ thư viện nào implement Standard JSON Schema interface đều hoạt động tốt trong v6.

Bảng so sánh

Cơ chếĐảm bảo schemaFailure mode vượt qua đảm bảoGiới hạn schema đáng chú ý
JSON mode (OpenAI / Anthropic)Chỉ hợp lệ cú pháp JSONN/A — không có đảm bảo schemaKhông có
OpenAI Structured Outputs (strict: true)Grammar-constrainedSafety refusal, cắt ngắn max_tokens, content filterKhông (hỗ trợ hầu hết tính năng JSON Schema)
OpenAI function calling (không có strict)Không cóTất cả những trường hợp trên
Anthropic strict tool use (strict: true + any)Grammar-constrainedSafety refusal, cắt ngắn max_tokens, content filterKhông có recursive schemas, không có ràng buộc số/độ dài chuỗi
Anthropic tool use (không có strict)Không cóTất cả những trường hợp trên
Vercel AI SDK v6 Output.objectPhụ thuộc modelTruyền từ providerPhụ thuộc model — giới hạn của Anthropic sẽ ảnh hưởng

Khuyến nghị

Với production pipeline mới trên OpenAI: dùng client.beta.chat.completions.parse() với zodResponseFormat. Xử lý rõ ràng message.refusalfinish_reason === "length".

Với production pipeline mới trên Anthropic: dùng strict tool use với tool_choice: { type: "any" }. Loại bỏ các ràng buộc số và độ dài chuỗi khỏi Zod schema trước khi truyền vào API — chúng sẽ bị âm thầm bỏ qua.

Với abstraction đa provider: Vercel AI SDK v6 là lựa chọn hợp lý, nhưng hãy xác minh hành vi schema Anthropic với các schema cụ thể của bạn trước khi release. Hành vi ở tầng SDK cho các tính năng schema không được Anthropic hỗ trợ chưa được ghi nhận đầy đủ.

Về validation layer: dùng .safeParse() ở khắp nơi khi xử lý LLM output. Dùng .parse() chỉ khi vi phạm schema thực sự là lỗi của developer.

Với streaming: dùng streamText với Output.object trong v6. Iterator partialOutputStream cung cấp các partial object có kiểu được xây dựng dần dần.

Về tối ưu chi phí: sau khi schema compliance ổn định, LLM cost routing: khi nào Haiku thắng Opus trình bày khi nào các tác vụ classification và extraction có thể chuyển sang model rẻ hơn mà không ảnh hưởng chất lượng output.

Về quản lý token budget: nếu system prompt dài đẩy structured-output response chạm giới hạn max_tokens, prompt caching năm 2026 — so sánh Anthropic, OpenAI và Gemini giải thích cách cả ba provider xử lý prefix caching, giảm chi phí context lặp lại tới 90%.

Gotcha và edge case

OpenAI Python SDK — nested Pydantic models với field descriptions

Nếu bạn dùng nested Pydantic models trong strict mode và thêm Field(description=...) vào một field có kiểu là một Pydantic model khác, SDK sẽ gửi JSON Schema không hợp lệ đến API và bạn nhận 400 BadRequestError. Nguyên nhân: JSON Schema cho $ref đi kèm extra properties cần inline expansion; code path cũ đã bỏ qua recursive strict coercion trên expanded object.

Cách sửa: nâng cấp openai-python lên phiên bản bao gồm PR #2025 (merged ngày 17 tháng 1 năm 2025). Bất kỳ phiên bản nào sau ngày đó đều an toàn.

# Python — điều gây ra bug (đã sửa trong openai-python sau 2025-01-17)
from openai import OpenAI
from pydantic import BaseModel, Field

class Address(BaseModel):
    street: str
    city: str

class Person(BaseModel):
    # Field(description=...) này trên nested model type gây ra lỗi 400
    address: Address = Field(description="Home address")
    name: str

Anthropic — không có numeric constraints ở API

z.number().min(0).max(100) trong Zod schema của bạn không tạo ra ràng buộc nào ở tầng Anthropic API. SDK âm thầm loại bỏ minimum, maximum, và multipleOf. Hãy validate range trong application code sau khi parse.

Anthropic — extended thinking không tương thích với forced tool_choice

Khi bật extended thinking, tool_choice: { type: "any" }tool_choice: { type: "tool", name: "..." } không được hỗ trợ và trả về runtime error. Chỉ tool_choice: { type: "auto" } (mặc định) và tool_choice: { type: "none" } hoạt động cùng với extended thinking.

Khuyến nghị production trong bài này — strict tool use với tool_choice: { type: "any" } — không áp dụng khi bạn thêm extended thinking. Nếu bật cả hai, API sẽ từ chối request. Hãy dùng tool_choice: auto và dựa vào prompting khi cần extended thinking.

Zod v4 — alias errors bị xóa

error.errors là alias cho error.issues trong Zod v3. Nó đã bị xóa trong v4. Nếu bạn đang nâng cấp từ v3, hãy tìm .errors và thay bằng .issues.

Vercel AI SDK v6 — generateObject vẫn còn nhưng đã deprecated

generateObject vẫn hoạt động trong v6 nhưng đã deprecated và sẽ bị xóa. Chạy nó hiện tại không tạo ra cảnh báo runtime. Kiểm tra trong codebase của bạn với:

grep -r "generateObject\|streamObject" src/ --include="*.ts"

Anthropic × Vercel AI SDK — tương thích schema

Liệu Vercel AI SDK v6 có dịch Zod schema cho Anthropic bằng cách loại bỏ các tính năng không được hỗ trợ hay không vẫn chưa được xác minh. Một GitHub issue (#13355) xác nhận API từ chối schema có các property không được hỗ trợ — nhưng liệu SDK có xử lý việc dịch này trước khi request đến API hay không thì chưa được ghi nhận. Hãy test schema cụ thể của bạn với Claude models trước khi dựa vào SDK như một abstraction layer.

Tài liệu tham khảo