home

LLM은 어떻게 에이전트가 되는가 — 에이전트 루프 구축 실습 with Ollama

로컬 LLM(올라마)으로 최소 에이전트를 직접 돌려보며 LLM과 에이전트의 관계, 도구 호출과 대화 기억이 실제로 어떻게 동작하는지 확인한다.

회사에서 AX 관련 업무를 하던 중, CTO님께서 올라마로 에이전트를 구성해보면 업무에 도움이 될 것이라고 조언해 주셨다. 나도 최근 AX 담당자로써 이것저것 해 보며 에이전트의 동작을 개념적으로는 이해했다고 생각했지만 정작 실제 내부 동작은 확인해 본 적이 없었다. 그래서 이번 기회에 간단한 LLM 기반 에이전트 코드를 직접 구성해보며 이론 지식을 더 탄탄히 다지는 시간을 가졌다.

올라마 설치

LLM과 에이전트의 동작을 보려면 모델을 직접 호출하고 응답을 그대로 뜯어볼 수 있어야 한다. 올라마(Ollama)는 로컬에서 도는 LLM 서버라 이 과정을 열어볼 수 있다.

올라마를 쓰는 이유는 아래와 같다.

  1. API 키, 요금, 회원가입이 없어 간편하다. (무료이고 간편하다!!)
  2. 그 위에서 프레임워크 없이 표준 라이브러리만으로 간단한 에이전트를 조립해볼 수 있다.
  3. 모델을 한 줄로 교체할 수 있어 "모델을 바꾸면 무엇이 달라지나"를 실험하기 좋다.

설치는 다음과 같다.

# macOS
brew install ollama          # 또는 ollama.com 에서 앱 다운로드
ollama serve                 # 로컬 서버 실행 (localhost:11434)
ollama pull qwen3:8b         # 모델 내려받기 (약 5.2GB)

이 글의 모든 코드는 qwen3:8b로 돌렸다. 필자의 컴퓨터는 M3 Pro 14GB이다. 이 이상되는 모델들은 내 컴퓨터로 돌리기 애매하고, 이 이하인 모델들은 추론 능력이 너무 형편없어 원하는 학습이 어려울 정도로 처참한 결과가 나오기 때문에 해당 모델을 강력히 추천한다.

LLM이란

LLM은 뭔가 마법 같지만 일반 개발자들 입장에서 LLM을 정의하면 **'텍스트를 입력받아 다음에 올 텍스트를 출력하는 모델'** 이라고 할 수 있다.

실제로 LLM은 그 이름(Large Language Model)에 걸맞게 텍스트 출력 외의 일은 하지 못한다. 즉, 파일을 읽거나 인터넷을 조회하거나 DB를 다루지 못한다. 입력 텍스트에서 출력 텍스트를 한 번 만들어내는 것이 전부다. 또한 입력된 언어를 바탕으로 다음 텍스트를 내놓는 단순한 모델이라, 근본적으로는 상태가 없다.(stateless). 이번 호출과 다음 호출 사이에 아무것도 기억하지 못하고 매번 백지에서 시작한다는 의미.

그래서 LLM 하나만 놓고 보면 단방향 대화 도구에 가깝다. 스스로 검색하고 파일을 고치고 여러 단계를 밟아 일을 처리하는 것은 LLM 혼자서는 할 수 없다. 그 일을 가능하게 만드는 것이 에이전트다.

에이전트란

에이전트는 LLM라는 뇌에 루프와 도구를 붙인 것이다.

  • 루프: LLM을 한 번 호출하고 끝내는 대신 while 문 안에서 반복 호출한다. 에이전트의 자율성은 이 반복에서 나온다.
  • 도구: LLM이 특정 도구의 실행을 요청하면, 코드가 그 도구를 실제로 실행하고 결과를 다시 모델에 입력한다. 이를 통해 LLM은 텍스트 바깥의 정보(날씨, 파일, DB)와 연결된다.

말로만 보면 추상적이므로 실제로 돌려보자.

에이전트의 동작 원리

