2025.03.24 (월) 을 시작으로 교육 및 단기 프로젝트로 이루어진 4개월과 최종 프로젝트 2개월, 총 6개월 간의 긴 여정이 2025.09.15 (월) 을 기점으로 끝이 났다. 이번 블로그에서는 최종 프로젝트를 진행하면서 배운 점과 느낀 점을 마지막으로 돌이켜보며 SKN 시리즈를 마무리하고자 한다 !
SKN Family AI 캠프는 특이하게도 최종 프로젝트에 있어서 그 큰 틀의 주제를 선정해주었다. 여러 주제 중 우리 조가 투표한 것은 아래와 같았다.
1) 자체 sLLM 개발 통한 기업 업무 활용 생성형 AI 플랫폼
2) LLM 활용 인공지능 인플루언서 만들기
3) LLM 활용 내부 고객 업무 효율성 향상을 위한 문서검색 시스템
그 중 가장 감사하게도 우리 조는 가장 경쟁이 치열했던 "자체 sLLM 을 통한 기업 활용 플랫폼" 주제를 맡게 되었다 !
그렇게 여러 번의 상의 끝에 결정된 세부 주제는 생성형 AI 를 자동차 디자인에 접목시키고자하는 "자동차 디자이너를 위한 프로토타입 이미지 생성 플랫폼__JJACKLETTE" 이었다.
|
|
-------------------------------------<JJACKLETTE 메인 홈페이지>----------------------------------------
메인이 이미지 생성 모델이 되며 기존 주제에서 조금 벗어난 감이 없지 않았지만 잘 엮으면 sLLM 을 파인튜닝함과 동시에 멀티모달을 사용해 볼 수 있겠다는 기대에서 진행되었다.
조금 욕심을 부려 우리 조는 sLLM 과 이미지 생성 모델을 제외하고도 이를 더 확장시켜 image to 3D, image to 4D 까지 사용하기로 하였으며 이미지 생성 모델 파인튜닝 데이터 셋을 구성하기 위해 별도의 image to text 캡셔닝 모델까지 사용하기로 하였다.
그렇게 굉장히 무거워진 우리의 프로젝트....
덕분에 굉장히 많은 토론을 통해 의견을 좁혀가고 높은 퀄리티를 뽑아낼 수 있었던 것 같다.
이번 두 달간의 프로젝트를 진행하는데에 있어 거의 1/3 의 시간은 회의에 사용했을 정도로 많은 토론과 합의가 오갔다.
정말 많고 다양한 의견이 있었고 서로의 의견이 다를 때도 있었지만 팀원 모두가 열린 시선으로 제시된 아이디어를 봐주고, 아이디어에 대해 깊게 구체화된 생각을 공유하며 진행하니 수많은 회의에도 별다른 충돌은 없었던 것 같다.
그 예시들은 다음과 같다.
자동차 디자이너가 우리 플랫폼을 사용하려면 어떤 UserScenario 가 발생할까 ? 에서 출발한 물음은 sLLM 의 Multi-Turn 진행 , 별도의 체크리스트 를 통한 디자인 요소 구체화 , 이를 채우기 위한 각 요소별 step-by-step 은 파인튜닝된 우리의 sLLM 과 사용자의 Interaction 인 Human in the Loop 등 다양하고 구체적인 아이디어로 뻗어나가게 되었다.
그 중 Multi-Turn 의 경우 예상 타겟인 자동차 디자이너의 특성에 따라 여러 질문을 통해 여러가지의 초기 이미지 생성 및 수정, 이후 해당 이미지를 이용한 3D / 4D 영상 생성의 시나리오가 예상되었기에 구현이 필수적이었다.
기업을 대상으로 한 우리는 온프레미스 플랫폼 구축이 하나의 큰 목표로 자리잡고 있었고, 이를 위해서 고작 8B 의 kanana 모델을 사용하기로 한 우리는 그런 방대한 Multi-Turn 을 sLLM 에 맡길 수 없었고 각 과정을 쪼개 Rule-Based 와 sLLM 을 통한 별도의 라우팅 노드를 구현하여 해결하게 되었다.

