Skip to main content

보이지 않는 실행을 보이게 만들기

앞 장까지 우리는 LangChain 체인, RAG, 도구 호출, 에이전트, LangGraph 워크플로를 차례로 만들었습니다. 이제 애플리케이션은 제법 똑똑해졌습니다. 사용자의 질문을 받고, 문서를 검색하고, 프롬프트를 조립하고, 모델을 호출하고, 필요하면 도구를 실행하고, 여러 단계의 상태를 지나 최종 답변을 만듭니다.

문제는 바로 그 "여러 단계"에 있습니다.

단순한 API 호출이라면 요청과 응답 로그만 봐도 대략의 원인을 짐작할 수 있습니다. 하지만 LLM 애플리케이션은 다릅니다. 최종 답변이 이상하다고 해서 모델만 탓할 수 없습니다. 검색기가 엉뚱한 문서를 가져왔을 수도 있고, 프롬프트 변수 하나가 비어 있었을 수도 있으며, 출력 파서가 모델의 답변을 잘못 해석했을 수도 있습니다. 에이전트라면 아예 잘못된 도구를 골랐거나, 같은 도구를 반복 호출하며 비용을 태우고 있을 수도 있습니다.

LangSmith는 이 복잡한 실행 과정을 들여다보는 관측 창입니다. LangChain 팀의 문서에서는 LangSmith를 LLM 애플리케이션과 AI 에이전트를 빌드하고, 디버깅하고, 평가하고, 배포 흐름까지 관리하기 위한 플랫폼으로 설명합니다.1 이 장의 목표는 단순히 "LangSmith를 켜는 법"이 아닙니다. 체인과 에이전트의 내부 실행을 추적하고, 실패 원인을 좁히고, 평가셋으로 품질을 비교하고, 운영 환경에서 품질 저하를 조기에 발견하는 습관을 만드는 것입니다.

LangSmith로 디버깅·평가·관측 가능성 확보하기

가장 먼저 켜야 하는 세 가지 환경 변수

LangChain으로 만든 체인이나 에이전트는 LangSmith와 자연스럽게 연결됩니다. 기본 설정은 간단합니다. 계정을 만들고 API 키를 발급한 뒤, 다음 환경 변수를 설정하면 됩니다. 공식 문서 기준으로 LangChain 애플리케이션의 trace를 LangSmith에 남기려면 LANGSMITH_TRACING=true와 LANGSMITH_API_KEY를 설정합니다.2

export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY="<your-langsmith-api-key>"
export LANGSMITH_PROJECT="langchain-book-dev"

# 사용하는 모델 제공자에 맞게 설정합니다.
export OPENAI_API_KEY="<your-openai-api-key>"

LANGSMITH_PROJECT는 꼭 필요하지는 않지만 실무에서는 거의 반드시 설정하는 편이 좋습니다. 프로젝트는 trace를 묶는 단위입니다. 개발용, 스테이징용, 운영용을 한 프로젝트에 섞어 두면 나중에 분석이 어려워집니다.

예를 들어 다음처럼 나눌 수 있습니다.

  • rag-local-dev: 로컬에서 RAG 프롬프트와 검색 설정을 실험하는 프로젝트

  • customer-agent-staging: 배포 전 QA 환경에서 에이전트를 검증하는 프로젝트

  • customer-agent-prod: 실제 사용자 요청이 들어오는 운영 프로젝트

이전 자료나 오래된 예제에서는 LANGCHAIN_TRACING_V2, LANGCHAIN_API_KEY, LANGCHAIN_PROJECT 같은 이름을 볼 수 있습니다. 기존 코드베이스에서는 아직 이런 이름을 쓰고 있을 수 있으나, 새 프로젝트라면 LANGSMITH_ 접두사의 환경 변수로 맞추는 편이 좋습니다. 팀에서는 이 차이를 README에 명확히 적어 두어야 합니다. 관측 도구가 켜져 있다고 믿었는데 실제로는 환경 변수 이름이 달라 trace가 하나도 남지 않는 경우가 의외로 흔합니다.

LCEL 체인은 별도 코드 없이 추적됩니다

LangSmith 추적을 켜면 LangChain의 Runnable, LCEL 체인, 채팅 모델, 출력 파서, 검색기, 도구 호출이 자동으로 기록됩니다. 다음은 앞 장들에서 여러 번 보았던 가장 기본적인 LCEL 체인입니다.

from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser

load_dotenv()

prompt = ChatPromptTemplate.from_messages([
    ("system", "너는 친절한 기술 문서 요약가다. 답변은 간결하게 작성한다."),
    ("human", "다음 주제를 초보자에게 설명해 줘: {topic}"),
])

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
parser = StrOutputParser()