날씨를 물으면 답하는, 도구 하나짜리 최소 에이전트를 만들었다. 핵심만 추리면 다음과 같다. (전체 코드는 게시글의 맨 아래에서 제공한다)

# 도구 — 실행 권한은 코드에 있다.
def get_weather(city):
    날씨표 = {"서울": {"sky": "맑음", "temp": 22}, "부산": {"sky": "흐림", "temp": 19}}
    return {"city": city, **날씨표.get(city, {})}

도구표 = {"get_weather": get_weather}     # 이름 → 함수 매핑

# 루프 — 에이전트의 자율성이 나오는 곳
def 한턴(질문, messages):
    messages.append({"role": "user", "content": 질문})
    while True:
        답 = 모델(messages)                 # LLM 한 번 호출
        messages.append(답)
        if not 답.get("tool_calls"):        # 도구 요청 없음 = 최종답 → 루프 종료
            return 답["content"]
        for call in 답["tool_calls"]:       # 도구 요청 있음 = 실행 단계
            이름, 인자 = call["function"]["name"], call["function"]["arguments"]
            결과 = 도구표[이름](**인자)       # 코드가 실제로 실행
            messages.append({"role": "tool", "content": json.dumps(결과, ensure_ascii=False)})

"서울 날씨 어때?"로 돌린 실제 로그다.

🧑 사용자: 서울 날씨 어때?
   📤 모델에 보내는 배열 상태: 길이 1  [user]
   📥 모델 응답: content='', tool_calls=[get_weather({'city': '서울'})]
   🔧 [코드가 실제 실행] get_weather('서울') → {'city': '서울', 'sky': '맑음', 'temp': 22}
   ↩️  도구 결과를 배열에 넣고 재호출 (배열 상태: 길이 3  [user · assistant · tool])
   📤 모델에 보내는 배열 상태: 길이 3  [user · assistant · tool]
   📥 모델 응답: tool_calls 없음(종료 신호) → 최종답
🤖 서울의 현재 날씨는 맑습니다. 기온은 22°C입니다.
🧑 사용자: 그럼 부산은?
   📥 모델 응답: content='', tool_calls=[get_weather({'city': '부산'})]
   🔧 [코드가 실제 실행] get_weather('부산') → {'city': '부산', 'sky': '흐림', 'temp': 19}
🤖 부산의 현재 날씨는 흐리고, 기온은 19°C입니다.

한 줄씩 따라가면 에이전트의 거의 전부가 들어 있다.

모델은 날씨를 답하는 대신 content를 비우고 tool_calls 필드에 get_weather({'city': '서울'})를 담아 응답했다. 이것은 자연어 답이 아니라 특정 도구를 특정 인자로 실행해 달라는 구조화된 요청이다. 코드는 그 요청을 읽어 get_weather('서울')을 실제로 실행하고, 결과를 배열에 넣은 뒤 모델을 다시 호출했다. 두 번째 호출에서 모델은 도구 결과를 반영해 자연어로 답했고, 이번에는 tool_calls가 없다. 이 '호출할 도구 없음'이 곧 종료 신호이며 루프는 여기서 빠져나온다. 질문 한 번에 루프가 자동으로 두 번 돈 셈이다.

핵심은 LLM은 내 컴퓨터의 어떤 것도 직접 실행하지 못한다는 것이다. 할 수 있는 일은 텍스트 출력뿐이다. 에이전트가 날씨를 조회하고 파일을 고치는 것처럼 보이는 이유는 모델에게 날씨 조회, OS 명령어 도구와 루프 코드가 제공되기 때문이다. 실제로 get_weather를 구현해 두지 않으면 모델이 그 도구를 요청해도 아무 일도 일어나지 않는다. 이 경우, LLM은 환각을 일으키거나, 날씨를 알 수 없다는 대답을 하게 될 것이다.

도구 호출의 실제

도구 호출은 세 조각으로 이뤄진다.

첫째, 요청을 보낼 때 어떤 도구가 있는지 그 명세를 함께 실어 보낸다. 이름, 설명, 인자 형식으로 된 JSON이다.

