MINERVA Tool Binding – 정적 파이프라인에서 동적 도구 선택으로

메서드 직접 호출에서 LLM 추론 기반 ToolNode로의 전환 청사진

MINERVA의 현재 sub_agent 호출은 정적 파이프라인이다. 본 글은 이 구조를 LangGraph의 ToolNode + bind_tools 패턴으로 전환할 때의 도구 정의·바인딩·실행 흐름을 설계한다. 표준화 추천, 도메인 분류, 도메인 감사, 물리명 생성을 도구 후보로 분해하고, Tool Schema·라우팅·실패 처리·관측성 통합까지 다룬다.

Agent
저자

Kwangmin Kim

공개

2026년 05월 06일

1 Tool Binding이란

정의: Tool Binding

Tool Binding은 LLM이 응답 생성 중에 외부 함수(도구)를 동적으로 호출할 수 있도록, 함수의 시그니처·설명·입력 스키마를 LLM에 사전 등록하는 기법이다.

  • 핵심 구성: Tool 정의(name, description, args_schema, function) + bind_tools(LLM에 등록)
  • 목적: 정적 파이프라인의 한계(분기 매번 코드 수정)를 넘어 LLM 추론으로 도구를 선택
  • 호환 모델: OpenAI Function Calling, Anthropic Tool Use, Azure OpenAI tool_choice

Tool Binding은 LangChain의 bind_tools나 LangGraph의 ToolNode로 구현된다. LLM은 응답 본문 대신 도구 호출 명령(tool call) 을 반환할 수 있고, 호스트 코드는 그 명령을 실제 함수 호출로 디스패치한다.

2 정적 파이프라인의 한계 (MINERVA 현재 상태)

MINERVA의 DataStandardizerAgent는 supervisor 패턴을 이미 적용했지만, 하위 에이전트 호출 순서는 코드에 고정되어 있다.

# src/agents/data_standardizer/agent.py (현재)
def run(self, query: Query) -> Response:
    recommender = self._ensure_recommender()
    mode = _resolve_mode(query)                              # 1. 모드 결정 (정적 분기)
    raw_text, docs = recommender.run(query.text, mode=mode)  # 2. RAG 추천 (항상 실행)
    processed = _apply_post_processing(raw_text, ...)        # 3. 후처리 (항상 실행)
    if _is_full_format(processed):                           # 4. 조건부 감사
        processed = self._auditor.audit_and_fix(processed)
    return self._build_response(processed, docs, query, ...)

이 구조의 문제:

문제 결과
새 도구 추가 시 코드 수정 필수 도메인 우선순위 변경, 약어 사전 추가 등 운영 변경마다 PR
무조건 호출 코드 모드인데 도메인 분류가 불필요해도 ALBERT 200MB 모델 로드
분기 폭증 mode 외에 사용자 의도(요약/표준화/감사만/예외 처리)별 if-else가 누적
LLM 강점 미활용 “감사가 필요한지”, “어떤 도구부터 호출할지”는 본질적으로 추론 문제

3 왜 Tool Binding이 필요한가

Tool Binding은 분기 책임을 코드에서 LLM으로 이동시킨다. 사용자가 “이 표에서 도메인 그룹만 다시 검토해줘”라고 요청하면 LLM이 RAG 추천을 건너뛰고 도메인 감사만 호출한다. 이때 코드는 도구를 등록하기만 하고, 호출 순서를 결정하지 않는다.

정적 파이프라인 vs Tool Binding 비교.

측면 정적 파이프라인 (현재) Tool Binding (목표)
도구 호출 결정 코드의 if/else LLM 추론
새 도구 추가 비용 코드 수정 + 라우팅 분기 도구 등록만
부분 실행 별도 mode/flag 추가 필요 LLM이 필요한 도구만 선택
실패 처리 매 분기마다 try/except ToolNode 통합 핸들러
관측성 메서드 호출 수동 로깅 tool_call 자동 기록
비결정성 입력 같으면 결과 같음 LLM 판단에 따라 변동

마지막 항목이 trade-off다. Tool Binding은 유연성을 얻는 대신 결정성을 잃는다. 따라서 모든 호출을 LLM에 위임할 필요는 없고, 반드시 거쳐야 하는 단계는 코드 분기로 두고 선택적 단계만 도구로 노출한다.

4 MINERVA의 도구 후보

현재 sub_agent 메서드를 도구 후보로 분해한다. 각 도구는 단일 책임이어야 하고, 입출력이 Pydantic 스키마로 정의 가능해야 한다.

