[2026 하계 모각코] - Team 하모 1회차

농담곰·2026년 6월 30일

2026.06.30 19:00 - 22:00
Discord 영상통화로 진행

Week 01 — AI Agent로 일정 관리 비서 Nana 만들기

들어가며

이번 주차에서는 LangChain을 사용해 개인 일정을 관리할 수 있는 AI Agent를 만들었다. LLM을 직접 만드는 게 아니라, 이미 만들어진 LLM(GPT)에게 도구(tool)와 역할(prompt)을 주어서 실제로 일정을 생성/조회/삭제할 수 있는 비서 Nana를 구현하는 것이 목표였다.


핵심 개념 정리

AI vs AI Agent vs Agentic AI

셋 다 비슷해 보이지만 할 수 있는 일의 범위가 다르다.

하는 일
AI (LLM)텍스트를 이해하고 생성만 함
AI Agenttool을 써서 실제로 행동함
Agentic AI여러 tool을 스스로 계획해서 연속으로 행동함

일반 LLM은 "치과 예약 잡아줘"라고 하면 말만 한다. AI Agent는 personal_create_schedule tool을 직접 호출해서 진짜로 일정을 만든다. 이번 주에 만든 Nana가 AI Agent 구조다.

Tool (도구)

LLM한테 쥐어주는 "할 수 있는 행동"이다. @tool 데코레이터를 붙이면 LLM이 이 함수의 존재를 알고, 상황에 맞게 스스로 골라서 호출한다.

LLM이 tool을 고르는 근거는 두 가지뿐이다.

  • 함수 이름: personal_create_schedule → 생성하는 거구나
  • docstring: 함수 바로 아래 """...""" 설명

그래서 docstring을 명확하게 쓰는 게 중요하다. 설명이 애매하면 LLM이 엉뚱한 tool을 고르거나 아예 못 고를 수 있다.

# 애매한 버전 → LLM이 헷갈림
@tool
def personal_create_schedule(...):
    """일정을 만든다."""

# 명확한 버전 → LLM이 정확히 고름
@tool
def personal_create_schedule(...):
    """Nana의 개인 일정을 현재 대화의 임시 메모리에 생성합니다."""

Prompt (프롬프트)

LLM한테 보내는 텍스트 입력이다. 크게 두 종류다.

  • System prompt: "너는 이런 역할이고, 이런 규칙을 따라라"는 사전 지침. 사용자한테는 안 보인다.
  • User prompt: 사용자가 채팅창에 실제로 입력하는 말.

사용자가 채팅창에 뭔가를 입력하는 순간, LLM한테 이런 식으로 한 번에 전달된다.

[system prompt]
너는 Nana야. 오늘 날짜는 2024-07-01이다.
일정 관련 요청이 오면 아래 tool을 써라.

[tool 목록 - docstring 포함]
- personal_create_schedule: Nana의 개인 일정을 생성합니다.
- personal_list_schedules: 일정을 조회합니다.
- personal_delete_schedule: 일정을 삭제합니다.

[user 입력]
치과 예약 2시에 잡아줘

이걸 통째로 받은 LLM이 "치과 예약 잡아달라고 했으니까 personal_create_schedule을 써야겠다"고 판단하는 구조다.

Tool Call vs Function Call

사실상 같은 말이다. OpenAI가 처음 이 기능을 만들었을 때 Function Calling이라고 불렀고, 이후 함수뿐 아니라 웹 검색, 코드 실행 등 더 다양한 것들도 호출할 수 있게 되면서 Tool Calling으로 이름이 바뀌었다. 최신 LangChain, OpenAI, Anthropic 문서는 전부 Tool Call로 통일해서 쓴다.

Trace (추적 기록)

LLM이 요청을 처리하는 동안 내부에서 무슨 일이 일어났는지 기록한 로그다.

{"event": "tool_call", "tool_name": "personal_create_schedule", "arguments": {...}}
{"event": "tool_result", "tool_name": "personal_create_schedule", "content": "..."}
  • LLM이 어떤 tool을 골랐는지
  • 그 tool에 어떤 인자를 넣었는지
  • tool이 뭘 반환했는지