tools_spec = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "특정 도시의 현재 날씨를 조회한다",   # 모델은 이 설명을 보고 언제 쓸지 판단한다
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string", "description": "도시 이름"}},
            "required": ["city"],
        },
    },
}]

모델은 도구의 이름과 인자 형식, 그리고 무엇보다 이 description을 보고 도구를 언제 쓸지 판단한다. 그래서 설명을 정확하게 쓰는 일이 에이전트 개발의 실질적 기술이 된다. 설명이 모호하면 모델이 엉뚱한 도구나 인자를 고를 것이다.

둘째, 도구가 필요하다고 판단한 모델은 구조화된 호출 요청을 돌려준다. 실제 응답 원문이다.

{
  "role": "assistant",
  "content": "",
  "tool_calls": [
    { "id": "call_u1jovl1d",
      "function": { "index": 0, "name": "get_weather", "arguments": { "city": "서울" } } }
  ]
}

호출 요청은 자연어 답이 담기는 content와 별개인 tool_calls 필드로 온다. 두 필드가 분리돼 있어 호출 요청이 자연어와 섞이지 않는다. 모델이 이 형식으로 응답하도록 훈련돼 있기 때문에 가능한 구조이다. 다만 완전히 깨지지 않는 것은 아니어서, 모델이 인자를 잘못 채우거나 필요 없는 상황에 도구를 요청하는 경우는 여전히 있다. 이는 좋은 모델일수록 줄어들지만, 도구 호출 오류는 스키마를 엄격히 쓰고, 인자를 검증하고, 실패 시 재시도/에러 처리를 넣어 줄여야 한다.

셋째, 코드가 요청에 담긴 이름을 보고 해당 함수를 찾아 실행한다. 이름과 함수를 매핑한 딕셔너리에서 함수를 꺼내 인자를 넣는다.

결과 = 도구표[call["function"]["name"]](**call["function"]["arguments"])
#      도구표["get_weather"](city="서울") 와 같다

도구 호출은 결국 이 왕복이다. 모델에 도구 명세를 건네고, 모델이 실행할 도구와 인자를 요청하고, 코드가 그 요청을 실행해 결과를 다시 모델에 돌려준다.

이 지점이 MCP(Model Context Protocol)와도 이어진다. 지금 예제에서는 도구 명세(tools_spec)와 실제 함수(get_weather)를 코드 안에 직접 넣어 두었다. MCP는 이 둘을 별도의 서버로 분리하고, 서버와 주고받는 규격을 표준화한 프로토콜이다. MCP 서버는 자신이 제공하는 도구의 명세(이름·설명·인자)를 알려주고, 에이전트는 그 명세를 받아 우리가 tools_spec을 모델에 넘기던 것과 똑같이 전달한다. 모델이 도구 호출을 요청하면 에이전트는 그 요청을 MCP 서버로 보내고, 서버가 실제 실행을 맡아 결과를 돌려준다. 우리 코드에서 도구표 디스패치와 get_weather가 하던 역할을 서버가 대신하는 셈이다.

기억의 실체

LLM은 상태가 없다고 했는데, 앞 로그의 두 번째 턴을 보면 이상하다.

🧑 사용자: 그럼 부산은?
   📥 모델 응답: content='', tool_calls=[get_weather({'city': '부산'})]
   🔧 [코드가 실제 실행] get_weather('부산') → {'city': '부산', 'sky': '흐림', 'temp': 19}
🤖 부산의 현재 날씨는 흐리고, 기온은 19°C입니다.

"그럼 부산은?"이라고만 했는데 모델은 이것을 날씨 대화의 연장으로 알아듣고 get_weather('부산')을 요청했다. 기억이 없다면서 어떻게 앞 대화를 알았을까.

매 턴 지금까지의 대화 전체를 다시 보내기 때문이다. "그럼 부산은?"을 물을 때 서버로 실제로 전달된 배열은 다음과 같다.

