ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 로컬 챗봇 시리즈 #7 — 도구 11개가 모이면 모델이 헷갈리기 시작한다: 풀 격리와 _safe_tool 안전판
    IT 2026. 5. 9. 21:00
    로컬 챗봇 시리즈 #7 — 도구 11개가 모이면 모델이 헷갈리기 시작한다: 풀 격리와 _safe_tool 안전판

    들어가며 — 도구 하나 더 추가했더니 RAG 호출이 줄었다

    로컬 챗봇에 도구를 하나씩 추가해 11개가 됐다. 지식금고 RAG, 공유 메모리, 첨부 검색, 이미지 분석, 웹 검색, 메모리 저장/회상, 캘린더, Claude CLI 자문, 외부 LLM 자문, 이미지 생성으로 구성된다. 모두 LangChain Tool 객체로 통일됐고 LangGraph ReAct에 묶여있다. 깔끔한 설계다.

    그런데 운영하다가 묘한 패턴을 봤다. 도구가 늘어날수록 작은 모델의 RAG 호출 빈도가 떨어진다. "어떤 도구를 써야 할지" 망설이다 그냥 답해버리는 경우가 늘어난 것이다. 도구가 풍부할수록 좋다는 직관과 반대다. 이번 글은 이 현상의 원인과, 두 가지 안전판 — _safe_tool wrapper와 allowed_tools 화이트리스트를 다룬다.


    1. 선택지가 늘면 정확도가 떨어진다 — "도구 paradox"

    인지심리학의 "선택의 역설"을 알 것이다. 30종류 잼이 진열된 매대에서 사람들이 한 종류도 못 고르고 떠나는 현상이다. LLM의 도구 선택에도 비슷한 역설이 있다.

    diagram

    이 그래프가 보여주는 곡선은 도구 풀 크기가 늘어남에 따라 호출 정확도가 떨어진다는 추세다. 가로축은 도구 개수(3개 → 7개 → 11개 → 20개+), 세로축은 모델이 적절한 도구를 정확히 호출하는 비율이다. 작은 모델 기준으로 도구 3-7개 영역에서는 정확도가 거의 100%에 가깝다 — "이건 web_search다"가 명백한 시나리오다. 그러나 11개를 넘으면 곡선이 빠르게 떨어진다. 모델이 "RAG가 맞나, web search가 맞나, attachment_search가 맞나" 망설이다 안전한 선택을 한다 — "그냥 자연어로 답한다". 결과는 RAG·메모리 같은 핵심 도구의 호출이 줄어드는 것이다. 사용자 입장에서는 챗봇이 "갑자기 멍청해진" 인상을 받는다.

    이 패러독스의 답이 시리즈 #8에서 다룰 봇별 도구 화이트리스트다. "이 작업에는 이 3-5개 도구만"으로 풀을 좁혀서 모델의 망설임을 줄인다. 도구 11개를 모두 등록하되, 사용 시점에는 봇별로 좁혀 노출하는 디자인이다.


    2. _safe_tool — 도구 하나의 실패가 에이전트 루프 전체를 깨지 않게

    두 번째 안전판은 회복탄력성에 관한 것이다. 도구는 자주 실패한다. RAG 서버가 꺼져 있거나, 외부 API가 timeout 되거나, MCP 서버가 죽거나, 첨부 파일이 손상되거나 한다. 그 예외가 LangGraph 루프 위로 올라가면 채팅 자체가 죽는다 — 사용자에게는 빈 응답이 가거나 500 에러가 떨어진다. 두 디자인을 비교한 그림이다:

    diagram

    이 그림이 보여주는 두 디자인의 차이는 "실패가 어디서 멈추느냐"다. 왼쪽(래핑 안 함)에서는 도구 함수의 ConnectionError가 그대로 던져진다 — 이 예외가 LangGraph 루프, handle_agent_chat 핸들러, aiohttp 응답 처리까지 차례로 거슬러 올라간다. 마지막엔 사용자에게 빈 응답이나 500 에러가 떨어진다. 도구 하나의 일시적 장애가 채팅 전체를 죽인다. 오른쪽(래핑함)에서는 try/except가 도구 함수 안에서 예외를 잡아 텍스트로 변환한다. "[도구 오류] search_kv: ConnectionError"라는 문자열이 도구의 정상 출력처럼 LangGraph로 돌아간다. 모델은 이 문자열을 보고 "이 도구가 안 되는구나, 다른 방법으로 답해야지"라고 자연스럽게 다음 step으로 넘어간다.

    구현은 5줄짜리 wrapper다.

    # agent/graph.py
    def _safe_tool(tool_func):
        """예외 → '[도구 오류] ...' 텍스트로 변환. 에이전트 루프 유지."""
        original_func = tool_func.func
    
        @functools.wraps(original_func)
        def wrapper(*args, **kwargs):
            try:
                return original_func(*args, **kwargs)
            except Exception as e:
                log.warning("Tool %s failed: %s", tool_func.name, e)
                return f"[도구 오류] {tool_func.name}: {e}"
    
        tool_func.func = wrapper
        return tool_func
    
    # 도구 등록 시 모두 일괄 래핑
    tools = [_safe_tool(t) for t in CORE_TOOLS]
    if include_extra_tools:
        tools.extend(_safe_tool(t) for t in _load_extra_tools())
    

    이 코드의 디자인 비결은 두 가지다. 첫째 — 도구의 원래 함수(tool_func.func)를 wrapper로 통째로 갈아치운다. 그래서 LangChain이 도구를 호출할 때 자기도 모르게 wrapper를 부른다. 도구 정의나 LangGraph 코드는 한 줄도 바꿀 필요가 없다. 둘째 — except가 모든 예외를 잡되 텍스트로 변환한다. 단순히 swallow하면 모델이 "도구가 빈 결과를 반환했네"라고 오해할 수 있지만, 명시적으로 "[도구 오류]"라는 신호를 텍스트에 넣으면 모델이 "이건 실패 신호다, 다른 시도를 해보자"로 정확히 해석한다.

    이 디자인의 비결은 "실패를 내부 상태가 아니라 출력 텍스트로 표현"한다는 것이다. LangGraph의 ReAct 루프는 도구 출력을 다음 모델 호출의 입력으로 넘긴다. 그러니까 모델은 [도구 오류] search_kv: ConnectionError라는 문자열을 보고 "이 도구가 안 되네, 그럼 다른 방법으로"라고 자연스럽게 다음 step을 결정한다.


    3. 트레이드오프 — 안전판의 비용

    3-1. _safe_tool이 모든 예외를 흡수해서 디버깅이 헷갈린다

    도구 실패가 사용자 응답 본문에 [도구 오류] tool_name: ...로 들어간다. 사용자에게는 보인다는 점에서 완전히 숨기는 건 아니다. 하지만 챗봇 로그를 따로 안 보면 같은 에러가 매번 같은 도구에서 나는지, 일시적 문제인지 구분하기 어렵다. RAG 서버가 30분간 다운돼서 모든 사용자에게 RAG 검색이 실패하고 있는데도 사용자 응답이 모두 그대로 나가는 (단지 RAG 결과 없이) 시나리오다. 운영자는 사용자 불만이 누적되거나 직접 모니터링을 봐야 알 수 있다.

    완화책은 두 가지 모니터링이다. 첫째, logger.warning이 남기는 메시지를 주기적으로 grep — "Tool X failed" 패턴이 짧은 시간에 N회 이상이면 알림을 보낸다. 둘째, 사용자 응답에 [도구 오류]가 들어간 건수를 카운트하는 메트릭이다. 두 모니터링 모두 별도 인프라가 필요한데(grafana, prometheus 등), 가정용 로컬 챗봇에서는 그 인프라 비용이 정당화되지 않아 현재는 수동으로 로그를 검토한다. 회사 환경으로 옮기면 즉시 추가해야 할 것이다.

    또 한 가지 함정 — _safe_tool이 모든 예외를 흡수하기 때문에 개발 중 코드 버그도 같은 형태로 흡수된다. 예를 들어 도구 함수의 if 조건에 오타가 나서 NameError가 발생해도 사용자 응답에 "[도구 오류] tool_name: NameError"로 표시되고 LangGraph가 정상 진행한다. 개발자가 즉시 발견 못 할 수 있다. 운영 안정성과 개발 즉시성의 트레이드오프 — 운영을 우선했다.

    3-2. extra_tools 지연 로드의 부작용 — 첫 호출에서 에러가 늦게 발견된다

    browser_consult, claude_cli, image_gen 같은 무거운 도구는 import 자체가 비싸다(Playwright, claude CLI 부팅 등). 그래서 _load_extra_tools()는 첫 호출 시점에만 로드하고 캐시한다. 지연 로드의 부작용은 — extra 도구 중 하나가 import에 실패하면 그 사실을 챗봇 부팅 시점에 알 수 없다. 챗봇은 정상으로 보이고, 사용자가 캘린더 도구를 트리거하는 시점이 되어서야 처음 import 에러가 발견된다.

    구체적인 시나리오 — Playwright의 chromium 패키지가 어떤 OS 업데이트로 깨졌다고 하자. 챗봇은 정상 부팅한다(import만 lazy니까 chromium이 깨진 사실을 모름). 사용자가 "Gemini한테 이거 물어봐줘"라고 요청해서 browser_consult 도구가 트리거된다. 그 순간 처음으로 chromium import 시도 → ImportError → "[도구 오류] browser_consult: ..." 응답이 나간다. 사용자 인지 비용은 작지만, 운영자 입장에서는 "왜 이 도구가 며칠째 안 동작하지?"의 발견이 늦어진다.

    완화책은 챗봇 부팅 시 dry-import를 한 번 돌려서 모든 extra 도구가 import 가능한지 확인하는 옵션을 두는 것이다. 다만 그러면 부팅이 1-2초 느려진다. 1인 사용자 챗봇에서는 부팅 시간이 더 중요해 dry-import를 안 하고, 운영 안정성이 중요한 환경에서는 dry-import를 켜는 식으로 환경별 결정이다.

    3-3. ReAct는 multi-hop reasoning이 약하다 — 작은 모델일수록 더

    "먼저 RAG 검색하고 그 결과를 가지고 웹 검색해서 비교 분석해" 같은 multi-hop 시나리오는 작은 모델에서 자주 흔들린다. 한 hop은 잘 한다 — RAG 검색 같은 단순 호출은 안정적이다. 두 번째 hop을 시작하기 전에 첫 결과를 잊거나, 두 결과를 합치는 단계에서 환각이 생긴다. "RAG에서 뭐라고 나왔지?"를 모델이 self-recall할 때 일부만 기억하고 나머지를 새로 만들어내기도 한다.

    이게 ReAct 패러다임 자체의 한계인지 작은 모델 한계인지는 논쟁이 있지만, 운영 측면에서는 둘 다다. 큰 모델(Claude Opus, GPT-5)은 multi-hop을 꽤 잘 하지만 작은 모델(Qwen3.6:35B)은 자주 흔들린다. 완화책 — (a) 도구 시그니처를 단순하게 유지(필수 인자 1-2개), (b) 시스템 프롬프트로 "한 번에 한 도구만 호출하라"는 가이드, (c) 복잡한 multi-hop이 필요한 시나리오는 봇 시스템 프롬프트로 명시적 step-by-step 지시를 둔다(시리즈 #8). 그래도 작은 모델 한계는 남는다 — 챗봇이 "이건 multi-hop 필요한 질문이라 외부 LLM에 자문해주세요"로 escalate하는 디자인이 더 합리적인 시나리오가 종종 있다.


    4. 마무리

    "도구는 많을수록 좋다"는 직관이 작은 모델에서 깨진다는 게 이번 글의 핵심 발견이다. 그 답은 도구를 줄이는 게 아니라 "풀을 풍부하게 등록하되 사용 시점에는 좁혀 노출"하는 디자인이다 — 봇별 화이트리스트는 시리즈 #8에서 다룬다. 그리고 도구가 실패해도 채팅이 멈추지 않게 하는 _safe_tool 5줄짜리 wrapper다.

    다른 부수 — LangGraph create_react_agent 사용, ContextVar로 도구 컨텍스트 주입(시리즈 #3과 동일 패턴), 11개 도구 중 7개는 CORE 4개는 EXTRA — 는 표준 LangChain 패턴이다.

    다음 편은 사용자 정의 봇이다. 도구 풀을 어떻게 좁힐지 사용자가 작업 단위로 정의하는 메커니즘을 다룬다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

    'IT' 카테고리의 다른 글

    로컬 챗봇 시리즈 #12 (완) — 정책을 데이터로 표현하기: jobs.conf 한 줄이 모든 GPU 동거 정책을 결정한다  (0) 2026.05.09
    로컬 챗봇 시리즈 #11 — Esc 한 키가 깨끗해야 한다: UI 임시 상태의 우선순위 스택 디자인  (0) 2026.05.09
    로컬 챗봇 시리즈 #10 — [hidden] 속성이 안 먹는 한 시간: HTML5의 작은 속성과 컴포넌트 CSS의 충돌  (0) 2026.05.09
    로컬 챗봇 시리즈 #9 — MCP 외부 서버 장애를 graceful하게 흡수하는 디자인: '채팅이 안 막히는 게 우선'  (0) 2026.05.09
    로컬 챗봇 시리즈 #8 — 봇은 도구 풀을 좁히는 장치다: '지식금고 검색가' 한 도구 봇이 가장 효과적인 이유  (0) 2026.05.09
    로컬 챗봇 시리즈 #6 — LLM을 우회하는 슬래시 커맨드: 작은 모델의 메타 판단 한계를 사용자가 보완하는 채널  (0) 2026.05.08
    로컬 챗봇 시리즈 #5 — 다른 프로젝트의 venv를 subprocess로 빌려쓰기: 의존성 추가를 거부하는 영리함  (0) 2026.05.08
    로컬 챗봇 시리즈 #4 — Vision 32B에서 7B로, 그리고 포기까지 — 두 vLLM 동거 시행착오  (0) 2026.05.08
    로컬 챗봇 시리즈 #3 — 도구에 '지금 누구의 첨부인지' 어떻게 알려주나: ContextVar 패턴  (0) 2026.05.08
    로컬 챗봇 시리즈 #2 — Project 시스템 프롬프트는 왜 글로벌 Custom Instructions '다음에' 와야 하나  (0) 2026.05.08
Designed by Tistory.