백엔드

유효성 검사기를 재사용하면 파이프라인에서 실패의 의미가 뒤집힙니다

동일한 실패 시 닫힘 검사는 데이터 생성 시에는 안전한 건너뛰기이며, 감사 시에는 파괴적인 삭제입니다. 이를 안전하게 이동하는 방법은 다음과 같습니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

몇 달 동안 프로덕션에서 실행된 검증기는 재사용하기에 가장 매력적인 코드입니다. 테스트를 거쳤고, 미세 조정되었으며, 이미 데이터의 형태를 알고 있습니다. 그래서 검증기가 존재하기 전에 작성된 레코드를 감사해야 할 때, 이전 행에 이 검증기를 적용하는 것은 한 줄짜리 작업처럼 보입니다. 동일한 함수, 새로운 입력 집합. 그 본능은 논리적으로는 맞지만 결과적으로는 틀렸습니다. "유효하지 않음"을 반환하는 확인은 생성 경로에서는 "이것을 쓰지 마시오"를 의미하고 감사 경로에서는 "이미 있는 것을 삭제하시오"를 의미하는데, 코드는 그 차이를 구별할 수 없습니다. 이 게시물은 이로 인해 발생하는 특정 실패와 이를 해결하는 세 갈래 판정에 관한 것입니다.

공유 검사기에 숨어있는 비대칭성

번역된 용어를 생성하고 저장하기 전에 각 용어를 검증하는 파이프라인을 생각해 봅시다. 검사기는 완전 일치 바로 가기, 저렴한 임베딩 거리 테스트, 그리고 모호한 중간 대역에 대해서는 예 또는 아니요로 답하는 언어 모델의 세 단계를 실행합니다. 어느 단계에서든 실패하는 모든 것은 그냥 기록되지 않습니다. 호출자는 다음으로 넘어가고 내일 다시 시도합니다.

이제 해당 검사기 내부의 오류 처리를 살펴보겠습니다.

for _, r := range gray {
    ok, err := e.verifyGray(ctx, r.ko, r.backKo)
    if err != nil {
        r.verdict = DropBacktranslate  // treat an API error as a failed check
        continue
    }
    if ok {
        r.verdict = VerdictPass
    } else {
        r.verdict = DropBacktranslate
    }
}

생성 경로에서는 이것이 올바르고 심지어 우아하기까지 합니다. 타임아웃, 속도 제한, 잘못된 형식의 응답 등 이 모든 것은 "확인하지 못했으므로 쓰지 않겠습니다"로 귀결됩니다. 장애 시 닫힘(Fail-closed) 방식입니다. 거짓 음성(false negative)의 비용은 건너뛴 행 하나와 다음 날의 재시도 한 번입니다.

동일한 함수를 기존에 확인된 레코드를 대상으로 실행하면 비용이 역전됩니다. 이제 단 한 번의 API 타임아웃으로 사람이 승인한 올바른 행이 삭제 대상으로 표시됩니다. 유효성 검사기는 변경되지 않았습니다. 호출자가 쓰지 않기로 한 결정을 파괴하기로 한 결정으로 바꾸었기 때문에 폭발 반경(blast radius)은 변경되었습니다.

함정은 이러한 비대칭이 호출 위치(call site)에서는 보이지 않는다는 것입니다. 감사 코드는 생성 코드만큼이나 문제없어 보입니다.

verdicts, err := engine.VerifyPairs(ctx, pairs)
for _, v := range verdicts {
    if !v.Pass {
        rejectRecord(ctx, v.ID)  // looks symmetric, is not
    }
}

반환값이 알려줄 수 없는 것

더 깊은 문제는 불리언(boolean) 통과 플래그가 두 개의 다른 세계를 하나로 압축한다는 것입니다. "모델이 이 쌍을 검토한 결과 일치하지 않는다고 판단했습니다"와 "답을 얻지 못했습니다"는 모두 Pass: false로 귀결됩니다. 생성 경로에서는 두 결과 모두 동일한 안전 조치로 이어지므로 이러한 압축은 무해합니다. 감사 경로에서는 이 둘을 필사적으로 분리해야 하지만, 타입 시그니처가 이를 허용하지 않습니다.

