LLM과 이메일: 테스트 가능한 계약 (Contracts)
요약
LLM 에이전트가 이메일을 발송하는 워크플로우에서 프롬프트보다 중요한 것은 에이전트, 도구, 검증 방식 사이의 명확한 '계약(Contract)'입니다. 결정론적 컴포넌트를 통해 데이터를 축소하고, 제한된 의도만을 요청함으로써 시스템의 안정성과 운영 가능성을 높이는 설계 패턴을 제안합니다.
핵심 포인트
- 에이전트의 결정과 실행 도구 사이의 명확한 데이터 계약 수립 필요
- LLM은 자유 텍스트 대신 고정된 스키마 내에서 제한된 의도만 선택해야 함
- trace_id, action_type, recipient_scope를 통한 추적성 확보
- LLM 결정, 도구 검증, 서비스 기록, E2E 테스트로 단계를 분리하여 구현
LLM (Large Language Model)을 포함한 워크플로우가 이메일을 보내기 시작할 때, 많은 사람들은 먼저 프롬프트 (Prompt)를 살펴봅니다. 저는 거의 그렇게 시작하지 않습니다. 취약한 지점은 대개 에이전트 (Agent)의 결정, 작업을 실행하는 도구, 그리고 결과를 검증하는 방식 사이의 계약 (Contract)에 있습니다. 만약 이 경계가 모호하다면, 팀은 실제 문제가 불완전한 페이로드 (Payload)나 뒤섞인 편지함이었음에도 불구하고 "지능"에 대해 논쟁하게 됩니다.
자동화 (Automation) 팀에서는 이런 일이 빠르게 나타납니다. 에이전트가 "후속 조치 이메일 발송"을 결정하지만, 시스템은 템플릿 (Template), 수신자, 시간 범위 및 멱등성 (Idempotency)을 알아야 합니다. 만약 LLM이 자유 텍스트를 전달하고 도구가 이를 과도하게 해석한다면, 모든 테스트는 다소 수작업 방식 (Artisanal)이 되어버립니다. 한 번은 작동하지만, 그 후 목요일 새벽 3시에 왜 실패했는지 아무도 이해하지 못하게 됩니다.
문제는 프롬프트가 아니라 계약입니다
저는 설계를 다음과 같은 언어적 다이어그램으로 생각하는 것을 좋아합니다:
- 비즈니스 이벤트가 컨텍스트 (Context)를 생성합니다.
- 결정론적 (Deterministic) 컴포넌트가 해당 컨텍스트를 안전한 데이터로 축소합니다.
- LLM이 허용된 동작을 선택합니다.
- 실행기 (Executor)가 해당 동작을 검증 가능한 이메일 명령으로 변환합니다.
- 테스트가 메시지, 수신자 및 최종 효과를 검증합니다.
이 4단계야말로 지루해져야 하는 지점입니다. 에이전트는 헤더 (Headers)나 전달 경로를 발명해서는 안 됩니다. 단지 send_review_request와 같이 제한된 의도 (Intention)만을 요청해야 합니다. 나머지는 일반적인 코드의 영역입니다. 덜 화려하게 들릴지 모르지만, 시스템을 훨씬 더 운영하기 쉽게 만듭니다.
에이전트 파이프라인 (Pipelines)을 검토할 때, 저는 각 전환 단계에서 최소 세 가지 필드인 trace_id, action_type, recipient_scope를 요구하곤 합니다. 이것만 있으면 너무 많은 추측 없이도 사례를 재구성할 수 있습니다. 또한 모델의 의도와 SMTP 또는 트랜잭션 이메일 제공업체를 통해 실제로 나간 결과물을 비교할 수 있게 해줍니다.
이메일을 보내는 도구를 위한 검증 가능한 워크플로우
안정적인 구현은 단일 계층에서 모든 것을 테스트하려고 시도하지 않습니다. 작업을 다음과 같이 나눕니다:
- LLM은 고정된 스키마 (schema) 내에서 결정합니다.
- 도구 (tool)는 권한과 필수 필드를 검증합니다.
- 이메일 서비스는 동일한
trace_id와 함께 전송을 기록합니다. - 엔드 투 엔드 (end-to-end) 테스트는 격리된 편지함을 열어 올바른 콘텐츠를 확인합니다.
이 패턴은 이전 액터가 Terraform이 아닌 에이전트 (agent)라는 점만 다를 뿐, Terraform의 승인 테스트 (approval tests in Terraform)와 매우 유사합니다. 제가 물려받은 아이디어는 간단합니다: 각 시나리오는 명확한 증거와 매우 분명한 기술적 경계가 필요하다는 것입니다.
Python이나 Node에서는 실행기 (executor)가 다음과 같이 작을 수 있습니다:
def execute_agent_action(action, trace_id):
if action["type"] != "send_review_request":
return {"ok": False, "reason": "unsupported"}
...
중요한 것은 언어가 아닙니다. 중요한 것은 이메일 인프라를 건드리기 전에 최종 명령이 확정되어야 한다는 점입니다. 내일 공급업체를 변경하더라도, 귀하의 테스트는 전송의 우연한 세부 사항이 아니라 계약 (contract)을 기술하고 있게 됩니다.
관찰을 위한 깨끗한 표면을 확보하는 것도 도움이 됩니다. 일부 팀에서는 제목, 링크, 수신인을 확인하기 위해 케이스별로 임시 편지함을 사용합니다. 이를 위한 옵션을 비교 중이라면, tempmailso를 시스템의 중심이 아닌 워크플로우의 작은 구성 요소로 활용할 수 있습니다. 진정한 가치는 결정과 결과 사이의 추적 가능성 (traceability)에 있습니다.
에이전트 오류와 전송 오류를 구분하기 위해 측정해야 할 것
테스트가 실패했을 때, 저는 다음 네 가지 증거를 순서대로 찾습니다:
- 에이전트의 원래 결정.
- 도구가 생성한 정규화된 (normalized) 명령.
- 격리된 편지함에 도착한 메시지.
- 링크를 열거나 작업을 수락한 후의 최종 효과.
이 네 가지 중 하나라도 누락되면 분석이 상당히 까다로워집니다. 팀원들은 "분명 모델 문제였을 거야"라거나 "분명 제공업체 문제였을 거야"라고 말하기 시작합니다. 물론 가끔은 맞는 말이지만, 생각만큼 자주 그런 것은 아닙니다. 실패의 원인은 노이즈가 섞인 재시도 (retries), 오래된 템플릿 (templates), 또는 공유 큐 (shared queue)인 경우가 매우 빈번합니다.
여기서 저는 FastAPI에서 이메일 재시도 디버깅하기에서도 볼 수 있는 아주 좋은 관행을 발견했습니다. 거대한 로그 (logs)만 남기는 것이 아니라, 작고 읽기 쉬운 상태 (states)를 기록하는 것입니다. "작업 수락됨", "이메일 발송됨", "메시지 수신됨", "링크 검증됨"과 같은 식입니다. 기본적으로 보일 수 있지만, 조사 시간을 크게 단축해 줍니다.
또한 팀이 실제로 사용하는 언어를 기록해 두는 것도 좋습니다. 만약 티켓 (tickets)에 temp mailid와 같은 용어가 등장한다면, 일상적인 운영이 이미 구두 약속과 공유된 기억에 의존하고 있다는 것을 알 수 있습니다. 큰 문제는 아니지만, 시스템에 계약 (contracts)이나 더 명확한 명칭이 부족하다는 신호입니다.
트레이드오프 (Tradeoffs) 및 소규모 팀을 위한 짧은 체크리스트
이 접근 방식은 구조를 조금 더 추가합니다. ID, 검증 (validations), 그리고 LLM과 발송 사이에 추가적인 계층 (layer)이 생깁니다. 그 대가로 운영 환경 (production)에서 금과 같은 가치를 지닌 것을 얻게 됩니다. 즉, 왜 이메일이 발송되었는지, 왜 발송되지 않았는지, 그리고 어떤 컴포넌트 (component)가 각 결정을 내렸는지를 설명할 수 있게 됩니다.
제가 주로 보는 트레이드오프 (tradeoffs)는 다음과 같습니다:
- 에이전트 (agent)의 자유도는 낮아지지만, 감사 가능성 (auditability)은 향상됩니다.
- 통합 (integration) 단계는 늘어나지만, 기이한 디버깅 (debugging) 상황은 줄어듭니다.
- 테스트 속도는 약간 느려질 수 있지만, 신호 (signal)는 더 명확해집니다.
- 겉으로 보이는 마법은 줄어들지만, 유지보수가 가능한 시스템이 됩니다.
거의 항상 효과가 있는 짧은 체크리스트입니다:
- 각 실행은 고유한
trace_id를 가집니다. - 에이전트는 유효한 스키마 (schema) 내의 작업만 요청할 수 있습니다.
- 도구 (tool)는 수신자, 템플릿, 컨텍스트 (context)를 재검증합니다.
- 테스트는 공유된 편지함이 아닌, 시나리오별로 분리된 편지함을 사용합니다.
- 최종 어서트 (assert)는 단순히 "이메일 발송됨"이 아니라 비즈니스 효과를 확인합니다.
이를 제대로 수행하기 위해 거대한 플랫폼이 필요한 것은 아닙니다. 필요한 것은 계약 (Contracts)에 대한 규율입니다. 설계 단계에서의 그 차이는 작을지 모르지만, 졸린 눈을 비비며 망가진 흐름을 디버깅 (debug)해야 할 때는 엄청나게 크게 다가옵니다.
자주 묻는 질문 (FAQ)
LLM이 이메일 전체를 작성하도록 내버려 두어야 하나요?
리스크가 낮은 경우에만 그렇습니다. 민감한 메시지의 경우, 저는 통제된 템플릿 (template)을 사용하고 모델이 제한된 필드나 블록을 채우도록 하는 방식을 선호합니다.
"모든 것이 괜찮아 보이는데" 사용자가 잘못된 내용을 받았을 때 무엇을 가장 먼저 확인해야 하나요?
에이전트 (agent)가 제안한 작업과 실행기 (executor)의 최종 명령을 비교하십시오. 그 도약(jump) 구간이 많은 통합 (integration)이 깨지는 지점이며, 때로는 눈에 띄지 않고 지나치기도 합니다.
에이전트에 대한 유닛 테스트 (unit tests)가 이미 있다면 실제 테스트가 필요한가요?
네. 유닛 테스트는 로컬 로직 (local logic)이 제대로 작동하는지 알려줍니다. 하지만 시스템 전체가 수신함 (inbox)과 최종 상태까지 계약 (contract)을 유지하는지는 알려주지 않습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기