LangChain

LLM 앱 운영 5종 세트 — 트레이싱·Async 콜백·LLM-as-judge·usage_metadata·캐싱

빌드 피벗(Build Pivot) 2026. 7. 1. 05:33
반응형
LANGCHAIN 실전 1.0 · 06
LangSmith로 관측·평가 — 트레이싱·콜백·평가·비용까지 운영 단계 손에 쥐기
환경변수 한 줄 트레이싱 · async 콜백 함정 · LLM-as-judge 평가 · usage_metadata 비용 추적 · 캐싱, 그리고 시리즈 완결
🟢 중급 ⏱️ 약 13분 🔧 LangChain 1.x 🗓️ 최종 검토 2026-06-21

TL;DR — 코드를 짜는 것과 운영하는 것은 다른 일입니다. LLM 앱은 입력이 같아도 출력이 흔들립니다. 호출마다 비용이 쌓이고 실패가 어느 단계에서 났는지 로그만으론 보이지 않습니다. LangChain 1.0은 이 운영 영역을 LangSmith로 표준화합니다. 트레이싱(LANGSMITH_TRACING=true · @traceable) · 콜백(동기/Async) · 평가(evaluate · LLM-as-judge) · 프롬프트 관리(pull_prompt) · 비용(usage_metadata · 캐싱)이 한 묶음입니다. 옛 langchain hub·langchain.evaluation은 classic으로 옮겨졌습니다.

이 글은 LangChain으로 만든 LLM 앱을 실제로 굴려야 하는 개발자운영 단계의 디버깅·품질·비용을 관측 가능한 형태로 다루는 방법을 이해하도록, 나아가 LangSmith 트레이싱·평가·비용 추적을 자기 코드에 붙일 기준을 얻도록 돕습니다.

이 글에서 다루는 것

  • 트레이싱: 환경변수 한 줄 + @traceable로 임의 함수 추적
  • 콜백: 동기 vs Async 핸들러 — 비동기 호출 시 함정
  • 평가: evaluate/aevaluate · LLM-as-judge / 휴리스틱 / pairwise
  • 프롬프트 관리(pull_prompt/push_prompt)와 토큰·비용·캐싱

이 글에서 다루지 않는 것

  • LangSmith 대시보드 UI 사용법·요금제 상세
  • LCEL·LangGraph·에이전트·RAG 본론 (앞선 1~5편에서 다룸)

1. 왜 관측·평가인가 — 운영은 코딩과 다른 일입니다

앞선 다섯 편으로 모델을 잡고(1편), 파이프로 잇고(2편), 상태를 가진 그래프를 그리고(3편), 에이전트를 세우고(4편), RAG로 외부 지식을 붙였습니다(5편). 여기까지는 동작하는 코드를 만드는 일입니다. 그런데 그 코드를 사람들이 실제로 쓰기 시작하면 성격이 다른 문제가 생깁니다.

LLM 앱의 운영이 일반 백엔드와 다른 지점은 세 가지입니다. 첫째, 출력이 비결정적입니다. 같은 입력에 다른 답이 나오니 버그와 그냥 다른 답을 구분하기 어렵습니다. 둘째, 호출마다 비용이 든다는 점입니다. 토큰 수가 곧 청구서입니다. 에이전트 한 번 돌면 LLM이 여러 번 호출됩니다. 셋째, 실패 지점이 깊다는 것입니다. 체인·검색·도구·에이전트가 겹친 호출에서 어느 단계가 잘못됐는지 텍스트 로그만으로는 거의 보이지 않습니다.

비유하면 LLM 앱 운영은 비행기 블랙박스와 같습니다. 무사히 착륙했을 때는 필요 없어 보이지만, 무언가 어긋났을 때 어느 고도에서 어떤 입력으로 무슨 판단을 했는지 되짚을 기록이 없으면 원인을 찾을 길이 없습니다. LangSmith는 그 블랙박스입니다 — 트레이싱은 비행 기록, 평가는 정기 점검, 비용 추적은 연료 계기판에 해당합니다. 이 글은 그 세 계기를 코드에 다는 방법입니다.

