포스트

2026-03-13 강의 정리

LangChain 소개 - 주요 아키텍처 및 컴포넌트 이해

2026-03-13 강의 정리

목차

  1. 문제와 접근
  2. LangChain이란? - OpenAI API vs LangChain 비교
  3. LangChain 메시지 클래스 - SystemMessage, HumanMessage, AIMessage
  4. LLM 호출 3가지 방식 - invoke / stream / batch
  5. llm.bind() - 파라미터 동적 고정
  6. ChatPromptTemplate - 프롬프트 템플릿화
  7. partial() - 변수 사전 고정
  8. StrOutputParser - 문자열 파싱
  9. JsonOutputParser - JSON 구조 파싱
  10. PydanticOutputParser - 타입 검증 파싱
  11. 파이프라인(|) 연산자 - 체인 구성
  12. 핵심 개념 총정리
  13. 자주 나오는 실수 / 주의사항
  14. 추상화가 유용해지는 지점

1. 문제와 접근

LangChain을 살펴볼 때는 제공하는 클래스 수보다 코드의 책임이 어떻게 나뉘는지가 중요하다. 프롬프트 구성, 모델 호출, 출력 변환을 분리하고 각 단계의 입력과 반환값을 따라가 본다. 체인을 연결하기 전에 개별 단계가 기대한 값을 내는지 확인하는 방식으로 접근한다.

LangChain을 쓰는 이유

OpenAI API 직접: messages = [{“role”: “user”, “content”: “…”}] response = client.chat.completions.create(model=…, messages=messages) text = response.choices[0].message.content ← 매번 이렇게

LangChain: chain = prompt | llm | StrOutputParser() text = chain.invoke({“변수”: “값”}) ← 한 줄로 처리


2. LangChain이란? - OpenAI API vs LangChain 비교

LangChain의 역할

  • OpenAI API의 “래퍼(Wrapper)” + 확장 기능 모음
  • 다양한 LLM(Claude, Gemini 등)을 같은 인터페이스로 사용 가능
  • 프롬프트 관리, 출력 파싱, 체인 구성 등을 구조화

기본 설정

1
2
3
4
5
6
7
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
from dotenv import load_dotenv

load_dotenv()

llm = ChatOpenAI(model='gpt-4o-mini', temperature=0.7)

가장 단순한 호출

1
2
3
response = llm.invoke('LangChain을 한 문장으로 설명해주세요')
print(type(response))    # <class 'langchain_core.messages.ai.AIMessage'>
print(response.content)  # 'LangChain은 LLM 기반 애플리케이션...'

llm.invoke() 반환값은 AIMessage 객체 .content 속성으로 텍스트 추출 (OpenAI 직접 호출과 다름!)


3. LangChain 메시지 클래스 - SystemMessage, HumanMessage, AIMessage

메시지 클래스 종류

클래스역할OpenAI API 대응
SystemMessage시스템 프롬프트 (AI 역할 지정){“role”: “system”, …}
HumanMessage사용자 질문{“role”: “user”, …}
AIMessageLLM 응답 객체{“role”: “assistant”, …}

사용 예시

1
2
3
4
5
6
7
8
9
10
from langchain_core.messages import SystemMessage, HumanMessage

persona = "MLOps 리더"
messages = [
    SystemMessage(content=f"당신은 {persona}입니다. 실무 팁 위주로 두 문장 이하로 답변해주세요"),
    HumanMessage(content="Langchain 체인을 배포할 때 고려해야 하는 리스크 두 가지는?")
]

response = llm.invoke(messages)
print(response.content)   # 실제 텍스트

응답 메타데이터 확인

1
2
3
4
5
6
7
8
9
response.response_metadata
# {
#   'model_name': 'gpt-4o-mini',
#   'token_usage': {'prompt_tokens': 45, 'completion_tokens': 68, 'total_tokens': 113},
#   'finish_reason': 'stop'
# }

response.response_metadata['token_usage']   # 토큰 수 모니터링 → 비용 관리
response.response_metadata.get('model_name')  # 'gpt-4o-mini'

