iOS 앱에 AI 넣어보기 - Foundation Models

피터·2025년 10월 27일
post-thumbnail

안녕하세요. 오늘은 Foundation Models에 대해서 알아보도록 하겠습니다.

Foundation Models는 Apple에서 제공하는 Apple Intelligence를 앱에서 직접 이용할 수 있도록 만들어진 프레임워크입니다. 디바이스의 AI를 사용하기 때문에 오프라인 상태에서도 동작하고, 앱 용량도 늘어나지 않습니다. 모든 처리가 기기 내에서 이루어지기 때문에 사용자 데이터도 안전하게 보호됩니다.

시작하기 전에 필요한 것들이 있는데요, Xcode 26이 필요하고 맥OS도 최신 버전인 Tahoe로 업데이트 해야 합니다...ㅎ

오늘 내용은
Code along with the Foundation Models framework | Meet with Apple
해당 영상을 기반으로 작성하였습니다.

한번 해보시죠!

Chapter 1: 기본 사용법

우선 어떻게 작동하는지 테스트를 해보려고 합니다. 많이 사용하는 방법은 Playground를 이용하는 건데, 이번에 플레이그라운드 매크로가 새로 생겼습니다.

#Playground 매크로를 사용하면 옆에 Canvas가 생기면서 바로 결과를 확인할 수 있습니다. 마치 SwiftUI Preview처럼 실시간으로 결과를 볼 수 있어서 굉장히 편리합니다.

이 기능은 다음에 더 자세히 다뤄봐야겠지만, 일단 개발 편의성이 확실히 올라간 건 맞네요~!

기본 사용 방법

간단한 사용법은 다음과 같습니다. 먼저 LanguageModelSession()을 생성합니다. URLSession()처럼 하나의 세션을 통해 언어 모델과 계속 대화할 수 있게 설계되어 있어서 Session이라는 이름을 사용하는 것 같습니다.

그리고 respond(to:) 메서드를 이용해서 AI에게 질문을 던집니다. to 파라미터에 프롬프트를 입력하면 일정 시간 후에 결과가 나옵니다.

#Playground {
    let session = LanguageModelSession()
    let response = try await session.respond(to: "한국 여행 일정 3일로 계획해줘")
}

실행하면 결과는 prompt, content, 그리고 duration으로 리턴됩니다.

와 신기하네요! 이렇게 간단하게 AI를 사용할 수 있다니. 당장 앱에 적용하고 싶지만, 아직 해결해야 할 문제들이 좀 보입니다. 이건 차차 해결해나가도록 하죠.

Instructions로 일관된 결과 만들기

AI 응답이 매번 달라지면 곤란하겠죠? 우리는 일관성 있고 정확한 정보를 원합니다.

이때 사용하는 게 instructions입니다. AI에게 주는 가이드라인이라고 생각하면 됩니다!

#Playground {
    let instructions = """
    사용자를 위한 여행 일정을 만드는 것이 당신의 임무입니다.
    각 날짜마다 활동, 숙소, 레스토랑을 포함해야 합니다.
    항상 제목, 간단한 설명, 그리고 일별 계획을 포함하세요.
    """
    let session = LanguageModelSession(instructions: instructions)
    let response = try await session.respond(to: "한국 여행 일정 3일로 계획해줘")
}

Instructions를 추가하니 결과가 훨씬 구조화되고 체계적으로 바뀌었습니다:

### 1일차: 서울
- 오전: 경복궁 방문 - 한국의 전통 궁궐을 둘러보세요.
- 점심: 북촌 한옥마을 근처의 한식당에서 전통 한식을 즐겨보세요.
- 오후: 인사동에서 전통 공예품과 기념품을 구매하세요.
- 저녁: 명동 거리에서 다양한 먹거리와 쇼핑을 즐기세요.
- 숙소: 신라호텔, 롯데호텔 등 서울 시내 중심에 위치
### 2일차: 서울
- 오전: 남산타워에서 서울 전경을 감상하세요.
- 점심: 남산 근처의 카페에서 가벼운 점심을 즐기세요.
- 오후: 국립중앙박물관 방문 - 한국의 역사와 문화를 깊이 탐구하세요.
- 저녁: 홍대 거리에서 다양한 문화 공연과 거리 음식을 즐겨보세요.
- 숙소: 호텔 메리어트 동대문, 그랜드 인터컨티넨탈 서울 파르나스
### 3일차: 서울
- 오전: 롯데월드 어드벤처 또는 코엑스 아쿠아리움 방문 - 가족 단위 여행객에게 적합합니다.
- 점심: 실내식당이나 카페에서 간단히 식사하세요.
- 오후: 남산서울타워 또는 남산공원 산책 - 서울의 풍경을 즐기며 여유로운 시간을 보내세요.
- 저녁: 마포나 홍대 근처에서 저녁 식사를 마무리하세요.
- 숙소: 호텔 메리어트 동대문, 그랜드 인터컨티넨탈 서울 파르나스

