AI를 탑재한 앱의 안정성을 높이는 방법

LLM 앱을 만들 때 가장 골치아픈 무작위성을 다루는 기본적인 개념과 방법을 다뤘습니다.
💡
2025년 4월에 작성해 SNS에 쪼개서 연재하던 글을 마무리 못하고 있다가, 겨우 봉합만 해서 공유합니다.

AI 스타트업인 XL8에서 3년 넘게 일했지만 LLM을 API로 직접 호출해서 사용해본 경험은 많지 않았습니다. 제품 기획, 고객 인터뷰, 프론트엔드 개발 등 사용자 접점이 있는 부분에 주로 신경썼다는 핑계가 있긴 했지만요.

최근 상황이 많이 달라졌는데, 이유는 크게 3가지입니다.

  1. 바이브 코딩으로 앱 만드는 게 이전보다 훨씬 쉬워짐
  2. Gemini를 필두로 값싸고 성능 좋은 LLM API 호출이 가능해짐
  3. 1과 2 덕분에 개인적으로도 AI 앱을 만들고 싶어졌고, 회사에서도 자체 모델과 LLM을 접목하여 성능과 비용 두 마리 토끼를 잡으려는 시도가 잘 동작하기 시작함

이런 맥락에서 지난 몇주간 ’LLM으로부터 안정적인 응답을 얻는 방법‘에 대해 공부하고 실험하며 얻은 걸 정리해봤어요. AI 앱을 개발하고자 하는 분들께 도움이 되길 바랍니다.

단, 저도 아직 경험이 일천하여 틀린 내용이 분명 나올테니(아예 틀리거나, 현 시점에 맞지 않거나 등등) 거리낌없이 지적해주시면 감사하겠습니다.

💡
* 아래 설명은 대부분 Gemini를 바탕으로 한 것이지만, 다른 LLM에서도 유사하게 적용할 수 있습니다.
* 글 최하단에 프리미엄 구독자 분들을 위해 제가 공부할 때 쓴 딥리서치 문서 링크를 첨부합니다.

들어가며

생성형 AI를 상용 서비스에서 활용하고자 한다면 AI로부터 얻는 응답의 '안정성Stability'을 높이는 게 아주 중요합니다. 대개 '예측가능성Predictability''일관성Consistency'이 높으면 응답이 안정적이라고 평가합니다.

  • 예측가능성이 높다: 특정 입력에 대해 모델이 어떤 종류의 출력을 생성할지 논리적으로 짐작할 수 있다
  • 일관성이 높다: 유사한 입력에 대해 모델이 유사한 출력을 생성할 가능성이 높다

그러면 어떻게 예측 가능하면서도 일관적인 응답을 LLM으로부터 얻어낼 수 있을까요?

RAG 같은 기법 없이, LLM API 호출 차원에서 쓸 수 있는 기본적인 방법이 크게 2가지 있습니다. 매개변수를 조절해서 무작위성을 낮추는 것과, 다양한 방식으로 출력 형식을 강제하는 것입니다.

무작위성 낮추기 (Temperature / Top-K / Top-P)

LLM은 아주 단순하게 말하면 '다음에 나오기 적당한 단어를 내 단어사전에서 골라 내뱉는 걸 반복하는 프로그램'입니다. 그리고 LLM이 다음 단어를 고르는 확률분포에 영향을 미치는 대표적인 매개변수들이 (AI Studio 같은 데서 보셨을) Temperature, Top-K, Top-P입니다. 이 세 값을 낮추면 왜 응답의 안정성이 높아지는지 (왜 무작위성이 낮아지는지) 최대한 쉽게(?) 설명해볼게요.

무작위성을 조절하는 3가지 매개변수

세 값이 응답 무작위성에 미치는 영향은 LLM이 '입력을 받아, 확률적으로 단어를(토큰을) 골라, 출력'하는 과정 안에서 이해하시는 게 좋습니다. 아래 그림이 이 과정을 시각화한 것인데요.

Claude로 시각화 후 Figma에서 편집했습니다.

"한국의 수도는"이라는 입력(1)이 들어왔다고 해보죠. 그러면 LLM은 크게 다섯 단계를 거쳐 다음에 올 토큰을 선택합니다.

