목차
- 오늘 강의 개요
- LCEL 복습 - | 연산자와 Runnable 계열
- RunnableParallel - 여러 체인 병렬 실행
- RunnableBranch - 조건 분기 처리
- Gradio 소개 - 웹 UI를 파이썬으로
- gr.Interface - 단순 입출력 UI
- gr.ChatInterface - 채팅 UI
- gr.Blocks - 커스텀 레이아웃
- Gradio history 형식 - 신버전 주의!
- LLM + Gradio 연결 - 실제 챗봇 구현
- FAQ 챗봇 구현 - Context + LLM 패턴
- 프로젝트 1 - 주택청약 FAQ 챗봇 (Weekend 과제)
- 핵심 개념 총정리
- 자주 나오는 실수 / 주의사항
1. 오늘 강의 개요
오늘은 LangChain LCEL의 Runnable 계열을 심화하고, Gradio로 실제 웹 UI가 있는 챗봇을 만들어본다.
오늘 배우는 것
RunnableParallel → 여러 체인 동시 실행, 딕셔너리로 수집 RunnableBranch → 입력 내용에 따라 다른 체인으로 라우팅 Gradio → 파이썬 코드로 웹 UI 생성 FAQ 챗봇 → 데이터를 context로 활용하는 실전 패턴
환경 설정
1
2
3
4
5
6
7
8
| from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableParallel, RunnableBranch
import gradio as gr
llm = ChatOpenAI(model="gpt-4o-mini")
parser = StrOutputParser()
|
2. LCEL 복습 - | 연산자와 Runnable 계열
LCEL (LangChain Expression Language)
- | 연산자로 체인을 구성하는 방식
- 기본 패턴: prompt | llm | parser
Runnable 계열 3가지
| 이름 | 역할 |
|---|
| RunnableSequence | 순차 실행 (| 연산자가 내부적으로 만들어 줌) |
| RunnableParallel | 병렬 실행 (여러 체인을 동시에 실행) |
| RunnablePassthrough | 입력 그대로 통과 (다른 키와 함께 넘길 때 사용) |
3. RunnableParallel - 여러 체인 병렬 실행
개념
- 여러 체인을 동시에 실행하고 결과를 딕셔너리로 합쳐서 반환
- 같은 입력을 여러 체인에 동시에 전달
기본 사용법
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| from langchain_core.runnables import RunnableParallel
# 각 체인 정의
summary_chain = ChatPromptTemplate.from_template('{text}를 한 줄로 요약해주세요') | llm | parser
keywords_chain = ChatPromptTemplate.from_template('{text}에서 키워드 3개만 뽑아주세요') | llm | parser
# 병렬 체인 구성
parallel_chain = RunnableParallel(
summary = summary_chain,
keywords = keywords_chain
)
result = parallel_chain.invoke({
"text": "LangChain은 LLM 기반 애플리케이션 개발 프레임워크입니다."
})
print(result)
# {
# 'summary': 'LangChain은 LLM 앱 개발을 위한 프레임워크입니다.',
# 'keywords': '1. LangChain 2. LLM 3. 프레임워크'
# }
|
실행 흐름 시각화
1
2
3
4
5
6
7
8
| 입력 {"text": "LangChain은 ..."}
↓
┌────────────┴────────────┐
│ summary_chain │ keywords_chain ← 동시 실행
│ (요약 생성) │ (키워드 추출)
└────────────┬────────────┘
↓
{'summary': ..., 'keywords': ...} ← 딕셔너리로 합산
|
주의사항
1
2
3
4
5
6
7
8
9
10
11
12
| # 아래처럼 llm 없이 프롬프트만 연결하면 ChatPromptValue 객체 반환됨
parallel_chain = RunnableParallel(
summary = ChatPromptTemplate.from_template('{text}를 한 줄로 요약해주세요'),
keywords = ChatPromptTemplate.from_template('{text}에서 키워드 3개')
)
# → 텍스트가 아니라 프롬프트 객체가 반환됨 (실수 주의!)
# 완성된 형태:
parallel_chain = RunnableParallel(
summary = prompt_summary | llm | parser, # ← llm | parser 필수!
keywords = prompt_keywords | llm | parser
)
|
딕셔너리 방식으로도 동일하게 사용 가능:
1
2
3
4
5
6
7
| # RunnableParallel 클래스 방식
chain = RunnableParallel(answer=rag_chain, source=retriever)
# 딕셔너리 방식 (LCEL에서 자동 변환)
chain = {"answer": rag_chain, "source": retriever}
# 두 방법은 동일하게 동작
|
4. RunnableBranch - 조건 분기 처리
개념
- 입력 내용에 따라 다른 체인으로 라우팅(분기)
- 구조: RunnableBranch( (조건1, 체인1), (조건2, 체인2), 기본체인 )
분류 기준 함수
1
2
3
4
5
6
7
| def route_logic(x):
text = x['question']
if '오류' in text or '에러' in text:
return 'technical'
elif '가격' in text or '요금' in text:
return 'billing'
return 'general'
|
각 팀별 체인 정의
1
2
3
| tech_chain = ChatPromptTemplate.from_template('기술지원팀입니다: {question}') | llm | parser
billing_chain = ChatPromptTemplate.from_template('요금관리팀입니다: {question}') | llm | parser
general_chain = ChatPromptTemplate.from_template('일반상담팀입니다: {question}') | llm | parser
|
RunnableBranch 구성
1
2
3
4
5
6
7
| from langchain_core.runnables import RunnableBranch
branch = RunnableBranch(
(lambda x: route_logic(x) == "technical", tech_chain), # 조건1 → 체인1
(lambda x: route_logic(x) == "billing", billing_chain), # 조건2 → 체인2
general_chain # ← 마지막 인자 = 기본(else) 체인
)
|
테스트
1
2
3
4
5
6
7
8
9
10
11
12
13
| questions = [
"프린터 오류가 났습니다",
"월 요금이 얼마인가요?",
"영업시간 알려주세요"
]
for q in questions:
print(f"Q: {q}")
print(f"A: {branch.invoke({'question': q})}")
print()
# Q: 프린터 오류가 났습니다 → 기술지원팀입니다: ...
# Q: 월 요금이 얼마인가요? → 요금관리팀입니다: ...
# Q: 영업시간 알려주세요 → 일반상담팀입니다: ...
|
분기 기준 정리
| 질문 내용 | 라우팅 결과 |
|---|
| “오류” / “에러” 포함 | 기술지원팀 체인 (tech_chain) |
| “가격” / “요금” 포함 | 요금관리팀 체인 (billing_chain) |
| 그 외 | 일반상담팀 체인 (general_chain) |
5. Gradio 소개 - 웹 UI를 파이썬으로
Gradio란?
- 파이썬 코드 몇 줄로 웹 인터페이스 생성
- 별도 프론트엔드(HTML/CSS/JS) 지식 불필요
- 머신러닝/AI 데모에 최적화
- share=True 로 외부 공유 URL 생성 (72시간 유효)
설치
1
2
| !pip install gradio
import gradio as gr
|
Gradio 컴포넌트 3종류
| 컴포넌트 | 특징 | 사용 상황 |
|---|
| gr.Interface | 입력→함수→출력 단순 구조 | 빠른 데모, 단일 기능 |
| gr.ChatInterface | 채팅 UI 자동 제공, history | LLM 챗봇 |
| gr.Blocks | 완전 자유 레이아웃, 이벤트 | 복잡한 UI, 다중 컴포넌트 |
6. gr.Interface - 단순 입출력 UI
구조
1
2
3
4
5
6
7
8
9
10
| def greet(name):
return f"안녕하세요, {name}님"
demo = gr.Interface(
fn = greet, # 실행할 함수
inputs = gr.Textbox(label='이름 입력'), # 입력 컴포넌트
outputs = gr.Textbox(label='인사말'), # 출력 컴포넌트
title = '인사봇'
)
demo.launch(share=True) # share=True: 외부 공유 URL 생성
|
fn : 입력을 받아 출력을 반환하는 함수 inputs / outputs에 컴포넌트 타입 지정 share=True : ngrok 터널로 외부 접속 가능한 임시 URL 생성
7. gr.ChatInterface - 채팅 UI
기본 에코봇
1
2
3
4
5
6
7
8
9
| def echo_bot(message, history):
return f"Echo: {message}"
demo = gr.ChatInterface(
fn = echo_bot,
title = '에코챗봇',
examples = ["안녕하세요", "오늘 날씨 어때요", "FAQ 챗봇 테스트"]
)
demo.launch(share=True)
|
fn 시그니처 반드시: (message: str, history: list) → str message : 현재 사용자 입력 history : 이전 대화 기록 (자동으로 넘어옴) examples : 클릭 가능한 예시 버튼
8. gr.Blocks - 커스텀 레이아웃
자유로운 레이아웃 구성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| with gr.Blocks(title='custom layout') as demo:
gr.Markdown('## Custom Layout Demo')
with gr.Row(): # 가로로 배치
with gr.Column(scale=2): # 비율 2 (넓은 쪽)
input_text = gr.Textbox(label='질문')
submit_btn = gr.Button("전송")
with gr.Column(scale=1): # 비율 1 (좁은 쪽)
category_output = gr.Textbox(label='카테고리')
output_text = gr.Textbox(label='답변')
def process(text):
cat = "기술" if any(kw in text for kw in ["오류", "설치", "연결"]) else "일반"
return cat, f"[{cat}] {text}에 대한 답변입니다"
submit_btn.click(
fn = process,
inputs = input_text,
outputs = [category_output, output_text] # 반환값과 1:1 매핑
)
demo.launch(share=True)
|
gr.Row() : 가로 배치 gr.Column(scale=n) : 비율 설정 (2:1이면 왼쪽이 2배 넓음) 컴포넌트.click(fn, inputs, outputs) : 버튼 클릭 시 함수 실행 outputs 여러 개 → 함수가 튜플(cat, text)로 반환하면 순서대로 매핑
9. Gradio history 형식 - 신버전 주의!
★★★ 가장 중요한 주의사항 ★★★
ChatInterface의 history 형식이 Gradio 버전에 따라 다르다!
구버전 - 튜플 형식
1
2
3
4
5
| # 구버전 history 형식
history = [('사용자 메시지', 'AI 응답'), ('...', '...')]
for human, ai in history: # 튜플 언패킹
print(human, ai)
|
신버전 - 딕셔너리 형식 (현재 기본값)
1
2
3
4
5
6
7
8
9
10
11
| # 신버전 history 형식
history = [
{'role': 'user', 'content': '안녕하세요'},
{'role': 'assistant', 'content': '안녕하세요!'},
{'role': 'user', 'content': '파이썬 알려줘'},
{'role': 'assistant', 'content': '파이썬은...'},
]
# 올바른 접근 방법
for msg in history:
print(msg['role'], msg['content'])
|
신버전에서 구버전 방식으로 접근하면 에러!
1
2
3
| # 신버전에서 이렇게 하면 에러!
for human, ai in history: # ValueError: too many values to unpack
print(human, ai)
|
실무 코드 패턴
1
2
3
4
5
6
| def chat_function(message, history):
# 신버전 딕셔너리 형식으로 처리
for msg in history:
role = msg['role']
content = msg['content']
# ... 처리
|
10. LLM + Gradio 연결 - 실제 챗봇 구현
전체 코드
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
| from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
import gradio as gr
llm = ChatOpenAI(model="gpt-4o-mini")
def chat_with_llm(message, history):
# 1. 시스템 프롬프트로 LangChain 메시지 리스트 시작
messages = [SystemMessage(content="당신은 친절한 한국어 어시스턴트입니다.")]
# 2. Gradio history를 LangChain 메시지 형식으로 변환 (신버전 딕셔너리 형식)
for msg in history:
if msg['role'] == 'user':
messages.append(HumanMessage(content=msg['content']))
else:
messages.append(AIMessage(content=msg['content']))
# 3. 현재 입력 메시지 추가
messages.append(HumanMessage(content=message))
# 4. LLM 호출 후 텍스트만 반환
return llm.invoke(messages).content
demo = gr.ChatInterface(
fn = chat_with_llm,
title = 'AI 챗봇',
examples = ["안녕하세요", "Python에 대해 알려주세요", "오늘 기분이 좋아요"]
)
demo.launch(share=True)
|
핵심 흐름 시각화
1
2
3
4
5
6
7
8
9
10
11
12
| 사용자 입력 (message) + 이전 대화 (history, 딕셔너리 리스트)
↓
Gradio history → LangChain 메시지 리스트 변환
(딕셔너리 role/content → HumanMessage / AIMessage)
↓
[SystemMessage, HumanMessage, AIMessage, ..., HumanMessage(현재)]
↓
llm.invoke(messages)
↓
.content 로 텍스트 추출
↓
Gradio UI에 표시
|
핵심: Gradio는 딕셔너리로 history를 전달 → LangChain은 메시지 객체를 원함 → 변환 과정 필수 (msg[‘role’] 로 분기)
11. FAQ 챗봇 구현 - Context + LLM 패턴
아이디어: FAQ 데이터를 system 프롬프트에 넣기
1
2
| FAQ 데이터 목록 → 하나의 문자열로 변환 → system 프롬프트에 삽입
→ LLM이 FAQ를 참고해서 답변
|
전체 코드
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
35
36
37
38
39
40
| from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
# FAQ 데이터
faq_data = [
{"category": "계정", "question": "비밀번호를 잊어버렸습니다. 어떻게 초기화하나요?",
"answer": "IT 포털(intranet.company.com)에서 '비밀번호 재설정'을 클릭하세요."},
{"category": "계정", "question": "계정이 잠겼습니다.",
"answer": "IT 헬프데스크 1234번으로 연락해주세요."},
# ...
]
# FAQ 데이터를 문자열로 변환 (context 구성)
faq_context = '\n'.join([
f"Q: {item['question']}\nA: {item['answer']}"
for item in faq_data
])
# 프롬프트에 FAQ context 주입
prompt = ChatPromptTemplate.from_messages([
("system",
"너는 사내 IT 지원팀 챗봇이야. 아래 FAQ 데이터를 바탕으로 친절하게 답해줘. "
"데이터에 없는 내용은 'IT 헬프데스크(1234번)으로 문의해주세요'라고 안내해줘.\n\n"
f"[FAQ 데이터]\n{faq_context}"),
("human", "{question}")
])
chain = prompt | llm | StrOutputParser()
# Gradio 연결
def chat_response(message, history):
return chain.invoke({"question": message})
demo = gr.ChatInterface(
fn = chat_response,
title = '사내 IT 지원 챗봇',
examples = ["비밀번호 어떻게 초기화하나요", "Wi-Fi가 너무 느려요"],
description = "무엇을 도와드릴까요?"
)
demo.launch(share=True)
|
FAQ 챗봇 구조
1
2
3
4
5
6
7
8
9
| 사용자 질문
↓
prompt (FAQ context + 질문 조합)
↓
LLM (context 기반 답변 생성)
↓
StrOutputParser()
↓
Gradio UI 출력
|
이 패턴 = 간단한 RAG(검색 증강 생성)의 원형 context를 직접 system 프롬프트에 넣는 방식 → 데이터가 많아지면 한계 다음 강의에서 벡터 검색 기반 RAG로 발전시킴
12. 프로젝트 1 - 주택청약 FAQ 챗봇 (Weekend 과제)
파일
- p1_weekend1_api_and_chain_0314.ipynb
핵심 기술
- OpenAI API, LangChain LCEL, Gradio
10사이클 목표
| 사이클 | 목표 |
|---|
| 1 | OpenAI API 직접 호출, system 역할에 “주택청약 전문 상담원” 설정 |
| 2 | FAQ 데이터 탐색, 카테고리별 개수 집계, difficulty 필터링 |
| 3 | 키워드 매칭으로 관련 FAQ 검색하는 search_faq() 함수 구현 |
| 4 | 검색 결과를 system prompt에 넣어 답변하는 ask_faq() 함수 |
| 5 | ChatPromptTemplate으로 FAQ 답변 + 카테고리 분류 프롬프트 구성 |
| 6 | LCEL 체인 + .stream() 스트리밍 출력 실습 |
| 7 | 검색→답변 자동화 rag_chain 구현, 5개 테스트 질의 검증 |
| 8 | 빈 입력 / 500자 초과 / 숫자만 입력 등 예외 처리 safe_ask() |
| 9 | gr.ChatInterface로 RAG 체인을 웹 채팅 UI로 구현 |
| 10 | 전체 파이프라인 통합 + 10개 질문 테스트 (응답시간, FAQ 수 출력) |
전체 파이프라인
1
2
3
4
5
6
7
8
9
10
11
| 사용자 질문
↓
safe_ask (입력 검증)
↓
search_faq (키워드 매칭 검색)
↓
context 구성 → LLM 호출 (ask_faq)
↓
답변 + 참고 FAQ 반환
↓
Gradio UI 출력
|
13. 핵심 개념 총정리
| 개념 | 핵심 내용 |
|---|
| RunnableParallel | 여러 체인 동시 실행, 결과를 딕셔너리로 반환 |
| RunnableBranch | (조건, 체인) 쌍 + 기본 체인 → 분기 라우팅 |
| gr.Interface | fn/inputs/outputs → 즉시 UI 생성 |
| gr.ChatInterface | (message, history) 시그니처 → 채팅 특화 |
| gr.Blocks | with문으로 레이아웃, .click()으로 이벤트 |
| history 형식 | 신버전: {‘role’: …, ‘content’: …} 딕셔너리 |
| FAQ 챗봇 패턴 | FAQ를 system 프롬프트에 삽입 → 간단 RAG |
| LLM+Gradio 연결 | history 변환 → LangChain 메시지 → LLM → .content |
14. 자주 나오는 실수 / 주의사항
실수 1: history 형식 착각 (★ 가장 흔한 실수)
1
2
3
4
5
6
7
8
9
10
| # 신버전 Gradio에서 구버전 튜플 방식 사용
def chat(message, history):
for human, ai in history: # ValueError! (신버전은 딕셔너리)
...
# 올바른 코드
def chat(message, history):
for msg in history:
if msg['role'] == 'user':
...
|
실수 2: RunnableParallel에서 llm 빠뜨리기
1
2
3
4
5
6
7
8
9
| # 틀린 코드 → ChatPromptValue 객체 반환 (텍스트 아님)
parallel_chain = RunnableParallel(
summary = ChatPromptTemplate.from_template('{text} 요약해줘') # llm 없음!
)
# 올바른 코드
parallel_chain = RunnableParallel(
summary = ChatPromptTemplate.from_template('{text} 요약해줘') | llm | parser
)
|
실수 3: RunnableBranch 기본 체인 위치
1
2
3
4
5
6
| # 기본 체인은 반드시 마지막에!
branch = RunnableBranch(
(lambda x: ..., chain1), # 조건1
(lambda x: ..., chain2), # 조건2
default_chain # ← 마지막! 앞에 두면 에러
)
|
실수 4: demo.launch() 전에 이전 demo 닫기
1
2
3
4
5
6
| # Jupyter에서 같은 포트를 재사용하려면
demo.close() # 이전 데모 닫기
# 또는
demo1.close()
demo2 = gr.Interface(...)
demo2.launch()
|
끝