[Senti 프로젝트] LLM 추천 API 응답시간 줄이기: 기다리지 말고 겹쳐 실행하기

728x90

 

Senti 서비스 소개

서비스 소개

`Senti` 는

책 속 한 문장으로 하루를 기록하고, 맘에 드는 문장을 공유하는 서비스예요!

 

이처럼 사용자가 자신의 감정을 기록하면,
기록한 내용을 기반으로 가장 어울리는 문장을 추천해 주는 기능이 핵심이라고 볼 수 있어요

 

문장 추천 로직

추천 플로우

1. 감정 카테고리 선택(SAD | NORMAL | HAPPY)

2. 카테고리에 따른 감정 태그 선택 -> `emotionTag`

3. 일기 작성 -> `diaryText`

4. 추천받고 싶은 문장 유형 선택 or 직접 작성 -> `needTag` or `feelingText`

5. 추천 input 제출

 

추천 로직은 사용자가 감정 태그를 직접 선택하기 때문에,

여러 종류의 태그를 사용하여 태그 기반으로 진행하려고 구상했어요

 

태그 종류

- `emotionTag` : 감정 (기분이 애매한, 마음이 싱숭생숭한, 기분 좋은, 뿌듯한, 감사함이 느껴지는 등)

- `needTag` : 필요한 행위 (용기를 주는 문장, 자존감을 채우는 문장, 마음의 짐을 덜어주는 문장, 공감해 주는 문장 등)

- `moodTag` :  분위기 (따뜻한, 희망적인, 잔잔한, 현실적인 등)

- `situationTag` : 상황 (가족, 상실, 학업 등)

- `contextTag` : 맥락 (밤, 바다, 비, 여행지 등)

- `roleTag` : 문장 역할 (다시 움직이게 하는 문장, 생각을 바꿔주는 문장 등)

 

사전 작업

가지고 있는 문장 데이터를 태그 기반으로 분류해 놓는 작업과

사용자가 검색창에 입력할 법한 의도 문장을 임베딩하는 작업은 미리 해두었어요

 

ex. 문장 : "두려움이 발목을 잡아도 마음이 나아가면 길이 열린다."

- 문장 `metadata tag` : [불안해지는,EMOTION], [앞이 막막한,EMOTION], [희망적인,MOOD], [시작을 응원하는 문장,NEED], [용기를 주는 문장,NEED], [다시 움직이게 하는 문장,ROLE]

- 문장 `metadata canonicalIntent` : "두려움에 발목 잡혀 못 나가고 있어 한 걸음 내딛을 용기가 필요하다"