해결책은 유효성 검사기를 더 똑똑하게 만드는 것이 아닙니다. 유효성 검사기에게 판정을 요청하는 것을 멈추고 증거를 요청하기 시작하는 것입니다. 대부분의 유효성 검사기는 이미 불리언 값 이상의 것을 반환합니다. 예를 들어 거리, 신뢰도, 중간 산출물 등이 있습니다. 대신 이러한 값들을 통해 파괴적인 결정을 라우팅하세요.

우리의 경우 반환 타입은 플래그와 함께 거리를 포함했습니다.

type PairVerdict struct {
    Pass   bool
    BackKo string   // the round-trip translation, empty if we never got one
    Dist   float64  // embedding distance, 0 when the shortcut matched
}

그 거리는 판단되는 것이 아니라 측정되는 것입니다. API 오류는 큰 거리를 만들어낼 수 없는데, 실패한 호출은 아예 거리를 생성하지 않기 때문입니다. 따라서 거리에 대한 임계값은 데이터에 대한 주장인 반면, 불리언(boolean) 값은 프로세스에 대한 주장입니다.

3가지 방식의 판단

감사에는 두 가지가 아닌 세 가지 결과가 필요하며, 세 번째 결과가 핵심입니다.

  • 삭제: 불일치에 대한 명백한 증거. 우리 파이프라인에서는 상위 밴드보다 높은 거리 값, 즉 합법적인 동의어가 존재하는 영역을 훨씬 벗어난 경우입니다.
  • 유지: 검증자가 레코드를 긍정적으로 통과시킨 경우. 언어 모델이 모호한 쌍을 보고 승인한 경우를 포함합니다.
  • 보류: 그 외 모든 경우. 검사가 완료되지 않았거나, 라운드 트립 결과가 비어 있거나, 모델이 모호한 밴드 내에서 '아니오'라고 답한 경우입니다. 결과를 기록하고, 데이터는 그대로 두며, 사람이 처리할 큐에 넣습니다.

보류 버킷은 재사용을 안전하게 만드는 요소입니다. 이는 "확실하지 않음"을 파괴적인 조치에서 작업 항목으로 전환합니다. 또한 감사를 정리 작업에서 분류 작업으로 전환하는데, 감사는 원래부터 그랬어야 했습니다.

이 설계에는 미묘한 순서 지정 버그가 잠재해 있으며, 우리가 겪었던 문제이므로 명확히 설명할 가치가 있습니다. 우리의 distance 필드는 두 가지 완전히 다른 상황에서 0이 됩니다. 하나는 정확히 일치하는 바로 가기가 실행될 때(완벽한 통과)이고, 다른 하나는 라운드 트립 결과가 비어 있을 때(평가 완전 실패)입니다. distance에만 의존하여 작성된 2분기 규칙은 빈 라운드 트립을 통과로 분류하고, 통과 레코드를 작성하여, 멱등성 필터를 통해 해당 행이 향후 모든 감사에서 제외되도록 만듭니다. 그러면 그 행은 단 한 번도 검증되지 않은 채 영원히 검증된 것으로 표시될 것입니다. distance를 건드리기 전에 라운드 트립 아티팩트가 비어 있는지 확인하는 한 줄의 코드로 이 허점을 막을 수 있습니다.

하나의 유효성 검사기, 두 개의 호출 사이트, 세 가지 결과 공유 validator generate audit 실패: 행 건너뛰기 비용 = 한 번의 재시도 실패: 행 삭제 비용 = 손실된 데이터 delete keep hold // same code, inverted blast radius

드라이런에서 얻은 수치

저희는 3자 규칙으로 감사를 구축하고, 어떤 것도 건드리기 전에 300개의 레거시 레코드에 대해 드라이런 모드로 실행했습니다. 거리 히스토그램이 흥미로운 부분이었습니다. 모든 레코드가 상한 임계값 미만이었습니다. 삭제 대상이 된 것은 아무것도 없었습니다.