유용한 함수 패턴

1
2
3
4
5
6
7
8
9
10
def ask_expert(persona, question):
    messages = [
        SystemMessage(content=f"당신은 {persona}입니다. 간결하게 답변해주세요"),
        HumanMessage(content=question)
    ]
    return llm.invoke(messages).content

# 다양한 전문가에게 같은 질문
print(ask_expert("파이썬 전문가", "리스트 컴프리헨션이 뭔가요?"))
print(ask_expert("수학 선생님", "미적분이 뭔가요?"))

4. LLM 호출 3가지 방식 - invoke / stream / batch

방식 1: invoke - 단일 동기식 호출

1
2
3
response = llm.invoke("파이썬이 뭔지 한 줄로 설명해주세요")
print(response.content)
# '파이썬은 간결한 문법과 강력한 라이브러리를 가진 프로그래밍 언어입니다.'

방식 2: stream - 실시간 스트리밍

1
2
3
4
5
6
7
stream_chunks = []
for chunk in llm.stream("머신러닝의 3가지 유형을 나열해줘"):
    stream_chunks.append(chunk.content)
    print(chunk.content, end="", flush=True)

# 토큰 단위로 실시간 출력됨
result = ''.join(stream_chunks)   # 전체 응답 문자열 조합

chunk.content : 이번 청크에 새로 생성된 텍스트

방식 3: batch - 다중 질문 병렬 처리

1
2
3
4
5
6
7
8
9
10
questions = [
    "파이썬의 장점 하나만 말해줘",
    "자바스크립트의 장점 하나만 말해줘",
    "러스트의 장점 하나만 말해줘"
]
results = llm.batch(questions)   # 병렬로 3개 동시 처리

for answer in results:
    print(answer.content)
    print()

batch()는 내부적으로 병렬 처리 → 순차 실행보다 빠름 대량 데이터 처리에 효과적

3가지 방식 비교

방식입력출력사용 상황
invoke단일 질문AIMessage일반적인 단건 처리
stream단일 질문iterator실시간 타이핑 효과 (UX)
batch질문 리스트[AIMessage]대량 처리, 성능 최적화

5. llm.bind() - 파라미터 동적 고정

개념

  • 특정 파라미터를 고정한 새로운 LLM 인스턴스 생성
  • 원본 llm 객체는 변경되지 않음

사용 예시

1
2
3
4
5
6
7
8
# temperature=0, max_tokens=80 고정
stream_llm = llm.bind(temperature=0, max_tokens=80)

# JSON 응답 강제
json_llm = llm.bind(response_format={"type": "json_object"})

# bind된 llm은 일반 llm과 동일하게 사용
response = stream_llm.invoke("파이썬의 특징을 설명해주세요")

JSON 응답 예시

1
2
3
4
5
6
7
8
9
10
from langchain_core.messages import SystemMessage, HumanMessage
import json

response = json_llm.invoke([
    SystemMessage(content="JSON으로 응답하세요"),
    HumanMessage(content="서울의 정보를 알려주세요")
])

data = json.loads(response.content)
# {'name': '서울', 'country': '대한민국', 'population': '약 950만'}

6. ChatPromptTemplate - 프롬프트 템플릿화

왜 필요한가?

1
2
3
4
5
6
7
8
9
10
11
# 이렇게 매번 쓰는 건 비효율적
response = llm.invoke("당신은 파이썬 전문가입니다. 함수 어떻게 쓰나요?")
response = llm.invoke("당신은 자바 전문가입니다. 함수 어떻게 쓰나요?")
response = llm.invoke("당신은 파이썬 전문가입니다. 클래스 어떻게 쓰나요?")

# ChatPromptTemplate으로 변수화하면
prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 {role} 전문가입니다. 핵심만 3줄로 답변해주세요"),
    ("human",  "{question}")
])
# role과 question만 바꿔서 재사용 가능!