chain = prompt | model | parser

result = chain.invoke(
    {"topic": "LangChain의 LCEL"},
    config={
        "run_name": "explain_lcel",
        "tags": ["local", "chapter-14", "lcel"],
        "metadata": {
            "app_version": "0.1.0",
            "experiment": "baseline_prompt",
        },
    },
)

print(result)

이 코드를 실행하면 LangSmith 프로젝트에 하나의 trace가 생깁니다. config에 넣은 run_name, tags, metadata는 나중에 실행을 필터링하고 비교할 때 매우 유용합니다. LangSmith 문서에서도 RunnableConfig를 통해 trace에 임의의 태그와 메타데이터를 붙일 수 있다고 설명합니다.2

처음에는 태그가 번거로워 보일 수 있습니다. 하지만 운영 환경에서 "어제 배포한 프롬프트 v3 이후 오류율이 올랐나?", "특정 고객사의 요청만 느린가?", "모델을 바꾼 실험군이 실제로 비용을 줄였나?"를 묻기 시작하면 태그와 메타데이터가 곧 생명줄이 됩니다.

LangChain 밖의 코드도 같은 trace에 묶기

현실의 애플리케이션은 LangChain 코드만으로 끝나지 않습니다. 사용자 권한을 확인하기 위해 사내 API를 호출할 수도 있고, 검색 전에 데이터베이스에서 고객 설정을 읽을 수도 있으며, 결제 시스템이나 티켓 시스템에 요청을 보낼 수도 있습니다. 이 부분이 trace 밖에 있으면 실행의 절반만 보는 셈입니다.

이때 LangSmith SDK의 traceable 데코레이터를 사용할 수 있습니다. traceable로 감싼 함수는 입력, 출력, 실행 시간, 예외가 LangSmith에 기록됩니다. 그리고 그 안에서 LangChain 체인을 호출하면 하위 run으로 자연스럽게 붙습니다.

from langsmith import traceable
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_messages([
    ("system", "주어진 고객 등급 정책에 근거해서만 답변한다."),
    ("human", "질문: {question}nn정책:n{policy}"),
])

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
chain = prompt | model | StrOutputParser()

@traceable(
    name="load_customer_policy",
    tags=["external-api", "policy"],
)
def load_customer_policy(customer_id: str) -> str:
    # 실제 서비스라면 여기서 DB 또는 사내 API를 호출합니다.
    if customer_id.startswith("vip"):
        return "VIP 고객은 우선 지원과 무료 교환 혜택을 받을 수 있다."
    return "일반 고객은 구매일로부터 7일 이내 교환을 신청할 수 있다."

@traceable(
    name="answer_customer_question",
    tags=["customer-support", "rag-like"],
    metadata={"app_version": "0.1.0"},
)
def answer_customer_question(customer_id: str, question: str) -> str:
    policy = load_customer_policy(customer_id)
    return chain.invoke({
        "question": question,
        "policy": policy,
    })

answer = answer_customer_question(
    "vip-123",
    "교환 요청을 하면 얼마나 빨리 처리되나요?",
)
print(answer)

이렇게 하면 trace의 루트에는 answer_customer_question이 보이고, 그 아래에 load_customer_policy, 프롬프트 생성, 모델 호출, 파서 실행이 계층적으로 나타납니다. LangSmith의 빠른 시작 문서도 traceable 래퍼를 사용해 함수의 입력과 출력, 중첩된 LLM 호출을 하나의 trace로 볼 수 있다고 설명합니다.3

외부 API 호출이 있는 애플리케이션에서는 이 방식이 특히 중요합니다. 최종 답변이 틀렸을 때 모델이 틀린 것인지, 모델에 들어간 정책 데이터가 틀린 것인지 구분할 수 있기 때문입니다.

run tree로 실행을 읽는 법

LangSmith trace를 열면 단순한 로그 목록이 아니라 나무 구조에 가까운 실행 흐름을 보게 됩니다. 이를 run tree라고 생각하면 쉽습니다. 루트 run은 사용자의 하나의 요청입니다. 그 아래에 체인, 프롬프트, 모델, 검색기, 도구, 파서 같은 하위 run이 붙습니다.

예를 들어 RAG 체인 하나를 실행했다고 하겠습니다.

root: answer_with_rag
  ├─ retriever
  │   └─ vectorstore.similarity_search
  ├─ format_documents
  ├─ ChatPromptTemplate
  ├─ ChatOpenAI
  └─ StrOutputParser

