Загружаем каталог…
Загружаем каталог…
예전에 학사정보 챗봇을 만들었다. 얼마 전 그 프로젝트를 다시 정리하다가, 내가 만든 챗봇이 요즘 말하는 "AI 챗봇"과는 꽤 다른 물건이었다는 걸 알았다. 그래서 이번 글에서는 그 챗봇을 돌아보고, 지금 다시 만든다면 어떤 방식으로 만드는지 를 파이썬 코드로 끝까지 정리해 본다. 결론부터 쓰면 이렇다. 알아듣고 말하는 부분은 LLM이 통째로 가져갔고, 내가 맡았던 데이터 쪽은 거의 그대로 남았다. 0. 회고 — 그때 만든 챗봇 학생이 "도서관 자리 있어?", "오늘 학식 뭐야?", "회로이론1은 뭘 배워?", "졸업요건 알려줘"라고 물으면 답해 주는 챗봇이었다. 네 명이 투입됐고, 챗봇 본체는 팀원이, 나는 챗봇이 읽는 데이터와 그 데이터를 꺼내는 조회 기능 세 개를 맡았다. 학생 질문 ↓ 1 알아듣기 ─ 의도 분류(Intent) + 엔티티 추출(Entity) 예문 358개(nlu.yml)로 학습시킨 분류 모델(DIETClassifier) 한국어 조사는 형태소 분석기(KoNLPy의 Okt)로 떼어 냄 ↓ 2 되묻기 ─ 슬롯(Slot)·폼(Form) 졸업요건이면 학과와 입학년도가 다 찰 때까지 되묻는다 ↓ 3 조회해서 답하기 ─ 커스텀 액션(Custom Action) 9개 ★ 그중 도서관 좌석·학식·과목 설명 3개가 내 것 ↑ DB ─ ★ 웹·PDF에서 모아 정제해 넣은 데이터 전부 개발 땐 구글 시트, 검증계부터 Oracle 도서관 좌석은 5분 배치로 갱신, 통계용 이력은 1년 보관 12는 Rasa 3.1로 팀원이 만들었고, 3의 일부와 DB가 내 자리였다. 학습 예문에 들어간 과목명 56개와 교수명 115명도 내가 모은 데이터에서 뽑았다. 정리하다가 깨달은 게 있다. 이 챗봇에는 LLM이 없었다. Claude나 ChatGPT 같은 거대 언어 모델은 하나도 안 들어갔다. 미리 정해 둔 의도 중 하나로 문장을 분류하고, 정해 둔 흐름대로 되묻고, 정해 둔 문장 틀(응답 템플릿, responses)에 값을 끼워 답하는 구조였다. 문서를 찾아서 답하는 RAG도 아니었다. 그때는 이게 챗봇을 만드는 표준적인 방법이었다. 그럼 요즘은 뭐로 만들까. 1. 요즘의 기본 — LLM + 도구 호출 + RAG 한 문장으로 말을 알아듣고 답을 쓰는 건 LLM에게 맡기고, LLM이 모르는 우리 학교 데이터는 "도구 호출"과 "RAG" 두 통로로 쥐여 준다. LLM이 질문을 보고 필요한 도구를 스스로 골라 부르고, 결과를 보고 또 부르기도 하면서 답을 완성하는 이 구조를 요즘은 흔히 AI 에이전트(Agent)라고 부른다. 세 가지를 하나씩 LLM (거대 언어 모델, Large Language Model) 엄청난 양의 글로 학습이 이미 끝난 모델이다. 내가 학습시키지 않고 API로 빌려 쓴다. 한국어 조사, 오타, 줄임말, "다음 주 화요일"이 며칠인지까지 알아서 이해한다. 다만 동국대 도서관에 지금 몇 자리 남았는지는 모른다. 학습할 때 없던 정보이고, 5분마다 바뀌는 정보라서다. 그래서 우리 데이터를 쥐여 줄 통로가 두 개 필요하다. 도구 호출 (Tool Calling, Function Calling이라고도 한다) LLM에게 "이런 함수들이 있다"는 설명서를 같이 보낸다. LLM은 질문을 보고 " get_library_seats 를 room="IC Zone" 으로 불러 줘"라고 요청만 한다. 실제 실행은 내 서버가 하고, 결과를 LLM에게 돌려주면 LLM이 그걸 보고 답을 쓴다. 좌석, 학식, 연락처처럼 DB에 표로 있는 값, 계속 바뀌는 값 은 이 통로로 간다. RAG (검색 증강 생성, Retrieval-Augmented Generation) 오픈북 시험이다. 질문과 관련 있는 문서 조각을 먼저 찾아서(검색) LLM에게 같이 보여 주고, "이걸 보고 답해"라고 한다. 과목 해설, 학칙처럼 문서로 된 지식 은 이 통로로 간다. 요즘은 이 검색 자체를 도구 하나로 만들어서 LLM이 필요할 때 부르게 하는 경우가 많고, 이 글도 그렇게 한다. 웹 개발 말로 바꾸면 요즘 챗봇 웹으로 치면 도구 (Tool) 백엔드 API 하나 도구 설명 + 입력 스키마 (description, input_schema) Swagger(OpenAPI) 문서 LLM 그 문서를 읽고 API를 골라 부른 뒤, 응답을 보고 화면 문구를 쓰는 클라이언트 에이전트 루프 (Agent Loop) 클라이언트가 API를 몇 번 부르며 화면을 완성하는 흐름 RAG 검색 API. 단, 글자가 아니라 뜻으로 찾는다 그때 나는 API(커스텀 액션)를 만들었고, 그 API를 부르는 쪽이 Rasa에서 LLM으로 바뀐 셈이다. 왜 이렇게 바뀌었나 그때 (Rasa) 지금 (LLM + 도구) 알아듣게 하려면 의도마다 예문을 써서 학습 (358개) 도구 설명 몇 줄 한국어 조사 형태소 분석기(Okt)를 붙였다 신경 쓸 필요 없다 처음 보는 표현 엉뚱한 의도로 가거나 "이해하지 못했어요" 대부분 알아듣는다 "자리랑 학식 둘 다 알려줘" 의도는 하나만 고른다 도구 두 개를 한 번에 부른다 "다음 주 화요일 학식" 날짜 파싱 코드를 직접 짰다 LLM이 계산한다 (오늘 날짜만 알려 주면) 답 문장 템플릿에 값 끼우기 상황에 맞게 LLM이 쓴다 기능 추가 예문 쓰고 다시 학습 도구 하나 추가 약점 정해진 것만 한다 그럴듯하게 지어낼 수 있다(환각, Hallucination). 호출마다 돈과 시간이 든다 마지막 줄이 중요하다. Rasa는 모르면 "모르겠다"고 하는데, LLM은 모르면 그럴듯하게 지어낼 수 있다. 받은 값이 틀려도 의심 없이 아주 자연스럽게 말한다. 그래서 요즘 방식에서 제일 공들이는 게 "LLM이 도구에서 받은 사실로만 답하게 만드는 것"이고, 그 사실을 만드는 게 데이터 쪽 일이다. 그럼 우리 학교 데이터로 모델을 학습시키나? 보통은 아니다. 좌석처럼 5분마다 바뀌는 값은 학습으로 넣을 수가 없고, 학습(파인튜닝, Fine-tuning)은 비싸고 느리다. 대부분 도구와 RAG로 먼저 만들고, 말투나 형식을 꼭 맞춰야 할 때 파인튜닝을 검토한다. Rasa는 사라졌나 아니다. Rasa도 LLM 쪽으로 왔다. 지금 Rasa의 대화 엔진인 CALM(Conversational AI with Language Models)은 LLM이 대화를 이해해서 미리 정의한 업무 흐름(Flow) 중 어디로 갈지 고르고, 흐름 안의 단계와 업무 규칙은 사람이 정해 둔 대로 돈다. 답변도 기본은 정해 둔 문장이다. 은행처럼 정해진 절차를 반드시 지켜야 하는 곳이면 이쪽이 맞을 수 있다. 학사정보처럼 "조회해서 알려 주기"가 대부분인 챗봇은 이 글의 방식이 더 단순하다. 대응표 — 그때 만든 것은 지금 어디로 가나 그때 (Rasa) 지금 누가 의도 분류 (Intent) LLM이 어떤 도구를 부를지 고른다 LLM 엔티티 추출 (Entity) 도구 인자 (tool input) LLM 학습 예문 ( nlu.yml ) 도구 설명 (description) + 입력 스키마 (input_schema) 개발자 분류 모델 (DIETClassifier) LLM 그 자체 — 형태소 분석기 (Okt) 필요 없음. 단, 검색용 정규화는 여전히 필요 — 슬롯 (Slot) 대화 기록 (messages) 서버가 보관 폼 (Form) 도구의 필수 인자 + "모르면 먼저 물어봐라" 규칙 개발자 설계 + LLM 정책 (Policy) 에이전트 루프. 다음 행동은 LLM이 고른다 LLM + 루프 코드 응답 템플릿 (responses) LLM이 쓰고, 지킬 규칙은 시스템 프롬프트(System Prompt)로 개발자 규칙 + LLM 커스텀 액션 (Custom Action) 도구 함수. 거의 그대로 개발자 수집·정제·적재, 5분 배치 그대로 개발자 굵은 두 줄이 그때 내 몫이었다. 위쪽(알아듣기, 되묻기, 말하기)은 LLM이 가져갔고, 아래쪽(데이터와 조회)은 그대로 남았다. 2. 전체 구조 [ 실시간: 질문에 답하기 ] 학생 ─ React 채팅 화면 │ POST /chat ▼ api.py (FastAPI) ─ 세션별 대화 기록 보관 │ ▼ agent.py (에이전트 루프) ⇄ LLM (Claude API) │ LLM: "이 도구를 이 인자로 불러 줘" ▼ tools.py (도구 = 예전 커스텀 액션) ├─ 좌석·학식·연락처·졸업요건 ──→ DB (예제는 SQLite, 운영은 Oracle) └─ 과목 해설 검색 (RAG) ───────→ 벡터 DB (Chroma) [ 배치: 데이터 만들기 ] ← 그때 내가 하던 일 scheduler.py (웹 서버와 따로 도는 프로세스) ├─ 5분마다 도서관 좌석 수집 → DB (현재 값 덮어쓰기 + 이력 쌓기) ├─ 매일 학식 수집, 보관 기간 지난 이력 삭제 └─ 매주 교직원 연락처 갱신 ingest_courses.py (학기마다) └─ 교육과정 PDF → 과목 단위로 자르기 → 임베딩 → 벡터 DB 위 절반이 새로 생긴 부분이고, 아래 절반은 그때와 같다. 3. 준비 폴더 구조 dongguk-bot/ ├─ db.py # DB 연결과 테이블 ├─ seed.py # 샘플 데이터 (바로 돌려 보기용) ├─ collectors/ # 수집기. 그때 코드를 다듬어 재사용 │ ├─ library.py │ ├─ meal.py │ └─ staff.py ├─ rag.py # 벡터 검색 ├─ ingest_courses.py # PDF → 벡터 DB ├─ tools.py # 도구 (= 예전 actions.py) ├─ agent.py # LLM + 도구 루프 ├─ api.py # FastAPI 서버 ├─ scheduler.py # 배치 └─ eval.py # 평가 설치 pip install anthropic voyageai chromadb fastapi uvicorn "apscheduler<4" \ selenium beautifulsoup4 pdfplumber export ANTHROPIC_API_KEY=... # LLM (Claude API) export VOYAGE_API_KEY=... # 임베딩 (Voyage AI) 키가 두 개인 이유: Anthropic은 임베딩 모델을 따로 내놓지 않고, 공식 문서에서 Voyage AI를 권한다. 임베딩은 6장에서 설명한다. APScheduler는 3.x 기준이다. 4.x는 사용법이 크게 달라서 버전을 묶어 뒀다. DB 예제는 설치 없이 바로 돌도록 SQLite로 쓴다. 운영 DB가 Oracle이면 연결을 oracledb (python-oracledb)로 바꾸고 SQLite 전용 문법 몇 군데(코드 주석에 표시)만 고치면 된다. :name 바인드 변수 문법은 둘이 같다. # db.py — 예제는 바로 돌려 보도록 SQLite. 운영 DB가 Oracle이면 연결 부분만 바꾼다. import sqlite3 from contextlib import contextmanager DB_PATH = "dongguk.db" SCHEMA = """ CREATE TABLE IF NOT EXISTS seat_current ( -- 챗봇이 읽는 현재 좌석 (5분마다 덮어쓰기) room TEXT PRIMARY KEY, used INTEGER, total INTEGER, remain INTEGER, status TEXT NOT NULL, -- 'open' | 'closed' collected_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS seat_history ( -- 통계용 이력 (5분마다 쌓기, 1년 보관) room TEXT, used INTEGER, total INTEGER, remain INTEGER, status TEXT, collected_at TEXT ); CREATE TABLE IF NOT EXISTS meal ( day TEXT, cafeteria TEXT, meal_time TEXT, corner TEXT, menu TEXT, status TEXT NOT NULL, -- 'open' | 'no_service' collected_at TEXT ); CREATE TABLE IF NOT EXISTS staff ( dept TEXT, name TEXT, position TEXT, phone TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS graduation_req ( department TEXT, admission_year INTEGER, content TEXT, source TEXT, page INTEGER ); """ @contextmanager def get_conn(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row # row["room"]처럼 컬럼 이름으로 꺼낸다 try: yield conn conn.commit() finally: conn.close() def init_db(): with get_conn() as conn: conn.executescript(SCHEMA) 좌석 테이블이 둘인 건 그때 구조 그대로다. 챗봇은 현재 값( seat_current )을 읽고, 통계는 이력( seat_history )을 본다. 처음부터 들어간 게 하나 있다면 status 컬럼이다. 이유는 바로 다음 장에 나온다. 수집기 없이 바로 돌려 보려면 샘플 데이터를 넣는다. # seed.py — 수집기 없이 바로 돌려 보기용 샘플 데이터 from db import get_conn, init_db init_db() with get_conn() as conn: conn.executemany("INSERT OR REPLACE INTO seat_current VALUES (?, ?, ?, ?, ?, ?)", [ ("IC Zone", 28, 40, 12, "open", "2026-10-03 14:05:00"), ("제1열람실", None, None, None, "closed", "2026-10-03 14:05:00"), ]) conn.execute("INSERT INTO graduation_req VALUES (?, ?, ?, ?, ?)", ("컴퓨터공학전공", 2023, "(예시) 졸업학점 OOO, 전공 OO학점", "2023_교육과정.pdf", 1)) 4. 데이터 — 그때 하던 일, 교훈을 넣어서 이 부분은 그때와 거의 같다. Selenium으로 다 그려진 페이지를 받고, BeautifulSoup으로 값을 뽑고, DB에 넣는다. 다른 점은 운영하면서 배운 걸 처음부터 넣는다 는 것이다. (주소와 선택자는 예시다.) # collectors/library.py — 5분마다 도는 도서관 좌석 수집기 from datetime import datetime from bs4 import BeautifulSoup from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait from db import get_conn URL = "https://library.example.ac.kr/seat" # 예시 주소 def fetch_html() -> str: options = webdriver.ChromeOptions() options.add_argument("--headless=new") driver = webdriver.Chrome(options=options) # 드라이버 경로를 적지 않는다 → 크롬 버전에 맞는 드라이버를 Selenium이 찾는다 try: driver.set_page_load_timeout(30) # 사이트가 멈추면 30초 뒤 포기하고 다음 5분에 다시 driver.get(URL) WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, "table tr td"))) return driver.page_source finally: driver.quit() # 에러가 나도 크롬을 꼭 닫는다 def to_int(s: str): s = s.replace(",", "").strip() return int(s) if s.isdigit() else None def to_row(room: str, used, total, collected_at: str) -> dict: """그때의 정제 규칙 + 운영하면서 고친 것""" if not total: # 전체 좌석이 비거나 0 → 문 닫은 것. "0석"이 아니다 return {"room": room, "used": None, "total": None, "remain": None, "status": "closed", "collected_at": collected_at} used = used or 0 return {"room": room, "used": used, "total": total, "remain": max(total - used, 0), # 사이트에 없는 '남은 자리'는 뺄셈으로 만든다 "status": "open", "collected_at": collected_at} def parse(html: str, collected_at: str) -> list[dict]: rows = [] for tr in BeautifulSoup(html, "html.parser").select("table tr"): tds = [td.get_text(strip=True) for td in tr.select("td")] if len(tds) < 3: # 머리줄(th)이나 빈 줄은 건너뛴다 continue room, total, used = tds[0], to_int(tds[1]), to_int(tds[2]) rows.append(to_row(room, used, total, collected_at)) return rows def collect_library_seats(): now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") rows = parse(fetch_html(), now) with get_conn() as conn: # 현재 값은 덮어쓰고(챗봇용), 같은 값을 이력에 쌓는다(통계용). Oracle이면 MERGE INTO로 쓴다 conn.executemany(""" INSERT INTO seat_current VALUES (:room, :used, :total, :remain, :status, :collected_at) ON CONFLICT(room) DO UPDATE SET used = excluded.used, total = excluded.total, remain = excluded.remain, status = excluded.status, collected_at = excluded.collected_at""", rows) conn.executemany(""" INSERT INTO seat_history VALUES (:room, :used, :total, :remain, :status, :collected_at)""", rows) 코드에 넣은 교훈은 네 가지다. "0석"과 "휴관"을 구분한다 ( status ). 운영 중에 휴관일이나 개관 전 새벽에 챗봇이 "0석 남았습니다"라고 답했다. 빈 값을 0으로 채운 게 "꽉 차서 0"과 "문 닫아서 0"을 섞은 것이다. LLM 시대엔 이게 더 위험하다. LLM은 remain: 0 을 받으면 의심 없이 "지금 0석 남았어요"라고 아주 자연스럽게 말한다. 값의 뜻을 데이터에 박아 둬야 한다. 수집 시각을 같이 넣는다 ( collected_at ). LLM이 "14:05 기준"이라고 답할 재료다. 크롬은 무조건 닫는다 ( finally: driver.quit() ). 운영 중에 수집이 겹치고 크롬이 안 닫혀 쌓인 적이 있다. 에러가 나도 닫히게 하고, 페이지 로딩 제한 시간을 둬서 한 번 멈춘 수집이 다음 수집까지 잡아먹지 않게 한다. 드라이버 경로를 적지 않는다. Selenium 4.6부터는 webdriver.Chrome() 만 쓰면 Selenium Manager가 설치된 크롬에 맞는 드라이버를 찾아 준다. 그때 Docker 안에서 막혔던 게, 따로 받아 둔 드라이버를 코드가 가리키고 있어서였다. 학식 수집기에는 하나가 더 들어간다. 운영
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
학사정보 챗봇, 요즘 방식으로 다시 만든다면. 예전에 학사정보 챗봇을 만들었다. 얼마 전 그 프로젝트를 다시 정리하다가, 내가 만든 챗봇이 요즘 말하는 "AI 챗봇"과는 꽤 다른 물건이었다는 걸 알았다. 그래서 이번 글에서는 그 챗봇을 돌아보고, 지금 다시 만든다면 어떤 방식으로 만드는지 를 파이썬 코드로 끝까지 정리해 본다. 결론부터 쓰면 이렇다. 알아듣고 말하는 부분은 LLM이 통째로 가져갔고, 내가 맡았던 데이터 쪽은 거의 그대로 남았다. 0. 회고 — 그때 만든 챗봇 학생이 "도서관 자리 있어?", "오늘 학식 뭐야?", "회로이론1은 뭘 배워?", "졸업요건 알려줘"라고 물으면 답해 주는 챗봇이었다. 네 명이 투입됐고, 챗봇 본체는 팀원이, 나는 챗봇이 읽는 데이터와 그 데이터를 꺼내는 조회 기능…
Открыть источник