
Perplexity 스타일의 에이전트를 개인 개발 앱에 통합 — 설계의 노하우와 과제
요약
개인 개발 앱 'Vector'에 Perplexity 스타일의 Q&A 에이전트를 통합한 설계 노하우를 공유합니다. 외부 웹 검색과 내부 데이터를 결합하여 출처가 포함된 답변을 생성하는 6단계 공정 아키텍처를 소개합니다.
핵심 포인트
- Safety Check, Planning, Search 등 6단계의 체계적인 에이전트 워크플로우 설계
- Redis Streams와 SSE를 활용한 실시간 진행 상황 및 답변 스트리밍 구현
- 경량 LLM을 활용한 사전 세이프티 체크 및 정책 차단 상태 분리 운영
- 단일 워커 태스크 내 연속 함수 호출을 통한 심플한 상태 관리 방식
서론
외부 웹 검색과 애플리케이션 내에서 사전에 AI로 분석한 독자적인 내부 기사를 결합하여, 출처가 포함된 답변을 반환하는 — 그런 「Perplexity 스타일의 Q&A 에이전트」를, 개인 개발 중인 해외 테크 뉴스 수집·분석 앱 『Vector』에 통합했습니다.
본래 서비스의 규모나 품질과 경쟁하려는 의도는 없습니다.
이 기사의 목적은 첨단 기술을 활용한 우수한 프로덕트의 경험에 개인 개발로 어디까지 다가갈 수 있는지에 대한 시도와, 실제 시스템 설계, 그리고 구현을 통해 드러난 현시점의 과제를 공유하는 것입니다.
소스 코드도 공개하고 있으니, 조금이라도 참고가 된다면 기쁘겠습니다.
GitHub 리포지토리: https://github.com/yook11/Vector -
실제 서비스 사이트: https://vectorbrief.online
(※ 실제 동작을 확인하실 수 있으나, 사용자 등록 및 이용은 초대제로 운영됩니다)
전체상
에이전트는 대화 형식의 UI로 제공됩니다. 사용자의 질문은 백그라운드의 worker에서 비동기적으로 처리되며, 그 진행 상황(현재 공정이나 검색 중인 키워드) 및 생성 중인 답변 텍스트는 Redis Streams (운영 환경은 ElastiCache Valkey)와 SSE를 경유하여 브라우저로 순차적으로 스트리밍되는 구조입니다.
전체 처리 흐름은 다음과 같은 6가지 공정으로 진행됩니다.
Safety Check (세이프티 체크)
사용자의 입력이 정책을 위반하지 않는지, 위험한 내용을 포함하고 있지 않은지를 가볍게 체크하는 계층입니다. -
Context Gathering (컨텍스트 정리)
사용자의 요구에 정확히 답하기 위해, "지금 무엇을 요구받고 있는가"라는 대화의 컨텍스트 (Context)를 정리합니다. -
Planning (플래닝)
질문에 대해 어떻게 접근할지를 결정합니다. 먼저 "직접 답변할 수 있는지, 검색이 필요한지"를 판단하고, 검색이 필요한 경우에는 구체적인 검색 플랜을 세웁니다. -
Search (에비던스 수집)
Planning에서 세운 검색 플랜에 따라 외부 검색과 내부 검색을 실행하여, 답변의 근거가 되는 정보를 모읍니다. -
Evidence Review (에비던스 리뷰)
수집한 정보 중에서 플랜에 대해 유효한 정보(근거)를 정밀하게 검토하고 선별합니다. -
답변 생성
Evidence Review에서 선별된 근거를 사용하여, 최종적인 출처가 포함된 답변을 생성합니다.
Planning에서 "검색 불필요"라고 판단된 경우에는 검색을 수행하지 않고 그대로 질문에 답변합니다.
실행 모델로서는 이러한 일련의 공정을 세세하게 큐 (Queue)로 나누지 않고, 모두 하나의 worker 태스크 내의 연속된 함수 호출로서 구현했습니다.
상태 전달은 메모리 상에서 완결되는 심플한 설계입니다. 이 접근 방식을 선택한 이유와 그 한계에 대해서는 기사 후반부에서 다루겠습니다.
이하에서는 공정별 노하우를 해설합니다.
1. Safety Check (세이프티 체크)
첫 번째 공정에서는 사용자의 질문이 안전 정책상 처리해도 좋은 것인지를 판정합니다.
에이전트 아키텍처에 대해 조사하던 중, "무거운 본체 처리에 들어가기 전에 가벼운 모델로 사전 체크를 거친다"라는 프랙티스 (Practice)를 알게 되어 제 앱에도 도입해 보았습니다. 실제 판정은 경량 LLM 콜 (Call)로 수행합니다.
여기서 고안한 점은 판정 그 자체보다 "차단 상태의 처리"입니다.
시스템 에러에 의한 실패 (failed) 와는 명확히 구분하여, "정책에 의한 차단 (policy_blocked)"이라는 전용 종료 상태를 마련했습니다.
에러 상태와 분리함으로써 다음과 같은 장점이 있습니다.
운영 측면: 시스템 에러가 아니므로 불필요한 재시도(Retry) 처리나 버그 조사 대상에서 제외할 수 있음 -
UI/UX 측면: 단순한 "에러가 발생했습니다"가 아니라, "정책에 의해 처리되지 않았습니다"라고 사용자에게 적절한 피드백을 줄 수 있음
참고로 구체적인 차단 이유는 사용자에게 반환하지 않습니다.
사용자에게는 고정된 문구만을 표시하고, 상세한 이유는 내부 로그와 메트릭스 (Metrics)에만 남기는 설계로 하고 있습니다.
2. Context Gathering (컨텍스트 정리)
이 부분은 구현 단계에서 저에게 큰 맹점이었던 부분입니다.
평소 코딩 에이전트 등을 사용하다 보면, 과거의 대화 내용을 자동으로 반영하여 답변해 줍니다. 하지만 직접 LLM을 호출하는 로직을 구현해 보니, "이전 대화는 매번 명시적으로 전달하지 않는 한 AI에게는 존재하지 않는다"라는 당연한 사실을 깨닫고 이 공정을 마련하게 되었습니다.
그렇다고 해서 과거의 대화 이력을 그대로 후속 공정(Planning 또는 답변 생성)에 전달할 수는 없습니다. 대화가 이어질수록 텍스트가 길어져, 불필요하게 컨텍스트 윈도우 (Context Window, 토큰 수)를 압박하기 때문입니다.
그래서 "후속 공정에 과거 대화 중 어떤 정보가 필요한가"를 정밀하게 검토하여, 필요한 정보만 추출해서 전달하는 설계로 만들었습니다.
구체적으로는, 직전의 대화 이력을 바탕으로 다음과 같은 요소들을 추출하고 정리합니다.
추상적인 질문의 구체화 (컨텍스트 보완)
예를 들어 "그거, 좀 더 자세히"와 같이 지시어를 포함한 추상적인 질문이 들어올 경우, 그대로 후속 공정에 전달하면 "그것"이 무엇인지 알 수 없습니다. 따라서 과거 이력을 바탕으로 "NVIDIA의 실적에 대해 더 자세히"와 같이, 단독으로도 의미가 통하는 구체적인 질문문으로 재구축합니다. -
답변이 충족해야 할 조건
"이 조건을 만족하지 않으면 요구에 부응한 것이 아니다"라는 수락 조건을 정리합니다. 여기서는 사용자의 요구사항만을 추출하여, AI가 임의로 새로운 관점을 만들어내지 않도록 제한합니다 (구체적인 검색 방법론으로의 분해는 다음 단계인 "Planning" 공정에서 수행합니다). -
이미 답변한 내용의 정리
지금까지의 답변 중 이번 질문과 관련된 부분만을 요약합니다. 이는 과거 턴에서 이미 설명된 내용을 AI가 다시 반복하는 것을 방지하기 위함입니다. -
스레드 전체의 목적
"투자 판단을 위해 조사하고 싶다"와 같이 대화 속에서 목적이 언급된 경우에는 그 목적도 정리하여 추출합니다. 후속 공정은 이를 바탕으로 조사 방향성과 답변 작성 방식을 결정하므로, 이번 질문문만 보는 것보다 사용자의 요청에 부합하는 답변에 가까워질 수 있습니다 (이력에 근거가 없는 목적을 추측으로 만드는 것은 금지하고 있습니다).
기타 노하우: 이력은 요약하지 않음 · 실패 시 축소(Fallback)
참고로, 이 공정에 입력하는 과거 대화 이력 자체는 "요약"하지 않습니다. 요약하면 정보가 손실될 뿐만 아니라, 요약 자체의 품질 문제(Hallucination 등)를 유입시킬 리스크가 있기 때문에, 직전 6개의 메시지를 각각 최대 2,000자 상한으로 전달하고 있습니다.
또한, 이 컨텍스트 정리 단계에서의 LLM 호출에 실패하거나 출력이 계약 조건을 충족하지 않을 경우에는 사용자의 질문 원문을 그대로 사용하여 다음 공정으로 진행하도록 했습니다. 보조적인 공정의 에러로 인해 전체 처리가 실패하지 않도록 설계한 것입니다.
3. Planning (플래닝)
이 공정에는 크게 "애초에 검색이 필요한지 여부의 판단"과 "검색할 경우의 계획 수립"이라는 두 가지 역할을 부여했습니다.
검색 필요 여부 판단 (비용과 시간 절약)
인사나 앱 사용법 등 외부의 사실 확인이 필요 없는 질문에 대해서는 검색을 수행하지 않고, 그대로 "직접 답변" 공정으로 스킵시킵니다. 이를 통해 불필요한 LLM 호출을 줄여 비용을 절약하고 응답 속도도 높였습니다. 검색 수행 여부에 대해서는 조금이라도 망설여진다면 검색을 수행하도록 설정했습니다. 만약 검색이 필요한 상황에서 검색이 이루어지지 않으면 치명적인 답변 오류로 이어질 수 있기 때문에, 이 부분은 안전한 방향으로 설계하여 Hallucination을 방지하고 답변의 질을 담보하도록 했습니다. -
리서치 목표와 내부 검색 쿼리 수립
검색이 필요한 경우에는 이전 공정에서 정리한 컨텍스트를 바탕으로 조사 계획을 세웁니다. 구체적으로는 "최종적으로 어떤 정보를 원하는가"라는 **리서치 목표 (Research Goal, 전체 방침)**와, 앱 내부에 있는 분석된 기사를 찾기 위한 내부 검색용 쿼리를 결정합니다.
구현상의 노하우와 가드레일 (Guardrail)
외부 검색의 구체적인 쿼리는 결정하지 않음
여기서 결정하는 것은 어디까지나 "리서치 목표"까지입니다. 외부 웹 검색에서 실제로 어떤 키워드(쿼리)를 보낼지는 후속 외부 검색 공정에 맡깁니다. "목적에 맞는 정보를 정확하게 히트시키기 위한 쿼리를 선택하는 것"은 Planning과는 또 다른 책임이라고 판단하여 역할을 분리했습니다. -
검색 수의 상한 (가드레일)
시스템이 폭주하는 것을 막기 위한 가드레일로서 검색 규모의 상한을 정해두었습니다. "조사의 축은 최대 3개까지", "내부 기사 검색 문구는 전체 합계 3건까지"라고 프롬프트에 명시하여, 유사한 검색을 늘리는 것을 제한하고 있습니다. -
출력의 모순 체크
LLM의 출력은 아무래도 변동성이 있기 때문에, 프로그램 측에서 정합성 체크 (Validation)를 수행하고 있습니다. "검색이 불필요하다고 했는데 조사 태스크가 생성됨", "검색을 한다고 했는데 검색 문구가 비어 있음"과 같은 불일치를 걸러내거나, 상한을 초과한 검색 문구를 잘라내는 등 후속 처리가 안전하게 동작하도록 정돈합니다. -
기간 지정의 정규화
"최근의", "지난주의"와 같은 상대적인 시간 표현도 이 공정에서 시스템이 다루기 쉬운 형태 (오늘, 지난주, 특정 월 등)로 변환·정규화하여 후속 단계로 전달하도록 하고 있습니다. -
내부 검색과 외부 검색에서 쿼리 생성 처리를 분리한 이유
외부 검색의 쿼리 책정은 후속 공정에 맡긴 반면, 내부 검색의 쿼리는 이 플래닝 (Planning) 공정의 출력을 그대로 사용하고 있습니다. 이유는 앱 내부의 기사 검색에는 벡터 검색 (Vector Search)을 채택하고 있기 때문입니다. 리서치 목표에 기반한 "의미적으로 가까운 것"을 찾는 특성상, 플래너 (Planner)가 출력한 쿼리를 그대로 사용해도 현재는 문제없이 기능하고 있으며, 불필요한 LLM 호출을 줄여 비용을 절약할 수 있다는 장점도 있습니다. 외부 검색과는 "의도한 정보를 히트시키기 위한 책임"이 다르기 때문에 접근 방식을 분리했지만, 향후 운용 과정에서 내부 검색의 정밀도에 과제가 발생할 경우 이 또한 쿼리 생성 공정을 분리하는 것을 검토할 예정입니다.
4. Evidence Collection (에비던스 수집)
이전 공정에서 세운 계획을 바탕으로, 답변의 재료 (근거)를 실제로 수집하는 공정입니다.
조사 태스크마다 자체 데이터베이스를 검색하는 "내부 검색"과 웹을 검색하는 "외부 검색"을 병렬로 실행합니다.
내부 검색: AI 분석 완료 기사 취득 및 벡터 캐시
내부 검색의 대상은 이 앱이 매일 수집하고 사전에 AI 분석까지 마친 스톡 (Stock) 기사입니다. 플래닝 공정에서 작성된 일본어 검색 쿼리를 벡터 (Vector)로 변환하고, pgvector를 사용하여 의미적으로 가까운 기사를 취득합니다.
여기서 고안한 점은 **벡터 변환 결과의 캐시 (Cache)**입니다.
텍스트의 벡터화에도 AI API 호출이 필요하기 때문에, 동일한 검색 문구의 변환을 매번 수행하면 비용과 레이턴시 (Latency)가 늘어납니다. 따라서 한 번 변환한 것은 PostgreSQL에 저장하고, 다음부터는 데이터베이스를 룩업 (Lookup)하여 API 호출을 생략하도록 했습니다.
캐시 키는 검색 문구의 평문을 그대로 남기는 것이 아니라, SHA-256으로 해싱한 것을 사용합니다. 나아가 키에는 "사용된 임베딩 (Embedding) 모델명 · 태스크 종류 · 차원 수"를 조합한 식별자를 포함하고 있습니다. 이를 통해 향후 모델이나 차원 수를 변경했을 때, 조건이 다른 오래된 벡터를 잘못 재사용하는 것을 방지하도록 설계했습니다.
외부 검색: 영어 쿼리로의 변환 및 크롤링 생략
외부 검색은 실제로 검색 엔진으로 보낼 쿼리를 작성하는 것부터 시작됩니다.
플래너로부터 받은 "리서치 목표 (무엇을 확인하고 싶은가)"를 바탕으로, LLM을 사용하여 웹 검색에서 정확하게 히트되기 쉬운 영어 검색 쿼리 (1~3건)를 생성합니다.
플래닝 공정에서 외부 검색의 쿼리 작성을 분리한 이유는 여기에 있습니다. "일본어 조사 목적으로부터 검색에 최적화된 영어 쿼리를 구성한다"라는 서로 다른 책임을 부여하기 위해서입니다.
생성된 쿼리를 사용하여 검색 API (Tavily)를 호출하고 기사 후보를 취득합니다.
여기서의 고안점은 웹 페이지의 본문을 직접 취득하지 않는다는 것입니다.
API가 반환하는 제목, URL, 스니펫 (Snippet), 날짜만을 후보로 취급하며 각 페이지의 크롤링 (Crawling)은 수행하지 않습니다. 이는 전체 레이턴시를 억제하는 것과, 임의의 URL을 fetch하는 기구를 앱 측에 두지 않는 것 (보안상의 리스크를 줄임)을 우선시했기 때문입니다.
한쪽이 실패해도 멈추지 않음 (Fallback)
내부 검색과 외부 검색은 한쪽이 실패하더라도 처리를 멈추지 않고, 성공한 쪽의 결과만으로 계속 진행합니다. 만약 외부 검색 API가 다운되어 모두 실패하더라도, 내부 스톡 기사만으로 충분히 답할 수 있는 케이스가 있기 때문에 전체 처리를 실패시키지 않기 위한 축퇴 설계 (Degraded Design)로 구성했습니다.
5. Evidence Review (에비던스 리뷰)
수집한 후보를 그대로 답변 생성에 넘기는 것이 아니라, 플래너가 설정한 "리서치 목표"에 대해 유효한 근거인지를 평가·선별하는 공정입니다.
단순히 근거를 고르는 것뿐만 아니라, 할루시네이션 (Hallucination)을 방지하기 위한 고안이나 향후 확장성을 고려한 설계를 도입하고 있습니다.
유효한 근거의 선별과 "부족 정보"의 추출
수집된 정보 중에서 리서치 목표에 적합한 것만을 선별하는 동시에, 이 과정에서는 **'이번 검색에서 무엇이 확인되지 않았는지(부족한 정보)'**도 특정하여 출력합니다.
이 부족한 정보는 다음과 같이 활용됩니다.
- 이번 답변 생성: 조사로 확인하지 못한 정보를 고려한 답변을 출력하도록 합니다. - 다음 계획 수립: 이번에 조사할 수 없었던 요소를 기반으로 다음 조사 계획을 수립하게 하여, 사용자의 요구를 충족하는 정보 수집으로 이어지게 합니다. - 컨텍스트 정리: 미해결이며 지속적으로 유효한 요구사항은 답변 생성 시의 '충족해야 할 조건'으로 기억시킵니다.
미래적인 재검색(루프 구조)을 염두에 둔 책임 분리
증거 수집과 리뷰 과정을 명시적으로 나눈 것은 책임을 분리하기 위함입니다.
이렇게 공정을 독립적으로 유지함으로써, '수집된 근거가 불충분하다'고 판단되었을 경우, 자동으로 전 단계의 수집 과정으로 돌아가 재검색을 수행하는 루프 구조를 미래에 통합하기 쉽게 만들었습니다.
URL을 노출하지 않는 구조적인 환각 방지책
이 공정에서의 가장 큰 고안은 LLM에게 URL 자체를 직접 보여주지 않는다는 점입니다.
LLM에게 URL 자체를 처리하게 하지 않고, 모든 것을 앱 측에서 부여한 '인덱스(통시번호)'로 일원 관리하는 설계입니다.
구체적으로는 LLM에 전달할 정보를 '번호・제목・출처명・날짜・스니펫'으로 한정하고, 출처 지정 역시 인덱스 번호만 사용하게 합니다. LLM이 반환한 인덱스를 기반으로 앱 측에서 복원하여 연결하는 메커니즘입니다.
프롬프트의 주의사항에만 의존하는 것이 아니라, 시스템(구조)을 통해 '실재하지 않는 URL을 조작하는' 유형의 환각을 막는 설계입니다.
6. 답변 생성
이전 공정에서 엄선된 근거를 바탕으로 최종적인 답변을 Markdown 형식으로 생성합니다.
- 출처 인라인 인용
근거에 기반한 주장이 나오는 직후에는 [[1]]과 같은 형태로 인라인 인용을 부착하도록 합니다.
프롬프트 지시 외에도, **'존재하지 않는 번호를 인용했는지', '필요한 인용이 누락되지 않았는지'**를 후처리로 검증하고, 규칙을 위반하면 내부적으로 재시도 및 수정을 시킵니다.
스트리밍(SSE) 표시 시의 고안
생성 중인 답변 텍스트는 SSE를 통해 브라우저에 순차적으로 스트리밍되지만, 여기서 송신 측(백엔드)에 약간의 고안을 추가했습니다.
스트리밍 중에는 어쩔 수 없이 [[나 [[1과 같은 중간 형태의 문자가 흘러나오는 순간이 있는데, 이것들이 화면에서 깜빡이지 않도록 마커 부분을 일시적으로 마스킹/제거합니다.
최종적으로 저장되는 텍스트에는 마커가 남도록 처리되어 있으며, 답변이 확정된 타이밍에 출처 링크(호버 또는 클릭 가능한 UI)로 변환됩니다.
대화를 넘어서는 또 하나의 컨텍스트: 과거 검색 기록
제2 공정의 'Context Gathering(컨텍스트 정리)'가 담당하는 것은 어디까지나 '이번 질문에서 사용자가 무엇을 원하는지'라는 사용자 측 의도의 추출입니다.
하지만 대화를 성립시키기 위해서는, 그것과는 별개로 **'과거 턴에서 시스템 측이 무엇을 검색했는지'**라는 액션 이력도 관리할 필요가 있었습니다.
그래서 외부 검색을 실행한 처리의 끝에는, 다음 액션에 활용하기 위해 다음과 같은 기록을 남기도록 하고 있습니다.
**플래너가 세운 리서치 목표실제로 외부 검색에 사용한 쿼리그 검색으로 무엇을 얻었는지(증거 리뷰에서 채택된 근거)**어떤 정보가 부족했는지
같은 스레드 내에서 대화가 이어지는 경우, 이 직전 3회분의 검색 기록이 플래너의 입력에 추가됩니다.
이것이 없으면, 비슷한 검색을 반복하거나 지난번에 확인하지 못한 점을 다른 접근 방식으로 해소할 수 없습니다.
LLM을 사용하지 않은 고안과 향후 전망
이 기록을 남기는 처리에 LLM은 호출하고 있지 않습니다. 각 공정이 이미 출력한 결과물을 프로그램 측에서 모아주는 것일 뿐입니다.
'어떤 기록이 이번 질문과 관련되는지'를 선택하는 필터링도 하지 않고, 직전 3건을 그대로 플래너에게 전달하며, 관련 여부 판단은 플래너에게 맡기고 있습니다.
다만, 향후 앱을 운영하면서, 플래너의 판단 정밀도가 떨어지는 같은 과제가 발생할 경우에는, 이 '검색 기록 정리・선택' 계층에도 LLM을 도입하는 것을 검토하고 있습니다.
검색이 실패했을 때의 처리
에러 등으로 실패한 검색은 실행하지 않은 것과 같으므로 기록하지 않습니다.
반대로 「검색 자체는 성공했지만, 유용한 정보를 찾지 못했다」는 경우에는 빈 채택 리스트(empty adoption list)로 기록합니다. 이는 지난번에 이 쿼리로 유용한 후보를 얻지 못했다는 시그널을 플래너(Planner)에게 보내기 위함입니다.
또한, 과거의 기록은 어디까지나 「중복 회피」나 「쿼리 개선」을 위한 참고 정보이며, 현재 답변의 직접적인 근거로 재사용하지 않도록 프롬프트(Prompt)로 제약하고 있습니다.
공정 전체를 뒷받침하는 공통 메커니즘
지금까지는 에이전트의 각 공정에 대해 작성했지만, 플로우(Flow) 전체를 외부에서 지원하는 인프라나 제어 메커니즘에 대해서도 몇 가지 소개해 드리겠습니다.
답변 스트림의 청크화 (Coalesce)
LLM이 생성하는 답변 텍스트를 토큰(Token) 단위로 그대로 SSE(Server-Sent Events)로 흘려보내면, 통신이 너무 세분화되어 백엔드(Backend)와 브라우저의 렌더링 모두에 부하가 걸립니다.
전송 전에 텍스트 파편(Delta)을 묶는 버퍼링 메커니즘(합류 / Coalesce)을 도입했습니다. 구체적으로는 「0.25초 간격」 또는 「512자에 도달한 시점」을 상한으로 하여 청크(Chunk)화하고, 이를 모아서 전송함으로써 균형을 맞추고 있습니다.
일일 쿼터(Quota) 관리
이 에이전트 기능에는 「1사용자당 1일 10회까지」라는 이용 상한(Quota)을 두고 있습니다.
소비되는 타이밍은 실제로 처리가 실행될 때가 아니라, 요청을 수락하여 run을 생성한 시점입니다.
여기서 주의한 점은 레이스 컨디션(Race Condition)입니다. 앱 측에서 SELECT를 통해 횟수를 확인한 후 UPDATE를 하는 방식으로 구현하면, 연타되었을 때 상한을 초과해 버릴 가능성이 있습니다.
따라서 쿼터 소비는 run의 생성과 동일한 트랜잭션(Transaction) 내에 묶어, 다음과 같은 「조건부 UPSERT」로 수행하도록 했습니다. 상한 체크를 앱 측의 로직이 아닌 SQL 구조로 담보하는 방식입니다.
ON CONFLICT (user_id, usage_date) DO UPDATE
SET used_count = agent_user_daily_quotas.used_count + 1
WHERE agent_user_daily_quotas.used_count < 10
...
참고로, 현재 소비한 쿼터를 반환하는 경우는 큐(Queue)에 들어간 채 실행되지 않고 종료된 run뿐입니다.
이를 뒤집어 말하면, 처리 시작 후에 시스템 측의 사정으로 실패할 경우, 사용자는 답변을 받지 못한 채 쿼터만 소비하게 됩니다.
현재는 초대제 애플리케이션이기 때문에 이 사양을 허용하고 있지만, 비즈니스적 가치를 제공하는 것을 전제로 한다면 「시스템 에러 등으로 사용자의 요구를 충족시키지 못했을 경우」에 소비분을 반환하는 메커니즘이 필요하다고 느끼고 있습니다.
향후 과제: 사용자가 늘어나면 설계를 어떻게 바꿀 것인가
여기서부터가 기사의 후반부입니다. 서두의 플로우 차트에서도 약간 언급했지만, 현재 에이전트 기능의 실행 모델은 상당히 타협한 심플한 설계로 되어 있습니다.
현재 아키텍처(Architecture)의 제약 사항은 다음과 같습니다.
- 1 run = 1 worker 태스크 (큐에 들어가는 것은 최초의 기동 트리거 1건뿐)
- 중간 결과는 메모리 상에만 존재 (각 공정에서 생성한 플랜이나 채택 근거 등을 저장·영속화하는 메커니즘이 없음)
- 도중에 실패하면 「모두 처음부터 다시 시작」 (도중에 실패할 경우, 거기서 재개할 수 없고 run 자체가 실패 처리됨)
현재는 초대제 애플리케이션이며, 「1일 10회까지」라는 일일 쿼터를 둔 소규모 운영입니다.
이 정도 규모라면 복잡한 상태 관리(State Management)를 구축하기보다, 실패 시에는 처리를 종료하고 사용자에게 재실행을 요청하는 구성이 더 심플하고 가시성이 좋다고 생각합니다.
지금부터는 만약 사용자가 늘어나서 비용 측면에서도, 경험 측면에서도 「도중에 실패하면 전부 다시 시작」하는 것이 허용되지 않게 되었을 때, 이 아키텍처의 어느 부분을 어떻게 바꿔야 하는지 현재 시점에서 상정하고 있는 과제를 정리합니다.
도중에 실패했을 때의 처리를 어떻게 바꿀 것인가 (리트라이 설계)
사용자가 늘어나면 가장 먼저 직면하는 것은 에러가 발생했을 때의 리트라이(Retry) 설계입니다. 여기서 중요한 것은 공정마다 수행하는 처리가 다르며, 실패의 성질도 다르다는 점입니다.
「에러가 발생하면 일률적으로 전체를 처음부터 다시 시작한다」는 대응은 적절하지 않습니다. 공정별로 「몇 번 다시 시도할 것인가」뿐만 아니라, **「재시도해도 실패했을 때 최종적으로 어떻게 폴백(Fallback)할 것인가」**까지 결정해야 합니다.
| 실패 유형 | 발생하기 쉬운 공정 | 결정해야 할 사항 (대응 방침) |
|---|---|---|
| 안전 판정을 할 수 없음 | 세이프티 체크 (Safety Check) | 상한을 두고 재시도하며, 그래도 판정할 수 없다면 후속 단계로 넘기지 않고 실패 처리한다 |
| LLM 출력 오류 | 플래닝 (Planning), 답변 생성 | 스키마 (Schema) 붕괴인지, 논리적 모순인지 등 오류 유형에 따라 재시도 방법을 구분한다 |
| 외부 API의 일시적 실패 | 에비던스 (Evidence) 수집 | 대기 후 재시도할 것인지, 해당 공정을 스킵(축소)할 것인지, 아니면 포기할 것인지 |
각각의 실패에 대해 어떻게 대처해야 하는지 정리합니다.
1. 안전 판정을 할 수 없을 때 (Fail-safe)
세이프티 체크 (Safety Check)는 판정 결과를 얻지 못한 채 후속 단계로 진행해서는 안 되는 중요한 공정입니다. 따라서 상한 횟수를 둔 재시도가 필요합니다.
포인트는 상한에 도달했을 때의 처리 (페일세이프 (Fail-safe))입니다. 다른 공정의 폴백 (Fallback)은 "실패하더라도 전체를 실패시키지 않는다"는 방향이지만, 여기만큼은 반대로, "결과를 얻을 수 없다면 절대로 후속 단계로 넘기지 않고 실패시켜야" 합니다.
"재시도를 다 썼으니 일단 통과시킨다"는 것은 검사 없이 통과시키는 것과 같기 때문입니다.
2. LLM의 출력이 부정확할 때
LLM의 출력은 매번 스키마 (Schema)를 준수하는 것이 아니기 때문에, 예상치 못한 응답이 돌아오는 것을 항상 고려해야 합니다. 출력 에러의 내용에 따라 효과적인 재시도 방법은 달라집니다.
스키마 (JSON 등)가 깨진 경우:
API 비용이나 사용자의 대기 시간과의 균형을 고려하여 리트라이 (Retry)를 실행하는 것이 좋다고 생각합니다.
구조는 올바르지만 내용이 모순되는 경우:
(예: 검색이 불필요하다고 판단했음에도 검색 태스크가 붙어 있거나, 존재하지 않는 출처 번호를 인용하는 등)
이 경우에는 이전 에러 내용을 프롬프트 (Prompt)에 전달하여 "수정"하게 한 뒤 재시도하는 방법도 고려할 수 있습니다.
또한, 단순히 그 자리에서 에러를 처리하는 것에 그치지 않고, LLM의 응답을 모니터링 및 평가하여 지속적으로 개선할 수 있는 메커니즘을 도입할 필요가 있다고 생각합니다.
3. 외부 API가 일시적으로 실패했을 때 (UX와의 트레이드오프)
외부 검색 API의 실패에 대해서도 구분이 필요합니다. 타임아웃이나 500번대 에러, 레이트 리밋 (Rate Limit) 등은 시간을 두고 기다리면 해결될 가능성이 있지만, 인증 에러 (401 / 403)나 요청 자체의 오류 (400)는 몇 번을 다시 보내도 마찬가지입니다.
나아가 외부 검색이 도저히 성공하지 않을 경우, "내부 스톡 (Stock) 기사만으로 답변을 만든다"는 선택지도 있습니다 (이는 이미 구현되어 있습니다).
여기서 중요한 점은 **"이것은 대화형 채팅 UI이다"**라는 제약 조건입니다.
백그라운드에서의 재시도 간격은 곧 "사용자를 기다리게 하는 시간"이 됩니다. API 복구를 기다리며 계속 시도하는 것보다, 차라리 포기하고 현시점에서 얻을 수 있는 정보만으로 답변하는 것이 결과적으로 사용자 경험 (UX) 측면에서 더 나은 상황도 있습니다. 따라서 공정별 리트라이 방침은 "시스템 전체에서 사용자를 얼마나 기다리게 해도 되는가"라는 조건과 함께 결정해야 합니다.
완료된 공정의 성과를 재사용하기
앞 절의 리트라이 설계를 진행하다 보면 또 다른 큰 문제에 부딪히게 됩니다. 그것은 바로 **"도중에 에러가 발생할 경우, 그때까지 완료되었던 공정의 성과도 모두 버려버린다"**는 현재의 사양입니다.
현재의 아키텍처 (In-memory 방식)에서는 run 도중에 실패하면 모든 것을 처음부터 다시 시작해야 합니다. 즉, 세이프티 체크나 플래닝 등의 LLM 호출을 다시 처음부터 수행하게 되며, 이는 다음 두 가지 측면에서 큰 낭비를 발생시킵니다.
비용의 낭비: 이미 성공했던 공정의 API 비용을 다시 지불해야 합니다.
사용자 경험 (UX)의 악화: 처음부터 다시 시작하는 만큼, 답변의 첫 글자가 화면에 나오기까지의 대기 시간이 더욱 길어집니다.
중간 단계까지 진행된 상태에서 재개할 수 있도록 만드는 것이 중요하다고 생각합니다.
재개를 위해 필요한 3가지 요소
중간부터 재개할 수 있게 하려면 다음 세 가지가 필요합니다.
공정별 성과물을 저장하기
어디까지 완료되었는지 기록하기
완료된 공정을 스킵하고 재개할 수 있는 입구 만들기
이 세 가지 중 2번인 "어디까지 완료되었는지"는 진행 상황 표시를 위한 현재 공정 (progress_stage
)를 DB에 기록하고 있으므로, 이미 유사한 기능을 가지고 있습니다. 문제는 1번인 결과물(artifact) 부분인데, 이 부분은 현재 어디에도 저장되지 않고 있습니다. 공정 간의 데이터 전달이 모두 메모리 상에서 완결되기 때문에, 프로세스가 종료되면 한꺼번에 사라집니다.
취할 수 있는 접근 방식은 다음 두 가지입니다.
- 메모리 계층에서의 데이터 보장 수준을 높이기 — 중간 결과물을 worker 프로세스 외부에 있는 인메모리 (in-memory) 데이터 스토어로 옮기고, 복제 (replication)나 디스크 쓰기 설정을 통해 보장 수준을 높이는 접근 방식입니다. 프로세스가 종료되었을 때 손실될 가능성을 완전히 제로로 만들 수는 없지만, 어느 정도까지 허용할지를 조정할 수 있습니다. 다만, 레플리카 (replica)를 늘리면 그만큼 메모리를 보유하는 노드가 늘어나며, 쓰기 완료를 기다리는 설정으로 하면 대기 시간도 길어집니다. 특히 메모리는 디스크에 비해 단가가 높기 때문에, 남겨두는 데이터가 늘어날수록 운영 비용에 영향을 미칩니다.
- DB (Postgres)로 영속화하기 — 프로세스와 완전히 독립적으로 데이터를 남기는 접근 방식입니다. 내구성 (durability)을 확보하기 쉽다는 장점이 있는 반면, 공정마다 DB 쓰기 레이턴시 (latency, 지연 시간)를 감수해야 합니다.
현시점에서는 두 번째인 'DB에 쓰기' 접근 방식이 가장 심플하다고 생각합니다.
다만, 실제로 채택할 수 있을지는 다음 두 가지를 측정하여 판단해야 합니다.
- 처리 속도에 미치는 영향: 지금까지 메모리 상에서 전달하던 중간 데이터를 DB 쓰기로 대체함으로써, 1회 실행 (run) 전체에서 처리 속도가 어느 정도 느려지는가.
- 커넥션 풀 (connection pool)과의 관계: 공정의 경계마다 쓰기 작업을 끼워 넣는다는 것은, 그만큼 DB 연결을 사용하는 상황이 늘어남을 의미합니다. 사용자가 늘어나 동시에 실행되는 run이 증가했을 때, 커넥션 풀의 상한선에 대해 어느 정도 여유를 유지할 수 있는지 확인해야 합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기