이 구조를 볼 때는 위에서 아래로 훑는 것보다, "데이터가 어디에서 변했는가"를 따라가는 편이 좋습니다. 사용자의 질문이 retriever로 들어갔을 때 어떤 검색어 또는 임베딩 요청이 만들어졌는지 봅니다. 검색 결과가 몇 개였는지, 각 문서의 점수와 메타데이터가 무엇인지 봅니다. 프롬프트 단계에서는 실제로 모델에 들어간 메시지를 확인합니다. 모델 단계에서는 원출력과 토큰 사용량, 지연 시간을 봅니다. 파서 단계에서는 모델 출력이 애플리케이션에서 기대한 구조로 제대로 변환되었는지 확인합니다.

LangSmith의 trace는 요청의 입력부터 최종 출력까지 실행된 모든 단계를 기록하고, 각 단계의 입력, 출력, 지연 시간, 오류를 살펴볼 수 있게 해 줍니다.4 중요한 것은 최종 답변만 보지 않는 것입니다. LLM 애플리케이션 디버깅은 마지막 문장에 밑줄을 긋는 일이 아니라, 조립 라인의 각 공정을 검사하는 일에 가깝습니다.

RAG 디버깅: 답변보다 먼저 검색 결과를 봅니다

RAG에서 흔한 증상은 "답변이 그럴듯하지만 근거가 없다"입니다. 이때 초보자는 프롬프트를 먼저 고치려 합니다. 물론 프롬프트가 문제일 수도 있습니다. 하지만 RAG에서는 검색 결과를 먼저 봐야 합니다. 모델은 주어진 컨텍스트 안에서만 답하도록 지시받았는데, 애초에 컨텍스트가 비어 있거나 엉뚱하다면 좋은 답변이 나올 수 없습니다.

다음은 추적하기 좋은 형태로 RAG 체인을 구성한 예입니다.

from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI


def format_docs(docs):
    return "nn".join(
        f"[source={doc.metadata.get('source', 'unknown')}]n{doc.page_content}"
        for doc in docs
    )

prompt = ChatPromptTemplate.from_messages([
    ("system", "너는 사내 문서 기반 QA 봇이다. 컨텍스트에 없는 내용은 모른다고 답한다."),
    ("human", "질문: {question}nn컨텍스트:n{context}"),
])

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)

rag_chain = (
    {
        "question": RunnablePassthrough(),
        "context": retriever | format_docs,
    }
    | prompt
    | model
    | StrOutputParser()
)

answer = rag_chain.invoke(
    "휴가 신청은 며칠 전까지 해야 하나요?",
    config={
        "run_name": "hr_policy_rag",
        "tags": ["rag", "hr-policy"],
        "metadata": {"top_k": 4, "retriever": "chroma_similarity"},
    },
)

LangSmith에서 이 trace를 열면 최소한 다음을 확인해야 합니다.

  • retriever가 반환한 문서가 질문과 실제로 관련 있는가

  • 프롬프트에 삽입된 컨텍스트가 잘리지 않고 들어갔는가

  • 모델이 컨텍스트에 없는 내용을 추가로 지어내지 않았는가

  • 최종 답변에 출처를 표시해야 하는데 빠뜨리지는 않았는가

검색 결과가 비어 있다면 임베딩, 청크 크기, top_k, 필터 조건을 봐야 합니다. 검색 결과는 맞는데 답변이 틀렸다면 프롬프트와 모델 설정을 봐야 합니다. 답변은 맞는데 형식이 깨졌다면 출력 파서나 후처리 로직을 봐야 합니다. LangSmith는 이 세 문제를 한 화면에서 분리해 볼 수 있게 해 줍니다.

프롬프트 변수 누락과 출력 파싱 오류 찾기

LCEL 체인에서 자주 만나는 오류 중 하나는 프롬프트 변수 누락입니다. 예를 들어 프롬프트는 {context}를 요구하는데 체인 입력에는 question만 들어오는 식입니다. 이런 오류는 로컬 콘솔에서도 traceback으로 확인할 수 있지만, 복잡한 체인에서는 어느 Runnable에서 입력 형태가 바뀌었는지 찾기가 어렵습니다.

LangSmith trace에서는 각 단계의 입력과 출력을 볼 수 있으므로 "어느 단계까지는 context가 있었고, 어느 단계부터 사라졌는지"를 추적할 수 있습니다.

출력 파싱 오류도 마찬가지입니다. 모델에게 JSON을 요구했지만 실제로는 설명 문장을 앞에 붙여 버리는 경우를 생각해 보겠습니다.

from pydantic import BaseModel, Field
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

class ActionItem(BaseModel):
    owner: str = Field(description="담당자")
    task: str = Field(description="해야 할 일")
    due_date: str = Field(description="마감일. 알 수 없으면 unknown")

