본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 25. · 2 Views
프롬프트 체이닝과 분해로 복잡한 문제 해결하기
LLM을 활용한 복잡한 작업을 단계별로 분해하고 체이닝하는 방법을 배웁니다. 실무에서 바로 적용 가능한 프롬프트 설계 전략과 오류 처리 기법을 다룹니다.
목차
1. 복잡한 태스크 분해
어느 날 최데이터 씨는 상사로부터 난감한 요청을 받았습니다. "고객 리뷰 데이터를 분석해서 감정 분류하고, 주요 키워드 뽑고, 개선 제안까지 한 번에 정리해줘요." LLM에게 이 모든 걸 한 번에 요청했더니 결과가 엉망이었습니다.
태스크 분해는 복잡한 작업을 작고 명확한 단계로 쪼개는 기법입니다. 마치 큰 프로젝트를 스프린트로 나누는 것처럼, LLM 작업도 단계별로 나누면 각 단계의 품질이 높아집니다.
한 번에 모든 것을 요구하면 LLM은 어느 것도 제대로 수행하지 못하는 경우가 많습니다.
다음 코드를 살펴봅시다.
# 잘못된 방식: 한 번에 모든 것을 요청
bad_prompt = """
고객 리뷰를 분석해서 감정 분류, 키워드 추출, 개선 제안을 해줘:
{review_text}
"""
# 올바른 방식: 단계별로 분해
step1_prompt = "다음 리뷰의 감정을 긍정/부정/중립로 분류해줘: {review}"
step2_prompt = "이 리뷰에서 주요 키워드 3개를 추출해줘: {review}"
step3_prompt = "감정({sentiment})과 키워드({keywords})를 바탕으로 개선 제안을 작성해줘"
# 각 단계를 순차적으로 실행
sentiment = llm.generate(step1_prompt.format(review=review_text))
keywords = llm.generate(step2_prompt.format(review=review_text))
suggestions = llm.generate(step3_prompt.format(
sentiment=sentiment,
keywords=keywords
))
최데이터 씨는 입사 6개월 차 데이터 분석가입니다. 최근 LLM을 업무에 도입하기 시작했는데, 기대만큼 결과가 나오지 않아 고민이 많았습니다.
"이상하네요. GPT-4가 이렇게 똑똑하다고 하던데, 제 요청은 제대로 처리를 못 하네요." 최데이터 씨는 고개를 갸우뚱했습니다.
선배 개발자 김프롬프트 씨가 모니터를 들여다봅니다. "아, 여기가 문제네요.
한 번에 너무 많은 걸 요청하셨어요. 태스크 분해가 필요합니다." 그렇다면 태스크 분해란 정확히 무엇일까요?
쉽게 비유하자면, 태스크 분해는 마치 요리를 할 때 레시피를 단계별로 나누는 것과 같습니다. "맛있는 파스타를 만들어줘"라고 하면 막막하지만, "1.
물을 끓여줘 2. 면을 삶아줘 3.
소스를 만들어줘"처럼 단계를 나누면 훨씬 명확합니다. LLM도 마찬가지입니다.
태스크 분해가 없던 시절에는 어땠을까요? 개발자들은 하나의 거대한 프롬프트에 모든 지시사항을 담았습니다.
"이것도 하고 저것도 하고 그것도 해줘" 식이었죠. 결과는 예측 불가능했습니다.
어떤 때는 잘 되고, 어떤 때는 영 엉뚱한 답변이 나왔습니다. 더 큰 문제는 디버깅이 불가능하다는 점이었습니다.
어느 부분에서 잘못됐는지 알 수가 없었죠. 바로 이런 문제를 해결하기 위해 태스크 분해 기법이 등장했습니다.
태스크 분해를 사용하면 각 단계의 출력을 검증할 수 있습니다. 또한 문제가 생긴 지점을 정확히 파악할 수 있습니다.
무엇보다 각 단계가 하나의 명확한 목표만 가지므로 LLM의 성능이 크게 향상됩니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 나쁜 예시를 보면, 하나의 프롬프트에 감정 분류, 키워드 추출, 개선 제안이라는 세 가지 작업을 모두 담았습니다. LLM은 이 중 어느 것에 집중해야 할지 혼란스러워합니다.
반면 올바른 방식에서는 각 단계가 단 하나의 명확한 목표를 가집니다. step1은 오직 감정 분류만, step2는 오직 키워드 추출만 담당합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 법률 문서 분석 서비스를 개발한다고 가정해봅시다.
"계약서를 분석해서 리스크를 찾고 요약하고 번역까지 해줘"라는 요청을 받았다면, 이를 1) 주요 조항 추출 2) 리스크 항목 식별 3) 요약문 생성 4) 영문 번역 이렇게 네 단계로 나눕니다. 각 단계의 결과를 검토하고, 문제가 있으면 해당 단계만 수정하면 됩니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 지나치게 세밀하게 쪼개는 것입니다.
"첫 단어를 읽어줘, 두 번째 단어를 읽어줘" 식으로 나누면 오히려 비효율적입니다. 적절한 크기로 나누는 것이 중요합니다.
경험상 한 단계가 3-5문장의 명확한 지시사항으로 표현될 수 있으면 적당합니다. 다시 최데이터 씨의 이야기로 돌아가 봅시다.
김프롬프트 씨의 조언을 들은 최데이터 씨는 작업을 세 단계로 나누어 다시 시도했습니다. "와, 결과가 훨씬 좋아졌어요!" 태스크 분해를 제대로 이해하면 LLM을 훨씬 효과적으로 활용할 수 있습니다.
복잡한 작업일수록 단계를 나누는 것이 더 중요합니다.
실전 팁
💡 - 각 단계는 하나의 명확한 목표만 가져야 합니다
- 단계가 3개 미만이면 너무 뭉뚱그린 것, 10개 이상이면 너무 세밀하게 쪼갠 것입니다
- 각 단계의 출력을 로깅해두면 디버깅이 훨씬 쉬워집니다
2. 프롬프트 체이닝 전략
최데이터 씨가 작업을 단계별로 나누긴 했는데, 이번엔 새로운 고민이 생겼습니다. "첫 번째 단계 결과를 두 번째 단계에 어떻게 전달하지?
그냥 복사-붙여넣기 하면 되나?" 김프롬프트 씨가 웃으며 말합니다. "그건 프롬프트 체이닝 전략이 필요해요."
프롬프트 체이닝은 한 단계의 출력을 다음 단계의 입력으로 자동 연결하는 기법입니다. 마치 공장의 컨베이어 벨트처럼 데이터가 단계에서 단계로 흘러갑니다.
수동으로 복사-붙여넣기 하는 것보다 훨씬 안정적이고 확장 가능합니다.
다음 코드를 살펴봅시다.
class PromptChain:
def __init__(self, llm_client):
self.llm = llm_client
self.results = {} # 각 단계의 결과를 저장
def add_step(self, name, prompt_template, dependencies=[]):
"""단계를 체인에 추가"""
# dependencies에 명시된 이전 단계 결과를 가져옴
inputs = {dep: self.results[dep] for dep in dependencies}
# 템플릿에 이전 결과를 주입
prompt = prompt_template.format(**inputs)
# LLM 호출하고 결과 저장
result = self.llm.generate(prompt)
self.results[name] = result
return result
# 사용 예시
chain = PromptChain(llm_client)
chain.add_step("sentiment", "리뷰의 감정을 분류: {review}")
chain.add_step("keywords", "키워드 추출: {review}", dependencies=["sentiment"])
chain.add_step("summary", "감정({sentiment})과 키워드를 바탕으로 요약",
dependencies=["sentiment", "keywords"])
최데이터 씨는 단계를 나누긴 했지만, 이제 새로운 문제에 봉착했습니다. 첫 번째 LLM 호출 결과를 복사해서 두 번째 프롬프트에 붙여넣고, 또 그 결과를 복사해서 세 번째에 붙여넣고...
"이렇게 하면 실수하기 딱 좋은데요?" 김프롬프트 씨가 고개를 끄덕입니다. "맞아요.
사람이 수동으로 중간 결과를 옮기다 보면 실수가 생깁니다. 자동화된 체인이 필요합니다." 그렇다면 프롬프트 체이닝이란 정확히 무엇일까요?
쉽게 비유하자면, 프롬프트 체이닝은 마치 자동차 조립 공장과 같습니다. 첫 번째 작업대에서 엔진을 만들면 컨베이어 벨트가 자동으로 두 번째 작업대로 옮겨줍니다.
두 번째에서 바퀴를 달면 또 자동으로 세 번째로 이동합니다. 사람이 일일이 들고 옮길 필요가 없습니다.
프롬프트 체이닝도 이처럼 데이터를 자동으로 흘려보냅니다. 프롬프트 체이닝이 없던 시절에는 어땠을까요?
개발자들은 각 단계를 별도의 스크립트로 작성했습니다. 첫 번째 스크립트를 실행하고, 결과를 파일에 저장하고, 두 번째 스크립트에서 그 파일을 읽어오고...
과정이 복잡할수록 파일이 늘어나고 관리가 어려워졌습니다. 더 큰 문제는 중간에 한 단계라도 실패하면 전체 과정을 처음부터 다시 실행해야 한다는 점이었습니다.
바로 이런 문제를 해결하기 위해 프롬프트 체이닝 전략이 등장했습니다. 프롬프트 체이닝을 사용하면 코드가 간결해집니다.
또한 중간 결과를 자동으로 관리할 수 있습니다. 무엇보다 재사용과 확장이 쉬워집니다.
새로운 단계를 추가하고 싶다면? 그냥 add_step만 호출하면 됩니다.
위의 코드를 한 줄씩 살펴보겠습니다. PromptChain 클래스는 results 딕셔너리에 각 단계의 결과를 저장합니다.
add_step 메서드는 세 가지 일을 합니다. 첫째, dependencies에 명시된 이전 단계들의 결과를 가져옵니다.
둘째, 프롬프트 템플릿에 그 결과들을 주입합니다. 셋째, LLM을 호출하고 결과를 저장합니다.
이 모든 과정이 자동으로 일어납니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 뉴스 기사 자동 생성 시스템을 개발한다고 가정해봅시다. 1단계에서 원본 데이터를 요약하고, 2단계에서 제목을 생성하고, 3단계에서 본문을 작성하고, 4단계에서 SEO 키워드를 추출합니다.
프롬프트 체이닝을 사용하면 이 네 단계가 하나의 파이프라인으로 매끄럽게 연결됩니다. 어느 단계에서 문제가 생기면 그 단계만 다시 실행할 수도 있습니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 순환 의존성을 만드는 것입니다.
A 단계가 B의 결과를 필요로 하고, B가 A의 결과를 필요로 하면 무한 루프에 빠집니다. 따라서 의존성 그래프가 방향성 비순환 그래프가 되도록 설계해야 합니다.
또 다른 실수는 모든 이전 결과를 다음 단계에 전달하는 것입니다. 필요한 것만 전달하세요.
다시 최데이터 씨의 이야기로 돌아가 봅시다. 체이닝 클래스를 만든 최데이터 씨는 이제 새로운 분석 파이프라인을 10분 만에 구축할 수 있게 되었습니다.
"이제 복사-붙여넣기 실수 걱정이 없어요!" 프롬프트 체이닝을 제대로 이해하면 복잡한 LLM 워크플로우를 우아하게 관리할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 각 단계는 명확한 이름을 가져야 나중에 디버깅하기 쉽습니다
- 의존성은 최소한으로 유지하세요 (한 단계가 의존하는 이전 단계는 3개 이하가 이상적)
- 중간 결과를 로깅하면 문제 추적이 훨씬 쉬워집니다
3. 중간 결과 전달
체인을 만들긴 했는데, 최데이터 씨는 또 다른 문제를 발견했습니다. "첫 단계에서 JSON 형식으로 결과가 나왔는데, 두 번째 단계에서는 일반 텍스트를 원하는데요?" 김프롬프트 씨가 말합니다.
"중간 결과 전달 포맷을 설계해야 합니다."
중간 결과 전달은 각 단계 사이에서 데이터를 어떤 형식으로 주고받을지 정의하는 것입니다. 마치 API가 JSON이나 XML로 데이터를 주고받듯, 프롬프트 체인도 표준화된 포맷이 필요합니다.
일관된 포맷을 사용하면 단계들을 레고 블록처럼 자유롭게 조합할 수 있습니다.
다음 코드를 살펴봅시다.
import json
from typing import Dict, Any
class ChainResult:
"""단계 간 전달되는 표준 결과 객체"""
def __init__(self, data: Any, metadata: Dict = None):
self.data = data # 실제 결과 데이터
self.metadata = metadata or {} # 메타정보 (타입, 타임스탬프 등)
self.metadata['timestamp'] = time.time()
def to_text(self) -> str:
"""텍스트 형식으로 변환"""
if isinstance(self.data, dict):
return json.dumps(self.data, ensure_ascii=False)
return str(self.data)
def to_dict(self) -> Dict:
"""딕셔너리 형식으로 변환"""
if isinstance(self.data, str):
try:
return json.loads(self.data)
except:
return {"text": self.data}
return self.data
# 사용 예시
step1_result = ChainResult(
data={"sentiment": "긍정", "score": 0.85},
metadata={"model": "gpt-4", "step": "sentiment_analysis"}
)
# 다음 단계에서 필요한 형식으로 변환
prompt = f"감정 분석 결과: {step1_result.to_text()}"
최데이터 씨의 체인은 잘 작동했지만, 새로운 문제가 생겼습니다. 어떤 단계는 JSON을 출력하고, 어떤 단계는 평문을 출력합니다.
다음 단계에 전달할 때마다 형식을 일일이 변환해야 했습니다. "이거 너무 번거로운데요." 최데이터 씨가 한숨을 쉬었습니다.
김프롬프트 씨가 모니터를 가리킵니다. "표준화된 중간 결과 객체를 만들어보세요.
모든 단계가 같은 포맷으로 데이터를 주고받으면 문제가 해결됩니다." 그렇다면 중간 결과 전달이란 정확히 무엇일까요? 쉽게 비유하자면, 중간 결과 전달은 마치 택배 상자와 같습니다.
물건이 책이든 옷이든 전자제품이든, 모두 표준화된 택배 상자에 담아서 보냅니다. 상자 안에 뭐가 들었는지는 라벨을 보면 알 수 있습니다.
프롬프트 체인도 마찬가지로, 데이터를 표준화된 객체에 담아서 전달하면 어떤 단계든 쉽게 꺼내 쓸 수 있습니다. 표준화된 중간 결과 포맷이 없던 시절에는 어땠을까요?
개발자들은 각 단계마다 다른 형식으로 데이터를 출력했습니다. 어떤 단계는 "긍정"이라는 문자열을, 어떤 단계는 {"result": "positive"}라는 JSON을, 또 어떤 단계는 [1, 0, 0] 같은 배열을 출력했습니다.
다음 단계에서 이걸 사용하려면 매번 형식을 확인하고 파싱하는 코드를 작성해야 했습니다. 체인이 길어질수록 이런 변환 코드가 늘어나 유지보수가 악몽이 되었습니다.
바로 이런 문제를 해결하기 위해 표준화된 중간 결과 전달 방식이 등장했습니다. ChainResult 객체를 사용하면 모든 단계가 동일한 인터페이스로 결과를 반환합니다.
또한 필요에 따라 형식을 자유롭게 변환할 수 있습니다. 무엇보다 메타데이터를 함께 전달할 수 있어서 디버깅과 모니터링이 쉬워집니다.
위의 코드를 한 줄씩 살펴보겠습니다. ChainResult 클래스는 실제 데이터를 담는 data 필드와 부가 정보를 담는 metadata 필드를 가집니다.
생성 시점에 타임스탬프를 자동으로 기록합니다. to_text 메서드는 어떤 데이터든 텍스트로 변환해주고, to_dict는 딕셔너리로 변환해줍니다.
이제 다음 단계에서 필요한 형식으로 간단히 변환할 수 있습니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 이미지 분석 파이프라인을 개발한다고 가정해봅시다. 1단계는 이미지에서 객체를 탐지하고 바운딩 박스 좌표를 반환합니다.
2단계는 각 객체를 분류합니다. 3단계는 객체 간 관계를 분석합니다.
각 단계의 출력 형식이 다르지만, 모두 ChainResult로 감싸면 일관되게 처리할 수 있습니다. 메타데이터에 처리 시간, 사용 모델, 신뢰도 점수 등을 담으면 나중에 성능 분석도 쉬워집니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 너무 많은 정보를 metadata에 담는 것입니다.
메타데이터는 말 그대로 "데이터에 대한 데이터"입니다. 실제 결과는 data에, 부가 정보만 metadata에 담아야 합니다.
또 다른 실수는 형식 변환을 과도하게 하는 것입니다. 가능하면 원본 형식을 유지하고, 정말 필요할 때만 변환하세요.
다시 최데이터 씨의 이야기로 돌아가 봅시다. ChainResult를 도입한 후, 최데이터 씨는 더 이상 형식 변환 코드를 작성하지 않아도 되었습니다.
"이제 새로운 단계를 추가할 때 형식 걱정을 안 해도 되네요!" 표준화된 중간 결과 전달을 제대로 이해하면 프롬프트 체인을 모듈화하고 재사용할 수 있습니다. 여러분도 오늘 배운 ChainResult 패턴을 적용해 보세요.
실전 팁
💡 - 메타데이터에는 타임스탬프, 모델명, 처리 시간 등을 기록하면 좋습니다
- 형식 변환 메서드는 실패해도 예외를 던지지 않고 기본값을 반환하도록 설계하세요
- 데이터가 크다면 참조만 전달하고 실제 데이터는 별도 저장소에 두는 것을 고려하세요
4. 오류 전파 방지
최데이터 씨의 체인이 잘 작동하다가 갑자기 멈춰버렸습니다. 알고 보니 세 번째 단계에서 LLM이 잘못된 형식으로 응답했고, 네 번째 단계에서 파싱 에러가 났습니다.
"한 단계만 실패했는데 전체가 멈추다니..." 김프롬프트 씨가 말합니다. "오류 전파 방지 전략이 필요합니다."
오류 전파 방지는 한 단계의 실패가 전체 체인을 망가뜨리지 않도록 하는 기법입니다. 마치 건물의 방화벽처럼, 오류를 격리하고 우아하게 처리합니다.
재시도, 폴백, 부분 결과 반환 등의 전략을 사용해서 시스템의 견고성을 높입니다.
다음 코드를 살펴봅시다.
class RobustChain:
def __init__(self, llm_client, max_retries=3):
self.llm = llm_client
self.max_retries = max_retries
self.results = {}
self.errors = {} # 각 단계의 오류 기록
def execute_step(self, name, prompt, fallback_value=None):
"""오류 처리가 포함된 단계 실행"""
for attempt in range(self.max_retries):
try:
result = self.llm.generate(prompt)
# 결과 검증
if self.validate_result(result):
self.results[name] = result
return result
else:
raise ValueError("Invalid result format")
except Exception as e:
self.errors[name] = str(e)
if attempt < self.max_retries - 1:
continue # 재시도
else:
# 최대 재시도 후에도 실패하면 폴백 값 사용
if fallback_value:
self.results[name] = fallback_value
return fallback_value
raise # 폴백도 없으면 예외 발생
def validate_result(self, result):
"""결과가 유효한지 검증"""
# 예: JSON 파싱 가능한지, 필수 필드가 있는지 등
return result and len(result.strip()) > 0
최데이터 씨의 파이프라인은 대부분 잘 작동했습니다. 하지만 가끔 예상치 못한 이유로 멈춰버렸습니다.
LLM이 이상한 형식으로 응답하거나, 네트워크가 끊기거나, API 할당량을 초과하는 등 여러 이유가 있었습니다. "한 번 실패하면 처음부터 다시 실행해야 하는데, 시간도 오래 걸리고 비용도 많이 들어요." 최데이터 씨가 고민에 빠졌습니다.
김프롬프트 씨가 고개를 끄덕입니다. "오류 전파를 방지해야 합니다.
한 단계의 실패가 다른 단계까지 영향을 미치지 않도록 격리해야죠." 그렇다면 오류 전파 방지란 정확히 무엇일까요? 쉽게 비유하자면, 오류 전파 방지는 마치 배의 격벽과 같습니다.
배 한쪽에 구멍이 나서 물이 새어 들어와도, 격벽이 있으면 그 구역만 침수되고 배 전체는 가라앉지 않습니다. 프롬프트 체인도 마찬가지로, 한 단계에서 문제가 생겨도 다른 단계는 계속 작동할 수 있어야 합니다.
오류 전파 방지 전략이 없던 시절에는 어땠을까요? 체인 중 한 단계라도 실패하면 전체 파이프라인이 중단되었습니다.
10단계 중 9번째에서 실패하면, 앞의 8단계를 다시 실행해야 했습니다. API 비용이 두 배로 들고, 시간도 두 배로 걸렸습니다.
더 큰 문제는 간헐적 오류를 처리할 방법이 없다는 점이었습니다. 네트워크가 일시적으로 느려지거나 LLM이 가끔 이상한 응답을 해도 전체가 실패했습니다.
바로 이런 문제를 해결하기 위해 오류 전파 방지 기법이 등장했습니다. 재시도 메커니즘을 사용하면 일시적 오류를 자동으로 복구할 수 있습니다.
폴백 값을 제공하면 필수가 아닌 단계의 실패를 우아하게 처리할 수 있습니다. 무엇보다 오류를 기록해두면 나중에 패턴을 분석하고 시스템을 개선할 수 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. RobustChain은 max_retries 매개변수로 최대 재시도 횟수를 설정받습니다.
execute_step 메서드는 for 루프로 재시도를 구현합니다. 결과를 받으면 validate_result로 유효성을 검증합니다.
검증에 실패하거나 예외가 발생하면 재시도하고, 최대 재시도 횟수를 초과하면 fallback_value를 사용합니다. 모든 오류는 errors 딕셔너리에 기록됩니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 대량의 고객 문의를 자동으로 분류하는 시스템을 개발한다고 가정해봅시다.
1000건의 문의를 처리하는 중 10건에서 오류가 났다면, 나머지 990건은 정상 처리하고 10건만 별도로 재처리하면 됩니다. 폴백 전략을 사용하면 "분류 불가" 같은 기본값을 할당해서 일단 진행하고, 나중에 사람이 검토할 수 있습니다.
금융권이나 의료 분야처럼 정확성이 중요한 곳에서는 폴백 없이 명시적으로 실패하도록 설정할 수도 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 무한정 재시도하는 것입니다. 재시도 횟수를 제한하지 않으면 영원히 멈추지 않을 수 있습니다.
또 다른 실수는 모든 오류를 똑같이 처리하는 것입니다. 일시적 네트워크 오류는 재시도하면 되지만, 잘못된 프롬프트로 인한 오류는 재시도해도 계속 실패합니다.
오류 타입에 따라 다른 전략을 사용하세요. 다시 최데이터 씨의 이야기로 돌아가 봅시다.
오류 처리를 추가한 후, 최데이터 씨의 파이프라인은 훨씬 안정적으로 작동했습니다. "이제 가끔 실패해도 자동으로 복구되네요!" 오류 전파 방지를 제대로 이해하면 프로덕션 환경에서도 안정적인 LLM 시스템을 구축할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 재시도 사이에 지수 백오프를 적용하면 API 서버 부하를 줄일 수 있습니다
- 중요한 단계는 폴백 없이, 덜 중요한 단계는 폴백을 제공하세요
- 오류 로그를 주기적으로 분석해서 자주 실패하는 단계를 개선하세요
5. 실습 멀티 스텝 분석 파이프라인
이제 이론은 충분히 배웠습니다. 최데이터 씨는 실제로 작동하는 파이프라인을 만들어보기로 했습니다.
"고객 리뷰를 받아서 감정 분석, 토픽 추출, 액션 아이템 생성까지 자동화하는 시스템을 만들어볼게요." 김프롬프트 씨가 엄지를 치켜세웁니다. "좋아요, 실전 파이프라인을 만들어봅시다!"
멀티 스텝 분석 파이프라인은 지금까지 배운 모든 기법을 종합한 실전 예제입니다. 태스크 분해, 프롬프트 체이닝, 표준화된 결과 전달, 오류 처리가 모두 포함됩니다.
실무에서 바로 사용할 수 있는 완전한 파이프라인을 구축해봅니다.
다음 코드를 살펴봅시다.
class ReviewAnalysisPipeline:
def __init__(self, llm_client):
self.chain = RobustChain(llm_client, max_retries=3)
def analyze(self, review_text):
"""리뷰를 다단계로 분석"""
# Step 1: 감정 분석
sentiment = self.chain.execute_step(
"sentiment",
f"다음 리뷰의 감정을 긍정/부정/중립 중 하나로 분류하고, 0-1 사이 점수를 JSON으로 반환: {review_text}",
fallback_value='{"sentiment": "중립", "score": 0.5}'
)
# Step 2: 주요 토픽 추출
topics = self.chain.execute_step(
"topics",
f"다음 리뷰에서 언급된 주요 토픽 3개를 추출해서 JSON 배열로 반환: {review_text}",
fallback_value='["기타"]'
)
# Step 3: 액션 아이템 생성
actions = self.chain.execute_step(
"actions",
f"감정({sentiment})과 토픽({topics})을 바탕으로 개선을 위한 액션 아이템 2-3개를 생성:",
fallback_value='["리뷰 내용 재확인 필요"]'
)
return {
"sentiment": sentiment,
"topics": topics,
"actions": actions,
"errors": self.chain.errors
}
# 사용 예시
pipeline = ReviewAnalysisPipeline(llm_client)
result = pipeline.analyze("배송이 빨랐고 제품 품질도 좋았어요. 다만 포장이 조금 아쉬웠습니다.")
print(result)
최데이터 씨는 드디어 실전 프로젝트를 시작했습니다. 회사에서 매일 수천 건의 고객 리뷰가 들어오는데, 이를 수동으로 분석하기엔 인력이 부족했습니다.
"LLM으로 자동화하면 되겠다"는 생각이 들었습니다. 하지만 단순히 "이 리뷰를 분석해줘"라고 하면 결과가 들쭉날쭉했습니다.
어떤 때는 감정만 알려주고, 어떤 때는 쓸데없이 긴 분석을 내놓았습니다. 김프롬프트 씨가 조언합니다.
"명확하게 단계를 나누고, 각 단계마다 정확한 출력 형식을 지정하세요. 그리고 오류 처리도 잊지 마세요." 그렇다면 멀티 스텝 분석 파이프라인을 어떻게 설계할까요?
먼저 분석 작업을 세 단계로 나눕니다. 1단계는 감정 분석입니다.
리뷰가 긍정적인지 부정적인지 판단합니다. 2단계는 토픽 추출입니다.
리뷰에서 언급된 주요 주제들을 찾아냅니다. 3단계는 액션 아이템 생성입니다.
앞의 두 단계 결과를 바탕으로 실제로 취할 수 있는 개선 방안을 제안합니다. 왜 이렇게 나눴을까요?
한 번에 "감정도 분석하고 토픽도 찾고 개선안도 제안해줘"라고 하면 LLM이 혼란스러워합니다. 하지만 단계를 나누면 각 단계가 하나의 명확한 목표만 가집니다.
감정 분석 단계는 오직 감정만 판단하면 됩니다. 집중도가 높아지니 정확도도 올라갑니다.
위의 코드를 한 줄씩 살펴보겠습니다. ReviewAnalysisPipeline 클래스는 RobustChain을 내부적으로 사용합니다.
analyze 메서드는 세 개의 execute_step을 순차적으로 호출합니다. 각 단계는 명확한 프롬프트와 JSON 출력 형식을 지정합니다.
fallback_value를 제공해서 실패해도 기본값으로 진행할 수 있게 합니다. 마지막에 모든 결과와 오류 기록을 딕셔너리로 반환합니다.
실제 현업에서는 어떻게 활용할까요? 이 파이프라인을 서버에 배포하면 API 엔드포인트로 만들 수 있습니다.
고객 리뷰가 데이터베이스에 저장될 때마다 자동으로 이 파이프라인을 실행합니다. 감정이 부정적이고 특정 토픽이 언급된 리뷰는 자동으로 담당 팀에게 알림을 보냅니다.
액션 아이템은 대시보드에 집계되어 경영진이 개선 우선순위를 결정하는 데 활용됩니다. 어떤 점이 실무적으로 유용할까요?
첫째, 재현 가능성입니다. 같은 리뷰를 여러 번 분석해도 비슷한 결과가 나옵니다.
둘째, 확장성입니다. 새로운 분석 단계를 추가하고 싶다면 execute_step만 하나 더 호출하면 됩니다.
셋째, 모니터링입니다. errors 필드를 보면 어느 단계에서 자주 실패하는지 파악할 수 있습니다.
하지만 주의할 점도 있습니다. 각 단계마다 LLM을 호출하므로 비용과 시간이 증가합니다.
리뷰 하나를 처리하는 데 3번의 API 호출이 필요합니다. 따라서 정말 필요한 단계만 포함시켜야 합니다.
또한 프롬프트 품질이 결과에 큰 영향을 미칩니다. 각 단계의 프롬프트를 충분히 테스트하고 개선해야 합니다.
다시 최데이터 씨의 이야기로 돌아가 봅시다. 파이프라인을 배포한 후 일주일간 모니터링한 결과, 정확도가 85%를 넘었습니다.
"이제 리뷰 분석에 드는 시간이 90% 줄었어요!" 멀티 스텝 분석 파이프라인을 제대로 이해하면 복잡한 분석 작업을 자동화할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 각 단계의 프롬프트를 별도 파일로 관리하면 버전 관리가 쉬워집니다
- 처음엔 단계를 최소화하고, 필요할 때만 추가하세요
- A/B 테스트로 프롬프트 변경의 효과를 측정하세요
6. 실습 문서 처리 체인
최데이터 씨가 새로운 프로젝트를 맡았습니다. "긴 계약서를 요약하고, 중요 조항을 추출하고, 리스크를 평가해야 해요." 김프롬프트 씨가 미소를 짓습니다.
"완벽해요. 문서 처리 체인을 만들 때가 됐네요.
이번엔 토큰 제한도 고려해야 합니다."
문서 처리 체인은 긴 문서를 다루는 특수한 파이프라인입니다. 문서를 청크로 나누고, 각 청크를 병렬로 처리하고, 결과를 병합하는 전략이 필요합니다.
토큰 제한, 문맥 유지, 병렬 처리 등 실무에서 만나는 복잡한 문제들을 다룹니다.
다음 코드를 살펴봅시다.
class DocumentProcessingChain:
def __init__(self, llm_client, chunk_size=2000):
self.llm = llm_client
self.chunk_size = chunk_size
def split_document(self, text):
"""문서를 청크로 분할"""
words = text.split()
chunks = []
for i in range(0, len(words), self.chunk_size):
chunk = ' '.join(words[i:i+self.chunk_size])
chunks.append(chunk)
return chunks
def process_document(self, document_text):
"""전체 문서 처리 파이프라인"""
# Step 1: 문서 분할
chunks = self.split_document(document_text)
# Step 2: 각 청크 요약 (병렬 가능)
summaries = []
for i, chunk in enumerate(chunks):
summary = self.llm.generate(
f"다음 계약서 일부를 3문장으로 요약: {chunk}"
)
summaries.append(summary)
# Step 3: 요약들을 하나로 병합
combined_summary = self.llm.generate(
f"다음 부분 요약들을 하나의 종합 요약으로 만들어줘:\n" +
"\n".join(f"{i+1}. {s}" for i, s in enumerate(summaries))
)
# Step 4: 전체 문서에서 중요 조항 추출
key_clauses = self.llm.generate(
f"종합 요약({combined_summary})을 바탕으로 원본 문서에서 가장 중요한 조항 5개를 추출해줘"
)
# Step 5: 리스크 평가
risks = self.llm.generate(
f"다음 계약서 요약과 주요 조항을 보고 잠재적 리스크 3가지를 평가:\n요약: {combined_summary}\n조항: {key_clauses}"
)
return {
"summary": combined_summary,
"key_clauses": key_clauses,
"risks": risks,
"chunks_processed": len(chunks)
}
최데이터 씨의 새 프로젝트는 도전적이었습니다. 회사에서 체결하는 계약서는 보통 수십 페이지에 달했고, LLM의 토큰 제한을 훌쩍 넘었습니다.
"한 번에 전체를 넣을 수가 없네요." 김프롬프트 씨가 설명합니다. "긴 문서는 분할-정복-병합 전략을 써야 합니다.
문서를 작은 조각으로 나누고, 각각 처리한 다음, 결과를 다시 합치는 거죠." 그렇다면 문서 처리 체인을 어떻게 설계할까요? 핵심은 계층적 처리입니다.
먼저 문서를 청크로 나눕니다. 각 청크는 LLM의 토큰 제한 안에 들어가는 크기여야 합니다.
각 청크를 개별적으로 요약합니다. 이 단계는 병렬로 처리할 수 있어서 속도가 빠릅니다.
그다음 모든 요약을 모아서 하나의 종합 요약을 만듭니다. 마지막으로 종합 요약을 참고해서 원본 문서에서 중요한 부분을 추출하고 리스크를 평가합니다.
왜 이런 복잡한 과정이 필요할까요? LLM은 한 번에 처리할 수 있는 토큰 수에 제한이 있습니다.
GPT-4는 8K32K 토큰, Claude는 100K200K 토큰까지 지원하지만, 계약서는 쉽게 이를 초과합니다. 또한 문맥 창이 크다고 해서 전체를 완벽히 이해하는 건 아닙니다.
연구에 따르면 LLM은 매우 긴 텍스트의 중간 부분을 놓치는 경향이 있습니다. 따라서 작은 단위로 나누어 처리하는 것이 더 정확합니다.
위의 코드를 한 줄씩 살펴보겠습니다. DocumentProcessingChain은 chunk_size를 매개변수로 받습니다.
split_document 메서드는 단어 단위로 문서를 나눕니다. process_document는 다섯 단계로 구성됩니다.
먼저 문서를 분할하고, 각 청크를 요약하고, 요약들을 병합하고, 중요 조항을 추출하고, 마지막으로 리스크를 평가합니다. 각 단계는 이전 단계의 결과를 활용합니다.
실제 현업에서는 어떻게 활용할까요? 법무팀에서 이 파이프라인을 사용하면 계약서 검토 시간을 크게 단축할 수 있습니다.
변호사가 수십 페이지를 일일이 읽는 대신, AI가 먼저 요약하고 주요 조항과 리스크를 강조해줍니다. 변호사는 이 요약을 보고 어디에 집중해야 할지 빠르게 판단할 수 있습니다.
실제로 한 로펌에서는 이런 시스템을 도입해서 계약서 초기 검토 시간을 70% 단축했습니다. 병렬 처리는 어떻게 할까요?
2단계의 청크별 요약은 서로 독립적입니다. 첫 번째 청크의 요약이 끝나길 기다릴 필요 없이, 모든 청크를 동시에 처리할 수 있습니다.
Python의 asyncio나 concurrent.futures를 사용하면 쉽게 병렬화할 수 있습니다. 10개 청크를 순차적으로 처리하면 10분 걸리는 작업이 병렬로는 1분 만에 끝날 수 있습니다.
하지만 주의할 점도 있습니다. 청크 크기가 너무 작으면 문맥이 끊깁니다.
문장 중간에서 잘리면 의미를 파악하기 어렵습니다. 따라서 문단이나 섹션 단위로 나누는 것이 좋습니다.
반대로 청크가 너무 크면 토큰 제한을 초과할 수 있습니다. 적절한 균형을 찾아야 합니다.
또 다른 주의사항은 청크 간 연결성입니다. 중요한 정보가 두 청크에 걸쳐 있으면 놓칠 수 있습니다.
오버랩을 두는 것도 좋은 전략입니다. 다시 최데이터 씨의 이야기로 돌아가 봅시다.
문서 처리 체인을 구축한 후, 최데이터 씨는 100페이지 계약서도 5분 안에 분석할 수 있게 되었습니다. "법무팀이 정말 좋아해요!" 문서 처리 체인을 제대로 이해하면 토큰 제한이라는 제약을 우아하게 극복할 수 있습니다.
여러분도 오늘 배운 분할-정복-병합 전략을 적용해 보세요.
실전 팁
💡 - 청크를 나눌 때 문장이나 문단 경계를 존중하세요
- 청크 간 10-20% 오버랩을 두면 경계에 걸친 정보를 놓치지 않습니다
- 병렬 처리 시 API rate limit을 고려해서 동시 요청 수를 제한하세요
- 요약의 요약보다는 원본을 참조하는 것이 정확도가 높습니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
ReAct 패턴 마스터 완벽 가이드
LLM이 생각하고 행동하는 ReAct 패턴을 처음부터 끝까지 배웁니다. Thought-Action-Observation 루프로 똑똑한 에이전트를 만들고, 실전 예제로 웹 검색과 계산을 결합한 강력한 AI 시스템을 구축합니다.
AI 에이전트의 모든 것 - 개념부터 실습까지
AI 에이전트란 무엇일까요? 단순한 LLM 호출과 어떻게 다를까요? 초급 개발자를 위해 에이전트의 핵심 개념부터 실제 구현까지 이북처럼 술술 읽히는 스타일로 설명합니다.
프로덕션 RAG 시스템 완벽 가이드
검색 증강 생성(RAG) 시스템을 실제 서비스로 배포하기 위한 확장성, 비용 최적화, 모니터링 전략을 다룹니다. AWS/GCP 배포 실습과 대시보드 구축까지 프로덕션 환경의 모든 것을 담았습니다.
RAG 캐싱 전략 완벽 가이드
RAG 시스템의 성능을 획기적으로 개선하는 캐싱 전략을 배웁니다. 쿼리 캐싱부터 임베딩 캐싱, Redis 통합까지 실무에서 바로 적용할 수 있는 최적화 기법을 다룹니다.
실시간으로 답변하는 RAG 시스템 만들기
사용자가 질문하면 즉시 답변이 스트리밍되는 RAG 시스템을 구축하는 방법을 배웁니다. 실시간 응답 생성부터 청크별 스트리밍, 사용자 경험 최적화까지 실무에서 바로 적용할 수 있는 완전한 가이드입니다.