확률적 모델 위에 결정적 시스템: 배민 에이전트 개발기
세션 소개
배민 앱의 AI 검색 버튼 뒤에서 자연어로 메뉴와 상품을 찾아 주는 대화형 검색 에이전트를 Java와 Spring AI로 만든 과정입니다. 「확률적으로 답하는 모델 위에, 어떻게 결정적인 시스템을 세우는가」라는 질문을 따라, 기반 선택과 아키텍처 유형은 성시형 님이, 구현부터 에이전트 테스트까지는 장재주 님이 발표했습니다.
핵심 내용
대화형 검색과 시스템 구성
목표는 배달의민족 안에서 고객의 말을 배민 기능 호출로 옮기는 에이전트였습니다. 에이전트 서버(Java, Spring AI)가 LLM, 데이터, API 세 계층을 호출합니다.
| 계층 | 구성 |
|---|---|
| LLM Provider | AWS Bedrock(메인), AWS G 인스턴스에 오픈소스 모델 자체 서빙(폴백) |
| Data | 단기·맥락·장기 데이터 |
| API | 시맨틱 검색, 전시 노출 정보를 조회하는 데이터 허브, 도메인 API |
| 풀어야 할 문제 | 내용 |
|---|---|
| 기능 수행 | 검색, 조건, 정렬, 주문 내역 같은 배민 기능 호출 |
| 일관성 | 같은 질문엔 같은 품질 |
| 데이터 정합성 | 가격·재고·영업 여부의 정합성과 감사(audit) 가능성 |
| 유지보수성 | 이후 기능 개발에 대응 |
| 비용 | LLM 인프라·운영 비용 대비 효용 |
시맨틱 검색은 같은 날 트랙 A 「배민 검색이 AI 검색이 되기까지」에서 다뤘습니다.
LLM 선택: 품질과 성능
모델은 계속 새로 나오므로 개별 모델 비교보다 고르는 기준을 세웠습니다. 기준은 품질과 성능, 비용, 가용성이고, 품질은 AI가 설계자가 원하는 대로 움직여 주는 정도를 뜻합니다.
| 기준 | 항목 |
|---|---|
| 품질 | 의도 이해, 사실 정확성, 기대 동작, 표현과 정책 준수 |
| 성능 | 첫 유의미 발화(스트림 시작), 답변 완료(스트림 완료), 실패율, 지연 시간 |
- 성능은 정량 지표라 기준을 세우기 쉬우나, 품질은 정성적이고 공개 벤치마크로는 우리 기능에 맞을지 알기 어려움
- 같은 발화를 여러 모델에 동시에 보내는 도구를 하루 만에 만들고, 1~2주 동안 여러 사람이 대화를 던지며 품질을 눈으로 확인
- 이 과정에서 시나리오별·모델별 토큰 사용량을 미리 측정해 비용 추정에 활용
| 모델 | 첫 토큰 | 완료 |
|---|---|---|
| Model A | 3,745ms | 3,812ms |
| GPT-5.6 Luna (현재 사용) | 3,781ms | 3,990ms |
| Gemma 4 26B FP8 (자체 서빙) | 2,677ms | 2,786ms |
| Model B | 812ms에 끊겨 실패 |
LLM 선택: 비용과 가용성
대고객 서비스라 LLM 비용을 따지지 않으면 사업 계획이 성립하지 않을 수 있습니다. 품질·성능을 통과한 후보를 대화당 비용으로 거르고, 장애에 대비해 모델을 다중화했습니다.
| 방식 | 과금 기준 | 쓰지 않을 때 비용 |
|---|---|---|
| 상용 API | 입력·출력 토큰 단가로 호출마다 계산 | 없음 |
| 자체 장비 | 장비 비용을 시간으로 나누고, 대당 처리량과 시간대별 필요 대수로 시간당 비용 계산 | 클라우드냐 설치형이냐에 따라 다름 |
프롬프트 캐시는 앞부분이 같은 요청의 계산을 재사용하는 기능으로, 고정된 시스템 프롬프트가 앞에 붙는 대화형 에이전트에 잘 맞습니다.
- 관리형 API 기준 캐시 쓰기는 약 1.2~2배, 캐시 히트는 약 10% 청구. 명시적 캐시를 잘못 걸면 비용이 오히려 늘어남
- 설치형은 캐시 히트만큼 GPU 사용률이 낮아져 장비당 처리량 증가
- 캐시는 첫 글자부터 경계까지의 접두라서, 변경 주기가 긴 블록부터 쌓음
| 순서 | 블록 | 변경 주기 |
|---|---|---|
| 1 | 시스템 지시 (전 회원 공용) | 배포마다 |
| BreakPoint 1 | ||
| 2 | 개인화 맥락 (위치, 주문 내역) | 맥락 갱신마다 |
| BreakPoint 2 | ||
| 3 | 대화 이력, 이번 턴 수행 결과, 현재 시각 | 요청마다, 캐시하지 않음 |
LLM 서비스는 다른 컴퓨팅 자원보다 지연과 장애가 잦고, 같은 계열 제공사끼리 장애가 겹치는 경향이 있습니다.
| 역할 | 모델 | 제공 방식 | 전환 조건 |
|---|---|---|---|
| 메인 대화 | GPT-5.6 Luna | Bedrock · 상용 API | |
| 야간·폴백 | Gemma 4 | 자체 호스팅 | API 지연이 늘어나는 시간대, 메인의 첫 토큰이 x초 넘게 늦을 때 자동 전환 |
- 관리형 API만 쓴다면 Claude 계열과 OpenAI 계열을 서로 폴백으로 두거나 xAI 계열을 더하는 방법도 있음
기술 스택: Java와 Spring AI
팀과 회사가 Java 기반이라 배포, 모니터링, 온콜을 기존 Spring 체계 그대로 두고 그 위에 AI 기능을 얹었습니다. 개발보다 유지보수가 어려워진 만큼 유지보수 가능성을 기준으로 골랐고, Spring AI 2.0 GA 출시 시점도 선택에 영향을 줬습니다.
- 서버 스택: Spring AI 2.0.0 + Spring Boot 4.1
- 사용 기능: ChatClient, 구조화 출력, 툴 호출, Advisor, 관측, 모델 추상화
| 시점 | 버전 | 비고 |
|---|---|---|
| 2026.06 | 2.0 GA | 서버에서 사용 중. MCP, 대화 메모리, RAG·벡터 스토어, Micrometer 관측 지원 |
| 2026.09 | 2.1.0-M1 | OpenAI Responses API, VectorStore upsert, MessagePart, Spring Boot 4.2 |
컨텍스트 데이터
모델에 넘길 데이터를 생애주기에 따라 세 종류로 나누고 서로 다른 객체로 다룹니다. 턴을 시작할 때 한 번에 미리 조회해 두고, 뒤 스텝에서 핸들러 파라미터나 프롬프트의 동적 컨텍스트로 씁니다.
| 구분 | 내용 |
|---|---|
| 단기 | 세션 단위 대화 메모리, 위경도 기반 위치·날씨, 현재 시각, 이전 턴에 보여 준 카드 |
| 맥락 | 행동 로그 집계 API의 실시간 행동 로그(가게·메뉴 상세, 장바구니, 앱 이동 경로), 최근 주문 집계(음식배달, 장보기·쇼핑), 세그먼트 |
| 장기 | 지난 대화를 압축해 유효한 내역만 추린 요약 |
아키텍처 유형과 워크플로우 선택
처음에는 검색·가게·상품·고객 특성을 MCP로 열어 주는 자율형으로 시작했지만, 같은 질문에도 실행 경로가 매번 달라 워크플로우형으로 옮겼습니다. 자율성이 높을수록 비용, 지연 시간, 복잡성도 함께 늘어나므로 해결하려는 문제를 기준으로 유형을 골랐습니다.
| 유형 | 특징 | 적절한 상황 |
|---|---|---|
| 단일 호출형 | 한 번 호출로 입력을 출력으로 바꿈. 도구·루프·분기 없음 | 요약·번역·분류처럼 한 번의 판단으로 끝나고 속도와 비용이 우선일 때 |
| 완전 자율형 | 모델이 도구를 골라 쓰며 다음 행동과 종료를 정함 | 코딩·리서치처럼 할 일을 미리 정할 수 없는 열린 문제 |
| 워크플로우형 (고정 제어) | 코드가 순서·횟수·종료를 고정하고 모델은 단계 하나씩만 맡음 | 할 일이 명확하고 같은 입력에 같은 결과, 높은 검증 가능성이 필요할 때 |
| 워크플로우형 (동적 제어) | 라우터 LLM이 요청마다 경로를 고르고, 경로 안 단계는 정해 둠. 오케스트레이터형 포함 | 요청 종류마다 경로가 다르지만 자율형보다 통제와 추적이 필요할 때 |
"만원 이하 국밥"을 자율형에 세 번 보낸 결과는 이랬습니다.
| 회차 | 실행 경로 | 결과 |
|---|---|---|
| 1 | 검색 | 답변 |
| 2 | 고객 특성 → 검색 → 가게 | 답변 |
| 3 | 검색 → 검색 → 상품 → … | 응답 지연 |
| 자율형의 문제 | 뒤집어 세운 원칙 |
|---|---|
| 실행 제어: 도구 선택과 중간 판단을 추적하기 어렵고 경로·호출 횟수가 요청마다 다름 | 정의된 기능을 정해진 절차로 수행, 정해진 조건 안에서 일관된 답변 |
| 비용과 응답 시간: 실행 주권이 AI에 있어 토큰·비용·지연이 매번 달라짐 | 예측 가능한 토큰 사용 |
| 검증과 유지보수: 제어 수단이 프롬프트뿐이라 기능과 예외가 늘수록 프롬프트가 커짐 | 여러 사람이 유지보수할 수 있는 가시적인 시스템 |
- 세 문제의 공통 원인은 AI의 확률적 결정이 신뢰성(정해진 조건에서 요구 기능을 정해진 기간 동안 해내는 능력)을 흔든다는 점
- 결론: 순서는 코드가 정하고, 모델은 정해진 단계에만 개입
한 턴의 구조: 의도 분류, 실행 제어, 최종 답변
유저 발화와 AI 응답을 합쳐 1 턴으로 보고, 한 턴 안에서 Step을 순서대로 실행합니다. 모든 Step은 턴 시작에 꺼내 둔 User Context를 참조합니다.
| 순서 | Step | LLM 호출 | 하는 일 |
|---|---|---|---|
| 1 | 1단 의도 분류 | 호출 | 발화를 분석해 경로 선택 |
| 2 | 2단 도메인 분류 | 호출 | 리프 의도 결정, 키워드·조건 구조화 추출 |
| 3 | 의도 실행기 | 비호출 | 검색·큐레이션·주문 API 호출, 필터·정렬·상한 적용 |
| 4 | 최종 답변 | 호출 | 전시 계획과 답변 문장 |
| 5 | 후속 칩 생성 | 다음 질문 추천 |
의도 분류. 자연어를 시스템이 알아듣는 호출로 옮기는 번역기 역할이고, 시스템이 이미 할 수 있는 일만 대상으로 합니다.
- 예: "만원 이하 국밥 배달 빠른 순으로" →
search(keyword="국밥", menuPriceMax=10000, sort=DELIVERY_TIME) - "아니 그거 말고" 같은 발화를 풀려면 직전 대화가 필요해, 분류 입력에 단기 컨텍스트를 함께 넣음
- 1단은 나열된 경로 중 하나만 고르니 싸고 빠름. 2단은 도메인마다 따로 두어 기능마다 프롬프트가 작게 유지됨
| 1단 경로 | 2단 리프 |
|---|---|
search/food | 메뉴 검색, 추천 큐레이션, 가게 메뉴판 |
search/commerce | 상품 검색, 셀러 진열, 쿠폰 가게 |
my-info | 주문 내역 |
recall | 자주 주문 |
app | 기능 안내 |
unmatched | 버리지 않고 모아 새 기능의 재료로 분석 |
실행 제어. 자율형에서 모델이 하던 툴 선택과 호출을 의도별 Handler 코드로 옮기고, 코드로 처리할 수 있는 일은 LLM에 맡기지 않았습니다. 위치처럼 정해진 값은 User Context에서 주입하고, 검색어처럼 추출이 필요한 값만 AI가 채웁니다. 검증과 재시도 정책은 구현체마다 다릅니다.
// 예시 코드 · 실제 구현 아님
class MenuSearch implements IntentHandler {
public HandlerResult handle(HandlerRequest req) {
var loc = req.ctx().location(); // 주입
var kw = req.param("keyword"); // AI
if (kw.isBlank()) return askBack();
var shops = retry(3, () -> searchApi.find(kw, loc));
return HandlerResult.of(shops);
}
}
최종 답변. 앞 Step에서 정해진 데이터만 가지고 역할·어조·전시 계획에 맞춰 설명 문장을 만듭니다. 입력은 결과 블록(핸들러 응답), 카드 풀(화면에 나갈 값), 대화 이력, 유저 맥락 일부이고, 출력은 문장, 카드 마커, 후속 질문입니다.
| 얻은 효과 | 내용 |
|---|---|
| 분해 | Step 단위 책임 캡슐화 |
| 계측 | Step별 토큰·지연·실패율. 느린 Step만 튜닝하거나 중요도에 따라 모델을 달리 씀 |
| 최적화·디버깅 | Step별 프롬프트 최적화와 테스트, 실패 단계 특정 |
| 부분 수정 | 단위별 수정과 배포 |
프롬프트 실패 사례
순서를 워크플로우로 고정해도 LLM이 들어가는 자리에서는 예상하지 못한 출력이 계속 나왔습니다. 사례는 프롬프트 모방, 부정 프롬프트, 실행부와 어긋나는 말 세 갈래이고, 고친 규칙이 새 결함을 만드는 일도 반복됐습니다.
| 유형 | 프롬프트에 넣은 것 | 모델이 낸 것 | 대응·교훈 |
|---|---|---|---|
| 모방 | 칩 프롬프트의 "빈 출력은 없어야 된다" | 「빈 출력」, 「empty」 칩 | 지시문 단어가 그대로 화면 버튼이 됨 |
| 결과 머리말 [찾은 갈래] | "확인된 갈래는 삼겹살이에요" | 내부 이름도 출력 후보. [찾은 음식]으로 변경 | |
| "발화가 지역을 말하면 기준 주소를 밝혀라" | 강남역을 한 번 말했는데 3턴 내내 주소 안내 | 규칙이 의도보다 넓게 지켜짐 | |
| 부정 | 지어낸 화면 경로를 막으려 넣은 「앱 화면 경로 안내 금지」 | "주문내역 어디서 봐?"에 "앱 화면 경로는 안내해 드리기 어려워요" | 금지 대신 할 수 있는 것을 안내. 「주문내역에서 확인하실 수 있어요」 + [주문내역] 버튼 |
| 실행부와 어긋남 | 페르소나 | 없는 담기 기능을 "담아드릴까요?"라고 묻고, "네" 뒤에도 같은 질문 반복 | 실행기가 할 수 없는 일은 정중하게 거절 |
| 품절 표시 없는 메뉴에 "품절된 메뉴를 확인했어요", 별점순 화면에 "배달 빠른 순", 못 하는 길찾기를 된다고 함 | 말해도 되는 것은 실행부가 그 턴에 실제로 한 일뿐 | ||
| 수정의 부작용 | 위의 거절 규칙 | "이제 검색 그만할게요"에 "검색 종료는 처리할 수 없어요" | '대화를 여닫는 발화' 예외 절 추가 |
| 예외 절 | "고마워!"를 네 번 중 세 번 거절 | 감사 좋은 예 추가, 거절 문구 자체 금지 |
- 프롬프트에 넣은 모든 것을 출력의 일부로 봐야 함
- 고친 규칙이 몇 시간 만에 다른 결함을 만들어, 고칠 때마다 전체 시나리오를 다시 돌림
테스트와 품질 지표
같은 입력에도 마지막 문장이 매번 달라 assert 기반 테스트가 통하지 않고, 서술형 입력이라 케이스도 사실상 무한합니다. 그래서 목표를 모든 경우의 충족에서 품질 하한 확인으로 바꾸고, 채점을 다시 LLM에 맡겼습니다.
| 방법 | 내용 | 현재 |
|---|---|---|
| LLM-as-a-Judge | 모델 하나가 기준표로 채점 | 사용 중 |
| LLM-as-a-Jury | 서로 다른 모델 여럿이 투표해 판사 편향 감소 | Judge 판정이 계속 흔들리면 도입 예정 |
| 캘리브레이션 | 사람 평가와 AI 판사 평가의 일치율 분석 | Judge에 붙여 사용 |
- 구상 중인 자동 판정 흐름: 수정 → 의도별 경우의 수 세팅 → Judge 채점으로 품질 점수 산출 → 사람 판정과 비교 → 통과해야 배포
- 개발 루프: 수정 → 자동 평가로 하한 확인 → 베타 → 운영. 운영 반응과 메트릭이 다음 수정의 바탕
- QA, 개발자, PM이 모두 시나리오를 직접 돌리고 결과를 함께 봄. 시나리오 설계와 판정 기준은 같은 날 QA 발표에서 다룸
- 모델 비교 도구도 테스트에 재사용. 모델이 바뀌면 같은 프롬프트도 다르게 동작해, 교체·도입 때 첫 토큰 시간, 토큰 수, 정확성을 나란히 비교
테스트가 지키는 것은 하한이고, 진짜 품질은 고객 반응으로 판단합니다. 추천/비추천과 그 시점의 대화 스냅샷이 기본 신호지만 누르는 유저가 적어 아래 지표를 함께 봅니다.
| 지표 | 보는 것 |
|---|---|
| 발화 감정 분석 | 감정 반응으로 부정 피드백 샘플링 |
| 주문 전환율 | 대화 후 실제 주문 여부 |
| 세션 전환율 | 탐색을 끝까지 이어갔는지 |
| 이탈 시간 | 어느 턴에서 이탈했는지 |
- 대화 내역은 개인정보라 약관 동의, 로그 마스킹, 접근 제한 적용. 민감한 값은 모델 인자 밖 요청 컨텍스트에 두고 코드에서 제어
발표의 답은 확률적으로 답하는 LLM을 가운데 두고 순서는 워크플로우가, 하한은 테스트가, 진짜 품질은 고객 지표가 지키는 네 겹 구조였습니다. 결정성은 모델을 둘러싼 시스템에서 나온다는 결론입니다.