parser = PydanticOutputParser(pydantic_object=ActionItem)

prompt = ChatPromptTemplate.from_messages([
    ("system", "회의 메모에서 액션 아이템 하나를 추출한다.n{format_instructions}"),
    ("human", "회의 메모:n{memo}"),
]).partial(format_instructions=parser.get_format_instructions())

chain = prompt | ChatOpenAI(model="gpt-4o-mini", temperature=0) | parser

chain.invoke(
    {"memo": "민수는 금요일까지 결제 오류 로그를 확인하기로 했다."},
    config={"run_name": "parse_action_item"},
)

파서가 실패했다면 LangSmith에서 모델의 원출력을 먼저 봐야 합니다. 모델이 잘못된 JSON을 냈는지, JSON은 맞지만 스키마 필드명이 다른지, 혹은 파서가 기대하는 타입과 맞지 않는지 확인합니다. 최종 예외 메시지만 보면 "파싱 실패"지만, trace를 보면 "모델 출력 문제"인지 "스키마 설계 문제"인지가 드러납니다.

에이전트 디버깅: 도구 선택의 궤적을 봅니다

에이전트는 체인보다 디버깅이 더 어렵습니다. 체인은 대체로 정해진 길을 따라가지만, 에이전트는 중간에 어떤 도구를 쓸지 모델이 결정합니다. 그래서 최종 답변만 보면 에이전트가 왜 그런 결정을 했는지 알기 어렵습니다.

다음과 같은 고객 지원 에이전트를 생각해 보겠습니다.

from langchain.agents import create_agent


def search_policy(query: str) -> str:
    """고객 지원 정책 문서를 검색합니다."""
    return "교환은 구매 후 7일 이내 가능하며, VIP 고객은 우선 처리된다."


def create_refund_ticket(order_id: str, reason: str) -> str:
    """환불 티켓을 생성합니다. 실제 운영에서는 사용자 승인이 필요합니다."""
    return f"refund ticket created for {order_id}: {reason}"

agent = create_agent(
    model="gpt-4o-mini",
    tools=[search_policy, create_refund_ticket],
    system_prompt=(
        "너는 고객 지원 에이전트다. "
        "환불이나 교환 같은 쓰기 작업은 정책을 먼저 확인한 뒤 수행한다."
    ),
)

response = agent.invoke(
    {
        "messages": [
            {"role": "user", "content": "주문 A-123을 환불 처리해 줘. 사유는 단순 변심이야."}
        ]
    },
    config={
        "run_name": "support_agent_refund",
        "tags": ["agent", "refund", "staging"],
    },
)

LangSmith에서 이 실행을 보면 모델 호출, 도구 선택, 도구 인자, 도구 반환값, 최종 응답이 단계별로 기록됩니다. LangChain의 에이전트는 LangSmith tracing을 자동 지원하며, trace에는 초기 사용자 입력부터 최종 응답까지 모든 도구 호출, 모델 상호작용, 의사결정 지점이 포함됩니다.5

에이전트 디버깅에서는 다음 질문을 순서대로 던져야 합니다.

  • 사용자의 요청을 올바르게 이해했는가

  • 먼저 호출해야 할 도구를 건너뛰지 않았는가

  • 도구 인자를 정확한 스키마로 만들었는가

  • 쓰기 도구를 실행하기 전에 승인이나 권한 확인을 거쳤는가

  • 도구 결과를 최종 답변에 왜곡 없이 반영했는가

특히 쓰기 도구는 위험합니다. 이메일 발송, 환불 처리, DB 업데이트, 파일 삭제 같은 도구는 한번 실행되면 되돌리기 어렵습니다. LangSmith trace는 "왜 이 도구가 호출되었는가"를 사후 분석하는 데 도움이 되지만, 이것만으로 안전이 보장되는 것은 아닙니다. 앞 장에서 말했듯이 중요한 작업에는 사용자 승인, 권한 검사, 정책 확인 노드를 반드시 넣어야 합니다.

trace에 이름, 태그, 메타데이터를 붙이는 습관

처음 LangSmith를 켜면 모든 것이 신기해서 trace를 하나씩 열어 보게 됩니다. 하지만 프로젝트가 커지면 trace는 금세 수천, 수만 개가 됩니다. 이때 이름과 태그가 없으면 바다에서 바늘 찾기가 됩니다.

실무에서는 다음 정보를 가능한 한 일관되게 붙이는 것이 좋습니다.

  • run_name: 실행의 기능적 이름. 예를 들어 answer_with_rag, classify_intent, refund_agent

  • tags: 환경, 기능, 실험군. 예를 들어 prod, rag, prompt-v3, reranker-on

  • metadata: 사용자 ID 해시, 세션 ID, 앱 버전, 모델 별칭, 검색 설정, 배포 버전