중간 대역은 더 많은 것을 시사했습니다. "피부 필러 주사"를 의미하는 한 용어는 4개 언어로 번역되었는데, 모두 "히알루론산 주사"로 왕복 변환되어 거리 0.36을 기록했습니다. 이것들은 올바른 번역입니다. 왕복 변환은 시술 대신 물질을 설명했는데, 이는 언어 모델이 "동일하지 않음"으로 표시하는 바로 그 종류의 의미 변화입니다. 2자 규칙 하에서는 올바르다는 이유로 4개 언어에 걸쳐 해당 4개의 행이 삭제되었을 것입니다.

이 단 하나의 관찰만으로 전체 설계가 정당화되었습니다. 또한 예상치 못한 두 번째 발견도 있었습니다. 바로 삭제 0건이라는 결과가 데이터가 깨끗하다는 증거는 아니라는 점입니다. 이는 부분적으로 모집단 필터의 인위적인 결과였습니다. 감사는 상위 항목이 확인된 상태인 레코드만 살펴보았는데, 저희의 가장 유명한 불일치 예시, 즉 번역이 의사 약력의 한 단락으로 변질된 용어는 미확인 상위 항목 아래에 있었습니다. 감사는 그것을 찾을 수 없었을 것입니다. 검사기는 전달받은 행만큼만 우수할 수 있습니다.

파괴적인 경로에 검사기를 적용하기 위한 규칙

  • 각 경로에서 실패 시 어떤 비용이 드는지 자문해 보세요. 한 경로는 재시도를 비용으로 치르고 다른 경로는 삭제된 데이터를 비용으로 치른다면, 이는 검사기를 재사용하는 것이 아니라 새로운 작업을 부여하는 것입니다.
  • 판정이 아닌 측정을 통해 파괴 경로를 정하세요. 거리, 개수, 차이(diff)는 인프라 장애로 인해 조작될 수 없습니다. 불리언(Boolean)은 그럴 수 있습니다.
  • 삭제를 추가하기 전에 '보류' 버킷을 추가하세요. 감사에 세 번째 결과가 없다면 불확실성을 손상으로 표현하게 될 것입니다.
  • 점수만이 아니라 결과물을 확인하세요. 비어 있는 중간 결과는 완벽한 결과와 동일한 숫자 값을 갖는 경우가 많습니다. 해당 사례를 명시적으로 테스트하세요.
  • 멱등성 함정을 주의하세요. 재처리를 피하기 위해 "확인됨"이라고 기록하면, 잘못 분류된 통과는 영구적입니다. 해당 행은 검증됨으로 표시되고 다시는 검토되지 않을 것입니다.
  • 먼저 실제 데이터 일부로 드라이런(dry-run)을 실행하고 분포를 살펴보세요. 삭제 결과가 0인 것도 정보입니다. 데이터가 깨끗하거나, 임계값이 잘못되었거나, 모집단 필터가 찾으려던 바로 그 행들을 제외하고 있다는 뜻입니다.
  • 덮어쓰기 전에 원본을 보존하세요. 우리의 경우 로그 테이블의 기존 미사용 열에 저장했습니다. 롤백을 위한 보험은 보기보다 비용이 적게 드는 경우가 많습니다.

일반적인 형태

이것은 사실 유효성 검사기에 대한 이야기가 아닙니다. 함수의 안전 속성은 함수에 내재된 것이 아니라는 사실에 관한 것입니다. 그것은 함수와 그 호출자의 쌍 안에 존재합니다. Fail-closed는 "이 검사를 완료할 수 없을 때 어떤 일이 일어나는가"에 대한 속성이며, 어떤 일이 일어나는지는 전적으로 return문 반대편에 있는 코드에 의해 결정됩니다.

다음에 성숙한 유효성 검사 로직을 새로운 데이터 세트에 적용할 때, 던져야 할 질문은 그 로직이 여전히 적용되는지 여부가 아닙니다. 그것은 "아니오"의 의미가 여전히 적용되는지 여부입니다. 만약 "아니오"라고 말하는 것이 이전에는 "대기"를 의미했고 지금은 "파괴"를 의미한다면, 당신에게는 세 번째 단어가 필요합니다.

관련 글