LLMOps 26
LLM 에이전트 관측: Snapshot과 Trace의 명확한 역할
Snapshot과 Trace를 둘 다 로그라고만 보면 언제 무엇을 봐야 하는지 섞인다.
근거 · 교안 p90-p93
1장: 기록 방식의 차이: 상태 vs. 흐름
솔라는 노트북 화면 한구석에 띄워 둔 메모장을 노려봤다. 기술 문서에서 급하게 복사해 온 한 문장이 눈에 거슬렸다.
"Snapshot은 상태 변화 중심이고 LangSmith Trace는 LLM/Tool 호출 실행 흐름과 상태를 Tree 구조로 보여준다."
분명히 다른 두 개념을 설명하는 문장이었지만, 솔라에게는 그 차이가 와닿지 않았다. ‘상태 변화’, ‘실행 흐름’, ‘Tree 구조’ 같은 단어들은 머릿속을 맴돌 뿐이었다. 결국 에이전트가 실행되고 난 뒤 남는 기록이라는 점은 똑같지 않은가? 솔라는 자기도 모르게 중얼거렸다.
“어차피 둘 다 그냥 로그 아냐? 뭐가 다른 건지 모르겠네. 언제 뭘 봐야 할지 어떻게 알아.”
그냥 ‘로그’라는 큰 상자에 모든 걸 쓸어 담아버리고 싶은 마음이었다. 이 모호한 설명만으로는 두 도구를 언제, 어떻게 써야 할지 판단할 수 없었다. 솔라는 마른세수를 하며 한숨을 쉬었다. 그때, 조용히 자기 코드를 보던 루나가 고개를 돌렸다.
“뭐가 잘 안 풀려?”
“아, 언니. 이거 봐봐. LLM 에이전트 만들 때 Snapshot이랑 Trace라는 걸로 기록을 남길 수 있잖아. 근데 설명이 너무 뜬구름 잡는 소리 같아. ‘상태 중심’, ‘흐름 중심’이라는데, 그래서 뭐가 다른 거냐고. 내 눈엔 그냥 다 실행 기록으로 보이는데.”
솔라는 투덜거리며 자신의 화면을 가리켰다. 루나는 솔라의 자리로 다가와 잠시 화면을 들여다보더니, 의자 등받이에 가볍게 기댔다. 정답을 바로 알려주기보다, 늘 그랬듯 다른 판을 짜려는 움직임이었다.
“음, 그 둘을 같은 종류의 ‘기록’으로 보고 있으니 헷갈릴 수 있겠네. 직접 눈으로 보면 그 관점의 차이가 보일 거야. 간단한 에이전트 하나만 같이 돌려볼까?”
루나는 자신의 노트북에 짧은 코드를 몇 줄 입력했다. ‘오늘 서울 날씨를 확인하고, 그에 맞는 옷차림을 추천하는’ 간단한 에이전트였다. 코드를 실행하자, 에이전트는 순식간에 작업을 마치고 결과를 내놓았다.
“자, 이제 이 에이전트가 방금 실행되면서 남긴 두 가지 기록을 비교해 보자. 먼저 이거, 마지막 단계의 Snapshot이야.”
루나가 먼저 보여준 것은 단출한 데이터 뭉치였다.
{
"values": {
"input": "오늘 서울 날씨 어때?",
"messages": [
"...",
"AIMessage(content='', tool_calls=[{'name': 'search_weather', 'args': {'city': '서울'}, 'id': '...'}])",
"ToolMessage(content='{\"temperature\": 25, \"condition\": \"맑음\"}', tool_call_id='...')",
"AIMessage(content='오늘 서울의 날씨는 25도에 맑습니다. 반소매와 얇은 긴 바지를 추천합니다.')"
]
}
}
솔라는 눈을 가늘게 뜨고 내용을 훑었다. “음… 사용자가 뭘 물어봤는지, 중간에 날씨 검색 도구를 썼는지, 그래서 최종 답변이 뭔지가 들어있네. 그냥… 마지막 순간의 변수 값들을 다 모아놓은 것 같은데?”
“맞아. 그게 핵심이야. ‘특정 순간의 값들’. 마치 스튜디오에서 최종 결과물을 놓고 찍은 한 장의 ‘증명사진’ 같지. 이 사진 안에는 이 결과가 나오기까지 어떤 순서로, 얼마나 오래 작업했는지는 담겨 있지 않아. 그저 마지막에 이런 값들이 남았다는 사실만 명확하게 보여줄 뿐이지.”
루나는 말을 마치고 다른 창을 띄웠다. LangSmith의 Trace 화면이었다. 아까와는 전혀 다른 모습이 펼쳐졌다. 여러 개의 막대가 계층적으로 펼쳐져 있었고, 각 막대 옆에는 실행 시간이 표시되어 있었다.
▶ AgentExecutor (전체 실행: 2.5s)
└─ ▶ ChatOpenAI (LLM 호출 1: 0.8s)
└─ ▶ search_weather (도구 호출: 0.5s)
└─ ▶ ChatOpenAI (LLM 호출 2: 1.2s)
“이건 어때 보여?”
“어… 이건 완전 다르네. 에이전트가 실행되면서, 처음엔 LLM을 부르고, 그 다음에 LLM이 날씨 검색 도구를 부르고, 도구가 결과를 주니까 다시 LLM을 불러서 최종 답변을 만들었구나. 누가 누구를 불렀는지, 각 단계가 얼마나 걸렸는지 순서대로 다 보여주네.”
솔라는 두 화면을 번갈아 보았다. 방금 전까지 ‘그냥 로그’라고 뭉뚱그려 생각했던 두 데이터가 전혀 다른 이야기를 하고 있다는 사실을 깨달았다. 하나는 결과물을 찍은 선명한 스틸 사진이었고, 다른 하나는 제작 과정을 처음부터 끝까지 촬영한 메이킹 필름이었다.
“아… 이제 알겠다. Snapshot은 특정 시점의 ‘상태 값’ 그 자체를 찍어두는 거구나. 냉장고 문을 열었을 때 그 안에 계란, 우유, 사과가 들어있다는 사실을 기록하는 것처럼. 반면에 Trace는 내가 냉장고 문을 열고, 계란을 꺼내고, 프라이팬에 깨뜨려 넣기까지의 ‘행동 순서와 관계’를 기록하는 거였어.”
솔라의 목소리에 막혔던 무언가가 풀려나간 듯한 시원함이 묻어났다. ‘상태 중심’과 ‘흐름 중심’이라는 단어가 더 이상 추상적으로 들리지 않았다. 하나는 값의 목록(What)을, 다른 하나는 호출의 관계(How)를 기록하고 있었다. 이것이 두 도구의 근본적인 차이였다.
“그럼 이제 궁금한 게 생겼어.” 솔라가 말을 이었다. “기록하는 내용이 이렇게 다르다면, 쓰임새도 완전히 달라지겠네. 증명사진이 필요한 순간이랑 메이킹 필름이 필요한 순간이 다르듯이 말이야. 상태 값을 그대로 찍어두는 Snapshot은 대체 언제 유용한 거지? 그리고 이 복잡한 흐름을 보여주는 Trace는 또 언제 써야 하는 걸까?“
2장: Snapshot: 상태 재현으로 디버깅하기
솔라는 이마를 찌푸린 채 자신의 노트북 화면을 응시하고 있었다. 화면에는 간헐적으로 실패하는 LLM 에이전트의 실행 결과가 떠 있었다. 열 번 중 한두 번꼴로, 에이전트는 특정 외부 API를 호출하는 부분에서 맥없이 멈춰 섰다. 문제는 그 실패가 예측 불가능하다는 점이었다. 똑같은 입력으로 실행해도 어떤 때는 성공하고, 어떤 때는 실패했다.
“아, 또 실패했네. 대체 왜 이러는 거야.”
솔라는 혼잣말을 하며 키보드를 신경질적으로 두드렸다. 지난번 루나와의 대화 이후, Snapshot은 ‘상태의 증명사진’이고 Trace는 ‘과정의 메이킹 필름’이라는 차이는 명확히 이해했다. 하지만 지금 당장 이 예측 불가능한 버그 앞에서, 그 ‘증명사진’이 무슨 소용이 있을지 감이 오지 않았다. 실패의 원인을 알려면 오히려 전체 흐름을 담은 ‘메이킹 필름’이 더 유용하지 않을까?
답답한 마음에 커피를 가지러 일어선 솔라의 등 뒤로 루나의 차분한 목소리가 들렸다.
“디버깅 중이야? 간헐적으로 발생하는 오류만큼 골치 아픈 것도 없지.”
“언니, 이것 좀 봐. 에이전트가 외부 API에서 데이터를 가져오다가 가끔 실패해. 근데 매번 그러는 게 아니라서 원인을 못 찾겠어. 이럴 때 뭘 봐야 하지? 실패 직전까지의 실행 흐름을 봐야 하니까 Trace를 뒤져봐야 하나?”
솔라는 당연히 ‘과정’을 봐야 한다고 생각했다. 하지만 루나는 고개를 저으며 솔라의 화면에서 다른 기록을 찾아 열었다. 실패한 실행 바로 직전 단계에서 찍힌 Snapshot이었다.
// 실패 직전 단계의 Snapshot
{
"values": {
"input": "오늘의 주요 기술 뉴스 요약해 줘.",
"messages": [
// ... (이전 대화 생략)
"AIMessage(content='', tool_calls=[{'name': 'fetch_tech_news', 'args': {}, 'id': '...'}]"
],
"intermediate_steps": [
// ... (이전 도구 호출 결과 생략)
[
"AgentAction(tool='fetch_tech_news', tool_input={}, ...)",
"API Error: Rate limit exceeded" // <-- 문제의 API 응답
]
]
}
}
“Trace를 보는 것도 방법이지만, 이런 경우엔 Snapshot이 더 강력한 단서가 될 수 있어.” 루나가 말했다. “이 Snapshot은 에이전트가 멈추기 바로 직전의 상태를 그대로 얼려놓은 거야. 뭐가 보여?”
솔라는 코드를 자세히 들여다봤다. intermediate_steps 항목에, 평소라면 뉴스 기사 목록이 들어있어야 할 자리에 낯선 문자열이 끼어 있었다. "API Error: Rate limit exceeded".
“아! API 호출량 초과 에러가 떴었네. 우리 에이전트는 정상적인 뉴스 데이터가 올 거라고만 예상하고, 이런 에러 메시지를 받았을 때 어떻게 처리해야 할지 몰라서 그냥 멈춰버린 거구나.”
순간, 퍼즐 조각이 맞춰지는 느낌이 들었다. 실패의 원인은 ‘과정’의 복잡한 흐름 속에 숨어있는 것이 아니었다. 실패 직전의 특정 ‘상태 값’이 문제였다. 예측하지 못한 형태의 데이터가 들어온 바로 그 순간, 그 상태가 버그를 유발한 것이다.
“바로 그거야.” 루나가 확인해주었다. “중요한 건 지금부터야. 원인을 알았으니 코드를 수정해야겠지. 그런데 수정하고 나서 그 코드가 정말 이 문제를 해결했는지 어떻게 확신할 수 있을까? 또다시 오류가 발생할 때까지 열 번, 스무 번 에이전트를 돌려봐야 할까?”
“음… 아니, 그럴 필요 없겠네.” 솔라는 무릎을 탁 쳤다. “이 Snapshot 데이터가 있잖아! 이 ‘API 에러’가 발생한 상태를 그대로 복사해서, 내 코드에 테스트 케이스로 넣어보면 돼. 에이전트 전체를 다시 실행할 필요 없이, 이 특정 상태를 입력값으로 줬을 때 에러를 잘 처리하는지만 확인하면 되는 거지.”
솔라의 눈이 빛났다. ‘동일 상태로 재실행 가능’이라는 말의 진짜 의미를 깨닫는 순간이었다. 그것은 단순히 실행을 반복한다는 뜻이 아니었다. 문제의 원인이 된 ‘오염된 상태’ 그 자체를 박제해서, 버그를 100% 재현할 수 있는 실험실을 만드는 것과 같았다.
“맞아. Snapshot의 핵심은 바로 그 ‘상태 재현’에 있어. 에이전트의 실행이라는 긴 여정 속에서, 문제가 발생한 특정 지점의 상태를 그대로 가져와 디버깅할 수 있게 해주는 타임머신 같은 거지. 덕분에 우리는 불확실한 상황을 계속 반복할 필요 없이, 실패가 확실히 일어나는 환경에서 문제를 해결할 수 있게 돼.”
솔라는 고개를 끄덕이며 자신의 코드를 수정하기 시작했다. API 에러가 발생했을 때를 대비한 예외 처리 구문을 추가했다. 그리고 방금 Snapshot에서 확인한 에러 상태를 이용해 테스트를 실행했다. 에이전트는 더 이상 멈추지 않고, 사용자에게 API 호출에 문제가 생겼음을 알리는 메시지를 정상적으로 출력했다. 골치 아팠던 버그가 너무나 명확하게 해결되었다.
“이제 확실히 알겠어. Snapshot은 특정 순간의 상태를 찍어두기 때문에, 예측 불가능한 에러가 발생했을 때 그 순간을 완벽하게 재현해서 원인을 찾고 해결하는 데 최적화된 도구구나. 정말 강력한 디버깅 도구였네.”
문제를 해결하고 나니 새로운 궁금증이 고개를 들었다.
“그럼 Trace는 이런 ‘재현’이 어려운 걸까? 메이킹 필름처럼 모든 과정을 기록하고 있는데도? 만약 문제가 버그가 아니라, 시스템 전체가 느려지거나 비용이 많이 나오는 경우라면 어떡하지? 그럴 땐 상태를 재현하는 것만으로는 부족할 것 같은데… 그땐 Trace의 ‘실행 흐름’을 봐야만 하는 이유가 있는 걸까?“
3장: Trace: 실행 흐름 분석으로 시스템 모니터링
솔라가 골치 아픈 버그를 해결한 뒤에도, 루나는 자리를 뜨지 않고 자신의 노트북에서 무언가를 분주하게 설정하고 있었다. 화면에는 여러 개의 에이전트가 서로 연결된 복잡한 다이어그램이 떠 있었다. ‘여행 계획 에이전트’가 ‘항공권 예약 에이전트’와 ‘숙소 리뷰 조회 에이전트’를 호출하는 구조였다. 이전의 날씨 에이전트와는 비교도 안 되게 복잡했다.
솔라는 방금 전 Snapshot으로 특정 상태를 완벽하게 재현해 버그를 잡았던 짜릿함을 곱씹고 있었다. ‘상태 재현’이라는 강력한 무기를 얻은 기분이었다. 하지만 눈앞의 복잡한 시스템을 보니, 문득 떠올랐던 질문이 다시 고개를 들었다. 이 모든 과정이 담긴 Trace, 즉 메이킹 필름이 있다면 그걸로도 똑같이 재현할 수 있어야 하는 것 아닐까? 왜 굳이 어렵다고 하는 걸까.
“언니, 이건 뭐야? 엄청 복잡해 보이는데.”
“다음 프로젝트에 쓸 멀티 에이전트 시스템 프로토타입이야.” 루나가 키보드에서 손을 떼며 말했다. “이 시스템은 버그가 있어서 만든 게 아니야. 한번 실행해 볼게. 문제는 다른 데 있어.”
루나가 코드를 실행하자, 여러 에이전트가 연쇄적으로 작동하는 로그가 화면을 빠르게 스쳐 지나갔다. 최종적으로 ‘뉴욕 3박 4일 여행 계획’이 출력되기까지 10초가 넘는 시간이 걸렸다.
“봐. 결과는 잘 나왔지. 하지만 너무 느리고, 비용도 많이 드는 것 같아. 솔라, 이 문제를 해결해야 한다면 어디서부터 시작할래? Snapshot을 볼까?”
솔라는 잠시 고민에 빠졌다. 시스템이 멈춘 것도 아니고, 잘못된 값을 내놓은 것도 아니었다. 특정 지점의 ‘상태’를 찍어 봐도, 그 안에는 ‘느리다’거나 ‘비용이 많이 든다’는 정보는 없을 터였다. 문제가 된 특정 ‘값’이 없는데, 상태를 재현하는 게 무슨 의미가 있을까.
“아니… 이럴 땐 Snapshot은 별 도움이 안 될 것 같아. 실패한 특정 순간이 없으니까. 그럼… 역시 전체 과정을 기록한 Trace를 봐야 하나?”
“한번 열어보자.”
루나가 방금 실행된 작업의 LangSmith Trace 화면을 띄웠다. 이전과는 비교도 안 되는 복잡한 트리 구조가 화면을 가득 채웠다. 여러 개의 막대가 서로를 호출하며 계단처럼 얽혀 있었고, 각 막대 옆에는 실행 시간과 토큰 사용량이 빼곡히 적혀 있었다.
▶ TravelPlannerAgent (전체 실행: 10.2s, 1250 tokens)
├─ ▶ ChatOpenAI (계획 수립: 1.5s, 500 tokens)
├─ ▶ BookingAgent (항공권 조회: 4.0s)
│ ├─ ▶ API: search_flights (3.5s) // 외부 항공사 API
│ └─ ▶ ChatOpenAI (결과 요약: 0.5s, 150 tokens)
├─ ▶ ReviewLookupAgent (호텔 리뷰 검색: 3.2s)
│ ├─ ▶ DB: query_reviews (2.8s) // 내부 리뷰 DB
│ └─ ▶ ChatOpenAI (리뷰 분석: 0.4s, 300 tokens)
└─ ▶ ChatOpenAI (최종 정리: 1.5s, 600 tokens)
“와…” 솔라는 자신도 모르게 탄성을 내뱉었다. “이건… 시스템의 엑스레이 사진 같네.”
한눈에 모든 게 보였다. 전체 실행 시간 10.2초 중 대부분이 ‘항공권 조회’(4.0s)와 ‘호텔 리뷰 검색’(3.2s)에 쓰였다는 사실이 명확했다. 특히 외부 항공사 API를 호출하는 search_flights 단계가 가장 큰 병목 지점이었다. 비용 역시 마지막 최종 정리 단계에서 가장 많은 토큰(600 tokens)을 사용하고 있었다.
“이제 알겠다. Trace의 트리 구조는 단순히 호출 순서를 보여주는 게 아니었어. 시스템 전체의 시간과 비용이 어디서 어떻게 사용되는지 한눈에 보여주는 지도였구나. 이걸 보면 어디가 느린지, 어디서 돈이 새고 있는지 바로 알 수 있으니 성능이나 비용 분석에 최적화된 게 당연하네.”
솔라의 시선이 Trace의 한 지점에 머물렀다. API: search_flights (3.5s).
“그런데 언니, 여기서 궁금한 점이 생겼어. Trace가 이렇게 모든 과정을 기록하고 있다면, 이 기록을 그대로 다시 실행하면 똑같이 10.2초가 걸리고 1250 토큰이 사용되어야 하는 거 아냐? 왜 ‘재현이 어렵다’고 하는 거지?”
“좋은 질문이야. 우리가 지금 이 시스템을 똑같이 다시 실행하면 어떻게 될까?” 루나가 되물었다. “저 항공사 API 서버의 상태가 지금과 똑같을까? 네트워크 상황은? 그리고 검색 결과로 나오는 항공권 가격이나 좌석 정보는? LLM이 최종 계획을 정리할 때, 완전히 똑같은 단어와 문장 구조를 사용할 거라고 보장할 수 있을까?”
루나의 질문에 솔라는 말문이 막혔다. 당연히 그럴 수 없었다. 외부 API의 응답 속도, 데이터베이스의 상태, 심지어 LLM의 비결정적인 출력까지. Trace에 기록된 ‘흐름’은 수많은 외부 요인과 실시간 상호작용의 결과였다. 그 ‘흐름’을 다시 실행한다는 것은, 그 시점의 외부 세상 전체를 복제하지 않는 한 불가능한 일이었다.
“아… Snapshot은 우리 에이전트 내부의 상태 값이니까 언제든 똑같이 재현할 수 있지만, Trace는 우리 통제 밖의 외부 세상과의 상호작용까지 전부 포함하고 있구나. 그래서 과정을 기록했더라도 완벽한 재현은 불가능한 거였어. 메이킹 필름을 다시 튼다고 해서 촬영 당시의 날씨나 배우의 컨디션까지 똑같이 돌아오진 않는 것처럼.”
솔라는 두 도구의 역할이 머릿속에서 완벽하게 분리되는 것을 느꼈다. 하나는 내부의 문제를 해결하기 위한 현미경이었고, 다른 하나는 외부와의 관계 속에서 시스템 전체의 건강을 진단하는 청진기였다.
더 이상 질문은 없었다. 대신 솔라는 자신의 노트북을 열고 새 노트를 켰다. 망설임 없이 제목을 입력했다.
나만의 LLM 에이전트 시스템 진단 가이드
그리고 그 아래에 두 가지 질문으로 시작하는 간단한 규칙을 적어 내려가기 시작했다.
-
문제가 ‘예측 불가능한 실패’인가? 특정 단계에서 버그가 발생하는가?
- 진단 도구:
Snapshot - 해결 전략: 실패 직전의 상태(State)를 확보하여 100% 재현 가능한 테스트 환경을 구축한다. 원인이 된 값을 찾아 코드를 수정한 뒤, 해당 Snapshot으로 검증하여 버그를 해결한다.
- 진단 도구:
-
문제가 ‘느린 응답’, ‘높은 비용’, 혹은 ‘복잡한 호출 관계’인가?
- 진단 도구:
Trace - 해결 전략: 전체 실행 흐름(Flow)을 시각화하여 병목 구간(시간)과 비용 집중 지점(토큰)을 파악한다. 비효율적인 API 호출, 무거운 DB 쿼리, 불필요한 LLM 호출 등을 찾아내 시스템을 최적화한다.
- 진단 도구:
솔라는 자신이 만든 간결한 가이드를 보며 만족스럽게 미소 지었다. 이제 더 이상 ‘로그’라는 모호한 단어 뒤에 숨지 않아도 되었다. Snapshot과 Trace. 그것들은 각기 다른 목적을 가진, 명확하고 강력한 두 개의 진단 도구였다. 솔라는 어떤 문제가 닥쳐도, 알맞은 도구를 자신 있게 꺼내 들 수 있을 것 같았다.