블로그

2026년 8월 15일 · 8분 읽기

Foundation Models 에러 처리: guardrailViolation 폴백 설계

Apple Foundation Models가 던지는 오류를 종류별로 나누고 폴백 경로를 만든 기록입니다. 평범한 한국어 문장이 guardrailViolation으로 거부된 일에서 시작합니다.

  • Apple Foundation Models
  • 에러 처리
  • guardrailViolation
  • 온디바이스 LLM

2026년 8월 23일 업데이트

Apple의 온디바이스 모델은 안전 필터에 걸린 입력을 거부합니다. 그 필터가 평범한 한국어 문장에서도 걸렸습니다.

거부되면 요약이 없습니다. 그때 화면이 비어 있으면 사용자는 앱이 고장 났다고 생각합니다. 사용자가 보낸 글자는 그대로 있는데도 그렇습니다.

실패했을 때 무엇을 대신 보여줄지, 그 실패를 어떻게 기록해서 다음에 고칠지. 이번 편은 이 둘입니다. 프로덕션에서 온디바이스 모델을 쓰면 실패 경로가 성공 경로보다 코드가 많습니다.

한눈에 보기

문제 Apple의 온디바이스 모델은 안전 필터에 걸린 입력을 거부합니다.
결정 프롬프트를 고쳤을 때 나아졌는지 알려면 이 숫자를 봐야 합니다.
결과 요약이 실패해도 사용자가 보낸 글자는 남습니다.
제약 두 검사가 없으면 한국어를 영어로 옮겨 달라고 했는데 한국어가 돌아온 것을 모릅니다.

오류를 문자열로 가르면 조용히 깨집니다

처음에는 오류 설명을 문자열로 훑었습니다. guardrail, safety, refus 같은 단어를 찾았습니다.

Apple이 안전 필터 거부에 주는 문구는 이것입니다.

"May contain sensitive content"

찾던 단어가 하나도 없습니다. 안전 필터 거부가 분류되지 않고 "알 수 없는 오류"로 떨어졌습니다.

그 결과가 화면에 이렇게 나왔습니다. "처리 중 문제가 생겼어요 · 다시 시도." 사용자가 눌렀습니다. 같은 입력이니 또 거부됐습니다. 누를 때마다 모델을 처음부터 돌렸고, 실기기가 뜨거워졌습니다.

타입으로 가릅니다

LanguageModelSession.GenerationError의 case를 직접 봅니다.

// FoundationProfileModelExecutor.swift:126-136
switch generation {
case .guardrailViolation: return .rejected("guardrailViolation")
case .refusal: return .rejected("refusal")
case .rateLimited, .concurrentRequests: return .transient("rateLimited")
case .assetsUnavailable: return .unavailable
case .exceededContextWindowSize: return .permanent("exceededContextWindowSize")
case .unsupportedGuide: return .permanent("unsupportedGuide")
case .unsupportedLanguageOrLocale: return .permanent("unsupportedLanguageOrLocale")
case .decodingFailure: return .permanent("decodingFailure")
@unknown default: return .permanent("unknownGenerationError")
}

Apple이 문구를 바꿔도 case는 그대로입니다. @unknown default가 있으니 새 case가 추가돼도 컴파일이 깨지지 않습니다.

여덟 가지를 네 가지로 접습니다.

분류 재시도 버튼
rejected 이 입력은 거부됐습니다 보여주지 않습니다
transient 잠시 후 하면 됩니다 보여줍니다
unavailable 지금 이 기기에서 못 씁니다 보여주지 않습니다
permanent 다시 해도 안 됩니다 보여주지 않습니다

이 네 갈래가 재시도 정책의 근거입니다. 넷을 하나로 뭉개면 모든 실패에 재시도 버튼이 붙습니다.

실려 나가는 문자열은 기계 토큰입니다

rejected("guardrailViolation")의 문자열은 사용자에게 보여줄 문구가 아닙니다. 상위 계층이 사유로 분기할 수 있게 하는 안정적인 이름입니다.

원문이 섞인 오류 설명을 그대로 실어 보내면 그것이 영수증에 남습니다. 사용자가 쓴 글이 로그와 저장소로 새어 나갑니다.

// FoundationProfileModelExecutor.swift:104-109
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)
}

여는 괄호나 콜론에서 자릅니다. Foundation Models의 오류 설명에는 프롬프트가 딸려 오기 때문입니다. 진단에 필요한 것은 어떤 실패인가이고, 사용자가 무엇을 썼는가가 아닙니다.

조각의 출처를 세 가지로 저장합니다

모델이 실패한 조각을 결과에서 빼면 안 됩니다. 일곱 조각 중 하나가 실패했는데 여섯 개만 저장하면, 사용자는 문서가 여섯 조각이었다고 믿습니다.

그래서 조각마다 어떻게 만들어졌는지 저장합니다.

