-
검색 결과를 에이전트 도구로 — build_context와 @tool 패턴IT 2026. 6. 28. 23:00
RAG 파이프라인을 에이전트에 붙이려는 순간 벽을 만난다. 리트리버는
list[Document]를 돌려주는데, 에이전트 도구는 문자열을 기대한다. 이 둘을 연결하는 접착제가build_context이고,@tool데코레이터는 그 함수를 에이전트가 직접 선택하고 호출할 수 있는 도구로 탈바꿈시킨다. 이 글은 그 연결 고리가 어떻게 작동하는지, 그리고 도구를 하나로 합치지 않고 카테고리별로 분리하는 이유가 무엇인지를 살펴본다.1. 리트리버와 에이전트 사이의 다리 — build_context
리트리버는 질문과 유사한 청크를 벡터 DB에서 꺼내 문서 목록으로 반환한다. 그런데 에이전트 도구의 반환 타입은 문자열이어야 한다. 에이전트는 도구가 돌려준 문자열을 자신의 프롬프트에 이어 붙여 최종 답변을 생성하기 때문이다. 이 간극을
build_context가 메운다.위 다이어그램은
build_context내부에서 데이터가 어떻게 변환되는지를 보여준다. 리트리버가 꺼낸 청크 목록은"\n\n".join()으로 하나의 긴 문자열이 되고, 원래 질문과 묶여 반환된다. 에이전트는 이 문자열 하나를 받아 컨텍스트로 삼는다. 함정은doc.page_content가 아닌doc자체를 join에 넘기는 실수다. Document 객체를 문자열로 직렬화하면 메타데이터까지 섞인 노이즈가 된다.def build_context(query: str, retriever) -> str: # 1. 벡터 DB에서 관련 청크 검색 docs = retriever.invoke(query) # 2. 각 청크의 본문만 추출해 하나의 컨텍스트 문자열로 합치기 context = "\n\n".join([doc.page_content for doc in docs]) # 3. 컨텍스트와 질문을 구조화해 반환 — 에이전트가 이 텍스트를 읽고 최종 답변 생성 return f"[참고 문서]\n{context}\n\n[질문]\n{query}"코드에서 중요한 지점은
retriever.invoke(query)다. LangChain의 리트리버 인터페이스는get_relevant_documents가 아닌invoke를 표준으로 삼는다. 구버전 코드를 가져다 쓰면DeprecationWarning이 뜨거나 조용히 동작을 바꿀 수 있다. 반환 포맷([참고 문서] / [질문])은 고정 약속이 아니다. 에이전트 시스템 프롬프트에 맞춰 구조를 바꿔도 된다.2. @tool로 리트리버를 에이전트 도구로 변환
build_context를 그대로 노출하면 에이전트는 어떤 리트리버를 쓸지 알 수 없다.@tool데코레이터는 함수를 에이전트가 이름과 설명으로 검색 가능한 도구 객체로 만든다. 각 영화 카테고리마다 별도 함수를 만들고, 어떤 질문에 이 도구를 쓰는지를 docstring으로 명시한다.에이전트는 질문을 받으면 등록된 도구 목록을 훑고, 각 도구의 docstring을 읽어 어떤 도구를 호출할지 결정한다.
@tool이 없다면 에이전트는 이 함수가 존재한다는 사실 자체를 모른다. 선택이 아니라 구조의 문제다.@tool def search_movie_info(query: str) -> str: """특정 영화의 줄거리, 평점, 감독, 출연진에 대한 질문에 답변한다.""" # retriever_movie는 해당 카테고리 벡터 컬렉션과 연결된 리트리버 return build_context(query, retriever_movie) @tool def search_other_movie(query: str) -> str: """다른 영화 카테고리에 대한 질문에 답변한다.""" return build_context(query, retriever_other)@tool은 함수 시그니처에서 타입 힌트를 읽어 에이전트에게 "이 도구는 문자열 하나를 받아 문자열을 반환한다"는 스키마를 알린다. 타입 힌트를 생략하면 에이전트가 인자 타입을 추론 못 해 잘못된 형식으로 호출할 수 있다.query: str은 기능이 아니라 계약이다.3. 도구를 분리하는 이유 — 하나로 합치면 안 되는가
직관적으로는 모든 영화 정보 자료를 하나의 벡터 컬렉션에 넣고 도구 하나만 쓰는 게 단순해 보인다. 그런데 실제 검색 결과를 보면 문제가 드러난다. "액션 영화 추천해줘"라는 질문에 드라마 평점 청크가 섞여 반환된다. 에이전트가 노이즈를 걸러낼 것이라는 기대는 낙관이다.
위 다이어그램은 모든 자료를 한 컬렉션에 몰아넣고 도구 하나로 검색할 때 벌어지는 일이다. 질문이 "액션 영화"를 가리켜도 벡터 유사도 검색은 컬렉션 전체를 대상으로 하므로, 드라마·SF 청크가 함께 상위에 올라와 컨텍스트에 섞인다. 이 노이즈가 그대로 에이전트 프롬프트로 들어가면 답변 정확도가 떨어진다. 에이전트가 알아서 걸러낼 것이라는 기대는 검색 단계의 문제를 생성 단계에 떠넘기는 셈이다.
위 다이어그램은 카테고리별로 도구와 컬렉션을 분리했을 때의 흐름이다. 핵심은 필터링을 에이전트에게 맡기지 않고, 도구 선택 시점에 이미 검색 공간을 좁힌다는 점이다. 에이전트는 docstring을 보고 액션 전용 도구를 고르고, 그 도구는 액션 컬렉션만 검색하므로 애초에 다른 장르 청크가 섞일 여지가 없다. 노이즈를 사후에 걸러내는 게 아니라 구조적으로 차단하는 것이다. 복합 질문("A 영화와 B 영화를 비교해줘")에서는 에이전트가 두 도구를 각각 호출해 두 컨텍스트를 개별로 확보한 뒤 통합 답변을 만든다. 이 구조는 단일 도구로는 불가능하다.
4. 복합 질문 처리 흐름
에이전트의 진가는 복합 질문에서 드러난다. "A 영화와 B 영화의 평점을 비교해줘"라는 질문에 에이전트는 두 도구를 순차로 호출하고, 각 결과를 컨텍스트로 삼아 비교 답변을 생성한다.
이 흐름에서 놓치기 쉬운 함정은 순서다. LangChain 에이전트가 두 도구를 병렬로 호출할 것처럼 보이지만, 기본 ReAct 루프는 한 번에 하나씩 순차 호출한다. 첫 번째 도구 결과가 에이전트의 다음 판단에 영향을 준다. 병렬 호출이 필요하면 LangGraph의 병렬 노드를 별도로 설계해야 한다.
5. docstring이 라우팅을 결정한다
에이전트가 도구를 선택하는 기준은 도구 이름이 아니라 docstring이다.
search_action이라는 이름보다"""액션 영화의 줄거리, 평점, 추천에 대한 질문에 사용한다."""라는 설명이 에이전트의 판단에 더 직접적으로 작용한다. 에이전트 내부에서 도구 선택은 "이 질문에 맞는 설명을 가진 도구를 골라라"는 언어 추론 문제다.docstring 작성에서 흔한 실수 두 가지. 첫째, 너무 넓게 쓰는 것이다.
"""영화 정보를 검색한다."""처럼 모호하면 에이전트가 모든 질문에 이 도구를 쓰려 한다. 둘째, 너무 좁게 쓰는 것이다."""2023년 아카데미 수상 액션 영화의 러닝타임을 조회한다."""처럼 구체적이면 조금만 다른 표현의 질문은 이 도구를 건너뛴다. 도구 하나가 커버하는 의미 범위를 docstring이 정확히 대리해야 한다. 도구 이름을 바꾸는 것보다 docstring 한 줄을 다듬는 것이 라우팅 정확도에 훨씬 큰 영향을 미친다.6. tools 리스트 등록과 에이전트 초기화
개별 도구를 만든 것으로 끝이 아니다. 에이전트 초기화 시
tools리스트에 등록해야 에이전트가 이 도구들의 존재를 안다.# 에이전트에 노출할 도구 목록 tools = [search_action, search_drama, search_scifi] # 에이전트 초기화 — tools 리스트가 에이전트의 선택지 전부 agent = create_react_agent(llm, tools=tools)tools리스트는 에이전트의 세계 전부다. 여기에 없는 도구는 에이전트에게 존재하지 않는다. 새 카테고리가 생기면 리트리버를 만들고,@tool로 감싸고, 이 리스트에 추가하는 세 단계를 밟는다. 반대로 도구를 리스트에서 빼도 코드는 멀쩡히 작동한다. 그 카테고리에 대한 질문만 에이전트가 답하지 못할 뿐이다. 이 구조의 장점은 각 도구가 독립적이라 하나를 수정해도 다른 도구에 영향이 없다는 점이다.마치며
build_context는 RAG의 출력을 에이전트 도구의 입력 형식으로 변환하는 단 하나의 역할에 집중한다.@tool은 그 함수를 에이전트가 인식하고 선택할 수 있는 객체로 승격시킨다. 도구를 카테고리별로 분리하면 검색 공간을 사전에 좁혀 노이즈를 구조적으로 차단한다. 그리고 docstring이 에이전트의 라우팅 판단을 실질적으로 제어한다. 이 네 가지 조각이 맞물려야 RAG가 에이전트 안에서 제대로 작동한다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
서브 에이전트를 @tool로 감싸는 멀티 에이전트 패턴 (0) 2026.06.30 @tool이 내부에서 하는 일 — Pydantic BaseModel이 LLM의 호출 인터페이스가 되는 과정 (0) 2026.06.30 Pydantic BaseModel이란 무엇인가 — 타입 힌트를 진짜 검증으로 바꾸는 도구 (0) 2026.06.29 LangGraph가 Annotated를 쓰는 이유 — 덮어쓰기 문제와 리듀서의 등장 (0) 2026.06.29 RAG 에이전트 완전 조립 — create_agent부터 동작 추적까지 (1) 2026.06.29 RAG의 배경과 make_retriever — LLM이 모르는 문서를 검색하는 방법 (0) 2026.06.28 생성과 검증의 분리 — generator 노드와 validator 노드 설계 (0) 2026.06.28 LangGraph 자기 수정 패턴의 State 설계 — 루프를 위한 5가지 필드 (0) 2026.06.27 LangGraph 조건부 라우팅 — 상태 값으로 다음 노드를 결정하는 방법 (0) 2026.06.27 LLM을 분류기로 쓰기 — SystemMessage와 HumanMessage로 Classifier Node 만들기 (0) 2026.06.27