2026.06.30 19:00 - 22:00
Discord 영상통화로 진행
이번 주차에서는 LangChain을 사용해 개인 일정을 관리할 수 있는 AI Agent를 만들었다. LLM을 직접 만드는 게 아니라, 이미 만들어진 LLM(GPT)에게 도구(tool)와 역할(prompt)을 주어서 실제로 일정을 생성/조회/삭제할 수 있는 비서 Nana를 구현하는 것이 목표였다.
셋 다 비슷해 보이지만 할 수 있는 일의 범위가 다르다.
| 하는 일 | |
|---|---|
| AI (LLM) | 텍스트를 이해하고 생성만 함 |
| AI Agent | tool을 써서 실제로 행동함 |
| Agentic AI | 여러 tool을 스스로 계획해서 연속으로 행동함 |
일반 LLM은 "치과 예약 잡아줘"라고 하면 말만 한다. AI Agent는 personal_create_schedule tool을 직접 호출해서 진짜로 일정을 만든다. 이번 주에 만든 Nana가 AI Agent 구조다.
LLM한테 쥐어주는 "할 수 있는 행동"이다. @tool 데코레이터를 붙이면 LLM이 이 함수의 존재를 알고, 상황에 맞게 스스로 골라서 호출한다.
LLM이 tool을 고르는 근거는 두 가지뿐이다.
personal_create_schedule → 생성하는 거구나"""...""" 설명그래서 docstring을 명확하게 쓰는 게 중요하다. 설명이 애매하면 LLM이 엉뚱한 tool을 고르거나 아예 못 고를 수 있다.
# 애매한 버전 → LLM이 헷갈림
@tool
def personal_create_schedule(...):
"""일정을 만든다."""
# 명확한 버전 → LLM이 정확히 고름
@tool
def personal_create_schedule(...):
"""Nana의 개인 일정을 현재 대화의 임시 메모리에 생성합니다."""
LLM한테 보내는 텍스트 입력이다. 크게 두 종류다.
사용자가 채팅창에 뭔가를 입력하는 순간, 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을 써야겠다"고 판단하는 구조다.
사실상 같은 말이다. OpenAI가 처음 이 기능을 만들었을 때 Function Calling이라고 불렀고, 이후 함수뿐 아니라 웹 검색, 코드 실행 등 더 다양한 것들도 호출할 수 있게 되면서 Tool Calling으로 이름이 바뀌었다. 최신 LangChain, OpenAI, Anthropic 문서는 전부 Tool Call로 통일해서 쓴다.
LLM이 요청을 처리하는 동안 내부에서 무슨 일이 일어났는지 기록한 로그다.
{"event": "tool_call", "tool_name": "personal_create_schedule", "arguments": {...}}
{"event": "tool_result", "tool_name": "personal_create_schedule", "content": "..."}
이게 다 trace에 찍힌다. 디버깅할 때 필수다. 처음에 함수 본문이 ...만 있어서 content: null이 찍혔는데, 이걸 보고 "함수가 아무것도 안 하고 있다"는 걸 바로 파악할 수 있었다.
LLM으로 뭔가를 만들 때 편하게 쓸 수 있는 프레임워크다. @tool 데코레이터, create_agent() 같은 부품들을 제공해서 LLM + tool + prompt를 묶어 agent를 빠르게 만들 수 있게 해준다.
LLM이 엔진이라면, LangChain은 그 엔진으로 차를 만들 때 필요한 부품 키트다.
이번 주에 직접 구현하진 않았지만, 설치된 패키지(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
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
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
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가 있어도 건드리지 않는다.
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들 쓸 수 있어, 이렇게 행동해"라고 세팅해주는 것뿐이다.
{"event": "tool_result", "content": null}
함수 본문이 ...(Ellipsis)만 있으면 파이썬에서 None을 반환한다. 즉 TODO를 아직 안 채운 상태라는 뜻이다. _json({...})으로 감싼 문자열을 반환하도록 함수 본문을 구현하면 해결된다.
tool 함수 자체는 그냥 파이썬 함수인데, @tool 하나만 붙이면 LLM이 그걸 도구로 인식하고 스스로 골라 쓴다는 게 신기했다. 그리고 코드 로직보다 docstring이나 prompt 같은 "LLM한테 어떻게 설명하느냐"가 agent 품질을 좌우한다는 게 가장 인상적인 부분이었다. trace를 통해 LLM 내부 동작을 눈으로 확인할 수 있어서 디버깅이 훨씬 수월했다.