- 임베딩 `quote embedding` : [0.054626465,0.045074463, ...(생략)

 

> 문장 데이터 확보 방식 <

실질적으로 모든 책의 원문 데이터를 보유하고 있어야 하는데, 현실적으로는 불가능했다.

따라서 알라딘에서 제공하는 api 를 활용하여

LLM 에게 해당 책 구매 링크에 들어가서  책 소개, 책 배경, 줄거리 등을 보고 문장을 가져오도록 하였다.

(이렇게 해도 100% 검증을 확신할 수 없기 때문에 앱 내에 저작권 신고 창구를 열어 놓았고, 현실적인 방안을 계속 탐구 중이다)

 

추천 로직

1. 사용자 입력 분석
    - 사용자가 작성한 `feelingText`, `diaryText`를 LLM 에 전달
    - LLM이 한 번에 여러 일을 처리
        - `emotionTag` 추출
        - `needTag` 추출
        - `situationTag` 추출
        - `contextTag` 추출
        - 검색/임베딩에 사용할 `canonicalIntent` 생성

    - LLM 이 감정 판단, 니즈 판단, 상황 판단, 요약까지 꽤 많은 책임을 가짐

      (상황, 맥락은 코드로 매핑하여 처리하는 것이 한계가 있을 것 같아서, LLM 이 판단을 도와주도록 구상하였다.)
2. effectiveTags 생성
    - 사용자가 직접 선택한 `emotionTag`, `needTag`와
    - LLM이 추출한 `emotionTag`, `needTag`, `situationTag`, `contextTag`를 합쳐 `effectiveTags`를 생성
    - 같은 태그가 여러 출처에서 나오면 `importance` 를 합치거나 강화
    - 이 `effectiveTags` 가 이후 후보 조회와 점수 계산의 기준이 됨
3. canonicalIntent 기반 embedding 생성
    - LLM 이 만든 `canonicalIntent`를 embedding input 으로 사용
    - 사용자 입력 `embedding` 을 생성
    - 이후 문장 `quote embedding` 과 `cosine similarity` 를 계산해서 semantic score 로 사용
4. effectiveTags 기준 후보 문장 조회
    - LLM 분석과 `embedding` 이 끝나면 후보 문장을 조회
    - `effectiveTags` 를 기준으로 문장 `metadata tag` 와 비교하여 후보로 가져옴
5. 후보 점수 계산
    - 후보 문장마다 metadata score 를 계산
        - `emotion match`
        - `need match`
        - `situation match`
        - `context match`
        - `mood match`
    - `embedding` 이 있으면 `semantic score` 도 계산
    - metadata score 와 semantic score 를 합쳐 finalScore 를 계산
6. fallback 후보 보강
    - 후보가 부족하거나 점수가 낮으면 `fallback` 후보를 추가로 조회
    - `emotion range fallback`, `semantic fallback`, `need fallback`, `relaxed/random fallback` 등을 순서대로 탐색
7. 최종 랭킹 및 top10 구성
    - 후보들을 `finalScore` 기준으로 정렬
    - `roleTag` 다양화나 감정 매칭 보정 같은 후처리를 적용
    - 최종 top10을 만들고, 1등을 `mainQuote`로 반환

 

이 로직들이 모두 동기적으로 진행되어
사용자 input 을 받고, 추천 로직을 거쳐 추천 문장이 반환될 때까지 15s ~ 20s 가 소요되었어요..

이 수치는 로딩 인터렉션 + 텍스트만으로 기다리게 하는 건 어렵기 때문에 응답시간 단축이 1순위 과제가 되었습니다.

 

> LLM 을 사용하는 이유 <

사용자의 입력 문구에서 상황, 맥락이 섞인 추천 의도를 우리의 표준 태그 체계로 정규화하기 위해서 사용을 도입했다.

(ex. "강가" 라는 contextTag 가 있는 데, 사용자의 입력 문구에서 "한강에서" 라는 문구가 있으면 "강가" contextTag 로 분류해야 함)

또한 일기 같은 경우 여러 문장으로 이루어져 있고, 많은 감정들이 뒤섞여 있을 수도 있기 때문에

사용자의 입력 문구 전체를 임베딩하면 결과 정확도가 떨어질 것이라고 예상했고, 실제 결과로도 그랬다.

따라서 LLM 을 통해 contextTag, situationTag 를 중점으로 추출하고,

임베딩 검색을 위한 요약 한 문장 canonicalIntent 을 생성했다.

이 외의 모든 추천 로직은 백엔드 랭킹 시스템에서 가중치를 부여하며 직접 계산하도록 구현했다....!

 

추천 응답시간 단축 

응답시간 로그 분석

우선 추천 로직의 여러 단계에서 로그들을 찍어 보며

어디서 시간이 얼마 소요되는지 확인해 보았어요.

전체 추천 응답 19초

1. 사용자 입력 LLM Parser
  → emotion / need / context / situation / canonicalIntent 뽑는 OpenAI 호출
  → 대략 9~14초대
2. 사용자 query embedding 생성
  → OpenAI embedding 호출
  → 대략 1초 안팎 ~ 2초
3. 후보군 조회
  → emotionTag + needTag 기반 DB 조회
  → 거의 짧음, 수백 ms
4. semanticScore / metadataScore 계산 
  → 애플리케이션 내부 계산
  → 거의 짧음, 수백 ms
5. ResultComposer / 다양화 / 저장
  → 거의 짧음, 수백 ms

 

동기 방식의 문제점

- 추천은 사용자가 기다리는 동기 api 이다.

- LLM 분석 및 embedding 실패/지연이 곧 사용자 대기 시간으로 이어진다.

- LLM 분석, embedding, 후보 조회, ranking 단계가 필연적이며, 순서가 중요하다.

 

현재 가장 큰 처리 시간이 걸리는 단계는 사용자 입력 LLM Parser 인 걸로 확인됐어요.

 

이 부분에서 처리 시간을 줄이려면 어떻게 하는 게 좋을지 계속 생각해 보았고,

LLM 호출은 횟수보다 입력/출력 토큰의 양(추론 시간)을 줄여야 보다 빠른 응답을 받을 수 있다는 것을 알게 되었어요.

 

결론적으로 시간을 줄이는 방법은 LLM 의 책임을 최소화하고, 명확한 하나의 일만 맡기는 것이라고 생각했어요.

 

LLM 응답을 기다리는 동안 미리 조회하거나, 미리 계산할 수 있는 부분은 병렬로 준비를 하고,
응답 시간을 `MAX(사용자 입력 LLM Parser)` 로 되어 4 ~ 7초대 까지 단축하는 것이 목표였어요...!

 

1. 사용자 입력 LLM 책임 축소

기존

1. 사용자가 선택한 `emotion` `need` 도 있지만, 더 정확한 결과를 위해 사용자가 입력한 문구들에 대해 `emotion` `need` 재추출

2. 사용자가 입력한 문구들에 대해 `situation` `context` 추출

3. 각 태그 추출 후 `evidence`(어느 부분을 보고 추출했는지 증거) `confidence`(얼마나 확실한지) 판단

4. `canonicalntent` 생성

5. `IntentType`(CONTEXT_BASED | EMOTION_NEED_BASED | SITUATION_BASED | MIXED) 판단

  (IntentType : 사용자가 원하는 의도 타입의 종류에 따라 태그 종류 별로 가중치를 다르게 주는 백엔드 룰)

 

이 구조에서는 프롬프트에 전달해야 하는 태그 후보도 많고,
LLM 이 반환해야 하는 JSON 필드도 많아서 입력/출력 토큰이 모두 커졌어요.

입력/출력 토큰, 추론 시간이 많이 소요 == 응답시간 많이 소요

 

따라서 과감하게 LLM 에게 맡긴 책임을 줄이도록 했어요

(실제로도 `emotion``need` 도 추출하게 할 때는,

가지고 있는 태그 목록을 매번 전부 보내줘야 했기 때문에 입력 토큰의 크기가 매우 컸다. `10,000` 이 넘던,,,)

 

개선

1. 사용자가 입력한 문구들에 대해서만 `situation` `context` 추출

2. `canonicalIntent` 추출

 

최종적으로 이렇게 두 책임만 남기고 나머지는 백엔드 룰로 계산하도록 설계를 수정했어요

 

감정 태그는 사용자가 직접 선택한 값을 가장 강한 신호로 사용하고,
mood, role, intentType 같은 정책성 값은 LLM이 아니라 백엔드 룰로 계산하도록 변경했습니다.

 

2. 사용자 입력 LLM 병렬 호출

1번과 2번의 책임은 서로 독립적입니다.
`tag parser` 결과는 후보 문장의 `metadata score` 를 계산할 때 필요하고

`canonicalIntent` 는 임베딩 생성을 위해 필요합니다.

따라서 두 작업을 순차적으로 기다릴 필요가 없었고,
두 LLM 호출을 동시에 실행하도록 바꿨어요...!


1. canonicalIntent LLM
   - 사용자의 입력을 임베딩 검색에 적합한 한 문장으로 요약
2. tag parser LLM
   - (`need`), `situation`, `context` 만 추출
   - 사용자가 이미 `needTag` 를 선택한 경우에는 `need` 후보도 보내지 않음


기존

사용자 입력 분석 LLM 완료 → canonicalIntent 확보 → embedding 생성 → 후보 조회/랭킹

 

개선

canonicalIntent LLM ┐
                    ├ 병렬 실행
tag parser LLM      ┘


canonicalIntent 가 먼저 끝나면 tag parser 를 기다리지 않고 바로 embedding 생성을 시작하도록 설계를 수정했습니다!

 

3. LLM 프롬프트에서 prompt cache 보조 최적화로 사용

추가로 OpenAI 응답 로그에서 `cached token` 을 확인하면서,
반복되는 프롬프트 `prefix` 에는 `prompt cache` 가 적용될 수 있다는 점도 확인했습니다...!

다만 우리가 직접 측정한 핵심 지표는 `TTFT` 가 아니라 전체 `LLM elapsed time` 이었기 때문에,
`prompt cache` 는 응답시간을 줄이는 보조 수단으로만 다루었습니다.

 

고정 프롬프트와 schema 는 최대한 앞쪽에 배치하고,
동적 입력은 뒤로 배치하여 캐시가 최대한 많이 되도록 프롬프트 구성을 변경했어요.

이를 통해 OpenAI `prompt cache` 가 적용될 수 있는 구간을 늘리고,
반복되는 프롬프트 `prefix` 의 입력 처리 비용과 지연을 줄이고자 했습니다.

다만 `prompt cache` 는 응답시간을 항상 크게 줄여주는 장치는 아니었습니다.
실제 로그에서도 `cache hit` 인 경우가 평균적으로 더 빠르긴 했지만
전체 응답시간은 병렬로 실행되는 두 LLM 호출 중 더 느린 호출,
`output token` 수, 외부 API 지연의 영향을 함께 받았습니다.

그래서 prompt cache 는 LLM 책임 축소와 병렬화 이후에 적용한 보조 최적화로 적용해 보았습니다

 

> TTFT 란? <

TTFT는 Time to First Token의 약자로,

사용자가 질문을 입력하고 '전송' 버튼을 누른 후 AI가 답변을 첫 글자(첫 토큰)로 뱉어내기까지 걸리는 시간을 의미한다.

 

LLM 최적화 결과

( 추천 분석 결과를 저장하는 recommendation_analysis_logs 테이블 데이터 )

inputTokens = sum(canonical.inputTokens, tag.inputTokens)
outputTokens = sum(canonical.outputTokens, tag.outputTokens)
cachedTokens = sum(canonical.cachedTokens, tag.cachedTokens)
llmElapsedMs = max(canonical.llmElapsedMs, tag.llmElapsedMs)

 

LLM 의 입력 토큰은 많은 케이스에서 10,000 대에서 4,000 대 수준으로 줄었고,

LLM elapsed time 도 기존 14초 안팎에서 6초대까지 줄어드는 케이스를 확인할 수 있었어요!

 

4. LLM을 기다리는 동안 DB 조회 prefetch

추천 후보 조회는 사용자가 이미 선택한 `emotionTag` , `needTag` 만으로도 어느 정도 시작할 수 있습니다.

그래서 LLM 분석이 끝날 때까지 기다리지 않고,
사용자가 선택한 태그 기준으로 1차 후보 문장 조회를 먼저 시작했습니다.

동시에 랭킹에 필요한 `mood tag`, `tag rarity weight` 같은 데이터도 함께 미리 조회했습니다.

이후 LLM 분석이 끝나면 최종 effectiveTags를 만들고,
`hard filter` 가 그대로라면 미리 조회해 둔 후보를 재사용합니다.

현재 `hard filter` 는 `emotion tag` 를 우선으로 사용하기 때문에,
사용자가 선택한 감정 태그가 그대로 유지되는 대부분의 경우에는 `prefetch` 결과를 그대로 사용할 수 있습니다.

이처럼 LLM 분석 시간과 DB 조회 시간을 직렬로 더하지 않고, 서로 겹쳐 실행하도록 만들었습니다.

 

5. embedding을 기다리다 전체 추천이 늦어지지 않도록 처리

`canonicalIntent` 가 생성되면 바로 `embedding` 생성을 시작합니다.

다만 `embedding` 은 외부 API 호출이기 때문에 실패하거나 지연될 수 있습니다.
이때 추천 전체가 실패하거나 오래 멈추지 않도록 `timeout` 과 `degrade` 처리(`embedding` 결과 사용 제한)를 넣었습니다.

embedding 이 성공하면 semantic score 를 사용하고,
실패하면 metadata score 만으로 추천을 계속 진행합니다.

의미 기반 점수는 추천 품질을 보완하는 역할을 하지만, 외부 API 지연 때문에 추천 API 전체가 막히지는 않도록 했습니다.

 

결과

결과적으로 추천 API의 병목을 없앤 핵심은
LLM이 꼭 해야 하는 일만 남기고,
나머지는 `백엔드 룰`, `prefetch`, `병렬 처리`, `degrade` 구조로 분리한 것이었습니다.

 

 

 

추천 API 누르고 응답까지 15s ~ 20s 까지 소용되던 시간을
최종적으로 4s ~ 7s 로 줄일 수 있게 되었습니다...!

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

728x90