2026년 8월 15일 · 8분 읽기
Apple Foundation Models 시작하기: @Generable 구조화 출력 실측
Apple Foundation Models로 온디바이스 LLM을 처음 붙이면서 availability 확인부터 @Generable 구조화 출력까지 실제 호출 모양을 좁혔습니다. 평문과 구조화 출력을 각각 다섯 번 재 비교합니다.
2026년 8월 23일 업데이트
같은 요약을 두 방식으로 받아 봤습니다. 마크다운 평문으로 받으면 2.16초에서 2.36초가 걸리고 173자에서 202자가 나왔습니다. 구조화 출력으로 받으면 1.89초에 115자였습니다. M4 Max에서 8K 창으로 각 다섯 번 돌린 중앙값입니다.
구조화 출력은 115자를 생성했습니다. 평문은 173자에서 202자를 생성했습니다. 온디바이스에서는 이 출력 차이가 실행 시간과 발열로 이어집니다.
한눈에 보기
| 문제 | 같은 요약을 두 방식으로 받아 봤습니다. |
| 결정 | @Guide(description:)을 처음에는 한국어로 썼습니다. |
| 결과 | 출력 언어를 스키마가 흔들지 않도록 description은 전부 영어로 바꿨습니다. |
| 제약 | 진단에 필요한 것은 어떤 실패인가입니다. |
availability는 네 갈래이고 각각 다른 문장이 필요합니다
SystemLanguageModel에는 availability가 있습니다. 이걸 확인하지 않고 세션을 만들면 호출 시점에 오류가 납니다.
"쓸 수 있다/없다"만 보면 안 됩니다. 못 쓰는 이유가 넷이고, 각각 사용자가 할 수 있는 일이 다릅니다.
// app/JustSend/Sources/FM/FMAvailability.swift:59-70
switch FoundationModelRuntime.shared.model.availability {
case .available:
return .available
case .unavailable(.deviceNotEligible): // 기기가 지원하지 않습니다
return .unavailableDeviceNotEligible
case .unavailable(.appleIntelligenceNotEnabled): // 설정에서 꺼져 있습니다
return .unavailableAppleIntelligenceNotEnabled
case .unavailable(.modelNotReady): // 다운로드 중입니다
return .unavailableModelNotReady
@unknown default:
return .unavailableOther
}
@unknown default는 보험입니다. availability는 Apple이 나중에 case를 더할 수 있는 열거형입니다. 그때 컴파일이 깨지지 않고 unavailableOther로 떨어집니다.
넷을 하나로 뭉개면 화면에 띄울 문장이 하나가 됩니다. "AI 기능을 쓸 수 없습니다." 사용자는 무엇을 해야 할지 모릅니다.
| 상태 | 사용자가 할 수 있는 일 |
|---|---|
deviceNotEligible |
없습니다. 기기를 바꿔야 합니다 |
appleIntelligenceNotEnabled |
설정에서 켤 수 있습니다 |
modelNotReady |
기다리면 됩니다 |
| 그 외 | 알 수 없습니다 |
셋 중 방치하기 쉬운 것이 modelNotReady입니다. 모델은 처음에 다운로드됩니다. 그동안 기능이 없는 것처럼 보이면 사용자는 앱이 고장 났다고 생각합니다. "모델을 준비하는 중이에요. 잠시 후 다시 시도해주세요."라고 말하면 기다립니다.
안 되는 기기를 개발 중에 만들 수 있어야 합니다
지원하지 않는 기기에서만 실행되는 경로는 시뮬레이터에서 테스트하기 어렵습니다. 그래서 상태를 주입하는 구멍을 뒀습니다.
// FMAvailability.swift:50-57
#if DEBUG
// 출시 빌드는 이 분기를 컴파일하지 않는다. 개발·UI 테스트에서만
// `UITEST_FM_UNAVAILABLE=deviceNotEligible`처럼 정본 상태를 주입해
// 실제 미지원 기기에서만 실행되는 저장·화면 경로를 재현한다.
if let forced = forcedAvailability(from: ProcessInfo.processInfo.arguments) {
return forced
}
#endif
#if DEBUG가 이 구멍의 안전장치입니다. 출시 빌드에는 이 코드가 들어가지 않습니다. 실행 인자로 모델 상태를 바꿀 수 있는 앱을 스토어에 올리면 안 됩니다.
첫 판정 비용은 사라지지 않고 다음 사람에게 옮겨 갑니다
availability를 처음 물으면 모델 핸들이 초기화됩니다. 이 비용이 어디서 나는지가 결정입니다.
앱을 켤 때 확인하면 실행이 느려집니다. 그래서 런치 경로에서 걷어내는 게 흔한 선택입니다. 하지만 걷어낸 비용은 사라지지 않습니다.
// FMAvailability.swift:39-47
/// 첫 프레임이 선 뒤 **배경에서** 모델 핸들을 깨워 둔다.
///
/// 판정을 런치 경로에서 걷어 내면 그 비용은 사라지지 않고 처음 묻는 사람에게
/// 옮겨 간다 — 설정 화면을 여는 손이나 첫 저장이다. 그 자리가 런치보다 낫지만,
/// 아무도 내지 않는 것이 가장 낫다.
static func warmInBackground() {
Task.detached(priority: .utility) { _ = check() }
}
첫 화면이 뜬 다음에 낮은 우선순위로 미리 깨웁니다. 사용자가 설정을 열거나 첫 메모를 저장할 때는 이미 준비돼 있습니다.
FoundationModelRuntime.shared가 static let이라 초기화가 한 번만 돕니다. 그 한 번을 유틸리티 스레드가 냅니다.
온디바이스 모델만의 문제도 아닙니다. 첫 호출이 비싼 자원은 전부 같은 선택지를 갖습니다. 세 자리 중 하나입니다.
| 어디서 내나 | 누가 기다리나 | 대가 |
|---|---|---|
| 실행 경로 | 앱을 켠 사람 전부 | 콜드 스타트가 늘어난다 |
| 처음 쓸 때 | 설정을 열거나 첫 저장을 하는 손 | 그 화면이 멈춘다 |
| 첫 프레임 뒤 배경 | 아무도 | 안 쓸 사람의 배터리도 쓴다 |
세 번째를 골랐습니다. 마지막 칸이 그 대가입니다. AI 기능을 쓰지 않을 사용자의 기기에서도 모델 핸들이 한 번 깨어납니다. 그 비용과 첫 사용자를 기다리게 하는 비용을 재서 후자가 크다고 봤습니다.
세션은 요청마다 새로 만듭니다
LanguageModelSession을 만들 때 두 개를 줍니다. 모델과 지시문입니다.
// FoundationProfileModelExecutor.swift:34-41
let session = LanguageModelSession(
model: model,
instructions: request.instructions // 이번 단계의 지시문
)
let options = GenerationOptions(
sampling: .greedy, // 가장 확률 높은 토큰만 고릅니다
maximumResponseTokens: request.responseTokens // 답의 길이 상한
)
sampling: .greedy는 매번 같은 입력에 같은 답을 내게 합니다. 창작이 아니라 추출을 시키는 경우에 맞습니다. 요약 결과가 실행마다 달라지면 회귀를 판정할 수 없습니다.
세션을 요청마다 새로 만드는 것도 선택입니다. 세션을 재사용하면 앞의 대화가 컨텍스트에 남습니다. 문서 조각 서른 개를 요약하는데 앞 조각들이 창을 차지하면 뒤로 갈수록 넣을 공간이 줄어듭니다. 각 조각을 독립으로 다루려면 세션도 독립이어야 합니다.
창 크기는 물어봐서 정합니다
컨텍스트 창의 크기를 상수로 박아 두면 OS가 올라갈 때 뒤처집니다.
// FoundationModelRuntime.swift:186-191
static func resolveContextTokenLimit(dynamicContextSize: Int?) -> Int {
guard let dynamicContextSize, dynamicContextSize > 0 else {
return SummaryContextBudget.sessionLimit // 못 물어보면 기본값
}
return dynamicContextSize // 물어봤으면 실측값
}
실제 창 크기를 알려 주는 API가 있으면 그 값을 쓰고, 없으면 보수적인 기본값으로 갑니다. 기본값은 4,096입니다. 이 값으로 호출을 막지는 않습니다. 조각을 몇 개로 나눌지 계산할 때만 씁니다.
구조화 출력이 평문보다 빠르고 짧았습니다
답을 어떤 모양으로 받을지가 속도를 바꿉니다.
@Generable을 붙인 타입을 만들면 모델이 그 모양으로만 답합니다. Apple은 이것을 guided generation이라고 부릅니다.
// FoundationProfileModelExecutor.swift:180-187
@Generable
private struct GeneratedSummaryChunk {
@Guide(description: "concise one-line title")
let headline: String
@Guide(description: "concise concrete sentences ending with punctuation", .maximumCount(3))
let points: [String]
}
호출은 generating:에 그 타입을 넘깁니다.
// FoundationProfileModelExecutor.swift:57-61
try await session.respond(
to: request.prompt,
generating: GeneratedSummaryChunk.self, // 이 모양으로만 답하세요
options: options
)
같은 요약을 세 방식으로 재 봤습니다.
| 받는 방식 | 시간 | 길이 |
|---|---|---|
| 구조화 출력 | 1.89초 | 115자 |
| 마크다운 평문 | 2.16~2.36초 | 173~202자 |
| 구조화 + 스키마를 프롬프트에서 뺌 | 2.48초 | 166자 |
M4 Max, 8K 창, 각 다섯 번의 중앙값입니다.
구조화가 빠른 이유는 짧기 때문입니다. 스키마가 형식을 강제하니 모델이 절제합니다. 평문으로 받으면 "다음은 요약입니다" 같은 말을 붙이고 마크다운 기호를 넣습니다. 그 글자도 생성 비용입니다.
스키마를 프롬프트에서 빼면 느려졌습니다
세 번째 줄이 뜻밖이었습니다.
includeSchemaInPrompt는 기본값이 true입니다. 스키마를 프롬프트에 함께 넣는다는 뜻입니다. 프롬프트가 길어지니 끄면 빨라질 것 같습니다.
껐더니 2.48초로 오히려 느려졌습니다. 모델이 형식을 모르니 더 길게 썼습니다. 입력을 줄인 대신 출력이 늘어났고, 온디바이스에서는 출력이 더 비쌉니다.
기본값을 바꾸기 전에 재 보는 편이 낫습니다.
배열에는 상한만 두고 하한은 두지 않았습니다
points에 .maximumCount(3)이 붙어 있습니다. 최소 개수는 없습니다. 둘 다 이유가 있습니다.
개수를 열어 두면 모델이 원문 문장을 거의 그대로 여섯 일곱 개로 옮깁니다. 요약이 아니라 복사입니다. 그리고 토큰 예산 끝에서 마지막 값이 잘립니다.
잘린 모양이 이렇습니다.
"...증가율을 놓친"guided generation이 예산 끝에서 값을 문법적으로 닫아 주지만 내용은 중간에서 끊깁니다. 2026년 8월 12일 iPhone 17 Pro 실기에서 대형 문서 열두 개를 돌렸을 때 200-token 예산은 이런 요점을 반복해서 만들었습니다.
그래서 구조와 예산을 함께 제한했습니다. 배열은 최대 세 개, 각 값은 구두점으로 끝나는 완결문이어야 한다고 스키마와 지시문 양쪽에서 한 번씩 말합니다. 긴 조각에는 280-token까지 줘서 마지막 요점을 닫을 공간을 남깁니다.
하한을 두면 모델이 지어냅니다
.count(2...4)처럼 최소를 요구하면 어떻게 되는지도 재 봤습니다.
사실이 둘뿐인 짧은 원문에서 모델이 나머지를 채워야 합니다. 채우려면 지어내야 합니다. 그 지어낸 문장이 원문에 근거가 있는지 검사하는 게이트에 걸려서, 결과가 전부 버려졌습니다.
앵커가 밀집한 짧은 본문에서 하한 있는 스키마는 세 번 중 세 번 실패했습니다.
| 제약 | 결과 |
|---|---|
| 개수 제한 없음 | 원문 복사, 마지막 값 잘림 |
| 최대 3개 | 요약다운 길이 |
| 최소 2개 이상 요구 | 짧은 원문에서 지어냄, 0/3 통과 |
빈 배열을 받는 것이 지어낸 문장을 받는 것보다 낫습니다.
스키마 설명의 언어가 출력 언어를 끌어당깁니다
@Guide(description:)을 처음에는 한국어로 썼습니다. 한국어 원문에 영어 요약을 요청했더니 한국어 요약이 돌아왔습니다. 스키마 설명도 프롬프트의 일부라서 모델이 그 언어로 끌린 것입니다.
같은 날 16개 언어를 한 번에 재 봤습니다. 서울시 심야버스 기사 하나를 짧은 입력 164자와 긴 입력 515자로 넣고, 언어 코드마다 그 언어로 요약해 달라고 요청했습니다.
| 요청한 언어 | 짧은 입력 | 긴 입력 |
|---|---|---|
| ko, ja, zh-Hans, zh-Hant, it, pt-BR | 요청한 언어 | 요청한 언어 |
| en | 요청한 언어 | 한국어 |
| es | 영어 | 요청한 언어 |
| fr, de, da, nl, sv | 영어 | 영어 |
| nb | 영어 | 한국어 |
| tr, vi | 한국어 | 한국어 |
짧은 입력에서 7/16, 긴 입력에서 7/16이 요청한 언어로 왔습니다. 둘 다 맞은 것은 여섯입니다. 프랑스어를 요청하면 영어가 오고, 터키어를 요청하면 한국어가 왔습니다. 원문이 한국어니까 모델이 원문 쪽으로 끌리거나, 지시문이 영어니까 영어로 끌립니다.
숫자도 같이 흔들렸습니다. 스페인어 요청에서 from 12 to 22000이 나왔습니다. 원문은 22개 노선입니다.
출력 언어를 스키마가 흔들지 않도록 description은 전부 영어로 바꿨습니다. 언어는 코어의 지시문이 정하고, 스키마는 형태만 말합니다. 기록은 tmp/evidence/fm-locale-compliance-2026-08-11.log에 남겼습니다.
오류에 프롬프트가 딸려 오는 것을 조심해야 합니다
모델이 실패하면 오류를 로그에 남기고 싶어집니다. 그런데 Foundation Models의 오류 설명에는 프롬프트가 들어 있습니다. 그대로 남기면 사용자가 쓴 글이 로그에 쌓입니다.
// FoundationProfileModelExecutor.swift:104-109
/// 오류의 **케이스 이름만** 남긴다. Foundation Models 오류 설명에는 프롬프트가
/// 딸려 오므로 괄호 뒤 payload는 버린다.
static func signature(of error: Error) -> String {
let described = String(describing: error)
let head = described.prefix { $0 != "(" && $0 != ":" && !$0.isNewline }
return head.trimmingCharacters(in: .whitespaces)
}
여는 괄호나 콜론이 나오는 순간 자릅니다. 남는 것은 guardrailViolation 같은 케이스 이름입니다.
진단에 필요한 것은 어떤 실패인가입니다. 사용자가 무엇을 썼는가가 아닙니다.
로그 수준도 나눴습니다. 성공 경로까지 info로 남기면 실행마다 한 줄씩 불어납니다. 평상시에는 debug로 숨기고 막힌 경우만 error로 올립니다. log show --debug로 필요할 때 꺼냅니다.