[TIL-0312] LlamaIndex 도입 - 4. LangGraph Agent의 도구로 LlamaIndex QueryEngine 통합

jiny·2026년 3월 11일

AI Agent 실습

목록 보기
9/21

🌟 개요

LangGraph Agent는 기본적으로 명시적 파라미터 기반 도구만 사용할 수 있다. 카테고리명, 플랫폼, 팔로워 수처럼 정해진 값을 넘겨야만 검색이 시작되고, 자연어 질문이나 의미적 탐색은 불가능하다.
이 글에서는 LlamaIndex의 Index/QueryEngine을 LangChain의 @tool로 래핑하여 Agent가 자연어만으로 벡터 DB와 Knowledge Graph를 탐색할 수 있도록 5개의 도구를 추가한 과정을 정리한다.

핵심은 두 프레임워크를 연결하는 방식에 있다. LlamaIndex데이터 인덱싱검색을 담당하고, @tool 데코레이터그 결과를 LangChain Agent가 이해할 수 있는 인터페이스로 변환한다.
Agent 입장에서는 내부 구현이 LlamaIndex인지 모른 채 도구를 호출하고, LlamaIndex 입장에서는 Agent가 어떻게 호출하는지 알 필요 없이 검색 결과를 반환한다.

1~3번 글에서 InfluencerVectorIndex, KnowledgeGraphQueryIndex, MatchHistoryIndex 각각의 구현을 다루면서 @tool 데코레이터, Container 지연 로딩, LlamaIndex 글로벌 설정 같은 내용을 부분적으로 언급했다.
하지만 그 글들의 초점은 각 Index가 어떻게 데이터를 검색하는가였고, 구현된 Index가 어떻게 Agent의 도구로 연결되는가는 온전히 다루지 못했다. 이 글에서는 그 연결 구조 전체를 한 번에 정리한다.


🌟 구현 구조와 통합 전략

📌 개요

LangGraph Agent는 기존에 Neo4j 기반 검색 도구들만 가지고 있었다. 이 도구들은 모두 명시적인 파라미터(카테고리명, 플랫폼, 팔로워 범위 등)를 넘겨야만 검색이 가능했다.
여기에 LlamaIndex의 QueryEngine을 LangChain @tool로 래핑하여, Agent가 자연어 질문만으로 벡터 DB와 Knowledge Graph를 탐색할 수 있는 도구 5개를 추가했다.

⚠️ 기존 방식의 문제

기존 도구들은 Agent가 먼저 파라미터를 결정해야만 검색이 시작된다.

  • search_influencers_by_category(category="Beauty")
    ➡️ 카테고리명을 정확히 알아야 함
  • query_knowledge_graph(cypher="MATCH ...")
    ➡️ Cypher 문법으로 관계를 직접 표현해야 함

"삼성전자가 과거에 협업했던 패션 인플루언서는?" 같은 질문은, Agent가 올바른 Cypher를 생성하지 못하면 결과가 없거나 틀린 답이 나왔다. 또한 과거 매칭 이력(성과 데이터, 평점, 계약 금액)을 활용한 RAG 기반 추천은 아예 불가능했다.

✨ LlamaIndex 도구 통합 후

@tool 데코레이터 하나로 LlamaIndex Index의 메서드를 LangChain 도구로 변환한다. Agent는 자연어 쿼리 문자열만 넘기면 되고, 내부에서 Index가 임베딩 → 벡터 검색 → 결과 반환을 처리한다.

@tool
def semantic_search_influencers(query: str) -> str:
   container = Container.get_instance()
   influencer_index = container.influencer_vector_index()
   return influencer_index.retrieve(query, top_k=10)  # JSON 반환

Agent는 이 도구를 기존 Cypher 도구나 파라미터 기반 검색 도구들과 동일한 방식으로 호출한다. LlamaIndex와 LangChain 사이의 프레임워크 차이를 @tool 래퍼가 완전히 가린다.

🧩 인덱스별 역할과 데이터 소스

3개의 Index 클래스가 각각 다른 데이터를 담당한다.

Index 클래스Neo4j 노드/관계역할
InfluencerVectorIndexInfluencerEmbedding 노드인플루언서 프로필 시맨틱 검색
MatchHistoryIndexPROMOTED, MATCHED_WITH 관계과거 실적 기반 RAG 추천
KnowledgeGraphQueryIndex그래프 전체 구조자연어 그래프 탐색

MatchHistoryIndex는 Neo4j에서 "인플루언서 → 광고 캠페인" 관계를 Cypher로 읽어와 Document로 변환한 뒤 임베딩한다. 즉 기존 Neo4j 그래프 데이터를 RAG의 재료로 재활용하는 구조이다.

🔧 5개 도구와 반환 형식

Agent에 등록되는 도구는 총 5개이다. 반환 형식이 도구마다 다른 이유는 Agent의 후속 처리 방식이 다르기 때문이다.

도구내부 호출반환 형식선택 이유
semantic_search_influencersinfluencer_index.retrieve()JSONAgent가 influencer_id로 후속 조회가 가능함
query_kg_natural_languagekg_index.query()자연어결과 탐색 결과는 요약이 더 유용함
analyze_match_historymatch_index.retrieve()JSON성과 수치를 Agent가 직접 비교함
search_similar_campaignsmatch_index.retrieve()JSON컨셉 + 아이템 조합으로 검색함
search_collected_ad_datacollected_ad_index.retrieve()JSON원본 실적 데이터 그대로 전달

