1. 이번 단계에서 달라지는 것
⑥편까지 에이전트는 "돌아가긴" 했다. 하지만 어느 단계에서 느린지, 토큰을 얼마 쓰는지는 print 로그로 짐작할 뿐이었음. 이번에는 LangSmith를 붙여 전 과정을 추적·계량한다.
에이전트가 MCP 도구·RAG·멀티 노드로 복잡해질수록, "왜 느리지 / 비용이 얼마지 / 어디서 실패했지"를 코드 밖에서 봐야 함. LangSmith는 LangChain/LangGraph 실행을 자동으로 추적해 대시보드로 보여주는 관측 도구다.
- 데이터 흐름(Flow) — 사용자 입력 → LLM → Tool → 최종 응답까지 각 단계가 자동 기록됨.
- 처리 시간(Latency) — 전체·LLM·Tool 실행 시간을 단계별로 분해해 병목을 찾음.
- 성능 지표(Metrics) — 토큰 사용량, API 호출 비용, 성공률 등 운영 숫자를 산출함.
6편이 "도구를 외부 표준으로 빼낸" 단계였다면, 이번 편은 "에이전트의 내부를 들여다보는" 단계다. 기능을 더하는 게 아니라 관측을 더한다.
2. 설치와 연결
LangSmith는 코드를 거의 안 건드림. 가입해서 API 키를 받고 `.env`에 넣으면, LangChain이 환경변수를 자동 감지해 추적을 켠다.
2.1 가입과 API 키
1. https://smith.langchain.com/ 접속 -> signup -> login (구글 계정 등)
2. 좌측 메뉴 > settings > + API Key
3. 설명·만료일 지정 후 생성 -> 키 복사 (lsv2_pt_53c5...)
프로젝트별로 추적이 묶이므로, 키 발급 시 용도를 적어두면 나중에 구분이 쉽다.
2.2 .env 설정
LANGCHAIN_API_KEY=lsv2_pt_53...
LANGCHAIN_PROJECT=agent-mcp-llm
LANGCHAIN_TRACING_V2=true
세 줄이 전부임. `LANGCHAIN_TRACING_V2=true`가 추적 스위치, `LANGCHAIN_PROJECT`가 기록이 쌓일 프로젝트 이름. 키만 있으면 LangChain 내부에서 알아서 추적을 시작한다.
env_path = Path(__file__).parent.parent / ".env"
if env_path.exists():
load_dotenv(dotenv_path=env_path, override=True)
langsmith_api_key = os.getenv("LANGCHAIN_API_KEY")
langsmith_project = os.getenv("LANGCHAIN_PROJECT", "agent-mcp-llm")
`.env`를 명시 경로로 로드하고(`override=True`로 기존 환경변수보다 우선), 키 존재 여부로 추적 활성/비활성을 판단한다. 키가 없어도 에이전트는 그대로 돌아가게 설계 — 관측은 부가 기능이라 없어도 본체는 동작해야 함.
3. 추적 켜기 — tracing 컨텍스트
그래프 실행을 추적 컨텍스트로 한 겹 감싸면, 그 안의 모든 단계가 LangSmith로 전송됨.
from langsmith import Client
from langchain_core.tracers.context import tracing_v2_enabled
# 클라이언트는 키가 있을 때만 생성
if langsmith_api_key:
self.langsmith_client = Client()
`Client()`는 환경변수에서 키를 자동으로 읽는다. 키가 없으면 아예 만들지 않고, 이후 분기에서 추적을 건너뛴다.
async def process_query(self, user_input: str) -> str:
messages = [HumanMessage(content=user_input)]
if self.langsmith_client:
with tracing_v2_enabled(project_name=langsmith_project): # 추적 ON
result = self.graph.invoke({"messages": messages})
else:
result = self.graph.invoke({"messages": messages}) # 추적 없이 그대로
...
핵심은 `tracing_v2_enabled` 한 줄. 이 with 블록 안에서 일어난 agent ↔ tools 순환, LLM 호출, 도구 실행이 전부 한 묶음(Run)으로 기록된다. 키가 없으면 같은 `invoke`를 추적 없이 호출하므로 동작은 동일함.
6편 그래프 코드는 한 줄도 안 바꿨다. 추적은 실행을 "감싸기"만 할 뿐, 그래프 내부 로직과 분리돼 있다. 관측 가능성(observability)을 끼워 넣을 때의 이상적인 모습.
4. 직접 계량하기 — 메트릭 헬퍼
LangSmith 대시보드와 별개로, 콘솔에서도 단계별 시간을 보고 싶었음. 간단한 메트릭 수집 클래스를 따로 둠.
class LangSmithMetrics:
def __init__(self):
self.start_time = None
self.metrics = {}
def start(self):
self.start_time = time.time()
self.metrics = {"start_time": datetime.now().isoformat(), "steps": []}
def log_step(self, step_name: str, duration: float, data: dict = None):
step_info = {
"name" : step_name,
"duration_ms" : duration * 1000,
"timestamp" : datetime.now().isoformat()
}
if data:
step_info["data"] = data
self.metrics["steps"].append(step_info)
def end(self):
total = time.time() - self.start_time
self.metrics["total_duration_ms"] = total * 1000
return self.metrics
`start`로 타이머를 켜고, 각 단계가 끝날 때 `log_step`으로 이름·소요시간·부가데이터를 쌓고, `end`로 전체 시간을 마감한다. LangSmith가 자동으로 잡는 것과 중복되지만, 콘솔에서 즉시 확인할 용도.
LLM 노드 안에서 호출 시간을 직접 재 log_step에 넘김.
def call_model(state: MessagesState) -> dict:
messages = state["messages"]
step_start = time.time()
response = llm_with_tools.invoke(messages)
step_duration = time.time() - step_start
self.metrics.log_step("llm_call", step_duration, {
"input_length" : len(str(messages)),
"output_length" : len(response.content) if hasattr(response, 'content') else 0,
"has_tool_calls" : bool(getattr(response, 'tool_calls', []))
})
return {"messages": [response]}
LLM 호출 직전·직후 시간을 빼 소요시간을 구하고, 입력 길이·출력 길이·도구 호출 여부를 함께 기록한다. `has_tool_calls`로 이 차례에 도구를 부르려 했는지까지 남김.
5. 처리 통계 출력
한 번의 쿼리가 끝나면, 누적한 메트릭을 사람이 읽기 좋은 트리 형태로 콘솔에 뿌림.
metrics = self.metrics.end()
print(" 처리 통계:")
print(f" ├─ 총 처리시간: {metrics['total_duration_ms']:.0f}ms")
print(f" ├─ 단계 수: {len(metrics['steps'])}")
for i, step in enumerate(metrics['steps'], 1):
print(f" ├─ [{i}] {step['name']}: {step['duration_ms']:.0f}ms")
print(f" └─ LangSmith: {'기록됨' if self.langsmith_client else '비활성화'}")
총 처리시간 → 단계 수 → 단계별 시간 순으로 출력. 어느 LLM 호출이 오래 걸렸는지 대시보드에 들어가지 않고도 바로 보인다. 마지막 줄에 LangSmith 기록 여부를 표시해, 추적이 켜졌는지 한눈에 확인.
에러도 메트릭에 남겨, 실패한 실행의 소요시간과 원인을 함께 보존함.
except Exception as e:
metrics = self.metrics.end()
metrics["error"] = str(e)
import traceback
traceback.print_exc()
return f" 처리 중 에러 발생: {str(e)}"
예외가 나도 `end()`로 타이머를 닫고 `error` 필드를 붙인다. "얼마나 돌다가 어디서 터졌나"가 함께 남아야 디버깅에 쓸모가 있음.
6. 실행 모드 — 데모·대화형·단일
추적을 켠 채로 세 가지 방식으로 돌려봄. 모든 모드의 실행이 동일하게 LangSmith에 기록됨.
async def run_demo(self):
demo_queries = [
"10과 5를 더해줘",
"현재 시간은?",
"메모 저장해줘. ID는 'demo_001', 내용은 'LangSmith 통합 성공!'",
"저장된 메모들을 보여줘",
"20과 30을 곱해줘",
]
for query in demo_queries:
await self.process_query(query)
await asyncio.sleep(0.5)
데모 모드는 미리 짠 쿼리들을 순서대로 흘려, ⑥편 MCP 도구(add·get_time·save_note·list_note)가 실제로 호출되는 흐름을 한 번에 추적으로 남긴다. 마지막 "곱하기"는 서버에 없는 도구라, LLM이 어떻게 반응하는지 관측 포인트가 됨.
async def interactive_mode(self):
while True:
user_input = input(" 프롬프트: ").strip()
if not user_input:
continue
if user_input.lower() in ['exit', 'quit', '종료']:
break
await self.process_query(user_input)
대화형 모드는 사용자가 직접 입력하며 매 턴을 추적으로 남긴다. `exit/quit/종료`로 빠져나옴. 단일 쿼리 모드는 한 번만 받아 처리하고 끝.
모드는 셋이지만 추적 경로는 하나(process_query)로 모인다. 어떤 방식으로 돌려도 같은 프로젝트에 기록이 쌓이므로, 데모와 실사용을 한 대시보드에서 비교할 수 있다.
요약
그래프 코드를 건드리지 않고 추적 컨텍스트로 감싸 LangSmith 관측을 붙였고, 콘솔용 메트릭 헬퍼로 단계별 시간까지 직접 계량했음.
| 항목 | 6편까지 | 7편 (LangSmith) |
| 흐름 파악 | print 로그로 짐작 | 단계별 자동 추적(Flow) |
| 시간 측정 | 없음 | 전체·LLM·Tool latency 분해 |
| 비용·품질 | 안 보임 | 토큰·호출 비용·성공률 지표 |
| 코드 변경 | — | 그래프 무수정, 실행만 감쌈 |
| 에러 추적 | 스택트레이스만 | 소요시간 + error 필드 보존 |
5편이 무한 루프·비용 폭탄을 iterations 상한으로 사전 차단했다면, 7편의 LangSmith는 그 비용을 사후에 숫자로 보여준다. 안전장치와 관측은 짝이다 — 막을 건 막고, 일어난 일은 본다. 이로써 1편 상태 그래프부터 6편 MCP 연계, 7편 관측까지 "동작하고 / 외부 도구를 쓰고 / 관측 가능한" 에이전트 한 벌이 완성됐다.
'SK플래닛 ai활용 데이터엔지니어 과정 2기 > ML & DL' 카테고리의 다른 글
| LangGraph 6 - MCP 도구 연계 (0) | 2026.06.05 |
|---|---|
| LangGraph 5 - 멀티 에이전트 협업 (0) | 2026.06.04 |
| LangGraph 4 - RAG 에이전트 (0) | 2026.06.02 |
| LangGraph 3 - 단기기억 (0) | 2026.06.02 |
| LangGraph 2 - Tool + LLM (0) | 2026.06.02 |