요구사항대로 나온 것을 확인하실 수 있습니다! 각 날짜별로 활동, 숙소가 명확하게 구분되어 나왔네요.

Instructions vs Prompt의 차이

그런데 instructionsprompt의 차이가 뭘까요?

  • Instructions는 개발자가 제공: 앱의 규칙이나 페르소나를 정의
  • Prompts는 사용자로부터 올 수 있음: 실제 사용자 입력
  • Instructions가 우선순위가 높음: Prompt injection 공격 방어
  • 신뢰할 수 없는 콘텐츠는 instructions에 넣지 말 것

정리하자면 Instructions는 세션 전체에서 유지되며, 세션의 transcript(대화 기록)에서 항상 첫 번째 항목으로 기록됩니다. 그래서 Instructions는 정적으로 유지하고 사용자 입력을 넣지 않는 게 좋습니다.

모델 가용성 체크하기

그런데 문제가 있습니다. Apple Intelligence를 지원하지 않는 기기이거나 사용자가 활성화하지 않았을 수도 있잖아요?

이럴 때는 어떻게 해야 할까요?

#Playground {
    let model = SystemLanguageModel.default
    // The availability property provides detailed information on the model's state.
    switch model.availability {
        case .available:
            print("Foundation Models를 사용할 수 있으며 준비되었습니다!")
        case .unavailable(.deviceNotEligible):
            print("이 기기에서는 모델을 사용할 수 없습니다.")
        case .unavailable(.appleIntelligenceNotEnabled):
            print("설정에서 Apple Intelligence가 활성화되지 않았습니다.")
        case .unavailable(.modelNotReady):
            print("모델이 아직 준비되지 않았습니다. 나중에 다시 시도해주세요.")
        case .unavailable(let other):
            print("알 수 없는 이유로 모델을 사용할 수 없습니다.")
    }
}

이렇게 시스템 모델의 availability 상태를 확인할 수 있습니다. 각 케이스별로 적절한 안내 메시지를 사용자에게 보여줄 수 있겠죠.

앱에 적용하기

자, 이제 Playground에서 충분히 테스트해봤으니 실제 앱에 적용해보겠습니다!

ItineraryGenerator라는 여행일정 생성자 객체를 만들어서 진행하겠습니다. Playground에서 했던 것 처럼

final class ItineraryGenerator {
    var error: Error?
    let landmark: Landmark
    private var session: LanguageModelSession
    private(set) var itineraryContent: String?
    init(landmark: Landmark) {
        self.landmark = landmark
        let instructions = """
        사용자를 위한 여행 일정을 만드는 것이 당신의 임무입니다.
        각 날짜마다 활동, 숙소, 레스토랑을 포함해야 합니다.
        항상 제목, 간단한 설명, 그리고 일별 계획을 포함하세요.
        """
        self.session = LanguageModelSession(instructions: instructions)
    }
}

Instructions를 이용해서 세션을 초기화합니다.

여기서 sessionvar로 선언한 이유는 나중에 대화가 이어지면서 세션을 업데이트하거나 새로운 세션으로 교체할 수 있게 하기 위함입니다.

이제 실제로 여행 일정을 생성하는 함수를 만들어봅시다:

func generateItinerary(dayCount: Int = 3) async {
        do {
            let prompt = "\(landmark.name)에서 \(dayCount)일 간의 여행계획을 만드세요."
            let response = try await session.respond(to: prompt)
            self.itineraryContent = response.content
        } catch {
            self.error = error
        }
}

이걸 뷰에 적용시켜 보겠습니다.

struct LandmarkTripView: View {
    let landmark: Landmark
    @State private var itineraryGenerator: ItineraryGenerator?
    @State private var requestedItinerary: Bool = false
    var body: some View {
        ScrollView {
            if !requestedItinerary {
                VStack(alignment: .leading, spacing: 16) {
                    Text(landmark.name)
                        .padding(.top, 150)
                        .font(.largeTitle)
                        .fontWeight(.bold)
                    Text(landmark.shortDescription)
                }
                .padding(.horizontal)
                .frame(maxWidth: .infinity, alignment: .leading)
            } else if let content = itineraryGenerator?.itineraryContent {
                Text(LocalizedStringKey(content))
                    .padding()
            }
        }
        .scrollDisabled(!requestedItinerary)
        .safeAreaInset(edge: .bottom) {
            ItineraryButton {
                requestedItinerary = true
                await itineraryGenerator?.generateItinerary()
            }
        }
        .task {
            let generator = ItineraryGenerator(landmark: landmark)
            self.itineraryGenerator = generator
        }
        .headerStyle(landmark: landmark)
    }
}

.task에 generator를 생성하고 버튼을 누렀을 경우에 생성합니다.
그럼 그 결과가 text로 나오게 됩니다.

