Guided Generation – Khi Swift type trở thành hợp đồng với model
iOS & SwiftContents
Chào mừng bạn đến với Fx Studio. Mình là một thằng iOS dev quay lại nghề sau một quãng nghỉ kha khá, đang dựng lại mọi thứ qua đường agent. Có những thứ mới tới mức phải ngồi học lại từ đầu. Guided Generation trong Apple Foundation Models là một trong số đó.
Nghe tên thì hơi nghiêm trọng. Nhưng ý tưởng phía sau lại rất đời.
LLM rất giỏi nói chuyện. Nó viết mô tả, trả lời câu hỏi, tóm nội dung, đặt tên biến, giải thích code, đủ thứ trên đời. Nhưng app iOS không sống bằng văn chương. App cần dữ liệu rõ ràng: String, Date, Bool, một enum đóng, một struct có field rành mạch, field nào được nil, ngày giờ format ra sao.
Nói cách khác: LLM thích nói bằng ngôn ngữ tự nhiên, còn app cần ngôn ngữ lập trình. Guided Generation nằm đúng ở giữa hai thế giới đó.
Bắt đầu thôi!
Chuẩn bị
Trước khi vào, bạn nên ngó qua:
- Foundation Models Hello World – bài trước trong series, dựng môi trường và chạy phát đầu tiên.
- Struct, Enum trong Swift – ôn lại type system một chút nếu cần.
Về môi trường: framework Foundation Models là của WWDC 2025, chạy on-device, cần iOS 26 / macOS 26 trở lên và máy có Apple Intelligence. Demo cuối bài mình chạy trên device thật.
Đây vẫn là giai đoạn beta nhiều thứ. Số version với API trong bài mình verify ở
developer.apple.com, nhưng bạn cứ kiểm lại sát ngày dùng cho chắc nhóe.
Vấn đề cũ: bắt LLM trả JSON rồi tự parse
Trước khi có cơ chế kiểu Guided Generation, cách phổ biến để lấy dữ liệu có cấu trúc từ LLM là bắt model trả JSON.
Ví dụ mình muốn biến câu này:
họp team thứ 6 lúc 3 chiều tại văn phòng quận 1, nhớ mang laptop và deck Q2
Thành một object dùng trong app Calendar. Mình prompt:
Extract event information from the text below. Return valid JSON with title, date, time, location and notes.
Model trả về:
{
"title": "Họp team",
"date": "2026-06-26",
"time": "15:00",
"location": "văn phòng quận 1",
"notes": "nhớ mang laptop và deck Q2"
}
Nhìn thì ổn. Cho tới khi nó không ổn.
- Có lúc model bọc JSON trong markdown, thêm câu “Here is the JSON:” ở đầu, thế là parser toang.
- Có lúc thiếu field. Có lúc
datetrả về"thứ 6 tuần này"thay vìyyyy-MM-dd. - Có lúc
timetrả về"3 PM"thay vì15:00.
Vậy là mình phải viết thêm một lớp parse. Rồi validate. Rồi fallback. Rồi sửa prompt. Rồi vẫn run run mỗi lần ship.
Đơn giản mà mệt.
LLM sinh text thì rất tự nhiên. Nhưng app nhận text lỏng lẻo thì mình lãnh đủ.
Vấn đề gốc nằm ở đây: model sinh ra text tự do trước, rồi mình mới đứng ở cuối đường mà gọt nó về cấu trúc. Cái khuôn được áp vào sau khi model đã nói xong. Mà text tự do thì vô vàn cách sai.
Cú chuyển
Guided Generation lật ngược toàn bộ chuyện đó. Gói trong hai dòng:
Trước: prompt -> model trả text -> mình parse -> hy vọng đúng.
Sau: mình khai báo type -> model bị ép sinh đúng type ngay trong lúc decode.
Khác biệt nằm ở chữ “ngay trong lúc”. Cấu trúc không còn là cái lưới mình quăng ra ở cuối. Nó là cái khuôn đặt sẵn từ đầu, và model không có cách nào sinh ra ngoài khuôn.
Cơ chế đằng sau gọi là constrained decoding. LLM sinh output theo từng token một. Với constrained decoding, ở mỗi bước model chỉ được phép chọn token nào giữ cho output tiếp tục khớp schema. Token làm hỏng cấu trúc thì không nằm trong tập cho phép. Apple nói thẳng là cách này bảo đảm tính đúng cấu trúc ở mức nền tảng – không phải “thường thì đúng”, mà đúng theo thiết kế.
Hệ quả mình thích nhất: model không thể bịa ra một field lạ, cũng không thể trả về một enum case không tồn tại. Cái lo “lỡ nó hallucinate ra giá trị tào lao” biến mất một mảng lớn.
Đây là chỗ mình ngồi ngẫm hơi lâu. Bao năm coi output của LLM là thứ phải đi dọn. Hoá ra mình được phép định hình nó từ trong ra, chứ không phải lau từ ngoài vào.
Và một cái lợi nữa lúc đầu mình không để ý: prompt gọn hẳn. Mình không cần dặn model về format, vì schema lo phần đó rồi. Prompt giờ chỉ còn lo đúng một việc, là mình muốn nội dung gì.
Mental model gọn lại như vầy:
Prompt chính = user muốn gì @Generable = output phải có hình dạng gì @Guide = từng field nên được điền thế nào Swift type = hợp đồng giữa model và app
Hay nói đời hơn:
Prompt là lời nhờ vả,
@Generablelà cái form cần điền,@Guidelà ghi chú bên cạnh từng ô. Model đọc ngôn ngữ tự nhiên rồi điền form đó thành dữ liệu typed cho Swift dùng.
@Generable – cái khuôn
Bạn gắn @Generable lên một struct hoặc một enum, thế là báo cho Foundation Models: đây là type mà model được phép sinh ra trực tiếp. Macro tự sinh schema cho bạn ở compile time, tự lo phần ráp output thành object Swift.
Với app Calendar, mình định nghĩa:
import FoundationModels
@Generable
struct EventStruct {
var title: String
var date: String
var time: String
var location: String?
var notes: String?
}
Khi gắn @Generable, EventStruct không còn là struct thường nữa. Mình đang nói với framework: output của model cần được sinh theo hình dạng này.
Cách cũ: Prompt -> LLM trả text/JSON -> mình parse -> Swift struct Guided Generation: Prompt + Swift type -> LLM sinh output khớp Swift struct
Yêu cầu duy nhất: mọi property bên trong cũng phải là kiểu generable được. String, Int, Double, Bool, Array – mấy kiểu cơ bản thì sẵn. Struct với enum @Generable khác cũng lồng vào nhau được, đệ quy luôn được.
Một cách nhớ:
@Generablebiến Swift type thành hợp đồng output giữa app và model. Hai bên ký vào cái khuôn, rồi không bên nào được phá.
Vì từ góc nhìn app, mình không muốn một cục text đẹp. Mình muốn một object dùng tiếp được trong code: hiển thị preview card, cho user sửa trước khi lưu, validate ngày giờ, map sang EKEvent, viết test. Có object rồi mới làm được mấy thứ đó. @Generable có giá trị không phải vì làm LLM thông minh hơn, mà vì làm output của LLM dễ đưa vào app hơn.
@Guide – vạch giới hạn trên khuôn
Có khuôn rồi, @Guide là chỗ siết từng chi tiết. Và đây là điểm dễ hiểu hụt nhất: @Guide có hai tầng khác hẳn nhau về độ chắc.
Tầng mềm là description. Một lời dặn bằng natural language cho riêng field đó. Model nên nghe, nhưng vẫn có thể chệch. Giống ghi chú bên lề.
import FoundationModels
@Generable
struct EventStruct {
@Guide(description: "Tiêu đề sự kiện, ngắn gọn, không thêm thông tin không có trong input.")
var title: String
@Guide(description: "Ngày diễn ra, format yyyy-MM-dd. Suy ra theo lịch hiện tại nếu user nói hôm nay, mai, thứ 6.")
var date: String
@Guide(description: "Giờ bắt đầu, format HH:mm theo 24h. Ví dụ 3 giờ chiều là 15:00.")
var time: String
@Guide(description: "Địa điểm nếu user có nhắc tới. Để nil nếu input không có.")
var location: String?
@Guide(description: "Ghi chú thêm: cần mang gì, chuẩn bị gì, thông tin phụ.")
var notes: String?
}
description giúp model hiểu field này nghĩa là gì, format mong muốn ra sao, khi nào nên để nil, có được suy diễn không và suy diễn trong giới hạn nào. Nó không thay prompt chính, nó bổ sung ngữ cảnh ngay cạnh field.
Tầng cứng là mấy ràng buộc như .range(...), .count(...), .anyOf([...]), hay regex. Mấy cái này không phải lời dặn. Chúng được bảo đảm qua constrained decoding. Bạn ghi .range(1...5) thì model không thể trả về 7. Một cái là lời xin, một cái là luật.
Phân biệt này quan trọng lúc bạn cần một field chắc chắn. Đừng dặn nó trong description rồi mong nó nghe lời, hãy tìm xem có ràng buộc cứng nào áp được không.
Lấy ngay EventStruct. Field calendarHint gợi ý loại lịch. Nếu để String? rồi mô tả “Công việc, Cá nhân, Sức khỏe…” thì đó là tầng mềm, model vẫn có thể trả ra một chuỗi lạ. Muốn đóng cứng không gian output, dùng enum:
@Generable
enum CalendarHint {
case work
case personal
case health
case family
case study
case other
}
@Guide(description: "Loại lịch phù hợp nhất dựa trên nội dung user nhập.") var calendarHint: CalendarHint
Enum làm không gian output hẹp lại, model đỡ bay. Đây chính là tầng cứng: model bị ép chọn đúng một trong các case, không bịa được. Với date cần đúng yyyy-MM-dd, nếu muốn chắc hơn cả mô tả, bạn còn dùng được regex guide để ép cấu trúc chuỗi. Mô tả là gợi ý, ràng buộc cứng mới là bảo đảm.
Lưu ý design: type càng chặt thì càng phải nghĩ kỹ fallback. User nhập loại sự kiện không nằm trong enum thì sao? Có cần
.otherkhông? AI không cứu mình khỏi việc phải thiết kế đâu nhóe.
Cho nó một hình hài
Tới giờ toàn khái niệm. Cho một ví dụ để nó sáng lên. Lấy lại câu ban đầu, đưa qua một parser nhỏ.
import Foundation
import FoundationModels
struct EventParser {
private let session = LanguageModelSession()
func parse(_ rawText: String, referenceDate: Date = .now) async throws -> EventStruct {
let today = referenceDate.formatted(date: .numeric, time: .omitted)
let prompt = """
Parse the natural-language calendar request into EventStruct.
User text:
\(rawText)
Context:
- Today is \(today).
- User locale is \(Locale.current.identifier).
- Use yyyy-MM-dd for date, HH:mm 24-hour for time.
- Do not invent location or notes if the user did not mention them.
"""
let response = try await session.respond(to: prompt, generating: EventStruct.self)
return response.content // -> EventStruct, đã typed
}
}
Để ý hai chỗ. Một, prompt chính không hề liệt kê tên field, vì cái khuôn lo phần đó rồi, prompt chỉ lo nhiệm vụ và ngữ cảnh. Hai, mình truyền thêm today và locale vào, vì “mai” hay “thứ 6” cần một mốc thời gian để suy ra, không có mốc thì model đoán lệch.
respond(to:generating:) trả về Response<EventStruct>, mình lấy struct ra ở .content. Không còn một dòng parse JSON nào.
EventStruct(
title: "Họp team",
date: "2026-06-26",
time: "15:00",
location: "văn phòng quận 1",
notes: "nhớ mang laptop và deck Q2"
)
Có struct rồi, app hiển thị một card cho user xác nhận: tiêu đề, ngày giờ đã chuẩn hoá, địa điểm, ghi chú. Đúng thì bấm lưu, sai thì sửa. User gõ theo cách người ta muốn nói, app nhận dữ liệu theo cách code muốn xử lý.
Đó chính là chỗ ngôn ngữ tự nhiên bắt tay với type system.