이게 다 trace에 찍힌다. 디버깅할 때 필수다. 처음에 함수 본문이 ...만 있어서 content: null이 찍혔는데, 이걸 보고 "함수가 아무것도 안 하고 있다"는 걸 바로 파악할 수 있었다.

LangChain

LLM으로 뭔가를 만들 때 편하게 쓸 수 있는 프레임워크다. @tool 데코레이터, create_agent() 같은 부품들을 제공해서 LLM + tool + prompt를 묶어 agent를 빠르게 만들 수 있게 해준다.

LLM이 엔진이라면, LangChain은 그 엔진으로 차를 만들 때 필요한 부품 키트다.

RAG (Retrieval-Augmented Generation)

이번 주에 직접 구현하진 않았지만, 설치된 패키지(chromadb) 덕분에 개념을 알게 됐다.

LLM은 학습 데이터 기준으로만 알고 있어서, 최신 정보나 사내 문서 같은 건 답을 못 한다. RAG는 이걸 해결하는 방식이다.

사용자: "우리 회사 휴가 정책이 뭐야?"
  ↓
1. 사내 문서에서 "휴가 정책" 관련 내용을 검색 (Retrieval)
2. 그 내용을 prompt에 붙여서 LLM한테 전달 (Augmented)
3. LLM이 그 내용을 바탕으로 답변 생성 (Generation)

chromadb는 이 검색 단계에서 쓰는 벡터 데이터베이스다. 다음 주차에서 직접 다룰 예정이다.


구현 코드

전체 흐름

개발자가 tool 함수 만들기
        ↓
create_agent()로 LLM + tool + prompt 묶기
        ↓
사용자가 채팅 입력
        ↓
LLM이 어떤 tool 쓸지 판단
        ↓
tool 호출 → 결과 반환
        ↓
LLM이 결과 보고 사용자한테 답변

헬퍼 함수

# dict를 JSON 문자열로 변환 (한글 깨짐 방지)
def _json(payload: dict[str, Any]) -> str:
    return json.dumps(payload, ensure_ascii=False)

# 현재 시각을 ISO 문자열로 반환
def _now_iso() -> str:
    return datetime.now().astimezone().isoformat(timespec="microseconds")

# personal_xxxxx 형태의 임시 고유 ID 생성
def _new_personal_id() -> str:
    return f"personal_{uuid.uuid4().hex[:10]}"

# 현재 세션의 일정만 필터링
def _current_session_schedules() -> list[dict[str, Any]]:
    session_id = current_session_scope()
    return [s for s in PERSONAL_SCHEDULES if _schedule_scope(s) == session_id]

Tool 1 — 일정 생성

@tool
def personal_create_schedule(
    title: str,
    date: str,
    start_time: str,
    end_time: str = "미정",
    attendees: list[str] | None = None,
) -> str:
    """Nana의 개인 일정을 현재 대화의 임시 메모리에 생성합니다."""

    schedule = {
        "id": _new_personal_id(),
        "title": title,
        "date": date,
        "start_time": start_time,
        "end_time": end_time,
        "attendees": attendees if attendees is not None else [],  # None이면 빈 리스트로
        "created_at": _now_iso(),
        "session_id": current_session_scope(),  # 현재 대화 세션 표시
    }
    PERSONAL_SCHEDULES.append(schedule)
    return _json({"ok": True, "tool_name": "personal_create_schedule", "created_schedule": schedule})

session_id를 일정에 함께 저장하는 게 핵심이다. 여러 사용자가 동시에 앱을 써도 서로의 일정이 섞이지 않게 하는 장치다.

Tool 2 — 일정 조회