query_kg_natural_languageas_query_engine().query()를 사용해 LLM 요약 응답을 반환하고, 나머지 4개는 retrieve() 방식으로 JSON 원본 데이터를 반환한다. Knowledge Graph 탐색은 구조적 데이터보다 자연어 요약이 Agent에게 더 이해하기 쉬운 형태이기 때문이다.

  • 다른 4개 도구가 JSON을 반환하는 이유
    Agent는 결과를 받은 후 추가 작업을 한다.
    semantic_search_influencers -> JSON
      └→ influencer_id 추출
        └→ get_influencer_details(id) 호출
          └→ calculate_match_score(id) 호출
    ➡️ influencer_id, score, likes, rating 같은 수치와 식별자를 꺼내서 후속 도구에 넘기거나 비교해야 한다. JSON이어야 Agent가 파싱해서 활용할 수 있다.
  • query_kg_natural_language가 자연어를 반환하는 이유
    KG 탐색의 목적 자체가 다르다. "삼성전자가 협업했던 인플루언서는?"이라는 질문의 답은 Agent가 그대로 사용자에게 전달하면 끝이다. 후속 계산이나 추가 도구 호출이 필요 없다.
    만약 JSON으로 반환한다면 KG 탐색 결과는 이런 형태가 된다.
    [
      {"subject": "Influencer A", "relation": "PROMOTED", "object": "Advertisement B"},
      {"subject": "Advertiser C", "relation": "CREATED_AD", "object": "Advertisement B"},
      ...
    ]
    ➡️ 트리플렛(triplet)이 수십 개 나열된 이 구조를 Agent가 읽고 "삼성전자와 협업한 인플루언서는 A와 B입니다"로 추론하는 건 LLM이 이미 잘 하는 일을 중복으로 시키는 것이다. as_query_engine()내부적으로 LLM 요약을 한 번 거치면 Agent 입장에서는 바로 답변에 쓸 수 있는 텍스트가 온다.

🏗️ Agent 등록 방식

AgentFactory에서 기존 도구들과 함께 *LLAMAINDEX_TOOLS를 단순 언패킹(리스트를 펼쳐서 바깥 컨테이너에 합치는 과정)으로 추가한다. LlamaIndex 도구가 이미 LangChain Tool 인터페이스를 따르기 때문에 별도 어댑터 없이 바로 등록된다.

tools = [
   *Neo4j_기반_기본_검색_도구들,
   *NEO4J_TOOLS,
   *LLAMAINDEX_TOOLS,  # 5개 도구 추가
   ...
]
agent = create_agent(model=llm, tools=tools, prompt=AGENT_SYSTEM_PROMPT)

🌟 관련 파일 목록

파일역할
container.pyIndex 인스턴스를 싱글톤으로 관리하고 도구에 주입
llamaindex_tools.py@tool로 5개 도구 정의 - LlamaIndex와 LangChain의 연결 지점
agent_factory.py*LLAMAINDEX_TOOLS 언패킹으로 Agent 도구 목록에 등록
    container.py → llamaindex_tools.py → agent_factory.py
(Index 인스턴스 생성)    (@tool로 래핑)      (Agent에 등록)

🌟 container.py 파일 분석

1. 파일 소개

이 파일은 Container 클래스 하나로 구성되어 있으며, 크게 두 가지 역할을 한다.

  1. LlamaIndex 글로벌 설정
    • 애플리케이션 시작 시 LLM 프로바이더(ollama/openai/anthropic)에 따라 임베딩 모델LLM전역으로 설정한다.
    • LlamaIndex는 Settings라는 전역 객체를 통해 임베딩/LLM을 관리하기 때문에, 이 설정이 한 번만 실행되면 이후 모든 Index 클래스에서 별도 설정 없이 공유된다.
  1. Index 인스턴스 관리
    • InfluencerVectorIndex, KnowledgeGraphQueryIndex, MatchHistoryIndex, CollectedAdIndex 4개의 Index 인스턴스를 생성하고 제공한다.
    • LlamaIndex 도구들(llamaindex_tools.py)은 Index 객체를 직접 생성하지 않고, Container.get_instance()를 통해 여기서 받아간다.

싱글톤 패턴을 적용해 프로세스 전체에서 Container 인스턴스가 하나만 존재하도록 보장한다.
Index 객체는 생성 시 Neo4j에 연결하고 임베딩 모델을 로드하는 비용이 크기 때문에, 도구 호출마다 새로 만들지 않고 최초 1회만 생성해 재사용한다.
llamaindex_tools.py의 모든 도구 함수가 Container.get_instance()를 호출하는 이유가 여기에 있다.

2. 싱글톤 패턴

class Container:
   _instance: Optional["Container"] = None
   
   @classmethod
   def get_instance(cls, settings: Optional[Settings] = None) -> "Container":
      if cls._instance is None:
         cls._instance = cls(settings)
      return cls._instance
   
   @classmethod
   def reset_instance(cls) -> None:
      if cls._instance and cls._instance._neo4j_database:
         cls._instance._neo4j_database.close()
      cls._instance = None