먼저 입력 컨텍스트에 기반해 사전상의 모든 토큰에 점수를 매깁니다(2). 이 값을 로짓(Logit)이라고 합니다.

그리고 각 로짓 값을 Temperature 값으로 나눕니다(3). T=1이면 아무 영향이 없고, >1이면 토큰간 점수 차이가 작아지고, <1이면 차이가 커지겠죠.

다음은 이 로짓 값들로 합계가 1인 확률분포를 만듭니다(4). 이 분포 안에서의 확률이 곧 그 토큰이 선택될 확률이 되는 거죠. 아래 그림처럼 '1보다 작은 T'로 보정된 분포에서는 '더 그럴듯한' 토큰이 원래 분포보다 더 많이 선택됩니다.

출처: 자세히 쓰는 Gemini API에서 가져와서 코멘트 추가

응답이 그럴듯할수록 사용자는 진부하다고 느낄 수도 있습니다. 그래서 '창의성'이 중요한 기획이나 작문 같은 영역에서는 흔히 T를 높게 설정하라고들 해요. 물론 T가 너무 높으면 정말 터무니없는 응답이 나올 수 있지만요. (예: "서울의 수도는 7v字") 참고로 LLM들의 T 기본값은 대개 0.7 ~ 1입니다.

Temperature를 이해했다면 Top-K와 Top-P는 쉬워요.

Top-K는 확률분포에서 상위 K개만 골라내서 총합 1의 확률분포로 정규화합니다(5). 여기서 다시 Top-P를 적용해, 위부터 누적 확률이 P가 되는 토큰까지 골라내 또 정규화합니다(6). 이 분포에서 최종적으로 다음 토큰을 선택해 출력(7)하는 거죠. 여러 LLM에서 Top-K는 40~60, Top-P는 0.95~1로 기본값이 설정되어 있습니다.

출처: 자세히 쓰는 Gemini API에서 가져와서 코멘트 추가

응답 무작위성을 조절한다는 측면에서, Top-K가 정적이라는 점(아무리 확률이 낮아도 선택될 수 있음)을 제외하면 이 세 설정값의 역할은 흡사합니다. 아래 표를 보시면 셋 모두 Focus하려면 낮추고 Creative하려면 높이라고 하죠.

3가지 매개변수 요약 (출처: 딥리서치)

결국 우리는 안정성과 창의성 사이를 조절하는 레버를, 비슷한 걸로 3개 가진 셈입니다. 상용 서비스에서는 이 3개를 언제 어떻게 (기본값과 다르게) 조절하는 게 유리할까요?

안정성이 중요한 상용 서비스에서 매개변수를 어떻게 지정할까?

엄밀한 규칙을 꼭 따라야 하는 서비스(틀린 말을 하면 안되는 법률 챗봇이라거나)에서 흔히 첫번째로 고려하는 조정은 T를 0에 가깝게, 또는 아예 0으로 두는 것입니다.

T는 로짓 값을 나누는 역할이라고 했었는데요. T=0이라면, 0으로 나눌 순 없으니 가장 높은 값을 지닌 토큰 딱 하나만 선택됩니다. 동일 입력에 대해 동일한 출력을 내뱉는 거죠. 엄밀하게는 모델이 외부 API를 호출할 수도 있고, 일부 GPU 연산이 비결정적인 탓에 100% 같은 응답을 보장할 순 없지만요.

출처: 자세히 쓰는 Gemini API에서 가져와서 코멘트 추가

다른 두 Top 값은 T=0일 때는 아무 효과가 없습니다. 어차피 토큰 하나만 선택되니까요. 마찬가지로, Top-K=1 또는 Top-P=0.01 처럼 한 값이 아주 낮으면 다른 두 값과 무관하게 토큰 하나만 선택되는 효과는 동일합니다. 즉 극도로 안정성을 높이고 싶다면 셋 중 뭘 쓰든 상관없습니다.

하지만 이렇게 했을 때는 '너무 뻔하게 응답한다' 외에도 반드시 고려해야 할 단점이 있습니다.

안정성 vs 반복 환각

