Skip to content

beomwon/bythonic

Repository files navigation

Bythonic

면접관의 질문에 대신 답하는 포트폴리오 에이전트

Live License Gemini React TypeScript Vercel


포트폴리오를 읽는 문서가 아니라 질문할 수 있는 인터페이스로 만든 프로젝트입니다.

랜딩 페이지에서 에이전트를 열면 이력·프로젝트·기술 스택을 컨텍스트로 쥔 에이전트가 1인칭으로 답합니다. LLM 래퍼를 하나 붙인 게 아니라, 환각·쿼터 소진·정보 유출이 실제로 일어난다는 전제 위에서 컨텍스트·가드레일·실행 하네스를 각각 설계했습니다.

기술 스택

레이어 사용 기술
에이전트 Gemini API (@google/genai) · SSE 스트리밍 · 모델 폴백 체인 · 출력 필터
런타임 Vercel Functions · Web Standard Request/Response · TypeScript
채팅 앱 React 18 · Vite 멀티 엔트리 · react-markdown + remark-gfm
그래픽 OGL (WebGL 셰이더) · GSAP
랜딩 바닐라 HTML · CSS · JS (의존성 없음)
메일 Nodemailer · Gmail SMTP

에이전트 설계

flowchart TB
    Q([질문]) --> GD{{"가드<br/>오리진 · 레이트리밋 · 길이"}}
    GD -->|차단| B([403 / 429 / 400])
    GD -->|통과| CA{{"응답 캐시<br/>mtime 포함 키"}}
    CA -->|히트| OUT
    CA -->|미스| CTX["시스템 프롬프트 조립<br/>profile.md + library.json"]
    CTX --> CH["모델 폴백 체인<br/>5종 순차 시도"]
    CH -->|전부 실패| DG["안내 메시지로 강등"]
    CH --> FT{{"민감 정보 출력 필터<br/>lookahead 80자"}}
    DG --> OUT
    FT --> OUT([SSE 스트리밍])
Loading

1. 컨텍스트 — 코드가 아니라 파일로

에이전트의 인격·지침·지식이 전부 data/ 두 파일에 있습니다. profile.md# SYSTEM_PROMPT 절이 역할과 금지 사항을, library.json이 경력·프로젝트·기술 스택을 담고, api/_lib/data.ts가 이 둘을 하나의 system instruction으로 조립합니다.

답변을 바꾸는 데 코드를 건드리지 않습니다. 에이전트 동작 수정이 곧 배포라면 빠르게 못 고치고, 결국 안 고치게 됩니다. 조립 결과는 파일 mtime을 키로 캐싱해 파일이 바뀐 순간에만 다시 만듭니다.

2. 가드레일 — 프롬프트를 믿지 않는 2단 방어

프로필에는 실명·연봉·법인명처럼 노출되면 곤란한 정보가 섞여 있습니다. 프롬프트 지침만으로는 부족하다고 보고 출력단에도 필터를 겁니다.

단계 위치 내용
1차 시스템 프롬프트 1인칭 고정, 추측·창작 금지, 연봉은 "협의 희망"으로만, 법인명 비공개
2차 스트리밍 출력 급여 표현·법인명 패턴을 정규식으로 치환 (sensitive.ts)

2차 필터의 문제는 스트림 청크 경계입니다. "연봉 3" / ",500만원"으로 쪼개져 도착하면 패턴이 매칭되지 않습니다. 그래서 항상 뒤쪽 80자를 흘려보내지 않고 버퍼에 남긴 뒤, 다음 청크가 붙은 상태에서 필터를 적용합니다 (pipeFiltered).

연락처처럼 "모르면 지어내는" 게 가장 위험한 항목은 프로필에 있는 값만 답하고 없으면 없다고 말하도록 명시했습니다.

3. 하네스 — 실패를 정상 경로로 취급

무료 티어는 모델당 일일 요청 수가 제한됩니다. 한 모델에 의존하면 그날 채팅이 죽고, 포트폴리오에서 그건 곧 면접관이 빈 화면을 보는 것입니다.

