SK플래닛 ai활용 데이터엔지니어 과정 2기/ML & DL

LangGraph 4 - RAG 에이전트

dev-lee 2026. 6. 2. 13:01

1. 이번 단계에서 달라지는 것

LLM이 모르는 "사내·최신 데이터"를 RAG로 끌어와 추론에 반영하는 식사 추천 에이전트를 만듦.

③편까지의 에이전트는 곱셈처럼 자체적으로 풀 수 있는 도구만 썼음. 이번에는 LLM이 모르는 정보를 다룸. RAG(Retrieval-Augmented Generation)를 도구로 차용해 다음 문제를 해결함.

  • 할루시네이션 방지 — 존재하지 않는 식당을 지어내지 않도록, 실제 데이터를 검색해 근거로 제공함.
  • 내부 데이터 접근 — LLM이 모르는 private 데이터(보안 이슈로 학습되지 않은 정보)를 보완함.
  • 최신 정보 부재 해소 — 학습 시점 이후의 정보를 외부에서 가져옴.

설계 방향은 다음과 같음.

  • 전체 의사결정 — LangGraph가 담당함.
  • 부분 체인 — 프롬프트 → LLM 추론은 LangChain으로 묶어 하나의 노드로 구성함.
  • 프롬프트 기법 — FewShot으로 답변 톤과 형식을 잡음.

②~③편이 "계산 도구"였다면, ④편의 도구는 "지식 검색"이다. 도구의 성격이 행동에서 지식 보강으로 확장된다.


2. RAG 저장소 구성 (rag_store.py)

더미 식당 데이터를 임베딩해 FAISS 벡터 DB에 적재하고, 유사도 검색 함수를 제공함.
from langchain_community.vectorstores import FAISS
from langchain_aws import BedrockEmbeddings
import boto3, os
from dotenv import load_dotenv

load_dotenv()

# 임베딩 모델(토크나이저) 구성
tokenizer = BedrockEmbeddings(
    model_id="amazon.titan-embed-text-v2:0",
    region_name=os.getenv('AWS_REGION')
)

# 더미 데이터 — LLM이 모르는 사내/최신 데이터 가정
data = [
    "가게명: 스파이시 웍, 메뉴: 마라탕, 꿔바로우, 특징: 아주 매움, 스트레스 풀림, 가격: 15000원",
    "가게명: 헬시 샐러드, 메뉴: 닭가슴살 샐러드, 샌드위치, 특징: 다이어트, 가벼움, 신선함, 가격: 9000원",
    "가게명: 엄마손 백반, 메뉴: 김치찌개, 제육볶음, 특징: 집밥 스타일, 가성비, 든든함, 가격: 8000원",
    "가게명: 골든 스시, 메뉴: 초밥 세트, 우동, 특징: 고급스러움, 깔끔함, 월급날 추천, 가격: 25000원",
    "가게명: 해장국 천국, 메뉴: 뼈해장국, 순대국, 특징: 국물 진함, 비 오는 날 추천, 가격: 10000원"
]

# 벡터화 -> 벡터 DB 세팅
vector_db = FAISS.from_texts(data, embedding=tokenizer)

# 검색 함수: 질의 -> 유사도 검색 -> 상위 k개 반환
def search_stores(query: str, k: int = 2):
    docs = vector_db.similarity_search(query, k)
    print(f'==== [RAG 검색 결과] : {docs}')
    return '\n'.join([doc.page_content for doc in docs])
현재는 인메모리 FAISS로 1회성 구성임. 향후 벡터 DB는 외부에 영속 구성하는 것이 실서비스 방향임.

3. 도구 래핑 (tools.py)

검색 함수를 `@tool`로 감싸 LLM이 호출 가능한 도구로 만듦.
from langchain_core.tools import tool
from rag_store import search_stores

@tool
def rag_search(cate: str) -> str:
    '''
    가격, 특징, 메뉴, 카테고리 등을 입력받아 벡터 유사도 검색 ->
    실제 식당 정보 제공(할루시네이션 회피)
    '''
    res = search_stores(cate)  # 상위 2개 반환
    return res if res else '관련 식당 정보를 찾을 수 없습니다.'