도구 이름 책임 입력 출력
search_standardization_docs 표준화 원칙 RAG query: str, mode: “data”|“code”, k: int docs: list[Document], context: str
classify_domain_group ALBERT 도메인 분류 term_korean: str (group: str, confidence: float)
generate_physical_name 영문 약어 → 물리명 logical_name: str, domain: str physical_name: str
audit_domain_priority 도메인 우선순위 감사 table_md: str, priority_yml_path: str corrections: list[dict]
inject_docstring 코드에 docstring 주입 code: str, mapping: dict enriched_code: str
compose_full_format 단건 3섹션 포맷 조립 answer: str, citations: list, agent_data: dict formatted: str

이 6개로 현재 data_standardizer 파이프라인을 모두 커버한다. recommender.run()search_standardization_docs + LLM 호출 자체로 분해되며, _apply_post_processingclassify_domain_group + generate_physical_name의 반복 호출로 표현된다.

5 Tool Schema 정의

LangChain v1의 @tool 데코레이터로 정의하면 함수 시그니처와 docstring이 자동으로 LLM 프롬프트에 변환된다.

# 주요 패키지 (2026년 기준)
# langchain >= 1.1, langchain-openai >= 1.1, langgraph >= 1.0

from langchain_core.tools import tool
from pydantic import BaseModel, Field
from typing import Literal

class ClassifyDomainArgs(BaseModel):
    term_korean: str = Field(description="분류할 한글 논리명 (예: '환자식별번호')")

@tool(args_schema=ClassifyDomainArgs)
def classify_domain_group(term_korean: str) -> dict:
    """한글 논리명을 ALBERT 분류기로 도메인 그룹에 매핑한다.

    반환: {"group": "환자정보", "confidence": 0.87}
    confidence < 0.6 이면 group="미분류" 로 반환한다.
    """
    from agents.data_standardizer.sub_agents.domain_classifier import classify
    group, conf = classify(term_korean)
    return {"group": group, "confidence": conf}

핵심 원칙:

  • docstring이 LLM이 보는 설명이다. 도구 사용 조건과 반환 의미를 명확히 적는다
  • args_schema는 LLM이 채우는 입력이다. Field description으로 예시를 준다
  • 반환은 직렬화 가능한 dict/str이어야 한다. Pydantic 모델이면 .model_dump()로 변환한다

6 ToolNode와 ReAct 루프

LangGraph의 create_react_agent는 LLM 노드와 ToolNode를 자동으로 연결한다.

from langchain_openai import AzureChatOpenAI
from langgraph.prebuilt import create_react_agent
from dotenv import load_dotenv

load_dotenv()

llm = AzureChatOpenAI(model="gpt-4.1", temperature=0.0)

tools = [
    search_standardization_docs,
    classify_domain_group,
    generate_physical_name,
    audit_domain_priority,
    inject_docstring,
    compose_full_format,
]

agent = create_react_agent(
    model=llm,
    tools=tools,
    state_schema=DataStandardizerState,  # C8 (State 설계) 참조
    prompt="당신은 데이터 표준화 도우미다. ...",
)

# 호출
result = agent.invoke({"messages": [{"role": "user", "content": "환자식별번호의 표준 메타데이터를 추천해줘"}]})

내부적으로 일어나는 일:

사용자 질문 → LLM 노드 (도구 호출 결정)
   │
   ├─ tool_calls=[search_standardization_docs(...)] → ToolNode 실행 → 결과
   │  └─ LLM 노드 재호출 (도구 결과 반영)
   │
   ├─ tool_calls=[classify_domain_group(...), generate_physical_name(...)] → 병렬 실행
   │  └─ LLM 노드 재호출
   │
   ├─ tool_calls=[audit_domain_priority(...)] → 단일 실행
   │  └─ LLM 노드 재호출
   │
   └─ tool_calls=[] → 종료, 최종 응답 반환

tool_calls가 비어 있으면 ReAct 루프가 종료된다. 이 종료 조건을 LLM이 결정한다는 점이 정적 파이프라인과의 가장 큰 차이다.

7 강제 호출과 선택적 호출

모든 도구를 LLM에 맡기면 비용·지연·예측 가능성이 모두 악화된다. 두 가지 패턴으로 통제한다.

7.1 사전 도구 강제 (tool_choice)

표준화 답변에 RAG 인용이 반드시 필요하다면 첫 도구는 강제로 검색을 호출한다.

# OpenAI/Azure: tool_choice 파라미터
llm_with_search = llm.bind_tools(
    tools=[search_standardization_docs],
    tool_choice={"type": "function", "function": {"name": "search_standardization_docs"}},
)
# 첫 응답은 반드시 search_standardization_docs 호출이 된다

7.2 노드 분리 (필수 단계는 코드, 선택 단계만 ToolNode)

from langgraph.graph import StateGraph, END

builder = StateGraph(DataStandardizerState)
builder.add_node("search", search_node)              # 강제 — 코드 호출
builder.add_node("agent", agent_node)                # ReAct 루프
builder.add_node("tools", ToolNode(tools=optional_tools))  # 선택적 도구만

