툴 콜링

모델이 함수를 호출하게 한다

툴 콜(함수 호출)은 모델이 스스로 갖고 있지 않은 정보나 동작을 요청할 수 있게 해줍니다. 모델은 아무것도 직접 실행하지 않습니다 — 어떤 함수를 어떤 인자로 부르고 싶은지 tool_calls 배열로 알려주면, 그 함수를 여러분이 직접 실행하고 결과를 다음 요청에 실어 보냅니다. 모델은 그 결과를 받아 최종 답을 만듭니다.

요청·응답 형식은 OpenAI Chat Completions 의 툴 콜링 규격을 그대로 씁니다. 이미 OpenAI 나 OpenAI 호환 게이트웨이로 이 방식을 쓰고 있다면, base URL 을 https://openrouter.myip.co.kr/api/v1 로, 모델을 우리가 서빙하는 모델로 바꾸는 것만으로 그대로 동작합니다.

호출 전에 지원 여부 확인하기

GET /modelssupported_parameters 가 유일한 진실이고, 쿼리 필터로도 씁니다.

bash
curl "https://openrouter.myip.co.kr/api/v1/models?supported_parameters=tools"

지원하지 않는 모델로 조용히 넘어가는 대신 요청 자체를 막고 싶다면 provider{}require_parameters: true 를 추가하세요. Provider 라우팅 을 보세요.

세 단계로 이루어지는 교환

1단계 — 요청에 툴을 함께 보낸다

json
{
  "model": "google/gemma-4-26b-a4b",
  "messages": [
    { "role": "user", "content": "지금 부산 날씨 어때?" }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "특정 도시의 현재 날씨를 가져온다",
        "parameters": {
          "type": "object",
          "properties": {
            "city": { "type": "string", "description": "도시 이름, 예: Busan" }
          },
          "required": ["city"]
        }
      }
    }
  ]
}

모델이 툴이 필요하다고 판단하면, 메시지 본문 대신 finish_reason: "tool_calls"tool_calls 배열로 응답합니다.

json
{
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_8f2a",
            "type": "function",
            "function": { "name": "get_weather", "arguments": "{\"city\":\"Busan\"}" }
          }
        ]
      },
      "finish_reason": "tool_calls",
      "native_finish_reason": "tool_calls"
    }
  ]
}

2단계 — 툴을 직접 실행한다

우리는 어떤 함수도 대신 실행하지 않습니다. function.arguments 는 객체가 아니라 JSON 문자열이므로 파싱한 뒤 여러분의 코드를 호출하세요.

typescript
const args = JSON.parse(toolCall.function.arguments) as { city: string };
const result = await getWeather(args.city);

3단계 — 결과를 다시 보낸다

어시스턴트의 툴 콜 메시지와 결과를 담은 role: "tool" 메시지를 이어 붙이고, 같은 tools 배열과 함께 다시 호출합니다.

json
{
  "model": "google/gemma-4-26b-a4b",
  "messages": [
    { "role": "user", "content": "지금 부산 날씨 어때?" },
    {
      "role": "assistant",
      "content": null,
      "tool_calls": [
        { "id": "call_8f2a", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\":\"Busan\"}" } }
      ]
    },
    { "role": "tool", "tool_call_id": "call_8f2a", "content": "{\"tempC\":21,\"condition\":\"clear\"}" }
  ],
  "tools": [ /* 1단계와 같은 툴 정의 */ ]
}

두 번째 응답은 finish_reason: "stop" 인 평범한 텍스트 메시지입니다.

전체 예제

import json
import os
from openai import OpenAI

client = OpenAI(base_url="https://openrouter.myip.co.kr/api/v1", api_key=os.environ["MYIP_API_KEY"])
MODEL = "google/gemma-4-26b-a4b"

def get_weather(city: str) -> dict:
    return {"tempC": 21, "condition": "clear"}  # 실제 구현으로 교체

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "특정 도시의 현재 날씨를 가져온다",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}]

messages = [{"role": "user", "content": "지금 부산 날씨 어때?"}]

response = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
message = response.choices[0].message
messages.append(message.model_dump())

for call in message.tool_calls or []:
    args = json.loads(call.function.arguments)
    result = get_weather(**args)
    messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})

final = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
print(final.choices[0].message.content)

tool_choice

json
{ "tool_choice": "auto" }      // 기본값: 모델이 판단
{ "tool_choice": "none" }      // 툴 호출 금지
{ "tool_choice": "required" }  // 무조건 어떤 툴이든 호출
{ "tool_choice": { "type": "function", "function": { "name": "get_weather" } } }  // 이 툴 강제

tool_choice 는 우리 라우팅 키 목록에 없으므로 보낸 그대로 업스트림에 전달됩니다. 모든 변형을 다 지키는지는 우리가 아니라 그 엔진의 구현에 달려 있습니다.

parallel_tool_calls

일부 엔진은 한 턴에 여러 툴을 동시에 요청할 수 있습니다. parallel_tool_calls: false 를 보내면 한 번에 하나씩만 요청하게 만들 수 있습니다. 라우팅 대상이 아닌 다른 파라미터와 마찬가지로 그대로 전달될 뿐이라, 서빙 엔진이 실제로 구현한 경우에만 효과가 있습니다.

스트리밍에서의 툴 콜

툴 콜은 배열 인덱스로 구분되는 delta.tool_calls 청크로 나뉘어 옵니다. finish_reason: "tool_calls" 가 나올 때까지 function.arguments 문자열을 이어 붙이세요.

typescript
const toolCallsByIndex: Record<number, { id: string; name: string; args: string }> = {};

for await (const chunk of stream) {
  for (const delta of chunk.choices[0]?.delta.tool_calls ?? []) {
    const slot = (toolCallsByIndex[delta.index] ??= { id: '', name: '', args: '' });
    if (delta.id) slot.id = delta.id;
    if (delta.function?.name) slot.name = delta.function.name;
    if (delta.function?.arguments) slot.args += delta.function.arguments;
  }
  if (chunk.choices[0]?.finish_reason === 'tool_calls') break;
}

SSE 청크의 전체 형식(마지막 usage 청크, 스트림 도중의 오류 처리 방식 포함)은 스트리밍 을 보세요.

최소한의 에이전트 루프

몇 개의 툴을 정의하든, 모델이 더 이상 툴을 요청하지 않을 때까지 반복하는 구조는 같습니다.

python
def run(messages, tools, max_turns=10):
    for _ in range(max_turns):
        response = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
        message = response.choices[0].message
        messages.append(message.model_dump())
        if not message.tool_calls:
            return message.content
        for call in message.tool_calls:
            result = TOOL_MAP[call.function.name](**json.loads(call.function.arguments))
            messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
    raise RuntimeError("max_turns exceeded")

턴 수는 항상 제한하세요. 모델이 계속 툴을 요청하는 상황은 가상의 시나리오가 아니라, 통합 코드가 예상치 못한 비용을 만들어내는 가장 흔한 원인입니다.

좋은 툴 정의

  • 이름을 구체적으로 짓습니다. weather 가 아니라 get_weather_forecast 처럼요.
  • 설명을 문서처럼 씁니다. 모델은 description 에 적힌 것만 압니다 — 단위, 형식, 예외 상황을 적어두세요("도시 이름, 우편번호, 또는 'lat,lng'").
  • 스키마를 엄격하게 유지합니다. "additionalProperties": falserequired 목록은 잘못된 인자를 줄입니다.
  • 툴이 서로 연결되도록 설계합니다. search_productsget_product_detailscheck_inventory 처럼 이어지는 흐름은 각 툴의 역할을 이해한 모델에게 자연스럽습니다.

툴 콜링 관련 오류

상황결과
지원하지 않는 모델/후보에 tools 를 보냄엔진마다 다릅니다: 대개 그 필드를 조용히 무시하고 안 보낸 것처럼 응답합니다
모델이 만든 arguments 가 JSON 으로 잘못됨우리가 검사하지 않습니다 — JSON.parse 전에 직접 검증하세요
후속 호출에서 tools 를 다시 보내지 않음모델이 같은 툴을 다시 요청하거나, 이전 결과를 쓰지 않고 답할 수 있습니다

일반적인 오류 형식은 오류와 디버깅 을 보세요.

마지막 수정 2026. 9. 5.