도구를 별도 모듈로 분리해, 도구가 늘어나도 에이전트 본체가 비대해지지 않게 했음.

4. 에이전트 본체 (lg_rag_agent.py)

4.1 모델·도구 준비

온도·토큰 한도를 지정한 Bedrock 모델에 RAG 도구를 바인딩함.
from langgraph.graph import StateGraph, END
from typing import TypedDict, List
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, BaseMessage
from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate
from langchain_aws import ChatBedrockConverse
from langgraph.prebuilt import ToolNode, tools_condition
from dotenv import load_dotenv
import os, boto3
from tools import rag_search   # 외부에 구성한 커스텀 RAG 도구

load_dotenv()

llm = ChatBedrockConverse(
    model=os.getenv('MODEL_ID'),
    max_tokens=1000,
    temperature=0.5,
    region_name=os.getenv('AWS_REGION')
)

tools = [rag_search]
llm_with_tools = llm.bind_tools(tools)

4.2 FewShot 프롬프트 구성

예시 입력-출력 쌍을 주어 답변 톤(센스 있는 추천)을 학습시킴.
# 샘플 형태는 다양하게 구성 가능
examples = [
    {"input": "비 오는 날 국밥이 땡겨", "output": "국룰이죠. 칼국수와 잔치국수가 좋습니다."},
    {"input": "다이어트를 위해서 오늘 칼로리가 낮은 걸로 추천해줘.", "output": "관리하시는군요. 닭가슴 샐러드 드세요."}
]
examples_prompt = ChatPromptTemplate.from_messages([
    ('human', '{input}'),
    ('ai', '{output}')
])
few_shot_prompt = FewShotChatMessagePromptTemplate(
    examples=examples,
    example_prompt=examples_prompt
)

final_prompt = ChatPromptTemplate.from_messages([
    # 1. 페르소나
    ('system', '당신은 센스있는 식사 메뉴 추천 전문가입니다. 사용자의 상황에 맞춰 메뉴를 추천하고, 필요하면 도구를 사용해 실제 식당을 찾으세요.'),
    # 2. 퓨샷 샘플
    few_shot_prompt,
    # 3. 사용자 질의
    ('human', '{query}')
])
`시스템(페르소나) → FewShot 예시 → 사용자 질의` 순서로 프롬프트가 조립됨.

FewShot은 "이런 식으로 답하라"는 모범 답안을 보여주는 기법이다. 규칙을 길게 설명하는 것보다 예시 한두 개가 톤을 더 정확히 잡아준다.

4.3 상태 정의

메시지 리스트를 담는 커스텀 상태를 ①편처럼 직접 정의함.
class AgentState(TypedDict):
    messages: List[BaseMessage]

5. 노드 3종 구성

"생각 → (필요 시)도구 → 최종 답변" 세 노드로 의사결정을 나눔.

5.1 thinking_node — 1차 추론

사용자 질의를 받아 직접 답할지, 도구를 쓸지 판단함.
def thinking_node(state: AgentState):
    # 사용자 입력 추출 (UI 입력 -> 서버 -> 그래프.invoke(프롬프트) -> state['messages'])
    msg = state['messages'][-1].content
    # 랭체인 단방향 구성: FewShot 프롬프트(페르소나+샘플) | LLM
    chain = final_prompt | llm_with_tools
    # 1차 추론
    res = chain.invoke({"query": msg})
    return {'messages': [res]}
프롬프트와 LLM을 `|`로 묶은 LangChain 체인을 하나의 노드 안에 넣은 점이 포인트임.

5.2 tool_node — RAG 검색 수행

