LLM API 호출: temperature, top_p, max tokens
1. 개요
LLM API를 호출할 때, prompt 외에도 텍스트 생성 매개변수를 함께 전송합니다. 가장 자주 사용되는 세 가지 매개변수는 temperature, top_p, max_tokens입니다. 이들은 응답이 안정적일지 창의적일지, 그리고 얼마나 길지를 결정합니다.
이 글은 작은 Java 시뮬레이션 코드로 각 매개변수의 메커니즘을 설명합니다. 그다음 Spring AI로 이를 구성하고 잘린 응답을 감지하는 방법을 배웁니다. 읽은 후에는 각 작업 유형에 어떤 값을 선택할지 알게 될 것입니다.
2. 모델이 토큰을 선택하는 방법
각 단계에서 LLM은 다음에 나타날 수 있는 모든 토큰에 대한 점수(logit)를 계산합니다. softmax 함수는 이러한 logit을 확률로 변환합니다. 그 후 모델은 그 분포에 따라 토큰을 샘플링합니다.
temperature와 top_p는 모델의 “지식”을 변경하지 않습니다. 이들은 단순히 샘플링 단계에만 개입합니다.
2.1. Temperature: 분포를 더 뾰족하게 또는 더 평평하게
temperature T를 포함한 softmax 공식:
p_i = exp(z_i / T) / Σ exp(z_j / T)
다음 코드는 모델이 “모자의 색상 …” 문장의 다음 단어를 선택하는 것을 시뮬레이션합니다:
import java.util.*;
public class SamplingDemo {
static final List<String> TOKENS = List.of("파랑", "빨강", "노랑", "보라");
static final double[] LOGITS = {2.0, 1.0, 0.5, 0.1};
static double[] softmax(double[] logits, double temperature) {
double[] exp = new double[logits.length];
double sum = 0;
for (int i = 0; i < logits.length; i++) {
exp[i] = Math.exp(logits[i] / temperature);
sum += exp[i];
}
for (int i = 0; i < exp.length; i++) {
exp[i] /= sum;
}
return exp;
}
public static void main(String[] args) {
for (double t : new double[]{0.5, 1.0, 1.5}) {
double[] p = softmax(LOGITS, t);
StringBuilder line = new StringBuilder("T=" + t + ": ");
for (int i = 0; i < p.length; i++) {
line.append(String.format(Locale.ROOT, "%s=%.2f ", TOKENS.get(i), p[i]));
}
System.out.println(line);
}
}
}
softmax 함수는 exp를 취하기 전에 각 logit을 temperature로 나눕니다. main 함수는 세 가지 temperature 수준에서의 분포를 출력합니다:
T=0.5: 파랑=0.83 빨강=0.11 노랑=0.04 보라=0.02
T=1.0: 파랑=0.57 빨강=0.21 노랑=0.13 보라=0.09
T=1.5: 파랑=0.46 빨강=0.24 노랑=0.17 보라=0.13
결과는 temperature의 효과를 명확하게 보여줍니다:
- T가 낮음 (0.5): 토큰 “파랑”이 83%를 차지합니다. 모델은 거의 항상 가장 가능성이 높은 답변을 선택합니다. 응답은 안정적이고 예측 가능합니다.
- T가 높음 (1.5): 확률이 더 균등하게 분포합니다. “보라”가 2%에서 13%로 증가합니다. 응답이 더 다양하지만 주제를 벗어날 가능성이 높습니다.
- T = 0: 이론상으로는 항상 가장 높은 확률을 가진 토큰을 선택합니다 (greedy).
참고:
temperature = 0은 호출할 때마다 정확히 같은 결과를 보장하지 않습니다. GPU의 부동소수점 계산과 공급자의 배치 처리 방식이 여전히 작은 차이를 만들 수 있습니다. 실제 LLM 출력과 정확한 문자열을 비교하는 테스트를 작성하지 마세요.
2.2. Top_p: 낮은 확률 테일 제거
top_p (nucleus sampling)는 다르게 작동합니다. 모델은 토큰을 확률의 내림차순으로 정렬합니다. 합계 확률이 p 임계값에 도달하는 가장 작은 그룹을 유지한 후 해당 그룹에서만 샘플링합니다.
다음 메서드를 SamplingDemo에 추가합니다:
static List<String> nucleus(double[] probs, double topP) {
Integer[] idx = {0, 1, 2, 3};
Arrays.sort(idx, (a, b) -> Double.compare(probs[b], probs[a]));
List<String> kept = new ArrayList<>();
double cumulative = 0;
for (int i : idx) {
kept.add(TOKENS.get(i));
cumulative += probs[i];
if (cumulative >= topP) {
break;
}
}
return kept;
}
nucleus(softmax(LOGITS, 1.0), 0.8)를 호출하면 다음을 반환합니다:
[파랑, 빨강, 노랑]
“파랑”과 “빨강”의 합은 0.785이므로 0.8에 도달하지 않습니다. 따라서 “노랑”이 추가되고 합계는 0.914가 됩니다. 토큰 “보라”는 temperature가 아무리 높아도 완전히 제외됩니다.
두 매개변수의 주요 차이점:
temperature는 분포의 형태를 변경합니다.top_p는 선택할 수 있는 후보 집합을 변경합니다.
참고: 대부분의 공급자는 두 매개변수 중 하나만 조정하고 다른 하나는 기본값으로 두는 것을 권장합니다 (
top_p = 1.0). 둘 다 동시에 조정하면 어떤 변경이 결과를 야기했는지 알기 어렵습니다.
3. Max tokens: 출력 길이 제한
max_tokens는 모델이 응답에서 생성할 수 있는 최대 토큰 수입니다. 이것은 목표 길이가 아닙니다. 모델은 이 숫자를 “채우기 위해 쓰려고 하지” 않습니다. 제한에 도달하면 문장 중간이라도 즉시 중지합니다.
알아야 할 세 가지 포인트가 있습니다:
- 토큰은 단어가 아닙니다. 영어의 경우 하나의 토큰은 약 3/4 단어입니다. 한국어는 같은 양의 내용에 더 많은 토큰을 소비하는 경향이 있습니다.
- 비용은 토큰으로 계산됩니다.
max_tokens는 비용과 지연 시간의 제한자입니다. - 컨텍스트 윈도우에는 입력과 출력이 모두 포함됩니다. prompt가 이미 컨텍스트 윈도우의 대부분을 사용했다면, 출력을 위해 남은 부분은 매우 적습니다.
잘린 경우, API는 finish_reason을 stop 대신 length로 반환합니다. OpenAI 스타일 API의 축약 응답은 다음과 같습니다:
{
"choices": [{
"message": { "role": "assistant", "content": "후에의 원뿔 모자는 어린 종려 잎으로 만들어져" },
"finish_reason": "length"
}],
"usage": { "prompt_tokens": 42, "completion_tokens": 20, "total_tokens": 62 }
}
내용이 max_tokens = 20이기 때문에 중간에 중단됩니다. 코드가 content만 읽으면, 사용자에게 알지 못한 채 잘린 문장을 표시하게 됩니다.
4. Spring AI로 구성하기
이 섹션은 Spring Boot 3.x에서 Spring AI를 사용하여 OpenAI를 호출합니다.
4.1. 의존성 및 기본 구성
pom.xml에 BOM과 starter를 추가합니다:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
</dependencies>
전체 애플리케이션의 기본값은 application.properties에 있습니다:
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.3
spring.ai.openai.chat.options.max-tokens=300
재정의되지 않은 모든 요청은 temperature = 0.3을 사용하고 최대 300개의 토큰을 출력합니다. API 키는 환경 변수에서 가져오며, 파일에 하드코드되지 않습니다.
4.2. 각 작업 유형에 대한 매개변수 프로필
각 작업에는 다른 매개변수 집합이 필요합니다. 코드 전체에 “매직 숫자”를 흩어놓는 대신, 이를 enum으로 모으세요:
import org.springframework.ai.openai.OpenAiChatOptions;
public enum GenerationProfile {
EXTRACTION(0.0, 1.0, 200), // 데이터 추출, 분류
SUPPORT_REPLY(0.4, 1.0, 400), // 고객 응답
MARKETING_COPY(0.9, 1.0, 600); // 제품 설명, 슬로건 작성
private final double temperature;
private final double topP;
private final int maxTokens;
GenerationProfile(double temperature, double topP, int maxTokens) {
this.temperature = temperature;
this.topP = topP;
this.maxTokens = maxTokens;
}
public OpenAiChatOptions toOptions() {
return OpenAiChatOptions.builder()
.temperature(temperature)
.topP(topP)
.maxTokens(maxTokens)
.build();
}
}
각 상수는 작업 유형을 설명합니다. EXTRACTION은 일관된 결과가 필요하므로 temperature 0을 사용합니다. MARKETING_COPY는 다양성이 필요하므로 0.9를 사용합니다. 셋 다 top_p = 1.0을 유지하므로, 섹션 2.2에서 권장한 대로 temperature만 조정합니다.
4.3. 요청별 매개변수 재정의 및 잘림 감지
Spring Boot는 ChatClient.Builder bean을 자동으로 구성합니다. 이를 서비스에 주입합니다:
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.stereotype.Service;
@Service
public class ProductDescriptionService {
private final ChatClient chatClient;
public ProductDescriptionService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("당신은 Nón Lá Shop의 카피라이터입니다. 최대 3 문장을 작성하세요.")
.build();
}
public String describe(String productName) {
ChatResponse response = chatClient.prompt()
.user(u -> u.text("제품에 대한 설명을 작성하세요: {name}")
.param("name", productName))
.options(GenerationProfile.MARKETING_COPY.toOptions())
.call()
.chatResponse();
Generation result = response.getResult();
String finishReason = result.getMetadata().getFinishReason();
if ("length".equalsIgnoreCase(finishReason)) {
throw new TruncatedResponseException(
"설명이 " + response.getMetadata().getUsage().getCompletionTokens() + " 토큰 후에 잘렸습니다");
}
return result.getOutput().getText();
}
}
각 부분을 분석해봅시다:
.options(...)는application.properties의 값을 이 요청에만 재정의합니다..chatResponse()는.content()같은 문자열만이 아니라 메타데이터를 포함한 전체 응답을 반환합니다.getFinishReason()은 중지 이유를 알려줍니다. Spring AI는 대문자LENGTH를 반환할 수 있으므로 코드는equalsIgnoreCase를 사용합니다.getUsage().getCompletionTokens()는 실제 출력 토큰 수를 알려줍니다. 이 숫자는 로깅 및 비용 추적에 유용합니다.
예외는 다음과 같이 간단하게 정의됩니다:
public class TruncatedResponseException extends RuntimeException {
public TruncatedResponseException(String message) {
super(message);
}
}
이 예외를 만나면 두 가지 옵션이 있습니다. 첫 번째 방법은 max_tokens를 증가시키고 다시 시도하는 것입니다. 두 번째 방법은 더 짧은 응답을 요청하도록 prompt를 조정하는 것입니다. 두 번째 방법이 일반적으로 더 저렴하고 더 깔끔한 결과를 제공합니다.
5. 테스트
실제 LLM의 출력을 문자열 비교로 테스트해서는 안 됩니다. 테스트해야 할 부분은 자신의 로직입니다: 매개변수 프로필과 시뮬레이션 알고리즘입니다.
import org.junit.jupiter.api.Test;
import org.springframework.ai.openai.OpenAiChatOptions;
import static org.assertj.core.api.Assertions.assertThat;
class GenerationProfileTest {
@Test
void extractionProfile_shouldBeDeterministicAndShort() {
OpenAiChatOptions options = GenerationProfile.EXTRACTION.toOptions();
assertThat(options.getTemperature()).isZero();
assertThat(options.getTopP()).isEqualTo(1.0);
assertThat(options.getMaxTokens()).isEqualTo(200);
}
@Test
void marketingProfile_shouldBeMoreCreativeThanSupport() {
assertThat(GenerationProfile.MARKETING_COPY.toOptions().getTemperature())
.isGreaterThan(GenerationProfile.SUPPORT_REPLY.toOptions().getTemperature());
}
@Test
void lowerTemperature_shouldConcentrateProbabilityOnTopToken() {
double[] cold = SamplingDemo.softmax(SamplingDemo.LOGITS, 0.5);
double[] hot = SamplingDemo.softmax(SamplingDemo.LOGITS, 1.5);
assertThat(cold[0]).isGreaterThan(hot[0]);
assertThat(cold[3]).isLessThan(hot[3]);
}
@Test
void nucleus_shouldDropLowProbabilityTail() {
double[] probs = SamplingDemo.softmax(SamplingDemo.LOGITS, 1.0);
assertThat(SamplingDemo.nucleus(probs, 0.8))
.containsExactly("파랑", "빨강", "노랑");
}
}
처음 두 테스트는 누구도 실수로 중요한 프로필의 매개변수를 변경하지 않도록 보장합니다. 다음 두 테스트는 섹션 2에서 설명한 동작을 확인합니다: 낮은 temperature는 확률을 앞의 토큰에 집중시키고, top_p = 0.8은 “보라” 토큰을 제외합니다.
6. 일반적인 오류 및 사용하지 않아야 할 때
정확성이 필요한 작업에 높은 temperature를 사용합니다. JSON 추출, 티켓 분류 또는 SQL 생성은 일관된 결과가 필요합니다. Temperature 0.8 이상은 형식 오류 및 환각의 위험을 증가시킵니다.
낮은 temperature가 “환각”을 방지한다고 생각합니다. 낮은 temperature는 모델이 생각하는 가장 가능성 높은 답변을 선택하게 할 뿐입니다. 모델이 답을 모르면 자신감 있게 잘못 말할 수 있습니다. 환각을 줄이려면 올바른 컨텍스트(RAG)를 제공하고 출력을 검증해야 합니다.
저축하기 위해 max_tokens를 너무 낮게 설정합니다. 응답이 잘려서 다시 호출해야 하면 절약이 되지 않습니다. prompt로 길이를 제한하고(“최대 3 문장”), max_tokens는 안전 네트로만 사용하세요.
finish_reason을 확인하지 않습니다. 이것이 가장 일반적인 오류입니다. 사용자는 잘린 문장을 보지만 로그에는 아무것도 기록되지 않습니다.
매개변수를 다른 모델로 그대로 이동합니다. 각 공급자는 자신의 규칙을 가지고 있습니다:
- 다른 temperature 범위입니다. OpenAI는 0–2, Anthropic은 0–1을 사용하므로 같은 값 1.0은 다른 의미를 가집니다.
- Reasoning 모델(예: OpenAI의 o-series)은
temperature와top_p를 거부할 수 있습니다. - 일부 모델은
max_tokens대신max_completion_tokens를 요구합니다. Spring AI는OpenAiChatOptions.builder().maxCompletionTokens(...)를 통해 이 필드를 지원합니다.
참고: Reasoning 모델의 경우 내부 “사고” 토큰도 출력 제한에 포함됩니다. 제한을 너무 낮게 설정하면
finish_reason = length와 함께 빈 내용을 받을 수 있습니다.
7. 결론
이 글은 LLM API를 호출할 때의 세 가지 핵심 매개변수를 다루었습니다:
temperature는 확률 분포의 뾰족함을 변경합니다. 낮은 값은 안정적인 결과를, 높은 값은 다양한 결과를 제공합니다.top_p는 토큰 후보 집합을 제한합니다. 이 두 매개변수 중 하나만 조정해야 하며, 둘 다 조정하지 마세요.max_tokens는 길이와 비용의 한계입니다. 잘린 응답을 감지하기 위해 항상finish_reason을 확인하세요.
Spring AI로 기본값을 application.properties에 설정하고 .options(...)로 요청별로 재정의합니다. GenerationProfile 같은 enum으로 매개변수 집합을 모으면 코드를 읽기 쉽고, 테스트하기 쉽고, 다른 모델로 전환할 때 조정하기 쉬워집니다.