기본 사용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 {role} 전문가입니다. 핵심만 3줄로 답변해주세요"),
    ("human",  "{question}")
])

# 어떤 변수가 필요한지 확인
print(prompt.input_variables)   # ['role', 'question']

# 변수 채우기
result = prompt.invoke({
    "role"    : "파이썬",
    "question": "함수 어떻게 쓰나요?"
})
# ChatPromptValue(messages=[SystemMessage(...), HumanMessage(...)])

# LLM에 넘기기
chain    = prompt | llm
response = chain.invoke({"role": "파이썬", "question": "함수 어떻게 쓰나요?"})

{role}, {question} : 중괄호로 감싼 부분이 변수 prompt.input_variables : 어떤 변수를 채워야 하는지 확인 prompt | llm : 파이프 연산자로 체인 구성 (LCEL)


7. partial() - 변수 사전 고정

개념

  • 자주 쓰는 변수를 미리 고정해서 재사용
  • partial이 적용된 새로운 프롬프트 반환 (원본 불변)

사용 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 {role} 전문가입니다. 핵심만 3줄로 답변해주세요"),
    ("human",  "{question}")
])

# role을 "파이썬"으로 미리 고정
partial_prompt = prompt.partial(role="파이썬")

# 이제 question만 입력하면 됨
response = (partial_prompt | llm).invoke({"question": "함수 어떻게 쓰나요?"})

# input_variables 비교
print(prompt.input_variables)          # ['role', 'question']
print(partial_prompt.input_variables)  # ['question']  ← role은 고정됨

실무에서: 같은 역할의 챗봇에서 role은 고정, question만 변경할 때 유용

PM 챗봇 실전 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 {role}입니다. 액션 아이템을 불릿 3개 이하로 제시해주세요.\n"
               "[Context]\n{context}"),
    ("human",  "{question}")
])

parser = StrOutputParser()
pm_chain = prompt.partial(role="프로덕트 매니저") | llm | parser

result = pm_chain.invoke({
    "question": "LangChain 워크샵을 90분 세션으로 구성하려 할 때 어떤 활동이 필요한가요?",
    "context" : "- 대상: 사내 주니어 엔지니어 25명\n- 장비: 노트북 12대"
})
print(result)

8. StrOutputParser - 문자열 파싱

왜 필요한가?

llm.invoke() 반환값 → AIMessage 객체 StrOutputParser 거치면 → 순수 문자열(str)

사용 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_messages([
    ("human", "{topic}에 대해 한 줄로 설명해주세요")
])

parser = StrOutputParser()

# 파이프라인 구성
chain = prompt | llm | parser

# 호출
result = chain.invoke({"topic": "LangChain"})
print(type(result))   # <class 'str'>  ← AIMessage가 아닌 순수 문자열!
print(result)         # 'LangChain은 LLM 기반 애플리케이션 개발 프레임워크입니다.'

StrOutputParser 없이: chain.invoke() → AIMessage 객체 StrOutputParser 붙이면: chain.invoke() → str 문자열 대부분의 경우 StrOutputParser를 체인 마지막에 붙이는 것이 편리


9. JsonOutputParser - JSON 구조 파싱

개념

  • LLM에게 JSON 형식으로 응답하도록 지시 + 결과를 dict로 변환
  • Pydantic 모델로 스키마 정의 가능

사용 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from langchain_core.output_parsers import JsonOutputParser
from pydantic import BaseModel, Field

# JSON 스키마 정의
class CityInfo(BaseModel):
    name       : str       = Field(description="도시이름")
    country    : str       = Field(description="국가")
    population : str       = Field(description="인구 (대략적)")
    famous_for : list[str] = Field(description="유명한 것 3가지")

# 파서 생성
parser = JsonOutputParser(pydantic_object=CityInfo)

# 형식 지시 프롬프트 (LLM에게 JSON 형식 알려줌)
print(parser.get_format_instructions())
# "The output should be formatted as a JSON instance..."

