지연시간 최적화

지연시간 최적화 (Latency optimization)

LLM 기반 애플리케이션을 만들다 보면 "왜 첫 토큰이 이렇게 늦게 오지?" 하는 순간이 반드시 오는데요. 지연시간을 줄이는 방법은 개별 테크닉이 아주 많지만, 사실 몇 개의 큰 원칙으로 정리할 수 있어요. 이 가이드는 다양한 상용 프로덕션 경험을 바탕으로 한 일곱 가지 원칙으로 지연시간 개선을 체계적으로 묶어놨어요. 각 기법을 외우기보다는, 지금 만드는 시스템에 어떤 원칙이 해당하는지 짚어 보는 관점으로 읽으면 좋아요.

출처: 공식문서

일곱 가지 원칙

  1. 토큰을 더 빨리 처리한다
  2. 토큰을 더 적게 생성한다
  3. 입력 토큰을 더 적게 쓴다
  4. 요청을 더 적게 보낸다
  5. 병렬화한다
  6. 사용자가 덜 기다리게 한다
  7. LLM을 기본값으로 쓰지 않는다

토큰을 더 빨리 처리한다

지연시간 하면 가장 먼저 떠오르는 게 **추론 속도(Inference speed)**인데요, 이는 LLM이 토큰을 처리하는 실제 속도로 TPM(tokens per minute)이나 TPS(tokens per second)로 재는 지표예요. 사실 이 원칙이 전부는 아니라는 걸 곧 알게 되겠지만, 여전히 중요한 출발점이에요.

추론 속도에 가장 큰 영향을 주는 건 모델 크기예요. 작은 모델이 대개 더 빠르고 저렴하게 동작하고, 제대로 쓰면 큰 모델을 능가하기도 합니다. 작은 모델로도 품질을 유지하려면 다음을 시도해 볼 수 있어요.

또한 Predicted outputs 같은 추론 최적화 기능을 쓸 수도 있어요. 출력의 대부분을 미리 알고 있을 때(예: 코드 편집 작업) Predicted outputs는 생성 지연시간을 크게 줄여줍니다. 모델에 예측값을 주면 실제 변경되는 부분에 집중하고, 그대로 유지될 부분에는 신경을 덜 써요.

추론 속도에 영향을 주는 다른 요인으로는 확보한 컴퓨팅 자원과 사용하는 추가 추론 최적화 기법이 있어요. 대부분의 사람이 이 요소는 직접 조절하지 못하지만, 인프라 일부 통제권이 있다면 더 빠른 하드웨어낮은 포화도로 엔진을 운영하는 방식으로 TPM을 조금 올릴 수 있어요. 더 깊이 파고들고 싶다면 끝없이 많은 추론 최적화 기법들이 있지만, 이 가이드 범위를 벗어나는 내용이에요.

토큰을 더 적게 생성한다

LLM을 쓸 때 거의 항상 가장 지연이 큰 단계가 토큰 생성입니다. 경험적으로 출력 토큰을 50% 줄이면 지연도 약 50% 줄어들 수 있어요. 출력을 줄이는 방식은 출력 타입에 따라 달라집니다.

자연어를 생성한다면 "20단어 이하로" 같은 지시로 모델에게 더 간결하게 말하도록 시킬 수 있어요. few-shot 예시나 파인튜닝으로 짧은 응답을 가르칠 수도 있고요.

구조화된 출력이라면 출력 문법을 최소화해 보세요. 함수명을 짧게 하고, 이름 붙은 인자를 생략하고, 파라미터를 합치는 식이에요.

마지막으로 흔하진 않지만 max_tokensstop_tokens로 생성을 일찍 끝낼 수도 있어요.

하나 기억하세요. 출력 토큰 하나를 줄이는 건 (밀리)초 하나를 버는 일이에요.

입력 토큰을 더 적게 쓴다

입력 토큰을 줄여도 지연이 줄어들긴 하지만, 보통 큰 요인은 아니에요. 프롬프트를 50% 줄여도 지연 개선은 대개 1~5% 수준이거든요. 정말로 거대한 컨텍스트(문서, 이미지)를 다루는 게 아니라면, 노력은 다른 곳에 쓰는 게 나아요.

