Small Asteroid Blog

Langfuse의 Span과 Generation의 관계 본문

AI

Langfuse의 Span과 Generation의 관계

작은소행성☄️ 2025. 8. 9. 23:54
728x90

Langfuse를 사용하다가 "왜 LLM 호출할 때마다 Generation이 자동으로 생성되지?" "Span과 Generation의 차이가 뭐지?" 같은 궁금증이 생겼습니다. 

 

 

Langfuse 기본 개념 이해하기

Trace, Span, Generation의 관계

Trace (전체 LLM 애플리케이션 요청)
├── Span (입력 검증)
├── Span (비즈니스 로직)
│   └── Generation (LLM 호출) 
└── Span (결과 후처리)

각각의 역할

  • Trace: 전체 요청의 메타데이터 저장소 (user_id, session_id, 전체 비용 등)
  • Span: 일반적인 작업 단위 (함수나 메서드 단위의 작업)
  • Generation: LLM 호출에 특화된 Span (토큰, 비용, 모델 파라미터 등 자동 추적)

 

"LLM 호출하면 자동으로 Generation이 생성되나요?"

결론부터 말하면: 아니요!

일반 OpenAI 사용 시

import openai

# 이렇게 호출해도 Langfuse는 아무것도 모릅니다
response = openai.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": "안녕하세요"}]
)
# → Langfuse에 아무것도 기록되지 않음

Langfuse OpenAI 래퍼 사용 시

from langfuse.openai import OpenAI  # 핵심은 여기!

client = OpenAI(api_key="your-key")

# 이제 자동으로 Generation이 생성됩니다
response = client.chat.completions.create(
    model="gpt-4", 
    messages=[{"role": "user", "content": "안녕하세요"}]
)
# → 자동으로 Langfuse에 Generation 기록됨

핵심: langfuse.openai.OpenAI는 일반 OpenAI 클라이언트를 래핑하여 자동으로 Generation을 생성하는 특별한 클라이언트입니다.

 

실제 코드로 보는 Span과 Generation의 관계

실제 프로덕션 코드를 통해 왜 Span 밑에 Generation이 생성되는지 살펴보겠습니다:

class LLMCaller:
    def __init__(self, service_name: ServiceName):
        # 핵심: langfuse.openai 사용
        self.client = OpenAI(api_key=api_key)
        self.langfuse_client = get_langfuse_client(service_name)

    def call(self, prompt_name: str, input_vars: dict, **kwargs):
        # 1. 수동으로 Span 생성
        with self.langfuse_client.start_as_current_span(
            name="llm_business_logic", 
            input=input_vars
        ) as root_span:
            
            # 2. Trace 메타데이터 설정
            root_span.update_trace(
                user_id=kwargs.get("user_id"),
                session_id=kwargs.get("session_id"),
                tags=kwargs.get("tags")
            )
            
            # 3. LLM 호출 - 여기서 자동으로 Generation 생성!
            response = self.client.chat.completions.create(**params)
            #          ↑ langfuse.openai.OpenAI가 현재 활성 span을 감지하고
            #            그 span의 child로 generation을 자동 생성
            
            # 4. 결과 처리
            result = self.process_response(response)
            root_span.update_trace(output={"result": result})
            
            return result

실행 흐름 상세 분석

# 사용자 호출
result = llm_caller.call(
    prompt_name="product_analysis",
    input_vars={"product": "iPhone"},
    user_id="user123",
    session_id="session456"
)

 

생성되는 구조

Trace: product_analysis
├── user_id: "user123"           ← Span을 통해 설정
├── session_id: "session456"     ← Span을 통해 설정  
├── input: {"product": "iPhone"} ← Span에서 설정
└── output: {"result": "..."}    ← Span에서 설정

└── Span: llm_business_logic (150ms)
    ├── 전체 비즈니스 로직 시간 추적
    └── Generation: product_analysis (80ms)  ← 자동 생성
        ├── model: "gpt-4"
        ├── prompt_tokens: 120
        ├── completion_tokens: 80
        ├── cost: 0.004
        └── duration: 80ms

 

왜 Span을 추가로 만드는가?

"Generation만으로도 충분한데 왜 Span을 만들어?" 라는 의문이 들 수 있습니다.

1. Trace 메타데이터 설정

Langfuse에서는 Generation만으로는 Trace 레벨 정보를 설정할 수 없습니다

# ❌ Generation만으로는 이런 정보들을 설정할 수 없음
user_id = "user123"
session_id = "session456" 
tags = ["ecommerce", "analysis"]

# ✅ Span을 통해야 Trace 정보 설정 가능
with span as root_span:
    root_span.update_trace(
        user_id=user_id,
        session_id=session_id, 
        tags=tags
    )

 

2. 전체 비즈니스 로직 추적

# Span이 감싸는 전체 작업
with span as root_span:
    ├── 1. 프롬프트 가져오기 (Langfuse에서)
    ├── 2. 파라미터 검증 및 빌드  
    ├── 3. LLM 호출              ← Generation 자동 생성
    ├── 4. 응답 후처리 (JSON 파싱)
    └── 5. 최종 결과 설정

