Skip to content
This page has been auto-translated and may contain errors.View in English

도구 사용(Tool use)

docs.scrimba.com

도구 사용(흔히 함수 호출이라고도 합니다)은 언어 모델이 자신의 텍스트 입출력 범위를 벗어나 무언가를 할 수 있게 해주는 방법입니다. 모델에게 실행을 요청할 수 있는 함수 목록을 건네주면, 모델은 언제 그 함수를 사용할지 스스로 판단합니다. 모델은 그 무엇도 직접 실행하지 않습니다. 오직 호출을 요청할 뿐이며, 실제 작업은 여러분의 코드가 수행합니다.

모델 혼자서는 상자 안에 밀봉되어 있는 것과 같습니다. 오늘의 날씨를 확인할 수도 없고, 여러분의 데이터베이스에서 사용자를 조회할 수도 없고, 이메일을 보낼 수도 없습니다. 학습 시점에 멈춰버린, LLM은 어떻게 동작하는가에서 본 것과 똑같은 그림 그대로, 모델은 오직 학습한 내용을 바탕으로 텍스트를 예측할 뿐입니다. 도구 사용은 모델이 그 상자 밖으로 손을 뻗을 수 있게 해주는 방법이며, 에이전트가 만들어지는 토대이기도 합니다.

기억해야 할 단어는 "요청"입니다. 모델은 어떤 함수를 원하는지, 어떤 인자와 함께 원하는지를 알려줄 뿐입니다. 여러분의 코드가 그것을 실행하고 결과를 돌려줍니다. 통제권은 항상 여러분에게 있습니다.

모델은 오직 텍스트만 생성하므로, 혼자서는 실시간 데이터를 가져오거나 여러분의 데이터베이스를 다루거나 어떤 동작을 실행할 수 없습니다. 도구 사용은 이 간극을 메워줍니다. 여러분이 함수 집합을 설명해주면, 모델은 그중 하나를 호출하겠다는 구조화된 요청을 내보내고, 여러분의 코드가 그것을 실행하고, 그 결과를 다시 모델에게 넘겨줍니다. 모델은 지휘하고, 여러분의 코드는 실행합니다.

이 장은 앞으로 만들 모든 에이전트의 근간이 되는 메커니즘입니다. 여기서 루프, 도구 스키마, 오류 처리 경로를 제대로 익혀두면 에이전트는 이 동일한 패턴을 반복해서 실행하는 것에 불과해집니다.

도구 사용은 확률적인 텍스트 생성기가 여러분의 결정론적인 코드와 맞닿는 접점이며, 실무에서 겪는 고통 대부분이 바로 이 접점에서 발생합니다. 모델은 구조화된 호출을 예측하고, 여러분의 코드가 이를 실행하며, 그 결과는 다시 컨텍스트로 주입됩니다. 이 작업을 어렵게 만드는 모든 요소들, 즉 신뢰할 수 없는 인자 검증, 멱등성, 추가 왕복 요청, 환각으로 생성된 호출, 관측 가능성 문제는 모두 바로 이 한 번의 전달 지점에서 비롯됩니다.

여기에 새로운 수학은 없습니다. 확률적인 시스템이 여러분의 시스템에서 실제 부작용을 일으키도록 허용하는 데 필요한 엔지니어링 규율일 뿐이며, 에이전트 장 전체가 딛고 서 있는 기반이기도 합니다.

루프

도구 사용은 한 번의 호출이 아니라 **주고받는 과정**입니다.

  1. 사용자의 메시지와 모델이 사용할 수 있는 도구 목록을 함께 보냅니다.
  2. 모델은 두 가지 방식 중 하나로 응답합니다. 일반적인 답변이거나, 원하는 인자와 함께 도구 호출을 요청하는 것입니다.
  3. 도구를 요청했다면, 여러분의 코드가 해당 함수를 실행하고 그 결과를 모델에게 돌려줍니다.
  4. 모델은 그 결과를 이용해 최종 답변을 작성합니다.

이것이 전체 패턴입니다. 모델이 요청하면, 여러분의 코드가 실행합니다. 모델이 여러 도구를 필요로 한다면 2단계와 3단계가 반복될 수 있는데, 이것이 몇 장 뒤에 나올 에이전트의 씨앗입니다.

Juno루프 도구 사용은 하나의 루프입니다. 메시지와 도구 목록을 보내면, 모델은 답변하거나 인자와 함께 도구 호출을 요청하고, 여러분의 코드가 함수를 실행하고, 그 결과를 다시 모델에게 보내 사용하게 합니다. 모델은 결코 코드를 직접 실행하지 않고 오직 호출을 요청할 뿐이므로, 통제권은 항상 여러분에게 있습니다. 이 중간 단계를 반복하면 에이전트의 시작점이 됩니다.

이 루프는 네 단계로 이루어집니다. 메시지와 도구 목록을 보내고, 모델이 답변하거나 호출을 요청하고, 여러분의 코드가 함수를 실행하고, 결과를 돌려주면 모델이 이어서 진행합니다. 초보자들이 흔히 놓치는 부분은 이 과정이 한 번의 왕복으로 끝나는 경우가 드물다는 점입니다. 모델은 도구를 요청하고, 그 결과를 읽은 뒤, 방금 본 내용을 바탕으로 다시 다른 도구를 요청할 수 있으며, 이 과정이 계속될 수 있습니다.