예시는 다음과 같습니다.

response = rag_chain.invoke(
    "연차는 반차로 나눠 쓸 수 있나요?",
    config={
        "run_name": "answer_hr_question",
        "tags": ["prod", "rag", "hr", "prompt-v3"],
        "metadata": {
            "app_version": "2026.05.09-1",
            "user_tier": "internal_employee",
            "retriever_top_k": 5,
            "reranker": "bge-reranker-v2",
            "model_alias": "fast-answer-model",
        },
    },
)

개인정보는 그대로 넣지 않는 것이 원칙입니다. 이메일, 전화번호, 주민등록번호, 원문 고객 문의처럼 민감한 값은 마스킹하거나 해시 처리해야 합니다. 관측 가능성은 강력하지만, 잘못 쓰면 민감 정보의 복사본을 또 하나 만드는 일이 됩니다.

평가셋은 품질 개선의 기준선입니다

디버깅이 "한 번의 실패를 파헤치는 일"이라면, 평가는 "여러 입력에서 전반적으로 좋아졌는지 확인하는 일"입니다. 프롬프트를 조금 고쳤더니 특정 질문은 좋아졌지만 다른 질문은 나빠질 수 있습니다. 모델을 바꾸니 비용은 줄었지만 근거성이 떨어질 수 있습니다. 검색기의 top_k를 늘리니 정확도는 오르지만 지연 시간과 토큰 비용이 증가할 수 있습니다.

LangSmith는 개발 중에 curated dataset으로 오프라인 평가를 실행해 버전을 비교하고 회귀를 잡는 흐름과, 운영 트래픽에서 온라인 평가를 수행해 실시간 품질을 감시하는 흐름을 모두 지원합니다.6

평가셋은 대단한 것으로 시작할 필요가 없습니다. 실제로 자주 들어오는 질문 20개, 반드시 맞혀야 하는 정책 질문 20개, 이전에 장애를 일으킨 실패 사례 10개만 있어도 충분히 가치가 있습니다.

from langsmith import Client

client = Client()

dataset_name = "hr_policy_qa_regression"

try:
    dataset = client.read_dataset(dataset_name=dataset_name)
except Exception:
    dataset = client.create_dataset(
        dataset_name=dataset_name,
        description="HR 정책 RAG 회귀 테스트용 질문 모음",
    )

    client.create_example(
        dataset_id=dataset.id,
        inputs={"question": "연차는 반차로 사용할 수 있나요?"},
        outputs={"answer": "가능하다. 회사 정책에 따라 반차 단위로 사용할 수 있다."},
        metadata={"category": "leave", "must_pass": True},
    )

중요한 점은 평가셋을 정답 암기 시험처럼만 만들지 않는 것입니다. RAG라면 정답 문장뿐 아니라 기대 출처, 허용 가능한 답변 범위, "모른다고 답해야 하는 질문"도 넣어야 합니다. 에이전트라면 최종 답변뿐 아니라 기대 도구 호출도 평가 대상이 됩니다.

Experiment로 프롬프트, 모델, 검색 설정 비교하기

평가셋이 있으면 같은 입력에 대해 여러 버전의 애플리케이션을 반복 실행할 수 있습니다. LangSmith에서는 이런 실행 묶음을 experiment로 보고 비교합니다. 예를 들어 다음 실험을 나란히 비교할 수 있습니다.

  • 프롬프트 v1과 v2

  • gpt-4o-mini와 다른 모델

  • temperature 0과 0.3

  • retriever top_k=3과 top_k=8

  • reranker 적용 전과 적용 후

아래는 evaluate를 사용해 체인을 평가하는 단순 예시입니다.

from langsmith import evaluate


def target(inputs: dict) -> dict:
    question = inputs["question"]
    answer = rag_chain.invoke(
        question,
        config={
            "run_name": "hr_rag_eval_run",
            "tags": ["eval", "rag", "top-k-5", "prompt-v3"],
            "metadata": {"top_k": 5, "prompt_version": "v3"},
        },
    )
    return {"answer": answer}


def exact_keyword_evaluator(inputs: dict, outputs: dict, reference_outputs: dict) -> dict:
    expected = reference_outputs.get("answer", "")
    actual = outputs.get("answer", "")

    score = 1 if any(token in actual for token in expected.split()[:3]) else 0
    return {
        "key": "rough_correctness",
        "score": score,
        "comment": "기준 답변의 핵심 단어가 포함되는지 확인한 단순 평가",
    }