_instance클래스 변수로 선언되어 있어 모든 인스턴스가 공유한다.
get_instance()를 호출할 때 _instanceNone이면 처음 한 번만 생성하고, 이미 있으면 기존 것을 그대로 반환한다. 즉 Container.get_instance()를 아무리 많이 호출해도 항상 동일한 객체가 반환된다.
이 패턴이 필요한 이유는 llamaIndex_tools.py의 도구 함수들이 호출될 때마다 Container에서 Index 인스턴스를 꺼내기 때문이다.

# llamaindex_tools.py - 도구가 호출될 때마다 실행됨
@tool
def semantic_search_influencers(query: str) -> str:
   container = Container.get_instance()  # 매번 호출
   influencer_index = container.influencer_vector_index()
   return influencer_index.retrieve(query)

싱글톤이 없다면 Agent가 도구를 10번 호출할 때 Container와 Index가 10번 새로 생성된다.
Index 생성은 Neo4j 연결과 임베딩 모델 로드를 포함하기 때문에 비용이 크다. 싱글톤으로 묶어두면 최초 1회만 생성하고 이후 호출은 캐시된 인스턴스를 재사용한다.

reset_instance()는 테스트 환경에서 싱글톤을 초기화할 때 사용한다. Neo4j 연결이 열려 있으면 close()로 명시적으로 닫은 뒤 _instanceNone으로 리셋한다.
현재 프로젝트에서는 사용되지 않고 있지만, 테스트 코드 작성 시 싱글톤을 리셋하기 위한 용도로 준비된 메서드이다.

3. LlamaIndex 글로벌 설정

if self.settings.llm_provider == "openai":
   LlamaSettings.embed_model = OpenAIEmbedding(api_key=...)
   LlamaSettings.llm = OpenAILLM(api_key=..., model=...)
elif self.settings.llm_provider == "anthropic":
   LlamaSettings.embed_model = OllamaEmbedding(model_name=..., base_url=...)  # Ollama 유지
   LlamaSettings.llm = AnthropicLLM(api_key=..., model=...)
else:  # ollama (기본값)
   LlamaSettings.embed_model = OllamaEmbedding(model_name=..., base_url=...)
   LlamaSettings.llm = OllamaLLM(model=..., base_url=...)

LlamaSettingsLlamaIndex가 제공하는 전역 설정 객체이다.
여기에 embed_modelllm을 한 번 설정해두면, 이후 InfluencerVectorIndex, MatchHistoryIndex모든 Index 클래스에서 별도 설정 없이 자동으로 참조한다. 각 Index에 모델을 일일이 주입하지 않아도 되는 이유가 여기에 있다.

주목할 점은 Anthropic 분기이다. Anthropic은 자체 임베딩 API를 제공하지 않기 때문에, LLM만 Anthropic으로 교체하고 임베딩 모델은 Ollama를 그대로 유지한다.
즉 Anthropic 프로바이더를 선택하면 임베딩은 Ollama, 추론은 Anthropic이라는 혼합 구성이 된다.

이 설정은 Container.__init__()에서 실행되므로 싱글톤 인스턴스가 처음 생성될 때 딱 한 번만 수행된다. 이후 도구 호출이 몇 번이든 LlamaSettings는 변경되지 않고 유지된다.

4. lazy 초기화: if None 패턴

Index 인스턴스 생성은 if None 체크 하나로 관리한다.

def influencer_vector_index(self) -> InfluencerVectorIndex:
   if self._influencer_vector_index is None:
      self._influencer_vector_index = InfluencerVectorIndex(
         neo4j_database=self.neo4j_database,
      )
   return self._influencer_vector_index
  1. lazy 초기화란?
    __init__에서 모든 Index를 즉시 생성하지 않고, 처음 실제로 사용될 때 생성하는 패턴이다.
    Index 객체는 생성 시점에 Neo4j에 연결하고 벡터 스토어를 초기화하는 비용이 크다. 애플리케이션 시작 시 무조건 모든 Index를 생성하면 불필요한 리소스가 소모된다.

  2. 동작 방식
    Container.__init__에서 _influencer_vector_index = None으로 초기화
    ② 도구에서 처음 container.influencer_vector_index()를 호출하면 None 체크 통과 → 인스턴스 생성
    ③ 이후 동일한 Container 인스턴스에서 재호출하면 이미 None이 아니므로 기존 인스턴스 반환

    Container 자체가 싱글톤이므로 self는 항상 같은 객체이다. 따라서 if None 체크만으로 Index 인스턴스도 사실상 싱글톤처럼 동작한다.

  3. 이전 코드와의 차이
    이전 코드에는 @lru_cache가 함께 붙어 있었다.

    @lru_cache
    def influencer_vector_index(self) -> InfluencerVectorIndex:
       if self._influencer_vector_index is None:
          ...

    @lru_cache함수 호출 결과를 인자 기준으로 캐시한다. 인스턴스 메서드에 붙이면 self가 캐시 키에 포함된다.
    Container가 싱글톤이므로 self는 항상 동일한 객체이다.
    따라서 @lru_cache의 캐시 키도 항상 같아서, @lru_cache만으로도 if None과 똑같이 "첫 호출에만 실행, 이후에는 캐시된 값 반환" 효과를 낸다.

    결과적으로 두 장치가 같은 역할을 중복 수행하는 구조였다. @lru_cache는 인스턴스 메서드에 사용할 경우 Container 인스턴스가 캐시 키로 잡혀 메모리에 계속 남는 부작용도 있다.
    if None 패턴이 인스턴스 생명주기를 더 명시적으로 제어하므로 @lru_cache를 제거하고 if None만 남기는 것이 적절하다.