멋있네요! 실제로 동작하는 AI 기능이 생겼습니다.

그런데 여기서 문제가 있습니다. 지금은 단순 텍스트로만 나오는데, 이걸 지도에 표시하거나 UI를 가독성 좋게 만들고 싶어요. 어떻게 하면 활용도를 높일 수 있을까요?

Chapter 2: 구조체로 응답 받기 (Guided Generation)

이제 단순 문자열이 아닌 구조화된 데이터(Swift 구조체)로 응답을 받아봅시다!

왜 구조체로 받아야 할까?

  • 타입 안정성: 컴파일 타임에 오류 잡기
  • 예측 가능한 결과: 항상 같은 형태의 데이터
  • 데이터 다루기 쉬움: 문자열 파싱 필요 없음

이런 장점이 생깁니다.

핵심 매크로: @Generable과 @Guide

먼저 알아야 할 매크로가 두 가지 있습니다.

@Generable 매크로

  • 이 구조체를 "AI가 생성할 수 있는 타입"으로 만들어줌
  • AI한테 "이런 형태로 데이터 만들어줘!" 라고 알려주는 것
  • 이게 없으면 AI가 이 구조체를 어떻게 만들지 몰라요

@Guide 매크로

AI한테 힌트를 주는 매크로입니다. "이 속성은 이렇게 만들어줘!"

  1. @Guide(description: "...")
@Guide(description: "An exciting name for the trip.")
let title: String
  • AI한테 설명 제공
  • "title은 그냥 문자열이 아니라 흥미진진한 여행 이름이야!"
  • 이게 없으면 AI가 "여행"이라고만 쓸 수도 있음
  1. @Guide(.anyOf(...))
@Guide(description: "An exciting name for the trip.")
@Guide(.anyOf(ModelData.landmarkNames))
let title: String
  • 선택지 제한: 이 배열 안에 있는 것만 골라서 써!
  • ModelData.landmarkNames = ["에펠탑", "콜로세움", "타지마할"...]
  • AI가 막 이상한 장소 만들어내는 걸 방지

예시:

  • ❌ 없으면: "환상의 섬 아틀란티스 여행" (존재 안 함)
  • ✅ 있으면: "에펠탑 여행" (실제 랜드마크)
  1. @Guide(.count(...))
@Guide(description: "A list of day-by-day plans.")
@Guide(.count(3))
let days: [Day]
  • 개수 제한: 정확히 3개만 만들어줘!
  • 배열의 요소 개수를 지정

예시:

  • ❌ 없으면: AI가 2개나 10개 만들 수도 있음
  • ✅ 있으면: 무조건 3일치 일정

이건 추후에 더 자세히 다뤄보겠습니다.

@Generable
struct Itinerary: Equatable {
    @Guide(description: "An exciting name for the trip.")
    let title: String
    @Guide(.anyOf(ModelData.landmarkNames))
    let destinationName: String
    let description: String
    @Guide(description: "An explanation of how the itinerary meets the user's special requests.")
    let rationale: String
    @Guide(description: "A list of day-by-day plans.")
    @Guide(.count(3))
    let days: [DayPlan]
}

그래서 위와같은 데이터 구조체를 만들었습니다.

이제 적용하는 방법입니다.
아까 respond(to:) 메서드에서 generating: 파라미터에 데이터 타입을 넣습니다.

   @Observable
@MainActor
final class ItineraryGenerator {
    var error: Error?
    let landmark: Landmark
    private var session: LanguageModelSession
    private(set) var itinerary: Itinerary?
    init(landmark: Landmark) {
        self.landmark = landmark
        let instructions = """
        사용자를 위한 여행 일정을 만드는 것이 당신의 임무입니다.
        각 날짜마다 활동, 숙소, 레스토랑을 포함해야 합니다.
        """
        self.session = LanguageModelSession(instructions: instructions)
    }
    func generateItinerary(dayCount: Int = 3) async {
        do {
            let prompt = "\(landmark.name)에서 \(dayCount)일 간의 여행계획을 만드세요."
            let response = try await session.respond(
                to: prompt,
                generating: Itinerary.self
            )
            self.itinerary = response.content
        } catch {
            self.error = error
        }
    }
}

이제 content가 Itinerary 타입으로 바뀌어서 나오게 됩니다.

이제 instructions도 줄일 수있습니다. 왜냐하면 어떤 데이터 타입으로 나올지 결정되었기 때문입니다.

변경했더니 이렇게 나왔습니다.

로그를 찍어보니