prompt = ChatPromptTemplate.from_messages([
    ("system", "정확한 정보를 json 형식으로 응답해주세요.\n{format_instructions}"),
    ("human",  "{city}에 대해서 알려주세요")
])

chain = prompt | llm | parser

result = chain.invoke({
    "city"               : "서울",
    "format_instructions": parser.get_format_instructions()
})
print(result)
# {'name': '서울', 'country': '대한민국', 'population': '약 960만',
#  'famous_for': ['경복궁', '남산타워', '한강']}

print(type(result))   # <class 'dict'>
print(result['name']) # '서울'

get_format_instructions() : 파서가 LLM에게 “이런 형식으로 출력해”라고 알려주는 문자열 반환값이 dict이므로 result[‘name’] 처럼 딕셔너리로 접근 가능


10. PydanticOutputParser - 타입 검증 파싱

파싱에 성공했다는 것은 정해 둔 형식으로 읽었다는 뜻이다. 필드가 존재하고 타입이 맞더라도 실제 내용이 맞는지는 별도의 문제이므로, 숫자 범위나 필드 간 관계 같은 업무 규칙은 호출부에서 추가로 확인한다.

JsonOutputParser와의 차이

  • JsonOutputParser → dict 반환
  • PydanticOutputParser → Pydantic 모델 객체 반환 (속성 접근 + 타입 검증)

사용 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from langchain_core.output_parsers import PydanticOutputParser
from pydantic import BaseModel, Field

class BookReview(BaseModel):
    title      : str  = Field(description="책 제목")
    author     : str  = Field(description="저자")
    rating     : int  = Field(description="평점 (1-5)", ge=1, le=5)  # 1~5 제약
    summary    : str  = Field(description="한줄요약")
    recommended: bool = Field(description="추천 여부")

parser = PydanticOutputParser(pydantic_object=BookReview)

prompt = ChatPromptTemplate.from_messages([
    ("system", "도서 리뷰를 작성해주세요.\n{format_instructions}"),
    ("human",  "{book} 책에 대한 리뷰를 작성해주세요")
])

chain = prompt | llm | parser

result = chain.invoke({
    "book"               : "장발장",
    "format_instructions": parser.get_format_instructions()
})

print(result)
# BookReview(title='장발장', author='빅토르 위고', rating=5, ...)

print(type(result))       # <class '__main__.BookReview'>  Pydantic 객체!
print(result.title)       # '장발장'     (속성 접근)
print(result.author)      # '빅토르 위고'
print(result.rating)      # 5
print(result.recommended) # True

ge=1, le=5 : Pydantic 제약 조건 (1 이상 5 이하, 벗어나면 ValidationError) result.title 처럼 점(.) 으로 속성 접근 (dict[‘key’] 보다 편리)


11. 파이프라인(|) 연산자 - 체인 구성

체인이 길어질수록 마지막 응답만 보고 원인을 찾기 어렵다. 조립 전에는 프롬프트에 들어간 값, 모델의 원문 응답, 파서 결과를 각각 확인하고, 조립 후에도 같은 경계에서 문제를 좁힐 수 있게 한다.

기본 구조

1
2
3
4
Prompt | LLM | Parser

= Prompt의 출력 → LLM의 입력
  LLM의 출력    → Parser의 입력

단계별 역할

단계입력출력
Prompt딕셔너리 {변수: 값}ChatPromptValue (messages 리스트)
LLMChatPromptValueAIMessage (응답 객체)
ParserAIMessagestr / dict / Pydantic 객체

예시 모음

1
2
3
4
5
6
7
8
9
10
11
# 단순 텍스트 체인
chain1 = prompt | llm | StrOutputParser()
result = chain1.invoke({"topic": "LangChain"})   # → str

# JSON 체인
chain2 = prompt | llm | JsonOutputParser(pydantic_object=CityInfo)
result = chain2.invoke({"city": "서울"})         # → dict

# Pydantic 체인
chain3 = prompt | llm | PydanticOutputParser(pydantic_object=BookReview)
result = chain3.invoke({"book": "장발장"})       # → BookReview 객체

