AI 실험에서 기록은 실행 계약이어야 했다
앞선 스프린트에서는 답변 품질을 보며 질문이 조금씩 이동했습니다. 처음에는 모델의 답변을 봤고, 그다음에는 답변 앞에 들어가는 Context를 봤고, 멀티모달 RAG에서는 검색 결과를 평가하는 Judge까지 의심하게 됐습니다.
네 번째 스프린트에서는 질문이 한 번 더 앞쪽으로 갔습니다.
결과가 이상할 때, 이제는 먼저 이런 생각을 하게 됐습니다. 이 결과가 정말 내가 생각한 설정에서 나온 결과가 맞나?
AI 실험에서는 모델, 프롬프트, 검색 설정, 평가 기준, 입력 데이터가 조금만 바뀌어도 결과가 달라집니다. 그런데 실험이 빨라질수록 "무엇을 바꿨는지"와 "무엇이 실제로 실행됐는지"가 어긋나기 쉽습니다. 결과 파일은 남아 있지만, 그 결과를 다시 믿을 수 있는지는 다른 문제였습니다.
이번 스프린트에서 가장 크게 남은 배움은 기록을 많이 남기는 법이 아니었습니다. 기록된 값과 실행된 값이 어긋나지 않게 만드는 법이었습니다.
실험 기록은 보고서가 아니라 실행 계약이어야 했습니다.
여기서 계약이라는 말은 거창한 문서를 뜻하지 않습니다. 실행이 끝난 뒤에도 모델, 프롬프트, config, trace, 결과를 다시 한 묶음으로 복원할 수 있어야 한다는 뜻입니다.
이 글에서는 그 기준이 어떻게 Stage Config, prompt version, snapshot, LangSmith trace로 이어졌는지를 정리합니다. 도구를 소개하려는 글이 아니라, 실험 결과를 다시 믿기 위해 어떤 실행 조건을 코드와 기록에 묶었는지에 대한 기록입니다.
결과보다 먼저 조건을 복원하기
출발점은 결과 파일 자체보다 그 결과가 만들어진 조건을 다시 찾을 수 있는가였습니다.
결과 파일은 있는데 조건을 복원할 수 없는 순간
처음에는 실험 결과를 잘 남기면 충분하다고 생각하기 쉽습니다. 어떤 모델을 썼고, 어떤 점수가 나왔고, 어느 질문에서 실패했는지 기록하면 나중에 비교할 수 있을 것 같습니다.
하지만 RAG 파이프라인에서는 결과가 만들어지기 전까지 너무 많은 조건이 지나갑니다.
입력 데이터
-> 전사 설정
-> 비전 설명 설정
-> 전사 교정 설정
-> chunking 설정
-> embedding 설정
-> retrieval 설정
-> rerank 설정
-> QA 프롬프트
-> Judge 프롬프트
-> 결과
결과가 하나라 해도 그 뒤에는 여러 실행 조건이 붙어 있습니다. 그래서 결과만 저장하면 "그때 어떤 조건이었는지"를 복원하기 어렵습니다.
더 까다로운 점은 조건들이 독립적으로 보이지만 실제로는 서로 영향을 준다는 점이었습니다. 비전 프롬프트가 길어지면 frame description이 길어지고, 그것이 chunk에 붙고, 다시 embedding 입력 길이에 영향을 줍니다. 검색 설정을 바꾸면 답변이 바뀌고, Judge 프롬프트가 바뀌면 같은 답변의 평가도 달라집니다.
이 구조에서는 실험 결과가 좋거나 나쁜 것보다 먼저, 그 결과가 어떤 조건에서 나왔는지 복원할 수 있어야 했습니다.
그래서 이후의 작업은 "결과를 어디에 저장할까"가 아니라 "실행된 조건이 결과와 같은 묶음으로 남는가"를 확인하는 방향으로 바뀌었습니다.
실행 조건을 결과와 묶기
이 질문은 config, prompt, snapshot처럼 실행과 함께 남아야 하는 값들로 이어졌습니다.
Stage Config: 실험 레버를 책임별로 나누기
그 기준에서 처음 걸린 것은 config였습니다.
작은 프로젝트에서는 중앙 config 하나가 편합니다. 모델 이름, threshold, top-k, provider 같은 값을 한곳에서 관리하면 찾기 쉽고 바꾸기도 쉽습니다.
그런데 파이프라인이 길어지면 중앙 config는 점점 애매해집니다. 전사, 비전, embedding, retrieval, QA, Judge가 모두 다른 책임을 가지는데, 설정은 한 덩어리에 섞입니다. 한 stage에서 필요한 provider와 다른 stage에서 필요한 provider가 같은 이름으로 묶이면, 실험 조건을 읽을 때도 혼동이 생깁니다.
그래서 config를 stage별 책임으로 나눴습니다. 전사는 전사 설정을, 비전은 비전 설정을, correction은 전사 교정 설정을, embedding은 chunking과 embedding 설정을, retrieval은 검색과 rerank 설정을, QA와 Judge는 각자의 모델과 프롬프트 버전을 갖도록 분리했습니다.
이 변화는 단순한 정리가 아니었습니다. 실험에서 "무엇을 바꾸는가"를 코드 구조가 표현하게 만드는 작업이었습니다.
구조는 이렇게 나뉘었습니다.
# pipeline_config.py - stage별 실행 조건을 나눠 snapshot에 남기는 구조
class PipelineConfig:
transcription: TranscriptionConfig
vision: VisionConfig
correction: CorrectionConfig
embedding: EmbeddingConfig
retrieval: RetrievalConfig
qa: QAConfig
judge: JudgeConfig
핵심은 값의 종류가 아니라 책임의 위치였습니다. 전사 모델을 바꾸는 실험과 Judge 기준을 바꾸는 실험이 같은 config 필드처럼 보이면, 결과를 읽을 때 원인이 섞입니다.
| 이전 감각 | 바꾼 뒤의 기준 |
|---|---|
| 중앙 config에 모든 값을 모은다 | stage별로 자기 설정을 갖는다 |
| provider를 하나의 선택처럼 다룬다 | 전사, 비전, correction, embedding, QA/Judge, rerank의 선택지를 분리한다 |
| 결과 파일에 현재 값을 남긴다 | 실행에 사용된 config snapshot을 남긴다 |
| 실험하려면 config를 직접 고친다 | scoped override로 특정 실행만 바꾼다 |
이렇게 나누고 나서야 "이번 실험은 비전 모델만 바꾼 것인지", "embedding 모델도 같이 바뀐 것인지", "Judge 기준까지 바뀐 것인지"를 더 정확히 말할 수 있었습니다.
프롬프트 버전도 결과의 일부
config를 나누다 보니 프롬프트도 같은 문제로 보였습니다.
프롬프트는 코드처럼 보이지 않을 때가 많습니다. 문자열이고, 문장이고, 실험 중에 조금씩 고치기 쉽습니다. 그래서 결과가 흔들릴 때도 "모델이 비결정적이라 그런가?"라고 먼저 생각하게 됩니다.
하지만 같은 모델을 쓰더라도 프롬프트가 바뀌면 다른 실험입니다. 특히 Judge 프롬프트나 QA 프롬프트는 결과를 직접 바꿉니다. 평가자가 보는 기준이 바뀌면 점수도 바뀌고, 답변 생성 프롬프트가 바뀌면 같은 Context에서도 답변이 달라집니다.
이번에는 프롬프트를 버전으로 관리하고, snapshot에 현재 프롬프트 버전을 포함시키는 쪽으로 정리했습니다. 중요한 것은 프롬프트 문장을 예쁘게 정리하는 것이 아니라, 결과를 볼 때 "이 결과는 어떤 프롬프트 버전에서 나왔는가"를 함께 볼 수 있게 만드는 것이었습니다.
실행 결과
├─ config snapshot
├─ prompt versions
├─ model / provider
├─ correction options
├─ retrieval options
├─ judge options
└─ trace id
이 묶음이 있어야 결과를 다시 읽을 수 있습니다. 점수가 올랐다고 해도, 그 사이에 Judge 기준이 바뀌었다면 단순 개선으로 말할 수 없습니다. 답변이 좋아졌다고 해도, 검색 설정과 프롬프트가 동시에 바뀌었다면 원인을 나눠야 합니다.
프롬프트 버전 관리는 그래서 문장 관리가 아니라 재현성 인프라에 가까웠습니다.
snapshot이 붙잡아야 하는 것
snapshot은 "현재 설정을 저장한다"는 점에서는 기록처럼 보입니다. 하지만 이번 스프린트에서 필요했던 snapshot은 단순 기록보다 강한 의미를 가져야 했습니다.
기록은 나중에 사람이 읽는 문서입니다. 계약은 실행과 묶여 있어야 합니다. 실행된 값과 snapshot에 남은 값이 다르면 snapshot은 더 이상 믿을 수 없습니다.
그래서 config snapshot을 단일한 경로로 만들고, 새 config 필드를 추가하면 snapshot도 함께 갱신해야 한다는 기준을 세웠습니다. 실험 조건이 늘어났는데 snapshot에 빠져 있으면, 결과를 나중에 복원할 수 없습니다.
특정 실험에서만 설정을 바꿔야 할 때도 같은 문제가 있었습니다. 중앙 config를 직접 수정하면 편하지만, 실험이 끝난 뒤 원래 상태로 돌아왔는지 확신하기 어렵습니다. 그래서 특정 실행 범위 안에서만 값을 바꾸고, 실행이 끝나면 원래 값으로 돌아오는 override 구조가 필요했습니다.
이 둘은 같은 방향의 장치였습니다.
- snapshot은 실행 조건을 결과와 함께 남긴다.
- override는 실행 조건의 변경 범위를 좁힌다.
- stage config는 어떤 책임의 조건인지 나눈다.
이 셋이 같이 있어야 실험 조건이 말로만 관리되지 않았습니다.
trace와 평가 기록의 역할 분리
조건을 남기는 일과 요청 하나를 관측하는 일은 서로 다른 역할을 가졌습니다.
LangSmith는 요청 한 건을 어디까지 보여줘야 하나
이번 스프린트에서 LangSmith도 붙였습니다. 처음에는 "실험 결과를 더 잘 보는 도구"처럼 생각하기 쉬웠지만, 실제로는 역할을 다르게 잡아야 했습니다.
공식 비교와 보관은 기존 evals 흐름이 더 적합했습니다. 정해진 질문 세트를 돌리고, 결과 파일을 남기고, 조건별로 비교하는 일은 여전히 필요했습니다.
반면 개발 중에는 특정 질문 하나가 왜 실패했는지 바로 보고 싶었습니다. 검색 후보가 무엇이었는지, rerank에서 어떤 후보가 밀렸는지, 답변 생성에 어떤 Context가 들어갔는지, 어느 단계에서 시간이 많이 걸렸는지를 한 요청 단위로 보고 싶었습니다.
그래서 LangSmith는 결과 저장소라기보다 실제 app 요청을 관측하는 도구에 가까웠습니다.
qa.request
-> retrieve
-> vector search
-> bm25 search
-> rrf fuse
-> rerank or threshold
-> build context
-> generate answer
이런 trace가 있으면 답변 하나를 보고도 원인을 나눌 수 있습니다. 검색 후보가 없었는지, 후보는 있었는데 rerank에서 떨어졌는지, Context는 맞았는데 생성이 실패했는지 구분할 수 있습니다.
중요한 것은 LangSmith 자체가 아니라 trace의 경계였습니다. 어떤 함수를 trace할지보다, 어떤 실행 단위를 하나의 관측 단위로 볼지가 더 중요했습니다. qa.request는 함수 이름이라기보다 질문 한 건을 처리하는 실행 계약에 가까웠습니다.
evals와 LangSmith의 역할 분리
이 과정에서 evals와 LangSmith의 역할도 나눠야 했습니다.
| 도구 | 주된 역할 | 잘 맞는 질문 |
|---|---|---|
| evals | 공식 비교와 결과 보관 | 이 조건이 이전 조건보다 나은가? |
| LangSmith | 실제 요청의 흐름 관측 | 이 질문은 어느 단계에서 실패했는가? |
둘 중 하나로 통일하는 것이 깔끔해 보일 수도 있습니다. 하지만 실제 개발에서는 두 질문이 다릅니다.
비교하려면 같은 조건으로 여러 질문을 반복해서 돌려야 합니다. 디버깅하려면 특정 요청 하나를 깊게 봐야 합니다. 결과 저장과 실행 관측을 한 도구에 모두 밀어 넣으면 오히려 경계가 흐려질 수 있습니다.
그래서 evals는 기록용으로 남기고, LangSmith는 app 요청과 개발 중 디버깅 루프에 붙이는 쪽이 더 자연스러웠습니다.
점수 앞에 붙은 질문: 이 조건을 다시 만들 수 있는가
이번 스프린트 이후 실험 결과를 보는 기준이 조금 바뀌었습니다.
예전에는 점수가 오르면 먼저 기뻤습니다. 어떤 설정을 바꿨고, 얼마나 올랐는지 보는 것이 자연스러웠습니다. 물론 그 기준은 여전히 중요합니다. 실험은 결국 더 나은 답변과 더 안정적인 시스템을 만들기 위한 것이니까요.
하지만 이제는 점수 앞에 하나의 질문이 더 붙습니다.
이 점수는 어떤 조건에서 나온 것인가?
그 조건을 다시 복원할 수 없다면 점수는 다음 판단의 근거가 되기 어렵습니다. 어떤 모델을 썼는지, 어떤 프롬프트 버전인지, 어떤 검색 설정인지, 어떤 Judge 기준인지, 어떤 trace에서 어느 단계가 실패했는지 함께 남아야 합니다.
AI 실험에서 기록은 결과를 설명하는 부록이 아니었습니다. 실행 조건을 고정하고, 나중에 다시 비교할 수 있게 만드는 계약이었습니다.
이번 스프린트에서 config, prompt version, snapshot, trace를 정리한 이유도 결국 하나였습니다.
실험 결과를 더 많이 남기기 위해서가 아니라, 남긴 결과를 다시 믿기 위해서였습니다.