5. 도구와의 연결 고리

llamaindex_tools.py의 모든 도구 함수는 실행될 때마다 Container에서 Index 인스턴스를 꺼내온다.

@tool
def semantic_search_influencers(query: str) -> str:
   container = Container.get_instance()                    # 1. 싱글톤 컨테이너 획득
   influencer_index = container.influencer_vector_index()  # 2. Index 인스턴스 획득
   return influencer_index.retrieve(query, top_k=10)       # 3. 검색 실행
   
@tool
def query_kg_natural_language(query: str) -> str:
   container = Container.get_instance()
   kg_index = container.kg_query_index()  # 다른 Index 사용
   return kg_index.query(query)

@tool
def analyze_match_history(query: str) -> str:
   container = Container.get_instance()
   match_index = container.match_history_index()
   return match_index.retrieve(query, top_k=5)

도구 함수가 Index를 생성자에서 받아두지 않고 매번 Container.get_instance()를 호출하는 이유가 있다.
LangChain @tool 데코레이터함수를 모듈 로드 시점(import가 실행되는 순간)에 Tool 객체로 변환하는데, 이 시점에는 아직 Container가 초기화되어 있지 않을 수 있다.
함수 본문 안에서 Container를 가져오면 실제 도구가 호출되는 시점, 즉 Container가 이미 준비된 이후에 Index를 참조하게 된다.

싱글톤 덕분에 get_instance() 호출 비용은 사실상 0에 가깝고, Index 인스턴스는 if None 체크로 lazy 초기화되어 있으므로 재생성도 일어나지 않는다.
도구가 아무리 많이 호출되어도 Index 객체는 항상 동일한 인스턴스가 재사용된다.


🌟 llamaindex_tools.py 파일 분석

1. 파일 소개

이 파일은 5개의 @tool 함수와 LLAMAINDEX_TOOLS 리스트로 구성되어 있으며, LlamaIndex Index 메서드를 LangChain Agent 도구로 변환하는 브릿지 역할을 한다.

앞서 구현한 InfluencerVectorIndex, KnowledgeGraphQueryIndex, MatchHistoryIndex, CollectedAdIndex는 각각 강력한 검색 기능을 갖추고 있지만, 그 자체로는 LangGraph Agent가 호출할 수 없다. Agent는 LangChain BaseTool 인터페이스를 따르는 도구만 인식하기 때문이다.
이 파일은 각 Index의 query() 또는 retrieve() 메서드를 @tool 데코레이터로 감싸서 Agent가 바로 사용할 수 있는 형태로 반환한다.

5개 도구는 각각 다른 Index와 연결되어 있으며, 용도에 따라 분리되어 있다.
인플루언서 프로필 시맨틱 검색, Knowledge Graph 자연어 탐색, 과거 매칭 이력 검색, 유사 캠페인 검색, 과거 광고 캠페인 검색으로 구성된다.
파일 하단의 LLAMAINDEX_TOOLS 리스트에 5개를 묶어두고, agent_factory.py에서 *LLAMAINDEX_TOOLS로 언패킹하여 Agent 도구 목록에 일괄 등록한다.

2. @tool 데코레이터 원리

from langchain_core.tools import tool

@tool
def semantic_search_influencers(query: str) -> str:
   """광고 컨셉을 자연어로 입력하면 의미적으로 가장 적합한 인플루언서를 검색합니다.
   ...
   """
   ...

@toollangchain_core가 제공하는 데코레이터로, 일반 Python 함수를 LangChain BaseTool 객체로 변환한다.
변환 결과로 만들어진 Tool 객체는 세 가지 정보를 가진다.

  • name: 함수 이름 (semantic_search_influencers)
  • description: 함수의 docstring 전체
  • args_schema: 함수 파라미터와 타입 힌트로 자동 생성된 입력 스키마

Agent가 도구를 선택할 때 description을 읽고 판단한다.
"카테고리명을 정확히 모르거나, 키워드 검색으로 적절한 결과를 찾기 어려울 때 사용하세요"라는 설명이 있으면, Agent는 해당 상황에서 이 도구를 선택한다.
docstring이 곧 Agent에게 보내는 도구 사용 설명서인 셈이다.

@tool 적용은 모듈이 로드되는 시점에 일어난다.
llamaindex_tools.py가 import될 때 5개 함수가 이미 BaseTool 객체로 변환되어 LLAMAINDEX_TOOLS 리스트에 담긴다.
agent_factory.py에서 이 리스트를 언패킹할 때는 이미 완성된 Tool 객체들을 꺼내서 쓰는 것뿐이다.

llamaindex_tools.py import 시점:
   @tool 적용 → BaseTool 객체 생성
      name: "semantic_search_influencers"
      description: "광고 컨셉을 자연어로..."
      args_schema: {"query": str}