그건 바로 '출력 무한 반복'이라는 특별한 환각의 가능성입니다. 아래 스샷은 제가 Gemini로 영상을 자막화하려다가 실제로 겪었던 현상인데요. 여기엔 여러 이유가 있지만 '응답의 지역 최적화'가 가장 큰 원인입니다. 모델이 선택 가능한 토큰이 극도로 제한되어, 함정에 빠진 뒤 거길 영원히 벗어나지 못하는 거죠.

같은 문구가 무한 반복되고 있음

그러면 안정성을 높이면서 반복은 피하는 방법이 있을까요?

여기에 대해 깔끔한 해결책을 찾진 못했습니다. '이것들을 한 번에 하나씩 해보면서 계속 실험하라' 정도가 일반적 전략이었어요.

  • 프롬프트 더 잘 쓴다
  • T를 0으로 두는 대신 살짝 높인다
  • T 대신 Top-P를 0.5 정도로 낮춘다
  • 반복 제어를 위한 추가적인 매개변수를 쓴다

(참고로 Top-K는 정적이라서인지 애초에 조절 불가능한 모델도 많고, 가능하더라도 건드리지 않는 걸 권장하더군요)

딥리서치가 알려준 전략들

이 반복 환각은 (JSON과 같은) 출력 형식을 지정하여 제약을 강화하면 더욱 심각해집니다. 다음 섹션에서 더 자세히 다뤄볼게요.

출력 형식 지정하기

"테이블 형태로 만들어줘" "이미지로 만들어줘" "단일 HTML 파일로 만들어줘"...

ChatGPT 좀 써봤다 싶은 분에게는 익숙하실 문구들이죠. LLM은 원하는 출력 형식을 이렇게 간단히만 지정해줘도 상당히 잘 따릅니다.

하지만 상용 서비스에서는 '상당히' 잘 따르는 것만으로는 아무래도 불안하죠. LLM 응답을 그대로 보여주는 챗봇 같은 서비스가 아니라면 응답을 기계가 읽을 수 있는(machine-readable) + 패턴화된 형태로 만드는 게 아주 중요합니다. 그래야 이러한 일련의 작업이 '안정적'으로 가능해지기 때문입니다.

응답
→ 룰 기반으로 파싱
→ 데이터 추출
→ 적절한 형태로 가공
→ UI 표시, DB 업데이트, 외부 API 호출 등 다음 작업

Napkin.ai로 그렸습니다.

동일 API를 동일 매개변수로 호출했는데 자꾸 파싱 오류가 난다... 같은 끔찍한 문제를 피하기 위해 출력 형식을 고정하는 방법이 크게 4개 있습니다.

  • 첫번째는 위에 언급했듯 프롬프트 내에 간단하게 형식을 지정하는 것이고
  • 두번째는 첫번째의 그 프롬프트에 예시까지 추가하는 겁니다. 즉 few-shot 프롬프팅인데, 이런 데이터가 들어오면 이렇게 해석해라, 와 같은 용도로도 잘 쓰이지만 LLM에게 정확한 출력 형식을 알려주는 데에도 큰 도움이 됩니다.
  • 세번째는 매개변수 차원에서 JSON과 같은 구조화된 출력(Structured Output)을 강제하는 것이고
  • 네번째는 이 역시 매개변수 차원에서 함수 호출(Function Calling) 또는 도구 호출(Tool Calling)을 하도록 만드는 것입니다.

여기서 첫번째는 너무 단순하고 불안정하니 패스하고, 네번째는 결과적으로 세번째인 구조화된 출력과 유사합니다. 그러니 퓨샷 프롬프팅과 구조화된 출력의 동작 방식 및 주된 차이에 집중해보겠습니다.

출력 형식 보장이 어떻게 가능한가?

근본이 랜덤인 LLM에서 출력 형식을 '보장'한다는 게 어떻게 가능할까요?

위 섹션에서 LLM이 단어 사전의 모든 토큰에 로짓값을 매기고, 여러 필터링을 거쳐 최종 확률분포를 만들어 출력 토큰을 선택하는 과정을 설명했는데요. 구조화된 출력이 설정되면 LLM은 검증기(validator)를 이용해 이 확률분포에 개입합니다.