(lldb) po itinerary
▿ Itinerary
  - title : "Sahara Desert Adventure"
  - destinationName : "Sahara Desert"
  - description : "Experience the vast beauty and mystery of the Sahara Desert."
  - rationale : "This itinerary offers a balanced mix of adventure, relaxation, and cultural immersion in the Sahara Desert, ensuring an unforgettable experience."
  ▿ days : 3 elements
    ▿ 0 : DayPlan
      - title : "Day 1: Arrival and Exploration"
      - subtitle : "Welcome to the Sahara!"
      - destination : "Sahara Desert"
      ▿ activities : 3 elements
        ▿ 0 : Activity
          - type : FoundationModelsCodeAlong.Kind.sightseeing
          - title : "Visit Erg Chebbi Sand Dunes"
          - description : "Explore the stunning Erg Chebbi sand dunes, one of the most iconic sites in the Sahara."1 : Activity
          - type : FoundationModelsCodeAlong.Kind.foodAndDining
          - title : "Dine at a Local Berber Restaurant"
          - description : "Enjoy traditional Berber cuisine under the stars at a local restaurant."2 : Activity
          - type : FoundationModelsCodeAlong.Kind.hotelAndLodging
          - title : "Stay at a Desert Camp"
          - description : "Overnight at a luxurious desert camp with camel rides and stargazing opportunities."1 : DayPlan
      - title : "Day 2: Cultural Immersion and Adventure"
      - subtitle : "Experience Desert Life"
      - destination : "Sahara Desert"
      ▿ activities : 3 elements
        ▿ 0 : Activity
          - type : FoundationModelsCodeAlong.Kind.sightseeing
          - title : "Explore Todra Gorge"
          - description : "Hike through the majestic Todra Gorge, known for its dramatic cliffs."1 : Activity
          - type : FoundationModelsCodeAlong.Kind.foodAndDining
          - title : "Traditional Moroccan Lunch"
          - description : "Savor a hearty Moroccan lunch at a local eatery."2 : Activity
          - type : FoundationModelsCodeAlong.Kind.sightseeing
          - title : "Visit a Berber Village"
          - description : "Learn about the nomadic lifestyle by visiting a nearby Berber village."2 : DayPlan
      - title : "Day 3: Relaxation and Departure"
      - subtitle : "Unwind and Reflect"
      - destination : "Sahara Desert"
      ▿ activities : 3 elements
        ▿ 0 : Activity
          - type : FoundationModelsCodeAlong.Kind.foodAndDining
          - title : "Farewell Dinner"
          - description : "Enjoy a farewell dinner featuring regional specialties and live music."1 : Activity
          - type : FoundationModelsCodeAlong.Kind.hotelAndLodging
          - title : "Check Out and Depart"
          - description : "Check out of your desert camp and begin your journey home, taking memories of the Sahara with you."2 : Activity
          - type : FoundationModelsCodeAlong.Kind.sightseeing
          - title : "Sunset at the Oasis"
          - description : "Watch the breathtaking sunset over the desert oasis before departing."

이렇게 나왔습니다. 신기하네요

Chapter 3: 프롬프팅 기법

다음 단계는 프롬프팅 기법입니다!

질문하는 방식을 개선해서 더 정확하고 일관된 응답을 받는 방법입니다.

핵심 기법:

  1. PromptBuilder: 복잡한 프롬프트를 Swift 코드처럼 작성
  2. One-shot/Few-shot Prompting: 예시를 보여주면 AI가 따라함

PromptBuilder 사용하기

이전에 generateItinerary에서 dayCountlandmark.name을 동적으로 받지 못하는 문제가 있었습니다.

PromptBuilder를 이용하면:
1. 동적인 값을 프롬프트에 넣을 수 있음
2. 예시 데이터를 제공해서 정확도 증가
3. Swift 구문처럼 자연스럽게 작성

약간 느낌이 SwiftUI의 VStack { } 같은 느낌입니다.

@available(iOS 26.0, macOS 26.0, *)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
public struct Prompt : Sendable {
    /// Creates an instance with the content you specify.
    public init(_ content: some PromptRepresentable)
}
@available(iOS 26.0, macOS 26.0, *)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
extension Prompt : PromptRepresentable {
    /// An instance that represents a prompt.
    public var promptRepresentation: Prompt { get }
}
@available(iOS 26.0, macOS 26.0, *)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
extension Prompt {
    public init(@PromptBuilder _ content: () throws -> Prompt) rethrows
}

우선 정의는 이렇게 나와있는데 이것도 다음에 잘 다뤄봐야겠습니다.
우선 example은 다음과 같습니다.

extension Itinerary {
    static let exampleTripToJapan = Itinerary(
        title: "일본 벚꽃 여행",
        description: "도쿄와 교토의 아름다운 봄...",
        days: [
            Day(activity: "...", hotel: "...", restaurant: "..."),
            // ...
        ]
    )
}

이걸 제공하는 것을
One-shot Prompting (예시 제공) 이라고한다고 합니다.

💡 One-shot vs Few-shot

설명예시 개수
Zero-shot예시 없이 요청만0개
One-shot예시 1개 제공1개
Few-shot예시 여러 개 제공2~5개