agent_factory.py:
   tools = [..., *LLAMAINDEX_TOOLS] ← 이미 완성된 객체를 꺼냄
   create_agent(tools=tools)

3. Container 지연 import 패턴

# 파일 상단
from langchain_core.tools import tool
# Container 임포트 없음

@tool
def semantic_search_influencers(query: str) -> str:
   from infrastructure.container import Container  # 함수 내부에서 import
   container = Container.get_instance()
   ...

일반적으로 import는 파일 상단에 한 번만 작성한다.
그런데 이 파일의 모든 도구 함수는 Container import를 함수 본문 안에서 수행한다. 이유는 @tool 데코레이터의 동작 시점과 관련이 있다.

@tool모듈이 로드되는 시점에 함수를 BaseTool 객체로 변환한다. 그런데 이 시점은 app.pymain()이 실행되기 전이므로, Container가 아직 초기화되지 않은 상태이다.

만약 Container를 파일 상단에서 import하면, llamaindex_tools.py가 로드되는 순간 container.py도 함께 로드되고 Container 클래스가 초기화된다.
문제는 이 시점에 아직 .env 설정이 읽히지 않았거나, Neo4j 연결 정보가 준비되지 않았을 수 있다는 점이다.

# 상단 import 시 발생하는 문제
앱 시작
  └→ llamaindex_tools.py 로드
         └→ from infrastructure.container import Container ← 즉시 실행
                └→ Container 초기화 시도 → 설정/연결 미준비 → 오류 가능

함수 내부에서 import하면 실행 시점이 도구가 실제로 호출될 때로 미뤄진다.
그때는 이미 앱이 완전히 초기화되어 Container 싱글톤도 준비된 상태이다.

# 지연 import 시 흐름
앱 시작 → Container 초기화 완료
  └→ Agent가 도구 호출
         └→ from infrastructure.container import Container ← 이 시점에 실행
               └→ Container.get_instance() → 이미 준비된 싱글톤 반환

한 가지 더, Python의 import는 모듈 단위로 캐시된다. container.py가 한 번 로드되면 이후 from infrastructure.container import Container를 몇 번 호출하든 실제 파일을 다시 읽지 않고 캐시된 모듈을 반환한다.
5개 도구 함수가 모두 같은 import 구문을 가지고 있어도 실제 로드는 최초 1회만 일어난다.

4. 5개 도구 분석

5개 도구는 각각 다른 Index와 연결되며, 용도에 따라 호출 메서드와 반환 형식이 다르다.

도구Index호출 메서드반환 형식
semantic_search_influencersInfluencerVectorIndexretrieve()JSON
query_kg_natural_languageKnowledgeGraphQueryIndexquery()자연어
analyze_match_historyMatchHistoryIndexretrieve()JSON
search_similar_campaignsMatchHistoryIndexretrieve()JSON
search_collected_ad_dataCollectedAdIndexretrieve()JSON
  1. semantic_search_influencers
    influencer_index = container.influencer_vector_index()
    results = influencer_index.retrieve(query, top_k=10)
    인플루언서 프로필 벡터 인덱스에서 쿼리와 의미적으로 가장 유사한 인플루언서 10명을 반환한다.
    결과가 JSON이기 때문에 Agent가 influencer_id를 꺼내 get_influencer_detailscalculate_match_score 같은 후속 도구를 연달아 호출할 수 있다.
  1. query_kg_natural_language
    kg_index = container.kg_query_index()
    result = kg_index.query(query)
    유일하게 query()를 호출한다. "삼성전자가 협업했던 인플루언서는?" 같은 관계 탐색 질문은 답이 나오면 Agent가 바로 사용자에게 전달하면 되기 때문에 LLM이 자연어로 요약한 결과가 더 적합하다.
    JSON 구조로 받아봤자 Agent가 다시 해석해야 하는 중복 작업이 생긴다.
  1. analyze_match_historysearch_similar_campaigns
    두 도구는 동일한 MatchHistoryIndex를 사용한다. 차이는 쿼리를 구성하는 방식이다.
    # analyze_match_history: 자유로운 자연어 쿼리
    match_index.retrieve(query, top_k=5)
    
    # search_similar_campaigns: 컨셉과 아이템을 조합
    match_index.retrieve(f"{ad_concept} - {ad_item}", top_k=5)
    search_similar_campaigns는 파라미터가 query: str 하나가 아니라 ad_concept: str, ad_item: str 두 개이다.
    광고주가 기획 중인 캠페인의 컨셉과 아이템이 명확히 정해져 있을 때 사용하는 도구로, 두 값을 " - "로 연결해 하나의 검색 쿼리로 만든다. 같은 Index를 쓰면서 입력 인터페이스만 다르게 설계한 셈이다.
  1. search_collected_ad_data
    collected_ad_index = container.collected_ad_index()
    results = collected_ad_index.retrieve(query, top_k=5)
    실제 인스타그램에서 수집한 광고 캠페인 원본 데이터를 검색한다.
    analyze_match_historyNeo4j 그래프 관계(PROMOTED, MATCHED_WITH)에서 구성한 문서를 검색하는 반면, 이 도구스크래핑으로 수집한 CSV를 직접 임베딩한 인덱스를 사용한다.
    같은 과거 실적 데이터라도 출처가 다른 두 개의 인덱스를 별도 도구로 분리해 Agent가 상황에 따라 선택할 수 있도록 했다.