그래서 실제 형태는 **다중 턴 루프**입니다. 도구 호출이 전혀 없는 응답이 돌아올 때까지 모델을 계속 호출하고 요청받은 도구를 계속 실행합니다. 도구 호출이 없는 그 마지막 응답이 사용자에게 줄 답변입니다.

python
messages = [{"role": "user", "content": user_text}]

while True:
    response = client.chat.completions.create(
        model=MODEL, messages=messages, tools=tools,
    )
    msg = response.choices[0].message
    messages.append(msg)              # 모델이 말한 내용을 기록

    if not msg.tool_calls:            # 도구가 필요 없다면: 이것이 답변
        return msg.content

    for call in msg.tool_calls:       # 첫 번째만이 아니라 요청받은 모든 호출을 실행
        args = json.loads(call.function.arguments)
        result = tool_impls[call.function.name](args)
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": json.dumps(result),
        })

루프 조건은 안전성과 직결되는 질문입니다. 계속해서 도구를 요청하는 모델은 스스로 멈추지 않으므로, 프로덕션 환경에서는 반복 횟수에 상한을 두고, 그 상한에 도달하면 명확한 오류로 멈추도록 해야 합니다. 루프는 모델이 도구 호출 없는 응답을 반환할 때 끝납니다.

Juno루프 루프는 도구를 보내고, 모델이 답변하거나 호출을 요청하고, 여러분이 실행하고, 결과를 돌려주고, 이를 반복하는 것입니다. 핵심은 이것입니다. 모델이 도구 호출 없는 응답을 반환할 때까지 계속되므로, 하나의 if문이 아니라 진짜 루프로 작성해야 합니다. 그리고 반복 횟수에 상한을 두세요. 계속 도구를 요청하는 모델은 여러분의 비용으로 아무렇지도 않게 영원히 돌아갈 수 있습니다.

이 루프는 구조적으로는 단순합니다. 도구와 함께 모델을 호출하고, 요청받은 것을 실행하고, 결과를 덧붙이고, 도구 호출 없는 응답이 돌아올 때까지 반복합니다. 흥미로운 부분은 매 반복이 온전한 모델 **왕복 요청**이라는 점이며, 여러분의 지연 시간과 토큰 예산이 소모되는 지점도 바로 여기입니다.

매 턴마다 계속 커지는 메시지 히스토리 전체를 다시 전송하므로, 다섯 번의 도구 호출이 필요한 작업은 프롬프트를 다섯 번이나 반복해서 지불하는 셈이고, 결과가 쌓일수록 입력도 매 턴 커집니다. 조절할 수 있는 지점들:

  • 한 번의 호출로 더 많은 일을 해내는 도구를 제공해서 왕복 횟수를 최소화합니다.
  • 다시 전송되는 히스토리가 부풀어 오르지 않도록 도구 결과를 간결하게 유지합니다.
  • 서로 독립적인 도구 호출은 직렬로 처리하지 말고 동시에 실행해서, 전체 소요 시간이 각 호출의 합이 아니라 가장 느린 호출의 시간에 맞춰지도록 합니다.
python
MAX_TURNS = 8

for turn in range(MAX_TURNS):
    response = client.chat.completions.create(
        model=MODEL, messages=messages, tools=tools,
    )
    msg = response.choices[0].message
    messages.append(msg)

    if not msg.tool_calls:
        return msg.content

    # 모델은 한 턴에 여러 호출을 요청할 수 있습니다. 실행한 뒤 계속 진행합니다
    for call in msg.tool_calls:
        messages.append(run_tool(call))   # 검증되고, 기록되고, 오류가 감싸진 상태

raise RuntimeError("tool loop did not converge within MAX_TURNS")

이 상한선은 선택 사항이 아닙니다. 상한선이 없으면 혼란에 빠진 모델이 무한히 루프를 돌 수 있고, 그 실패 양상은 명확한 오류가 아니라 느리고 비용이 많이 드는 요청으로 나타납니다. 상한선을 두고, 그 상한선에 도달하면 크게 오류를 내면서 트레이스를 기록해서 모델이 무엇을 쫓고 있었는지 볼 수 있게 하세요.

Juno루프 루프를 작성하는 것은 쉽지만 실행하는 데는 비용이 많이 듭니다. 매 턴이 계속 다시 전송되는 히스토리 전체에 대한 온전한 모델 호출이므로, 다섯 번의 도구 호출은 프롬프트를 다섯 번 지불하는 것과 같습니다. 결과를 간결하게 유지하고, 독립적인 호출은 동시에 실행하고, 자잘한 도구를 많이 두기보다 적으면서도 더 많은 일을 하는 도구를 선호하세요. 그리고 턴 수에 강한 상한선을 두세요. 언젠가 모델이 무한히 루프를 돌기로 결정하는 날, 로그에 남는 것은 다섯 자리 청구서가 아니라 깔끔한 오류이길 원할 테니까요.

실제로 벌어지는 일

도구 사용은 마치 모델이 새로운 능력을 얻은 것처럼 느껴질 수 있지만, 실제로는 그렇지 않습니다. 겉으로 드러나지 않는 내부에서 모델은 늘 하던 그 한 가지 일, 즉 텍스트 예측을 하고 있을 뿐입니다. 모델이 갇혀 있는 그 상자 자체는 변한 것이 없습니다.

