LLM 서비스 구축 · RAG
프롬프트 엔지니어링 개념을 실제 동작하는 서비스로 구현함.
Streamlit(프론트) + FastAPI(백엔드) + Bedrock + LangChain으로 "식사 메뉴 추천 AI"를 구성하고,
이후 RAG + Vector DB(FAISS)를 도입하여 LLM이 모르는 데이터를 추론에 활용함.
1. 서비스 아키텍처
화면(Streamlit)과 API(FastAPI)를 분리하고, LLM 호출은 별도 모듈로 격리하는 계층형 구조로 설계함.
1.1 구성 요소
| 계층 | 역할 | 기술 |
| 프론트엔드 | 채팅 화면, 입력/출력 처리 | Streamlit |
| 백엔드 | /chat API 제공, 프론트 ↔ Bedrock 중계 | FastAPI + uvicorn |
| LLM 모듈 | 프롬프트 구성, Bedrock 호출, 체인 구성 | LangChain (langchain-aws) |
| 모델 서빙 | LLM 추론 담당 | AWS Bedrock |
- 서비스 주제 — 점심/저녁 식사 메뉴 추천 (날씨·기분·단체여부·예산·MBTI 등 상황 기반)
- 프론트와 백엔드 분리 — 프론트는 화면만, 백엔드는 LLM 통신만 담당하여 책임을 나눔
1.2 프로젝트 구조
/
├─ .env : 환경변수 (Bedrock 키, 리전, 모델 ID)
├─ .gitignore : 가상환경/.env 등 git 미반영 처리
├─ requirements.txt : 패키지 목록
├─ app.py : 프론트엔드 (Streamlit)
├─ server.py : 백엔드 (FastAPI)
└─ llm/__init__.py : Bedrock 통신 + LangChain 체인 모듈
1.3 환경 설정
# 가상환경 구축 및 활성화 (Windows)
python -m venv llm_venv
./llm_venv/Scripts/activate
# 패키지 설치
pip install -r requirements.txt
# requirements.txt
fastapi # 백엔드 구성
uvicorn # FastAPI 구동
streamlit # 프론트 구성
requests # 프론트 -> 백엔드 요청
boto3 # AWS SDK
langchain-aws # 랭체인 AWS 전용
langchain-core # 랭체인 코어
python-dotenv # .env 로드
# .env (키는 git에 올리지 않음)
AWS_REGION='us-east-1'
MODEL_ID='google.gemma-3-27b-it'
AWS_BEARER_TOKEN_BEDROCK='bedrock-api-key-...'
키 정보는 .env로 분리하고 .gitignore에 등록함. 코드에는 os.getenv()로 주입받아 자격증명이 소스에 노출되지 않도록 함.
2. 백엔드 — FastAPI
프론트에서 들어온 채팅 입력을 받아 LLM 체인을 호출하고, 응답을 다시 프론트로 전달하는 중계 서버.
2.1 server.py
from fastapi import FastAPI
from pydantic import BaseModel # 입력 구조 정의 + 유효성 검사
from llm import chain # LLM 모듈의 체인 import
app = FastAPI(title='식사 메뉴 추천 AI')
# 요청 데이터 구조 정의
class UserRequest(BaseModel):
query: str
@app.post('/chat')
async def chat(req: UserRequest):
try:
response = chain.invoke({"user_input": req.query})
return {"response": response.content}
except Exception as e:
return {"response": f"에러 {e}"}
- Pydantic BaseModel — 요청 JSON의 구조(query: str)를 강제하여 잘못된 입력을 사전 차단
- chain.invoke() — LangChain 체인 호출, 입력 변수명(user_input)이 프롬프트 템플릿과 일치해야 함
- response.content — LangChain 응답 객체에서 본문 텍스트만 추출
2.2 요청 흐름
클라이언트 채팅 입력 -> POST /chat -> 프롬프트 구성 -> Bedrock 호출 -> 응답 파싱 -> 프론트 전달
화면이 없는 순수 API 서버임. try/except로 LLM 호출 실패 시에도 서버가 죽지 않고 에러 메시지를 응답으로 반환하도록 처리함.
3. LLM 모듈 — Bedrock + LangChain
Bedrock 클라이언트 생성부터 Few-Shot 프롬프트, 체인 구성까지 담당하는 핵심 모듈.
3.1 클라이언트 / 모델 구성
import os, boto3
from dotenv import load_dotenv
from langchain_aws import ChatBedrockConverse
from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate
load_dotenv()
# Bedrock 클라이언트
bedrock_client = boto3.client(
service_name='bedrock-runtime',
region_name=os.getenv('AWS_REGION')
)
# LLM 객체
llm = ChatBedrockConverse(
client=bedrock_client,
model_id=os.getenv('MODEL_ID'),
max_tokens=512,
temperature=0.7
)
- ChatBedrockConverse — Bedrock의 converse API를 LangChain 인터페이스로 래핑, 모델명만 바꿔도 동일 코드로 호출 가능
- temperature=0.7 — 추천 서비스 특성상 약간의 다양성을 허용
- max_tokens=512 — 응답 길이 제한
3.2 Few-Shot 프롬프트 구성
# 1. 예시 데이터 (입력 -> 모범 출력)
fewshot_samples = [
{"input": "오늘 점심 메뉴 추천해줘. 비가 오고 있어서 나가기 귀찮아. 혼자 먹을 거야.",
"output": "비 오는 날, 집에서 편하게 즐길 수 있는 '따뜻한 김치수제비와 해물파전'을 추천합니다! ..."},
# ... (회식, 데이트, 스트레스 상황 등 4개)
]
# 2. 예시 1건의 포맷 (human -> ai)
fewshot_samples_format = ChatPromptTemplate.from_messages([
('human', '{input}'),
('ai', '{output}')
])
# 3. Few-Shot 프롬프트로 변환
fewshot_prompt = FewShotChatMessagePromptTemplate(
examples=fewshot_samples,
example_prompt=fewshot_samples_format
)
- Few-Shot의 목적 — 파인튜닝 없이 "일정한 품질의 결과물"을 유도, 답변 톤과 형식을 예시로 학습시킴
- FewShotChatMessagePromptTemplate — 예시를 실제 대화(human/ai) 형태로 주입하여 채팅 모델에 자연스럽게 전달
- 상황 다양화 — 비 오는 날 혼밥, 단체 회식, 데이트, 스트레스 등 시나리오를 골고루 넣어 일반화 유도
3.3 체인 구성
# 최종 프롬프트 = 페르소나(system) + 예시(few-shot) + 사용자 입력(human)
last_prompt = ChatPromptTemplate.from_messages([
('system', '당신은 직장인들의 식사 메뉴 고민을 해결해주는 계획적인 메뉴 추천 전문가입니다. ...'),
fewshot_prompt, # 샘플 삽입
('human', '{user_input}') # 실제 사용자 질의
])
# 단방향 체인 (프롬프트 -> LLM)
chain = last_prompt | llm
프롬프트는 페르소나 → Few-Shot 예시 → 사용자 입력 순으로 쌓임. 파이프 연산자(|)로 prompt | llm 체인을 만들면, 어떤 모델을 쓰든 chain.invoke() 하나로 호출 인터페이스가 통일됨.
4. 프론트엔드 — Streamlit
파이썬만으로 채팅 UI를 구성하고, 대화 맥락은 세션 상태로 관리함.
4.1 세션 상태 기반 대화 관리
import streamlit as st
import requests as req
API_URL = 'http://localhost:8000/chat'
st.set_page_config(page_title='식사 메뉴 추천')
st.title('AI 식사 메뉴 추천')
# 대화 내용 저장 공간 (최초 1회만 초기화)
if "messages" not in st.session_state:
st.session_state.messages = [
{'role': 'assistant', 'content': '안녕하세요! 오늘 식사 메뉴를 추천해드릴게요. 정보를 입력해주세요.'}
]
# 이전 대화 내용 화면 출력
for msg in st.session_state.messages:
with st.chat_message(msg['role']):
st.markdown(msg['content'])
- st.session_state — 재실행(rerun) 사이에도 대화 내역을 유지하는 저장소, 새로고침 전까지 맥락 보존
- 초기화 가드 — if "messages" not in st.session_state로 최초 1회만 기본 안내 메시지 세팅
- st.chat_message — role(user/assistant)에 따라 채팅 말풍선 UI 자동 렌더링
4.2 채팅 입력 처리
if prompt := st.chat_input('현재 상황을 자세하게 입력하세요...'):
# 1. 사용자 입력 저장 및 출력
st.session_state.messages.append({'role': 'user', 'content': prompt})
with st.chat_message('user'):
st.markdown(prompt)
# 2. "생각 중" 연출
with st.chat_message('assistant'):
msg_holder = st.empty()
msg_holder.markdown('생각 중입니다...')
# 3. 백엔드 호출
try:
res = req.post(API_URL, json={"query": prompt})
result = res.json().get('response', '...') if res.status_code == 200 else f'서버 오류 {res.status_code}'
except Exception as e:
result = f'서버 오류 {e}'
# 4. 응답 출력 및 저장
msg_holder.markdown(result)
st.session_state.messages.append({'role': 'assistant', 'content': result})
- st.chat_input — 하단 고정 입력창, 왈러스 연산자(:=)로 입력 여부 판정과 값 할당을 동시에 처리
- st.empty() placeholder — "생각 중" 메시지를 먼저 띄우고, 응답이 오면 같은 자리를 덮어쓰기
- 상태 동기화 — 사용자/AI 메시지를 모두 session_state에 append하여 다음 rerun에서도 누적 표시
프론트는 LLM을 직접 모르고 오직 /chat API만 호출함. 화면 로직과 추론 로직이 분리되어 모델을 교체해도 프론트 코드는 손댈 필요가 없음.
5. RAG — 검색 증강 생성
LLM이 학습하지 않은 데이터(사내 데이터, 최신 정보)를 검색하여 프롬프트에 함께 전달하는 기법.
5.1 RAG vs 파인튜닝
| 항목 | 파인튜닝 | RAG |
| 방식 | 신규 데이터로 모델 재학습 | 추론 시 외부 데이터를 검색하여 주입 |
| 비용 | 학습 비용 높음 | Vector DB 업데이트만 하면 됨 (효율적) |
| 최신성 | 재학습 전까지 고정 | DB만 갱신하면 즉시 반영 |
| 보안 | 사내 데이터를 학습에 노출 | 데이터를 외부 학습에 넘기지 않음 |
- RAG 사용 동기 — 사내 데이터는 노출하기 싫지만, LLM의 강력한 추론/생성 능력은 쓰고 싶을 때
- 대상 데이터 — 회사 데이터, 개인 데이터, LLM 생성 시점 이후 발생한 최신 데이터 등
- 흐름 — 추론 요청 시 관련 데이터를 검색해 프롬프트에 첨부 → (질의 + 검색 결과) 형태로 LLM 전달
5.2 Vector DB
- 역할 — RAG를 위한 장기 기억 저장소, 유사도 기반 검색 기능 제공
- 데이터 형태 — 자연어를 토크나이저로 벡터화하여 저장
- 임베딩 교체 — BedrockEmbeddings에서 모델만 바꾸면 토크나이저 교체 가능
| 제품 | 특징 |
| Pinecone | 관리형, 무료 계정은 DB 1개 제한 |
| Milvus / Qdrant | 대규모 처리에 적합한 오픈소스 |
| Chroma / FAISS | 메모리 기반, 로컬 실습에 적합 |
최신 흐름은 클라우드 중심에서 온프레미스·디바이스 AI로 이동 중임. 데이터 주권과 비용 문제로 인해 사내 구축 + RAG 조합이 현실적 대안이 됨.
6. RAG 실습 — FAISS
인메모리 검색 -> 대량 문서 청킹/저장 -> 검색 + 추론 체인까지 3단계로 RAG를 구현함.
6.1 기본 — 인메모리 FAISS
from langchain_community.vectorstores import FAISS
from langchain_aws import BedrockEmbeddings
# LLM이 모르는 데이터라고 가정
data = [
"맥도날드의 대표 제품은 빅맥이다.",
"삼성은 한국의 최대 기업이다.",
"6월 3일에 선거가 예정되어 있다.",
"서브노티카2가 최근 OBT를 시작하였다."
]
# 임베딩 모델 (자연어 -> 분절 -> 벡터화 -> 패딩)
tokenizer = BedrockEmbeddings(
model_id="amazon.titan-embed-text-v2:0",
region_name=os.getenv('AWS_REGION')
)
# 텍스트를 벡터화하여 메모리 DB에 적재
vector_db = FAISS.from_texts(data, tokenizer)
# 유사도 기반 검색
docs = vector_db.similarity_search("요즘 OBT를 시작한 게임이 뭐였지")
print(docs[0].page_content) # -> "서브노티카2가 최근 OBT를 시작하였다."
- BedrockEmbeddings — 자연어를 벡터로 변환하는 임베딩 모델, 검색·저장 모두 동일 모델 사용
- FAISS.from_texts — 문자열 리스트를 벡터화하여 메모리 기반 DB 구성
- similarity_search — 질의와 의미적으로 가장 가까운 문서를 반환 (키워드 일치가 아닌 유사도 기반)
6.2 대량 문서 — 청킹 & 저장
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
import glob
# 1. 문서 로드
files = glob.glob('./rag/data/*.txt')
raw_docs = [TextLoader(file, encoding='utf-8').load()[0] for file in files]
# 2. 청크 단위 분할
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 자르는 단위
chunk_overlap=100, # 문맥 유지를 위한 겹침 구간
)
splits = splitter.split_documents(raw_docs)
# 3. 벡터화 후 DB 적재 및 로컬 저장
vector_db = FAISS.from_documents(splits, tokenizer)
vector_db.save_local('hp-story')
- 청크(chunk) — 말뭉치를 토큰 제한에 맞춰 일정 크기로 분할한 단위, 크기 설정이 검색 성능에 직접 영향
- chunk_overlap — 청크 경계에서 문맥이 끊기지 않도록 앞뒤를 겹쳐 자름
- save_local — 한 번 구축한 DB를 파일(index.faiss, index.pkl)로 저장하여 재사용
- 최적 청크 크기 — 정답이 없으므로 여러 차례 시도하며 성능을 비교해 찾아야 함
6.3 RAG 체인 — 검색 + 추론
from langchain_aws import ChatBedrock
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
# 저장된 DB 로드
vector_db = FAISS.load_local('hp-story', tokenizer, allow_dangerous_deserialization=True)
# 프롬프트 (context + 질문)
prompt = ChatPromptTemplate.from_template('''
다음의 제공된 context를 사용하여 질문에 답변해주세요.
문맥에서 답을 찾을 수 없다면 "잘 모르겠음"이라고 답변하세요.
<context>{context}</context>
질문 : {user_input}
''')
# 리트리버 (상위 3개 청크 참조)
retriever = vector_db.as_retriever(search_kwargs={"k": 3})
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
# LCEL 파이프라인
rag_chain = (
{"context": retriever | format_docs, "user_input": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
res = rag_chain.invoke('톰 마볼로 리들의 능력은?')
- as_retriever(k=3) — 유사도 상위 3개 청크를 검색하는 리트리버 생성
- format_docs — 검색된 여러 청크를 하나의 context 문자열로 병합
- RunnablePassthrough — 사용자 질문을 검색과 프롬프트에 동시에 흘려보냄
- StrOutputParser — LLM 응답 객체에서 문자열만 추출 (.content 수동 호출 불필요)
- LCEL — | 연산자로 검색 → 프롬프트 → LLM → 파싱을 하나의 흐름으로 연결
메뉴 추천 체인은 prompt | llm 단방향이었지만, RAG 체인은 앞단에 검색(retriever) + 결합(format_docs) 단계가 추가됨. 결과적으로 프롬프트가 (검색된 문맥 + 질문) 형태로 구성되어, LLM이 학습하지 않은 데이터에도 근거 기반 답변을 생성함.
7. 요약
| 주제 | 핵심 내용 |
| 아키텍처 | Streamlit(프론트) / FastAPI(백엔드) / LangChain(LLM) 계층 분리 |
| 백엔드 | /chat API가 체인을 호출하고 응답을 중계, Pydantic으로 입력 검증 |
| LLM 모듈 | ChatBedrockConverse + Few-Shot으로 일정 품질의 추천 유도 |
| 프론트 | session_state로 대화 맥락 유지, API만 호출하여 모델과 분리 |
| RAG | 파인튜닝 없이 외부 데이터를 검색·주입, 비용/보안 효율적 |
| Vector DB | BedrockEmbeddings로 벡터화, FAISS로 유사도 검색 |
| RAG 체인 | 리트리버 + LCEL로 검색 → 프롬프트 → LLM → 파싱 통일 |
핵심은 체인 인터페이스의 통일임. 메뉴 추천이든 RAG든 결국 chain.invoke() 하나로 호출되며, 차이는 체인 앞단에 검색 단계가 붙느냐일 뿐임. 프롬프트 엔지니어링(LLM 1)에서 다룬 Few-Shot·페르소나 설계가 LangChain 위에서 그대로 코드화되고, RAG가 더해지면서 "모델이 모르는 데이터"까지 다룰 수 있는 실용 서비스로 확장됨.
'SK플래닛 ai활용 데이터엔지니어 과정 2기 > ML & DL' 카테고리의 다른 글
| LangGraph 2 - Tool + LLM (0) | 2026.06.02 |
|---|---|
| LangGraph 1 - 상태 그래프 기초 (0) | 2026.06.02 |
| LLM 1 - 프롬프트, 컨텍스트 (1) | 2026.05.28 |
| DL 6 - 트랜스포머 기반 학습 모델 가져오기 (0) | 2026.05.27 |
| DL 5 - NLP 실습 (0) | 2026.05.27 |