예를 들어 JSON 형식을 지정했고, 입력 토큰이 {"key" 라고 해봅시다. 이게 유효한 JSON, 즉 {"key": value} 형식을 갖추려면 다음 위치에는 쌍점(:), 공백문자( ), 줄바꿈(\n) 3가지만 올 수 있어요.

따라서 LLM은 검증기를 돌려서 이 3가지가 아닌 다른 모든 토큰을 invalid로 마킹하여, 그들이 나올 확률을 0으로 만들어요. 그러면 Temperature나 Top-P 같은 설정값이 얼마이든 간에, 오직 유효한 JSON을 만들어나갈 수 있는 토큰 중 하나만 선택하게 되죠.

예전 GPT 모델은 'JSON 모드 켜면 유효한 JSON 보장해줄게' 정도였는데요. 요즘 모델은 '어떤 key가 어떤 타입(숫자, 문자, 배열 등)이다'가 정의된 스키마를 요구합니다. 벤더 또는 모델마다 스키마를 매개변수로 넘기는 방법은 조금씩 다르지만 목적은 같아요. 문자 자리에 숫자가 못 오게 하는 것처럼, 더 엄밀한 검증기로 더 일관된 응답을 만드는 거죠.

참고로 이 검증기는 LLM의 신경망 밖에서 돌도록 동적으로 구현됩니다. 이를 응용해서 중국어 LLM인 Qwen에서 중국어 출력이 나오지 않게 한 사례도 있어요.

이제 프롬프트 내에 JSON 형식을 지정해도 왜 가끔 틀리게 반환하는지 이해하시겠죠? 이러한 인위적 개입이 없어서 그렇습니다. 형식을 지정하고 예제를 넘기면 그 형식으로 응답하도록 LLM이 확률분포를 조절할 뿐입니다.

이렇게 보면 상용 서비스에서는 무조건 구조화된 출력을 써야 할 것 같지만 또 그렇게 상황이 단순하지는 않아요. 몇 가지 문제가 있습니다.

구조화된 출력의 문제점

우선, 구조화된 출력을 적용하면 (무작위성 매개변수 값을 낮췄을 때처럼) 응답 품질이 저하되고 반복 환각이 생길 수 있습니다.

구조화된 출력과 T=0의 조합으로 한숨 나오는 응답을 받은 사례

원래는 다른 텍스트를 출력했을텐데, 선택 가능한 토큰이 제한되어 온몸을 비틀다 보니 더 부자연스러운 출력이 나오거나 때론 같은 출력만 반복하는 거죠. 당연히 Temp 같은 설정값을 낮추면 이 현상이 더욱 심화됩니다.

반복 환각 제어를 주제로 딥리서치한 문서 일부

더 골때리는 문제는 구조화된 출력이 '스키마와 일치하는 유효한 JSON'을 100% 보장하진 못한다는 겁니다.

검증기가 동적으로 구현된다고 말씀드렸죠? 스키마가 복잡할수록 이 검증기 자체에 버그가 생길 가능성도 커져요. 유저가 기대하는 응답 시간도 있으니 복잡한 검증기를 마냥 구현하고 있을 수도 없고요. 안전 설정 등으로 인해 토큰 일부가 잘리면서 구조가 깨질 수도 있어요.

구조화된 출력을 더 잘 쓰는 방법들

그럼 어떻게 해야 하나? 구조화된 출력을 잘 쓰는 몇 가지 방법을 말씀드려볼게요.

1) 프롬프트부터 잘 쓴다

언제나 가장 중요한 건 프롬프트입니다. 프롬프트에 명확한 구조(Role, Context, Task)와 함께 출력 형식과 예제를 담으면, LLM이 확률 높게 제시한 토큰을 검증기가 걸러버릴 일이 줄어듭니다. 검증기는 문법적으로 올바른지만 검토해서 걸러줄 뿐이고, 확률분포 생성은 여전히 LLM의 몫이니까요. 즉 품질 저하와 반복 환각 문제도 줄어들죠. 추가로 '이런 반복을 피하라'고 명시하는 것도 유효합니다.

2) 극단적인 매개변수는 지양한다