요청에 도구 정의를 포함시키면, 그것을 모델이 예측의 근거로 삼는 컨텍스트에 추가하는 것입니다. 모델은 이런 도구들이 주어졌을 때, 올바른 다음 내용이 일반적인 문장이 아니라 "도시 오슬로로 get_weather를 호출하라"는 의미의 특별한 형식의 메시지인 경우가 있다는 것을 학습된 예시들을 통해 익혔습니다. 그래서 여러분의 질문이 어떤 도구를 유용해 보이게 만들면, 가장 가능성 높은 다음 출력은 그 구조화된 **도구 호출 메시지**가 되고, API는 이를 tool_calls라는 형태로 여러분에게 보여줍니다.

모델이 밖으로 손을 뻗어 무언가를 실행한 것이 아닙니다. 도구 호출이 올바른 다음 수라고 예측하고, 여러분의 코드가 읽을 수 있는 형식으로 그 요청을 작성한 것입니다.

그다음, 모델이 아니라 여러분이 그 함수를 실행합니다. 실제 결과를 가져와서 메시지에 추가 컨텍스트로 다시 넣으면, 모델은 그 확장된 컨텍스트로부터 최종 답변을 예측합니다. 이 기능 전체는 그 사이에서 여러분의 코드가 실제 작업을 수행하는, 두 번의 평범한 예측일 뿐입니다.

여러분은 모델의 텍스트 상자와 현실 세계를 잇는 다리입니다. 이 그림을 마음에 새기면, 에이전트를 포함해 이 장의 나머지 내용이 더 이상 신비롭게 느껴지지 않을 것입니다. AI가 취하는 모든 행동은 예측된 요청이며, 그것을 실행할지는 여러분의 코드가 선택하는 것입니다.

Juno실제로 벌어지는 일 도구 사용도 여전히 순수한 예측입니다. 모델은 컨텍스트에 도구 정의가 주어지면, 가끔 확률적으로 "이 인자들로 이 함수를 호출하라"는 구조화된 메시지를 출력하도록 학습되었고, API는 이를 tool_calls라는 형태로 여러분에게 넘겨줍니다. 모델은 결코 무언가를 실행하지 않고, 요청을 예측할 뿐입니다. 여러분의 코드가 함수를 실행하고, 그 결과를 다음 예측을 위한 추가 컨텍스트로 다시 넘겨주므로, 여러분이 모델과 현실 세계를 잇는 다리입니다.

도구 사용은 모델에 나중에 덧붙여진 새로운 능력이 아니라, LLM은 어떻게 동작하는가에서 본 것과 똑같은 다음 토큰 예측이 구조화된 목표를 향하고 있을 뿐입니다. 도구 정의는 컨텍스트에 들어가고, 모델은 어떤 도구가 적합할 때 가장 확률이 높은 다음 내용이 산문이 아니라 형식화된 호출이 되도록 학습되었습니다. API는 그 결과를 파싱해서 tool_calls라는 형태로 여러분에게 넘겨줍니다.

여기서 유용한 결론은, 도구 호출이 **제약된 생성**이라는 점입니다. 즉 여러분이 제공한 스키마에 맞춰 모델이 생성하는 출력입니다. 이는 구조화된 출력과 정확히 같은 메커니즘이며, 그곳에서도 여러분이 정의한 형태로 JSON을 반환해달라고 모델에게 요청합니다.

두 가지를 가르는 것은 의도입니다. 구조화된 출력은 "이 형태로 데이터를 주고 멈춰라"입니다. 도구 사용은 "무언가를 실행하고 그 결과를 다시 받을 수 있도록 이 형태로 데이터를 달라"입니다.

모델로부터 파싱된 객체만 얻으면 충분하다면 구조화된 출력을 쓰세요. 모델이 실제로 무언가를 실행하고 여러분의 코드가 반환한 것에 반응해야 한다면 도구 사용을 쓰세요.

호출이 예측된 것일 뿐 실행된 것이 아니므로, 여러분의 시스템에 실제로 닿는 것은 여러분의 코드뿐입니다. 모델은 제안하고, 여러분의 코드가 처리를 결정하며, 실제 일이 벌어지기 전 모든 검사는 바로 그 경계선에 두어야 합니다.

Juno실제로 벌어지는 일 도구 호출도 같은 다음 토큰 예측이며, 산문이 아니라 스키마를 겨냥하고 있을 뿐입니다. API는 그 예측을 tool_calls라는 형태로 드러냅니다. 이는 제약된 생성이며, 구조화된 출력과 동일한 메커니즘입니다. 차이는 의도입니다. 구조화된 출력은 데이터를 주고 멈추고, 도구 사용은 무언가를 실행하고 반응할 수 있도록 데이터를 줍니다. 어느 쪽이든 모델은 제안만 하고, 실제로 무언가에 닿는 것은 오직 여러분의 코드입니다.

도구 호출은 예측된 텍스트일 뿐 실행이 아니며, 이를 명확히 구분하는 것이 여러분을 지켜주는 힘입니다. 모델은 컨텍스트에 주어진 도구를 바탕으로 가장 확률이 높은 다음 내용을 내보내고, API는 이를 구조화된 호출로 디코딩하며, 여러분의 코드는 그것을 실행할지 결정합니다. 기계적으로 보면 이것은 부작용이 붙은 구조화된 출력입니다.

이런 시각이 신뢰 경계를 정확히 설정해줍니다. 도구 호출 속 인자는 모델의 출력이며, 이는 그것이 **신뢰할 수 없는 입력**이라는 뜻입니다. 확률적인 시스템이 생성한 텍스트이며, 사용자가 폼에 입력한 것과 다를 바 없는 지위를 가집니다. 그렇게 대해야 합니다.

모델은 루프 중간에 읽는 콘텐츠에 의해서도 조종될 수 있습니다. 그래서 어떤 도구가 웹페이지나 문서에서 가져온 텍스트를 반환한다면, 그 텍스트에 숨겨진 프롬프트 인젝션이 모델을 꼬드겨 공격자가 지정한 인자로 다른 도구를 요청하게 만들 수 있습니다. 방어는 시스템 프롬프트에 넣는 예의 바른 지시문이 아니라 구조적인 것이어야 합니다. 인자를 엄격한 스키마로 검증하고, 각 도구의 권한을 필요한 최소 범위로 제한하고, 도구의 권한이 그 입력에 영향을 줄 수 있는 가장 신뢰할 수 없는 당사자에게 허용할 권한보다 커지지 않도록 하세요.

그러니 여기서 가져야 할 사고 모델은 동료가 아니라 요청 라우터입니다. 모델은 제안된 인자와 함께 어떤 함수로 라우팅합니다. 여러분의 코드는 인증하고, 권한을 확인하고, 검증한 다음에야 실행합니다. "그다음에야"라는 말 이후에 벌어지는 모든 일이 바로 이 장이 진짜 가치를 발휘하는 지점입니다.

Juno실제로 벌어지는 일 도구 호출은 API가 대신 디코딩해주는 예측된 텍스트이며, 부작용이 붙은 구조화된 출력일 뿐, 모델이 무언가를 실행하는 것이 절대 아닙니다. 그러므로 인자는 신뢰할 수 없는 입력이며, 낯선 사람이 채워 넣은 폼 필드와 같은 지위를 가집니다. 도구가 반환한 텍스트에 숨은 프롬프트 인젝션은 다음 호출을 다른 방향으로 돌릴 수 있습니다. 경계선에서 검증하고, 범위를 제한하고, 권한을 확인하세요. 모델은 요청을 라우팅할 뿐이고, 손잡이를 실제로 쥐고 있는 것은 오직 여러분의 코드입니다.

도구 정의하기

각 도구를 모델에게 설명해야 합니다. 이름, 무엇을 하는지, 어떤 인자를 받는지를 말입니다. 이 설명은 여러분을 위한 문서가 아니라 언제 이 도구를 사용해야 하는지에 대해 모델에게 주는 지시이므로, 그것이 곧 프롬프트임을 염두에 두고 작성해야 합니다.

python
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get the current weather for a city. Use when the user asks about weather.",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "The city name, e.g. 'Seoul'"},
                },
                "required": ["city"],
            },
        },
    },
]

parameters 블록은 **스키마**이며, 구조화된 출력과 같은 개념입니다. 즉 모델이 생성해야 하는 인자를 정의합니다. 모델이 get_weather를 호출하기로 결정하면, 이 형태에 맞는 city 인자를 돌려보냅니다. 모호한 설명("날씨를 가져온다")은 모델이 엉뚱한 순간에 도구를 사용하게 만듭니다. 명확한 설명("사용자가 날씨에 대해 물을 때 사용")은 모델을 잘 안내합니다.

Juno도구 정의하기 도구 정의는 이름, 설명, 그리고 인자를 위한 parameters 스키마로 구성됩니다. 설명은 사실 프롬프트입니다. 언제 도구를 사용해야 하는지 모델에게 알려주므로 신경 써서 작성해야 합니다. parameters 스키마는 구조화된 출력과 동일한 메커니즘으로, 모델이 보내는 인자를 제약합니다.

도구 정의에는 모델이 읽는 세 가지 요소가 있습니다. 이름, 설명, parameters 스키마입니다. 설명은 사람들이 흔히 소홀히 다루는 부분입니다. 이것은 여러분의 동료를 위한 주석이 아니라, 모델이 언제 그 도구를 호출할지 결정하는 데 쓰는 **프롬프트**이므로, 그렇게 작성해야 합니다. 도구가 무엇을 하는지, 언제 사용해야 하는지, 그리고 언제 사용하지 말아야 하는지를 명시하세요.

python
tools = [
    {
        "type": "function",
        "function": {
            "name": "search_orders",
            "description": (
                "Look up a customer's orders by their account email. "
                "Use only when the user asks about an existing order. "
                "Do not use for product questions or refunds."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "email": {"type": "string", "description": "Account email to search"},
                    "status": {
                        "type": "string",
                        "enum": ["pending", "shipped", "delivered", "cancelled"],
                        "description": "Optional status filter",
                    },
                },
                "required": ["email"],
                "additionalProperties": False,
            },
        },
    },
]

스키마를 조여두면 모델도 조여집니다.

  • required는 도구가 없이는 실행할 수 없는 필수 인자를 강제합니다.
  • enum은 어떤 필드를 고정된 값 집합으로 제한하므로, 모델이 다섯 번째 상태를 지어낼 수 없습니다.
  • additionalProperties: false는 여러분이 정의하지 않은 필드를 거부합니다.

스키마는 여러분의 첫 번째 방어선입니다. 스키마가 좁을수록 모델이 도구를 잘못 호출할 수 있는 방법도 줄어듭니다.

이 도구 형태는 OpenAI 방식입니다. 정확한 키 이름은 제공자마다 다르지만(Anthropic 등은 스키마를 다르게 중첩합니다), 이름, 설명, 스키마라는 세 가지 요소는 어디서나 동일합니다.

Juno도구 정의하기 이름, 설명, 그리고 parameters 스키마, 이것이 도구입니다. 설명은 독스트링이 아니라 프롬프트입니다. 언제 호출해야 하고 언제 호출하지 말아야 하는지 모델에게 알려주지 않으면, 엉뚱한 순간에 호출하게 됩니다. required, enum, 그리고 불필요한 속성 금지로 스키마를 조여두세요. 좁은 스키마는 모델이 도구를 잘못 호출할 방법을 줄여줍니다. 정확한 JSON 키는 제공자마다 다르지만, 그 세 가지 요소는 변하지 않습니다.

도구 정의는 한 모자를 쓴 두 개의 프롬프트입니다. 설명은 모델이 도구를 언제 호출하는지를 조종하고, 스키마는 어떤 인자를 보낼 수 있는지를 제약하며, 둘 다 모델을 향한 지시문이지 내부 문서가 아닙니다. 스키마는 여러분이 엄격해질 수 있는 지점이며, 이 지점에서의 엄격함이 가장 저렴한 환각 방지책입니다.

python
{
    "name": "issue_refund",
    "description": (
        "Issue a refund for a specific order line. "
        "Use only after confirming the order ID and amount with the user. "
        "Never call for amounts above the order total."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "order_id": {"type": "string", "pattern": "^ord_[0-9]{8}$"},
            "amount_cents": {"type": "integer", "minimum": 1, "maximum": 100000},
            "reason": {"type": "string", "enum": ["damaged", "wrong_item", "late"]},
        },
        "required": ["order_id", "amount_cents", "reason"],
        "additionalProperties": False,
    },
}

제약된 필드(enum, pattern, minimum/maximum, 또는 required가 설정된 필드)는 모델이 내보낼 수 있는 값의 범위를 좁혀주지만, 그 한계를 명확히 이해해야 합니다. 많은 제공자가 스키마의 구조를 검증하지만, 값 자체는 여전히 예측에서 나오므로, 엄격한 스키마는 형식이 잘못된 호출을 줄여줄 뿐 그 호출이 올바르거나 안전하다는 것을 증명하지는 않습니다. 패턴에 맞는 order_id라고 해서 그 주문이 실제로 존재하거나 이 사용자의 것이라는 뜻은 아닙니다. 그러니 스키마는 필터일 뿐 인증 검사가 아닙니다.

부작용이 실행되기 전, 여러분의 코드에서 여전히 모든 인자를 실제 상태와 대조해서 검증해야 합니다. 주문이 실제로 존재하는지, 금액이 총액을 넘지 않는지, 호출자가 그 주문을 소유하고 있는지 말입니다. 스키마 검증은 잘못된 형식을 잡아내지만, 잘못된 행동을 잡아내는 것은 오직 여러분의 코드뿐입니다. (스키마 키와 제공자별로 이를 얼마나 엄격하게 강제하는지는 다르므로, 제약이 강제된다고 가정하기보다 사용하는 제공자의 동작을 직접 확인하세요.)

한 가지 더 조절할 수 있는 지점은 도구의 개수입니다. 대략 열두 개를 넘으면 선택 정확도가 떨어지고 모델이 잘못된 도구를 집게 되며, 모든 정의는 매 호출마다 컨텍스트를 차지하며 토큰을 소모합니다. 전체 API 표면을 한꺼번에 노출하기보다, 활성 도구 집합을 작업에 맞게 작고 관련성 높게 유지하세요.

Juno도구 정의하기 설명은 모델이 언제 호출하는지를 통제하고, 스키마는 무엇을 보낼 수 있는지를 통제하며, 둘 다 프롬프트입니다. enum, pattern, 범위 지정으로 스키마를 단단히 잠가두되, 형식이 올바른 것과 행동이 올바른 것을 혼동하지 마세요. 형식이 올바른 order_id라도 여전히 신뢰할 수 없으므로, 부작용이 실행되기 전 실제 상태와 대조해 재검증하세요. 그리고 도구 집합을 작게 유지하세요. 열두 개를 넘으면 모델이 잘못 고르게 되고 모든 정의가 컨텍스트에 부담을 줍니다. 스키마는 걸러내고, 여러분의 코드가 승인합니다.

호출 처리하기

모델이 도구를 원할 때, 응답에는 최종 답변 대신 tool_calls가 들어 있습니다. 요청된 함수와 인자를 읽고, 실제 함수를 실행하고, 결과를 tool 메시지로 돌려보냅니다. 그런 다음 모델을 다시 호출해서 마무리하게 합니다.

python
import json

# 도구의 실제 구현
def get_weather(city):
    # 실제 앱에서는 날씨 API를 호출하지만, 여기서는 가짜로 만듭니다
    return {"city": city, "tempC": 18, "condition": "cloudy"}

messages = [{"role": "user", "content": "서울 날씨가 어때?"}]

# 1. 첫 번째 호출: 도구를 제공합니다
response = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
tool_calls = response.choices[0].message.tool_calls
tool_call = tool_calls[0] if tool_calls else None

if tool_call:
    # 2. 모델이 전달한 인자로 요청된 함수를 실행합니다
    args = json.loads(tool_call.function.arguments)
    result = get_weather(args["city"])

    # 3. 모델의 요청과 결과를 다시 보냅니다
    messages.append(response.choices[0].message)         # 어시스턴트의 도구 요청
    messages.append({
        "role": "tool",
        "tool_call_id": tool_call.id,
        "content": json.dumps(result),
    })

    # 4. 모델이 결과를 이용해 답변할 수 있도록 다시 호출합니다
    response = client.chat.completions.create(model=MODEL, messages=messages)

print(response.choices[0].message.content)
# "서울은 현재 18도이고 흐립니다."

인자는 **JSON 문자열**로 도착하므로, json.loads로 실제 객체로 변환한 다음 사용하기 전에 확인합니다. 히스토리에는 두 개의 메시지를 추가합니다. 모델의 도구 요청과 여러분의 tool 결과이며, 이 둘은 tool_call_id로 연결됩니다. 마지막 호출은 원시 날씨 데이터를 자연스러운 문장으로 바꿔줍니다.

Juno호출 처리하기 모델이 도구를 원할 때, 응답에는 텍스트 대신 tool_calls가 담겨 있습니다. JSON 문자열에서 인자를 파싱하고, 실제 함수를 실행하고, 두 개의 메시지를 돌려보냅니다. 모델의 요청과 여러분의 결과이며, tool_call_id로 연결됩니다. 마지막 모델 호출이 여러분의 원시 결과를 자연스러운 답변으로 바꿔줍니다.

모델이 도구를 원할 때, 응답에는 content 대신 tool_calls가 담겨 있습니다. 각 호출은 함수 이름, id, 그리고 JSON 문자열로 된 인자를 줍니다. 파싱하고, 실행하고, 일치하는 tool_call_id로 태그된 tool 메시지를 추가합니다. 그래야 모델이 어느 결과가 어느 요청에 대한 답인지 알 수 있습니다.

제대로 다뤄야 할 중요한 부분은 실패 처리입니다. 여러분의 도구는 예외를 던질 것입니다. 잘못된 인자, 404, 타임아웃 등입니다. 그 예외가 그대로 위로 전파되도록 두려는 유혹이 들지만, 그렇게 하면 전체 턴이 끝나버립니다. 더 나은 방법은 **오류를 데이터로 반환하는 것**입니다. 오류를 잡아서 도구 결과로 돌려보내면, 모델이 무엇이 잘못되었는지 읽고 복구하거나, 수정된 인자로 재시도하거나, 사용자에게 할 수 없다고 알릴 수 있습니다.

python
def run_tool(call):
    try:
        args = json.loads(call.function.arguments)
        result = tool_impls[call.function.name](args)
        content = json.dumps(result)
    except Exception as err:
        # 실패를 예외가 아니라 데이터로 돌려줍니다
        content = json.dumps({"error": str(err)})
    return {"role": "tool", "tool_call_id": call.id, "content": content}

오류를 도구 메시지로 반환하는 것이 도구 루프를 취약하지 않고 견고하게 만들어줍니다. 잘못된 형식의 이메일로 search_orders를 호출한 모델은 {"error": "invalid email"}을 돌려받고 사용자에게 수정을 요청할 수 있으며, 여러분의 전체 요청이 500 오류로 죽는 일은 없습니다. 응답 형태(tool_calls, tool_call_id, tool 역할)는 OpenAI 방식이며 제공자마다 다르지만, 파싱하고, 실행하고, 결과나 오류를 반환하는 패턴은 어디서나 그대로 유지됩니다.

Juno호출 처리하기 응답에는 tool_calls가 담겨 있습니다. JSON 인자를 파싱하고, 함수를 실행하고, 일치하는 tool_call_idtool 메시지를 추가하세요. 실패 시 핵심 요령은 이것입니다. 예외가 그대로 전파되어 턴을 끝내버리게 하지 말고, 잡아서 {"error": ...}를 도구 결과로 돌려보내야 모델이 복구하거나 재시도할 수 있습니다. 정확한 필드 이름은 제공자마다 다르지만, 파싱하고, 실행하고, 결과나 오류를 반환하는 것은 어디서나 똑같습니다.

호출을 파싱하고 라우팅하는 것은 일상적인 부분입니다. 위험한 부분은 인자를 받은 시점부터 부작용을 실행하기까지 그 사이에 벌어지는 모든 일입니다. 인자는 모델의 출력이고, 함수는 실제로 무언가를 하기 때문입니다.

시연용 데모와 프로덕션에서 돌릴 수 있는 도구를 가르는 세 가지 습관이 있습니다.

  • 첫째, 실행하기 전에 검증하세요. 스키마가 아니라 실제 상태와 대조해서입니다. 스키마는 이미 통과했을 테니(그렇지 않았다면 여기까지 오지 않았을 것입니다), 이제는 주문이 실제로 존재하는지, 사용자가 그것을 소유하고 있는지, 금액이 범위 안에 있는지를 확인하세요.
  • 둘째, **멱등성**을 갖도록 만드세요. 같은 작업을 두 번 실행해도 한 번 실행한 것과 같은 효과를 내는 속성입니다. 모델은 재시도하고, 루프는 다시 실행되고, 네트워크는 중복을 일으키므로, 쓰기 작업을 하는 도구는 멱등성 키가 필요합니다(주로 tool_call_id에서 파생됩니다). 그래야 반복 실행이 이중 결제나 이중 발송을 일으키지 않습니다.
  • 셋째, 오류를 데이터로 반환하되, 모델이 그것을 바탕으로 행동할 수 있을 만큼 충분히 구조화하세요. 그래야 복구 가능한 실패는 재시도로 이어지고, 복구 불가능한 실패는 사용자에게 전달되는 깔끔한 메시지가 됩니다.
python
def run_tool(call):
    name, args = call.function.name, json.loads(call.function.arguments)
    log.info("tool_call", name=name, args=redact(args), call_id=call.id)  # 관측 가능성

    try:
        validate(name, args)                       # 실제 상태 검사, 잘못된 입력이면 예외 발생
        result = TOOLS[name](args, idem_key=call.id)  # 재시도해도 멱등
        content = json.dumps(result)
    except ValidationError as err:
        content = json.dumps({"error": "invalid", "detail": str(err)})  # 모델이 재시도 가능
    except Exception as err:
        log.exception("tool_failed", call_id=call.id)
        content = json.dumps({"error": "tool_failed"})  # 모델에게 내부 정보를 노출하지 않음

    return {"role": "tool", "tool_call_id": call.id, "content": content}

프로덕션 환경에서 마주치는 현실 두 가지가 더 있습니다.

  • 관측 가능성: 어떤 도구가 실행되었는지, 어떤 인자와 함께였는지(비밀 정보는 가려서), 무엇을 반환했는지 기록하세요. 에이전트가 이상하게 동작할 때, 도구 호출 트레이스만이 실제로 무슨 일이 있었는지 재구성할 수 있는 유일한 방법이기 때문입니다.
  • 파괴적인 도구: 삭제, 결제, 이메일 발송처럼 되돌릴 수 없는 작업은 모델의 호출을 최종 권한으로 삼지 마세요. 확인 절차, 무슨 일이 일어날지 알려주는 드라이런, 또는 사람의 승인 단계로 막아두고, 모델이 잘못되거나 탈취당해도 피해 범위가 한정되도록 자격 증명의 권한을 제한하세요.

스키마상 유효한 호출이라도 여러분의 코드가 승인하기 전까지는 여전히 신뢰할 수 없습니다. (필드 이름과 재시도 형식은 OpenAI 방식이지만, 이 원칙은 어떤 제공자에게도 적용됩니다.)

Juno호출 처리하기 파싱은 일상적인 부분입니다. 인자와 부작용 사이에서는, 스키마만이 아니라 실제 상태와 대조해서 검증하고, 재시도가 두 번 발생할 것이므로 tool_call_id를 기반으로 쓰기 작업을 멱등하게 만들고, 여러분의 내부 정보를 노출하지 않으면서 모델이 행동할 수 있는 데이터로 오류를 반환하세요. 모든 호출과 그 인자를 기록해서 에이전트가 실제로 무엇을 했는지 재구성할 수 있게 하세요. 그리고 파괴적인 작업은 확인 절차나 범위가 제한된 자격 증명으로 막아두세요. 스키마상 유효한 환불이라도 여러분의 코드가 허락하기 전까지는 여전히 낯선 사람의 요청일 뿐입니다.

실전에서

같은 흐름을 재사용 가능한 함수로 만든 것입니다. 모델과 여러분의 시스템 사이를 가로막고 있는 유일한 것이 여러분이 작성하고 통제하는 코드라는 점에 주목하세요.

python
import json

tool_impls = {"get_weather": lambda args: get_weather(args["city"])}

def answer_with_tools(user_text):
    messages = [{"role": "user", "content": user_text}]

    response = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
    calls = response.choices[0].message.tool_calls
    if not calls:
        return response.choices[0].message.content  # 모델이 직접 답변함

    call = calls[0]
    args = json.loads(call.function.arguments)
    result = tool_impls[call.function.name](args)

    messages.append(response.choices[0].message)
    messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})

    response = client.chat.completions.create(model=MODEL, messages=messages)
    return response.choices[0].message.content

이 코드는 하나의 도구 호출만 처리합니다. 모델은 한 번에 여러 도구를 요청할 수도 있는데, 이를 **병렬 도구 호출**이라고 부르며, 이는 tool_calls의 모든 항목을 순회하는 방식으로 처리하면 됩니다. 그리고 모델이 끝날 때까지 매번 스스로 다음 단계를 결정하며 루프 안에서 계속 도구를 호출하게 두면, 바로 에이전트가 되며, 이것이 다음 장에서 다룰 내용입니다.

Juno실전에서 함수로 감싸면, 도구 사용은 도구를 고르기 위한 한 번의 모델 호출, 그것을 실행하는 여러분의 코드, 그리고 결과를 답변으로 바꾸는 두 번째 호출로 이루어집니다. 모델과 여러분의 시스템 사이에 있는 유일한 것은 여러분이 작성한 코드이므로, 통제권은 여러분에게 있습니다. 모델이 끝날 때까지 이 과정을 반복하면 에이전트가 됩니다.

실제 **도구 핸들러**에서는 다중 턴 루프, 병렬 호출, 오류 반환을 하나의 함수 안에 결합합니다. 모델은 한 턴에 여러 도구를 요청할 수 있으며, 루프는 더 이상 요청하지 않을 때까지 계속됩니다.

python
import json

MAX_TURNS = 6

def answer_with_tools(user_text):
    messages = [{"role": "user", "content": user_text}]

    for _ in range(MAX_TURNS):
        response = client.chat.completions.create(
            model=MODEL, messages=messages, tools=tools,
        )
        msg = response.choices[0].message
        messages.append(msg)

        if not msg.tool_calls:
            return msg.content

        for call in msg.tool_calls:        # 병렬 호출 전체를 처리
            messages.append(run_tool(call))  # 파싱, 실행, 결과나 오류 반환

    return "죄송하지만 완료할 수 없었습니다."  # 턴 상한에 도달함

이것이 데모가 아니라 프로덕션에서 쓸 수 있는 코드가 되게 만드는 세 가지 결정이 있습니다. [0]이 아니라 tool_calls모든 항목을 순회해야 합니다. 그렇지 않으면 모델이 요청한 호출을 조용히 놓치게 됩니다. MAX_TURNS에 상한을 두어서 모델이 영원히 돌지 못하게 하세요. 그리고 실패는 run_tool을 통해 오류 메시지로 라우팅해서, 하나의 잘못된 호출이 전체 대화를 무너뜨리지 않도록 하세요.

각 단계를 미리 하드코딩해두는 대신 모델이 매 턴 스스로 다음 행동을 결정하게 두면, 바로 이 루프가 에이전트가 됩니다. 차이는 아키텍처가 아니라 자율성에 있습니다.

Juno실전에서 프로덕션용 핸들러는 다중 턴 루프에 세 가지를 결합한 것입니다. 첫 번째만이 아니라 tool_calls의 모든 항목을 순회하고, 턴 수에 상한을 두어 영원히 돌지 못하게 하고, 실패를 도구 메시지로 반환해서 하나의 잘못된 호출이 전체 실행을 무너뜨리지 않게 하는 것입니다. 같은 루프에 자율성만 더하면 에이전트가 됩니다. 아키텍처는 변하지 않고, 모델이 스스로 다음 단계를 결정하는 권한만 늘어날 뿐입니다.

프로덕션용 핸들러는 앞서 다룬 모든 것을 하나로 엮습니다. 상한이 있는 다중 턴 루프, 동시에 실행되는 병렬 호출, 검증되고 기록되는 실행, 그리고 데이터로 반환되는 오류입니다. 남은 질문은 대부분의 팀이 건너뛰는 질문입니다. 언제 모델에게 도구를 아예 주지 말아야 하는가.

행동이 정말로 모델의 자연어 판단에 의존하는 경우, 즉 사용자가 어떤 주문을 말하는 것인지, 이것이 환불 대상인지, 무엇을 검색해야 하는지 같은 경우에는 도구가 올바른 답입니다. 경로가 **결정론적**일 때, 즉 같은 입력이 항상 같은 호출을 만들어낼 때는 잘못된 답입니다. 어떤 요청이 항상 같은 도출 가능한 인자로 같은 호출을 발생시킨다면, 그것을 코드로 짜넣고 왕복 요청을 건너뛰세요. 환각의 여지, 지연 시간, 토큰 비용을 손실 없이 없애는 셈입니다. 모델에게 도구를 주는 것은 유연성을 사는 대신 비결정성으로 그 대가를 치르는 일이므로, 유연성이 진짜 필요한 곳에만 그 비용을 쓰세요.

python
def answer_with_tools(user_text):
    messages = [{"role": "user", "content": user_text}]

    for turn in range(MAX_TURNS):
        response = client.chat.completions.create(
            model=MODEL, messages=messages, tools=tools, tool_choice="auto",
        )
        msg = response.choices[0].message
        messages.append(msg)
        if not msg.tool_calls:
            return msg.content

        results = run_tools_concurrently(msg.tool_calls)  # 독립적인 호출을 병렬로 처리
        messages.extend(results)

    log.warning("tool_loop_unconverged", turns=MAX_TURNS)
    return fallback_answer()

도구를 사용할 때 조절할 수 있는 두 가지 지점이 있습니다.

  • 도구 호출 환각을 제약하세요. 모델은 여러분이 정의하지 않은 도구에 대한 호출을 지어내거나 인자를 조작할 수 있으므로, 알려지지 않은 도구 이름은 무조건 거부하고, 어느 쪽이 될지 매번 운에 맡기기보다 도구가 필요하다는 것을 알고 있을 때는 강제하고, 필요 없다는 것을 알고 있을 때는 금지하기 위해 tool_choice(도구 사용을 강제하거나 금지하거나 자유롭게 두는 매개변수)를 사용하세요.
  • 관측 가능성 트레이스, 즉 도구, 인자, 결과, 턴 수를 계속 남기세요. 자율적인 루프는 실제로 무엇을 했는지 재현할 수 있을 때만 디버깅이 가능합니다.

이 핸들러는 에이전트의 그야말로 씨앗입니다. 에이전트는 더 넓은 도구 집합과 목표를 향해 호출을 연결해나갈 수 있는 자유가 주어진, 바로 이 루프이며, 이것이 여기서의 실패 양상이 그곳에서 더 크게 확대되는 이유이기도 합니다. (SDK 표면은 OpenAI 방식이며, tool_choice와 메시지 형식은 제공자마다 다릅니다.)

Juno실전에서 완전한 핸들러는 상한이 있는 루프에 동시에 실행되고, 검증되고, 기록되는 도구 실행과 데이터로서의 오류를 결합한 것입니다. 건너뛴 질문은 언제 도구를 주지 말아야 하는가입니다. 호출이 결정론적이라면 코드로 짜넣고 왕복 요청, 지연 시간, 환각의 여지를 없애세요. 운에 맡기지 말고 tool_choice로 도구 사용을 강제하거나 금지하고, 여러분이 정의하지 않은 도구에 대한 호출은 거부하고, 트레이스를 남기세요. 이것은 아직 보조 바퀴가 달려 있는 에이전트일 뿐이며, 실패 양상은 여기서부터 점점 더 커질 뿐입니다.