5. LLAMAINDEX_TOOLS 리스트

LLAMAINDEX_TOOLS = [
   semantic_search_influencers,
   query_kg_natural_language,
   analyze_match_history,
   search_similar_campaigns,
   search_collected_ad_data,
]

파일 마지막에 5개 도구를 하나의 리스트로 묶어 export한다.
각 변수는 @tool이 적용된 BaseTool 객체이므로, 리스트에 담기기 전에 이미 LangChain 도구로 변환이 완료된 상태이다.
agent_factory.py에서는 이 리스트를 *로 언패킹해 전체 도구 목록에 합친다.

# agent_factory.py
tools = [
   create_search_by_category_tool(influencer_repo),  # 개별 Tool 객체
   create_search_by_platform_tool(influencer_repo),
   ...
   *NEO4J_TOOLS,       # 리스트 언패킹
   *LLAMAINDEX_TOOLS,  # 리스트 언패킹 → 5개 도구가 펼쳐져 들어감
   ...
]

이 구조의 장점은 새 도구를 추가할 때 agent_factory.py를 수정하지 않아도 된다는 점이다.
llamaindex_tools.py에 새 @tool 함수를 작성하고 LLAMAINDEX_TOOLS 리스트에 추가하면, agent_factory.py*LLAMAINDEX_TOOLS를 그대로 언패킹하므로 자동으로 Agent 도구 목록에 포함된다.
도구 구현과 도구 등록 로직이 분리되어 있어 각자의 역할이 명확하다.


🌟 agent_factory.py 파일 분석

1. 파일 소개

이 파일은 AgentFactory 클래스 하나로 구성되어 있으며, 그 안에 create_agent() static 메서드 하나만 존재한다.
역할은 명확하다. LLM 선택, 도구 조립, Agent 생성 세 단계를 순서대로 처리하고 실행 가능한 Agent 객체를 반환한다.

container.py가 Index 인스턴스를 관리하고, llamaindex_tools.py가 Index를 도구로 변환했다면, agent_factory.py는 그 결과물을 포함한 모든 도구를 한데 모아 Agent에 장착하는 마지막 조립 단계이다.
이 파일을 기점으로 LlamaIndex, Neo4j 등 각각 다른 방식으로 구현된 도구들이 LangChain Agent의 단일 도구 목록으로 통합된다.

도구 그룹은 총 5가지로 구성된다. InfluencerRepositoryPort를 주입받아 생성하는 기본 검색 도구 5개, Neo4j Cypher 직접 호출 도구들(NEO4J_TOOLS), LlamaIndex 기반 시맨틱 검색 도구들(LLAMAINDEX_TOOLS), Instagram 스크래핑 데이터 기반 광고 이력 도구(ad_history_tools), DuckDuckGo 웹 검색 기반 평판 조회 도구(REPUTATION_TOOLS)이다.
그룹마다 도구를 등록하는 방식이 다른데, 이 차이가 이 파일에서 가장 주목할 부분이다.

2. @staticmethod: 왜 인스턴스가 없는가?

class AgentFactory:
   """LangGraph React Agent 팩토리"""
   
   @staticmethod
   def create_agent(provider: str, influencer_repo: ..., ...):
      ...

AgentFactory는 클래스로 선언되어 있지만 __init__이 없고, create_agent() 하나만 존재하며 @staticmethod가 붙어 있다. self도 없고, 인스턴스 변수도 없다.

일반 메서드라면 AgentFactory().create_agent(...)처럼 인스턴스를 먼저 만들어야 한다.
@staticmethod를 붙이면 인스턴스 없이 AgentFactory.create_agent(...)로 바로 호출할 수 있다.

이렇게 설계한 이유는 팩토리의 역할 자체에 있다. create_agent()호출할 때마다 새로운 Agent 객체를 만들어 반환하는 것이 목적이다. 자신의 상태를 기억하거나 유지할 필요가 전혀 없다. 인스턴스가 있어야 할 이유가 없는 것이다.

# 인스턴스가 필요한 경우: 상태를 유지해야 할 때
class AgentService:
   def __init__(self):
      self._agent = None  # 상태를 가짐
      
# 인스턴스가 불필요한 이유: 그때그때 만들기만 하면 될 때
class AgentFactory:
   @staticmethod
   def create_agent(...):  # 상태 없음, 만들고 반환하면 끝
      ...

container.pyAgentService와 비교하면 차이가 명확하다.
AgentServiceself._agent라는 인스턴스 변수로 Agent를 캐시해두고 재사용한다.
반면 AgentFactory매번 새 Agent를 조립해서 넘겨주는 역할이므로 기억해야 할 것이 없다.
클래스로 감싼 것은 단순히 관련 기능을 네임스페이스로 묶기 위한 것이다.

3. LLM 선택: provider 별 분기

if provider == "ollama":
   from langchain_ollama import ChatOllama
   llm = ChatOllama(
      model=model or "GPT-OSS-20B",
      base_url=base_url or "http://localhost:11434",
      temperature=temperature,
   )
elif provider == "openai":
   from langchain_openai import ChatOpenAI
   llm = ChatOpenAI(
      api_key=api_key,
      model=model or "gpt-4-turbo-preview",
      temperature=temperature,
   )