--------------------------------<단계별 sLLM 용 의도 분류 프롬프트>----------------------------------
디자이너라면 분명 자동차의 여러 부분에 대한 세세한 터치를 원할텐데 이미지 생성 모델에는 너무나 큰 창의성이 주어져있다는 것 역시 우리가 마주한 문제 중 하나였다.
이에 우리는 디자인적으로 유효하고 차별성이 있는 큰 10개의 카테고리를 선정하고 세부 항목들을 정해 하나의 큰 디자인 체크리스트 를 만들어 해결하고자 하였다.
그렇게 만들어진 디자인 체크리스트 는 아래 예시처럼 체크박스로 이루어진 뷰포인트 부터 차량의 크기 등급, 차종, n-box 형식 등을 담은 차체 분류 등 총 30개의 요소로 이루어져 있다.
이 과정을 통해 디자이너의 세부적인 요청을 이미지에 반영함과 동시에 우리는 각각의 요소에 대해 파인튜닝된 sLLM 이 디자이너와 상호작용하며 체크리스트를 채워나가는 긴 호흡의 챗봇을 구현할 수 있었다.
------------------<사용자의 대화가 디자인 체크리스트에 실시간으로 반영되는 모습>----------------
일반 대화, 디자인적인 질문, Multi-Turn 으로 이루어진 체크리스트 기반 프로토타입 이미지 생성, 이미지 기반 3D / 4D 생성 의 여러 기능을 갖춘 우리의 챗봇 페이지는 정말 다양한 루트의 UserScenario 를 가지고 있었다.
이와 더불어 앞서 설명한 긴 호흡의 Multi-Turn 을 구현하기 위해서는 중간 중간 흐름을 멈춰 사용자와 상호작용하는 Human in the Loop 가 필수적이었다.
1) 에서 설명한 라우팅과 동시에 그때그때마다 LangGraph 의 interrupt 기능을 이용, 별도로 정해진 프롬프트를 생성해 내도록하여 이를 구현해내었다.
|
|
-----------------------------------<가장 첫 두 단계의 Routing Node>----------------------------------
그렇게 개개인의 디자이너와 천천히 상호작용하며 개인의 취향을 반영한, 그럼에도 불구하고 타겟 기업의 특색을 반영한 프로토타입 이미지를 생성해주는 우리의 플랫폼 JJACKLETTE 가 탄생하게 되었다.
처음 내가 상상했던 것보다 굉장히 길고 지루하지만 중요한 작업이 데이터 수집과 전처리 과정이었다.
sLLM 의 파인튜닝을 통해 챗봇을 구현하는 우리는 기본적으로 1) sLLM 파인튜닝용 데이터, 2) 챗봇 RAG 용 데이터, 3) 홈페이지용 데이터가 필요했다.
-----------------------------------------<데이터 수집 관리표>------------------------------------------
가장 먼저 우리의 메인이 될 sLLM 에게 먹일 먹이에 대해 많은 생각을 해보았다.
파인튜닝을 통해 우리가 결국 얻고 싶은 것은 ?
목표가 되는 기업에 대한 특화 지식과 디자인 분야에 대한 전문적인 지식
이를 이루기 위해 디자인적인 요소에 대한 여러 논문이나 우리가 타겟으로 한 현대 자동차의 디자인 철학이 담긴 여러 포럼등을 수집한 뒤, 이를 전처리하는 과정을 거쳤다.
파인튜닝을 위한 QA 쌍으로 이를 변환시키기 위해서는 그 맥락을 해치지 않았어야 했고 이를 위해 각각의 데이터 셋마다 별도의 1차 chunking 과정을 거치게 되었다.
디자인 철학 관련 문서 - 주제별
리뷰 및 관련 포럼 - 각 기사별
디자인 관련 논문 등 - 단원별
그 과정에서 수집한 데이터의 확장자에 맞는 처리는 당연하게도 필수적이었다.
|
|
----------------<pytesseract 를 이용한 OCR>----------------------<pypdf 를 이용한 PdfReader>----
sLLM 을 파인튜닝하기 위해 초기에 선택한 데이터 구성 방식은 일반 QA 쌍이었다. 하지만 그렇게 구성한 일반 QA 쌍으로 파인튜닝한 sLLM 은 우리 생각만큼 해당 지식을 잘 학습하지 못하였다.

--------------------------------------------<일반 QA 쌍>-------------------------------------------------
초기 Multi-Turn 을 구현하기 위해 여러 QA 를 연달아 넣어놓은 모습.
여기서의 문제점은 겹치지 않게끔 QA 를 구성하였음에도 하나하나의 QA 가 너무 많은 양을 담고 있다는 것. 그리고 너무나 낯선 내용을 한 번에 배우도록 시킨 것.
그래서 우리는 이를 개선해 QA 길이를 짧게하고 별도의 context 를 주어 답을 외우는 것이 아니라 답을 찾아가도록 만들었다.
이와 더불어 positive context 와 negative context 를 주고 이 중 올바른 context 의 번호를 별도로 주어 강화학습의 효과를 기대하고자 하였다.

----------------------------<Positive / Negative context 가 주어진 QA 쌍>-----------------------------
해당 QA 추출은 캠프에서 지원해 준 gpt 모델을 사용하여 진행하였으며 해당 데이터셋을 이용한 파인튜닝의 테스트 결과는 후에 서술된다.
우리의 주제 자체가 "sLLM 의 파인튜닝" 이었기에 굳이 RAG 구현이 필요한가 싶었지만 우리는 RAG 를 다른 목적으로 사용하기로 하였다.
RAG 용 데이터 : sLLM 모델이 기본적으로 모르고 있어도 되는, 그렇지만 사용자가 물어볼 때 답할 수 있어야 하는 정보들.
우리는 개별 차종에 대한 사용자의 수많은 리뷰 데이터와 차종별 각종 포럼 및 뉴스들, 그리고 타겟 기업이 아닌 다른 기업의 자동차에 대한 내용을 여기에 담기로 하였다.
위와 동일한 방식으로 chunking 을 진행한 뒤, 각각의 문서를 QA 쌍으로 뽑아내는 대신 개별 데이터 포인트로 만들어 VectorDB (Qdrant) 에 올리는 내용은 지난 단기 프로젝트에서 이미 해본 내용이라 넘어가도록 하겠다.
의욕 넘치던 우리, 초기에는 sLLM, 이미지 생성 모델(이하 FLUX), 3D 모델, 4D 모델 모두 파인튜닝을 하고자 하였지만 이를 위한 RunPod 비용 문제와 시간적인 한계로 프로젝트의 핵심이 되는 sLLM 과 FLUX 만을 파인튜닝하기로 결정하였다.
우리는 sLLM 모델의 추론 능력을 높이고자 positive / negative contexts 를 가진 QA 쌍을 구성하였지만, 실제 우리가 사용할 sLLM 은 결국 사용자의 Question 에 대한 Answer 만을 뱉는 것이 목표였기에 파인튜닝 이전에 위 QA-context 쌍을 합치는 과정이 필요했다.