🔍 디버깅
겹친 호출의 어느 단계가 느리고 틀렸는지 단계별로 추적
📊 품질
데이터셋에 대해 점수화 — 프롬프트·모델 변경의 회귀 측정
💰 비용
호출별 토큰·비용을 추적하고 캐싱으로 중복 호출 절감
그림 1. 관측·평가가 맡는 세 가지 운영 과제

2. 트레이싱 — 환경변수 한 줄로 켜집니다

트레이싱의 장점은 코드를 거의 안 고쳐도 된다는 데 있습니다. 환경변수만 켜면 이후의 모든 체인·에이전트·검색·도구 호출이 자동으로 추적됩니다.

환경

  • 언어/런타임: Python 3.10 이상(1.x 요구)
  • 핵심: langchain · langchain-core · langsmith · 파트너 패키지(예: langchain-openai)
  • 선택: langchain-community(SQLiteCache 등)
  • 키: LangSmith API 키(트레이싱 전송용)
PYTHON · 자동 트레이싱 + @traceable
import os os.environ["LANGSMITH_TRACING"] = "true" # 구 LANGCHAIN_TRACING_V2도 호환 os.environ["LANGSMITH_API_KEY"] = "ls__..." # 이후 모든 체인/에이전트/검색/도구 호출이 자동 추적(비용 포함) from langsmith import traceable @traceable def my_step(x): # LangChain이 아닌 임의 함수도 추적 ...

환경변수 두 줄이면 LangChain 컴포넌트 호출은 손대지 않아도 추적됩니다. 그렇게 쓴 이유는 트레이싱이 켜고 끄는 스위치여야 운영·개발 환경을 코드 변경 없이 오갈 수 있기 때문입니다. 다만 LangChain 바깥의 순수 함수(전처리·후처리·외부 API 호출 등)는 자동으로 잡히지 않으므로, 그 부분에 @traceable을 붙여 같은 트레이스에 묶습니다. 이렇게 하면 LLM 호출과 내 코드가 한 타임라인에서 보입니다.

📌 참고LANGSMITH_TRACING은 예전 LANGCHAIN_TRACING_V2 이름과 호환됩니다. 옛 자료가 후자를 쓰더라도 동작은 같으니, 새 코드는 현행 이름을 쓰는 편이 헷갈리지 않습니다.

3. 콜백 — 트레이싱·스트리밍의 기반, 그리고 Async 함정

트레이싱과 토큰 스트리밍이 실제로 동작하는 바탕에는 콜백(callback)이 있습니다. 콜백은 실행 도중 일어나는 사건(LLM 새 토큰·체인 시작·도구 호출·검색 시작 등)에 끼어들어 무언가를 하는 후크입니다. 직접 핸들러를 만들어 로깅·모니터링·UI 업데이트에 연결할 수 있습니다.

여기서 1.0 운영에서 가장 자주 밟는 함정이 하나 있습니다. 핸들러에는 동기용비동기용 두 종류가 있습니다. 호출 방식과 핸들러 종류가 어긋나면 이벤트 루프가 막혀 성능이 무너집니다. 두 갈래를 나란히 보겠습니다.

🟢 동기 — BaseCallbackHandler
언제invoke / stream 등 동기 호출.
class H(BaseCallbackHandler):
  def on_llm_new_token(self, tok, **kw): ...
🔴 비동기 — AsyncCallbackHandler
언제ainvoke / astream 등 비동기 호출 → 필수.
class H(AsyncCallbackHandler):
  async def on_llm_new_token(self, tok, **kw): ...
