프레임워크 뒤가 궁금했다

LangChain, LangGraph를 공부하면서 계속 걸리는 게 있었다. “에이전트가 도구를 쓴다”는 게 정확히 어떤 동작인지, 프레임워크가 뒤에서 뭘 대신 해주는 건지 궁금했다.

그래서 이번엔 프레임워크 없이 OpenAI API를 raw하게 호출해서 계산기 도구 하나짜리 에이전트를 만들어 보기로 했다. 거창한 걸 만들 생각은 아니었고 그냥 “도대체 뭐가 자동으로 되는 건지” 직접 확인하고 싶었다.

준비: 계산기 도구 하나만 등록하기

실험이니까 최대한 작게 가려고 도구는 사칙연산 계산기 하나만 등록했다. 모델에게 “이런 도구가 있다”고 알려줄 스키마를 정의했다. 이 시점에서는 아무 코드도 실행되지 않는다. 그냥 모델이 읽을 설명서일 뿐이다.

import os
from openai import OpenAI

OpenAI.api_key = os.getenv("OPENAI_API_KEY")
client = OpenAI()

calculator_tool = {
    "type": "function",
    "function": {
        "name": "calculator",
        "description": "Evaluate a basic arithmetic expression",
        "parameters": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "The arithmetic expression to evaluate, e.g. '2+2'",
                },
            },
            "required": ["expression"],
        },
    },
}

첫 호출: “2+2가 뭐야?”

도구를 등록한 상태로 모델에게 계산이 필요한 질문을 던졌다.

messages = [{"role": "user", "content": "2+2가 뭐야?"}]

response = client.chat.completions.create(
    model="gpt-4o",
    tools=[calculator_tool],
    messages=messages,
)

message = response.choices[0].message
print(response.choices[0].finish_reason)
print(message.tool_calls)

client.chat.completions.create가 실제로 모델을 호출하는 함수다.

  • model — 어떤 모델을 쓸지 정한다. 나는 ("gpt-4o")를 골랐다.
  • tools — 모델에게 “이런 도구를 쓸 수 있어”라고 알려주는 스키마 목록이다. 아까 정의한 calculator_tool을 여기에 넣었다.
  • messages — 지금까지의 대화 히스토리. 첫 호출이라 사용자 메시지 하나뿐이지만 도구 호출 이후 재호출할 때는 여기에 모델의 도구 호출 메시지와 도구 실행 결과가 계속 쌓인다.

호출 한 번은 딱 한 번의 왕복(요청 → 응답)일 뿐이다. 응답은 response 객체로 돌아오는데, 쓸모 있는 내용은 response.choices[0] 안에 들어있다. choices가 배열인 건 한 번의 요청으로 답변 후보를 여러 개 동시에 받을 수도 있어서다(n 파라미터로 조절). 기본값은 1개라 지금은 choices[0]만 보면 된다. 그 안에서 finish_reason(모델이 왜 멈췄는지)과 message(실제 응답 내용)를 꺼내 쓴다.

실행해보면 print(message.tool_calls)에는 이런 파이썬 객체가 찍힌다.

tool_calls
[ChatCompletionMessageFunctionToolCall(id='call_sf5CfiHWBjmCds4qsK2xDprx', function=Function(arguments='{"expression":"2+2"}', name='calculator'), type='function')]

참고로 이 파이썬 객체는 내가 손으로 재구성한 게 아니라 OpenAI 서버가 보낸 JSON 응답을 SDK가 파싱해서 만들어준 것이다. 원래 JSON이 궁금하면 아래와 같이 뽑아볼 수 있다.

print(response.choices[0].message.model_dump_json(indent=2))
{
  "content": null,
  "refusal": null,
  "role": "assistant",
  "annotations": [],
  "audio": null,
  "function_call": null,
  "tool_calls": [
    {
      "id": "call_sf5CfiHWBjmCds4qsK2xDprx",
      "function": {
        "arguments": "{\"expression\":\"2+2\"}",
        "name": "calculator"
      },
      "type": "function"
    }
  ]
}

여기서 네 가지를 확인했다.

  • id는 이 호출 하나만의 고유 식별자다. call_sf5CfiHWBjmCds4qsK2xDprx처럼 매 호출마다 새로 발급된다. 도구를 한 번에 여러 개 호출하면 tool_calls 배열에 항목이 그만큼 생기고 각각 id가 다르다. 나중에 실행 결과를 다시 넣어줄 때 이 idtool_call_id로 그대로 돌려줘야 “이 결과는 방금 그 호출에 대한 답”이라고 API가 짝을 맞춘다.
  • contentnull이다. 모델이 자연어 대신 “이 함수를 이 인자로 불러줘”라는 구조화된 지시만 내놓은 거다.
  • type, name이 내가 정의한 스키마 그대로다. tools=[calculator_tool]로 넘긴 스키마의 type: "function", name: "calculator"가 응답에도 1:1로 실려서 돌아온다. 도구를 여러 개 등록했을 때 “어떤 도구가 호출됐는지”를 이 name으로 분기하면 된다.
  • arguments가 객체가 아니라 JSON 문자열이다. "{\"expression\":\"2+2\"}"처럼 이스케이프된 문자열로 온다. 실제 값을 쓰려면 한 번 파싱해야 한다.