results = evaluate(
    target,
    data="hr_policy_qa_regression",
    evaluators=[exact_keyword_evaluator],
    experiment_prefix="hr-rag-prompt-v3-topk5",
    metadata={
        "prompt_version": "v3",
        "retriever_top_k": 5,
        "model": "gpt-4o-mini",
    },
)

이 예시는 일부러 단순한 규칙 기반 evaluator를 사용했습니다. 모든 평가를 LLM에게 맡길 필요는 없습니다. 형식 준수, 필수 필드 존재, JSON 파싱 가능 여부, 금지어 포함 여부, URL 개수, 응답 길이 같은 것은 규칙 기반 평가가 더 싸고 안정적입니다.

반대로 "이 답변이 질문에 충분히 답했는가", "검색된 근거와 모순되지 않는가", "고객에게 무례하지 않은가"처럼 의미 판단이 필요한 항목은 LLM-as-judge를 사용할 수 있습니다. 다만 LLM-as-judge도 틀릴 수 있으므로 중요한 평가 항목은 사람의 검토와 함께 보정하는 것이 좋습니다.

RAG 평가에서는 검색과 생성을 분리합니다

RAG 품질을 평가할 때 가장 흔한 실수는 최종 답변 점수만 보는 것입니다. 최종 답변이 틀렸다면 검색이 틀렸는지, 생성이 틀렸는지 분리해야 합니다.

검색 평가는 이런 질문을 봅니다. "정답을 만들기 위해 필요한 문서가 검색 결과 안에 있었는가?", "상위 3개 문서 중 관련 문서가 몇 개인가?", "검색 결과가 비어 있지는 않았는가?" 생성 평가는 이런 질문을 봅니다. "모델이 검색된 문서에 근거해 답했는가?", "문서에 없는 내용을 지어내지 않았는가?", "출처를 정확히 달았는가?"

이 둘을 분리하면 개선 방향이 명확해집니다. 검색 점수가 낮으면 청크 전략, 임베딩 모델, 메타데이터 필터, 하이브리드 검색, reranker를 봐야 합니다. 검색 점수는 높은데 생성 점수가 낮으면 프롬프트, 컨텍스트 압축 방식, 답변 형식, 모델을 봐야 합니다.

에이전트 평가는 최종 답변만으로 부족합니다

에이전트는 "맞는 답을 했는가"뿐 아니라 "맞는 방식으로 답했는가"도 중요합니다. 예를 들어 환불 요청을 처리하는 에이전트가 최종적으로 "환불 처리되었습니다"라고 답했다면 겉보기에는 성공입니다. 하지만 내부적으로 정책 조회 없이 바로 환불 도구를 호출했다면 위험한 성공입니다.

에이전트 평가는 trajectory, 즉 실행 궤적을 봐야 합니다. 어떤 메시지가 오갔는지, 어떤 도구를 어떤 순서로 호출했는지, 불필요한 도구를 호출하지 않았는지 평가합니다. LangSmith 관련 문서에서는 agent trajectory 평가를 통해 도구 호출 순서를 엄격히 맞추거나, 순서는 다르더라도 필요한 도구 집합이 호출되었는지 확인하는 방식 등을 소개합니다.7

예를 들어 다음과 같은 평가 기준을 둘 수 있습니다.

  • 환불 또는 결제 취소 전에는 반드시 search_policy를 호출해야 합니다.

  • 사용자 승인 전에는 create_refund_ticket을 호출하면 안 됩니다.

  • 단순 정보 질문에는 쓰기 도구를 호출하지 않아야 합니다.

  • 같은 검색 도구를 3회 이상 반복 호출하면 실패로 봅니다.

이런 기준은 LLM-as-judge보다 규칙 기반 trajectory 평가가 더 적합한 경우가 많습니다. 도구 호출 목록은 trace에 구조적으로 남기기 좋기 때문입니다. 반면 "도구 사용 전략이 합리적이었는가"처럼 더 넓은 판단은 LLM-as-judge와 사람 리뷰를 함께 쓰는 편이 좋습니다.

온라인 평가와 피드백 루프

오프라인 평가는 배포 전에 필요합니다. 하지만 배포 후에도 품질은 계속 변합니다. 사용자의 질문 분포가 달라지고, 문서가 업데이트되고, 외부 API 응답이 변하고, 모델 제공자의 동작이 미묘하게 바뀔 수 있습니다. 그래서 운영 환경에서는 온라인 평가와 피드백 루프가 필요합니다.