⚠️ 함정
비동기 호출(ainvoke·astream)에 동기 핸들러를 끼우면 핸들러 안의 무거운 작업이 이벤트 루프를 차단합니다. 비동기 경로에는 반드시 AsyncCallbackHandler(async def 훅)를 써야 비차단으로 동작합니다.
그림 2. 호출 방식별 핸들러 선택 — 비동기 경로엔 Async 핸들러 필수
PYTHON · 콜백 주입과 주요 훅
from langchain_core.callbacks import AsyncCallbackHandler class MyHandler(AsyncCallbackHandler): async def on_llm_new_token(self, token, **kw): print(token, end="") # on_chain_* / on_tool_* / on_retriever_* 훅도 동일 # 주입: config 인자로 핸들러 리스트 전달 await chain.ainvoke(x, config={"callbacks": [MyHandler()]})

핸들러는 config={"callbacks": [handler]}로 주입합니다. 제공되는 훅은 토큰 단위(on_llm_new_token)부터 체인·도구·검색 생애주기(on_chain_* · on_tool_* · on_retriever_*)까지 있어, 내가 원하는 지점만 골라 가로챌 수 있습니다. 표준 훅 바깥의 사건을 흘리고 싶다면 dispatch_custom_event로 커스텀 이벤트를 발행합니다.

4. 평가 — "다른 답"과 "틀린 답"을 가르는 잣대

출력이 비결정적이라는 LLM 앱의 특성은, 프롬프트나 모델을 바꿨을 때 좋아졌는지 나빠졌는지를 감으로 판단하게 만듭니다. 평가(evaluation)는 이걸 데이터셋 기반 점수로 바꿉니다. 미리 만든 예시 묶음에 대해 내 함수를 돌린 뒤 각 출력을 평가자(evaluator)로 채점하는 오프라인 방식입니다.