왜 결과를 바로 보여주면 안 되나

4라는 답을 얻으려면 아직 할 일이 남아 있다. arguments는 그냥 “이 계산을 해달라”는 요청일 뿐, 실제 계산은 내가 해야 한다. 그래서 도구를 실행하는 함수를 따로 만들었다.

import json

def run_tool(tool_call):
    if tool_call.function.name == "calculator":
        args = json.loads(tool_call.function.arguments)  # unpack the JSON string
        try:
            result = eval(args["expression"])
        except Exception as e:
            result = f"error: {e}"
        return str(result)

    raise ValueError(f"Unknown tool: {tool_call.function.name}")
tool_result = run_tool(message.tool_calls[0])
print(tool_result)  # "4"

결과물은 4로 잘 나오지만, 이 결과를 다시 한 번 모델에 넣어주는 과정이 필요하다.

  • 모델은 stateless다. 방금 내가 계산했다는 사실을 모델은 모른다. 결과를 다시 대화 히스토리에 넣어줘야 모델이 그걸로 다음 응답(자연어 설명이든, 추가 도구 호출이든)을 만든다.
  • 결과 해석이 필요할 수도 있다. 지금은 단순 계산이라 결과를 그대로 보여줘도 되지만 복잡한 태스크라면 “이 결과를 보고 다른 도구를 더 호출해야 하나?”를 판단해야 한다. 그 판단은 결국 모델 몫이라 결과를 다시 넣어주는 과정이 빠지면 안 된다.

결과를 다시 넣고 한 번 더 호출

그래서 계산 결과를 role: "tool" 메시지로 만들어 대화에 추가하고 모델을 한 번 더 불렀다. 위에서는 결과만 눈으로 확인해보려고 tool_calls[0]을 직접 꺼내서 한 번 불러본 거였다. 실제로는 도구 호출이 몇 개가 올지 모르니 반복문으로 감싸서 전부 처리해야 한다.

# Save the assistant's tool-call message first — the model needs to see
# the exact call it made before it can make sense of the result.
messages.append(message)

for tool_call in message.tool_calls:
    tool_result = run_tool(tool_call)
    messages.append({
        "role": "tool",
        "tool_call_id": tool_call.id,  # links this result back to the specific call
        "content": tool_result,
    })

response = client.chat.completions.create(
    model="gpt-4o",
    tools=[calculator_tool],
    messages=messages,
)

print(response.choices[0].finish_reason)   # stop
print(response.choices[0].message.content) # "2+2는 4입니다."

이번엔 finish_reasonstop으로 바뀌었고 content에 자연어 답변이 채워져서 돌아왔다. 도구 호출 한 번, 결과 주입, 재호출 한 번 — 이 세 스텝을 거쳐야 비로소 사용자가 볼 수 있는 답이 나온다는 걸 실제로 확인한 셈이다.

이걸 반복문으로 감싸면 에이전트 루프

지금까지는 호출을 딱 두 번(도구 호출 한 번 + 최종 답변 한 번)만 가정하고 손으로 풀어썼다. 그런데 도구를 여러 번 연달아 호출해야 하는 경우도 있으니 finish_reasontool_calls가 아닐 때까지 이 과정을 반복하도록 감싸면 된다.

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

while True:
    response = client.chat.completions.create(
        model="gpt-4o",
        tools=[calculator_tool],
        messages=messages,
    )
    message = response.choices[0].message
    messages.append(message)

    if response.choices[0].finish_reason != "tool_calls":
        break  # model gave a final answer, no more tools needed

    # model wants to call a tool — execute it and feed the result back
    for tool_call in message.tool_calls:
        tool_result = run_tool(tool_call)
        messages.append({
            "role": "tool",
            "tool_call_id": tool_call.id,
            "content": tool_result,
        })

앞에서 손으로 한 번씩 실행해봤던 “호출 → 관찰 → 실행 → 재호출”이 그대로 while 루프 안에 들어갔을 뿐이다.

결론: 에이전트에 필요한 세 가지

거창하게 들리지만 뜯어보면 구성 요소는 단순하다. LangChain은 이 세 가지를 전부 대신 관리해준다.

  1. LLM 호출 — 판단/추론을 담당

  2. 도구 인터페이스 — 실행 수단

  3. 상태/메모리 관리 — 반복 루프를 돌리기 위한 대화 히스토리

image-20260725173820725

Leave a comment