읽기 전용 도구 9종으로 카탈로그에 묻고 답하다 — Function Calling 설계
Argus Catalog 어시스턴트가 스스로 도구를 골라 카탈로그를 조회하며 근거 기반으로 답하는 구조를 해부합니다. 도구 레지스트리 패턴, tool-use 루프, 사용자 권한 위임 보안 모델, Text-to-SQL 자가검증, 그리고 환각 방어 기법까지 정리합니다.
"이 테이블 품질이 왜 나빠?"라는 질문에 LLM이 그럴듯하게 지어내 답하면 곤란합니다. 데이터 카탈로그 어시스턴트의 답은 실제 데이터에 근거해야 합니다. Argus Catalog Agent는 이를 위해 카탈로그 API를 LLM Function Calling 도구로 노출하고, 모델이 스스로 도구를 골라 조회한 결과로만 답하게 만듭니다.
이 글은 LLM 레이어 편에 이은 시리즈 3편으로, 도구 레이어(tools.py)와 tool-use 루프(assistant.py)를 다룹니다.
1. 도구 = 스키마 + 실행 함수
각 도구는 (OpenAI tools 스키마, 실행 함수) 한 쌍으로 정의됩니다. 스키마는 모델에게 "이런 도구가 있고 이런 인자를 받는다"고 알려 주고, 함수는 실제로 카탈로그 API를 호출합니다. 둘을 레지스트리(TOOLS dict)에 모아 둡니다.
TOOLS: dict[str, dict] = {
"search_datasets": {
"fn": search_datasets,
"schema": {
"type": "function",
"function": {
"name": "search_datasets",
"description": "데이터 카탈로그를 시맨틱 검색한다. 사용자가 "
"어떤 데이터/테이블을 찾거나 언급하면 먼저 이 도구로 찾는다.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "검색어 (한국어 가능)"},
"limit": {"type": "integer", "description": "결과 수 (기본 5)"},
},
"required": ["query"],
},
},
},
},
...
}description에 "먼저 이 도구로 찾는다" 같은 사용 지침을 직접 적어 두는 게 핵심입니다. 모델은 이 설명을 읽고 언제 어떤 도구를 부를지 판단하므로, 스키마 자체가 일종의 미니 프롬프트 역할을 합니다.
2. 도구 9종
어시스턴트가 다루는 도구는 모두 9개입니다. 데이터셋을 찾고, 깊이 들여다보고, 관계를 따라가고, 작성한 SQL을 검증하는 흐름을 커버합니다.
| 도구 | 무엇을 답하나 |
|---|---|
search_datasets | 데이터셋·용어집·Agent·API를 한 번에 시맨틱 검색 |
get_dataset_detail | 스키마(컬럼·타입·PK·PII)·설명·태그·행 수 |
get_erd | FK 조인 경로 — 다중 테이블 SQL의 조인 근거 |
get_quality | 품질 점수·실패 규칙·위반 샘플 |
get_lineage | 리니지(원천/영향 범위)와 업스트림 품질 경고 |
get_glossary_term | 용어집에서 비즈니스 용어의 사내 정의 |
get_standard_compliance | 표준 용어 준수율 + 표준에 어긋난 컬럼 |
get_quality_rule_recommendations | 프로파일 통계 기반 품질 규칙 추천 후보 |
validate_sql | 작성한 SQL이 안전한 조회(SELECT)인지 자가검증 |
전부 읽기(GET) 또는 검증 전용입니다. 데이터를 바꾸는 도구는 하나도 없습니다 — 어시스턴트는 묻고 답할 뿐, 카탈로그를 수정하지 않습니다.
3. tool-use 루프
대화 한 번은 "질문 → 도구 선택·실행 → 다시 모델에게 → 최종 답변" 루프로 흘러갑니다. assistant.py가 이 루프를 SSE 이벤트 제너레이터로 구현합니다.
MAX_TOOL_ROUNDS = 6 # 도구 호출 무한 루프 방지 — 일반 질문은 2~3회면 충분
for round_no in range(MAX_TOOL_ROUNDS):
# 1) 스트리밍 호출 — 답변 토큰은 즉시 흘리고, tool_calls 는 조립
for ev in llm.chat_stream(messages, tools=tools):
if ev["type"] == "content":
yield {"type": "text_delta", "data": {"text": ev["text"]}}
elif ev["type"] == "final":
msg, usage = ev["message"], ev["usage"]
tool_calls = (msg or {}).get("tool_calls") or []
if not tool_calls:
# 2) 도구 호출 없음 = 최종 답변 → 종료
yield {"type": "done", "data": {}}
return
# 3) 도구 실행 라운드 — 결과를 role=tool 메시지로 붙여 다시 호출
messages.append(msg)
for tc in tool_calls:
name = tc["function"]["name"]
args = json.loads(tc["function"]["arguments"] or "{}")
yield {"type": "tool_call", "data": {"id": call_id, "name": name, "args": args}}
tool_result = run_tool(tool_ctx, name, args)
yield {"type": "tool_result", "data": {"id": call_id, "name": name, "result": tool_result}}
messages.append({"role": "tool", "tool_call_id": call_id,
"content": json.dumps(tool_result, ensure_ascii=False)})모델이 도구를 더 부르지 않으면 그게 곧 최종 답변입니다. MAX_TOOL_ROUNDS=6은 도구만 무한 반복하고 답을 못 만드는 상황을 막는 안전장치로, 초과하면 "질문을 좁혀 달라"는 오류를 돌려줍니다. 시스템 프롬프트는 모델에게 흐름을 안내합니다.
테이블을 찾을 때:
search_datasets→get_dataset_detailSQL 작성:get_dataset_detail(스키마) + 조인이 필요하면get_erd(FK 경로) → 작성 →validate_sql로 자가 검증 후 제시 도구로 확인되지 않은 내용은 추측하지 말고 모른다고 답한다
4. 권한 위임 — 사용자가 못 보는 건 도구도 못 본다
보안 모델이 단순하면서 강력합니다. 도구는 사용자의 토큰으로 카탈로그 API를 호출합니다. 별도의 서비스 계정도, 권한 상승도 없습니다.
class ToolContext:
"""도구 실행 컨텍스트 — 카탈로그 API 주소와 사용자 토큰."""
def __init__(self, api_url: str, user_token: str) -> None:
self.base = api_url.rstrip("/") + "/api/v1"
self.token = user_token
def get(self, path: str):
"""사용자 토큰으로 GET."""
req = urllib.request.Request(
self.base + path,
headers={"Authorization": f"Bearer {self.token}"})
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())결과적으로 도구가 보는 데이터 = 그 사용자가 직접 볼 수 있는 데이터입니다. 권한이 없는 데이터셋은 도구로도 조회되지 않으니, 어시스턴트가 권한 우회 통로가 될 일이 없습니다. 토큰 검증 자체는 카탈로그 API가 하므로 에이전트는 중복 검증하지 않습니다.
5. 컨텍스트는 짧게 — 토큰을 아끼는 설계
도구 결과는 그대로 LLM 컨텍스트에 들어갑니다. API 응답을 통째로 넣으면 토큰이 폭발하므로, 각 도구는 필요한 필드만 추려 돌려줍니다.
def get_quality(ctx: ToolContext, dataset_id: int) -> dict:
"""품질 상태 — 점수·실패 규칙·위반 샘플."""
...
failed = [
{"rule": r.get("rule_name"), "type": r.get("check_type"),
"column": r.get("column_name"), "severity": r.get("severity"),
"detail": r.get("detail"),
"samples": (r.get("failed_samples") or [])[:2] or None} # 위반 샘플 2행만
for r in results if r.get("passed") == "false" # 실패 규칙만
]- 품질: 실패한 규칙만, 위반 샘플은 2행만
- 표준 준수: 비준수 컬럼만, 상한 20개
- 검색: id·name·urn·요약 등 핵심 필드만, 설명은 200자로 잘라서
"통과한 규칙"이나 "이미 준수하는 컬럼"은 설명할 필요가 없으니 아예 빼는 식입니다. 컨텍스트가 짧을수록 7B 모델도 핵심에 집중합니다.
6. Text-to-SQL과 자가검증
가장 까다로운 작업은 SQL 생성입니다. 잘못된 조인이나 위험한 쿼리를 사용자에게 던지면 안 됩니다. 흐름은 이렇습니다.
get_dataset_detail로 컬럼·타입을 확보- 조인이 필요하면
get_erd로 FK 경로를 확인 - SQL 작성
validate_sql로 스스로 검증한 뒤 제시
검증 도구는 SELECT 단일 문장만 통과시키는 가드입니다. 품질 배치의 SELECT-only 가드와 동일한 2차 방어선입니다.
_FORBIDDEN_SQL = re.compile(
r"\b(insert|update|delete|drop|alter|create|truncate|"
r"grant|revoke|merge|call|exec|execute)\b", re.IGNORECASE)
def validate_sql(ctx, sql: str) -> dict:
stripped = re.sub(r"--[^\n]*", "", sql) # 주석 제거
stripped = stripped.strip().rstrip(";").strip()
if ";" in stripped:
return {"valid": False, "reason": "다중 문장(;)은 허용되지 않습니다"}
head = stripped.split(None, 1)[0].lower()
if head not in ("select", "with"):
return {"valid": False, "reason": "SELECT/WITH 조회 쿼리만 허용됩니다"}
if _FORBIDDEN_SQL.search(stripped):
return {"valid": False, "reason": "허용되지 않는 키워드"}
return {"valid": True, "reason": "통과"}이 도구는 실행 기능이 없습니다 — 검증만 합니다. 시스템 프롬프트가 "사용자에게 SQL을 제시하기 전에 스스로 호출하라"고 유도해, 모델이 자기 결과물을 한 번 더 점검하게 만드는 것이 목적입니다.
7. 환각 방어 — 실패도 결과로
마지막으로, 도구 실행이 실패해도 루프가 끊기면 안 됩니다. run_tool은 모든 예외를 결과(오류) 로 변환해 모델에게 돌려줍니다.
def run_tool(ctx, name: str, args: dict) -> dict:
"""모든 예외를 결과(오류)로 변환해 루프가 끊기지 않게 한다."""
tool = TOOLS.get(name)
if not tool:
return {"error": f"알 수 없는 도구: {name}"}
try:
return tool["fn"](ctx, **args)
except Exception as e:
return {"error": f"도구 실행 실패: {e}"}모델은 오류를 보고 재시도하거나 우회하거나, 정직하게 "확인하지 못했다"고 답합니다. 데이터가 없을 때도 마찬가지로 빈 결과가 아니라 note("해당 용어가 글로서리에 없습니다")를 함께 돌려줘, 모델이 "정의 없음"을 명확히 답하도록 유도합니다. 추측 대신 "모른다"를 답하게 만드는 것 — 이것이 근거 기반 어시스턴트의 핵심입니다.
마치며
도구 레이어의 설계 철학은 "근거 없이는 답하지 않는다" 입니다. 9개 읽기 전용 도구로 카탈로그를 조회하고, 사용자 권한을 그대로 위임해 보안을 단순화하고, 컨텍스트를 짧게 유지해 소형 모델을 돕고, SQL은 스스로 검증하게 하고, 실패조차 모델에게 정직하게 알려 환각을 막습니다.
다음 편에서는 이 루프가 만들어 내는 이벤트가 어떻게 사용자 화면까지 실시간으로 흘러가는지 — SSE 통신 규약과 tool_call UI를 다룹니다.