온라인 평가에서는 모든 요청을 다 평가하지 않아도 됩니다. 비용이 부담된다면 샘플링을 적용합니다. 예를 들어 운영 trace 중 5%만 LLM-as-judge로 평가하고, 오류가 발생한 trace나 사용자가 낮은 평점을 준 trace는 100% 검토 대상으로 보낼 수 있습니다. LangSmith는 운영 trace에 대해 online evaluator를 설정해 안전성 검사, 형식 검증, 품질 휴리스틱, reference-free LLM-as-judge를 실행하는 흐름을 제공합니다.6

좋은 피드백 루프는 다음처럼 돕니다.

운영 trace 수집
  → 낮은 점수 또는 사용자 불만 trace 선별
  → 원인 분석
  → 평가셋에 실패 사례 추가
  → 프롬프트·검색기·도구 정책 수정
  → 오프라인 experiment로 회귀 확인
  → 재배포

여기서 핵심은 실패 사례를 그냥 고치고 잊지 않는 것입니다. 반드시 평가셋에 넣어야 합니다. 그래야 같은 문제가 다음 배포에서 다시 살아나는 일을 막을 수 있습니다. LLM 애플리케이션의 평가셋은 살아 있는 버그 노트입니다.

운영에서 봐야 할 지표

LangSmith를 운영에 연결하면 trace를 개별적으로 보는 것에서 끝나면 안 됩니다. 제품 수준의 지표를 정의해야 합니다. 일반 웹 서비스에서 CPU, 메모리, p95 지연 시간, 오류율을 보듯이 LLM 애플리케이션에도 고유의 지표가 필요합니다.

대표적으로 다음을 볼 수 있습니다.

  • 토큰 사용량과 비용: 요청당 입력 토큰, 출력 토큰, 총 비용, 특정 기능의 비용 급증

  • 지연 시간: 전체 p50·p95·p99, 모델 호출 지연, 검색 지연, 도구 호출 지연

  • 오류율: 모델 API 오류, 파서 오류, 도구 예외, 타임아웃, 빈 응답

  • 검색 품질: 빈 검색 결과 비율, 낮은 관련도 점수 비율, 필터로 인한 검색 실패

  • 에이전트 행동: 반복 루프, 과도한 도구 호출, 금지 도구 호출, 승인 없는 쓰기 작업

LangSmith Observability 문서는 개별 trace부터 운영 전반의 성능 지표까지 LLM 애플리케이션의 가시성을 제공하며, 대시보드와 알림을 통해 문제를 조기에 발견하는 흐름을 지원한다고 설명합니다.8

예를 들어 RAG 고객지원 봇이라면 다음 알림이 유용합니다.

조건 1: p95 latency > 8초가 10분 이상 지속
조건 2: 빈 검색 결과 비율 > 15%
조건 3: JSON 파서 오류율 > 3%
조건 4: 요청당 평균 비용이 전날 대비 2배 증가
조건 5: 에이전트 도구 호출 평균 횟수 > 6회

이런 지표는 단순한 기술 운영 지표가 아닙니다. 사용자 경험과 직접 연결됩니다. 검색 결과가 비면 답변은 부실해지고, 파서 오류가 늘면 UI가 깨지고, 도구 호출이 반복되면 사용자는 답변을 기다리다 떠납니다.

샘플링과 개인정보 보호

운영 trace를 모두 저장하면 분석에는 좋지만 비용과 개인정보 문제가 생깁니다. 그래서 샘플링 전략이 필요합니다. 모든 요청을 100% 저장하는 대신, 중요한 요청과 실패 요청을 우선 저장할 수 있습니다.

실무에서는 보통 다음 원칙을 씁니다.

  • 개발과 스테이징은 trace를 넉넉히 남깁니다.

  • 운영은 기본 샘플링 비율을 낮추되 오류 trace는 반드시 남깁니다.

  • VIP 고객, 결제, 의료, 법률처럼 민감한 영역은 입력 마스킹을 먼저 적용합니다.

  • 장기 보관이 필요 없는 원문은 보존 기간을 짧게 둡니다.

LangSmith를 비롯한 관측 도구는 문제를 찾기 위해 많은 정보를 담습니다. 바로 그 점 때문에 보안 검토가 필요합니다. 프롬프트 안에 내부 정책 문서가 들어가고, 사용자 질문에 개인정보가 포함될 수 있으며, 도구 반환값에 사내 데이터가 있을 수 있습니다. trace를 남기기 전에 무엇을 남기고 무엇을 가릴지 팀의 기준을 세워야 합니다.

디버깅 절차를 팀의 습관으로 만들기

LangSmith를 도입했다고 해서 자동으로 품질이 좋아지지는 않습니다. trace를 읽고, 가설을 세우고, 실험하고, 평가셋에 반영하는 습관이 있어야 합니다. 다음 절차를 팀의 공통 디버깅 순서로 삼으면 좋습니다.

