ChatGPT나 Claude를 쓰면서 AI가 자연스럽게 웹 검색을 하거나 코드를 실행하는 걸 보면, 마치 모델이 스스로 판단해 적절한 도구를 꺼내 쓰는 것처럼 느껴진다. 그런데 그 안을 들여다보면, “지능적 선택”이라고 부르기에는 꽤 기계적인 과정이 담겨 있다.
Function Calling이라는 이름에는 뭔가 특별한 메커니즘이 있을 것 같은 뉘앙스가 있다. 하지만 현재 대부분의 LLM에서 Function Calling은 특수한 회로나 별도의 모듈이 아니라, 결국 프롬프트 엔지니어링과 파인튜닝의 조합으로 구현된다. 시스템 프롬프트에 JSON 스키마 형태로 도구 목록을 주입하고, 모델이 그 스키마를 따르는 출력을 내도록 훈련시키는 것이 전부다.
구체적으로 보면 이렇다. 사용자가 “오늘 서울 날씨 알려줘”라고 입력하면, 프레임워크는 사용자 메시지 앞에 다음과 같은 내용을 자동으로 삽입한다. “당신은 다음 도구를 사용할 수 있습니다: get_weather(location: string), search_web(query: string), calculate(expression: string). 도구를 사용하려면 JSON 형식으로 응답하세요.” 모델은 이 맥락을 읽고 {"tool": "get_weather", "arguments": {"location": "Seoul"}} 같은 출력을 생성한다. 이걸 파싱해서 실제 날씨 API를 호출하고, 결과를 다시 대화 컨텍스트에 넣은 뒤 최종 응답을 생성하는 건 프레임워크가 담당한다.
JSON을 강제하는 두 가지 방법
문제는 언어 모델이 근본적으로 확률적이라는 데 있다. 모델에게 “JSON으로 응답하라”고 아무리 지시해도, 토큰 샘플링 과정에서 {"tool": 다음에 엉뚱한 텍스트가 나올 수 있다. 이를 해결하는 방식이 두 갈래로 나뉜다.
첫 번째는 구조화 출력(Structured Output) 또는 Constrained Decoding 방식이다. 모델이 다음 토큰을 생성할 때 JSON 문법 상 유효한 토큰에만 확률을 배분하도록 logit을 조작한다. llama.cpp의 grammar 모드가 대표적이다. BNF(Backus-Naur Form)와 유사한 문법 정의를 넘기면, 런타임에서 각 디코딩 스텝마다 문법 오토마톤을 실행해 허용되지 않는 토큰의 logit을 마이너스 무한대로 설정한다. Python 생태계에서는 outlines 라이브러리가 같은 역할을 한다. 이 방식은 JSON이 절대로 깨지지 않는다는 보장을 주지만, 문법 오토마톤 실행 오버헤드가 생기고, 모델이 “억지로” 특정 구조를 채우다가 의미 없는 값을 채워 넣는 부작용이 생기기도 한다.
두 번째는 파인튜닝 기반 접근이다. OpenAI의 gpt-4o나 Anthropic의 Claude처럼 클라우드 모델들은 Function Calling에 특화된 데이터로 사전 훈련되어, 도구 호출 형식을 안정적으로 출력하도록 가중치 자체가 조정되어 있다. 별도의 문법 강제 없이도 JSON을 잘 지킨다. 로컬 모델 중에서는 Qwen 계열, Mistral NeMo, Llama 3.x Instruct 계열이 이 방면에서 상대적으로 낫다는 평가를 받는다. 특히 Qwen2.5와 Qwen3 계열은 tool_call 형식에 관한 훈련 데이터가 풍부하게 포함되어 있어, 소형 모델임에도 function calling 성공률이 눈에 띄게 높다.
ReAct 루프의 구조와 실제 작동 방식
단일 도구 호출로 끝나는 작업은 그나마 단순하다. 에이전트가 여러 단계를 거쳐야 할 때, 즉 도구 호출 결과를 바탕으로 다음 판단을 내려야 할 때 사용되는 패턴이 ReAct(Reasoning + Acting)다. 2022년 Google과 Princeton 연구진이 공동 발표한 논문에서 제안된 이 패턴은 생각(Thought) → 행동(Action) → 관찰(Observation) 세 단계를 반복하는 루프 구조다.
실제로는 이렇게 진행된다. 모델이 먼저 자연어로 추론 과정을 텍스트로 출력한다(“사용자가 최신 논문을 원하니 먼저 arXiv를 검색해야겠다”). 그 다음 도구 호출 JSON을 출력한다. 프레임워크가 실제 도구를 실행하고 결과를 “Observation: …”으로 컨텍스트에 추가한다. 모델은 이 Observation을 읽고 다음 Thought를 생성하며 루프가 이어진다. 최종적으로 더 이상 도구가 필요하지 않다고 판단하면 “Final Answer:”를 출력하며 루프를 종료한다.
이 흐름에서 중요한 점은 Thought 단계가 단순한 출력 포맷이 아니라, 모델의 “추론 경로”를 명시적으로 드러낸다는 것이다. Thought가 올바르게 생성되면 그 뒤의 Action 선택도 대체로 적절하다. 반대로 Thought가 엉뚱하게 흐르면 Action도 빗나간다. 이 때문에 ReAct를 실무에 적용할 때는 Thought 단계에서의 추론 품질을 로깅해 두는 것이 디버깅에 크게 도움이 된다.
이미지 출처: Unsplash
로컬 모델에서 자주 만나는 실패 모드
클라우드 API에서는 묻혀 있던 문제들이 로컬 모델에서는 표면으로 올라온다. 가장 흔한 실패 패턴 네 가지를 정리하면 이렇다.
잘못된 도구 선택은 instruction-following 능력이 부족한 모델에서 주로 나타난다. 도구 목록에 search_web과 search_database가 함께 있을 때, 맥락상 데이터베이스 검색이 적절함에도 불구하고 두 이름이 비슷하다는 이유만으로 잘못된 쪽을 고르는 경우다. 이는 도구 설명(description)을 얼마나 명확하게 작성하느냐에 달려 있다.
JSON 포맷 붕괴는 특히 컨텍스트가 길어질수록 빈번하다. 중간에 자연어가 섞이거나, 필수 필드가 빠지거나, 닫는 중괄호를 생성하지 않은 채로 멈추는 일이 생긴다. Constrained Decoding을 적용하면 형식 문제는 해결되지만, 그러면 값이 의미 없이 채워지는 다른 문제가 생긴다.
무한 루프는 에이전트가 특정 도구를 반복 호출하면서 빠져나오지 못하는 상태다. “더 많은 정보가 필요하다”는 Thought를 계속 생성하며 같은 검색을 반복하는 패턴이 전형적이다. 최대 스텝 수 제한과 동일 도구 연속 호출 횟수 제한으로 대응한다.
할루시네이션된 인자 값은 도구 스키마에 없는 파라미터를 만들어 내거나, 존재하지 않는 함수 이름을 생성하는 케이스다. 모델이 스키마를 “이해”하기보다 패턴 매칭으로 처리할 때 자주 발생한다.
컨텍스트가 폭발하는 문제와 실무 대응
ReAct 루프가 길어질수록 컨텍스트 창이 빠르게 찬다. Thought + Action + Observation 세트가 한 루프에서 수백 토큰을 차지하고, 이것이 10~20 스텝 쌓이면 로컬 모델의 일반적인 컨텍스트 한도인 8K~32K를 쉽게 넘어선다. 컨텍스트가 꽉 찬 상태에서는 모델의 응답 품질이 급격히 떨어지거나 아예 오류가 발생한다.
실무에서 쓰이는 대응법은 몇 가지가 있다. 가장 간단한 것은 오래된 Observation을 요약(summarize)해서 원본 텍스트 대신 짧은 요약을 컨텍스트에 유지하는 방식이다. LangGraph 같은 프레임워크에서는 이를 “memory compression” 단계로 명시적으로 구현한다. 또 다른 방식은 각 루프 스텝마다 필요한 정보만 선별해 컨텍스트에 넣는 선택적 컨텍스트 관리다. 도구 호출 결과 전체를 넣는 대신, 태스크와 직접 관련 있는 부분만 추출해 삽입한다.
병렬 도구 호출(Parallel Tool Use)은 이 문제를 다른 각도에서 완화한다. 단일 응답 턴에서 모델이 여러 도구 호출을 동시에 요청하면, 프레임워크는 이를 병렬로 실행하고 결과를 한꺼번에 컨텍스트에 넣는다. 순차 호출 대비 루프 스텝 수가 줄어들기 때문에 컨텍스트 누적이 느려진다. Claude 3.5 Sonnet이나 GPT-4o는 병렬 도구 호출을 공식 지원하며, 로컬 모델에서는 Qwen2.5-72B 이상 크기에서 신뢰할 만한 수준의 병렬 호출이 가능하다. 구현 방식은 단순하다. 단일 JSON 객체 대신 배열 형태로 여러 도구 호출을 한 번에 출력하도록 훈련된 모델이라면, 프레임워크는 배열을 파싱해 각 항목을 비동기로 실행하면 된다.
MCP가 바꾸는 로컬 에이전트의 풍경
Anthropic이 2024년 말 공개한 MCP(Model Context Protocol)는 로컬 에이전트 생태계에 제법 의미 있는 변화를 가져왔다. 기존에는 도구 통합이 프레임워크별로 제각각이었다. LangChain 용 도구, AutoGen 용 도구, 직접 구현한 도구가 서로 호환되지 않았다. MCP는 이 통합 계층을 표준화한다. JSON-RPC 기반의 서버-클라이언트 구조로, 도구 제공자(MCP Server)와 모델 클라이언트(MCP Client)를 분리한다.
로컬 환경에서의 의미는 더 직접적이다. ollama나 LM Studio 같은 로컬 서버가 MCP 클라이언트 역할을 하고, 파일 시스템, 로컬 데이터베이스, 사내 API를 MCP 서버로 감싸면, 모델 입장에서는 동일한 프로토콜로 모든 도구를 다룰 수 있다. 클라우드 API와 로컬 모델 사이에서 동일한 도구 세트를 재사용할 수 있다는 점이 실무적으로 가장 큰 장점이다. 아직 MCP를 완전히 지원하는 로컬 런타임은 많지 않지만, 2025년 이후 LM Studio와 Jan을 중심으로 통합이 빠르게 진행되고 있다.
흥미로운 건, MCP가 도구 호출 방식 자체를 바꾸지는 않는다는 점이다. 여전히 모델은 프롬프트에서 도구 스키마를 읽고 JSON을 출력한다. MCP가 표준화하는 것은 그 이후 단계, 즉 JSON을 실제 실행으로 연결하는 배관(plumbing)이다. 덕분에 Function Calling의 근본적인 한계, 즉 모델의 instruction-following 품질에 의존한다는 사실은 MCP를 써도 달라지지 않는다.
결국 로컬 에이전트의 신뢰성은 모델 크기와 훈련 데이터 품질, 그리고 Constrained Decoding 같은 런타임 안전장치의 조합으로 결정된다. 7B 이하 모델에서 복잡한 에이전트 루프를 돌리는 것은 여전히 운에 기대는 측면이 있다. 반면 Qwen3-30B나 Llama 3.3-70B 수준에서는 단순한 멀티스텝 에이전트를 로컬에서 안정적으로 운영하는 것이 현실적인 영역 안에 들어왔다. 하드웨어 비용과 에이전트 신뢰성이라는 두 축 사이의 균형점이 조금씩, 그러나 확실하게 로컬 쪽으로 이동하고 있다.
출처
- ReAct: Synergizing Reasoning and Acting in Language Models (arXiv)
- llama.cpp Grammar-based Sampling (GitHub)
- outlines: Structured Text Generation (GitHub)
- Model Context Protocol — Introduction (Anthropic)
- Qwen2.5 Function Calling Guide (Qwen Blog)
- OpenAI Function Calling Documentation
- Structured Outputs in llama.cpp (Hacker News discussion)
- LangGraph: Multi-Agent Frameworks (LangChain Docs)