PYTHON · 오프라인 평가
from langsmith import evaluate, Client evaluate( target_fn, # 평가 대상(내 체인/에이전트 호출) data="my-dataset", # 미리 만든 예시 데이터셋 evaluators=[correctness, relevance], # 휴리스틱 / LLM-as-judge / pairwise / human ) # 비동기 평가는 aevaluate(...)

핵심은 evaluate(target, data, evaluators=[...]) 한 줄입니다. 비동기 파이프라인이라면 aevaluate를 씁니다. 이렇게 데이터셋을 고정해두면 프롬프트·모델·검색 설정을 바꿀 때마다 같은 잣대로 회귀를 잴 수 있어, 변경이 개선인지 후퇴인지를 수치로 말할 수 있습니다. 평가자는 네 갈래로 나뉩니다.

평가자 유형 방식 언제 쓰나
휴리스틱규칙·문자열 비교 등 코드 기반정답이 명확·자동 채점 가능할 때
LLM-as-judgeLLM이 출력을 채점정답이 모호·서술형 품질을 볼 때
pairwise두 출력(A/B) 중 우위 판정프롬프트·모델 두 버전 비교
human사람이 직접 채점기준 보정·자동 평가 검증

LLM-as-judge는 강력하지만 채점자 자체가 LLM이라 비용과 편향이 생깁니다. 그래서 자동 채점이 가능한 항목은 휴리스틱으로 두고 서술형 품질에만 LLM-as-judge를 씁니다. 기준이 흔들릴 땐 human으로 보정하는 식으로 섞는 것이 현실적입니다. 이 평가 흐름은 이 저장소의 ch19 Evaluation 패턴(응답·궤적·세션 레벨, LLM-as-Judge, 드리프트 탐지)과 곧장 이어집니다.

5. 프롬프트 관리 — 코드에 박지 말고 버전으로

프롬프트를 코드 문자열에 박아두면, 문구 한 줄 바꾸는 데도 배포가 필요하고 누가 언제 무엇을 고쳤는지 추적되지 않습니다. LangSmith의 프롬프트 관리는 프롬프트를 버전이 붙은 리소스로 끌어와 쓰게 합니다.

PYTHON · 프롬프트 pull / push
from langsmith import Client client = Client() prompt = client.pull_prompt("my-prompt") # 버전 관리된 프롬프트 (구 langchain hub 대체) # client.push_prompt("my-prompt", object=prompt) # 새 버전 등록

pull_prompt로 저장소의 프롬프트를 가져온 뒤 push_prompt로 새 버전을 올립니다. 이렇게 분리해두면 프롬프트 개선을 코드 배포와 떼어 빠르게 반복하면서도 버전 이력을 남길 수 있습니다. 다만 이 부분은 옛 자료와 import 경로가 크게 달라진 지점이라 주의가 필요합니다.

🕰️ 레거시 — 옛 자료의 from langchain import hub(hub.pull)과 langchain.evaluation(QAEvalChain · load_evaluator)은 langchain-classic으로 이동했습니다. 현행은 LangSmith의 Client.pull_promptlangsmith.evaluate입니다. 옛 코드가 hub.pull(...)로 프롬프트를 받아왔다면 이 경로 교체가 먼저입니다.

6. 토큰·비용 — 청구서를 코드에서 들여다보기

호출당 비용을 가늠하려면 토큰을 세야 합니다. 1.0에서 호출별 토큰은 응답 메시지 자체에 들어 있습니다 — AIMessage.usage_metadata입니다.

PYTHON · 호출별 토큰 + 집계 핸들러
from langchain_core.callbacks import UsageMetadataCallbackHandler msg = model.invoke("안녕하세요") print(msg.usage_metadata) # {'input_tokens':.., 'output_tokens':.., 'total_tokens':..} # 여러 호출을 누적 집계하려면 핸들러를 붙인다 cb = UsageMetadataCallbackHandler() model.invoke("...", config={"callbacks": [cb]})

usage_metadata에는 input_tokens · output_tokens · total_tokens가 들어 있어 호출 하나의 비용을 바로 가늠할 수 있습니다. 에이전트처럼 한 번 실행에 LLM이 여러 번 불리는 경우 호출마다 따로 보면 합산이 번거로우므로, UsageMetadataCallbackHandler를 콜백으로 붙여 누적합을 모읍니다 — 집계 결과는 cb.usage_metadata(모델ID별 input/output/total 합계)로 읽고, with get_usage_metadata_callback() as cb: 컨텍스트 매니저 형태도 동일하게 쓸 수 있습니다. 트레이싱을 켜뒀다면 LangSmith가 비용을 함께 기록하므로, 코드 레벨 집계와 대시보드 집계를 같이 쓸 수 있습니다.

7. 캐싱 — 같은 입력에 두 번 돈 내지 않기

비용을 가장 직접적으로 줄이는 방법은 중복 호출을 피하는 것입니다. 같은 입력에 같은 답이 나올 호출이라면, 결과를 캐시해 두 번째부터는 모델을 부르지 않습니다. 1.0에서는 전역 캐시를 한 번 설정하면 됩니다.

PYTHON · 전역 LLM 캐시 (영속)
from langchain.globals import set_llm_cache from langchain_community.cache import SQLiteCache set_llm_cache(SQLiteCache(database_path=".langchain.db")) # 영속 (InMemoryCache는 세션 한정)

set_llm_cacheSQLiteCache를 넘기면 캐시가 파일로 영속화되어 프로세스를 재시작해도 살아남습니다. 세션 동안만 필요하면 InMemoryCache를 씁니다. 한 가지 전제는, 캐시는 입력이 완전히 같을 때만 적중하므로 동적 컨텍스트가 많이 섞이는 호출에는 효과가 제한적이라는 점입니다.

한 단계 더 들어가면 프로바이더 네이티브 프롬프트 캐싱이 있습니다. 4편에서 본 미들웨어로 붙이는데, AnthropicPromptCachingMiddleware · BedrockPromptCachingMiddleware로 안정적인 시스템 프롬프트·도구 정의 부분을 모델 측에서 캐싱합니다. 전역 LLM 캐시가 응답 전체를 재사용한다면 프롬프트 캐싱은 반복되는 입력 앞부분을 재사용하는 결이라 둘은 보완 관계입니다.

시리즈: 01 시작하기 → 02 LCEL & Runnable → 03 LangGraph → 04 create_agent → 05 현대 RAG → 06 LangSmith 관측·평가 · 완결

8. 정리 · 시리즈 완결

관측·평가는 화려한 기능은 아니지만 코드를 돌아가는 것에서 운영할 수 있는 것으로 바꾸는 마지막 한 겹입니다. 운영 단계에서 손에 쥐어야 할 다섯 가지를 체크리스트로 정리합니다.

✅ LangSmith 운영 체크리스트
트레이싱을 LANGSMITH_TRACING=true로 켜고 내 함수엔 @traceable을 붙였는가
비동기 호출 경로에 AsyncCallbackHandler를 썼는가 (동기 핸들러 ❌)
프롬프트·모델 변경을 evaluate(데이터셋)로 회귀 측정하는가
프롬프트를 코드에 박지 않고 pull_prompt로 버전 관리하는가 (hub.pull ❌)
비용을 usage_metadata로 보고 set_llm_cache로 중복 호출을 줄이는가
🧭 LangChain 실전 1.0 — 6편 회고
01시작하기 — 1.0은 패키지 4계층 + langchain-classic. 옛 ImportError의 정체와 init_chat_model·with_structured_output 진입점.
02LCEL & Runnable — 파이프(|) 하나로 모델·프롬프트·파서를 잇는 표준 인터페이스(invoke/batch/stream).
03LangGraph — 상태·영속성(checkpointer+thread_id)·스트리밍·HITL로 순환과 분기를 다루는 런타임.
04create_agent — LangGraph 위에 선 1.0 에이전트 표준. bind_tools→tool_calls→ToolMessage와 미들웨어(HITL/Summarization/PII).
05현대 RAG — 로드→분할→임베딩→검색(하이브리드·Rerank)→2-step LCEL vs Agentic RAG로 외부 지식 연결.
06LangSmith — 트레이싱·콜백·평가·프롬프트·비용·캐싱으로 운영을 관측 가능하게. 코드를 운영 가능한 것으로 마감.
★ 마치며

여섯 편을 돌아보면, 이 시리즈는 LangChain 1.0이라는 이사를 끝낸 집을 방마다 둘러본 여정이었습니다. 1편에서 집 구조(패키지 4계층)를 파악했습니다. 2편에서는 배관 역할의 파이프(LCEL)를 익혔고 3편에서 상태를 가진 그래프(LangGraph)를, 4편에서 그 위에 선 에이전트(create_agent)를, 5편에서 외부 지식을 끌어오는 RAG를 다뤘습니다. 그리고 이번 6편은 그 집에 블랙박스와 계기판(LangSmith)을 달아, 만든 것을 안심하고 운영할 수 있게 마감했습니다.

처음 목표였던 내가 보는 코드가 현행인지 레거시인지 구분하는 눈은 이제 현행 코드를 직접 짜고 운영하는 손으로 바뀌었을 것이라 생각합니다. 옛 자료를 만나도 어느 방으로 옮겨졌는지 알 수 있고 새 코드를 1.0 기준으로 세울 수 있다면 이 시리즈의 역할은 다한 셈입니다. 여기까지 함께 읽어 주셔서 고맙습니다.

#LangChain #LangSmith #트레이싱 #LLM평가 #usage_metadata #LLMOps #생성형AI #기술블로그
📚 참고 자료 · 공식 문서
본문 버전·API는 공식 문서 기반이며 LangChain API는 버전에 따라 변동될 수 있습니다(검증: LangChain 1.x, 2026-06).
안내 · 본 글은 LangChain 공식 문서·저장소를 기준으로 정리한 교육용 콘텐츠입니다. 코드는 개념 설명용 예시이며 LangChain API는 버전에 따라 달라질 수 있습니다.
반응형