[
  { "role": "user", "content": "서울 날씨 어때?" },
  { "role": "assistant", "content": "", "tool_calls": [ { "id": "call_u1jovl1d", "function": { "index": 0, "name": "get_weather", "arguments": { "city": "서울" } } } ] },
  { "role": "tool", "content": "{\"city\": \"서울\", \"sky\": \"맑음\", \"temp\": 22}" },
  { "role": "assistant", "content": "서울의 현재 날씨는 맑습니다. 기온은 22°C입니다." },
  { "role": "user", "content": "그럼 부산은?" }
]

새 질문 한 줄만 보낸 것이 아니라 앞의 대화 네 개를 그대로 이어 다시 보냈다. 모델은 기억하는 것이 아니라 매번 전체를 처음부터 다시 읽는다. 기억의 실체는 이 messages 배열 하나다.

그래서 배열은 대화가 진행될수록 늘어나기만 한다. 실제 로그의 길이 변화다.

턴1 시작: 길이 1   [user]
턴1 끝:   길이 4   [user, assistant, tool, assistant]
턴2 시작: 길이 5   [..., user]          # 앞의 4개를 그대로 이어감
턴2 끝:   길이 8   [..., assistant, tool, assistant]

이 단순한 구조가 운영에서 겪는 현상들을 그대로 설명한다. 대화가 길수록 비용과 지연이 커진다. 배열은 대화가 진행될수록 늘어나고, 매 턴 그 전체를 다시 실어 보낸다. 실어 보내는 토큰이 많아질수록 요금도, 응답을 기다리는 시간도 함께 늘어난다.

그리고 언젠가는 모든 대화 내역을 담을 수 없는 순간이 온다. 모델이 한 번에 읽을 수 있는 양(컨텍스트 한계)은 정해져 있어서, 배열이 그 한계에 다다르면 더 담지 못한다. 챗봇이나 에이전트 프레임워크가 오래된 대화를 잘라내거나 요약해 크기를 줄이는 이유다. (Claude Compact를 떠올리면 좋다.)

그래서 에이전트를 실제로 만들면 이 배열 하나를 다루는 일이 생각보다 빠르게 복잡해진다. 대화가 길어지면 오래된 메시지를 잘라내거나 요약해야 하고, 도중에 여러 갈래로 분기했다가 되돌아와야 하고, 중단된 작업을 이어서 재개해야 한다. LangGraph는 바로 이 지점에서 상태와 실행 흐름을 그래프로 명시하고, 체크포인트와 재개 같은 구조를 다루기 쉽게 해 주는 프레임워크다. 즉, 단순한 messages 배열이 실제 서비스에서는 “상태 관리와 실행 흐름 관리” 문제로 커진다는 것을 보여주는 예라고 볼 수 있다.

정리

에이전트에서 지능을 담당하는 부분은 LLM 하나다. 루프, 도구 실행, 대화 상태 관리는 모두 그 주위를 감싼 일반 코드가 맡는다. 에이전트가 똑똑해 보이는 것은 LLM 덕분이지만, 그 판단을 실제 동작으로 옮기는 것은 코드다.

직접 만들 때의 어려움은 이 뼈대가 아니라 다른 곳에 있다. OS 접근 권한을 가진 도구를 안전하게 붙이는 일, 모델이 결과를 지어내는 것을 막는 일, 불어나는 맥락을 관리하는 일에서 많은 시행착오와 에러가 생긴다. 결국 어려움은 루프와 배열이 아니라 그 안의 모델을 신뢰할 수 있게 만드는 데 있다.

그럼에도 이 구조를 한 번 파악해 두면, 에이전트를 도입할지 판단할 때나 직접 만들어야 할 때 기준이 생긴다. 결국 "LLM에 어떤 도구와 루프를 붙였는가"로 환원해서 볼 수 있기 때문이다.

(부록) 실습 코드