그래도 정말 거대한 컨텍스트를 다루거나(그리고 다른 모든 옵션을 다 써봤거나) 성능의 마지막 한 방울까지 짜내야 한다면, 입력 토큰을 줄이는 다음 기법을 쓸 수 있어요.

  • 모델을 파인튜닝해서 길~ 게 쓰던 지시나 예시를 대체한다.
  • 컨텍스트 입력을 필터링한다. RAG 결과를 정리하거나 HTML을 정돈하는 식이에요.
  • 공유 프롬프트 접두사를 최대화한다. 동적인 부분(RAG 결과, 히스토리 등)을 프롬프트 뒤쪽에 두는 방식이에요. 이러면 대부분의 LLM 공급자가 쓰는 KV cache에 친화적이어서 요청마다 처리되는 입력 토큰 수가 줄어듭니다.

프롬프트 캐싱이 어떻게 동작하는지 자세히 보고 싶다면 문서를 확인해 보세요.

요청을 더 적게 보낸다

요청을 보낼 때마다 왕복(round-trip) 지연이 발생하는데, 이게 쌓이면 무시할 수 없어요. LLM이 순차적으로 해야 할 단계들이 있다면, 단계마다 요청을 쏘는 대신 하나의 프롬프트에 모아서 하나의 응답으로 받는 것을 고려해 보세요. 왕복 지연을 피할 수 있고, 여러 응답을 처리하는 복잡함도 줄어듭니다.

이를 구현하는 방법 중 하나는 여러 단계를 열거형 리스트로 합친 프롬프트를 쓴 다음, 모델에게 결과를 JSON 객체의 이름 붙은 필드로 돌려달라고 요청하는 거예요. 그러면 각 결과를 파싱하고 참조할 수 있어요.

병렬화한다

병렬 처리는 LLM으로 여러 단계를 수행할 때 매우 강력해요.

단계들이 엄격히 순차적이지 않다면 병렬 호출로 쪼갤 수 있어요. 셔츠 두 벌은 한 벌과 같은 시간에 마르거든요.

반면 단계들이 엄격히 순차적이라면, 여전히 **추측 실행(speculative execution)**을 활용할 수 있어요. 특히 특정 결과가 다른 결과보다 더 그럴듯한 분류 단계(예: 중재/moderation)에서 효과적이에요.

  1. 1단계와 2단계를 동시에 시작한다(예: 입력 중재와 스토리 생성)
  2. 1단계의 결과를 검증한다
  3. 결과가 예상과 다르면 2단계를 취소한다(필요하면 재시도)

1단계를 맞혔다면 사실상 추가 지연 없이 둘 다 실행한 셈이에요!

사용자가 덜 기다리게 한다

기다리는 것진행 상황을 지켜보는 것 사이에는 큰 차이가 있어요. 사용자가 후자를 경험하게 만드는 게 목표예요. 몇 가지 기법을 볼게요.

  • 스트리밍: 가장 효과적인 방법이에요. 대기 시간을 1초 이하로 줄여줘요. (ChatGPT가 응답이 완성될 때까지 아무것도 안 보여준다면 아마 많이 달라졌겠죠.)
  • 청킹(Chunking): 출력이 사용자에게 보이기 전에 추가 처리가 필요하다면(중재, 번역), 한 번에 처리하는 대신 조각 단위로 처리하는 걸 고려해요. 백엔드로 스트리밍한 뒤 처리된 조각을 프론트엔드로 보내는 방식이에요.
  • 단계 보여주기: 여러 단계를 거치거나 도구를 쓰고 있다면 이를 사용자에게 드러내요. 실제 진행 상황을 보여줄수록 좋아요.
  • 로딩 상태: 스피너와 진행 바만으로도 큰 효과가 있어요.

단계 보여주기와 로딩 상태는 주로 심리적 효과가 크고, 스트리밍과 청킹은 앱+사용자 시스템 전체로 보면 실제 지연시간을 줄여줘요. 사용자가 응답을 더 일찍 다 읽게 되니까요.

LLM을 기본값으로 쓰지 않는다