Span 없이는 LLM 호출 시간만 알 수 있고, 전체 로직의 성능은 추적할 수 없습니다.

 

3. Context 관리

# 이 Context 내에서 일어나는 모든 Langfuse 작업은
with self.langfuse_client.start_as_current_span(...) as span:
    
    # 이 span의 하위 요소가 됩니다
    response = self.client.chat.completions.create(...)
    # ↑ Generation이 span의 child가 되는 이유

 

Span 정보가 적어 보이는 이유

"Span은 Trace나 Generation 대비 정보가 별로 없는데?" 라는 의문이 들 수도 있습니다.

현재 코드에서의 Span 역할

# 현재: Span이 단순 wrapper 역할
Span {
    name: "business_logic",
    duration: 150ms,
    status: "success"
    // 실제 정보는 별로 없음...
}

# 대신 Trace와 Generation에 모든 정보가 집중
Trace { user_id, session_id, input, output, ... }
Generation { model, tokens, cost, ... }

 

더 복잡한 시나리오에서의 Span 활용

실제로는 이렇게 여러 단계로 나누어 사용할 때 Span이 빛을 발합니다:

def complex_product_analysis(self, product_data):
    with self.langfuse_client.trace(name="product_analysis") as trace:
        
        # 각 단계별로 의미있는 Span들
        with trace.span(name="data_validation") as validation_span:
            validated_data = self.validate(product_data)
            validation_span.update(
                input=product_data,
                output=validated_data,
                metadata={"validation_rules": 5}
            )
        
        with trace.span(name="market_research") as research_span:
            market_data = self.get_market_data()
            research_span.update(
                output=market_data,
                metadata={"api_calls": 3, "cache_hit": True}
            )
        
        with trace.span(name="llm_analysis") as llm_span:
            # 여기서 Generation 자동 생성
            analysis = self.llm_call(validated_data, market_data)
            llm_span.update(metadata={"prompt_version": "v2.1"})
        
        with trace.span(name="result_formatting") as format_span:
            final_result = self.format_result(analysis)
            format_span.update(
                output=final_result,
                metadata={"format_type": "json"}
            )

 

결과

Trace: product_analysis (500ms, $0.02)
├── Span: data_validation (50ms)
│   ├── validation_rules: 5
│   └── input/output 정보
├── Span: market_research (200ms) 
│   ├── api_calls: 3
│   ├── cache_hit: true
│   └── market_data 정보
├── Span: llm_analysis (200ms)
│   ├── prompt_version: "v2.1"
│   └── Generation: gpt4_call (150ms, $0.015)
│       ├── model: "gpt-4"
│       ├── tokens: 800
│       └── LLM 상세 정보
└── Span: result_formatting (50ms)
    ├── format_type: "json"
    └── 포맷팅 결과

 

언제 무엇을 사용할까?

Generation을 사용하는 경우

  • LLM 호출을 추적할 때
  • 토큰 사용량과 비용을 자동으로 계산하고 싶을 때
  • Langfuse의 LLM 특화 대시보드 기능을 활용하고 싶을 때

Span을 사용하는 경우

  • 일반적인 비즈니스 로직을 추적할 때
  • 여러 단계의 워크플로우를 구조화하고 싶을 때
  • 커스텀 메타데이터가 필요할 때

권장 패턴

def process_user_request(user_input):
    # 전체 요청을 trace로 감싸기
    trace = langfuse.trace(name="user_request_processing")
    
    # 각 단계별로 span 생성
    with trace.span(name="input_validation") as span:
        validated_input = validate(user_input)
        span.update(output=validated_input)
    
    with trace.span(name="business_logic") as span:
        # LLM 호출은 자동으로 Generation 생성
        result = llm_caller.call(prompt="analyze", input=validated_input)
        span.update(metadata={"processing_steps": 3})
    
    with trace.span(name="response_formatting") as span:
        formatted_result = format_response(result)
        span.update(output=formatted_result)
    
    return formatted_result

자동화 옵션들

매번 수동으로 Generation을 만들기 번거롭다면 이런 방법들을 사용할 수 있습니다:

1. Langfuse OpenAI 통합

from langfuse.openai import openai

# 이후 일반 openai처럼 사용하면 자동 추적
response = openai.chat.completions.create(...)

2. 데코레이터 사용

from langfuse.decorators import observe

@observe()
def my_llm_function(prompt):
    return openai.chat.completions.create(...)

3. LangChain 통합

from langfuse.callback import CallbackHandler

handler = CallbackHandler()
llm = ChatOpenAI(callbacks=[handler])

 

결론

Langfuse의 Span과 Generation은 처음에는 복잡해 보이지만, 각각의 역할을 이해하면 매우 강력한 도구입니다.

  • Trace: 전체 요청의 메타데이터와 컨텍스트
  • Span: 비즈니스 로직의 구조화된 추적
  • Generation: LLM 호출의 자동화된 상세 추적

핵심은 langfuse.openai 래퍼를 사용하면 자동으로 Generation이 생성되고, 적절한 Span 구조를 만들면 전체 애플리케이션의 성능과 비용을 효과적으로 모니터링할 수 있다는 점입니다.

 

728x90
반응형