도구 카탈로그의 단계적 공개: 검색을 pull로 옮기고 '연결'과 '탐색 허용'을 나누기까지
엔진이 도구 스키마와 검색 결과를 매 턴 밀어 넣던 구조를, 모델이 목차를 보고 필요한 것만 승격해 쓰는 pull 구조로 바꿨습니다. 미승인 도구 승격, 약한 모델의 발견 실패, 임의 가중치 검색, 연결 도구 소멸을 차례로 겪으며 가시성을 출처별로 나누고 도구 검색을 BM25F로 정했습니다.
Key Results
- 65 → 1
- 기본 지연 카탈로그 도구 수플랫폼 도구 탐색 토글 OFF, 연결 RAG만 있는 워크플로우 실측(2026-07-02)
- 0 → 2
- 연결 도구 수정 후 카탈로그 소스뉴스 MCP 연결 워크플로우, 수정 전 tool_sources 0, 수정 후 지연 항목 2개(연결 도구·제출 도구). 로컬 스택 2026-07-06
XGEN 하네스 노드에는 캔버스에서 연결한 도구, 플랫폼에 등록된 노드와 워크플로우, MCP 세션 도구가 모두 후보로 들어옵니다. 6월 말 로컬 스택 기준으로 MCP 도구만 66개였습니다. 초기 엔진은 이 도구 스키마를 매 호출 tools 파라미터에 실었고, RAG와 GraphRAG도 매 턴 엔진이 직접 호출해 결과를 프롬프트에 넣었습니다. 필요 없는 턴에도 검색 비용과 컨텍스트가 들었고, 도구가 많아질수록 모델이 혼란스러워지는 도구 폭발 문제도 피할 수 없었습니다.
2026년 5월부터 7월까지 이 구조를 Progressive Disclosure(단계적 공개)로 바꿨습니다. 모델은 목차를 먼저 보고, 필요한 도구를 검색해 스키마를 승격한 뒤 호출합니다. 문제는 한 단계를 고칠 때마다 반대편 문제가 드러났다는 점입니다. 찾으면 나오게 했더니 승인하지 않은 도구를 모델이 찾아 썼고, 그래서 숨겼더니 약한 모델이 사용자가 직접 연결한 검색 도구조차 찾지 못했습니다. 플랫폼 도구를 줄이려고 건 허용 목록은 연결한 도구까지 지웠습니다.
이 글은 그 왕복을 따라가며, 결국 "사용자가 연결했다"와 "플랫폼 전체를 탐색해도 된다"를 서로 다른 권한으로 나누게 된 과정과, 도구 검색 알고리즘을 1차 출처 조사로 BM25F로 정한 판단을 정리합니다.
출발점: 엔진이 넣어 주던 도구와 검색 결과
하네스 엔진은 10개 단계로 구성되고, 그중 프롬프트 단계와 도구 단계가 컨텍스트를 만듭니다. 5월 초까지는 프롬프트 단계가 도구 단계보다 먼저 실행됐습니다. 시스템 프롬프트를 만드는 시점에 도구 카탈로그가 아직 비어 있었으니, 프롬프트에 도구 목차를 넣는 것 자체가 불가능했습니다. 검색은 컨텍스트 단계가 맡았습니다. 모델이 원하든 원치 않든 엔진이 매 턴 RAG와 온톨로지 질의를 실행해 결과를 밀어 넣는 push 구조였습니다.
같은 플랫폼의 일반 에이전트 노드는 연결 도구 목록을 미리 평탄화해 모두 바인딩합니다. 도구가 몇 개일 때는 이 방식이 가장 단순합니다. 하네스는 플랫폼 전체의 노드, 워크플로우, MCP 세션까지 후보로 다루기 때문에, 연결·허용된 도구를 카탈로그에 두고 필요한 후보의 상세 정의만 지연 공개하는 쪽을 택했습니다.
제약도 하나 분명했습니다. 폐쇄망 환경은 vLLM으로 서빙하는 모델만 쓸 수 있었고, 내부에서 기준으로 쓰던 모델은 Qwen3.6-27B였습니다. 프론티어 모델에서만 동작하는 발견 흐름은 의미가 없었습니다. 6월 말에는 "약한 모델도 흐름을 타야 하네스가 일을 한 것"이라는 기준을 명시적으로 두게 됩니다.
목표로 한 공개 단계는 도구와 자원 두 갈래였습니다.
도구 목차(이름·설명·범주) → search_tools(후보 검색) → ToolSearch(스키마 승격) → 호출
자원 L1 메타 → L2 샘플 → L3 검색 → L4 본문 fetch(긴 본문은 별도 저장소에서 필요한 만큼)첫 전환: 검색 주체를 엔진에서 모델로
5월 8일부터 12일 사이에 세 단계로 push를 pull로 바꿨습니다.
첫 단계는 단계 순서 교체였습니다. 도구 단계를 프롬프트 단계 앞으로 옮긴 diff는 한 줄이었지만, 이것이 있어야 시스템 프롬프트에 사용 가능한 도구와 지연 도구 목차를 렌더할 수 있었습니다. 동시에 선택 목록 밖의 도구는 지연(deferred) 처리했습니다. 모델이 ToolSearch로 이름을 지정하면 그 스키마가 상태의 도구 정의에 합류하고, 다음 턴의 tools 파라미터에 누적됩니다. 카탈로그가 실행 중에 자라는 구조입니다.
두 번째 단계는 자원의 도구화입니다. RAG는 rag_search 내장 도구로, 온톨로지는 query_graph 도구로 바꿨습니다. 본문은 별도 저장소에 보관하고 모델에는 인덱스와 200자 스니펫만 보여 줍니다. 정책 확인, 프롬프트 탐색, 컬렉션 탐색도 같은 문법의 도구로 만들어, 자원, 정책, 프롬프트가 모두 목차, 샘플, 검색, 본문의 같은 단계를 밟게 했습니다. 프롬프트의 도구 목차는 각 도구가 스스로 선언한 범주로 묶어, 엔진에 범주를 하드코딩하지 않았습니다.
세 번째 단계에서 컨텍스트 단계의 자동 검색 코드를 지웠습니다. 컨텍스트 단계는 검색 범위만 선언하고, 검색은 모델이 도구를 호출하는 경로 하나로 통일했습니다. 기존 워크플로우가 쓰던 검색 파라미터 9종은 검색 도구의 기본 인자로 옮겨, 사용자가 UI에서 정한 값이 바뀌지 않게 했습니다.
이 전환의 배경에는 그 직전에 걷어 낸 설계가 있었습니다. LLM이 파이프라인을 스스로 구성하는 플래너 모드를 2주 만에 제거하면서, 에이전트의 자율성은 파이프라인 구성이 아니라 도구 호출에서 발휘돼야 한다고 정리했습니다. 단계적 공개는 그 판단을 컨텍스트 설계로 옮긴 것이었습니다.
찾으면 나온다의 대가: 미승인 도구와 행동 지시 철회
5월 17일 개발 서버에서 문제가 보고됐습니다. RAG만 선언한 워크플로우에서 모델이 search_tools와 ToolSearch로 DB 적재 도구를 찾아 승격하고 호출했습니다. 사용자가 그 워크플로우에 넣지 않은 도구였습니다. 지연 로딩은 "보이지 않지만 찾으면 나온다"는 뜻이었고, 검색만 하면 승격까지 허용됐습니다.
그래서 strict 모드를 만들고 기본값으로 올렸습니다. 선택 목록에 명시하지 않은 도구는 메타 도구나 자원 도구가 아닌 한 카탈로그에서 완전히 빠지고, 검색에도 나오지 않습니다. 메타 도구(search_tools, ToolSearch 등)는 항상 즉시 노출하고, rag_search처럼 연결 자원과 매칭되는 도구는 별도 경로로 등록해 영향을 받지 않게 했습니다. 이전 동작이 필요한 경우를 위해 기존 전략을 레지스트리에서 다시 선택할 수 있게 남겼습니다. 이때 자율성(모델의 선택)과 통제(사용자 선언)의 경계가 선택 목록이라는 계약으로 정해졌습니다.
같은 주에 판단 하나를 더 내렸는데, 이후 설계 전체의 기준이 됐습니다. 도구 결과를 받고도 모델이 "확인해 주시면 진행하겠습니다" 같은 확인 요청을 반복하는 회귀가 있었습니다. 처음에는 시스템 프롬프트 끝에 "확인 요청 금지" 등 4개 항목의 지시를 넣었고, 다음 날 추론 과정을 쓰는 형식 가이드와 예시 문장까지 추가했습니다. 그리고 같은 날, 이 방식이 단계적 공개의 전제와 어긋난다는 지적을 받아 두 변경을 모두 되돌렸습니다. 모델이 목차를 보고 스스로 도구를 찾아 쓰게 한다면서 엔진 코드가 "이렇게 답하라, 이런 톤은 금지"를 강제하면 모델의 자율성을 깎는 일이라는 데 동의했습니다.
되돌린 범위는 지시 문구에서 끝나지 않았습니다. 엔진의 개입 수단을 "어떤 도구를 보이고 숨길지"라는 환경 설정 하나로 한정하고, 합성을 재촉하던 안전망 장치와 여러 단계에 흩어진 명령형 문구를 함께 지웠습니다. 그중에는 닷새 전 자동 검색을 폐기하면서 보완재로 넣었던 "답하기 전에 반드시 호출하라(MUST call)"는 문구도 있었습니다. 엔진은 환경을 바꾸고 행동은 모델이 정한다는 원칙이 이때 코드로 확정됐습니다.
숨기자 약한 모델이 길을 잃었다: 발견 흐름과 BM25F
6월 30일, 반대 방향의 문제가 지적됐습니다. rag_search는 미리 바인딩된 즉시 노출 상태라 에이전트가 발견 단계 없이 바로 호출했습니다. 그렇다고 지연으로 바꾸면 strict 모드가 지연 도구를 전부 숨기기 때문에, Qwen 같은 약한 모델은 사용자가 참조 문서 포트에 연결한 검색 도구가 있다는 사실조차 알 수 없었습니다. 원한 흐름은 "연결 노드를 도구로 인식하고, 도구를 검색하고, RAG가 연결됐다는 것을 알고, rag_search를 호출하는" 추론의 연쇄였습니다.
지연 도구를 두 부류로 나눴습니다. 사용자가 명시적으로 연결한 자원은 이름과 (load first) 표식을 보여 주고, 익명 대량 카탈로그만 개수로 접었습니다. 렌더 결과는 [retrieval] - rag_search (load first)와 [catalog] (66 more tools, hidden)이었습니다. Qwen3.6-27B로 실행하자 컬렉션을 먼저 발견하고, 검색하고, 컬렉션 이름이 틀리자 전체 이름으로 스스로 고치고, 키워드를 바꿔 다시 검색하는 흐름이 로그에 남았습니다.
다만 search_tools가 첫 행동이 되지는 않았습니다. 그래서 도구 단계가 발견 우선 플래그를 세팅하고, 모델 호출 단계가 첫 호출에서 tool_choice=search_tools를 한 번만 강제하게 했습니다. 여기에 함정이 있었습니다. 루프 카운터는 첫 반복이 시작되기 전에 1로 증가하므로, "반복 번호가 1이면 강제"로 구현하면 동작하지 않습니다. 반복 번호 대신 상태 키를 한 번 꺼내 소비하는 방식으로 바꿨습니다.
발견 흐름이 생기자 검색 품질이 드러났습니다. 기존 search_tools 점수는 이름 일치에 +3, 설명 일치에 +1을 주는 임의 규칙이었습니다. 알고리즘은 1차 출처를 조사해 정했습니다. Anthropic의 Tool Search는 BM25 또는 정규식으로 이름, 설명, 인자명, 인자 설명의 4필드를 검색했고, 업계의 합의는 BM25를 기준선으로, 밀집 검색과 하이브리드를 다음 단계로 보는 쪽이었습니다. 임의 가중치를 쓰는 사례는 찾지 못했습니다.
BM25F 구성
IDF 희소한 단어에 가중
TF 포화 k1 = 1.5
길이 정규화 b = 0.75
필드 가중치 이름 3 · 인자명 2 · 설명 1 · 인자 설명 1
검색 필드 이름, 설명, 인자명, 인자 설명(입력 스키마의 properties)4필드 검색은 실제로 차이를 냈습니다. 검증 질의에 쓴 컬렉션 이름은 rag_search의 인자 정보에 들어 있어서, 이름과 설명만 검색하면 놓쳤습니다. 토큰은 부분 문자열 매칭으로 한국어 조사 변형을 흡수했습니다. 순서 버그도 하나 고쳤습니다. 검색 도구가 생성 시점의 스키마 스냅샷을 들고 있어서, 그 뒤에 등록되는 rag_search나 query_graph를 영영 찾지 못했습니다. 실행 시점에 살아 있는 스키마를 조회하도록 바꿨습니다. 모델이 넣은 범주 필터 때문에 결과가 0건이 되면 필터를 풀고 다시 검색하는 폴백도 넣었습니다.
같은 날 검증 중에 틀린 가설도 하나 있었습니다. rag_search가 계속 0건을 반환해 점수 임계값(0.2)이나 하네스를 의심했는데, 추적해 보니 문서 서비스가 500을 내고 있었습니다. 로컬 Redis의 인증 정보가 런타임에 어긋나 설정 클라이언트가 빈 로컬 설정으로 폴백했고, 벡터 DB 호스트 설정이 없어 검색기가 초기화되지 못한 것입니다. 하네스는 그 실패를 "0 results"로 보고하고 있었습니다. 설정을 복구하자 10건이 반환됐고, 점수는 0.45로 임계값과 무관했습니다. 발견 흐름을 검증할 때 검색 백엔드의 실패를 따로 분리해 봐야 한다는 것을 배웠습니다.
범위를 좁히자 연결한 도구가 사라졌다
7월 2일에는 플랫폼 도구를 기본 카탈로그에서 빼는 노드 토글을 만들었습니다. 기본값은 OFF이고, OFF면 도구 단계에 빈 소스 허용 목록을 넣어 플랫폼 전역 소스(노드, 캔버스 워크플로우, 하네스 에이전트, 커스텀 API, MCP 세션)를 지연 카탈로그에서 제외합니다. 선택 목록에 명시한 이름과 설정으로 지정한 소스는 유지합니다. 연결 RAG만 있는 워크플로우에서 기본 카탈로그가 65개에서 1개(rag_search)로 줄었습니다.
이 과정에서 로그의 Matched 1 of 1을 보고 소스가 붕괴했다고 의심한 순간이 있었습니다. 확인해 보니 그 숫자는 BM25 매칭 수라 질의에 따라 달라지는 값이었고, "rag_search" 한 단어로 질의하면 건강한 65개 카탈로그에서도 1이 나옵니다. 소스 붕괴가 아니었습니다.
나흘 뒤 실제 붕괴가 나왔습니다. 하네스 노드의 도구 포트에 뉴스 검색 MCP 노드를 직접 연결했는데, 뉴스를 묻는 질문에도 에이전트가 그 도구를 한 번도 호출하지 않고 첨부 문서만 뒤진 뒤 "문서에 없다"고 답했습니다. 시스템 프롬프트로 사용을 강제해도 마찬가지였습니다. 모델은 도구의 존재 자체를 모르고 있었습니다.
연동 계층의 등록부터 확인했고, 연결 도구는 정상적으로 별도 도구 소스로 등록되고 있었습니다. 결함은 엔진 도구 단계에 두 겹으로 있었습니다.
[DBG-src] sid=langchain-runtime gated=True allowlist=set() → collected=0
[Tool Index] tool_sources: 0첫째, 허용 목록 게이트가 소스 ID가 허용 목록에 없으면 건너뛴다였기 때문에, 빈 목록이 들어오자 연결 도구 소스까지 전부 건너뛰었습니다. 연결 도구는 지연 목록에도, 스키마 목록에도 들어가지 않았습니다. rag_search만 동작한 것은 자원 매칭 예외로 별도 등록되는 경로였기 때문입니다. 둘째, 게이트를 통과하더라도 지연 항목의 범주가 "deferred"로 하드코딩돼 있어 프롬프트 단계의 "이름 숨김" 분기로 빠졌습니다. 프롬프트 단계 주석에 적힌 "연결 자원은 이름으로 보여 준다"는 의도가 코드에서 지켜지지 않고 있었습니다.
시점도 추적했습니다. 출발점은 6월 30일 연결 자원을 즉시 노출에서 지연으로 바꾼 변경이었습니다. 그 전에는 연결 도구가 tools 파라미터에 바로 실렸기 때문에 지연 목록에 걸리는 게이트와 무관했습니다. 지연으로 옮겨진 연결 도구 위에 7월 2일의 허용 목록 게이트가 겹치면서 지도에서 사라졌습니다. 대형 자동 카탈로그를 가볍게 하려던 두 변경이 사용자가 하나만 연결한 도구까지 숨긴 것이고, 뉴스 도구 하나가 아니라 연결한 모든 런타임, MCP, 커스텀 API 도구가 조용히 오답을 만드는 문제였습니다.
7월 10일에는 같은 계열의 결함을 하나 더 찾았습니다. search_tools가 카탈로그 12개 이상에서만 등록되는 휴리스틱이라, 발견 우선 설정을 켜도 7개 도구짜리 데모 워크플로우에서는 강제가 애초에 발동하지 않았습니다. 그 전에 "채팅이 느린 것은 발견 우선 강제 때문"이라고 진단했던 것도 틀린 진단이었습니다. 실제 원인은 모델의 재검색 선택과 Judge 경유였습니다.
설계 판단: 가시성을 출처별 권한으로 나누기
이 왕복을 거쳐 가시성 선택지를 다음처럼 정리했습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 전부 즉시 노출 | 바로 호출 가능, 구현 단순 | 66개 스키마가 매 호출 컨텍스트 소모, 약한 모델의 선택 흔들림 | 기각 |
| 지연 기본(찾으면 승격) | 토큰 절약, 동적 카탈로그 | 선언하지 않은 도구까지 승격 가능 | 기각 |
| strict로 전부 숨김 | 사용자 선언 밖 도구 차단 | 약한 모델이 연결 자원의 존재를 모름 | 보완 |
| 출처별 가시성 | 연결 의도 유지와 통제를 함께 확보 | 출처 분류 로직 필요, 연결 MCP 도구가 많으면 이름이 모두 노출 | 채택 |
출처별 가시성은 세 등급입니다. 사용자가 캔버스에 연결한 도구는 지연 상태를 유지하되 이름과 (load first)를 보여 줍니다. 플랫폼 카탈로그는 탐색 토글과 허용 목록이 허락할 때만 후보가 되고, 그때도 개수로 접습니다. 선택 목록에 없는 도구는 strict 모드에서 아예 빠집니다. 핵심은 빈 허용 목록의 의미를 고정한 것입니다. 허용 목록이 비었다는 것은 플랫폼 카탈로그에서 허용된 소스가 없다는 뜻이지, 사용자가 연결한 도구가 없다는 뜻이 아닙니다. 선택한 주체가 다르면 권한도 다릅니다.
수정 범위는 의도적으로 좁게 잡았습니다. 연결 도구를 즉시 노출로 되돌리거나 tool_choice로 사용을 강제하지 않고, 지도만 복원했습니다. 5월에 정한 원칙대로 엔진은 무엇을 보여 줄지만 정하고, 어떤 도구를 쓸지는 모델이 고릅니다.
# 연결 소스는 플랫폼 허용 목록 게이트의 예외
gated = (source_allowlist is not None
and sid not in source_allowlist
and sid not in connected_source_ids)
category = "connected" if is_connected else "deferred" # connected는 이름 노출
# strict 모드: 메타 도구와 연결 도구는 카탈로그에서 제외하지 않음
skip_completely = not (is_meta_tool or is_connected)연결 소스가 없는 워크플로우에서는 is_connected가 항상 거짓이라 기존 동작이 완전히 같습니다. search_tools 등록 휴리스틱은 명시적 opt-in이 있으면 카탈로그 크기와 무관하게 등록하도록, 설정이 휴리스틱보다 우선하게 바꿨습니다.
결과와 남은 과제
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 발견 체인 | search_tools 미경유, 또는 연결 RAG 존재를 모름 | search_tools → BM25F 매칭(rag_search, score 6.73) → ToolSearch 승격 → rag_search 호출 | Qwen3.6-27B 라이브, 로컬 스택(2026-06-30) |
| 기본 지연 카탈로그 | 65개 | 1개(rag_search) | 플랫폼 도구 탐색 토글 OFF, 연결 RAG 워크플로우(2026-07-02) |
| 연결 뉴스 도구 | tool_sources 0, 호출 0 | 지연 항목 2개 노출, ToolSearch 승격 후 실호출로 실제 기사 반환 | 로컬 스택, 수정한 SDK(2026-07-06) |
| 배포본의 연결 MCP | 발견 불가 | 에이전트가 ToolSearch로 자율 발견하고 호출 | 개발 서버 배포본 기능 검수 |
배포본 검수에서는 연결한 메타 검색 MCP가 발견되고 호출됐지만, 그 서버의 검색 백엔드 설정 문제로 결과는 비어 있었습니다. 에이전트는 검색 결과가 없다는 사실을 밝히고 보유 지식으로 답했습니다. 이 수정과는 별개인 인프라 문제였습니다.
돌아보면 이 작업에서 가장 오래 걸린 것은 알고리즘이 아니라 "보인다"의 정의였습니다. 숨김과 노출을 한 축으로 다루는 동안은 한쪽을 고치면 다른 쪽이 무너졌습니다. 목차에 이름이 보이는 것, 스키마가 승격되는 것, 그 도구를 쓸 권한이 있는 것을 각각 다른 시점과 다른 주체의 결정으로 나누자 왕복이 멈췄습니다.
남은 과제는 네 가지입니다. 첫 턴 강제 검색은 응답을 2~5초 늦추므로 노드의 "첫 턴 도구 검색 우선" 토글로 끌 수 있게 하고, 이 변화를 배포 공지 사항으로 정리했습니다. 한국어 질의와 영문 도구 이름 사이의 매칭은 아직 약해서, 서비스명이 붙은 한국어 뉴스 질의가 영문 도구 이름과 매치되지 않는 경우가 코드 주석에 기록돼 있습니다. 별칭 보강이 필요합니다. 연결한 MCP 서버가 도구를 수십 개 가지면 그 이름이 모두 노출되므로 상한이 필요할 수 있습니다. 마지막으로 개발 서버 배포본에서는 RAG 결과를 받고도 "먼저 검색하겠습니다" 턴에서 합성 없이 끝나는 증상이 남았습니다. 같은 수정을 넣은 로컬 스택에서는 정상이라 배포본과 로컬의 합성 경로 차이를 의심하고 있고, 도구 공개와는 별개 과제로 분리했습니다.