내 문서를 읽는 작은 에이전트를 다시 만들며
내 사이트 dongjun.win에 붙어 있던 작은 AI 어시스턴트를 최근에 다시 손봤다. 방문자가 AI 어시스턴트 페이지에서 질문을 던지면, 내 이력서와 프로젝트 문서, 강점 진단, 리더십 리포트, 버크만(Birkman) 리포트를 바탕으로 답하는 기능이다.
겉으로는 단순하다. "최근 프로젝트는?", "어떤 기술을 쓰나요?", "일하는 방식은 어떤가요?" 같은 질문에 답하는 채팅창이다. 하지만 구현 관점에서는 단순한 챗봇보다 작은 에이전트 런타임에 더 가까웠다.
이번 작업의 핵심은 새 챗봇을 만드는 것도, 모델을 바꾸거나 프롬프트를 더 길게 쓰는 것도 아니었다. 기존 어시스턴트가 문서를 언제, 어떤 경로로, 어떤 형태로 모델에게 보여줄 것인가를 다시 설계하는 일이었다.
기존 구현은 가장 쉬운 방식이었다. 모든 문서를 시스템 프롬프트에 넣었다. LLM 컨텍스트가 충분히 길어졌고, 문서도 몇 개 안 됐다. 별도 RAG를 붙이고 싶지도 않았다. 그래서 이력서, 프로젝트 문서, 성향 자료를 통째로 prompt에 넣고 질문만 덧붙였다.
동작은 했다. 하지만 운영하기 좋은 구조는 아니었다.
프롬프트를 데이터 저장소로 썼을 때
초기 구조는 이렇게 단순했다.
system prompt
- 답변 원칙
- 이력서 전체
- 사이드 프로젝트 문서
- CliftonStrengths 요약
- 리더십 리포트
- 버크만(Birkman) 리포트
user question
- "최근 프로젝트는?"
이 방식의 장점은 분명하다. 검색 실패가 없다. chunk 설계도 필요 없다. embedding 모델도 고르지 않아도 된다. 문서가 적고 고정되어 있다면 Full Context는 꽤 합리적인 선택처럼 보인다.
문제는 제품의 기본 경로가 매번 무거워진다는 점이었다. 질문이 가벼워도 모델은 항상 모든 문서를 들고 출발했다. 정확한 벤치마크를 따로 남겨두지는 않았지만, 첫 응답이 무겁게 늦어지는 체감이 분명했다.
더 큰 문제는 노이즈였다. 기술 스택 질문에 성향 진단 문맥이 같이 들어오고, 프로젝트 질문에 리더십 리포트의 표현이 섞였다. 모델이 완전히 틀린 답을 한다기보다, 필요 없는 문맥까지 참고하면서 답변의 초점이 흐려졌다.
이 구조에서는 시스템 프롬프트가 세 가지 역할을 동시에 맡고 있었다.
assistant의 행동 원칙
내 문서 전체
문서 선택 전략
이 셋이 한 덩어리로 섞이면 변경의 단위가 흐려진다. 말투를 고친 것인지, 지식을 바꾼 것인지, 문서 접근 방식을 바꾼 것인지 추적하기 어렵다. 프롬프트가 길어진 것이 문제가 아니라, 프롬프트가 런타임의 모든 책임을 떠안은 것이 문제였다.
그래서 방향을 바꿨다.
RAG를 새로 만들지는 않는다. 하지만 모든 문서를 항상 넣지도 않는다. 중간 지점으로, LLM의 tools를 이용해 필요한 문서만 열도록 했다.
RAG 대신 문서 접근 도구를 둔 이유
이 작업에서 일반적인 벡터 RAG는 과했다.
문서 수는 적고, 종류는 명확했다. 이력서, 사이드 프로젝트, 강점 진단, 리더십 리포트, 버크만 리포트. 이런 규모에서는 "비슷한 chunk를 검색한다"보다 "어떤 문서 범주를 열어야 하는지 결정한다"가 더 중요한 문제였다.
그래서 검색 엔진을 새로 붙이는 대신, 기존 어시스턴트의 문서 접근을 tools 기반으로 분리했다.
사용자 질문
-> LLM
-> 필요한 문서 범주 선택
-> searchDocuments
-> 필요한 documentId 선택
-> readDocument
-> 답변 생성
searchDocuments는 벡터 검색이 아니다. 키워드 검색도 아니다. 질문에 필요한 문서 범주를 선택하게 하는 category router에 가깝다. readDocument는 선택한 문서 본문 일부를 읽는다.
이 구조의 의도는 명확했다.
문서는 system prompt에서 분리한다.
문서 선택은 모델에게 맡기되, 선택 가능한 경로는 제한한다.
검색 실패 가능성은 줄이되, 모든 문서를 매번 넣는 비용은 피한다.
RAG 인프라를 만들지 않고도 문서 접근을 lazy하게 만든다.
즉, Full Context와 RAG 사이의 좁은 해법이다. 문서가 수백 개라면 부족한 구조다. 하지만 dongjun.win의 공개 프로필 AI에는 이 정도가 더 맞았다.
도구 설명은 문서 접근 계약이다
이 구조에서 품질을 결정한 것은 도구 개수가 아니라 도구 설명이었다.
도구는 두 개뿐이다.
searchDocuments: 필요한 문서 범주를 고르고 후보와 snippet을 반환한다.readDocument: 선택한 documentId로 본문 일부를 읽는다.
중요한 것은 searchDocuments의 description이다. 단순히 "문서를 검색한다"가 아니라, 어떤 질문에서 어떤 문서를 열어야 하는지를 명시했다.
이력서: 경력, 기술 스택, 주요 프로젝트, 연락처
사이드 프로젝트: Pikt, bunqldb 같은 개인 제품과 오픈소스
CliftonStrengths: 강점, 커뮤니케이션, 팀 역할
리더십 리포트: 대인관리, 성과관리, 변화관리, 자기관리
버크만(Birkman) 리포트: 흥미, 욕구, 스트레스 행동, 선호 환경
여기서 도구 description은 문서실의 안내 표지판에 가깝다. 모델이 무엇을 할 수 있는지보다, 언제 무엇을 열어야 하는지가 더 중요했다.
이 판단은 Hermes Agent 코드를 읽으며 정리했던 내용과도 맞닿아 있다. 도구는 시스템 프롬프트 안의 자연어 지시가 아니라 별도 채널로 들어간다. 시스템 프롬프트는 원칙과 전략을 담고, tools parameter는 호출 가능한 행동과 입력 schema를 담는다.
이 분리를 하자 책임이 선명해졌다.
| 책임 | 위치 |
|---|---|
| 답변 원칙과 톤 | system prompt |
| 현재 날짜와 경력 계산 | runtime context |
| 문서 접근 경로 | tools |
| 대화 상태 | messages |
| 장기 지식 | documents table |
프롬프트 하나에 모든 것을 밀어 넣을 때보다 훨씬 다루기 쉬운 구조가 됐다.
대화 저장은 부가 기능이 아니라 런타임이다
에이전트 런타임에서 대화 저장은 "채팅 기록 보기"를 위한 부가 기능이 아니다. 다음 호출의 입력을 복원하기 위한 핵심 경로다.
LLM은 호출 사이에 기억을 갖지 않는다. 매번 system + tools + messages를 새로 전달해야 한다. 따라서 어떤 메시지를 어떤 단위로 저장하느냐가 다음 호출의 동작을 결정한다.
dongjun.win의 assistant-agent는 세 테이블로 상태를 나눴다.
assistant_agent_threads
assistant_agent_sessions
assistant_agent_messages
메시지는 user, assistant, tool role을 그대로 저장한다.
user: "주요 강점이 뭔가요?"
assistant: searchDocuments tool-call
tool: 문서 후보 결과
assistant: readDocument tool-call
tool: 문서 본문 일부
assistant: 최종 답변
이 구조에서 중요한 것은 assistant의 텍스트만 저장하지 않는다는 점이다. tool-call과 tool-result의 관계를 보존해야 한다.
assistant가 어떤 도구를 어떤 인자로 호출했는지, tool result가 어떤 call id에 대응하는지, 그 결과를 다음 모델 호출에 어떤 형태로 복원할지를 저장소가 잃어버리면 안 된다.
그래서 메시지 저장에는 다음 필드를 둔다.
parts
tool_calls
tool_call_id
tool_name
AI SDK가 provider별 변환을 상당 부분 맡아주더라도, 저장소는 provider가 바뀌어도 복원 가능한 중립 구조를 유지해야 한다. OpenAI 계열은 tool role을 쓰고, Anthropic 계열은 user/assistant content block 안에 tool result를 묶는다. 표면 형식은 달라도 보존해야 하는 의미는 같다.
assistant가 어떤 도구를 요청했고, 앱이 어떤 결과를 돌려줬는가.
이 페어를 보존하는 것이 메시지 저장의 핵심이었다.
최종 답변과 진행 상태를 분리했다
스트리밍 UI에서 흔히 어색해지는 지점이 있다.
먼저 문서를 검색해볼게요. 관련 내용을 확인해보겠습니다. 이제 답변드리겠습니다.
콘솔에서는 괜찮다. 하지만 제품 UI에서는 내부 진행 로그가 최종 답변 안에 섞인다. 사용자는 답변을 읽고 싶은데, 메시지는 도구 실행 일지를 보여준다.
그래서 응답 본문과 진행 상태를 분리했다.
백엔드는 SSE 이벤트를 별도로 보낸다.
start
activity
text-delta
finish
error
도구 호출은 activity 이벤트로 흘리고, assistant 본문에는 최종 답변만 남긴다. 프론트에서는 내부 도구명을 그대로 보여주지 않고 사용자에게 자연스러운 상태로 바꾼다.
자료 확인 중
자료 확인 완료
답변 정리 중
시스템 프롬프트에도 같은 원칙을 넣었다. searchDocuments, readDocument를 사용했다는 사실을 답변 본문에서 과정 설명처럼 쓰지 않는다. 진행 상태는 activity 이벤트가 담당하고, 최종 답변은 결론과 근거만 담는다.
이 분리는 작지만 제품감에 영향을 크게 줬다. 도구 호출이 드러나지 않는 것이 아니라, 도구 호출이 있어야 할 채널로 이동한 것이다.
개인 소개 AI는 톤도 기능이다
이 어시스턴트는 일반 지식 챗봇이 아니다. 공개 프로필 사이트에 붙어 있고, 사용자는 나에 대해 묻는다. 따라서 답변 품질은 사실성만으로 결정되지 않는다.
예를 들어 "단점이 뭐예요?"라는 질문은 단순 정보 검색이 아니다. 너무 방어적으로 답하면 신뢰가 떨어지고, 자기비하처럼 답하면 공개 사이트에 붙은 AI로서 부적절하다.
그래서 단점 답변의 원칙을 별도로 잡았다.
강점의 반대편
-> 주의할 점
-> 보완 방식
-> 잘 맞는 환경
단점을 숨기지는 않는다. 다만 결함처럼 단정하지 않고, 업무 스타일과 강점의 반대편에 있는 특성으로 설명한다. 그리고 실제 보완 방식을 함께 말한다.
이건 미화가 아니라 맥락화다. 개인 소개 AI는 일종의 대리 커뮤니케이션이다. 사용자는 "이 사람이 어떤 사람인가"를 묻고 있고, 답변은 문서 기반이어야 하면서도 공개 프로필의 맥락을 잃지 않아야 한다.
시간에 따라 변하는 정보는 문서에서 빼냈다
이력서 기반 AI에서 자주 어긋나는 값이 경력 연차다.
문서에는 "14년", "16년차" 같은 정적 표현이 들어갈 수 있다. 하지만 시간이 지나면 틀린 정보가 된다. 문서를 수정하지 않는 한 모델은 오래된 숫자를 계속 인용한다.
그래서 현재 날짜와 경력 계산은 문서가 아니라 runtime context로 분리했다.
현재 날짜: Asia/Seoul 기준 YYYY-MM-DD
경력 시작 연도: 2010년
현재 날짜 기준 계산된 경력: 현재 연도 - 2010
그리고 답변 규칙은 이렇게 잡았다.
경력 연차를 답변할 때는 문서의 정적 표현보다
runtime context의 계산값을 우선 사용한다.
가능하면 "2010년부터 현재까지 약 N년"처럼 기준을 함께 설명한다.
stable한 지식과 volatile한 실행 정보를 분리한 것이다. 모든 정보를 documents table에 넣어두면 편하지만, 시간이 흐르면서 틀어지는 값은 호출 시점에 주입하는 편이 낫다.
공개 페이지에 필요한 최소 운영 장치
작은 기능이어도 공개 페이지에 붙는 AI라면 최소 운영 장치가 필요하다.
이번 재설계에서 넣은 운영 장치는 크지 않다.
IP 기준 요청 제한
IP 기준 동시 스트림 제한
세션별 active run lock
SSE heartbeat
스트림 취소 시 guard release
provider/model/usage/duration 저장
대화가 80,000자 이상 길어졌을 때 새 대화 권장 warning
특히 세션별 active run lock은 중요했다. 같은 thread에서 동시에 두 답변이 생성되면 메시지 순서가 꼬일 수 있다. 그래서 session에 active_run_id, active_run_started_at을 두고, 이미 생성 중인 답변이 있으면 409로 막았다.
모델 메타데이터도 메시지에 남긴다. 어떤 provider와 model이 답했는지, 얼마나 걸렸는지, usage는 어땠는지 남겨야 나중에 품질과 비용을 감으로 보지 않는다.
처음에는 metadata에 대충 넣을 수도 있었다. 하지만 운영에서 반복해서 볼 값은 컬럼으로 빼는 편이 낫다. 최근에는 메시지 metadata를 단순화하고 provider, model, duration_ms, elapsed_ms, usage 같은 필드만 명확히 남기는 쪽으로 정리했다.
걷어낸 것과 넣지 않은 것
이번 작업에서는 오래된 Mastra 기반 career agent 경로를 걷어내고, 기존 어시스턴트를 assistant-agent 독립 런타임으로 정리했다.
Mastra가 나쁘다는 뜻은 아니다. 처음 실험하기에는 좋은 추상화다. 다만 dongjun.win의 요구사항은 작았다.
1. 질문을 받는다.
2. 필요한 문서를 도구로 연다.
3. 답변을 스트리밍한다.
4. 메시지와 도구 결과를 저장한다.
5. UI에 진행 상태를 보낸다.
이 정도라면 AI SDK의 streamText, 작은 tool set, DAO 몇 개로 직접 구성하는 편이 더 설명 가능했다.
걷어낸 것만큼, 일부러 넣지 않은 것도 있다. 대표적으로 컨텍스트 압축이다.
Hermes Agent에서 컨텍스트 압축은 꽤 정교한 기능이다. 오래된 대화를 요약하고, 앞뒤 메시지를 보존하고, role alternation과 tool-call/result 페어를 깨지 않도록 처리해야 한다. 구현할 가치는 있지만, 모든 에이전트에 필요한 기능은 아니다.
dongjun.win의 어시스턴트는 사용자가 하루 종일 붙잡고 작업하는 코딩 에이전트가 아니다. 방문자가 몇 가지 질문을 던지고 떠나는 공개 프로필 기능이다. 그래서 자동 압축 대신 80,000자 이상이면 새 대화를 권장하는 warning만 둔다.
공부한 것을 전부 넣지 않는 것도 설계다. 필요한 전제만 가져오고, 기능은 문제 크기에 맞게 자른다.
최종 구조
결과적으로 남은 흐름은 이렇게 정리된다.
사용자 질문
-> Vue chat store
-> /api/assistant/chat SSE 요청
-> request guard
-> thread/session resolve
-> session run lock
-> user message 저장
-> active config + system prompt 로드
-> runtime context 추가
-> streamText 호출
-> searchDocuments
-> readDocument
-> 최대 5 step
-> assistant/tool messages 저장
-> text-delta/activity 이벤트 스트리밍
-> finish
-> run lock release
제품으로 보면 채팅창 하나다. 내부적으로는 에이전트 런타임의 기본 요소가 거의 다 들어 있다.
stateless LLM 호출
누적 messages
tool calling
tool-call/result 페어 보존
thread/session/message 저장
runtime context
streaming
activity event
request guard
model/usage/duration 관측
규모는 작지만 구조는 작지 않았다.
작게 만들수록 선명해진 것
이번 작업에서 남은 결론은 단순하다.
에이전트는 모델 하나가 아니라, stateless LLM 앞뒤에서 messages와 tools를 정확히 관리하는 런타임이다.
여기서 중요한 단어는 "정확히"다.
messages를 대충 저장하면 기억이 흔들린다. tool-call과 tool-result를 대충 다루면 다음 호출이 깨진다. 문서 접근 경로를 prompt에 묻어두면 느리고 흐려진다. 진행 상태와 최종 답변을 섞으면 UI가 지저분해진다. 시간이 흐르는 값을 문서에 박아두면 답변이 낡는다.
반대로 경계를 잘 나누면 작은 에이전트도 안정적으로 동작한다.
문서는 prompt가 아니라 tool로 연다.
대화 상태는 messages로 복원한다.
현재 시점 정보는 runtime context로 넣는다.
진행 상태는 activity event로 보낸다.
운영 판단에 필요한 값은 컬럼으로 남긴다.
이번 dongjun.win AI는 범용 에이전트가 아니다. 내 문서를 읽고, 내 경력과 프로젝트에 대해 답하는 좁은 에이전트다. 그런데 오히려 그 좁음 덕분에 구조가 선명해졌다.
큰 에이전트를 잘 만들려면 작은 에이전트부터 설명 가능해야 한다. 이번 작업은 그 기준을 맞추는 과정이었다.
www.dongjun.win은 이제 단순한 포트폴리오가 아니라, 내가 최근에 어떤 구조를 공부했고 그것을 제품 안에 어떻게 녹였는지 보여주는 작은 데모가 됐다. 직접 확인해보고 싶다면 AI 어시스턴트 페이지에서 질문을 던져보면 된다.