구조화된 출력을 쓸거면 어차피 안정성이 보장되니, T=0.5~0.8 정도로도 충분할 수 있습니다. 물론 작업 특성상 일관되고 정확한 응답이 필요하다면 낮춰야겠지만 그래도 0은 피하는 게 좋아요. 0.2 정도만 되어도 치명적인 반복 환각 문제는 줄어들 겁니다.

3) 복잡도를 낮추고 쪼갠다

LLM이 해야 할 일이 많을수록, 출력할 스키마가 복잡할수록 품질 저하와 반복 환각의 가능성이 커집니다. 이럴 땐 API 호출을 한 번만 하는 대신, 응답 시간 손해를 감수하더라도 프롬프트와 스키마를 단순화하여 여러 턴에 걸쳐 대화하는 게 나을 수 있습니다. 특히 T를 낮춰야 하는 상황이라면 더더욱 쪼개는 걸 검토해보세요.

4) 언제나 추가로 검증한다

어떤 방법을 쓰든 간에 LLM 응답을 그대로 믿으면 안 됩니다. 유효한 JSON인지 / 스키마가 맞는지 / 반복 환각이 발생했는지 등을 꼭 확인해서 후속 조치를 취해야 해요. T=0이 아니라면 재시도만으로도 해결될 수도 있고, 다른 모델을 호출해볼 수도 있겠죠.

5) T를 낮추는 대신 구조화된 출력을 쓰지 않는다

JSON 출력은 결국 파싱 잘 해서 다음 작업 잘 하는 것이었잖아요? 즉 파싱만 잘 할 수 있으면 꼭 JSON으로 응답받을 필요는 없습니다. 작업이 복잡하여 품질 저하를 막고 싶다면 더욱 그렇습니다. 그럴 때는 구조화된 출력을 쓰지 않고 퓨샷 프롬프팅만 하는 것도 고려할 수 있어요. 물론 추가 검증과 재시도 로직은 필수고요.

6) 꾸준히 실험한다

뻔한 얘기지만, 모든 상황에 들어맞는 해결책은 없습니다. 유저가 어떤 입력을 넣을지도 모르고요. 그러니 생성형 AI를 활용한 상용 서비스를 만들려면 다양한 입력, 다양한 설정, 다양한 프롬프트를 계속 실험해봐야만 합니다.

부록

그 외 공부했던 것들도 몇 단락 끼워둡니다.

반복 제어 매개변수와 구조화된 출력

'반복을 제어하는 추가적인 매개변수가 있다'는 얘기를 했었죠. 이들은 LLM이 로짓값을 계산하는 로직에 개입합니다. 해당 토큰이 컨텍스트상에 존재하는지(presence penalty), 또는 얼마나 나타났는지(frequency penalty)에 따라 로짓값을 낮춰주는 역할을 해요. 자연스럽게 최종적으로 선택될 확률이 낮아지죠.

이들 또한 반복 환각을 낮추는 데 도움이 되지만, 구조화된 출력과 함께 쓰기는 어려워요. JSON에서 { } , : " 처럼 반복될 수밖에 없는 토큰들의 확률이 낮아지면서 의도치 않은 결과가 나타날 수 있기 때문이죠. 예를 들어 배열에서 ,가 다수 반복되니 , 대신(아이템을 더 넣는 대신) 배열을 끝마치는 ]를 출력하는 식입니다.

이를 방지하려면 배열 길이를 프롬프트에 명시하든 페널티를 낮추든 해야 하는데 더 복잡해져서 저는 잘 못 쓰겠더군요. 잘 쓰시는 분들의 사례를 듣고 싶네요.

구조화된 출력과 함수 호출의 차이

둘 모두 모델의 응답을 보다 제어 가능하고 예측 가능하게 만드는 기능입니다. 함수 호출이 구조화된 출력(특히 구조화된 JSON 객체)을 활용한다는 점에서 중복이 있지만, 주요 목적은 다릅니다.