"""
데모 — LLM은 어떻게 에이전트가 되는가.

이 한 파일이 두 가지를 눈으로 보여준다:
  ① 도구 호출: 모델은 도구를 '실행'하지 않는다. "이 도구를 이 인자로 불러줘"라는
     쪽지(tool_calls)만 뱉고, 진짜 실행은 내 코드가 한다.
  ② 기억: 모델은 stateless. 대화가 이어지는 건 매 턴 messages 배열을
     '통째로' 다시 보내기 때문. 배열은 늘기만 한다.

모델: qwen3:8b (로컬 ollama). think=False로 생각 토큰을 꺼서 로그를 깔끔하게 유지.
의존성 0 — 파이썬 표준 라이브러리 urllib만 사용.
"""
import json
import urllib.request

MODEL = "qwen3:8b"


# ────────────────────────────────────────────────────────────
# 도구 — 실행 권한은 100% 내 코드에 있다.
# ────────────────────────────────────────────────────────────
날씨표 = {
    "서울": {"sky": "맑음", "temp": 22},
    "부산": {"sky": "흐림", "temp": 19},
}


def get_weather(city):
    결과 = {"city": city, **날씨표.get(city.strip(), {"sky": "알수없음", "temp": 0})}
    print(f"   🔧 [코드가 실제 실행] get_weather({city!r}) → {결과}")
    return 결과


도구표 = {"get_weather": get_weather}

tools_spec = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "특정 도시의 현재 날씨를 조회한다",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string", "description": "도시 이름"}},
            "required": ["city"],
        },
    },
}]


# ────────────────────────────────────────────────────────────
# 모델 한 방 호출 — 매번 messages 배열을 통째로 실어보낸다.
# ────────────────────────────────────────────────────────────
def 모델(messages):
    body = {
        "model": MODEL,
        "stream": False,
        "messages": messages,
        "tools": tools_spec,
        "think": False,   # qwen3의 생각 토큰 끄기 → 기본기 로그를 깔끔하게
    }
    req = urllib.request.Request(
        "http://localhost:11434/api/chat",
        data=json.dumps(body, ensure_ascii=False).encode(),
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req) as r:
        return json.load(r)["message"]


def 배열상태(messages):
    roles = " · ".join(m["role"] for m in messages)
    return f"길이 {len(messages)}  [{roles}]"


# ────────────────────────────────────────────────────────────
# 루프 — 에이전트의 '자율성'의 정체.
# messages를 함수 밖에서 넘겨받는다 → 대화가 여러 턴 이어진다(기억).
# ────────────────────────────────────────────────────────────
def 한턴(질문, messages):
    print(f"\n🧑 사용자: {질문}")
    messages.append({"role": "user", "content": 질문})

    while True:
        print(f"   📤 모델에 보내는 배열 상태: {배열상태(messages)}")
        답 = 모델(messages)
        messages.append(답)

        if not 답.get("tool_calls"):
            print(f"   📥 모델 응답: tool_calls 없음(종료 신호) → 최종답")
            print(f"🤖 {답['content']}")
            return 답["content"]

        요청 = 답["tool_calls"][0]["function"]
        print(f"   📥 모델 응답: content={답.get('content')!r}, "
              f"tool_calls=[{요청['name']}({요청['arguments']})]")

        for call in 답["tool_calls"]:
            이름 = call["function"]["name"]
            인자 = call["function"]["arguments"]
            결과 = 도구표[이름](**인자)   # ← 디스패치: 이름→함수 딕셔너리에서 골라 실행
            messages.append({"role": "tool", "content": json.dumps(결과, ensure_ascii=False)})
        print(f"   ↩️  도구 결과를 배열에 넣고 재호출 (배열 상태: {배열상태(messages)})")


if __name__ == "__main__":
    messages = []   # ← 대화 전체가 여기 한 곳에 쌓인다. 이게 '기억'.

    print("=" * 64)
    print("  턴 1 — 도구 호출을 눈으로")
    print("=" * 64)
    한턴("서울 날씨 어때?", messages)

    print("\n" + "=" * 64)
    print("  턴 2 — 기억을 눈으로 ('날씨'란 말을 안 해도 알아듣나?)")
    print("=" * 64)
    한턴("그럼 부산은?", messages)

    print("\n" + "=" * 64)
    print(f"  대화 끝 — 최종 배열: {배열상태(messages)}")
    print("=" * 64)

댓글

아직 댓글이 없습니다.