첫째, 실패한 trace를 하나 고릅니다. 사용자 불만, 낮은 평가 점수, 오류 로그, 비정상 비용 증가 등 출발점은 무엇이든 좋습니다.

둘째, run tree를 따라 입력부터 최종 출력까지 봅니다. 검색 결과, 실제 프롬프트, 모델 원출력, 파서 결과, 도구 인자와 반환값을 차례로 확인합니다.

셋째, 실패 원인을 한 문장으로 적습니다. "모델이 틀렸다"는 원인이 아닙니다. "휴가 정책 질문에서 retriever가 최신 문서 대신 2023년 문서를 반환했다"처럼 구체적이어야 합니다.

넷째, 수정 후보를 하나만 바꿉니다. 프롬프트, 모델, 검색 top_k, 필터, reranker, 파서 스키마를 한꺼번에 바꾸면 무엇이 효과가 있었는지 알 수 없습니다.

다섯째, 평가셋으로 회귀를 확인합니다. 문제 trace를 새 example로 추가하고, 기존 핵심 케이스가 나빠지지 않았는지 봅니다.

이 흐름이 반복되면 LangSmith는 단순한 로그 뷰어가 아니라 제품 품질을 누적 개선하는 실험실이 됩니다.

작은 예제: RAG 개선 실험을 끝까지 돌려 보기

마지막으로 하나의 현실적인 흐름을 짧게 정리해 보겠습니다. HR 정책 RAG 봇에서 사용자가 "배우자 출산휴가는 며칠인가요?"라고 물었는데, 봇이 틀린 답을 했다고 가정하겠습니다.

LangSmith trace를 열어 보니 retriever는 육아휴직 문서만 가져왔고 배우자 출산휴가 문서를 가져오지 못했습니다. 프롬프트와 모델 출력은 검색된 문서 안에서는 나름 일관적이었습니다. 따라서 원인은 생성이 아니라 검색입니다.

이제 수정 후보를 만듭니다. top_k를 3에서 6으로 늘리고, 메타데이터 필터에서 category="leave"만 허용하던 조건을 완화합니다. 그리고 reranker를 추가한 버전도 만듭니다.

실험은 세 개로 나눠 실행합니다.

experiment A: baseline, top_k=3, no reranker
experiment B: top_k=6, no reranker
experiment C: top_k=6, reranker enabled

각 실험에서는 최종 답변 점수만 보지 않습니다. 정답 문서가 검색 결과에 들어왔는지, 상위 몇 번째에 있었는지, 답변이 그 문서에 근거했는지, 지연 시간이 얼마나 늘었는지를 함께 봅니다. 만약 C가 가장 정확하지만 지연 시간이 너무 길다면, 모든 질문에 reranker를 쓰는 대신 검색 점수가 낮은 경우에만 조건부로 적용할 수 있습니다.

이것이 LangSmith를 쓰는 이유입니다. "뭔가 좋아진 것 같다"가 아니라, 어떤 변경이 어떤 질문군에서 어떤 비용으로 좋아졌는지 확인하기 위해서입니다.

이번 장의 핵심 정리

LangSmith는 LLM 애플리케이션의 블랙박스를 열어 주는 도구입니다. LCEL 체인, RAG, 도구 호출, 에이전트, LangGraph 워크플로가 내부에서 어떤 순서로 실행되었는지 trace로 확인할 수 있습니다. 디버깅할 때는 최종 답변만 보지 말고 검색 결과, 실제 프롬프트, 모델 원출력, 파서 결과, 도구 호출 궤적을 함께 봐야 합니다.

평가에서는 한 번의 성공보다 반복 가능한 기준선이 중요합니다. 평가셋을 만들고, experiment로 프롬프트와 모델과 검색 설정을 비교하고, 실패 사례를 다시 평가셋에 넣어 회귀를 막아야 합니다. 운영에서는 trace, 지표, 샘플링, 개인정보 보호를 함께 설계해야 합니다.

이제 우리는 LangChain 애플리케이션을 만들고, 복잡한 워크플로로 확장하고, 그 실행을 관찰하는 방법까지 갖추었습니다. 다음 장에서는 이 애플리케이션을 실제 서비스로 배포하고, 비용·속도·안정성을 관리하는 운영 관점으로 넘어갑니다.

참고

1LangSmith 문서

2LangSmith Tracing Quick Start

3LangSmith traceable 사용법

4LangSmith Observability

5LangSmith Agent observability

6LangSmith Evaluation

7LangSmith Agent Evaluation

8LangSmith Metrics and Monitoring

책 목록

LangChain 실전 활용법: LLM 앱부터 RAG·에이전트·운영까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기