로컬에 Ollama 하나 띄워 두고 함수 호출 에이전트를 붙이려는 분을 자주 본다. 클라우드 API 키 없이, 데이터도 밖으로 안 내보내고, OpenAI SDK 코드는 거의 그대로 재사용하고 싶다는 요구다. 막상 돌려 보면 요청 형식보다 응답을 받아 실제 함수를 돌리고 결과를 다시 넣는 루프, 그리고 모델이 엉뚱한 걸 뱉을 때의 처리에서 더 많이 막힌다.
결론부터 말하자면, Ollama는 http://localhost:11434/v1/chat/completions에서 OpenAI 호환 tools와 response_format을 받는다. 그래서 OpenAI 파이썬 클라이언트의 base_url만 바꾸면 tool calling 에이전트가 거의 그대로 돈다. 다만 tool_choice는 지원하지 않고, 응답의 함수 인자는 JSON 문자열로 오기 때문에 직접 파싱해야 한다.
tool calling이 로컬에서 도는 방식
tool calling은 모델이 직접 외부 동작을 하는 게 아니라, "이 함수를 이런 인자로 불러 달라"는 요청을 구조화된 형태로 내놓는 기능이다. 날씨를 물으면 모델은 답을 지어내는 대신 get_weather(city="서울")를 호출하라고 알려 주고, 실제 호출과 결과 전달은 애플리케이션 몫이다. 그 결과를 다시 대화에 넣어 주면 모델이 사람이 읽을 답으로 정리한다.
이걸 쓰려면 도구 호출로 미세조정된 모델이 필요하다. 공식 블로그 기준 Llama 3.1, Mistral Nemo 등이 지원하고, 모델별 지원 여부는 ollama.com 모델 페이지의 tools 태그로 확인할 수 있다. 도구 태그가 없는 작은 모델에 tools를 넘기면 함수 호출 대신 본문 텍스트로 코드를 흉내 내는 경우가 있으니 모델 선택이 먼저다.
준비는 서버를 띄우고 모델을 받는 것으로 끝난다.
$ ollama --version ollama version is 0.12.3 $ ollama pull llama3.1:8b $ ollama serve # 이미 systemd로 떠 있으면 생략
함수 하나를 모델에 넘기기
요청 형식은 OpenAI와 같다. tools 배열에 type: "function"과 함수 이름, 설명, JSON 스키마 형태의 parameters를 넣는다. 설명과 파라미터 description은 모델이 언제 어떤 인자로 부를지 판단하는 근거라 성의껏 적는 게 정확도에 직접 영향을 준다.
curl http://localhost:11434/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "llama3.1:8b",
"messages": [{"role": "user", "content": "서울 날씨 알려줘"}],
"temperature": 0,
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "도시의 현재 날씨를 조회한다",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "도시 이름"}
},
"required": ["city"]
}
}
}]
}'
모델이 호출이 필요하다고 판단하면 content는 비고 tool_calls가 채워진다. finish_reason도 tool_calls로 바뀐다.
{
"choices": [{
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "call_a1b2c3",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"서울\"}"
}
}]
},
"finish_reason": "tool_calls"
}]
}
여기서 첫 함정이 나온다. arguments는 객체가 아니라 JSON 문자열이다. 그대로 함수에 넘기면 안 되고 json.loads로 풀어야 한다. 모델이 작을수록 이 문자열이 깨진 JSON으로 올 때가 있어서 파싱은 항상 try로 감싼다.
tool_calls를 받아 실제 함수를 돌리는 루프
함수를 실행한 결과는 role: "tool" 메시지로 되돌려 넣는다. 이때 tool_call_id를 응답에서 받은 id와 맞춰야 모델이 어느 호출에 대한 결과인지 안다. 그다음 다시 요청을 보내면 모델이 결과를 읽고 최종 답을 내거나, 필요하면 또 다른 함수를 부른다. 이 왕복을 끝날 때까지 반복하는 게 에이전트 루프다.
OpenAI 파이썬 클라이언트로 그대로 붙인다. base_url만 로컬로 바꾸고 api_key는 아무 값이나 넣으면 된다.
from openai import OpenAI
import json
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
def get_weather(city):
# 실제로는 외부 API 호출
data = {"서울": (12, "맑음"), "부산": (16, "흐림")}
temp, cond = data.get(city, (None, "알 수 없음"))
return {"city": city, "temp": temp, "condition": cond}
registry = {"get_weather": get_weather}
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "도시의 현재 날씨를 조회한다",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "도시 이름"}},
"required": ["city"],
},
},
}]
messages = [{"role": "user", "content": "서울이랑 부산 날씨 비교해줘"}]
for _ in range(6): # 무한 루프 방지용 상한
resp = client.chat.completions.create(
model="llama3.1:8b", messages=messages, tools=tools, temperature=0
)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls:
print(msg.content)
break
for call in msg.tool_calls:
fn = registry.get(call.function.name)
if fn is None:
result = {"error": f"unknown function: {call.function.name}"}
else:
try:
args = json.loads(call.function.arguments)
result = fn(**args)
except (json.JSONDecodeError, TypeError) as e:
result = {"error": f"bad arguments: {e}"}
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
else:
print("최대 반복 횟수 초과")
루프에 상한을 두는 건 선택이 아니다. 모델이 같은 함수를 반복 호출하거나 결과를 받고도 또 부르는 상태에 빠질 수 있어서, 6회 정도에서 끊고 사람이 보게 하는 편이 운영에 안전하다. 한 턴에 여러 tool_calls가 한꺼번에 올 수 있으니 반드시 리스트를 순회해 각각 결과를 넣는다.
JSON 스키마로 출력 모양을 고정하기
함수 호출과 별개로, 모델의 최종 답을 정해진 JSON 구조로 받고 싶을 때가 있다. 파싱해서 바로 DB에 넣거나 다른 서비스로 넘길 때다. Ollama의 네이티브 API는 format 필드에 JSON 스키마를 받고, OpenAI 호환 엔드포인트에서는 response_format으로 같은 걸 한다.
resp = client.chat.completions.create(
model="llama3.1:8b",
messages=[
{"role": "user", "content": "서울 날씨를 요약해줘. JSON으로 답해라."},
],
temperature=0,
response_format={
"type": "json_schema",
"json_schema": {
"name": "weather_summary",
"schema": {
"type": "object",
"properties": {
"city": {"type": "string"},
"temp": {"type": "number"},
"advice": {"type": "string"},
},
"required": ["city", "temp", "advice"],
},
},
},
)
data = json.loads(resp.choices[0].message.content)
단순히 유효한 JSON만 받으면 될 때는 {"type": "json_object"}로 충분하다. 스키마로 키와 타입까지 강제하려면 위처럼 json_schema를 쓴다. 두 경우 모두 temperature를 0으로 두고 프롬프트에 "JSON으로 답해라"를 함께 넣어야 결과가 안정적이다. 스키마만 걸고 지시문을 빼면 빈 문자열이나 설명이 섞인 응답이 나오곤 한다.
모델이 엉뚱한 걸 부를 때
가장 자주 겪는 건 등록되지 않은 함수 이름을 지어내는 경우다. 위 루프처럼 registry에서 못 찾으면 에러를 tool 메시지로 되돌려 넣어라. 모델은 그 에러를 읽고 다른 함수를 고르거나 사용자에게 되묻는다. 예외를 던지고 루프를 멈추는 것보다, 에러 자체를 관찰 결과로 넘겨 모델이 스스로 고치게 하는 쪽이 에이전트로서 더 잘 돈다.
인자 JSON이 깨져서 들어오는 경우도 같은 방식으로 받는다. json.loads에서 JSONDecodeError가 나면 "bad arguments" 메시지를 넣어 재시도를 유도한다. 필수 인자가 빠졌거나 타입이 틀려 TypeError가 나는 것도 함께 잡아 둔다.
여기서 두 번째로 큰 함정이 있다. Ollama의 OpenAI 호환 엔드포인트는 tool_choice를 지원하지 않는다. OpenAI에서 하던 것처럼 "tool_choice": {"type": "function", "function": {"name": "..."}}로 특정 함수를 강제할 수 없다. 반드시 한 함수를 부르게 하고 싶다면 그 턴에서 tools에 함수를 하나만 넘기거나, 시스템 메시지로 "반드시 get_weather를 사용하라"고 지시하는 식으로 유도해야 한다. 같은 이유로 logit_bias, n, logprobs도 이 엔드포인트에서는 받지 않는다.
네트워크나 서버 측 일시 오류에는 애플리케이션 레벨 재시도를 따로 둔다. client.chat.completions.create 호출을 지수 백오프로 두세 번 감싸면 모델 로딩 지연이나 순간적인 연결 끊김을 흡수한다. 모델을 처음 호출할 때 로딩 때문에 첫 응답이 느릴 수 있으니, 타임아웃을 너무 짧게 잡지 않는 게 좋다.
여기까지 하면 로컬 모델만으로 함수 호출과 구조화 출력, 실패 복구가 도는 에이전트가 된다. 다음 단계로 붙일 만한 건 함수 실행 결과를 로그로 남겨 어떤 호출이 자주 실패하는지 보는 것, 그리고 tools가 열 개를 넘어갈 때 모델이 선택을 헷갈리기 시작하므로 함수 설명을 다듬거나 라우팅을 앞단에 두는 작업이다.