이 앱에서는 One-shot 사용 (일본 여행 예시 1개)

그래서 리팩토링을 진행합니다.

func generateItinerary(dayCount: Int = 3) async {
	do {
		let prompt = Prompt {
			"\(landmark.name)에 대한 \(dayCount)일 여행 일정을 만들어줘."
            "재미있는 제목과 설명을 포함해줘."
            "여기 원하는 형식의 예시가 있어. 내용은 복사하지 말고 형식만 참고해:"
            Itinerary.exampleTripToJapan
		}
		let response = try await session.respond(
				to: prompt,
				generating: Itinerary.self
            )
		self.itinerary = response.content
      } catch {
		self.error = error
      }
}

Chapter 4: 스트리밍 응답

다음은 스트리밍 응답에 대해서 알아보겠습니다!

문제점

지금까지의 방식에는 개선해야 할 점이 있습니다:

  • Duration이 보통 8~11초 정도 걸림
  • 완료되면 UI가 한 번에 업데이트됨
  • 사용자 입장에서는 꽤 오래 기다려야 함

해결책: 스트리밍

ChatGPT처럼 응답이 생성되면서 실시간으로 UI가 채워지는 방식으로 변경할 수 있습니다!

핵심 개념: PartiallyGenerated

여기서 알아야 할 핵심 개념은 PartiallyGenerated입니다.

🔍 PartiallyGenerated가 뭐야?

비유로 설명:

기존 Itinerary:

// 모든 값이 있어야 함 (완성품)
Itinerary(
    title: "파리 3일",           // ✅ 있음
    description: "낭만적인...",   // ✅ 있음
    days: [Day(...), ...]        // ✅ 있음
)

Itinerary.PartiallyGenerated:

// 일부만 있어도 됨 (제작 중)
Itinerary.PartiallyGenerated(
    title: "파리 3일",           // ✅ 있음
    description: nil,            // ⏳ 아직 안 만들어짐
    days: nil                    // ⏳ 아직 안 만들어짐
)

장점:

  • AI가 title만 만들어도 바로 화면에 보여줄 수 있음
  • 그 다음 description 만들어지면 추가로 보여줌
  • 점진적으로 완성!

그래서 @Observable을 사용하면 상태 변경에 따른 UI 업데이트를 SwiftUI에서 효과적으로 나타낼 수 있습니다!

@Observable
@MainActor
final class ItineraryGenerator {
    ...
    private(set) var itinerary: Itinerary.PartiallyGenerated?
    ...
}

우선 PartiallyGenerated? 타입으로 변경합니다.
그리고 AsyncStream을 이용해서 값의 변경을 일으킵니다.

func generateItinerary(dayCount: Int = 3) async {
    do {
        let prompt = Prompt {
            "Generate a \(dayCount)-day itinerary to \(landmark.name)."
            "Give it a fun title and description."
            "Here is an example of the desired format, but don't copy its content:"
            Itinerary.exampleTripToJapan
        }
        // 기존 (한 번에 받기)
        let response = try await session.respond(to: prompt,
                                                 generating: Itinerary.self)
        self.itinerary = response.content
        // 변경 후 (스트리밍으로 받기)
        let stream = session.streamResponse(to: prompt,
                                            generating: Itinerary.self)
        for try await partialResponse in stream {
            self.itinerary = partialResponse.content
        }
    } catch {
        self.error = error
    }
}

데이터 타입도 ResponseStream으로 바뀌었습니다.

View에서도 변경을 합니다.

struct ItineraryView: View {
    let landmark: Landmark
    let itinerary: Itinerary.PartiallyGenerated

모든 값이 옵셔널이 되어서 처리할 때도 언랩핑을 해서 로직 처리를 해줘야 합니다.

if let title = itinerary.title {
    Text(title)
        .contentTransition(.opacity)
        .font(.largeTitle)
        .fontWeight(.bold)
}

영상은 찍지 못했지만, 값이 변경될 때마다 UI가 실시간으로 업데이트되는 것을 확인할 수 있었습니다!

Chapter 5: Tool Calling - AI에게 도구 주기

이제 Tool Calling에 대해서 알아보겠습니다.

문제점

AI는 학습된 지식만 알고 있습니다. 실시간 데이터나 최신 정보는 가져오지 못하죠.

해결책: Tool Calling

AI한테 Swift 함수를 사용할 수 있게 해줍니다! AI가 필요하다고 판단하면 직접 우리가 정의한 함수를 호출할 수 있습니다.

Tool 이름과 설명 정의

final class FindPointsOfInterestTool: Tool {
    // MARK: - Tool 이름과 설명
    let name = "findPointsOfInterest"
    let description = "Finds points of interest for a landmark."
    let landmark: Landmark
    init(landmark: Landmark) {
        self.landmark = landmark
    }
}

Tool 프로토콜 이해하기

Tool이라는 프로토콜이 있습니다.

프로토콜의 정의를 보면, "사이드 이펙트를 수행하거나 런타임에 정보를 가져오기 위해 모델이 호출할 수 있는 것"이라고 되어 있습니다.

필수 요구사항:

  • name: Tool의 고유 ID
  • description: Tool이 무엇을 하는지 설명
  • Arguments: Tool에 전달할 인자 타입
  • call(arguments: Arguments) async throws: 실제 실행 메서드

name의 역할

  • Tool의 고유 ID
  • AI가 이 이름으로 함수를 호출함
  • 예: AI가 "호텔 찾아야지!" → findPointsOfInterest 호출

description의 역할

  • AI한테 이 Tool이 뭐 하는지 설명
  • AI가 이 설명을 보고 "아, 장소 찾을 때 이거 쓰면 되겠네!" 하고 판단

검색 가능한 카테고리 정의

// Tool 밖에 정의 (파일 끝부분에)
@Generable
enum Category: String, CaseIterable {
    case hotel
    case restaurant
}

역할:

  • AI가 선택할 수 있는 장소 타입 정의
  • "호텔을 찾아줘" 또는 "레스토랑 찾아줘"

💡 왜 enum을 쓰나?

장점:

  1. 타입 안전성: AI가 이상한 값 못 넣음
    • .hotel, .restaurant (정해진 것만)
    • ❌ "cafe", "museum" (정의 안 됨, 에러!)
  2. 선택지 제한: AI가 헷갈리지 않음
    • 문자열: AI가 "Hotel", "HOTEL", "호텔" 다 다르게 인식할 수 있음
    • enum: 딱 2개만 선택 가능
  3. @Generable: AI가 이 enum을 이해하고 사용 가능
@Generable
struct Arguments {
	@Guide(description: "This is the type of business to look up for.")
	let pointOfInterest: Category
}

   이렇게 하면
AI가 Arguments를 정의해서 Arguments(pointOfInterest: .hotel)
실제 검색을 수행합니다.

역할:

  • AI한테 힌트: "이 파라미터는 검색할 비즈니스 타입이야!라고 알려줍니다. .hotel 혹은 .restuarant를 넣을지 판단하는데 도움을 줍니다.
  • 이 구조체 또한 AI가 생성하는 것이기 때문에 @Generable을 붙입니다.
    func call(arguments: Arguments) async throws -> String {
        let results = await getSuggestions(category: arguments.pointOfInterest, landmark: landmark.name)
        return """
                There are these \(arguments.pointOfInterest) in \(landmark.name): 
                \(results.joined(separator: ", "))
                """
    }
@Generable
enum Category: String, CaseIterable {
    case hotel
    case restaurant
}
func getSuggestions(category: Category, landmark: String) -> [String] {
    switch category {
    case .hotel: return ["Hotel 1", "Hotel 2", "Hotel 3"]
    case .restaurant: return ["Restaurant 1", "Restaurant 2", "Restaurant 3"]
    }
}

실제로 호출하는 곳에서 이렇게 정의를 합니다.

getSuggestions 이 부분이 실제 서버 호출로 바뀔 수있겠죠
이제 이 Tool을 연결해보겠습니다.

let pointOfInterestTool = FindPointsOfInterestTool(landmark: landmark)

Tool을 생성하고
instructions에 내용을 추가합니다.

init(landmark: Landmark) {
    self.landmark = landmark
    // 기존
    let instructions = """
        Your job is to create an itinerary for the user.
        Each day needs an activity, hotel and restaurant.
        """
    self.session = LanguageModelSession(instructions: instructions)
    // 변경 후
    let pointOfInterestTool = FindPointsOfInterestTool(landmark: landmark)
    let instructions = Instructions {
        "Your job is to create an itinerary for the user."
        "For each day, you must suggest one hotel and one restaurant."
        "Always use the 'findPointsOfInterest' tool to find hotels and restaurant in \(landmark.name)"
    }
    // session 초기화는 다음 단계에서!
}

let pointOfInterestTool = FindPointsOfInterestTool(landmark: landmark)

명령도 추가

"Always use the 'findPointsOfInterest' tool to find hotels and restaurant in \(landmark.name)"

Instructions 없으면:

  • AI: "호텔? 내가 아는 지식으로 적당히 만들어볼게~"
  • → 존재하지 않는 호텔 만들 수 있음 ❌

Instructions 있으면:

  • AI: "호텔 필요하네? Tool 써야지!"
  • findPointsOfInterest 호출
  • → 실제 데이터(지금은 mock) 사용 ✅
// 기존
self.session = LanguageModelSession(instructions: instructions)
// 변경 후
self.session = LanguageModelSession(
    tools: [pointOfInterestTool],
    instructions: instructions
)

데이터 타입에서 추측할 수 있듯이 여러가지 tool을 넣을 수 있습니다.

let hotelTool = FindHotelTool(landmark: landmark)
let restaurantTool = FindRestaurantTool(landmark: landmark)
let weatherTool = GetWeatherTool()
self.session = LanguageModelSession(
    tools: [hotelTool, restaurantTool, weatherTool],
    instructions: instructions
)

다음은 Options에 대해서 알아보겠습니다.

Greedy Sampling 옵션 추가

변경할 부분:

func generateItinerary(dayCount: Int = 3) async {
    do {
        let prompt = Prompt {
            "Generate a \(dayCount)-day itinerary to \(landmark.name)."
            "Give it a fun title and description."
            "Here is an example of the desired format, but don't copy its content:"
            Itinerary.exampleTripToJapan
        }
        // 기존
        let stream = session.streamResponse(to: prompt,
                                            generating: Itinerary.self)
        // 변경 후
        let stream = session.streamResponse(to: prompt,
                                            generating: Itinerary.self,
                                            options: GenerationOptions(sampling: .greedy))
        for try await partialResponse in stream {
            self.itinerary = partialResponse.content
        }
    } catch {
        self.error = error
    }
}

플레이그라운드에서 확인해보니 검색 결과가 Tool의 더미데이터에 영향을 받았습니다.
검색 결과를 서버 요청이나 데이터에 한정지어서 결과를 냈던 것이지요

🔍 GenerationOptions(sampling: .greedy)가 뭐야?

Sampling: AI가 다음 단어를 선택하는 방식

2️⃣ Greedy Sampling:

options: GenerationOptions(sampling: .greedy)

특징:

  • 항상 가장 확률 높은 선택만 함
  • 일관된 결과 ✅
  • 덜 창의적이지만 예측 가능

예시:

  • 1번 실행: "파리 3일 여행"
  • 2번 실행: "파리 3일 여행"
  • 3번 실행: "파리 3일 여행" (거의 같음)

왜 Tool 쓸 때 Greedy가 좋을까요?

Tool 호출은 정확해야 하니까!

SamplingMode는 greedy와 random이 있는데

정확히는 모르겠지만 대략적으로
greedy는 가장 높은 확률의 응답값만 도출하기때문에 항상 동일한 결과를 나타내고
Random은
특징: - 확률이 threshold 이상인 토큰들 중에서 랜덤 선택 - probabilityThreshold: 0.9 → 상위 90% 확률 토큰들만 고려 - seed: 랜덤 시드 (재현성 위해 고정 가능)

이런 느낌이라 아마 더 창의적이겠지만 확률의 값에 의해 더 변동적일 것입니다.

Temperature라는 것도 있는 것 같은데 .. 이건 넘어가겠습니다.

Chapter 6: 성능 최적화

다음은 성능 최적화입니다!

측정 없이는 최적화 없다

최적화를 하기 위해서는 먼저 성능을 측정해야겠죠? Instruments를 사용해서 Profile을 해봅시다.

Instruments에서 Foundation Models를 검색합니다.

측정 결과:

  • 시작 시간: 9.949초
  • 총 소요 시간: 14초
  • 토큰 사용량: 1080개

목표는 첫 응답 속도를 개선하고 더 빠르고 반응성 좋은 AI 기능을 만드는 것입니다.

두 가지 최적화 기법

  1. Pre-warming: 모델을 미리 메모리에 로드
  2. Schema 최적화: 불필요한 정보 제거

1️⃣ Pre-warming

핵심 내용:
이 메서드는 세션에 필요한 리소스를 미리 메모리에 로드하고, 선택적으로 프롬프트의 prefix를 캐싱합니다.

언제 사용하나요?

  • 사용자가 세션과 상호작용할 가능성이 높을 때 (몇 초 내)
  • 예: 사용자가 텍스트 필드에 타이핑을 시작할 때
  • 중요: respond(to:) 호출 최소 1초 전에 실행해야 합니다

View의 .task 또는 onAppear에서 호출하면 좋습니다.

Pre-warming의 두 가지 역할

1️⃣ 리소스 로드

  • AI 모델을 메모리에 미리 로드
  • 필요한 자원들을 준비

2️⃣ Prompt Prefix 캐싱 (선택사항)

  • Prompt의 앞부분을 미리 처리
  • 나중에 같은 prefix 사용 시 더 빠름
.task {
    // 뷰가 나타날 때 실행
    // 비동기 작업 가능
}

타이밍:

1. 화면 열림 (LandmarkDetailView → LandmarkTripView)
   ↓
2. .task 실행
   ↓
3. generator.prewarmModel() 호출 (백그라운드)
   ↓
4. 사용자가 화면 보며 "Generate Itinerary" 버튼 찾음 (1~3초)
   ↓
5. 버튼 클릭
   ↓
6. 이미 준비됨! 즉시 생성 시작 ⚡

사용자 행동 패턴:

화면 열기
  ↓
랜드마크 정보 읽기 (2~3초)
  ↓
버튼 찾기
  ↓
버튼 클릭

Pre-warming을 적용하니까:

  • 시작 시간: 8초로 단축
  • Duration: 11초로 단축

확실히 개선됐습니다!

2️⃣ Schema 최적화

변경할 부분:

func generateItinerary(dayCount: Int = 3) async {
    do {
        let prompt = Prompt {
            "Generate a \(dayCount)-day itinerary to \(landmark.name)."
            "Here is an example of the desired format, but don't copy its content:"
            Itinerary.exampleTripToJapan
        }
        // 기존
        let stream = session.streamResponse(to: prompt,
                                            generating: Itinerary.self)
        // 변경 후
        let stream = session.streamResponse(to: prompt,
                                            generating: Itinerary.self,
                                            includeSchemaInPrompt: false)
        for try await partialResponse in stream {
            self.itinerary = partialResponse.content
        }
    } catch {
        self.error = error
    }
}

includeSchemaInPrompt: false가 뭐야?

Schema란?

  • @Generable 구조체의 정의
  • AI한테 "이런 형태로 만들어줘" 라고 알려주는 정보

📋 Schema 예시

@Generable
struct Itinerary {
    let title: String
    let description: String
    let days: [Day]
}

AI한테 전달되는 Schema 정보:

{
  "type": "object",
  "properties": {
    "title": { "type": "string" },
    "description": { "type": "string" },
    "days": {
      "type": "array",
      "items": { "$ref": "#/definitions/Day" }
    }
  },
  "required": ["title", "description", "days"]
}

언제 Schema를 제거해도 될까요?

✅ Few-shot 예시가 있을 때:

let prompt = Prompt {
    "Generate a 3-day itinerary"
    "Here is an example:"
    Itinerary.exampleTripToJapan  // 이미 형태를 보여줌!
}

AI 입장:

  • Schema 없어도 예시 보고 따라할 수 있음
  • 예시가 더 직관적

왜 Schema를 제거하는걸까요? 더 정확하면 좋을 것 같은데

1️⃣ 토큰 절약

Schema 포함: 1000 토큰
Schema 제외: 500 토큰
→ 500 토큰 절약! (50% 감소)

2️⃣ 처리 속도 향상

  • 토큰 적으면 처리 빠름

  • 특히 첫 토큰까지 시간 단축

    3️⃣ 비용 절감 (API 사용 시)

  • 토큰 수로 과금되는 경우

  • 50% 토큰 절약 = 50% 비용 절감


⚠️ 주의사항

❌ Schema 제거하면 안 되는 경우:

Few-shot 예시 없을 때:

let prompt = Prompt {
    "Generate a 3-day itinerary"
    // 예시 없음!
}
let stream = session.streamResponse(
    to: prompt,
    generating: Itinerary.self,
    includeSchemaInPrompt: false  // ❌ 위험!
)

→ AI가 구조를 모름!

Few-shot 예시 있을 때:

let prompt = Prompt {
    "Generate a 3-day itinerary"
    "Here is an example:"
    Itinerary.exampleTripToJapan  // ✅ 예시 있음
}
let stream = session.streamResponse(
    to: prompt,
    generating: Itinerary.self,
    includeSchemaInPrompt: false  // ✅ 안전!
)

→ AI가 예시 보고 따라함!


📊 성능 비교

Schema 포함Schema 제외
토큰 수~1000~500 ⚡
첫 토큰1.5초0.8초 ⚡
정확도높음높음 (예시 있으면) ✅

최종 결과:

  • 토큰 수: 1080 → 665로 대폭 감소!
  • 성능도 확실히 개선되었습니다

마무리

오늘 Foundation Models Framework를 통해 iOS 앱에 AI를 넣는 방법을 배웠습니다.

배운 내용 정리

  1. Chapter 1: 기본 사용법 - LanguageModelSession과 Instructions
  2. Chapter 2: 구조화된 응답 - @Generable과 @Guide 매크로
  3. Chapter 3: 프롬프팅 기법 - One-shot prompting으로 품질 향상
  4. Chapter 4: 스트리밍 응답 - PartiallyGenerated로 실시간 UI 업데이트
  5. Chapter 5: Tool Calling - AI가 Swift 함수를 호출하게 하기
  6. Chapter 6: 성능 최적화 - Pre-warming과 Schema 최적화

핵심 장점

  • 오프라인 동작: 인터넷 없이도 작동
  • 프라이버시: 모든 처리가 기기 내에서
  • 무료: API 비용 없음
  • 앱 용량 증가 없음: OS 레벨에서 제공

정말 신기하고 강력한 기능이네요! 실제 앱에 적용해보면서 더 많은 것을 배워봐야겠습니다 🚀

profile
iOS 개발자입니다.

0개의 댓글