이처럼 각 컬럼을 하나의 text 로 합쳐 우리가 파인튜닝 시킨 최종 sLLM 모델은 사용자의 질문과 유사한 정답 내용을 추론하여 뽑아낼 수 있게 될 것이다.
그렇게 최적의 하이퍼 파라미터를 찾아 파인튜닝한 모델의 평가 결과는....!
| 지표 | Base 모델 | Finetuned 모델 | 개선 폭 (Δ) |
|---|---|---|---|
| 🎯 Precision | 0.7611 | 0.9130 | +0.1519 |
| 🔍 Recall | 0.8491 | 0.9206 | +0.0714 |
| 🧮 F1 Score | 0.8011 | 0.9161 | +0.1150 |
| 🧠 문맥 적합도 (CtxAcc) | 0.8097 | 0.9965 | +0.1869 |
예상보다 훨씬 좋은 결과를 얻을 수 있었다 !!
우리의 파인튜닝 모델은 질문에 따라 적절한 문맥을 참고하는 법을 배웠으며, 이를 토대로 사용자가 디자인적인, 혹은 현대 자동차에 관련된 질문을 하였을 때 압도적으로 좋은 답변을 보이고 있었다 !
이전 일반 QA 쌍으로 진행한 파인튜닝의 경우 F1-Score 가 고작 0.01~ 언저리로 상승한 것에 비교해보면 그야말로 압도적인 수치이다.
위 sLLM 과 별개로 진행된 이미지 생성 모델 역시 기업의 특색 자체를 학습하기 위해 현대 자동차의 여러 자동차 및 컨셉차를 토대로 파인튜닝을 진행하였으며 그 정량적인 결과는 아래와 같았다.
| 📊 지표 | Base 모델 | Base + LoRA adapter | 개선 폭 (Δ) |
|---|---|---|---|
| 🎨 FID score | 66.35 | 52.42 | -13.93 ↓ (개선) |
| 🧠 GPT-score 지표 | Base 모델 | Base + LoRA adapter | 개선 폭 (Δ) |
|---|---|---|---|
| 💬 프롬프트 충실도 | 3.28 | 3.82 👍 | +0.54 |
| 🎨 디자인 품질 | 4.00 | 4.18 👍 | +0.18 |
| 🏷 브랜드 정체성 | 4.18 | 4.27 👍 | +0.09 |
| 🖼 이미지 품질 | 4.64 | 4.55 👍 | -0.09 (약간↓) |
동시에 진행한 정성적인 평가 결과 파인튜닝 이후 FLUX 는 "SUV" 나 "angular grille" 과 같은 디자인적인 요소들을 반영하여 이미지를 생성할 수 있게 되었으며 조금 더 현대 자동차스러움을 갖추게 되었음을 확인할 수 있었다.
외에도 image to 3D / image to 4D 모델 역시 별도의 파인튜닝을 통해 현대 자동차의 특색을 담을 수 있도록 하고 싶었지만 RunPod 비용의 한계로 진행하지 못하였다....
내가 이번 프로젝트에서 메인으로 맡은 부분은 다름아닌 React 를 이용한 프론트엔드였다. AI 캠프의 특성상 가장 선호하는 사람이 없을법도 하였고, 이번 기회에 프론트엔드가 어떻게 굴러가는지 체험해보자 ! 라는 생각에서 이 파트를 맡게 되었다.
my-app/
├── 📁 public/ # 정적 파일 - 서빙용
│ └── index.html
├── 📁 src/ # 실제 코드 작성 폴더
│ ├── 📄 main.jsx # 진입점
│ ├── 📄 App.jsx # 최상위 컴포넌트
│ ├── 📁 components/ # 재사용 컴포넌트 - Header, Footer 등
│ │ ├── Header.jsx # 최상단 Navigator 부분
│ │ ├── Footer.jsx # 최하단 Foot 부분
│ │ └── etc...
│ ├── 📁 pages/ # 실제 페이지들
│ │ ├── Home.jsx # HomePage
│ │ ├── AssetLibrary.jsx # AssetPage
│ │ ├── InsightTrends.jsx # InsightPage
│ │ ├── PrototypeLab.jsx # ChatbotPage
│ │ └── etc...
│ ├── 📁 services/ # 페이지를 구성하는 함수들
│ │ ├── authService.js #
│ │ ├── chatService.js
│ │ ├── unsplashService.js
│ │ └── etc...
└── 📁 node_modules/ # 설치된 패키지
내가 사용한 리액트의 기본 구조는 위와 같았다. 기본적으로 src 디렉토리 내에 기본 components 와 services 를 구성해놓은 뒤 pages 에서 이를 가져다가 전체 페이지를 구성하는 방식이었다.
그 중 단언컨대 챗봇을 이루는 chatService.js 와 PrototypeLab.jsx 가 가장 복잡했는데.... 그 내용을 간단히 살펴보자
| chatService.js 내 함수명 | 간단 설명 |
|---|---|
generateUniqueId | 고유 ID 생성 |
handleStreamingResponse | 스트리밍 응답 처리 |
getChatSessions | 챗봇 세션 목록 불러오기 |
createChatSession | 새 챗봇 세션 생성 |
updateSessionTitle | 세션 제목 수정 |
deleteChatSession | 챗봇 세션 삭제 |
getPromptLogs | 프롬프트 로그 조회 |
createPromptLog | 프롬프트 로그 생성 (사용자 입력 + AI 응답 저장) |
getGeneratedResults | 생성된 결과 목록 불러오기 |
createGeneratedResult | 생성 결과 저장 |
sendChatMessage | 챗봇 메시지 전송 (AI 응답 스트리밍 지원) |
generateMockResponse | 목업 응답 생성 (테스트용) |
updateChatHistory | 스트리밍 종료 후 대화 기록 업데이트 |
프로그래밍의 시작부터 쭉 파이썬과 함께한 나에게 JavaScript 의 화살표 함수와 세미 콜론은 너무나 낯선 존재였다. 본래도 파이썬 이후 다른 언어를 공부한다면 JavaScript 를 공부하고 싶었는데 이번 기회에 정말 많이 공부하고 접하고 쓰게 되어 많은 보람을 느낄 수 있었다.
그렇게 땀과 눈물로 작성된 코드를 구경해보자~!
// 챗봇 메시지 전송 (AI 응답 시뮬레이션)
export const sendChatMessage = async (sessionId, message, userId, checklistData = {}, completionStatus = {}) => {
try {
const requestBody = {
user_id: userId,
message: message,
checklistData: checklistData,
completionStatus: completionStatus
};
const response = await apiRequest(`${API_BASE_URL}/chat/sessions/${sessionId}/message/`, {
method: 'POST',
body: JSON.stringify(requestBody)
});
if (response.ok) {
const data = await response.json();
return {
success: true,
response: data.reply, // config/views.py에서 reply로 반환
generatedResults: data.generated_results || [],
completionStatus: data.completion_status || null, // 체크리스트 완성도 정보
checklistData: data.checklist_data || {},
intent: data.intent || '',
isFormComplete: data.is_form_complete || false,
imageQuery: data.image_query || '',
isJson: true,
auto_retry: data.auto_retry || false, // auto_retry 플래그 추가
generation_type: data.generation_type || '' // generation_type 추가
};
}
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('❌ Django 서버 연동 실패:', error);
throw error;
}
};
위 코드는 현재 사용자의 세션 ID, 사용자 입력, userId 와 우리 프로젝트의 핵심인 체크리스트 관련 데이터를 API Endpoint 에 넘겨 답변을 받고, 넘겨받은 형식에 맞게끔 변수명을 재할당하여 return 해주는 메인 메세지 처리 함수이다 !
이렇게 정의된 함수는 export 되어 메인 챗봇 페이지인 PrototypeLab.jsx 에서 마음껏 사용하게 된다.
거의 JavaScript 를 처음 이용하다보니.... 간단한 형태에 내용은 어지러운 구성이지만 이렇게 JavaScript 를 이용해 구현가능한 코드를 작성하는 것만으로 굉장히 뿌듯함을 느낄 수 있었다.
외에도 색다른 경험을 할 수 있었는데 그건 바로 API 정의서 에 대한 내용이다 !
API 정의서 란 프론트-백 간의 호출 형식에 대한 약속으로 정해진 url, 사용할 파라미터, 넘겨줄 input / output 형식을 나타내는 문서이다.
# API 정의서 목차 일부분 발췌
**챗봇 시스템 API 요약**
2. **Chat_session 테이블 관련 API**
- 2.1 유저별 챗봇 세션 조회: `GET /chat/sessions/` - 페이지네이션 지원
- 2.2 챗봇 세션 생성: `POST /chat/sessions/` - 새 세션 생성
- 2.3 세션 제목 수정: `PUT /chat/sessions/{session_id}/title/` - 세션 제목 변경
- 2.4 챗봇 세션 종료: `PUT /chat/sessions/{session_id}/end/` - 세션 종료
3. **Prompt_log & Generated_result 통합 API**
- 3.1 세션별 프롬프트 로그 조회: `GET /chat/sessions/{session_id}/prompts/` - 결과 포함
- 3.2 프롬프트 로그 생성: - AI 응답 및 결과 저장 / Django 내부 처리
- 3.3 챗봇 메세지 생성: `POST /chat/sessions/{session_id}/message/` - 챗봇 응답 생성
- **통합 특징**: 프롬프트와 생성 결과를 하나의 API로 관리 (result_type: text, image, 3d, 4d)
우리 프로젝트에서는 React - Django 간 총 24개의 API 를 연결하였다 !
정해진 API 정의서를 기준으로 React 와 Django 에서 공동작업하는 경험은 공유 문서의 중요성을 일깨워주고 협업의 느낌을 살려주는 유익한 경험이었다.
이렇게 정의된 sendChatMessage 함수는 별도의 세션 처리 함수 및 로그 저장 함수, 결과 렌더링 함수 등과 묶여 handleSendMessage 함수로 정의되고 이는 export 되어 챗봇 페이지에서 아래와 같이 사용된다.

