LangChain 없이 간단 에이전트 만들기
프레임워크 뒤가 궁금했다
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가 다르다. 나중에 실행 결과를 다시 넣어줄 때 이id를tool_call_id로 그대로 돌려줘야 “이 결과는 방금 그 호출에 대한 답”이라고 API가 짝을 맞춘다.content가null이다. 모델이 자연어 대신 “이 함수를 이 인자로 불러줘”라는 구조화된 지시만 내놓은 거다.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_reason이 stop으로 바뀌었고 content에 자연어 답변이 채워져서 돌아왔다. 도구 호출 한 번, 결과 주입, 재호출 한 번 — 이 세 스텝을 거쳐야 비로소 사용자가 볼 수 있는 답이 나온다는 걸 실제로 확인한 셈이다.
이걸 반복문으로 감싸면 에이전트 루프
지금까지는 호출을 딱 두 번(도구 호출 한 번 + 최종 답변 한 번)만 가정하고 손으로 풀어썼다. 그런데 도구를 여러 번 연달아 호출해야 하는 경우도 있으니 finish_reason이 tool_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은 이 세 가지를 전부 대신 관리해준다.
-
LLM 호출 — 판단/추론을 담당
-
도구 인터페이스 — 실행 수단
-
상태/메모리 관리 — 반복 루프를 돌리기 위한 대화 히스토리

Leave a comment