LLM 애플리케이션을 위한 실패 우선 설계: 포렌식 감사에서 얻은 세 가지 패턴
요약
멀티 에이전트 트레이딩 시스템의 포렌식 감사 경험을 바탕으로, LLM 애플리케이션 설계 시 반드시 고려해야 할 실패 패턴을 다룹니다. 특히 LLM의 출력 형식을 맹신하지 말고 스키마 검증을 통해 안정성을 확보해야 함을 강조합니다.
핵심 포인트
- LLM의 출력 형식이 항상 완벽할 것이라고 가정해서는 안 됨
- Pydantic 등을 활용한 즉각적인 스키마 검증 필수
- 잘못된 JSON 형식, 필드 누락, 타입 오류 등에 대한 방어적 설계 필요
- 의미론적으로 틀린 콘텐츠 생성 가능성에 대비한 구조적 접근 필요
몇 주 전, 저는 VPS에서 몇 달 동안 실행해 온 멀티 에이전트 (multi-agent) 옵션 트레이딩 시스템에 대해 포렌식 감사 (forensic audit)를 수행하며 주말을 보냈습니다. 이 시스템은 다섯 가지 전략을 통해 모의 투자 (paper-trading)를 수행하고, 일일 로그를 작성하며, 매 시장 개장 시마다 레짐 필터 (regime filter)를 호출하고 있었습니다.
감사 결과 발견된 사항:
- 제가 존재하는지조차 몰랐던 PM2에 의해 생성된 중복 프로세스
- 모든 거래가 두 번씩 기록되는 (중복 프로세스까지 실행될 때는 네 번씩) 거래 기록 중복 버그
- 300에서 858 사이의 값을 반환하는 ADX 계산 (해당 지표의 수학적 범위는 0-100임)
- 2주 동안 조용히 빈 파일을 작성해 온 "일일 요약" 로거 (logger)
- 몇 달 전에 폐기된 줄 알았던 레거시 (legacy) 주식 거래 알고리즘이 여전히 실행 중
- 서로 다른 모듈 전반에 걸쳐 일관되지 않게 구현된 세 가지 방식의 거래 로깅
감사가 끝날 무렵, 60일간의 모의 투자 검증 시계는 0으로 재설정되어야 했습니다. 제가 수집해 온 데이터 중 신뢰할 수 있는 것은 아무것도 없었습니다. 전체 감사 보고서는 agent-reliability-audit에서 확인할 수 있습니다.
이 포스트는 제가 그 다음에 무엇을 했는지에 관한 것입니다.
저는 상장 기업을 위한 구조화된 메모를 생성하는 멀티 에이전트 CFO 플랫폼이라는 다른 LLM 애플리케이션을 재구축하였고, 첫날부터 그 교훈들을 녹여냈습니다. 그 교훈들 중 세 가지는 이제 실제 부수 효과 (side effect, 파일 쓰기, 메시지 전송, 트랜잭션, 또는 되돌릴 수 없는 모든 것)를 유발하는 모든 LLM 애플리케이션에 대해 타협할 수 없는 패턴이 되었습니다.
패턴 1: LLM은 유효한 출력을 생성할 것이라고 절대 신뢰하지 마라
LLM 튜토리얼에서 저지르는 가장 기본적인 실수는 모델이 당신이 요청한 것을 반환할 것이라고 가정하는 것입니다. 하지만 모델은 종종 그렇지 않습니다.
"마크다운 펜스 (markdown fences) 없이 JSON만 출력하고, 정확히 이 스키마 (schema)를 따를 것"과 같은 명시적인 지침이 있더라도, 프로덕션 급 (production-grade) LLM 애플리케이션은 모델이 다음과 같은 상황을 보일 수 있다고 가정해야 합니다:
- 잘못된 형식의 JSON 반환 (Return malformed JSON)
- 필수 필드 누락 (Omit required fields)
- 잘못된 타입의 필드 반환 (Return fields with wrong types)
- 허용 범위를 벗어난 값 반환 (예: 1-5 척도에서 7점 반환)
- 반대 지침에도 불구하고 출력물을 마크다운 펜스 (markdown fences)로 감쌈
- 그럴듯해 보이지만 의미론적으로 틀린 콘텐츠 생성 (Generate plausible-looking but semantically incorrect content)
앞의 다섯 가지는 스키마 검증 (schema validation)을 통해 잡아낼 수 있습니다. 여섯 번째는 피할 수 없는 문제이며, 이것이 두 번째 패턴이 필요한 이유입니다.
CFO 플랫폼에서는 모든 LLM 호출 직후에 Pydantic 검증이 즉시 수행됩니다:
text = response.text.strip()
if text.startswith("```\n\njson"):
text = text.removeprefix("```\n\njson").removeprefix("```\n\n").strip()
text = text.removesuffix("```\n\n").strip()
memo_data = json.loads(text)
memo = CFOMemo.model_validate(memo_data)
CFOMemo 스키마에는 명시적인 필드 제약 조건이 있습니다:
class CFOMemo(BaseModel):
executive_summary: str = Field(..., min_length=20)
financial_highlights: list[str] = Field(..., min_length=1)
...
19자 길이의 요약문(executive summary)은 검증에 실패합니다. 필드가 누락되어도 검증에 실패합니다. key_risks에 문자열이 아닌 값이 들어있어도 검증에 실패합니다. 나머지 파이프라인은 결코 잘못된 데이터를 접하지 않습니다.
그 결과, 코드 내에 명확하게 위치한 하나의 신뢰 경계 (trust boundary)가 형성됩니다. 검사자들은 신뢰할 수 없는 LLM 출력이 언제 타입이 지정된 데이터 (typed data)로 변환되는지 정확히 확인할 수 있습니다. 하류 단계에서 "아마 이 딕셔너리에 올바른 키가 있겠지" 식의 처리는 존재하지 않습니다. 시간이 지나면서 변질될 수 있는 소비자별 임시 검증 (ad-hoc validation)도 없습니다.
스키마가 순수한 Pydantic으로 구성되어 있어 네트워크나 LLM이 필요 없으므로 테스트를 작성하기 쉽습니다. CFO 플랫폼의 테스트 매트릭스는 1초도 채 되지 않는 시간 내에 10가지 스키마 케이스를 모두 커버합니다.
패턴 2: 인간이 승인하고, 기계가 전달한다
LLM-as-judge (판사로서의 LLM)는 개발 과정에서 기계적인 품질 저하를 포착하는 데 유용합니다. 하지만 이는 외부로 나가는 콘텐츠에 대한 인간의 판단을 대체할 수는 없습니다.
CFO 플랫폼의 판사(judge)는 각 메모를 구체성 (specificity), 근거 제시 (grounding), 실행 가능성 (actionability), 수치적 정직성 (numeric_honesty)의 네 가지 차원에서 1-5 척도로 점수를 매깁니다. 이 출력물 자체도 스키마 검증을 거칩니다:
class JudgeScores(BaseModel):
specificity: int = Field(..., ge=1, le=5)
grounding: int = Field(..., ge=1, le=5)
...
판단자(judge)의 결정은 권고 사항입니다. 확인 게이트(Confirmation Gate)를 통해 인간이 승인/거부/수정하지 않는 한, 파일 쓰기, 이메일, WhatsApp 메시지 등 그 어떤 것도 출력 어댑터(output adapter)에 도달할 수 없습니다.
LLM 애플리케이션이 외부 부작용(side effects)을 가질 때, 이는 저에게 타협할 수 없는 원칙입니다. 판단자는 기계적인 품질 저하(quality regressions)를 저렴한 비용으로 잡아냅니다. 인간은 판단자가 잡아낼 수 없는, '그럴듯하지만 잘못된' 콘텐츠를 잡아냅니다. 판단자 또한 언어 모델(language model)이며 생성 모델(generator)과 동일한 사각지대(blind spots)를 공유하기 때문입니다.
아키텍처 측면에서 이는 두 개의 뚜렷한 신뢰 경계(trust boundaries)를 의미합니다:
- LLM 출력과 다운스트림(downstream) 코드 사이의 스키마 검증 (Schema validation)
- 자동화된 처리와 외부 전달 사이의 인간 확인 (Human confirmation)
코드베이스를 읽는 사람은 각 경계가 정확히 어디에 위치하는지 지목할 수 있어야 합니다. CFO 플랫폼의 경우, 첫 번째 경계는 synthesize_memo와 judge_memo(LLM 호출 직후)에 존재합니다. 두 번째 경계는 run_scheduler(output.deliver 직전)에 존재합니다.
패턴 3: 서킷 브레이커(circuit breaker) 내부에서 재시도(retry)하라, 그 반대가 아니라
이것은 가장 기술적으로 주관적인 패턴이며, 제가 가장 자주 잘못 구현된 것을 목격하는 패턴이기도 합니다.
LLM API 및 데이터 소스로의 외부 호출에는 두 가지 탄력성 계층(resilience layers)이 필요합니다:
- 재시도 (Retry): 일시적인 오류(타임아웃, 연결 오류, 5xx, 429)를 위한 것
- 서킷 브레이커 (Circuit breaker): 지속적인 장애(업스트림이 실제로 문제가 생겨서 계속해서 요청을 보내는 것을 멈춰야 하는 상황)를 위한 것
이 계층들을 조합하는 데에는 두 가지 방법이 있습니다:
옵션 A — 브레이커 내부에서 재시도. breaker.call(retrying_function). 브레이커는 해당 호출이 내부적으로 세 번 시도되었더라도 하나의 논리적 호출로 간주합니다.
옵션 B — 재시도 내부에서 브레이커. @retry def fn(): breaker.call(...). 브레이커는 각 재시도 시도를 별개의 관측값(observation)으로 간주합니다.
실패 임계값(failure threshold)이 약 10 미만인 경우, 옵션 B는 잘못된 방식입니다. 그 이유는 다음과 같습니다.
옵션 B를 사용하고 일반적인 failure_threshold=5를 설정하면, 단 한 번의 일시적인 오류(transient blip)가 세 번의 차단기 관찰(breaker observations, 즉 세 번의 재시도 시도)을 소모하게 됩니다. 두 번의 일시적인 오류가 발생하면, 애플리케이션 관점에서는 단 하나의 이벤트임에도 불구하고 차단기는 실패 예산(failure budget)의 60%를 사용하게 됩니다.
옵션 A는 차단기 예산이 재시도 메커니즘이 아닌 애플리케이션 수준의 상태(application-level health)를 추적함을 의미합니다. 일시적인 오류가 발생했을 때, 재시도가 성공하면 관찰값은 0이며, 재시도가 모두 소진되어 차단기가 해당 실패를 단일 이벤트로 인식할 경우에만 1의 관찰값이 소모됩니다.
CFO 플랫폼에서 이러한 구성은 다음과 같이 나타납니다:
class EdgarClient:
@retry(
retry=retry_if_exception(_is_retryable_edgar_error),
...
_fetch는 재시도를 수행하는 내부 함수입니다. _get_json은 이를 차단기(breaker)로 감쌉니다. 논리적 호출(logical call)당 하나의 차단기 관찰값이 소모됩니다.
저는 차단기를 통합할 때 이를 경험적으로 검증했습니다. 모든 호출에 의도적으로 503 오류를 주입했을 때: 5번의 논리적 시도 × 각 시도당 3번의 내부 재시도 = 총 15번의 실제 HTTP 요청이 발생한 후, 차단기가 작동(tripped)하였고 이후의 호출은 HTTP 요청 없이 실패했습니다. 이것이 의도한 동작입니다. 옵션 B였다면 차단기가 논리적 시도 2번 만에 작동했을 것이며, 이는 너무 민감한 설정입니다.
동일한 구성이 자체 호스트별 차단기를 가진 Anthropic API 클라이언트에도 적용됩니다. SEC EDGAR가 다운된다고 해서 Anthropic 차단기가 작동해서는 안 되며, 그 반대도 마찬가지입니다. 각 업스트림(upstream)은 자신만의 예산을 가집니다.
재시도 조건(retry predicate) 또한 중요합니다. 단순한 버전은 기본 클래스인 httpx.HTTPError에 대해 재시도를 수행하는데, 여기에는 모든 4xx 또는 5xx 응답에 대한 HTTPStatusError가 포함됩니다. 이는 404(티커를 찾을 수 없음) 오류의 경우에도 결정론적(deterministic)이며 개선될 여지가 없는 오류임에도 불구하고, 지수 백오프(exponential backoff)를 동반한 세 번의 재시도를 트리거한다는 것을 의미합니다.
범위를 좁힌 버전은 5xx, 429, 그리고 연결 관련 오류(connection-class errors)에 대해서만 재시도를 수행합니다:
def _is_retryable_edgar_error(exc):
if isinstance(exc, (httpx.ConnectError, httpx.TimeoutException, httpx.RemoteProtocolError)):
return True
...
404 에러는 즉시 발생합니다. 503 에러는 백오프 (backoff)와 함께 재시도하며, 모든 시도가 실패하면 서킷 브레이커 (breaker)로 전파됩니다.
내가 다르게 했을 점
이번 버전에서는 구현하지 않았지만, 실제 프로덕션 배포를 위해 수행할 세 가지 작업입니다:
확인 게이트 (Confirmation Gate)를 위한 MCP 서버. 현재 게이트는 CLI 프롬프트입니다. 무인으로 작동하는 시스템의 경우, 적절한 인터페이스는 Model Context Protocol (MCP)을 통해 노출되는 Slack 또는 웹 기반 승인 흐름입니다. 제가 설계한 ConfirmationAdapter 프로토콜은 스케줄러의 변경 없이 이러한 교체를 수용할 수 있도록 설계되었으며, 이는 의도된 사항이었습니다.
프롬프트 회귀 CI (Prompt regression CI). 판사 (judge) 모델이 합성된 메모의 품질 저하를 잡아내기는 하지만, 더 어려운 문제인 '판사가 알아차리지 못할 미묘한 환각 (hallucination)을 유발하는 프롬프트 변경'은 잡아내지 못합니다. 저는 프롬프트 템플릿이 변경될 때마다 고정된 테스트 프롬프트 세트에 대해 골든 출력 차이 (golden-output diff)를 실행하고, 병합 전 차이점에 대해 인간의 검토를 거치는 CI 단계를 추가하고 싶습니다.
명시적인 PII 경계 문서화. 현재 버전은 SEC 공개 데이터만 가져옵니다. 실제 Fractional CFO 제품은 비공개 재무 데이터, 이사회 회의록, 내부 예측치를 포함할 것입니다. 해당 데이터에 대한 신뢰 경계 (trust boundary)는 명시적이고 문서화되어야 합니다. 즉, 무엇이 LLM으로 넘어가는지, 무엇이 데이터베이스에 남는지, 프롬프트가 구성되기 전에 무엇이 비식별 처리 (redacted)되는지를 정의해야 합니다.
리포지토리 (The repo)
전체 코드베이스는 github.com/ajstehle2001/multi-agent-cfo에 있습니다. 이번 재구축의 동기가 된 감사 사례는 github.com/ajstehle2001/agent-reliability-audit에서 확인할 수 있습니다. 또한, 정량적 영역에 동일한 원칙을 적용한 structural-commodity-monitor는 원자재 공정 가치 모델을 위해 사전 등록된 통계 테스트, 신호 감쇠 거버넌스 (signal-decay governance), 그리고 검증 신뢰 경계 (validation trust boundaries)를 갖춘 LLM 데이터 추출을 구현하고 있습니다. 이는 메모가 아닌 시장을 대상으로 한, 본 포스트의 검증 철학을 적용한 사례입니다.
docs/ARCHITECTURE.md와 docs/adr/에 있는 세 개의 아키텍처 결정 기록 (Architecture Decision Records, ADR)은 미래의 독자들, 그리고 미래의 저 자신을 위해 설계 선택 사항들을 읽기 쉽게 만들고자 노력한 결과물입니다. LLM 시스템에 대한 프로덕션급 사고 (Production thinking)는 여전히 미성숙한 장르입니다. 대부분의 온라인 자료는 최신 모델 출시나 기본적인 프롬프트 엔지니어링 (Prompt engineering)만을 다룰 뿐, 애플리케이션 계층에서 프로덕션급 신뢰성 (Production-grade reliability)이 실제로 어떤 모습인지에 대해서는 거의 다루지 않습니다. 저는 하나의 리포지토리씩 차근차근 그 간극을 메워나가려 노력하고 있습니다.
만약 여러분이 LLM 애플리케이션을 구축하고 있으며 이 내용에 대해 논의하고 싶다면, 제가 공개하는 모든 것은 github.com/ajstehle2001에서 확인하실 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기