구조화된 출력

  • Input: 사전 정의된 스키마(대개 JSON)
  • Process: 기존과 동일
  • Output: 모델이 이 스키마를 따르는 응답을 생성하도록(더 정확히는, 생성을 시도하도록) 강제
  • 이점: 출력 형식에 예측 가능성을 높여 후처리 및 타 시스템과의 통합을 단순화
  • 구조화된 출력이 유용한 작업 예시
    • 데이터 추출: 비정형 텍스트에서 특정 정보(이름, 날짜, 주소 등)를 구조화된 형식으로 추출
    • 콘텐츠 생성: 설정 파일, 데이터 객체 또는 기타 구조화된 콘텐츠를 생성
    • 분류: 정의된 구조로 범주형 레이블 또는 레이블 세트를 출력

함수 호출 (또는 도구Tool 사용)

  • Input: 이 툴이 외부 툴 또는 API와 상호작용하는 시점 및 방법을 이해할 수 있도록 하는 설명
  • Process: 모델이 사용자의 프롬프트를 분석해서, 해당 요청을 이행하기 위해 제공받은 함수 중 하나를 호출하는 것이 유용한지 여부를 판단
  • Output: 모델이 함수를 호출하기로 결정하더라도 함수 자체를 실행하는 건 아니며, 대신 호출할 함수의 이름과 전달할 매개변수 값을 포함하는 JSON 출력을 생성
    • 이 JSON 출력을 받아 실제 함수 호출을 실행할 책임은 앱에 있음
    • 필요에 따라 결과를 다시 모델에 제공하여 추가 처리하거나, 최종 사용자 대상 응답을 생성할 수 있음
  • 함수 호출이 유용한 작업 예시
    • 실시간 정보 검색: 외부 API를 호출하여 현재 날씨, 주가 또는 기타 동적 데이터를 가져오기
    • 작업 수행: 외부 시스템에서 이메일 보내기, 캘린더 이벤트 만들기, 데이터베이스 업데이트와 같은 작업을 트리거
    • 비공개 데이터 액세스: 모델의 훈련 데이터에 없는 정보를 검색하기 위해 데이터베이스 또는 내부 시스템과 상호작용

둘의 주요 차이점

정리하면, 함수 호출구조화된 출력을 활용해 호출할 함수와 인수를 전달한다는 점에서 유사점이 있으나, 의도후속 작업에서 차이가 있습니다. 그리고 속도와 비용에서도 차이가 납니다.

  • 구조화된 출력: 다양한 목적을 위해 모델의 텍스트 출력을 스키마에 맞추는 게 목표. 앱의 후속 작업은 전달받은 구조화된 데이터를 처리하는 것
    • 입력 토큰: 사용자 프롬프트 + 응답 스키마를 표현하는 데 필요한 토큰.
    • 출력 토큰: 생성된 구조화된 텍스트 콘텐츠
    • 응답 시간: 일반적인 채팅과 유사하나, 응답 스키마에 맞춰 처리하는 데 시간이 더 들 수 있음. API는 대개 1번만 호출.
  • 함수 호출: 모델이 외부 작업을 제안할 수 있도록 하는 게 목표. 앱의 후속 작업은 전달받은 함수를 실행하는 것
    • 입력 토큰: 사용자 프롬프트 + 모든 함수 정의를 표현하는 데 필요한 토큰.
    • 출력 토큰: 함수를 호출하지 않았다면 일반적인 채팅과 유사. 함수 호출이라면 구조화된 출력과 유사
    • 응답 시간: 모델의 응답 시간 + 앱의 함수 실행 시간. 함수 실행 결과를 이용해 다시 모델을 호출해서 최종 결과를 내야 한다면 추가 왕복 필요.

이렇게 둘이 다르기 때문에, 이론적으로 함수 호출 기능으로 출력을 구조화하는 건 가능하나, 구조화된 출력만을 위해 함수 호출을 하는 건 권장되지 않습니다.

프롬프트 캐싱을 쓰면 비용이 어떤 식으로 절약되는가

이건 정리를 따로 해보고 있었는데, 페이스북 황민호님이 너무 정리를 잘 해주셔서 링크만 남겨둡니다: https://www.facebook.com/rev.minho/posts/10005937756092897

프리미엄 구독자 분들을 위한 딥리서치 자료 공유

마지막으로, 이 글을 쓰면서 Gemini 딥리서치를 꽤 여러 번 돌렸습니다. 그중 도움이 되겠다 싶은 자료 몇 개를 프리미엄 구독자 분들께 공유드립니다.

This post is for paying subscribers only