Bài này mình dừng ở preview card. Không ôm EventKit vội, vì trọng tâm là việc model sinh ra dữ liệu typed cho app, không phải chuyện lưu lịch. Map sang EKEvent để dành bài sau.
Vì sao không dùng JSON cho xong?
Câu hỏi hợp lý. JSON vẫn dùng được, và ở nhiều backend nó vẫn là cách phổ biến lấy structured output. Nhưng trong app Swift, Guided Generation có vài lợi thế rõ:
- Gần code hơn. Output định nghĩa bằng Swift type, không phải schema nằm lửng lơ trong prompt. Nhìn vào type là biết app muốn gì.
- Ít parse thủ công hơn. Không còn cảnh model trả markdown rồi mình regex cắt JSON. Nghe hơi quê, mà ai từng viết LLM output parser chắc hiểu cảm giác.
- Gom knowledge đúng chỗ. Thay vì prompt dài lê thê giải thích từng field, mình để hướng dẫn ngay cạnh field bằng
@Guide. Code đọc dễ hơn. - Hợp agentic coding. Khi để Claude Code hay Codex đọc project,
@Generablevà@Guidecho agent thấy type, thấy field, thấy constraint. Context rõ thì agent ít đoán mò.
Góc agentic: @Guide là documentation sống
Điểm này mình khá thích. Guided Generation không chỉ giúp app chạy tốt, nó còn giúp agent đọc project dễ hơn.
Khi agent mở code và thấy:
@Generable
struct EventStruct {
@Guide(description: "Ngày diễn ra, format yyyy-MM-dd.")
var date: String
}
Nó hiểu ngay đây là structured output của Foundation Models, và date cần format gì. Không cần lục prompt nằm đâu đó trong một file khác. Với agentic coding, context nằm trong code càng rõ thì agent càng ít phải đoán.
Nói hơi gắt:
Prompt tốt không cứu nổi một project mù mờ. Code tự giải thích được mới là thứ agent cần.
@Guidevì vậy không chỉ là hướng dẫn cho model lúc runtime, nó còn là documentation sống cho cả người đọc code lẫn agent viết code.
Luôn có availability gate
Foundation Models là on-device AI. Không phải device nào cũng chạy được, không phải môi trường nào cũng available. Nên trước khi render AI feature, cần một gate.
Một cái bẫy hay gặp: dùng cờ boolean kiểu SystemLanguageModel.default.isAvailable. Cờ này không đủ tin – nó có thể trả true cả khi model assets chưa tải xong. Cách chắc hơn là switch trên availability để bắt đúng lý do unavailable:
import SwiftUI
import FoundationModels
struct EventFeatureView: View {
var body: some View {
switch SystemLanguageModel.default.availability {
case .available:
NLEventCreationView()
case .unavailable(let reason):
// reason: .appleIntelligenceNotEnabled / .deviceNotEligible / .modelNotReady
ManualEventFallbackView(reason: reason)
}
}
}
Nếu model không available, app vẫn có form nhập tay. Đừng để demo chết chỉ vì máy không hỗ trợ. Và đừng xem fallback là phần phụ.
AI feature fail mà app vẫn dùng được, đó mới là app tử tế.
Những lỗi dễ mắc
- Guide quá chung chung.
@Guide(description: "Ngày")gần như vô dụng. Viết rõ format mong muốn vào. - Để model tự bịa field không có trong input. User không nói địa điểm thì
locationnên nil, và guide phải nói rõ điều đó. - Dồn toàn bộ constraint vào prompt chính. Prompt chính mô tả task tổng thể, constraint của field nào thì để gần field đó bằng
@Guide. - Quên locale và reference date. “Mai”, “thứ 6”, “cuối tuần này” cần mốc thời gian để suy ra. Không có mốc, model đoán lệch.
- Quên verify API trên SDK thật. Đây là API mới, cú pháp có thể đổi theo SDK. Trước khi publish, chạy thử bằng Xcode đang dùng. Làm với agent thì bắt nó
ExecuteSnippethoặc build project thật để xác nhận.
Đừng tin trí nhớ. Cũng đừng tin bài viết này tuyệt đối. Tin build log.
Tạm kết
Mình muốn bạn nhớ đúng một thứ, mà nó không phải tên hai cái macro:
- Guided Generation dời cấu trúc từ cuối đường (mình parse text) lên đầu đường (model bị ép sinh đúng type). Đó là cú chuyển.
@Generablelà cái khuôn (struct/enum).@Guidelà vạch giới hạn – tầng mềmdescriptionđể gợi ý, tầng cứng.range/.count/.anyOf/regex và enum để bảo đảm.- Cái làm nó tin được là constrained decoding, không phải tại model ngoan.
Câu ngắn nhất của bài: @Generable là cái form, @Guide là ghi chú bên cạnh từng ô, LLM đọc ngôn ngữ tự nhiên rồi điền form đó thành dữ liệu typed cho Swift dùng.
Vậy là từ một câu rất người – “họp team thứ 6 lúc 3 chiều tại văn phòng quận 1” – mình đi tới một Swift struct đủ rõ để app xử lý. Không cần bắt model viết văn rồi mình đoán ý.
Bài này mình cố tình giữ một ý. Khi type phức tạp lên, có vài thứ sẽ đụng phải: thứ tự khai báo property ảnh hưởng tới kết quả, rồi chuyện stream từng phần khi struct lớn, rồi map EventStruct sang EKEvent để lưu thật vào Calendar. Mấy cái đó để dành bài sau nhóe.
Okay! Tới đây mình xin kết thúc bài về Guided Generation với @Generable. Nếu có thắc mắc hay góp ý, bạn để lại bình luận hoặc gửi email qua trang Contact nhóe.
Cảm ơn bạn đã đọc bài viết này!
Related Posts:
Written by chuotfx
Hãy ngồi xuống, uống miếng bánh và ăn miếng trà. Chúng ta cùng nhau đàm đạo về đời, về code nhóe!
Leave a Reply Cancel reply
Fan page
Tags
Recent Posts
- Guided Generation – Khi Swift type trở thành hợp đồng với model
- Foundation Models và “Hello World” trong 10 phút
- Skill Boundary (P/L/R): dạy kỹ năng biết khi nào nên dừng
- Context – Cung cấp ít hơn để đạt kết quả tốt hơn
- Tui Học AI – Bài 3 – “Tôi hỏi AI” → “Tôi quản lý mọi thứ AI nhìn thấy”
- Đừng xoá hàm này (phần 1)
- Tui Học AI – Bài 2 – “Tôi ra lệnh cho AI” → “Tôi cộng tác với AI”
- Context Rot – Vì sao cho mô hình thêm thông tin đôi khi làm kết quả tệ đi?
- Giải mã tool poisoning – Vì sao con AI coding tool an toàn nhất cũng không tự bảo vệ bạn
- Tui Học AI – Bài 1 – “AI trả lời tôi” → “Tôi kiểm soát câu trả lời”
You may also like:
Archives
- June 2026 (13)
- May 2026 (2)
- April 2026 (5)
- March 2026 (5)
- February 2026 (1)
- January 2026 (10)
- December 2025 (1)
- October 2025 (1)
- September 2025 (4)
- August 2025 (5)
- July 2025 (10)
- June 2025 (1)
- May 2025 (2)
- April 2025 (1)
- March 2025 (8)
- January 2025 (7)
- December 2024 (4)
- September 2024 (1)
- July 2024 (1)
- June 2024 (1)
- May 2024 (4)
- April 2024 (2)
- March 2024 (5)
- January 2024 (4)
- February 2023 (1)
- January 2023 (2)
- November 2022 (2)
- October 2022 (1)
- September 2022 (5)
- August 2022 (6)
- July 2022 (7)
- June 2022 (8)
- May 2022 (5)
- April 2022 (1)
- March 2022 (3)
- February 2022 (5)
- January 2022 (4)
- December 2021 (6)
- November 2021 (8)
- October 2021 (8)
- September 2021 (8)
- August 2021 (8)
- July 2021 (9)
- June 2021 (8)
- May 2021 (7)
- April 2021 (11)
- March 2021 (12)
- February 2021 (3)
- January 2021 (3)
- December 2020 (3)
- November 2020 (9)
- October 2020 (7)
- September 2020 (17)
- August 2020 (1)
- July 2020 (3)
- June 2020 (1)
- May 2020 (2)
- April 2020 (3)
- March 2020 (20)
- February 2020 (5)
- January 2020 (2)
- December 2019 (12)
- November 2019 (12)
- October 2019 (19)
- September 2019 (17)
- August 2019 (10)