언어 모델은 강력하고 다재다능해서, 사실 더 빠른 고전적인 방법이 더 적절한 경우에도 쓰이곤 해요. 이런 자리를 찾아내면 지연시간을 크게 줄일 수 있어요. 다음 예시를 볼게요.

  • 하드코딩: 출력이 매우 제한적이라면 LLM 없이도 만들 수 있어요. 작업 확인 메시지, 거절 메시지, 표준 입력 요청 같은 게 훌륭한 하드코딩 후보예요. (각각에 변형 몇 개를 만들어 두는 고전적 방법도 쓸 수 있어요.)
  • 사전 계산(Pre-computing): 입력이 제한적이라면(예: 카테고리 선택), 응답 여러 개를 미리 만들어 두고 같은 걸 두 번 보여주지만 않으면 돼요.
  • UI 활용: 요약된 지표, 보고서, 검색 결과는 LLM 생성 텍스트보다 클래식한 맞춤 UI 컴포넌트로 전달하는 게 나을 때가 있어요.
  • 전통적인 최적화 기법: LLM 애플리케이션도 결국 애플리케이션이에요. 이진 탐색, 캐싱, 해시 맵, 런타임 복잡도 같은 건 언어 모델 세상에서도 여전히 유용합니다.

예제: 고객 서비스 봇에 적용해 보기

여기까지의 원칙을 실제에 어떻게 붙이는지, 가상의 고객 서비스 봇을 예로 살펴볼게요. 아래 아키텍처와 프롬프트가 초기 상태를 보여주고, 분석과 최적화에서 지연 최적화 과정을 하나씩 따라가 봐요. 이 예제가 모든 원칙을 다루진 않는다는 점, 실제 사례처럼 모든 기법을 쓸 필요는 없다는 점도 함께 기억해 두세요.

아키텍처와 프롬프트

다음은 가상의 고객 서비스 봇의 초기 아키텍처예요. 우리가 수정할 대상이에요.

대략적인 흐름은 이렇게 동작합니다.

  1. 사용자가 진행 중인 대화의 일부로 메시지를 보낸다.
  2. 마지막 메시지가 자립적으로 해석 가능한 쿼리(프롬프트의 예시 참고)로 변환된다.
  3. 그 쿼리에 답하려면 (검색된) 추가 정보가 필요한지 판단한다.
  4. **검색(Retrieval)**을 수행해 검색 결과를 얻는다.
  5. 어시스턴트가 사용자 쿼리와 검색 결과를 추론응답을 만든다.
  6. 응답이 사용자에게 전달된다.

다이어그램의 각 부분에 쓰이는 프롬프트는 아래와 같아요. 실제 프로덕션에서 볼 수 있는 구조와 표현으로 작성된, 단순화된 프롬프트예요. **[user input here]** 같은 자리표시자는 실행 시점에 실제 데이터로 교체되는 동적 부분이에요.

쿼리 맥락화 프롬프트

사용자 쿼리를 자립적인 검색 쿼리로 다시 씁니다.

SYSTEM: Given the previous conversation, re-write the last user query so it contains
all necessary context.

# Example
History: [{user: "What is your return policy?"},{assistant: "..."}]
User Query: "How long does it cover?"
Response: "How long does the return policy cover?"

# Conversation
[last 3 messages of conversation]

# User Query
[last user query]

USER: [JSON-formatted input conversation here]

검색 필요 여부 판단 프롬프트

쿼리에 실시간 조회가 필요한지 판단합니다.

SYSTEM: Given a user query, determine whether it requires doing a realtime lookup to
respond to.

# Examples
User Query: "How can I return this item after 30 days?"
Response: "true"

User Query: "Thank you!"
Response: "false"

USER: [input user query here]

어시스턴트 프롬프트

대화와 검색 정보를 바탕으로 미리 정의된 추론 단계를 거쳐 최종 응답을 내려보내는 JSON 필드를 채웁니다.

SYSTEM: You are a helpful customer service bot.

Use the result JSON to reason about each user query - use the retrieved context.

# Example

User: "My computer screen is cracked! I want it fixed now!!!"

Assistant Response:
{
  "message_is_conversation_continuation": "True",
  "number_of_messages_in_conversation_so_far": "1",
  "user_sentiment": "Aggravated",
  "query_type": "Hardware Issue",
  "response_tone": "Validating and solution-oriented",
  "response_requirements": "Propose options for repair or replacement.",
  "user_requesting_to_talk_to_human": "False",
  "enough_information_in_context": "True",
  "response": "..."
}

USER: # Relevant Information
` ` `
[retrieved context]
` ` `

USER: [input user query here]

분석과 최적화

파트 1: 검색 프롬프트 살펴보기

아키텍처에서 가장 먼저 눈에 띄는 건 연속된 GPT 호출이에요. 이는 비효율의 신호로, 보통 단일 호출이나 병렬 호출로 대체할 수 있어요.