gemini-3-flash-preview → gemini-3.5-flash → gemini-3.1-flash-lite
→ gemini-2.5-flash-lite → gemma-4-31b-it
  • 품질 순 폴백 — 쿼터를 모델별로 나눠 쓰면서 가능한 한 좋은 모델부터 시도합니다.
  • 부분 출력 후에는 폴백하지 않습니다. 이미 사용자 화면에 토큰이 나간 뒤 모델을 바꾸면 같은 문장이 두 번 찍힙니다. 방출 여부를 추적해 그 경우엔 폴백을 포기합니다.
  • 전부 실패해도 에러를 던지지 않습니다. 쿼터 소진이면 사유와 복구 시점(KST 자정)을 안내하고 라이브러리 탭으로 유도합니다. 실패를 화면에서 지우는 게 아니라 설명 가능한 상태로 강등시킵니다.
  • Live API 경로를 WebSocket으로 열어두고 REST로 폴백하도록 구성해 뒀습니다. 현재 무료 Live 모델이 전부 오디오 전용이라 비활성 상태고, 텍스트 지원 모델이 나오면 환경 변수만 채우면 켜집니다.

응답 캐시는 첫 턴(대화 이력 없음)에만 겁니다. 맥락에 따라 답이 달라져야 하는 후속 질문을 캐싱하면 틀린 답을 재사용하게 되니까요. 캐시 키에 데이터 파일 mtime을 섞어서 프로필을 고치면 이전 캐시가 자동으로 무효화됩니다.

4. 남용 방지

공개된 엔드포인트가 남의 LLM 놀이터나 스팸 릴레이가 되지 않도록 api/_lib/guard.ts에서 공통 처리합니다.

/api/chat /api/contact
오리진 검증
IP 레이트리밋 30회 / 10분 3회 / 1시간
입력 길이 제한 2,000자 · 이력 40턴 제목·회사·연락처 100자, 본문 4,000자
허니팟
헤더 인젝션 차단

오리진 검증은 배포 도메인을 하드코딩하지 않고 요청의 Host와 비교합니다. 프로덕션·프리뷰 배포·로컬이 별도 설정 없이 통과하고, 커스텀 도메인만 환경 변수로 더합니다.

대화 이력은 초과해도 거절하지 않고 최근 40턴만 남깁니다. 길어졌다는 이유로 대화가 끊기면 안 되니까요. 허니팟에 걸린 요청은 성공 응답을 돌려주고 조용히 버립니다 — 실패를 알려주면 봇이 우회를 시도합니다.

Warning

레이트리밋은 인스턴스 메모리에 저장합니다. 서버리스는 인스턴스가 여러 개 뜨고 콜드 스타트마다 초기화되므로 정확한 전역 제한이 아니라 최선 노력 방어입니다. 스크립트 한 대의 반복 호출은 막지만 분산 공격을 막으려면 외부 저장소가 필요합니다.

구현 노트

한 프로젝트에 두 개의 앱 — 랜딩(index.html)은 의존성 없는 정적 페이지, 채팅(chat.html)은 React 앱입니다. Vite 멀티 엔트리로 함께 빌드하고, 랜딩에서는 iframe 위젯으로 띄웁니다. 정적 페이지에 React 번들을 지우지 않으려고 분리했습니다.

로컬에서 Vercel Functions 재현vite.config.tsvercelApiDev 플러그인이 /api/* 요청을 가로채 api/*.ts를 SSR 로드하고, 표준 Request를 만들어 핸들러를 호출한 뒤 응답을 스트리밍합니다. SSE가 버퍼링되지 않도록 청크마다 flush합니다. 프로덕션과 같은 코드가 개발 환경에서도 그대로 돕니다.

정적 파일이 함수에서도 읽혀야 합니다data/는 프론트 번들이 아니라 서버리스 함수가 파일 시스템에서 직접 읽습니다. vercel.jsonincludeFiles로 함수 번들에 포함시키고, mtime 비교로 warm 인스턴스에서 재파싱을 건너뜁니다.

API

메서드 경로 설명
POST /api/chat 스트리밍 채팅 (SSE)
GET /api/profile 경력 · 프로젝트 · 기술 스택
GET /api/faq profile.md의 FAQ 파싱 → 추천 질문
GET /api/images 프로젝트 이미지 목록
POST /api/contact 문의 메일 발송
GET /api/health 상태 확인

라이선스

소스 코드는 MIT입니다. data/의 개인 정보와 제3자 에셋(아이콘·로고)은 적용 대상이 아닙니다 — THIRD-PARTY-NOTICES.md를 확인해주세요.

이 프로젝트는 Google LLC와 제휴·후원·승인 관계가 없습니다.

Releases

Packages

Contributors

Languages