// SummaryEngine.swift:11-18
public enum SummaryOrigin: String, Codable, Hashable, Sendable {
  /// 모델이 답했다.
  case model
  /// 모델이 세 번 다 쓸 만한 답을 내지 못해, 원문에서 문장을 골라 세웠다.
  case extracted
  /// 어느 방법으로도 만들지 못했다.
  case none
}

주석에 이 열거형이 생긴 이유가 적혀 있습니다. 실패한 단위가 결과 배열에서 그냥 빠졌고, 문서는 남은 하나로 완료가 됐습니다. 소비자에게는 일곱 장 문서가 한 조각으로 보였고 무엇을 잃었는지 알 방법이 없었습니다.

요약하지 못한 것을 요약했다고 말하는 셈이었습니다.

출처 화면에 보이는 것
model 요약
extracted 원문에서 뽑은 문장
none 이 부분은 정리하지 못했다는 표시

세 번째가 있어야 화면이 정직해집니다.

성공한 조각에도 거부 이력을 남깁니다

SummaryPiecerejections가 있습니다. 이 조각을 만들며 되돌려보낸 답들의 이유입니다.

// SummaryEngine.swift:26-31
/// 이 조각을 만들며 되돌려보낸 답들의 이유. 순서는 시도 순서다.
///
/// 진단을 위해 남긴다. `origin == .model`인데 이 목록이 비어 있지 않다면 첫 답이
/// 거부됐고 다음 시도가 통과했다는 뜻이다. 프롬프트가 몇 번째에 먹히는지
/// 아는 유일한 방법이고, 그것 없이는 프롬프트를 고칠 근거가 없다.
public let rejections: [String]

성공했으니 기록할 게 없다고 생각하기 쉽습니다. 하지만 첫 시도에 성공한 것과 세 번째에 성공한 것은 다릅니다.

프롬프트를 고쳤을 때 나아졌는지 알려면 이 숫자를 봐야 합니다. 최종 결과만 보면 둘 다 성공입니다.

거부 사유는 시도 순서대로 쌓입니다.

// SummaryEngine.swift:460-468
case .reject(let reason):
  rejections.append(reason.rawValue)   // 몇 번째 시도에서 왜 거부됐는지
  modelDraft = nil
}
} catch is CancellationError {
  throw CancellationError()
} catch {
  rejections.append(SummaryJudgment.Reason.modelFailed.rawValue)
  modelDraft = nil
}

취소는 다르게 다룹니다. 사용자가 화면을 닫은 것은 실패가 아니므로 기록하지 않고 그대로 올려보냅니다.

폴백 계단은 네 칸입니다

조각 하나를 만드는 흐름이 이렇습니다.

순서 무엇을 하는가 실패하면
1 모델에 요청합니다 사유를 기록하고 2로
2 판정을 통과하는지 봅니다. 최대 세 번 세 번 다 실패하면 3으로
3 원문에서 문장을 뽑습니다 뽑을 문장이 없으면 4로
4 빈 조각을 자리에 둡니다

두 번째 칸에서 그냥 다시 부르지 않습니다. 앞선 거부 사유를 다음 지시문에 실어 보냅니다. 같은 요청을 세 번 하면 세 번 같은 답이 옵니다.

세 번째 칸이 발췌입니다.

// SummaryEngine.swift:505-527
if let extracted = ExtractiveSummary.summarize(text: chunk) {
  let fields: [String]
  do {
    fields = try await localizeFields(...)
  } catch let error as ... {
    // 모델 초안과 달리 발췌는 원문에서 고른 값이므로 원문 언어 그대로 보존해도 안전하다.
    rejections.append(error.reason.rawValue)
    fields = [extracted.headline] + extracted.points
  }
  return SummaryPiece(index: index, ..., origin: .extracted, rejections: rejections)
}

발췌는 모델을 부르지 않습니다. 원문에서 문장을 골라 세웁니다. 요약은 아니지만 사용자가 무엇을 보냈는지는 보입니다.

발췌가 아무것도 못 돌려주는 조건이 있습니다

발췌는 원문을 그대로 옮기는 것이 아닙니다. 문장을 고르고 자릅니다. 그래서 자기 압축률을 보장하지 못하면 아무것도 돌려주지 않습니다.

무엇을 막는가
압축 상한 0.4 결과가 원문의 40%를 넘으면 요약이 아니라고 보고 버립니다
요점 길이 상한 120자 이 위로는 문장이 아니라 문단으로 읽힙니다
헤드라인 상한 60자 목록에서 한 줄로 읽혀야 합니다
문장 최소 8자 머리글과 쪽번호가 여기서 걸립니다

문장을 고르는 점수도 정해져 있습니다. 숫자가 있으면 개수만큼(최대 6점), %퍼센트가 있으면 2점, ··이 있으면 1점, 길이가 20자에서 120자 사이면 2점, 앞의 세 문장이면 1점입니다. 수치가 들어간 문장을 위로 올리는 배점입니다.

