프롬프트 엔지니어링으로도 해결할 수 없었던 LLM 버그, 20줄의 바이너리 파싱이 해결하다
요약
Gemini를 활용한 비디오 분석 중 발생하는 타임스탬프 드리프트 문제를 프롬프트 엔지니어링이 아닌 바이너리 파싱 코드로 해결한 사례를 공유합니다. 모델이 정확한 지속 시간을 인지하더라도 출력값의 일관성을 보장할 수 없으므로, 검증 가능한 코드 기반의 ground truth가 필요함을 강조합니다.
핵심 포인트
- 프롬프트 강화는 모델의 제약 조건 위반 빈도를 줄일 뿐 근본적인 해결책이 아님
- 모델이 올바른 정보를 가지고 있어도 출력 시 일관성(Consistency) 문제가 발생할 수 있음
- 검증 가능한 데이터(Ground Truth)를 확보하기 위해 바이너리 데이터 직접 파싱 권장
- 신뢰성이 중요한 수치 데이터는 프롬프트보다 코드로 제어해야 함
여기에 처음 왔습니다 (반가워요 👋). 무언가를 공유하고 싶어서요. 저희는 최근 저희 에이전시 내부에서 사용하던 광고 인텔리전스(ad intel) 앱을 공개했습니다. 이 앱/시스템은 Gemini를 통해 비디오 광고를 실행하여 트랜스크립트(transcript), 훅(hook), 그리고 몇 가지 시간대별 순간들을 추출합니다. 아이디어 구상(ideation) 단계에서 특히 꽤 잘 작동합니다. 트랜스크립션(transcription)은 정말 정확합니다.
한 사용자가 2분 22초짜리 영상을 열었는데, 3:37에 특정 순간이 표시된 것을 발견했습니다.
단순한 반올림 오차가 아닙니다. 사용자가 보고 있던 영상의 끝을 50%나 지나쳐 있었습니다.
흥미로운 부분
뻔한 추측은 모델이 영상의 길이가 얼마인지 몰랐을 것이라는 점입니다. 그것은 합리적인 버그일 것이고 수정하기도 쉬울 것입니다.
하지만 동일한 JSON 응답 내에서, Gemini는 duration_seconds를 정확하게 보고했습니다.
저희는 하나의 광고에 대해 동일한 파일, 동일한 프롬프트로 네 번의 동일한 실행을 통해 제대로 측정했습니다:
- 실제 지속 시간: 142.08초
- 모델이 보고한
duration_seconds: 매 실행마다 정확함 - 트랜스크립트 타임스탬프(timestamps): 4번 중 1번에서 영상 끝을 넘어 표류(drifted)하여
t=214에 도달함
단어들은 맞았습니다. 시계는 50%나 길었습니다. 모델은 올바른 지속 시간을 보유하고 있으면서도 어쨌든 불가능한 타임스탬프를 작성하고 있었습니다.
이것은 지식의 문제가 아닙니다. 일관성(consistency)의 문제이며, 이러한 문제는 정중하게 요청한다고 해서 해결되지 않습니다.
프롬프트 강화(Prompt hardening)는 효과가 없었습니다
저희는 여러분이 시도할 법한 것들을 시도했습니다. 프롬프트에 지속 시간을 명시했습니다. 타임스탬프가 그 시간을 초과해서는 안 된다고 말했습니다. 스키마(schema) 뒤에 제약 조건을 다시 명시했습니다. 대문자로 썼습니다.
실패율은 낮아졌습니다. 하지만 0이 되지는 않았습니다. 강화된 실행에서도 142초짜리 영상에서 t=218까지 표류했습니다.
이 지점에서 저는 일반적인 교훈이 있다고 주장하고 싶습니다:
모델이 제약 조건을 위반할 수 있다면, 프롬프팅은 그것이 발생하는 빈도를 줄여줄 뿐입니다. 오직 코드만이 그것을 불가능하게 만듭니다.
대부분의 출력물에 대해서는 이러한 트레이드오프(trade-off)가 괜찮습니다. 왜냐하면 대부분의 출력물은 허위임을 입증하기 어렵기 때문입니다. 아무도
타임스탬프(Timestamps)가 다릅니다. 타임스탬프는 클릭 한 번으로 허위임을 입증할 수 있습니다. 사용자가 2분 22초짜리 영상에서 3분 37초로 탐색(scrub)을 하면, 올바른 부분들을 포함하여 전체 분석의 신뢰성이 상실됩니다. 검증 가능한 단 하나의 잘못된 숫자가 그 주변의 검증 불가능한 모든 것을 오염시킵니다.
따라서 모델이 벗어날 수 없는 기준점(ground truth)이 필요했습니다.
Ground truth: 파일에서 직접 읽어오기
비디오는 모델 근처에 가기도 전에 이미 Buffer 형태로 메모리에 존재합니다. MP4 파일은 mvhd 아톰(atom, movie header)에 자체적인 재생 시간(duration) 정보를 담고 있습니다. API 호출도 필요 없고, 의존성(dependency)도 필요 없으며, 어차피 우리 호스트에는 ffprobe가 설치되어 있지도 않습니다.
4바이트 mvhd 타입 이후의 레이아웃은 다음과 같습니다: 버전(1바이트), 플래그(3바이트), 그리고 버전 0의 경우 — 생성 시간(4바이트), 수정 시간(4바이트), 타임스케일(timescale, 4바이트), 재생 시간(duration, 4바이트). 버전 1의 경우, 타임스탬프는 8바이트이고 재생 시간은 8바이트입니다.
초 단위의 재생 시간은 duration / timescale입니다.
function mp4DurationSeconds(buf: Buffer): number | null {
const limit = buf.length - 24;
for (let i = 0; i < limit; i++) {
...
설명할 가치가 있는 두 가지 의도적인 선택이 있습니다.
박스 트리(box tree)를 순회(walking)하는 대신 아톰을 스캔(scan)합니다. 원칙적으로는 순회하는 것이 더 정확합니다. 하지만 스캔 방식은 단 하나의 숫자만 필요할 뿐인 컨테이너 포맷을 위한 파서(parser)를 구현할 필요 없이, faststart 파일(moov가 앞에 있는 경우)과 moov가 끝에 있는 파일을 모두 처리할 수 있습니다.
잘못된 형식의 아톰(malformed atom)이 발견되어도 에러를 던지는 대신 루프를 계속 진행합니다. mvhd라는 4바이트 시퀀스는 압축된 비디오 데이터 내부에서 우연히 나타날 수 있습니다. 만약 파싱되지 않는 첫 번째 일치 항목에서 중단해 버린다면, 무작위 바이트 시퀀스가 재생 시간 확인 과정을 망가뜨릴 것입니다.
강제(enforcement)를 신뢰하기보다, 강제하기
실제적인 상한선(ceiling)이 생기면서, 모든 타임스탬프는 클라이언트에 도달하기 전 서버 측에서 검증됩니다. 모델의 출력물은 신뢰할 수 없는 입력값과 같으므로, 모든 신뢰할 수 없는 입력을 검증한다는 것과 동일한 원칙을 따릅니다.
흥미로운 결정 사항은 잘못된 값을 어떻게 찾아내느냐가 아니라, 잘못된 값을 발견했을 때 어떻게 처리하느냐에 관한 것이었습니다.
단어는 유지하고, 시간은 버리세요. 전사(Transcription) 내용은 정확합니다. 오직 타이밍(Timing)만이 신뢰할 수 없을 뿐입니다. 따라서 불가능한 타임스탬프(Timestamp)를 삭제하면 텍스트는 살아남습니다. 우리의 전사 컴포넌트는 이미 t 값이 누락된 경우를 위해 빈 거터(Gutter)를 렌더링하므로, 타임스탬프가 제거되면 잘못된 숫자가 표시되는 대신 깔끔한 스크립트로 변환됩니다.
잘못된 것 하나만 처리하지 말고, 쌍의 양 끝을 모두 null로 만드세요. 우리는 시작 시간과 종료 시간을 통해 "호기심 루프 (curiosity loops)"를 추적합니다. 만약 종료 시간이 불가능하다면, 그 값 하나만 null로 만드는 것은 빈 종료 시간 옆에 살아남은 시작 시간을 남기게 되며, 이는 우리가 가지고 있지 않은 정밀도를 가진 것처럼 렌더링됩니다. 둘 다 제거해야 합니다.
수정 사항 내부의 버그
이 문제는 오후 시간을 통째로 잡아먹었으며, AI와는 아무런 상관이 없는 순수 자바스크립트 (JavaScript) 문제입니다.
첫 번째 버전은 Number(x)를 사용하여 타임스탬프를 강제 변환(Coerce)했습니다. 무해해 보입니다:
// 잘못된 방식
const t = Number(chunk.t);
Number(null)은 0입니다.
"이게 언제 일어났는지 모르겠습니다"라는 의미로 정직하게 null을 반환하는 모델은 그 값이 0으로 변환됩니다. 이는 0:00으로 렌더링되며, 결과적으로 루프가 시작되기도 전에 닫히는 것으로 읽히게 되어, 우리를 보호하기 위해 만들어진 검증(Validation) 로직을 트리거하게 됩니다. 정직한 null이 단 한 번의 암시적 형변환 (Implicit coercion)을 통해 확신에 찬 거짓말로 변해버린 것입니다.
const asSeconds = (x: any): number | null => {
if (x === null || x === undefined || (typeof x === 'string' && !x.trim())) return null;
const n = Number(x);
...
다행히 사용자가 아닌 유닛 테스트 (Unit test)에 의해 발견되었습니다. 만약 LLM 출력을 정제 (Sanitising)하고 있다면, null과 0은 완전히 다른 의미를 가지며 자바스크립트는 기꺼이 이 둘을 하나로 합쳐버릴 것입니다.
모든 잘못된 타임스탬프가 같은 의미를 갖는 것은 아니다
마지막 부분은 실제 실패 사례를 살펴보지 않았다면 저 또한 틀렸을 부분입니다.
관찰된 모드는 표류하는 꼬리 (drifting tail) 형태입니다. 모델은 전체 과정 동안 정확하게 전사하지만, 진행될수록 시계가 늘어납니다. 따라서 이전의 모든 타임스탬프는 괜찮지만, 뒤쪽의 청크 (Chunks)들은 불가능한 값이 됩니다. 그 초기 값들은 좋은 데이터이며 유지할 가치가 있습니다.
본문 전체에 흩어져 있는 잘못된 타임스탬프(timestamps)는 완전히 다른 신호입니다. 이는 타이밍 패스(timing pass) 자체가 신뢰할 수 없음을 의미하며, 정확한 것과 틀린 것이 섞여 있는 상태를 마치 정밀한 것처럼 제시하는 것은 아무것도 제시하지 않는 것보다 더 나쁩니다.
따라서: 마지막 청크(chunk)에서 끝나는 연속적인 잘못된 타임스탬프의 흐름은 테일 드리프트(tail drift)로 간주하여 잘라냅니다. 그 외의 경우에는 모든 타이밍 정보를 제거합니다.
저의 첫 번째 직관은 "만약 25% 이상이 불가능한 값이라면, 모든 것을 버려라"와 같은 퍼센트 임계값(percentage threshold)을 설정하는 것이었습니다. 그것은 틀렸으며, 그 이유는 반드시 내재화할 가치가 있습니다:
동일한 실패라도 모델이 우연히 생성한 청크의 개수에 따라 다르게 판단될 것입니다. 5개의 청크가 밀리는 것은 26개 청크로 구성된 전사(transcript)에서는 19%이지만, 18개 청크로 구성된 전사에서는 28%가 됩니다. 동일한 실패임에도 불구하고, 당신이 통제할 수 없는 요소에 의해 정반대의 판결이 내려집니다. 비율(proportion)이 아니라 실패의 형태(shape)에 임계값을 설정해야 합니다.
이 경험을 통해 얻은 교훈
모델은 하나의 사실을 보유하면서도 동시에 그 사실을 위반할 수 있습니다. 지속 시간(duration)을 올바르게 보고한다는 사실은 모델이 그 값을 준수할 것인지에 대해 아무것도 알려주지 않았습니다.
프롬프팅(Prompting)은 경향성을 설정하지만, 코드(Code)는 보장을 설정합니다. 사용자가 클릭 한 번으로 확인할 수 있는 모든 것에 대해서는 보장이 필요합니다.
이미 가지고 있는 아티팩트(artefact)에서 그라운드 트루스(ground truth)를 찾으세요. 버퍼(buffer)는 내내 메모리에 있었습니다. 우리는 단 20줄의 코드 떨어진 바이너리 헤더(binary header)에 놓여 있는 숫자를 언어 모델(language model)에게 묻고 있었던 것입니다.
어느 부분을 버렸는지에 대해 정직하다면, 부분적인 출력이 보통 아무 출력도 없는 것보다 낫습니다. 단어들은 항상 옳았습니다. 오직 시계(clock)만이 틀렸을 뿐입니다. 잘못된 타임스탬프를 보내거나 아예 아무것도 보내지 않는 것보다, 타임스탬프가 없는 정확한 전사를 보내는 것이 더 낫습니다.
저는 브랜드가 실행 중인 라이브 광고를 가져와 무엇이 효과적인지 해독하는 SOCIALFUEL을 만들고 있습니다. 이 버그가 존재했던 곳은 그 뒤의 비디오 파이프라인(video pipeline)이었습니다. 만약 LLM을 사용하여 비디오에 대한 시간 기반 분석(timed analysis)을 수행하고 있다면, 타임스탬프를 신뢰하기 전에 컨테이너(container)와 대조하여 확인해 보시기 바랍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기