@tool
def personal_list_schedules(
    date_from: str | None = None,
    date_to: str | None = None
) -> str:
    """선택한 시작일과 종료일 범위에 포함되는 Nana의 개인 일정을 조회합니다."""

    schedules = _current_session_schedules()  # 현재 세션 것만
    if date_from:
        schedules = [s for s in schedules if s["date"] >= date_from]
    if date_to:
        schedules = [s for s in schedules if s["date"] <= date_to]
    return _json({"ok": True, "tool_name": "personal_list_schedules", "schedules": schedules})

날짜가 YYYY-MM-DD 형식이라 문자열 비교(>=, <=)만으로도 날짜 비교가 정확히 된다. 원본 리스트는 건드리지 않고 필터링한 결과만 반환한다.

Tool 3 — 일정 삭제

@tool
def personal_delete_schedule(schedule_id: str) -> str:
    """일정 ID에 해당하는 개인 일정을 삭제합니다."""

    session_id = current_session_scope()
    before_count = len(PERSONAL_SCHEDULES)

    PERSONAL_SCHEDULES[:] = [
        s for s in PERSONAL_SCHEDULES
        if not (s.get("id") == schedule_id and _schedule_scope(s) == session_id)
    ]

    deleted = before_count != len(PERSONAL_SCHEDULES)
    return _json({"ok": True, "tool_name": "personal_delete_schedule", "deleted": deleted})

포인트 두 가지:

슬라이스 대입 PERSONAL_SCHEDULES[:] = ...PERSONAL_SCHEDULES = [...]로 쓰면 리스트 객체 자체가 새로 만들어져서, 다른 곳에서 이 리스트를 참조하고 있을 때 연결이 끊겨버린다. 슬라이스 대입은 같은 객체의 내용물만 교체해서 이 문제를 피한다.

세션 조건 같이 체크id 일치 AND 현재 세션을 동시에 만족할 때만 삭제한다. 다른 세션에 우연히 같은 ID가 있어도 건드리지 않는다.

Agent 조립

def week01_prompt_parts() -> list[str]:
    return [
        f"너는 사용자의 개인 비서 Nana다. 오늘 날짜는 {current_app_date_iso()}이다.",
        "사용자가 개인 일정을 만들거나, 조회하거나, 삭제하려고 하면 "
        "personal_create_schedule, personal_list_schedules, personal_delete_schedule 중 "
        "알맞은 도구를 호출해서 처리한다. "
        "날짜가 명시되지 않으면 사용자에게 먼저 날짜를 확인한다.",
        "일정 생성 시 종료 시각이 주어지지 않으면 end_time을 비워두지 말고 기본값을 사용한다.",
    ]


def build_week01_agent() -> object:
    global _WEEK01_AGENT
    if _WEEK01_AGENT is None:  # 처음 한 번만 생성, 이후 재사용
        _WEEK01_AGENT = create_agent(
            model=chat_model(),                     # LLM
            tools=week01_tools(),                   # tool 목록
            system_prompt=week01_system_prompt(),   # prompt
        )
    return _WEEK01_AGENT

create_agent()가 하는 일이 사실 별게 없다. LLM한테 "너 이런 tool들 쓸 수 있어, 이렇게 행동해"라고 세팅해주는 것뿐이다.


트러블슈팅

tool_result content가 null로 찍히는 경우

{"event": "tool_result", "content": null}

함수 본문이 ...(Ellipsis)만 있으면 파이썬에서 None을 반환한다. 즉 TODO를 아직 안 채운 상태라는 뜻이다. _json({...})으로 감싼 문자열을 반환하도록 함수 본문을 구현하면 해결된다.


회고

tool 함수 자체는 그냥 파이썬 함수인데, @tool 하나만 붙이면 LLM이 그걸 도구로 인식하고 스스로 골라 쓴다는 게 신기했다. 그리고 코드 로직보다 docstring이나 prompt 같은 "LLM한테 어떻게 설명하느냐"가 agent 품질을 좌우한다는 게 가장 인상적인 부분이었다. trace를 통해 LLM 내부 동작을 눈으로 확인할 수 있어서 디버깅이 훨씬 수월했다.

profile
둥글게살자

0개의 댓글