-------------------------------------------<채팅 전송 버튼>----------------------------------------------
이로써 사용자가 화면의 전송 버튼을 클릭 -> React 에서 사용자의 여러 정보를 종합해 API 호출 까지 연결이 된 것이다.
그렇게 React 가 호출된 API 로부터 응답을 받으면 이를 화면에는 어떻게 띄우느냐 ?!
React 의 알파이자 오메가. 상태와 상태변환함수.
React 는 별도의 "상태(state)" 를 통해 내부 데이터를 관리하며 이를 변환시키는 별도의 "상태변환함수(setState)" 를 가진다.
이는 실시간 리렌더링을 위한 React 의 핵심 기능으로 상태 가 바뀔 때마다 React 는 해당 컴포넌트를 리렌더링하게 된다.
따라서 PrototypeLab.jsx 에 있는 채팅 기록 부분에 메세지를 담는 특정 상태 를 넣어놓고, API 호출 이후 응답을 상태변환함수 를 이용해 변환하면 자동으로 화면에서 리렌더링이 이루어진다는 이야기이다. 코드를 보자.
import { useState } from React; // state / setState 지정함수
const [ messages, setMessage ] = useState([]); // 상태 및 상태변환함수 지정
{/* Chat Container - 메시지와 입력창을 하나로 통합 */}
<div className="flex-1 px-8 pb-4 relative">
{/* Chat Messages - 백그라운드 위에 직접 배치 */}
<div className="relative z-10 h-full mb-40">
<div className="space-y-6 px-4 h-[calc(100vh-200px)] overflow-y-auto pb-20">
// 이 부분이 계속 리렌더링 !!
{messages.map((message, index) => renderMessage(message, index))}
<div ref={messagesEndRef} />
</div>
</div>
</div>
불필요한 부분은 제거하여 간소화하였다. 위 handleSendMessage 함수를 통해 챗봇 응답 결과를 messages 객체에 추가하면 저 부분이 재실행되어 renderMessage , 해당 응답을 담은 채팅 박스가 생성되어 화면에 보이게 되는 것이다.
이러한 useState 외에도 React 에는 특정 시점에 대해 실행 훅을 거는 useEffect , 변하지 않는 값의 useRef 등 배울 요소들이 굉장히 많아서 재미를 느낄 수 있었다 !
채팅 외에도 생각외로 고전을 면치못한 파트가 바로 체크리스트의 실시간 연동이었다.
앞서 말했듯 여러 UserScenario 에 의해 우리의 디자인 체크리스트 는 디자이너가 직접 입력을 할 수도, 혹은 우리의 챗봇 과의 상호작용을 통해 챗봇이 스스로 입력을 할 수도 있는 데이터이다.
이는 전체 프로젝트에 걸쳐 python 의 딕셔너리, javascript 의 object 데이터로 구현되었다.
그러나 채팅 API 의 경우와 다르게 체크리스트의 실시간 연동은 프로젝트의 가장 마지막 단계에서 구현되어 전체 프로젝트의 흐름을 따라가며 일일이 수정하는 과정을 거쳤는데 우리의 프로젝트는 시간 상의 이유로 구조 최적화를 거치지 못하였고.... 덕분에 그 과정이 굉장히 굉장히 복잡하고 불필요한 과정도 많았다.
아래는 화면상의 채팅 입력부터 그 내용이 가장 뒷단의 이미지 생성 모델에 전해지기까지의 일련의 과정이다.
| 파일명 / 위치 | 구분 | 함수명·요소 | 설명 |
|---|---|---|---|
| PrototypeLab.jsx | React(프론트엔드) | <form onSubmit=...> | 채팅 전송 버튼 |
| PrototypeLab.jsx | React(프론트엔드) | handleSendMessage() | 전체 메세지 처리 |
| chatService.js | React(프론트엔드) | sendChatMessage() | 오직 메세지만 |
| urls.py | Django(백엔드) | 'api/pipeline/chat/' | API url 매핑 |
| views.py | Django(백엔드) | chatbot_api() | 파이프라인 호출 |
| services.py | Pipeline(파이프라인) | BabsimPipelineService.process_query() | 전체 파이프라인 |
| text_pipeline_full_v2.py | Pipeline(파이프라인) | build_query_from_history() | Chat History 생성 |
| image_query_generator.py | Pipeline(파이프라인) | generate_image_query_from_checklist() | 새로운 Query 생성 |
| image_generator.py | Pipeline(파이프라인) | generate_image() | Runpod API 호출 |
| ( Runpod Endpoint ) | API 호출 | 서버에서 로딩되어있는 모델로 처리 | - |
| llm_provider.py | Pipeline(파이프라인) | generate_vllm_response_text() | 이미지 설명 추가 |
이 과정에서 체크리스트에 해당하는 데이터는 전송 데이터의 metadata 로써 끼워넣어져 전달된다.
프로젝트의 전체 흐름은 최대한 간결하고 유지 보수가 쉽게끔..... 짜자....
위 전 과정을 따라가며 프론트엔드에 체크리스트를 반영하는 함수부터 응답으로 체크리스트를 넘겨주고 그걸 또 받아서 넘겨주고 또 받아서 넘겨주는 과정은..... 오류 한 번 나는 순간 눈을 질끈 감게 된다. 하지만 끝내 해냈으니 좋은 경험....!
사용자가 직접 보는 화면에서부터 프론트엔드가 어떻게 이루어지는지 확인했으니 이제 백엔드 부분을 살펴보자. 연결고리가 되는 Django 는 제외하고 파이썬의 LangGraph 를 이용한 핵심 파이프라인만 보겠다.
우리 프로젝트의 전체 파이프라인 및 UserScenario 는 아래와 같다.
|
|
잘 보이진 않지만 굉장히 복잡한 그래프 구조와 함께 멀티턴을 구현하기 위해 앞뒤로 쉴새없이 이동하는 사용자 시나리오를 확인할 수 있다.
그 중 전체 파이프라인의 경우 이를 따라 개인 사용자가 긴 호흡으로 따라 내려오게끔 하기위해 우리는 LangGraph 를 이용하였다.
|
|
|
----------------------------<LangGraph 를 이루는 Node 와 Edge 들.....>-------------------------------
복잡한 파이프라인을 구현하려다보니 그래프에 노드와 엣지를 추가하는 과정만으로 위처럼 한눈에 들어오지 않는 양이 만들어지게 되었다....
LangGraph 는 "state" 를 이용하여 데이터를 저장, 파이프라인을 넘나들게 된다.
그럼 state 에는 어떤 데이터를 담아야 하느냐 ?
class PipelineState(TypedDict, total=False):
user_query: str # 사용자 입력
initial_intent: str # 1단계 의도 분류 결과
response: str # 챗봇 응답
chat_history: List[Dict[str, str]] # 누적 채팅
messages_summarized: bool # 요약본
image_query: str # 이미지 생성 Query
image_mode: str # 2단계 의도 분류 결과
is_form_complete: bool # 체크리스트 완성 여부
completion_status: Dict[str, Any] # 요소별 체크리스트 완성 여부
checklist_data: Dict[str, Any] # 체크리스트 데이터
pipeline_step: str # 현재 파이프라인 단계
current_field: Optional[str] # 질문중인 카테고리, 11개
current_field_conversation: Optional[str] # 현 카테고리 내 누적 채팅
waiting_node: Optional[str] # 이후 복귀할 노드
modification_request: Optional[str] # 2단계 의도 분류 결과
generated_image: Optional[str] # 생성된 이미지
image_generation_status: Optional[str] # 이미지 생성 완료 여부
generation_type: Optional[str] # 프롬프트 - 이미지, 3D, 4D 구분용 (기존 image_type)
error: Optional[str] # 에러
answer_type: Optional[str] # 답변 타입 - text, 이미지, 3D, 4D
s3_url: Optional[str] # 생성된 이미지 S3 url
s3_url_3d: Optional[str] # 생성된 3D S3 url
s3_url_4d: Optional[str] # 생성된 4D S3 url
session_id: Optional[uuid4] # 현재 세션 id
user_id: Optional[str] # 사용자 ID
available_images: Optional[List[Dict[str, Any]]] # 선택 가능한 이미지 목록
selected_image_info: Optional[Dict[str, Any]] # 선택된 이미지 정보
# RAG 처리 관련 필드들
rewritten: Optional[bool] # 재작성 여부
eval: Optional[Dict[str, Any]] # 평가
route: Optional[str] # 라우팅
# 스트리밍 관련 필드
is_streaming: Optional[bool] # 스트리밍 여부
streaming_id: Optional[str] # StreamingHttpResponse ID
is_loading: Optional[bool] # 로딩 여부
시간상의 이유로 최적화를 하지못한 우리의 파이프라인 state.... 정말 어지럽긴하다.
그때그때 필요에 의해 데이터 항목을 추가하기만 하다보니 나중에는 걷잡을 수 없이 코드가 어지러워 진다는 것을 깨닫게 되는 정말이지... 귀중한 경험이었다.
그 중 간단한 노드를 하나 살펴보자.
from .components.image_generator import ImageGenerator
_image_generator = ImageGenerator()
# Generate
def run_image_generation(state: PipelineState) -> Dict[str, Any]:
"""
ImageGenerator 인스턴스를 사용하여 이미지 생성
"""
# 이미지 생성
response, s3_url = _image_generator.generate_image(state)
# 멀티턴을 위해 대화 기록 업데이트
updated = chat_manager.add_message(state.get("chat_history", []), "user", state["user_query"])
updated = chat_manager.add_message(updated, "assistant", response)
return {**state, "chat_history": updated, "response": response, "s3_url": s3_url, "generation_type": "image"}
별도의 image 생성 인스턴스를 불러와 이미지를 받아 이를 다음 노드에 전해주는 과정을 볼 수 있다.
이렇게 작성된 우리의 전체 파이프라인 코드는 약 1500 줄 ! 물론 코어가 되는 컴포넌트들은 별도의 파일로 제외하고이다.
그 중 핵심이 되는 컴포넌트인 이미지 생성 파일을 살펴보자.
class ImageGenerator:
~~~
def generate_image(self, state_or_prompt, session_id: str = None, user_id: str = 'anonymous_user'):
"""RunPod을 통해 이미지 생성 모델에 연결하여 이미지 생성하고 S3에 업로드
state 또는 prompt를 받아서 처리"""
try:
# state인지 prompt인지 확인
if isinstance(state_or_prompt, dict) and 'image_query' in state_or_prompt:
# 기존 방식: state를 받는 경우
state = state_or_prompt
image_query = state.get("image_query", "")
# 이미지 쿼리를 영어로 번역 (한글이 포함된 경우)
translated_query = self._translate_to_english(image_query)
# RunPod 이미지 생성 API 호출
s3_url = self._call_runpod_image_api(translated_query)
# 이미지 쿼리에 대한 상세 설명 생성
detailed_description = self._generate_image_description(image_query)
return detailed_description, s3_url
마찬가지로 간소화하여 나타내었다.
이는 디자이너와 우리의 sLLM 이 만들어 나간 디자인 체크리스트 를 기반으로 한 이미지 생성용 Query 를 받아 이를 영문으로 번역, 이미지를 생성받고 이에 대한 설명을 추가하는 컴포넌트 핵심 메소드이다.
| 컴포넌트명 | 간단 설명 |
|---|---|
| answer_evaluator.py | 모델이 생성한 답변을 평가(정확도, 적합성 등) |
| babsim_rag_adapter.py | Babsim 파이프라인에서 RAG(Retrieval-Augmented Generation) 연결 담당 |
| chat_manager.py | 세션/대화 상태 관리, 히스토리 저장/로드 |
| checklist_generator.py | 체크리스트 형태의 프롬프트나 태스크 리스트 생성 |
| content_router.py | 입력 콘텐츠를 분류해 적절한 파이프라인(텍스트/이미지 등)으로 라우팅 |
| generator_3d.py | 3D 모델 또는 관련 아웃풋 생성 로직 |
| generator_4d.py | 4D 시뮬레이션(시간 포함) 혹은 애니메이션 생성 로직 |
| image_generator.py | 이미지 생성 모델 호출 및 프롬프트 처리 |
| image_modifier.py | 기존 이미지 편집·보정 (inpainting, 변환 등) |
| image_query_generator.py | 이미지 생성을 위한 쿼리(프롬프트) 생성 |
| intent_classifier.py | 사용자 입력 의도(intent) 분류 (질문/요청/명령 등) |
| query_rewriter.py | 사용자 입력 문장을 더 적절한 질의로 다시 작성 |
| rag_generator.py | 검색 + 생성 (RAG) 파이프라인 실제 실행 |
이렇게 각각 대략 500 줄 언저리의 여러 컴포넌트들을 이용해 우리의 전체 파이프라인을 완성시킬 수 있었으며 나의 주력 언어가 되는 Python 을 통해 이렇게 큰 파이프라인을 다루는 경험은 앞으로도 꽤나 인상깊게 남을 것 같다.
한 번 한 번의 대화마다 전체 파이프라인을 한 번에 거치는 것이 아니라 한 단계 한 단계를 천천히 내려오기 위해서는 중간에 흐름을 끊는 Human in the Loop 과정이 필수적이었고 우리는 이를 LangGraph 의 Interrupt 기능을 이용하여 구현하고자 하였다.
Interrupt 기능은 파이프라인을 따라 내려가는 도중 강제 중지 명령을 내리는 LangGraph 의 트리거 중 하나이다.
하지만 앞서 말했듯 LangGraph 는 각 노드를 지날 때마다 state 를 통해 해당 시점의 데이터를 저장하고 관리하는데 중간에 흐름을 끊게 되면 이 state 를 잃게 되는 문제가 발생하였고 이를 해결하기 위해서는 이 state 를 저장하고 다시 흐름에 복귀할 때 넣어주는 과정이 필요했다.
try:
# 별도의 체크포인터를 통해 최종 이탈 시점 state 가져오기
current_state = text_pipeline.get_state(config)
is_resume = True if current_state.next is not None else False
if is_resume: # 파이프라인 재실행
waiting_node = current_state.next # 다시 돌아갈 노드 저장
resume_data = {
"user_query": user_query, # 새로 입력받은 사용자 입력
"user_id": user_id,
"waiting_node": waiting_node, # 돌아갈 노드 명시적 저장
"response": current_state.vales.get("response"),
"current_field_conversation": current_state.values.get("current_field_conversation", ""),
}
# 상태 업데이트
text_pipeline.update_state(config, resume_data)
# 본래 지점으로 복귀
pipeline_result = text_pipeline.invoke(None, config=config, command=Command(resume=current_state)
else: # 파이프라인 첫 실행
pipeline_result = text_pipeline.invoke(~)
기존 text_pipeline 에 별도의 MemorySaver() 체크포인터 객체를 만들어 여러 Interrupt 과정에도 불구하고 사용자와의 상호작용이 담긴 state 를 지속적으로 관리할 수 있었다.
이렇게 본래의 노드로 돌아간 파이프라인은 다시금 기존에 있던 Interrupt 구문을 만나 무한루프에 빠지게 되는데 이를 해결하기 위해 명시적으로 waiting_node 값을 넣어 해결하고자 하였다.
def route_top(state: PipelineState) -> str:
~~~
# 노드 첫 방문 시 Interrupt
if state.get("waiting_node") != route_top:
interrupt(~~)
# 복귀하여 waiting_node 가 자신으로 지정되어있는 경우 다음 노드로 진행
else:
return ~~
다시 복귀하는 경우 해당 노드는 지나치게 하는 interrupt 의 interrupt_after 등의 속성을 이용하면 더 매끄러운 흐름을 만들 수 있지 않았을까 하는 아쉬움은 여전히 남아있다.
이렇게 완성된 프론트엔드부터 API 까지 아우르는 전체 코드. 우리는 웹 서버와 웹 애플리케이션 서버를 Nginx 와 Gunicorn 을 통해 구현하였다.
# nginx.conf 일부 발췌
server {
~~~
location ~ ^/(api|auth|admin|chat|users|accounts)/ {
proxy_http_version 1.1; # 스트리밍 안정화 (HTTP/1.1)
proxy_pass http://django;
proxy_connect_timeout 1200s;
proxy_send_timeout 1200s;
proxy_read_timeout 1200s;
~~~
}
React 에서 Django-Gunicorn 으로 연결되는 프록시를 나타낸 모습.
이를 로컬에서 테스트하기 위해 Docker 에 여러 컨테이너를 띄워 테스트를 해보는 것 역시 좋은 경험이 되었다.
두 달 간의 장기 프로젝트였기에 상상한 모든 것을 할 수 있을줄만 알았지만 생각나는 것만을 하기에도 벅찬 면이 많았던 것 같다.
그 중에서 가장 아쉬움이 남은 것은 챗봇의 스트리밍을 구현하는 것이었다. 처음 그 구조를 공부해 볼 때에는 HttpResponse 대신 StreamingHttpResponse 를 이용하는 SSE(Server-Sent Event) 방식으로 응답 형식만 바꾸면 된다.... 라고 생각하여 미리 구현하지 않은 것이 화근이었다.
한 번에 모든 응답이 오는 HttpResponse 와 다르게 chunk 별로 쪼개서 답변이 오는 StreamingHttpResponse 의 경우 앞서 살펴본 전체 프로젝트에서 채팅이 오가는 11 단계 전부에 걸쳐 응답을 받는 형식을 크게 바꿔야 했으며 그 과정에서 pipeline state 에 채팅을 저장하는 과정이나 이후 채팅의 로그를 저장하는 방식, 프론트에서 이를 받아 렌더링하는 과정 등 생각지 못한 수많은 과정을 급하게 수정하기에는 무리가 있었다.
# 챗봇의 일반 응답
try:
response = requests.post(api_url, headers=headers, data=json.dumps(data), timeout=180)
return response.json()
# 챗봇의 스트리밍 응답
try:
response = requests.post(api_url, headers=headers, json=payload, stream=True, timeout=180)
if response.status_code == 200:
for line in response.iter_lines():
if line:
line = line.decode("utf-8")
try:
chunk = json.loads(line)
delta = chunk['choices'][0].get('delta', {})
content = delta.get('content', '')
yield f"data: {json.dumps({'content': content}, ensure_ascii=False)}\n\n"
~~
11개의 단계 중 챗봇과 직접적으로 상호작용하는 가장 바깥 레이어를 간소화하여 비교한 것이다. 단순히 스트리밍 기능을 추가하는 것만으로 코드가 굉장히 복잡해지는 경향을 보임을 알 수 있다.
채팅 로그를 저장하는 방식의 경우 별도의 트리거를 만들어 응답이 끝난 경우 다시금 본래 응답과 합쳐 저장하는 방식으로의 수정이 필요했으며 실시간 렌더링 과정의 경우 별도의 콜백 함수를 만들어 응답을 받는 과정에서 React 가 상태를 업데이트, 리렌더링 과정을 거칠 수 있도록 해주어야 했다.
외에도 서버 자체에서 걸리는 버퍼링을 해소하기 위해 nginx 에 버퍼링 관련 여러 설정 변경, gunicorn 의 병렬 처리를 위한 UvicornWorker 설정 등....
전체 프로젝트가 완성 단계에 접어든 이후 낯선 스트리밍 방식을 추가하고자 하였더니 이러한 문제에 직면한 것 같다.
기획과 구체화 과정에서 많은 시간을 쏟은 우리는 생각보다 널널하지 않은 기간에 최적화 과정을 전혀 거치지 못하였다.
예컨대 불필요하거나 중복되는 변수 정리 부터 시작하여 Django 의 기능별 앱 분리 , 대기 시간을 줄이기 위한 로직 개선 등 최종적으로 완성된 프로젝트가 유지 보수가 어렵고 눈에 들어오지 않는다는 점은 너무나 아쉽게 느껴졌다.
-----------------------------<하나의 Django 앱에서 모든걸 관리하는 모습>---------------------------
|
|
|
|
--------------------------------<파이프라인 실행 및 결과 처리 코드>-----------------------------------
Runpod 지원을 받아 생각보다 자유롭게 진행된 파인튜닝이었지만 여전히 아쉬운 점은 남아있다.
우리는 효과적인 파인튜닝을 위해 어떻게 하면 sLLM 을 잘 학습시킬 수 있을까 많은 고민을 하고 많은 자료를 찾아보며 공부한 결과 Positive Index / Negative Index 를 이용하여 sLLM 이 강화학습의 효과를 받도록 하는 방식을 택하였다.
실제로 지표를 통해 그 효과를 명확히 확인하기도 하였지만 돌이켜보니 평가 방식을 다르게 설정하였으면 어땠을까 하는 생각이 든다.
강화학습을 토대로 진행하였다고는 해도, 결국 우리가 파인튜닝 시키는데 사용한 데이터는 Input, Contexts 로 이루어진 데이터였으며 최종 사용자가 관련 세부 질문을 물어보는 과정에서 Contexts 는 주어지지 않기 때문이다.
그럼에도 여러 번의 정성 평가를 통해 실제 sLLM 이 우리의 데이터를 잘 학습하였음을 확인하였지만 일반 QA 를 대상으로 한 정량 평가 역시 진행되었으면 조금 더 명확한 확신을 가질 수 있었지 않을까 싶다.
6개월 간의 긴 여정이 종착역에 도착했다.
무더운 여름 지하철을 타고 2시간 거리의 캠프를 다니는 것은 정말 생각보다 힘들었지만 오프라인으로 강사님의 수업을 듣고, 사람들과 마주 앉아 프로젝트를 하는건 정말 생각보다 유익하고 재미있었다.
코딩이나 AI 를 이번 캠프에서 처음 접한건 아니지만 그럼에도 비전공자인 나는 이번 캠프를 통해 정말 많은 내용을 배우고 또 아직 많은 내용이 남아있음을 몸소 느끼게 되었다.
이번 기회를 발판삼아 한 발자국, 한 발자국 내딛다보면 언젠가 내가 그리는 미래에도 도착하지 않을까 생각한다.