elif provider == "anthropic":
   from langchain_anthropic import ChatAnthropic
   llm = ChatAnthropic(
      api_key=api_key,
      model=model or "claude-sonnet-4-20250514",
      temperature=temperature,
   )
else:
   raise ValueError(f"지원하지 않는 LLM 프로바이더: {provider}")

provider 값에 따라 다른 LangChain Chat 클래스를 사용한다.
세 provider 모두 LangChain의 표준 인터페이스(BaseChatModel)를 구현하고 있어서, 이후 create_agent(model=llm, ...)에 넘길 때는 어떤 provider인지 신경 쓸 필요가 없다.

  • import가 함수 안에 있는 이유
    from langchain_ollama import ChatOllama 같은 import가 파일 상단이 아닌 함수 내부에 있다. provider별로 필요한 패키지가 다르기 때문이다.
    예를 들어 anthropic provider만 쓰는 환경에서는 langchain_ollama가 설치되어 있지 않을 수 있다.
    함수 안에 import를 두면 실제로 해당 provider를 선택했을 때만 import가 실행되므로, 사용하지 않는 패키지가 없어도 에러가 나지 않는다.
  • model or "기본값" 패턴
    model 인자가 None이면 기본 모델명을 사용한다.
    container.pyagent_service()에서 self.settings.llm_model을 그대로 넘기기 때문에 실제로는 .envLLM_MODEL 값이 들어온다. 기본값은 .env 없이 직접 호출하는 경우를 위한 fallback이다.
  • api_keybase_url의 출처
    api_keybase_urlcreate_agent()의 인자로 받는다.
    container.pyagent_service()가 provider에 따라 적절한 값을 골라서 넘겨주는 구조이다.
    AgentFactory는 LLM 생성만 담당하고, 어떤 키를 어디서 가져오는지는 Container가 결정한다. 역할이 명확하게 분리되어 있다.

4. Tools 조립: 툴 그룹별 역할

tools = [
   # Neo4j 기반 기본 검색 Tools (InfluencerRepositoryPort 경유)
   create_search_by_category_tool(influencer_repo),
   create_search_by_platform_tool(influencer_repo),
   create_search_by_followers_tool(influencer_repo),
   create_get_influencer_details_tool(influencer_repo),
   create_calculate_match_score_tool(influencer_repo),
   # Neo4j 기반 Tools (Knowledge Graph)
   *NEO4J_TOOLS,
   # LlamaIndex 기반 Tools (시맨틱 검색)
   *LLAMAINDEX_TOOLS,
   # 광고 이력 Tools (Instagram 스크래핑 데이터)
   *(create_ad_history_tools(ad_post_repo) if ad_post_repo else []),
   # 뉴스/화제성 Tools (DuckDuckGo 웹 검색)
   *REPUTATION_TOOLS,
]

Agent에게 제공할 툴 전체를 하나의 리스트로 조립하는 부분이다. 툴 그룹은 크게 5가지로 나뉜다.

  1. 그룹 1: Neo4j 기본 검색 툴 (repository 경유)

    create_search_by_category_tool(influencer_repo),
    create_search_by_platform_tool(influencer_repo),
    ...

    influencer_repo를 클로저로 주입받아 툴 객체를 생성하는 팩토리 함수 방식이다.
    InfluencerRepositoryPort를 통해 Neo4j에서 인플루언서를 조건 검색하거나 매칭 점수를 계산한다.
    도메인 레이어의 추상화를 경유하기 때문에 DB 구현체가 바뀌어도 툴 코드는 수정할 필요가 없다.

    🤔 influencer_repo를 클로저로 주입받는다?

    create_search_by_category_tool의 구조를 보면 다음과 같다.

    # search_tools.py
    def create_search_by_category_tool(repo: InfluencerRepositoryPort):  # 외부 함수
       @tool
       def search_influencer_by_category(categories: str) -> str         # 내부 함수
          ...
          influencers = repo.find_by_categories(category_list)           # repo 사용
          ...
       return search_influencers_by_category

    search_influencers_by_categoryrepo를 직접 인자로 받지 않는다. Agent가 이 툴을 호출할 때는 categories만 넘긴다.
    그런데 함수 내부에서 repo.find_by_categories()를 아무 문제 없이 쓴다.
    이게 클로저다. 내부 함수가 자신을 감싸는 외부 함수의 변수를 기억하는 것이다.
    create_search_by_category_tool(influencer_repo)를 호출하는 순간 repo = influencer_repo가 내부 함수에 캡처(capture)된다.
    이후 외부 함수 실행이 끝나도 repo는 사라지지 않고 내부 함수 안에 살아 있다.

    • AgentFactory에서
      tool = create_search_by_category_tool(influencer_repo)
      ➡️ 이 시점에 repo가 tool 내부에 캡처된다.
    • Agent가 나중에 툴을 호출할 때
      tool.invoke({"categories": "뷰티"})
      ➡️ categories만 넘겨도 repo는 이미 안에 있으므로 정상 동작한다.
    • 클로저 방식을 사용하는 이유
      @tool 데코레이터로 만든 툴은 Agent가 호출하므로 인자 형태를 임의로 바꿀 수 없다.
      Agent는 docstring을 보고 categories: str 하나만 넘긴다. repo를 인자로 추가하면 Agent가 repo도 채워야 한다는 뜻이 되어버린다.
      클로저를 쓰면 툴의 인터페이스(Agent가 보는 인자)는 그대로 유지하면서, 내부에서 필요한 의존성(repo)은 생성 시점에 미리 주입할 수 있다.
  2. 그룹 2: Neo4j Knowledge Graph 툴

    *NEO4J_TOOLS,

    Cypher 쿼리를 직접 실행해 그래프 구조를 탐색하는 툴 모음이다.
    neo4j_tools.py 모듈 로드 시점에 이미 툴 객체 리스트로 만들어져 있고, *로 언패킹해서 추가한다.

  3. 그룹 3: LlamaIndex 시맨틱 검색 툴

    *LLAMAINDEX_TOOLS,

    이 시리즈의 핵심 주제이다. 벡터 검색, KG 자연어 질의, RAG 매칭 분석 등 5개 툴이 들어 있다.
    llamaindex_tools.py 모듈 로드 시점에 리스트로 준비되어 있고, 마찬가지로 *로 언패킹한다.

  4. 그룹 4: 광고 이력 툴 (조건부)

    *(create_ad_history_tools(ad_post_repo) if ad_post_repo else []),

    Instagram 스크래핑 데이터 기반 툴이다.
    ad_post_repoNone이면 빈 리스트를 언패킹해 툴 목록에 아무것도 추가하지 않는다.
    현재 AgentService에서 ad_post_repo를 전달하지 않으므로 이 그룹은 비활성 상태이다.

  5. 그룹 5: 평판/화제성 툴

    *REPUTATION_TOOLS,

    DuckDuckGo 웹 검색으로 인플루언서의 최근 뉴스나 논란을 확인하는 툴이다. 외부 API 키 없이 동작한다.

  6. 툴을 하나의 리스트로 모으는 이유
    create_agent(model=llm, tools=tools, ...)는 툴을 단일 리스트로 받는다.
    Agent는 이 리스트를 보고 어떤 툴이 있는지 파악하고, 상황에 따라 적절한 툴을 선택해 호출한다. 툴이 어느 그룹에서 왔는지는 Agent 입장에서 관계없다. 이름과 docstring만 보고 판단한다.

