log-agent를 운영하면서 “에러 분석 에이전트를 만드는 데 쓴 구조를 일상에도 쓸 수 있지 않을까”는 생각이 들었다. 일정 관리, 지출 기록, 할 일 목록, 리마인더 같은 것들을 하나의 Slack 채널에서 말로 처리하는 개인 비서다.
이 포스트는 전체 구축 과정을 한눈에 볼 수 있는 인덱스다. 각 주제별 상세 글로 이동할 수 있다.
전체 구성
| 기능 | 수단 |
|---|---|
| 일정 추가·조회 | Google Calendar API |
| 지출 기록·조회 | Google Sheets API |
| 할 일 추가·완료 | MySQL |
| 리마인더 설정·취소 | APScheduler + MySQL |
| 장기 기억 | ChromaDB + LlamaIndex |
| 웹 검색 | DuckDuckGo |
| 파일 저장·검색·카테고리 관리 | 로컬 디스크 + ChromaDB + Slack Files API |
“다음주 화요일 오후 2시 치과 예약해줘”, “스타벅스 6500원”, “30분 후에 약 먹으라고 알려줘” — 이 세 문장이 각각 Calendar, Sheets, APScheduler로 연결된다.
아키텍처
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Slack DM
↓
FastAPI (Slack Events API 수신)
↓
LangGraph ReAct 에이전트
↓
도구 선택 및 실행
├── Google Calendar API
├── Google Sheets API
├── ChromaDB + LlamaIndex (장기 기억 + Knowledge Graph)
├── MySQL (Todo / Reminder)
└── DuckDuckGo (웹 검색)
↓
Slack DM 응답
핵심은 LangGraph의 ReAct 에이전트다. 사용자 메시지를 받으면 어떤 도구를 쓸지 LLM이 판단하고, 도구를 실행한 결과를 다시 LLM에게 넘겨 최종 답변을 만든다.
구축기 시리즈
1. 기초 구조 — FastAPI + LangGraph + Slack
→ [현재 포스트]
Slack Events API 연동, LangGraph ReAct 에이전트 구조, 도구 정의 방식, 시스템 프롬프트 설계를 다룬다.
주요 포인트:
- Slack은 3초 안에 200 응답을 요구 →
BackgroundTasks로 즉시 반환 후 백그라운드 처리 bot_id체크 없으면 봇 응답에도 이벤트가 발생해 무한루프@langchain_tool데코레이터로 도구 정의,user_id는 클로저로 바인딩- Slack은 표준 마크다운이 아닌 mrkdwn 형식 — 시스템 프롬프트에 명시 필수
1
2
3
4
5
6
7
@router.post("/events")
async def slack_events(request: Request, background_tasks: BackgroundTasks):
body = await request.json()
event = body.get("event", {})
if event.get("type") == "message" and not event.get("bot_id"):
background_tasks.add_task(handle_message, user_id, text, channel)
return {"ok": True}
2. 장기 기억 — ChromaDB + Ollama 임베딩
ChromaDB를 이용한 벡터 기반 장기 기억 구현을 다룬다.
주요 포인트:
- 임베딩: Ollama
nomic-embed-text모델,/api/embed엔드포인트 직접 호출 - ChromaDB HttpClient를 싱글턴으로 관리 — 매 요청마다 생성 시
ResourceWarning발생 min(n, count)없으면 저장된 문서보다 많은 n_results 요청 시 ChromaDB 에러_prefetch_memory(): 매 대화 전에 자동 검색 후 메시지 앞에 주입 — LLM이 도구를 호출할지 말지 판단하는 것보다 확실함- Docker 컨테이너 간 통신:
host.docker.internal대신 같은 네트워크에 붙여서 컨테이너 이름으로 접근
1
2
3
4
5
6
async def _prefetch_memory(user_id: str, message: str) -> str:
memories = await memory_service.search_memory(user_id, message)
if not memories:
return message
context = "\n".join(f"- {m}" for m in memories)
return f"[기억된 정보 (자동 조회)]\n{context}\n\n[사용자 메시지]\n{message}"
3. Google Calendar·Sheets 연동
→ 개인 비서에 Google Calendar·Sheets 연동하기
서비스 계정 방식의 Google API 연동과 삽질 기록이다.
주요 포인트:
- 서비스 계정 선택 이유: 서버 자동화에서 OAuth2 토큰 갱신 관리가 불필요
calendarId="primary"→ 서비스 계정 자체 캘린더에 이벤트 생성, 내 캘린더에 접근하려면 Gmail 주소 지정 필요- Groovy 단일 따옴표 문자열에서
\n은 리터럴 →json.loads(raw, strict=False)로 파싱 - Google API 클라이언트는 동기 라이브러리 →
run_in_executor로 스레드풀에서 실행
1
2
3
4
5
6
7
@langchain_tool
async def add_calendar_event(title: str, date: str, time: str) -> str:
loop = asyncio.get_event_loop()
return await asyncio.wait_for(
loop.run_in_executor(None, lambda: calendar_service.add_event(title, date, time)),
timeout=45,
)
4. 할 일 목록과 리마인더
APScheduler 기반 리마인더와 Todo CRUD 구현을 다룬다.
주요 포인트:
- Calendar(날짜 이벤트) vs Todo(완료 처리 작업) vs Reminder(시간 기반 알림) — 세 가지 구분이 중요
fired=True로 취소 처리 — 삭제하면 “아까 설정한 리마인더 취소해줘” 요청 시 목록 제공 불가[AGENT_ONLY]섹션: 에이전트에게 DB PK를 전달하되 사용자 화면에는 미노출recursion_limit=6에서 리마인더 수정(list→cancel→set, 3번 연속 도구 호출) 시 한도 초과 → 20으로 조정- 1분 polling 방식 — 개인 비서 용도로 충분한 정밀도
1
2
3
4
def start_scheduler() -> None:
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
_scheduler.add_job(_fire_reminders, "interval", minutes=1)
_scheduler.start()
5. LLM 전략 — Gemini 우선, Ollama 폴백
→ 개인 비서 LLM 전략 — Gemini 우선, Ollama 폴백
LLM을 Ollama에서 Gemini로 바꾼 이유와 폴백 전략을 다룬다.
주요 포인트:
- Ollama
gemma4:12b응답: 20~30초 / Gemini: 2~3초 — 개인 비서에서 30초는 너무 느림 gemini-3.1-flash-lite는 1,500회/일 — 최신 모델이 항상 관대하지 않음- Gemini로 바꾼 뒤 도구 호출 누락 → 시스템 프롬프트를 표 형식으로 강화, “도구 없이 텍스트만 답변하지 마세요” 추가
1
2
3
4
5
6
7
8
9
10
def _is_quota_error(e: Exception) -> bool:
s = str(e).lower()
return any(k in s for k in ("quota", "429", "resource exhausted", "rate limit"))
async def chat(user_id: str, message: str) -> str:
try:
result = await _invoke_graph(_make_gemini(), tools, message, timeout=120)
except Exception as e:
if _is_quota_error(e):
result = await _invoke_graph(_make_ollama(), tools, message, timeout=300)
6. 기억 시스템 디버깅
→ 개인 비서 기억 시스템 디버깅 — 고유명사 검색 실패, 데이터 오염, 확인 UX
운영하면서 터진 기억 시스템 버그들의 디버깅 기록이다.
주요 포인트:
- “또리” 같은 고유명사는 임베딩 모델이 의미를 학습하지 못해 벡터 유사도 낮음 → BM25 + 벡터 RRF 하이브리드로 해결
auto_save_memory프롬프트 끝에핵심 정보 (없으면 빈 문자열):를 붙이자 LLM이 그대로 복사 → ChromaDB 오염- “(내용 없음)” 8글자 →
len > 5필터를 통과해 저장 → 쓰레기 데이터가 top-3 점령 - 오염 데이터로 에이전트가 기억을 불신 → 시스템 프롬프트에 “신뢰하고 ‘없다’고 하지 마” 추가
- Slack Block Kit으로 저장 전 사용자 확인 UX 구현
1
2
3
4
5
6
루트 원인 체인:
auto_save_memory 프롬프트 잘못됨
→ 쓰레기 데이터 ChromaDB 저장
→ top-3 점령
→ 실제 데이터 밀려남
→ 에이전트가 "없다"고 답변
7. BM25 한국어 복합어 문제
→ 개인 비서 BM25 한국어 복합어 문제 — 형태소 분석기 없이 해결하기
“내차가”를 “차”로 매칭하지 못하는 BM25 한국어 문제와 해결법을 다룬다.
주요 포인트:
[가-힣]+패턴은 “내차가”를 하나의 토큰으로 → “차” 매칭 불가- 형태소 분석기(
kiwipiepy) 없이 해결: 2-gram + 음절 단위 토큰 추가 - “내차가” → [“내차가”, “내차”, “차가”, “내”, “차”, “가”] → “차” 매칭 성공
- BM25 IDF가 “이”, “의”, “는” 같은 공통 음절의 가중치를 자동으로 낮춤
kiwipiepy미도입 이유: Docker 이미지 크기 증가, arm64/amd64 호환성 문제
1
2
3
4
5
6
7
8
9
def _tokenize(text: str) -> list[str]:
tokens = re.findall(r"[A-Za-z가-힣0-9]+", text.lower())
korean_words = re.findall(r"[가-힣]+", text)
extra = []
for word in korean_words:
for i in range(len(word) - 1):
extra.append(word[i:i+2]) # 2-gram
extra.extend(list(word)) # 음절
return tokens + extra
8. LlamaIndex 도입 — 기억 검색 + Knowledge Graph
→ AI 개인 비서에 LlamaIndex 도입 — 기억 검색부터 Knowledge Graph까지
memory_service를 LlamaIndex로 교체하고, 대화 히스토리 압축과 Knowledge Graph를 추가한 기록이다.
주요 포인트:
OllamaEmbedding+VectorStoreIndex+ChromaVectorStore로 수동 httpx 임베딩 호출 대체QueryFusionRetriever(mode="reciprocal_rerank")로 BM25 + 벡터 RRF를 한 줄로- 대화 히스토리 압축: 5턴 초과 시 오래된 대화를 LLM으로 요약, 최근 3턴 원문 유지
SimplePropertyGraphStore: “또리 생일” →(또리) -[생일]→ (10월 27일)그래프 저장query_knowledge_graph도구로 엔티티 중심 쿼리 지원
1
2
3
4
5
6
7
retriever = QueryFusionRetriever(
retrievers=[vector_retriever, bm25_retriever],
similarity_top_k=n,
mode="reciprocal_rerank",
use_async=True,
)
nodes = await retriever.aretrieve(query)
9. Slack 파일 저장소 — 업로드·카테고리·번들·시맨틱 검색
Slack DM으로 보낸 파일을 저장하고, 나중에 말로 찾아오는 기능을 구현한 기록이다.
주요 포인트:
- Slack
url_private는 bot token 인증 없이 접근하면 403 —files:read스코프 필수 - 파일 내용이 아닌 파일명 + 카테고리 + 날짜를 ChromaDB에 임베딩 — 이진 파일 파싱 불필요
- “회의록 파일 줘”처럼 대략적으로 말해도 벡터 유사도로 찾아 전송
- 파일과 함께 보낸 짧은 텍스트(15자 이하)를 카테고리로 자동 인식 (“업무” → category=업무)
- 여러 파일 동시 전송 → FileBundle로 묶어 저장, 카테고리 조회 시 zip으로 일괄 전송
- Docker 볼륨 미설정 시 컨테이너 재시작에 파일 소실 —
docker-compose.yml볼륨 마운트 필수
1
2
3
4
5
def _make_embed_text(filename, mimetype, dt, category):
parts = [f"파일명: {filename}", f"종류: {mimetype}", f"날짜: {dt.strftime('%Y-%m-%d')}"]
if category:
parts.insert(1, f"카테고리: {category}")
return " | ".join(parts)
10. 한국어 처리 개선 — kiwipiepy 형태소 분석기 도입
→ 개인 비서 한국어 처리 개선 — kiwipiepy 형태소 분석기 도입
정규식으로 처리하던 한국어 파싱 로직 4곳을 kiwipiepy 형태소 분석 기반으로 교체한 기록이다.
주요 포인트:
- 카테고리 추출: “코오롱 업무에 저장해줘” → 조사 변형에 관계없이 명사구 “코오롱 업무” 추출
- BM25 토크나이저: 수동 2-gram 분해 → 형태소 단위 명사·동사 추출로 검색 품질 향상
- 정크 메모리 감지: “없습니다” 패턴만 → “없-“ 어간 분석으로 “없어요”, “없는데요” 등 활용형 포함
- 긍정/부정 판별: 정확한 키워드 일치 → 어간 매칭으로 “좋아요”, “아니에요” 등 활용형 처리
app/core/kiwi.py싱글톤으로 세 서비스가 Kiwi 인스턴스 공유 — 모델 로드 1회
1
2
3
4
5
6
7
8
9
10
# app/core/kiwi.py — 공유 싱글톤
from kiwipiepy import Kiwi
_instance: Kiwi | None = None
def get() -> Kiwi:
global _instance
if _instance is None:
_instance = Kiwi()
return _instance
기술 스택
| 영역 | 기술 |
|---|---|
| 백엔드 | FastAPI, Python 3.11 |
| LLM (기본) | Google Gemini API (gemini-2.0-flash) |
| LLM (폴백) | Ollama — gemma4:12b |
| 에이전트 | LangGraph (ReAct) |
| 기억 저장소 | ChromaDB + LlamaIndex VectorStoreIndex |
| Knowledge Graph | LlamaIndex SimplePropertyGraphStore |
| DB | MySQL (aiomysql + SQLAlchemy) |
| 스케줄러 | APScheduler |
| 외부 연동 | Google Calendar API, Google Sheets API |
| 파일 저장 | 로컬 디스크 + ChromaDB (메타데이터 임베딩) |
| 한국어 처리 | kiwipiepy (형태소 분석) |
| 알림 | Slack SDK + Block Kit |
전체 데이터 흐름
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
사용자 Slack DM
↓
FastAPI /events (즉시 200 반환 + BackgroundTask)
↓
_prefetch_memory() — ChromaDB 자동 검색 후 메시지 앞에 주입
↓
_get_history_context() — 대화 히스토리 (5턴 초과 시 LLM 압축)
↓
LangGraph ReAct 에이전트 (Gemini → 할당량 초과 시 Ollama)
├── search_memory → ChromaDB 하이브리드 검색 (벡터 + BM25 RRF)
├── query_knowledge_graph → SimplePropertyGraphStore 엔티티 조회
├── save_memory → Slack Block Kit 확인 버튼 → ChromaDB + Knowledge Graph 저장
├── add_calendar_event → Google Calendar API
├── add_expense → Google Sheets API
├── set_reminder → MySQL + APScheduler
├── add_todo / complete_todo → MySQL
├── web_search → DuckDuckGo
└── find_file / find_files_by_category → 로컬 디스크 + ChromaDB + Slack Files API
↓
Slack DM 응답