| 연산자가 없었다면:

1
2
3
4
5
# LCEL 없이 직접 구현한다면 이렇게 해야 함
prompt_value  = prompt.invoke({"topic": "LangChain"})
llm_response  = llm.invoke(prompt_value)
final_result  = parser.invoke(llm_response)
# LCEL: chain = prompt | llm | parser  → chain.invoke({"topic": "LangChain"})

12. 핵심 개념 총정리

개념핵심 내용
ChatOpenAIOpenAI API 래퍼, llm.invoke()
invoke/stream/batch단건/스트리밍/다중 병렬 처리
llm.bind()파라미터 고정한 새 LLM 인스턴스 생성
ChatPromptTemplate변수 {role} {question} 있는 프롬프트 템플릿
partial()자주 쓰는 변수 미리 고정
StrOutputParserAIMessage → str 변환
JsonOutputParserAIMessage → dict 변환 (스키마 검증)
PydanticOutputParserAIMessage → Pydantic 객체 변환 (타입 검증)
| 연산자 (LCEL)prompt | llm | parser 파이프라인 구성
get_format_instructionsLLM에게 출력 형식을 알려주는 지시문

Parser 비교

Parser반환 타입언제 쓰나?
StrOutputParserstr대부분의 경우 (텍스트 응답)
JsonOutputParserdictJSON 데이터 추출
PydanticOutputParserPydantic 객체타입 검증 필요, 속성 접근 편의

13. 자주 나오는 실수 / 주의사항

실수 1: LangChain 응답에서 .choices[0].message.content 접근

1
2
3
4
5
6
7
8
9
10
# LangChain llm.invoke() 반환값은 AIMessage!
# OpenAI 직접 호출과 다름!

response = llm.invoke("질문")

# 틀린 코드 (OpenAI 직접 호출 방식)
text = response.choices[0].message.content   # AttributeError!

# 올바른 코드 (LangChain)
text = response.content

실수 2: get_format_instructions() 없이 JsonOutputParser 사용

1
2
3
4
5
6
7
8
9
# JsonOutputParser/PydanticOutputParser는 LLM에게 형식을 알려줘야 함!
# format_instructions 없이 쓰면 LLM이 형식을 모름 → 파싱 실패

# 올바른 코드
chain = prompt | llm | parser
result = chain.invoke({
    "city"               : "서울",
    "format_instructions": parser.get_format_instructions()   # ← 필수!
})

실수 3: partial()과 invoke()의 변수 혼동

1
2
3
4
5
6
7
8
9
10
11
12
prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 {role} 전문가입니다"),
    ("human",  "{question}")
])

partial_prompt = prompt.partial(role="파이썬")

# role은 이미 고정됐으므로 invoke에서 role 또 주면 에러 or 무시됨
result = (partial_prompt | llm).invoke({
    "question": "함수 어떻게 쓰나요?"
    # role 은 이미 고정됨 → 여기서 주지 않아도 됨
})

실수 4: batch() 결과 순서

1
2
3
4
5
6
questions = ["질문1", "질문2", "질문3"]
results   = llm.batch(questions)

# 병렬 처리지만 순서는 입력과 동일하게 유지됨
for q, r in zip(questions, results):
    print(f"Q: {q} → A: {r.content[:30]}")

14. 추상화가 유용해지는 지점

단일 호출이라면 SDK만으로도 흐름을 충분히 표현할 수 있다. 반면 같은 프롬프트와 파서를 여러 처리 경로에서 조합한다면 공통 인터페이스의 이점이 커진다. 추상화는 구성 요소를 교체할 필요가 생기는 지점에 맞춰 도입한다.

다음 단계에서는 정상 JSON뿐 아니라 필드 누락과 잘못된 타입을 반환하는 응답도 파서에 넣어 본다. 실패 위치를 구분할 수 있어야 프롬프트를 수정할지, 파서를 바꿀지, 재시도를 허용할지 결정할 수 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

인기 태그