5. create_agent() 호출

agent = create_agent(
   model=llm,
   tools=tools,
   system_prompt=AGENT_SYSTEM_PROMPT,
)

LLM과 툴 목록, 시스템 프롬프트를 조합해 실제로 동작하는 Agent를 생성하는 부분이다.

  • create_agent()가 하는 일
    LangChain의 create_agent()는 세 가지 재료를 받아 ReAct 루프를 구성한다.
    1. model: 추론을 담당하는 LLM. 어떤 툴을 호출할지, 최종 답변을 언제 낼지 결정한다.
    2. tools: Agent가 사용할 수 있는 툴 목록. LLM은 이 리스트를 보고 툴 이름과 용도를 파악한다.
    3. system_prompt: 시스템 프롬프트. Agent의 역할과 행동 방침을 결정한다.
  • AGENT_SYSTEM_PROMPT의 역할
    from adapters.outbound.agent.prompt.agent_prompts import AGENT_SYSTEM_PROMPT
    agent_prompts.py에 정의된 시스템 프롬프트이다.
    "너는 인플루언서 매칭 전문가다", "반드시 한국어로 답해라" 같은 지침이 담겨 있다.
    LLM 자체의 능력은 같더라도 프롬프트에 따라 Agent의 행동 방식이 달라진다.
    어떤 툴을 우선적으로 사용할지, 결과를 어떤 형식으로 정리할지 등을 프롬프트로 유도한다.
  • 반환값
    return agent
    생성된 agent 객체를 그대로 반환한다. AgentService가 이 객체를 받아 .invoke()로 실행한다.
    AgentFactory는 생성만 담당하고 실행은 AgentService의 역할이다.

🌟 회고

이 글을 작성하면서 코드를 분석해보니, 내가 정확히 짚지 못했던 부분들이 보였다.

  1. @tool 데코레이터와 Container 초기화 순서
    app.py가 로드될 때 import 체인을 따라 llamaindex_tools.py가 먼저 로드되어 @tool이 실행되고, main()에서 Container.get_instance()가 호출되는 건 그보다 나중이다.
    @tool 실행 시점에는 Container가 아직 없기 때문에 함수 본문 안에서 import해야 한다는 흐름을 이번에 정확히 이해했다.
  1. NEO4J_TOOLSLLAMAINDEX_TOOLS의 차이
    둘 다 Neo4j를 쓴다는 점에서 헷갈렸는데, NEO4J_TOOLS그래프 관계를 정형화된 쿼리로 탐색하고, LLAMAINDEX_TOOLS벡터 임베딩으로 의미 유사도를 검색한다는 점이 핵심 차이였다.
    같은 저장소를 쓰더라도 어떻게 조회하느냐가 완전히 다른 도구라는 걸 이번에 명확히 정리했다.

velog로 내용을 정리하다 보니, 자연스럽게 "왜 이렇게 설계했는지"를 생각하게 되었다. 프로젝트에 대한 이해도가 더 높아진 것 같다.

0개의 댓글