LLM이 도구 사용을 결정했을 때, 실제 RAG 검색을 실행하고 결과를 메시지로 되돌림.
def tool_node(state: AgentState):
    last_msg = state['messages'][-1]
    print('tool_node 호출 : 툴 사용 LLM이 응답', last_msg.tool_calls)
    if last_msg.tool_calls:
        # 등록 도구가 1개라 0으로 고정. 도구가 늘면 상황별 선택 부여
        tool = last_msg.tool_calls[0]
        # tool_calls 예시 형태:
        # [{'name':'rag_search', 'args':{'cate':'가벼운 식사'},
        #   'id':'tooluse-...', 'type':'tool_call'}]
        result = rag_search.invoke(tool['args'])  # RAG 수행
        print('RAG 호출 결과 :', result)
        return {"messages": [
            HumanMessage(content=f'[사내데이터 검색결과]: {result}\n 제공된 정보를 기반으로 최종 답변을 해주세요.')
        ]}
검색 결과를 다시 사람 메시지처럼 주입해, 다음 노드가 그 근거로 답변하도록 유도함.

5.3 final_answer_node — 근거 기반 최종 답변

검색 결과가 포함된 전체 맥락으로 LLM이 마무리 답변을 생성함.
def final_answer_node(state: AgentState):
    msg = state['messages']
    res = llm.invoke(msg)
    return {'messages': [res]}
여기서는 별도 노드로 분리했지만, 실제로는 다시 `thinking_node`로 돌려보내 마무리해도 무방함.

6. 조건부 엣지와 흐름

커스텀 분기 함수로 "도구가 필요한지"를 직접 판정함.

②~③편은 라이브러리 제공 tools_condition을 썼음. 이번에는 분기 로직을 직접 작성해 동작을 명시적으로 드러냄.

def custom_check_tool_node(state: AgentState):
    # 마지막 AI 응답에서 tool_calls 확인
    last_msg = state['messages'][-1]
    if last_msg.tool_calls:
        print('툴 사용 필요')
        return 'tool'
    else:  # 도구 불필요 -> 답변 완성 -> END
        return END

workflow = StateGraph(AgentState)
workflow.add_node('thinking', thinking_node)
workflow.add_node('tool', tool_node)
workflow.add_node('final_answer', final_answer_node)
workflow.set_entry_point('thinking')

workflow.add_conditional_edges('thinking', custom_check_tool_node)
workflow.add_edge('tool', 'final_answer')   # 도구 사용 -> 최종 답변
workflow.add_edge('final_answer', END)

랭그래프객체 = workflow.compile()

흐름 시나리오는 다음과 같음.

시나리오  흐름
베스트 프롬프트 → thinking → END
RAG 경유 프롬프트 → thinking → (부족) → tool → RAG → final_answer → END

②편의 분기가 라이브러리에 맡긴 자동 분기였다면, 여기서는 분기 기준을 직접 코딩했다. 동작 원리를 손으로 짚어보는 단계다.


7. 실행

상황과 위치를 담은 질의를 던지면, RAG로 실제 식당을 찾아 식단까지 구성함.
if __name__ == '__main__':
    res = 랭그래프객체.invoke({
        "messages": [HumanMessage(content="근처에서 점심 추천 식당 세 군데 해주고 식단까지 그 식당 메뉴에 맞춰 짜줘. 영등포역이야")]
    })
    print(res)
LLM이 추천만으로 부족하다고 판단하면 `rag_search`를 호출해 더미 DB의 실제 식당 정보를 근거로 답변을 마무리함.

요약

RAG를 도구로 결합하고 노드를 3단계로 나눠, 근거 기반으로 답하는 식사 추천 에이전트를 완성했음.

 

항목  LangGraph 3 LangGraph 4
도구 성격 계산(행동) 지식 검색(RAG)
데이터 출처 LLM 내부 지식 외부 벡터 DB(FAISS)
프롬프트 기본 FewShot + 페르소나
노드 수 2개(chatbot/tools) 3개(thinking/tool/final)
분기 tools_condition 커스텀 분기 함수
모듈 구성 단일 파일 본체/도구/저장소 분리

4단계를 거치며 그래프는 "고정 흐름 → 자율 분기 → 기억 → 외부 지식 보강"으로 발전했다. 다음 확장 지점은 외부 영속 벡터 DB, 다중 도구 선택, 그리고 MCP 연계다.