48시간 현장 기록: 라이브 호출은 실패했지만 리플레이 카세트는 계속 통과했다
요약
LLM 기반 함수 테스트의 신뢰성 문제를 해결하기 위해 기록 및 리플레이(record-and-replay) 하니스를 구축한 경험을 공유합니다. 이 시스템은 라이브 호출 의존성을 제거하여 테스트 안정성을 높였으나, 결정론적 디버깅 도구가 오히려 새로운 종류의 버그를 발견하게 하는 함정이 될 수 있음을 보여줍니다.
핵심 포인트
- LLM 함수 테스트 시 기록/리플레이 하니스를 구축하여 라이브 호출 의존성에서 벗어나야 합니다.
- 캐시 키 설계 시, 시간(timestamp)이나 요청 ID 등 변동성이 큰 필드는 제외해야 합니다.
- 재현 불가능한 모든 필드는 해싱 전에 정규화하거나 제거하는 '정규화' 과정이 필수적입니다.
- 결정론적 디버깅 도구는 유용하지만, 그 자체로 새로운 버그를 생성할 수 있으므로 주의가 필요합니다.
내 테스트 스위트가 11시간 동안 녹색이었지만, 같은 코드가 프로덕션 환경에서는 실패했습니다. 이 모순이 바로 이야기의 전부입니다. 저는 LLM 기반 함수를 위해 기록 및 리플레이(record-and-replay) 하니스를 구축하여 제 테스트가 라이브 호출에 의존하는 것을 멈추게 했습니다. 제가 예상하지 못했던 것은, 그 하니스가 저장소에서 가장 설득력 있는 거짓말쟁이가 될 것이라는 점이었습니다. 여러분이 찾고 있는 버그가 생성한 fixture로 시스템을 어떻게 디버깅할 수 있을까요?
이 글은 환경(environment)을 보고 이해되는 실패에 대한 저의 지속적인 현장 기록 시리즈의 세 번째 항목입니다. 이전 글에서는 종료 코드, 백분위수(percentiles), 캐시 무효화(cache invalidation)를 다루었습니다. 이번 글은 디버깅 도구로서의 결정론(determinism)과, 그것을 너무 일찍 기록할 때 결정론이 여러분을 배신할 수 있는 특정한 방식에 관한 것입니다.
제가 실제로 고치려고 했던 것
문제는 사소했습니다. 모든 푸시(push)가 모델을 호출하고 구조화된 출력(structured output)에 대해 무언가를 단언하는 몇 가지 테스트를 실행했습니다. 이 테스트들은 느리고, 가끔 불안정했으며, 비행기에서 실행할 수 없을 정도였습니다. 저는 HTTP 기록 라이브러리의 정신을 담아 카세트 계층(cassette layer)을 원했고, 여기서 첫 번째 실행은 요청과 응답 쌍을 캡처하고 나중 실행은 디스크에서 읽어오도록 했습니다.
공개: 이 글은 MonkeyCode의 제품 홍보의 일환으로 작성되었습니다. 저는 무료 모델 액세스를 사용하여 스펜드 미터(spend meter)를 보지 않고 카세트를 기록했고, 무료 서버 옵션을 사용하여 노트북이 아닌 다른 곳에서 리플레이 스위트(replay suite)를 실행했습니다.
제품이 이 이야기에서 수행한 역할은 전체가 기록과 실행이었습니다. 아래의 모든 내용은 평범한 Python이며, 그 어떤 것도 어떤 제공업체(provider)가 호출에 응답했는지에 의존하지 않습니다.
시간 0~8: 레코더
첫 번째 버전에서는 각 카세트를 모델 태그, 프롬프트 및 매개변수의 정규화된 해시(canonical hash)로 키 지정했습니다. 저는 벽시계 타임스탬프(wall-clock timestamps)와 요청 ID를 의도적으로 키에서 제외했는데, 포함하면 모든 키가 고유해지고 캐시가 로그 파일처럼 변하기 때문입니다.
import hashlib, json
from pathlib import Path
...
해당 함수에서 중요한 것은 보이는 것보다 두 가지 결정입니다. 첫째, 샘플링 매개변수는 키(key) 안에 포함되어야 합니다. 왜냐하면 한 온도에서 녹화된 카세트는 다른 온도에서의 장비와 같지 않기 때문입니다. 둘째, 재현할 수 없는 모든 필드는 해싱 전에 정규화되거나 제거되어야 하며, 그렇지 않으면 리플레이가 조용히 재녹화(re-record)되는 것이 됩니다.
정규화의 함정 (The normalization trap)
저는 처음에 호출자로부터 제공된 request_id를 포함하는 딕셔너리에서 params를 그대로 해싱했습니다. 모든 리플레이가 실패했고, 실행할 때마다 새로운 카세트가 기록되었으며, 테스트 스위트는 빨라지기는커녕 느려졌습니다. 해결책은 전송(transport)에만 필요한 필드를 제거하는 작은 normalize() 단계였고, 저는 이제 리뷰에서 이 규칙을 강제합니다. 만약 답변을 바꾸지 않으면서 변경될 수 있는 것이라면, 그것은 키 안에 속하지 않는다는 것입니다.
8시간–22시간: 유효성 검사(validation)와 중첩 버그가 숨어 있던 곳
녹화하는 동안, 저는 장비에 쓰레기(garbage)를 얼리는 것을 방지하기 위해 유효성 검사기(validator)를 추가했습니다. 저의 첫 번째 시도는 정직했지만 얕았고, 여기서 '얕다'는 것은 정확히 잘못된 형태였습니다.
# 버그가 통과하도록 허용한 버전
def validate_loose(payload: str) -> dict:
doc = json.loads(payload)
...
이 검사는 외부 컨테이너가 존재하고 비어있지 않다는 것만 확인하며, 그 이상은 아무것도 하지 않습니다. 모델은 때때로 각 항목의 tags를 배열 대신 쉼표로 연결된 문자열로 반환했습니다. 다운스트림에서 for tag in item["tags"]는 행복하게 문자를 반복(iterate)했고, 의미 없는 레이블을 생성했으며, 절대 오류를 발생시키지 않았습니다. 최상위 레벨의 유효성 검사는 '예'라고 말했지만, 두 단계 아래에 있는 필드는 그렇지 않았습니다.
수정된 버전은 형태를 가장 깊은 곳까지 고정하고 추가 키를 거부하여, 새로운 필드가 조용한 드리프트(silent drift)가 아니라 의도적인 스키마 변경이 되도록 합니다.
import jsonschema
SCHEMA = {
...
22시간–36시간: 카세트가 버그를 얼렸다
이것이 여러분이 얻어 가야 할 실패 사례입니다. 저는 유효성 검사(validation)를 강화하기 전에 카세트를 기록했기 때문에, 여러 피처가 문자열화된 tags를 포함하고 있었습니다. 테스트 스위트가 녹색으로 표시되었는데, 이는 리플레이가 깨진 파서(parser)가 예상하는 정확히 깨진 페이로드(payload)를 반환했기 때문입니다. 녹색 테스트, 잘못된 코드, 그리고 버그를 영원히 방어할 피처였습니다.
여러분은 리뷰 과정에서 이것을 잡아냈을까요? 저는 그러지 못했습니다. 카세트 차이점(cassette diff)이 포착된 실수라기보다는 성공적인 기록처럼 보였기 때문입니다. 세 가지 변경 사항으로 문제가 해결되었는데, 그중 어느 것도 영리하지 않습니다:
- 엄격한 유효성 검사기(strict validator)를 통해서만 기록하여, 나쁜 페이로드가 절대로 피처가 되지 않도록 합니다.
- 모든 카세트에
schema_version을 저장하고, 버전이 더 이상 일치하지 않는 카세트는 삭제합니다. - 카세트 파일을 무시해야 할 생성된 아티팩트(generated artifacts)가 아니라, 리뷰할 수 있는 코드처럼 취급합니다.
세 번째 규칙은 사람들이 건너뛰는 것인데, 이것이야말로 저에게 밤을 절약해 주었을 것입니다.
36~48시간: 신선도 프로브와 의사 결정 테이블
리플레이(Replay)는 여러분의 파서가 피처와 일관적인지 증명합니다. 하지만 여러분의 피처가 세상과 일관적인지는 증명할 수 없습니다. 그래서 저는 기록된 사례 중 작은 샘플을 스케줄에 따라 다시 호출하고, 라이브 결과와 카세트를 텍스트가 아닌 구조적으로 비교하는 프로브(probe)를 추가했습니다.
# 매주, 푸시마다 아님
python -m harness.probe --sample 5 --strict --report probe.json
프로브가 의견이 다르면, 카세트가 오래된 것이므로 의도적으로 다시 기록합니다. 아래 표는 제가 이제 결정하는 데 사용하는 내용이며, 추측을 대부분 제거했습니다.
| 상황 | 리플레이(Replay) | 재기록(Re-record) | 라이브 호출(Live call) |
|---|---|---|---|
| 내부 로직 리팩토링, 동일 프롬프트 | 예(Yes) | 아니오(No) | 아니오(No) |
| ... |
한계점 및 이것을 건너뛰어도 되는 경우
Replay는 제공업체의 동작 변경(provider behaviour change), 지연 시간 변화(latency shifts), 또는 토큰 계정(token accounting)을 명시적으로 기록하지 않는 한 감지할 수 없습니다. 카세트도 프롬프트에 있던 모든 것을 캡처하므로, 커밋하기 전에 잠재적으로 민감한 데이터로 간주하여 제거(scrub)해야 합니다. 이 접근 방식은 작고 선별된 일련의 테스트 케이스에는 확장되지만, 몇천 개의 테스트 케이스가 되면 유지보수 비용이 가치를 초과합니다.
만약 실제 목표가 출력 품질에 대한 통계적 평가라면 이 방식을 채택하지 마십시오. 왜냐하면 리플레이는 하나의 샘플을 고정할 뿐 분산(variance)에 대해서는 아무것도 말해주지 않기 때문입니다. 데이터 상주성 규칙(data residency rules)이 디스크에 프롬프트를 저장하는 것을 금지한다면 이 방식을 채택하지 마십시오. 그리고 카세트 차이점(cassette diffs)을 검토할 사람이 아무도 없을 것이라면 절대 채택하지 마십시오. 왜냐하면 검토되지 않은 테스트 케이스는 단지 더 좋은 저장 공간을 가진 버그일 뿐이기 때문입니다.
제가 반복할 것들
저는 표준 키 기록기(canonical-key recorder), 엄격한 중첩 스키마(strict nested schema), 그리고 주간 프로브(weekly probe)를 중요도 순으로 유지할 것입니다. 또한, 이전보다 더 늦게 기록할 것이며, 30시간에 맞춰 사후 적용하는 대신 처음부터 스키마 버전을 관리할 것입니다. 다음번에는 카세트가 현재 스키마 버전보다 오래되었을 때 크게 실패(fails loudly)하는 단일 어설션도 추가할 것입니다.
만약 비슷한 것을 구축하고 있다면, 검증기(validator)로 시작하여 그것이 수락하는 것만 기록하십시오. 리플레이 계층은 쉽습니다. 무엇을 고정할 가치가 있는지 결정하는 것이 여러분의 '성공적인 빌드(green build)'가 아무 의미가 있는지를 결정하는 부분입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기