builder.set_entry_point("search")
builder.add_edge("search", "agent")
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
builder.add_edge("tools", "agent")

graph = builder.compile()

search_node는 항상 실행되고, audit_domain_priorityinject_docstring은 LLM이 필요하다고 판단할 때만 호출된다. 이 하이브리드 구조가 MINERVA 같은 실무 시스템에 적합하다.

8 실패 처리

도구 호출은 외부 의존(ALBERT 모델, RAG 인덱스, sg-data-standardization 패키지)을 포함하므로 실패 가능성이 높다. ToolNode의 기본 동작은 예외를 ToolMessage(error 표시)로 변환해 LLM에 다시 전달하는 것이다.

@tool
def classify_domain_group(term_korean: str) -> dict:
    """..."""
    try:
        group, conf = classify(term_korean)
        return {"group": group, "confidence": conf}
    except FileNotFoundError as e:
        # ALBERT 모델 미로드 — LLM에 명시적으로 알린다
        return {"error": "domain_classifier_unavailable", "fallback": "skip"}
    except Exception as e:
        return {"error": str(e), "fallback": "retry"}

LLM은 이 에러 메시지를 읽고 포기·재시도·다른 도구 사용을 결정한다. 이 점이 정적 파이프라인의 try/except와 다르다 — 정적 코드는 미리 정의한 fallback만 실행하지만, LLM은 컨텍스트를 보고 동적으로 결정한다.

9 관측성 통합

C9(Phase C-1: 에러 전파)와 metrics_logger를 도구 호출에도 적용한다.

@tool
def classify_domain_group(term_korean: str) -> dict:
    """..."""
    start = time.time()
    try:
        result = classify(term_korean)
        return {"group": result[0], "confidence": result[1]}
    finally:
        log_tool_call(
            tool_name="classify_domain_group",
            input_size=len(term_korean),
            latency_ms=int((time.time() - start) * 1000),
            success=True,  # 위 try 결과로 분기
        )

운영에서 보고 싶은 지표:

  • 도구별 호출 빈도 (어떤 도구가 LLM에 가장 자주 선택되는가)
  • 도구별 평균 지연 (병목 식별)
  • 도구별 에러율 (외부 의존 안정성)
  • 사용자 1명당 도구 호출 횟수 (ReAct 루프가 너무 길지 않은지)

이 데이터는 runs.jsonlextras 필드에 도구 호출 배열로 누적되며, monitoring 라우터의 별도 지표로 노출된다.

10 응용 분야

Tool Binding이 MINERVA에 가져올 변화를 시나리오로 정리한다.

시나리오 정적 파이프라인 동작 Tool Binding 동작
표만 다시 감사해달라는 요청 전체 RAG → 후처리 → 감사 (RAG·후처리 낭비) LLM이 audit_domain_priority만 호출
코드 입력에 도메인 분류 불필요 판단 항상 ALBERT 로드 (200MB) LLM이 코드 모드에서 classify_domain_group 생략
신규 도구(약어 사전 검색) 추가 agent.py 분기 추가 + 테스트 수정 @tool 데코레이터로 등록만
사용자 의도가 모호 (“정리해줘”) 의도 분류 코드 추가 LLM이 도구 시퀀스를 동적 구성
부분 실패 (ALBERT 다운) 전체 요청 실패 또는 하드코딩 fallback LLM이 에러 메시지 보고 검색 도구로 우회

11 마이그레이션 전략

기존 DataStandardizerAgent를 한 번에 교체하지 않는다. C10(BaseAgent v2)에서 정의한 v1/v2 어댑터를 활용해 점진적으로 전환한다.

  1. 도구 추출: sub_agents/post_processing/tables.py의 함수들을 @tool로 래핑. 기존 호출 경로는 유지
  2. 이중 실행 (shadow mode): 정적 파이프라인 결과를 정답으로 두고, ToolNode 결과를 비교 로깅. 차이가 임계값 이하가 될 때까지 튜닝
  3. A/B 실험으로 전환: arm A=정적, arm B=ToolNode. citation rate·latency·만족도를 비교. 유의미하게 같거나 더 나으면 ToolNode로 흡수
  4. 정적 분기 제거: A/B에서 승리한 후 기존 if-else를 삭제. 도구 정의만 남는다

이 4단계는 experiments.py(C-4 A/B 프레임워크)와 metrics_logger.py(C-9 관측성)를 그대로 재활용한다. 즉 Phase C-3 전환은 새 인프라를 요구하지 않고 기존 인프라 위에서 진행된다.

12 관련 주제

선행 지식 (같은 시리즈)

후속 주제 (Phase C-3)

다른 카테고리 연결

Subscribe

Enjoy this blog? Get notified of new posts by email: