포스트

LLM 에이전트 기초 (2) - Tool Use: 함수를 모델에 넘기고 결과를 돌려주기

도구를 모델에 넘기고 tool_use 블록을 받아 실행한 뒤 tool_result로 돌려주는 한 번의 왕복을 완성합니다.

LLM 에이전트 기초 (2) - Tool Use: 함수를 모델에 넘기고 결과를 돌려주기

LLM 에이전트 기초 시리즈의 2편입니다. 1편에서 프롬프트만으로 막혔던 지점을 도구로 넘습니다.

도구를 넘긴다는 것과 한 번의 왕복

1편에서 “이수진 고객 주문 확인해 줘”를 프롬프트만으로 시켜 봤습니다. 모델은 주문번호를 지어내거나 사용자에게 되물었습니다. 데이터베이스에 접근할 방법이 없으니 당연한 결과입니다.

여기서 흔한 오해가 하나 있습니다. 모델에 함수를 넘기면 모델이 그 함수를 실행해 준다는 생각입니다. 모델에 넘기는 것은 함수가 아니라 함수의 설명서입니다. 이름, 하는 일, 받는 인자를 JSON으로 적어서 보냅니다. 모델은 그 설명서를 읽고 “이 도구를 이 인자로 불러 주세요”라는 요청을 텍스트 대신 구조화된 블록으로 돌려줄 뿐입니다.

실행은 우리 몫입니다. 모델이 요청한 이름을 우리 코드의 함수에 매핑하고, 우리 서버에서 실행하고, 결과를 다시 모델에 보내 줍니다. 판단은 모델이 하고 실행은 우리가 한다는 이 분업을 먼저 못박아 두면 나머지가 전부 따라옵니다. 모델이 네트워크도 파일 시스템도 건드리지 못한다는 것은 제약이 아니라 안전장치입니다. 5편에서 환불처럼 되돌릴 수 없는 행동 앞에 사람을 세울 수 있는 이유도 실행 지점이 전부 우리 코드 안에 있기 때문입니다.

tool use(도구 사용) 한 번은 API 호출 두 번으로 이루어집니다.

  1. 도구 목록과 함께 요청한다. tools=TOOL_SPECS를 붙여 사용자 메시지를 보냅니다.
  2. 모델이 stop_reason="tool_use"로 응답한다. 최종 답변 대신 도구 호출 요청이 왔다는 신호입니다.
  3. tool_use 블록에서 nameinput을 꺼내 실행한다. 이름으로 함수를 찾고 input을 키워드 인자로 풀어 넣습니다.
  4. tool_result로 결과를 돌려주며 재요청한다. 직전 assistant 응답과 도구 결과를 대화에 이어 붙입니다.
  5. 모델이 최종 답변을 낸다. stop_reasonend_turn이면 끝입니다.

대화 배열로 보면 메시지 하나가 네 개로 늘어납니다. user(질문), assistant(도구 호출 요청), user(도구 결과), assistant(최종 답변). 도구 결과가 user 역할에 들어가는 것은 모델 입장에서 외부에서 들어온 입력이기 때문입니다.

표준 코드 세우기

0편에서 만든 tools.py의 함수 세 개와 TOOL_SPECS를 가져다 씁니다. 여기에 agent.py를 새로 만듭니다. 이 파일에서 정의하는 이름 네 개는 8편까지 그대로 씁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# agent.py
import json
import anthropic
from tools import TOOL_SPECS, search_orders, get_order, request_refund

client = anthropic.Anthropic()
MODEL = "claude-opus-5"

TOOL_FUNCS = {
    "search_orders": search_orders,
    "get_order": get_order,
    "request_refund": request_refund,
}


def run_tool(name: str, args: dict) -> dict:
    """도구 하나를 실행하고 결과를 dict로 돌려줍니다."""
    return TOOL_FUNCS[name](**args)

TOOL_FUNCS는 문자열 이름을 실제 함수에 연결하는 디스패치 테이블입니다. 모델이 돌려주는 것은 "search_orders"라는 문자열뿐이므로 이 매핑이 없으면 아무것도 실행할 수 없습니다. eval이나 globals() 조회로 대신하면 모델이 만들어 낸 임의의 이름이 실행 경로에 들어옵니다. 딕셔너리에 명시적으로 적은 것만 실행되게 둡니다.

이제 한 번의 왕복을 처리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# agent.py (이어서)
def one_round_trip(user_message: str) -> str:
    messages = [{"role": "user", "content": user_message}]

    # 1. 도구 목록과 함께 요청
    response = client.messages.create(
        model=MODEL, max_tokens=4096,
        tools=TOOL_SPECS, messages=messages,
    )
    print("stop_reason:", response.stop_reason)
    print("blocks:", [b.type for b in response.content])

    if response.stop_reason != "tool_use":
        return "".join(b.text for b in response.content if b.type == "text")

    # 2. assistant 응답을 통째로 대화에 추가
    messages.append({"role": "assistant", "content": response.content})

    # 3. tool_use 블록을 찾아 실행
    results = []
    for block in response.content:
        if block.type != "tool_use":
            continue
        print(f"호출 요청: {block.name}({block.input})")
        output = run_tool(block.name, block.input)
        results.append({
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": json.dumps(output, ensure_ascii=False),
        })

    # 4. 결과를 하나의 user 메시지로 돌려주며 재요청
    messages.append({"role": "user", "content": results})
    final = client.messages.create(
        model=MODEL, max_tokens=4096,
        tools=TOOL_SPECS, messages=messages,
    )
    print("stop_reason:", final.stop_reason)

    # 5. 최종 답변
    return "".join(b.text for b in final.content if b.type == "text")


if __name__ == "__main__":
    print(one_round_trip("이수진 고객 주문 목록 좀 확인해 줘."))

실행 결과입니다.

1
2
3
4
5
6
7
8
9
10
11
stop_reason: tool_use
blocks: ['thinking', 'text', 'tool_use']
호출 요청: search_orders({'customer': '이수진'})
stop_reason: end_turn
이수진 고객님의 주문은 두 건입니다.

- 20260801-0012 / 무선 이어폰 / 89,000원 / 2026-08-01 주문, 배송완료(2026-08-03 수령)
- 20260803-0447 / 노트북 거치대 / 24,000원 / 2026-08-03 주문, 배송중

배송완료된 이어폰 건은 반품 기간이 남아 있고,
거치대는 아직 배송 중이라 도착 후에 처리하셔야 합니다.

1편에서 지어냈던 주문번호가 실제 데이터로 바뀌었습니다. 주목할 것은 우리 코드에 “search_orders를 불러라”라는 지시가 한 줄도 없다는 점입니다. 도구 세 개의 설명서를 넘겼을 뿐이고, 고객 이름만 아는 상황에서 그중 하나를 골라야 한다고 판단한 것은 모델입니다.

응답 구조 뜯어보기

response.content는 문자열이 아니라 블록 리스트입니다. 위 출력에서 ['thinking', 'text', 'tool_use'] 세 개가 나온 것이 그것입니다. block.type으로 분기해야 합니다.

block.type내용주요 필드
thinking모델의 추론 과정thinking, signature
text사용자에게 보여줄 텍스트text
tool_use도구 호출 요청id, name, input

Claude Opus 5는 thinking이 기본으로 켜져 있어서 thinking 블록이 앞에 붙습니다. 파라미터를 생략하면 adaptive로 동작하고, 명시하려면 thinking={"type": "adaptive"}를 넘깁니다. 이 블록도 대화에 그대로 돌려주어야 합니다.

tool_use 블록의 세 필드가 이 편의 핵심입니다.

1
2
3
4
5
for block in response.content:
    if block.type == "tool_use":
        print("id   :", block.id)      # toolu_01A9k3...
        print("name :", block.name)    # search_orders
        print("input:", block.input)   # {'customer': '이수진'} (dict)

block.input은 이미 파싱된 dict입니다. JSON 문자열이 아니므로 다시 json.loads를 걸 필요가 없고, 그래서 run_tool(block.name, block.input)TOOL_FUNCS[name](**args)로 바로 이어집니다. block.idtoolu_로 시작하는 문자열이고, 결과를 돌려줄 때 짝을 맞추는 열쇠입니다.

분기의 기준은 response.stop_reason으로 잡습니다. 블록 리스트를 훑어 tool_use가 있는지 보는 방법도 동작은 하지만, stop_reason은 모델이 왜 생성을 멈췄는지를 API가 직접 알려주는 값이라 상태 판단에 더 정확합니다.

stop_reason의미대응
end_turn할 말을 다 했다텍스트를 뽑아 반환한다
tool_use도구 실행이 필요하다도구를 실행하고 결과를 돌려준다
max_tokens토큰 한도에 걸려 잘렸다max_tokens를 올리거나 스트리밍한다
refusal안전상 거절했다재시도 대신 사용자에게 알린다
pause_turn서버 측 도구가 잠시 멈췄다그대로 이어서 재요청한다

3편에서 루프를 짤 때 while 조건이 되는 것이 바로 이 값입니다.

반드시 지켜야 하는 규칙 세 가지

이 세 가지를 어기면 400 에러가 납니다. 도구 사용에서 처음 겪는 문제는 거의 여기에 있습니다.

1
2
3
4
5
6
7
8
9
# (a) assistant 메시지에 텍스트만 넣으면 tool_use 블록이 사라진다
text = "".join(b.text for b in response.content if b.type == "text")
messages.append({"role": "assistant", "content": text})       # 400
messages.append({"role": "assistant", "content": response.content})   # 올바름

# (c) 병렬 호출 결과를 나눠 보내면 대응 없는 tool_use가 남는다
messages.append({"role": "user", "content": [result_a]})
messages.append({"role": "user", "content": [result_b]})      # 400
messages.append({"role": "user", "content": [result_a, result_b]})    # 올바름

(a) assistant 메시지에 response.content를 통째로 넣습니다. 텍스트만 뽑아 문자열로 넣으면 tool_use 블록이 대화에서 증발합니다. 그런데 다음 user 메시지에는 tool_result가 들어 있으니, API는 대응하는 tool_use 없이 떠 있는 tool_result를 보고 400을 냅니다. thinking 블록도 같이 사라져 추론 연속성까지 끊깁니다.

(b) tool_use_id가 정확히 대응해야 합니다. tool_resulttool_use_id는 그 결과를 만들어 낸 tool_use 블록의 id와 문자 하나까지 같아야 합니다. 직접 만들어 넣거나 순서가 뒤엉키면 400입니다. 도구를 두 개 이상 실행하면서 id를 따로 관리할 때 이 실수가 나옵니다. block.id를 그 자리에서 복사해 넣으면 어긋날 수가 없습니다.

(c) 병렬로 호출된 도구 결과는 하나의 user 메시지에 전부 담습니다. Claude는 병렬 도구 호출이 기본으로 켜져 있어서 한 응답에 tool_use 블록이 두 개 이상 들어올 수 있습니다. 결과를 나눠 보내면 첫 메시지 시점에 result_b에 대응하는 tool_use가 미응답으로 남아 400이 납니다. 실패한 도구도 빠뜨리면 안 되고, is_error: True를 붙여 에러를 돌려줍니다. 병렬 호출을 끄려면 tool_choice={"type": "auto", "disable_parallel_tool_use": True}를 넘깁니다.

도구 설명이 곧 성능

모델이 도구를 고르는 근거는 description뿐입니다. 함수 본문도 변수 이름도 보지 못합니다. 0편의 TOOL_SPECS에서 설명만 일부러 뭉개 봤습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 나쁜 예: 무엇을 하는지만 적고 언제 부르는지는 안 적음
BAD_SPECS = [
    {
        "name": "search_orders",
        "description": "주문을 찾습니다.",
        "input_schema": {
            "type": "object",
            "properties": {"customer": {"type": "string"}},
            "required": ["customer"],
        },
    },
    {
        "name": "get_order",
        "description": "주문 정보를 봅니다.",
        "input_schema": {
            "type": "object",
            "properties": {"order_id": {"type": "string"}},
            "required": ["order_id"],
        },
    },
]

# 같은 질문에 tools=BAD_SPECS로 요청했을 때의 tool_use 블록
# get_order {'order_id': '이수진'}

get_order를 골랐고, 주문번호 자리에 고객 이름을 넣었습니다. 두 설명 모두 “주문”이라는 단어만 담고 있어 구분할 근거가 없었고, 필드 설명도 지웠으니 order_id가 무엇인지도 알 수 없었습니다. 결과는 {"error": "주문번호 이수진을 찾을 수 없습니다."}입니다.

0편의 원래 설명은 “고객 이름으로 그 사람의 주문 목록을 가져옵니다. 주문번호를 모르는 상태에서 주문을 찾아야 할 때 먼저 호출합니다”였습니다. 두 번째 문장이 핵심입니다. 무엇을 하는지가 아니라 언제 부르는지를 쓰는 것이 도구 설명의 요령입니다. 여기에 더할 것들입니다.

  • 헷갈릴 여지가 있으면 경계를 명시합니다. “주문번호를 이미 알고 있다면 get_order를 쓰세요”처럼 씁니다.
  • 부르면 안 되는 상황도 적습니다. request_refund의 “고객이 환불을 명시적으로 요청했을 때만 호출합니다”가 그 예입니다.
  • input_schema의 각 필드에도 description을 답니다. "주문번호. 예: 20260801-0012"처럼 형식 예시를 주면 인자를 지어낼 확률이 크게 줄어듭니다.
  • 인자를 엄격하게 검증하려면 도구 정의에 strict: True를, input_schemaadditionalProperties: False를 넣습니다.

