-
@tool이 내부에서 하는 일 — Pydantic BaseModel이 LLM의 호출 인터페이스가 되는 과정IT 2026. 6. 30. 21:00
LangGraph로 AI 에이전트를 만들다 보면 도구(tool) 함수를 정의하는 방식이 눈에 들어온다. 함수 위에
@tool하나 붙이면 LLM이 그 함수를 알아서 호출한다. 마치 마법처럼 보이지만, 내부에서는 Pydantic BaseModel이 핵심 역할을 한다. 이 글은 그 메커니즘을 분해한다 —@tool이 Python 함수에서 무엇을 만들어내는지, 왜 굳이 BaseModel인지, 그리고 실제로 어떤 문제들이 있었는지.LLM이 함수를 호출하려면 무엇이 필요한가
LLM은 텍스트를 입력받아 텍스트를 출력하는 모델이다. "search_movie 함수를 query='크리스토퍼 놀란 영화'로 호출해"라고 지시하려면 LLM에게 두 가지를 알려줘야 한다. 첫째, 그 함수가 어떤 인자를 받는지. 둘째, 각 인자의 타입과 의미가 무엇인지. 이 두 가지를 LLM이 이해할 수 있는 형식으로 전달하는 규격이 JSON Schema다.
JSON Schema는 데이터 구조를 기술하는 표준 형식이다. 필드 이름, 타입, 기본값, 설명을 JSON으로 표현한다. OpenAI의 function calling이 2023년에 이 형식으로 도구 인터페이스를 정의하면서 사실상 업계 표준이 됐다. 문제는 Python 함수를 개발자가 직접 JSON Schema로 변환해야 한다는 것이었다 — 반복적이고 오류가 나기 쉬운 작업이다.
@tool 데코레이터: 함수에서 스키마까지
LangChain의
@tool은 이 변환을 자동화한다. Python 타입 힌트와 docstring을 읽어 Pydantic BaseModel 서브클래스를 자동 생성하고, 그것을 JSON Schema로 변환해 LLM에 전달한다. 개발자는 타입 힌트만 제대로 쓰면 된다.from langchain_core.tools import tool from typing import Optional @tool def search_movie( query: str, top_k: int = 3, category: Optional[str] = None, ) -> str: """영화 데이터베이스에서 영화 정보를 검색합니다. Args: query: 검색할 영화 제목이나 키워드 top_k: 반환할 최대 결과 수 category: 장르 필터 (action, drama, comedy 등) """ ...먼저
@tool이 데코레이터(decorator)라는 점을 이해해야 한다. 데코레이터는 함수를 입력으로 받아 다른 객체로 바꿔서 돌려주는 장치다. 즉@tool을 붙이는 순간,search_movie라는 이름은 더 이상 우리가 작성한 그 함수를 가리키지 않는다. 대신 LangChain이 만든StructuredTool이라는 객체를 가리키게 된다. 원래 함수는 이 객체 안에 "실제로 실행될 코드"로 들어가 보관되고, 그 주위에 이름·설명·인자 명세 같은 메타데이터가 함께 묶인다. 평범한 함수를 "도구 객체"로 포장한 셈이다.args_schema는 바로 이 도구 객체가 들고 있는 여러 속성(attribute) 중 하나다. 코드로는search_movie.args_schema로 꺼내볼 수 있다. 그리고 그 값은 데이터가 아니라 클래스 그 자체다 — 정확히는 Pydantic BaseModel을 상속한 새 클래스다. 이 클래스는 데코레이터가 실행되는 그 순간(파일을 import하는 시점)에 LangChain이 함수의 시그니처와 docstring을 읽어 즉석에서 찍어낸다. 함수 파라미터 하나하나가 클래스의 필드가 되고, docstring Args 섹션의 각 줄이 해당 필드의 설명(description)으로 붙는다.@tool이 함수를 "도구 객체"로 바꾸고, 그 객체 안에 args_schema가 자리 잡는 구조. 맨 위는 우리가 손으로 쓴 함수다. 데코레이터가 실행되면(가운데) 함수는 통째로
StructuredTool객체로 감싸지고,search_movie라는 이름은 이제 함수가 아니라 이 객체를 가리킨다. 이 객체는 도구의 이름·설명·실제 실행 코드(.func), 그리고 인자 명세(.args_schema)를 속성으로 들고 있다. 여기서 핵심은 맨 아래 —.args_schema의 값이 단순한 딕셔너리나 문자열이 아니라 새로 정의된 클래스라는 점이다. LangChain이 함수 시그니처를 보고 이 클래스를 동적으로 생성한다. 놓치기 쉬운 함정은 "스키마를 만든다"는 말을 JSON 같은 데이터 한 덩이를 만든다고 오해하는 것이다 — 실제로 만들어지는 것은 데이터를 검증할 줄 아는 클래스이고, JSON Schema는 그 클래스에서 한 단계 더 변환해 뽑아내는 산출물이다(다음 절).그렇다면 이 "Pydantic BaseModel을 상속한 클래스"란 정체가 대체 뭘까? 왜 하필 이게 인자 명세의 그릇으로 쓰일까? 이 BaseModel이라는 도구 자체가 무엇이고 어떤 문제를 풀기 위해 태어났는지는 이야깃거리가 충분히 많아 별도의 글에서 따로 다룬다. 이 글에서는 일단 "데이터의 구조와 규칙(필드 이름·타입·기본값·설명)을 클래스로 선언하면, 들어오는 값을 그 규칙에 맞춰 자동으로 검사·변환해 주는 도구"라고만 알고 넘어가자.
앞의 함수 예시로 돌아가 정리하면,
@tool은 타입 힌트와 docstring을 읽어 위와 같은 BaseModel 클래스를 자동 생성하고, 그것을 다시 JSON Schema로 변환해 LLM에 전달한다. 함수의 파라미터 타입 힌트가 모델 필드가 되고, docstring의 Args 섹션이 각 필드의 description이 된다.@tool 데코레이터가 Python 함수를 LLM이 이해하는 JSON Schema로 변환하는 과정. 핵심 흐름은 두 갈래다. 하나는 타입 힌트에서 필드 타입을 추출하는 경로이고, 다른 하나는 docstring에서 각 필드의 설명을 추출하는 경로다. 이 두 정보가 합쳐져 BaseModel이 만들어지고, 그것이 JSON Schema로 변환된다. 놓치기 쉬운 함정은 docstring 없이 타입 힌트만 있으면 LLM이 각 파라미터의 의미를 이해하지 못한다는 것이다. 타입이
str인 파라미터가 "검색 쿼리"인지 "장르 이름"인지 LLM은 description을 통해서만 구분한다.@tool이 실제로 만들어내는 JSON Schema는 다음과 같다:# 런타임에 확인할 수 있는 실제 스키마 import json print(json.dumps(search_movie.args_schema.model_json_schema(), indent=2)){ "title": "search_movie", "description": "영화 데이터베이스에서 영화 정보를 검색합니다.\n\nArgs:\n query: 검색할 영화 제목이나 키워드\n top_k: 반환할 최대 결과 수\n category: 장르 필터 (action, drama, comedy 등)", "type": "object", "properties": { "query": { "title": "Query", "description": "검색할 영화 제목이나 키워드", "type": "string" }, "top_k": { "title": "Top K", "description": "반환할 최대 결과 수", "default": 3, "type": "integer" }, "category": { "title": "Category", "description": "장르 필터 (action, drama, comedy 등)", "anyOf": [{"type": "string"}, {"type": "null"}], "default": null } }, "required": ["query"] }category필드가anyOf: [string, null]로 표현된 것에 주목하라.Optional[str]을 LLM이 이해할 수 있는 형태로 번역한 결과다. LLM은 이 스키마를 보고 "category를 전달하지 않거나 null로 전달할 수 있다"는 것을 이해한다. 이 변환이 제대로 이루어지지 않으면 LLM이 null 대신 문자열"None"을 넘기는 문제가 발생한다.왜 BaseModel인가
@tool이 내부적으로 BaseModel을 선택한 이유는 대안들의 한계를 보면 명확해진다. 세 가지 방식을 비교해보자.▲ 수동 JSON Schema 방식
▲ TypedDict 방식
▲ Pydantic BaseModel 방식
세 방식의 핵심 차이는 "런타임에서 잘못된 인자를 어떻게 처리하는가"에 있다. LLM이 생성하는 JSON은 항상 신뢰할 수 없다.
"3"(문자열)을 전달해야 할 곳에3(정수)를 넘기거나, 필수 필드를 빠뜨리거나, 허용되지 않는 값을 넣을 수 있다. 수동 딕셔너리나 TypedDict는 이런 오류를 그냥 통과시킨다. BaseModel은 Pydantic의 검증 엔진이 즉시 에러를 내므로, 잘못된 인자가 함수 내부로 들어가는 것을 막는다.또 다른 결정적 이유는 description이다. Pydantic의
Field(description="...")는 LLM이 "이 인자가 무엇인지"를 이해하는 핵심 힌트다. TypedDict에는 description을 첨부하는 표준 방법이 없다. 수동 딕셔너리는 가능하지만 코드와 별도로 유지해야 한다. BaseModel은 필드 선언과 description이 한 곳에 있다.LLM tool call의 실제 데이터 흐름
LLM이 도구를 호출할 때 데이터가 어떤 경로를 거치는지 전체 흐름을 보자.
LLM이 도구를 호출할 때 실제 데이터가 이동하는 경로. 핵심은 LangGraph의 ToolNode가 LLM 응답에서 추출한 args JSON을 그대로 함수에 넘기지 않는다는 점이다. 반드시
args_schema.model_validate(args)를 통해 Pydantic 검증을 거친다. 검증이 실패하면 에러 메시지가 ToolMessage로 변환돼 대화 컨텍스트에 추가되고, LLM이 이를 보고 수정된 인자로 다시 시도한다. 이 자동 재시도 루프는 LangGraph의 ReAct 에이전트에서 기본으로 동작한다. 놓치기 쉬운 함정은 LLM이 생성한 args JSON이 바로 함수로 들어가지 않는다는 것이다 — Pydantic 레이어가 반드시 중간에 있다.명시적 BaseModel로 스키마를 더 세밀하게 제어하기
@tool의 자동 생성만으로 부족할 때args_schema를 명시적으로 정의할 수 있다. Pydantic의Field를 사용해 description을 더 상세하게 작성하거나, 허용 값을 제한할 수 있다.from pydantic import BaseModel, Field from typing import Literal, Optional class SearchInput(BaseModel): query: str = Field( description="검색할 영화 제목이나 키워드. 자연어 질문 형태로 입력." ) top_k: int = Field( default=3, ge=1, le=10, description="반환할 최대 결과 수. 1~10 사이 정수." ) category: Optional[Literal["action", "drama", "comedy"]] = Field( default=None, description="장르 필터. 지정하지 않으면 전체 검색." ) @tool(args_schema=SearchInput) def search_movie( query: str, top_k: int = 3, category: Optional[str] = None, ) -> str: """영화 데이터베이스에서 영화 정보를 검색합니다.""" ...명시적 BaseModel이 추가로 제공하는 것은 두 가지다.
ge=1, le=10같은 수치 범위 제약은 LLM이 out-of-range 값을 넘길 때 Pydantic이 즉시 에러를 낸다.Literal["action", "drama", "comedy"]는 JSON Schema에서enum으로 변환돼 LLM이 허용된 값 목록을 스키마에서 직접 읽을 수 있다. 실제로 에이전트를 운영하면 LLM이 허용 값 목록이 있을 때 정확도가 크게 올라간다 — LLM이 추측 대신 확인할 수 있기 때문이다.정리: BaseModel이 가져온 변화
BaseModel 방식이 가져온 변화 구조. 개발자가 타입 힌트와 docstring을 작성하면, 그 이후 과정은 모두 자동화된다. JSON Schema 변환, LLM에 대한 스키마 주입, 런타임 타입 검증이 모두 포함된다. 이 구조의 가장 큰 가치는 "함수 선언이 곧 LLM과의 계약"이 된다는 것이다. 함수 시그니처를 바꾸면 스키마도 자동으로 바뀐다 — 동기화 불일치가 원천적으로 없다. 놓치기 쉬운 함정은 docstring description의 품질이 LLM 호출 정확도를 직접 결정한다는 것이다. 타입 힌트만 완벽해도 description이 불명확하면 LLM이 어떤 값을 넣어야 하는지 추측에 의존한다.
결국
@tool한 줄이 하는 일은 Python 함수를 LLM이 이해하고 안전하게 호출할 수 있는 인터페이스로 변환하는 것이다. Pydantic BaseModel은 그 중간에서 Python의 타입 시스템과 LLM의 JSON Schema 세계를 잇는 다리 역할을 한다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
스킬의 끝은 스크립트인가 — 하네스 엔지니어링에서 스크립트는 '지능을 파낸 빈자리'다 (0) 2026.07.02 MCP는 왜 Streamable HTTP로 갈아탔나 — 엔드포인트 하나로 스트림을 다스리는 법 (0) 2026.07.01 MCP는 왜 SSE를 골랐나 — HTTP 위에서 서버가 먼저 말하게 하는 법 (0) 2026.07.01 서브 에이전트를 @tool로 감싸는 멀티 에이전트 패턴 (0) 2026.06.30 Pydantic BaseModel이란 무엇인가 — 타입 힌트를 진짜 검증으로 바꾸는 도구 (0) 2026.06.29 LangGraph가 Annotated를 쓰는 이유 — 덮어쓰기 문제와 리듀서의 등장 (0) 2026.06.29 RAG 에이전트 완전 조립 — create_agent부터 동작 추적까지 (1) 2026.06.29 검색 결과를 에이전트 도구로 — build_context와 @tool 패턴 (0) 2026.06.28 RAG의 배경과 make_retriever — LLM이 모르는 문서를 검색하는 방법 (0) 2026.06.28 생성과 검증의 분리 — generator 노드와 validator 노드 설계 (0) 2026.06.28