제목은 점수로 뽑지 않습니다. 문서 첫 줄이 8자에서 60자 사이면 저자가 붙인 제목으로 씁니다. 점수만으로 제목을 고르면 표의 한 행이나 KPI 하나가 기록 전체를 대표하게 됩니다.

같은 문장이 반복되는 원문은 흔합니다. 쪽마다 반복되는 머리글이나 스캔 오류입니다. 중복은 고르기 전에 접습니다. 접지 않으면 뽑아낸 결과가 그 반복을 물려받습니다.

마지막 칸이 빈 조각입니다.

// SummaryEngine.swift:530-531
return SummaryPiece(
  index: index, headline: "", points: [], origin: .none, rejections: rejections)

빈 조각에도 index는 남습니다. 순서가 있어야 화면이 "세 번째 부분은 정리하지 못했습니다"라고 말할 수 있습니다.

음성은 거부된 구간을 시간으로 말합니다

음성 전사에는 칸이 하나 더 있습니다. 녹음을 조각으로 나눠 요약하는데 한 조각만 안전 필터에 걸리는 경우입니다.

그 조각을 excludedBySafetyPolicy로 표시하고 나머지 조각의 요약은 그대로 완성합니다. 결과 상태가 ready가 아니라 readyWithExclusions가 되고, 화면은 몇 분 몇 초부터 몇 분 몇 초까지가 요약에서 빠졌는지 말합니다.

거부된 조각의 자리를 지우지 않고 시각으로 남기는 것이 이 경로의 전부입니다. 사용자는 자기 녹음의 어느 부분이 빠졌는지 알고, 필요하면 그 구간을 직접 듣습니다.

발췌한 문장은 번역하지 않습니다

번역 단계에서 판단이 하나 갈립니다.

모델이 만든 초안은 번역할 수 있습니다. 모델이 원문 언어로 답했는데 사용자 설정이 영어면, 영어로 옮겨야 합니다.

발췌는 다릅니다. 원문에서 그대로 고른 문장입니다. 번역이 실패하면 원문 언어 그대로 남겨도 안전합니다. 사용자가 보낸 글자와 같기 때문입니다.

주석이 이 구분을 적어 뒀습니다.

모델 초안과 달리 발췌는 원문에서 고른 값이므로 원문 언어 그대로 보존해도 안전하다.

모델 초안은 번역이 실패하면 검사를 한 번 더 거칩니다. 모델이 만든 문장이 원문에 근거가 있는지 확인하는 게이트입니다. 발췌는 그 게이트가 필요 없습니다.

번역기가 준 값도 확인합니다

앱이 준 번역기의 결과를 그대로 믿지 않습니다.

문장 다섯 개를 넣었는데 넷이 돌아오면 순서가 어긋납니다. 그리고 번역된 문장이 실제로 목표 언어인지 확인합니다.

검사 실패하면
개수가 같은가 번역 결과를 버립니다
목표 언어인가 원문을 씁니다

두 검사가 없으면 한국어를 영어로 옮겨 달라고 했는데 한국어가 돌아온 것을 모릅니다.

실패의 종류를 아홉 가지로 올려보냅니다

앱의 네 갈래가 코어에서 아홉 갈래가 됩니다.

// ProfileContracts.swift:387-397
public enum ProfileFailure: Codable, Hashable, Sendable {
  case invalidInput(String)                       // 입력이 비었거나 규격 밖
  case contextWouldOverflow(estimatedTokens: Int) // 창을 넘길 것으로 추정
  case malformedModelOutput(String)               // 모델이 규격을 벗어남
  case modelUnavailable                           // 모델을 쓸 수 없는 상태
  case modelFailed(String)                        // 호출 자체가 실패
  case modelCallBudgetExceeded(limit: Int)        // 요청당 호출 상한 초과
  case unsupportedProfile(ProfileIdentifier)      // 등록되지 않은 프로필
  case missingCheckpoint                          // 이어갈 기록이 없음
  case executionStoreUnavailable                  // 저장소를 열 수 없음
}

앞의 여섯은 모델과 입력의 문제입니다. 뒤의 셋은 앱이 준 어댑터의 문제입니다.

이 구분이 화면 문구를 결정합니다. modelUnavailable이면 설정을 안내하고, modelCallBudgetExceeded면 문서가 너무 길다고 말하고, executionStoreUnavailable이면 저장 공간을 확인하라고 합니다.

app/AGENTS.md에 이 규칙이 한 줄로 적혀 있습니다.

모든 모델 단계에 폴백이 있어야 하고, 폴백은 언제나 "기록으로 남긴다"다.

요약이 실패해도 사용자가 보낸 글자는 남습니다. 원문 보관은 파생 작업의 성공과 무관합니다.