도구가 세 개일 때 못 고르는 에이전트는 서른 개일 때 더 못 고릅니다. 에이전트가 이상한 도구를 부른다면 루프를 고치기 전에 설명부터 읽어 보는 것이 순서입니다.

tool_choice로 강제하기

기본값은 모델이 알아서 정하는 것이지만, 상황에 따라 강제하고 싶을 때가 있습니다. tool_choice로 조절합니다.

동작쓰는 상황
{"type": "auto"}도구를 쓸지 말지 모델이 정한다 (기본값)일반적인 에이전트 루프
{"type": "any"}도구 중 하나는 반드시 부른다반드시 조회를 거쳐야 하는 단계
{"type": "tool", "name": "..."}지정한 도구를 반드시 부른다구조화된 추출, 분류
{"type": "none"}도구를 부르지 못한다조사를 끝내고 요약만 시키는 턴

anytool은 남용하면 에이전트가 아니라 그냥 함수 호출기가 됩니다. 모델이 “지금은 도구가 필요 없다”고 판단할 권한을 뺏는 셈이라 필요 없는 조회가 한 번 더 돕니다. 흐름을 완전히 통제하고 싶은 단계에서만 쓰고 루프의 기본은 auto로 둡니다.

SDK가 대신해 주는 것

지금까지 짠 코드에는 반복되는 부분이 있습니다. stop_reason을 확인하고, tool_use 블록을 찾아 실행하고, tool_result를 만들어 붙이는 일입니다. Anthropic SDK에는 이 루프를 대신 돌려주는 헬퍼가 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from anthropic import beta_tool

@beta_tool
def search_orders(customer: str) -> str:
    """고객 이름으로 그 사람의 주문 목록을 가져옵니다.

    Args:
        customer: 고객 이름
    """
    ...

runner = client.beta.messages.tool_runner(
    model=MODEL, max_tokens=4096,
    tools=[search_orders],
    messages=[{"role": "user", "content": "이수진 고객 주문 확인해 줘."}],
)
for message in runner:
    print(message)

@beta_tool 데코레이터는 함수 시그니처와 docstring에서 input_schema를 자동으로 만들어 주고, client.beta.messages.tool_runner는 모델이 도구를 그만 부를 때까지 왕복을 알아서 반복합니다. 베타이고, 매 턴 개입할 수 있는 훅도 제공합니다.

다만 이 시리즈는 루프를 직접 짭니다. 그 안에서 API가 몇 번 호출되고 컨텍스트가 어떻게 쌓이는지 보이지 않으면 4편의 토큰 예산, 5편의 승인 게이트, 8편의 궤적 채점을 붙일 자리를 찾지 못합니다. 직접 짠 루프가 손에 익은 다음에 갈아타도 늦지 않습니다.

한 번으로는 모자란다

도구를 한 번 부르는 것까지는 됐습니다. 그런데 “이수진 주문 찾아서 환불해 줘”를 넣어 보면 다시 막힙니다.

1
2
3
stop_reason: tool_use
호출 요청: search_orders({'customer': '이수진'})
stop_reason: tool_use

두 번째 응답의 stop_reason이 또 tool_use입니다. 모델은 주문 목록을 받고 나서 request_refund를 이어 부르려 하는데, one_round_trip은 왕복을 한 번만 처리하도록 짜여 있어 그 요청을 버리고 끝납니다. 최종 답변 자리에서 빈 문자열이 돌아옵니다.

주문번호를 모르는 상태에서 환불하려면 도구를 최소 두 번 이어 불러야 합니다. 검색으로 주문번호를 알아내고, 그 번호로 환불을 접수합니다. 도구 호출은 하나의 사건이 아니라 사슬입니다. 3편에서 이 왕복을 while로 감고, 동시에 생겨나는 문제들을 마주합니다. 언제 멈출 것인가, 도구가 실패하면 어떻게 알릴 것인가, 같은 도구를 계속 부르면 누가 끊을 것인가.

다음 글: LLM 에이전트 기초 (3) - Agent Loop: 관측, 판단, 행동의 순환과 종료 조건

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.