이 경우, 검색 여부 판단이 맥락화된 쿼리를 필요로 하므로, 이 둘을 하나의 프롬프트로 합쳐 요청 횟수를 줄이는 것이 가능해요.

결합된 쿼리 맥락화 + 검색 판단 프롬프트

무엇이 달라졌나? 전에는 쿼리를 다시 쓰는 프롬프트와 검색 조회가 필요한지 판단하는 프롬프트가 따로 있었는데, 이제 이 결합 프롬프트가 둘 다 처리해요. 특히 프롬프트 첫 줄의 지시와 출력 JSON이 바뀐 점을 확인하세요.

{
  query: "[contextualized query]",
  retrieval: "[true/false - whether retrieval is required]",
}
combined_query = {
  query: "[contextualized query]",
  retrieval: "[true/false - whether retrieval is required]"
}

puts(combined_query)
SYSTEM: Given the previous conversation, re-write the last user query so it contains
all necessary context. Then, determine whether the full request requires doing a
realtime lookup to respond to.

Respond in the following form:
{
  query:"[contextualized query]",
  retrieval:"[true/false - whether retrieval is required]"
}

# Examples

History: [{user: "What is your return policy?"},{assistant: "..."}]
User Query: "How long does it cover?"
Response: {query: "How long does the return policy cover?", retrieval: "true"}

History: [{user: "How can I return this item after 30 days?"},{assistant: "..."}]
User Query: "Thank you!"
Response: {query: "Thank you!", retrieval: "false"}

# Conversation
[last 3 messages of conversation]

# User Query
[last user query]

USER: [JSON-formatted input conversation here]

실제로 맥락을 추가하고 검색 여부를 판단하는 건 명확하고 잘 정의된 작업이므로, 더 작고 파인튜닝된 모델을 쓸 수 있을 것 같아요. 더 작은 모델로 바꾸면 토큰을 더 빨리 처리할 수 있어요.

파트 2: 어시스턴트 프롬프트 분석

이제 어시스턴트 프롬프트를 볼게요. JSON 필드를 채우면서 여러 다른 단계가 일어나는 것처럼 보이는데, 이는 병렬화할 기회가 있다는 신호일 수 있어요.

다만 테스트를 해보니 JSON의 추론 단계를 나누면 응답 품질이 떨어진다고 가정해 볼게요. 그러면 다른 해결책을 찾아야 해요.

파인튜닝된 더 작은 모델을 쓸 수 있을까? 아마도요. 하지만 일반적으로 어시스턴트의 개방형 응답은 더 큰 모델이 다양한 케이스를 잘 처리하도록 남겨 두는 게 좋아요. 다만 추론 단계 자체를 보면, 전부 큰 모델급의 추론이 필요한 건 아니에요. 잘 정의되고 범위가 제한적이어서 파인튜닝 후보로 좋아요.

{
  message_is_conversation_continuation: "True", // <-
  number_of_messages_in_conversation_so_far: "1", // <-
  user_sentiment: "Aggravated", // <-
  query_type: "Hardware Issue", // <-
  response_tone: "Validating and solution-oriented", // <-
  response_requirements: "Propose options for repair or replacement.", // <-
  user_requesting_to_talk_to_human: "False", // <-
  enough_information_in_context: "True", // <-
  response: "...", // X -- benefits from large model
}

여기서 트레이드오프가 생겨요. 이것을 큰 모델이 전부 생성하는 단일 요청으로 유지할까, 아니면 두 개의 순차 요청으로 나눠 최종 응답 외에는 전부 작은 모델을 쓸까? 서로 충돌하는 원칙이에요. 첫 번째는 요청 횟수를 줄이고, 두 번째는 토큰을 더 빨리 처리하게 해주거든요.

많은 최적화 트레이드오프가 그렇듯 답은 세부 사항에 달려 있어요. 예를 들어:

  • response 필드와 다른 필드의 토큰 비중
  • 대부분의 필드를 더 빨리 처리함으로써 얻는 평균 지연 감소
  • 요청을 두 개로 나눔으로써 생기는 평균 지연 증가

결론은 케이스마다 다르고, 가장 좋은 방법은 프로덕션 예시로 테스트해 보는 거예요. 여기서는 테스트 결과 프롬프트를 둘로 나눠 토큰을 더 빨리 처리하는 쪽이 유리하다고 결론 내렸다고 가정할게요.

참고: responseenough_information_in_context는 두 번째 프롬프트에서 함께 묶어, 검색 컨텍스트를 두 프롬프트에 모두 넘기지 않도록 할 거예요.

어시스턴스 프롬프트 - 추론

이 프롬프트는 작은 모델로 처리하고, 정제된 예시로 파인튜닝할 수 있어요.

무엇이 달라졌나? enough_information_in_contextresponse 필드가 제거됐고, 이 프롬프트에 검색 결과는 더 이상 로드되지 않아요.

SYSTEM: You are a helpful customer service bot.

Based on the previous conversation, respond in a JSON to determine the required
fields.

# Example

User: "My freaking computer screen is cracked!"

Assistant Response:
{
  "message_is_conversation_continuation": "True",
  "number_of_messages_in_conversation_so_far": "1",
  "user_sentiment": "Aggravated",
  "query_type": "Hardware Issue",
  "response_tone": "Validating and solution-oriented",
  "response_requirements": "Propose options for repair or replacement.",
  "user_requesting_to_talk_to_human": "False",
}
어시스턴트 프롬프트 - 응답

이 프롬프트는 큰 모델이 처리하고, 이전 프롬프트에서 결정된 추론 단계와 검색 결과를 받아요.

무엇이 달라졌나? enough_information_in_contextresponse를 제외한 모든 단계가 제거됐고, 이전에 출력으로 채우던 JSON이 이 프롬프트로 전달돼요.

SYSTEM: You are a helpful customer service bot.

Use the retrieved context, as well as these pre-classified fields, to respond to
the user's query.

# Reasoning Fields
` ` `
[reasoning json determined in previous small-model call]
` ` `

# Example

User: "My freaking computer screen is cracked!"

Assistant Response:
{
  "enough_information_in_context": "True",
  "response": "..."
}

USER: # Relevant Information
` ` `
[retrieved context]
` ` `

사실 이제 추론 프롬프트가 검색 컨텍스트에 의존하지 않으므로 병렬화해서 검색 프롬프트와 동시에 쏠 수 있어요.

파트 3: 구조화된 출력 최적화

추론 프롬프트를 다시 볼게요. 추론 JSON의 필드 이름이 꽤 길다는 게 눈에 띄어요.

{
  message_is_conversation_continuation: "True", // <-
  number_of_messages_in_conversation_so_far: "1", // <-
  user_sentiment: "Aggravated", // <-
  query_type: "Hardware Issue", // <-
  response_tone: "Validating and solution-oriented", // <-
  response_requirements: "Propose options for repair or replacement.", // <-
  user_requesting_to_talk_to_human: "False", // <-
}

이름을 더 짧게 만들고 설명을 주석으로 옮기면 토큰을 더 적게 생성할 수 있어요.

{
  cont: "True", // whether last message is a continuation
  n_msg: "1", // number of messages in the continued conversation
  tone_in: "Aggravated", // sentiment of user query
  type: "Hardware Issue", // type of the user query
  tone_out: "Validating and solution-oriented", // desired tone for response
  reqs: "Propose options for repair or replacement.", // response requirements
  human: "False", // whether user is expressing want to talk to human
}

이 작은 변경만으로 출력 토큰 19개를 줄였어요. 작은 모델에서는 몇 밀리초 개선일 수 있지만, 큰 모델에서는 최대 1초까지 줄일 수 있어요. 더 큰 모델 출력일수록 이 영향은 훨씬 커질 거예요.

더 나아가 JSON 필드를 한 글자로 줄이거나 전부 배열에 넣을 수도 있지만, 그러면 응답 품질이 떨어질 수 있어요. 역시 테스트가 최선의 판단 기준이에요.

예제 정리

고객 서비스 봇에 적용한 최적화를 정리하면:

  1. 쿼리 맥락화와 검색 판단을 결합요청 횟수를 줄였고
  2. 새 프롬프트에 더 작고 파인튜닝된 모델로 전환토큰을 더 빨리 처리했으며
  3. 어시스턴트 프롬프트를 둘로 나눠 추론에는 더 작고 파인튜닝된 모델을 써서 다시 토큰을 더 빨리 처리했고
  4. 검색 판단과 추론 단계를 병렬화했으며
  5. 추론 필드 이름을 짧게 줄이고 설명을 프롬프트로 옮겨 토큰을 더 적게 생성했어요.

더 알아보기 (Learn more)