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는 모델이 응답에서 생성할 수 있는 최대 토큰 수입니다. 이것은 목표 길이가 아닙니다. 모델은 이 숫자를 “채우기 위해 쓰려고 하지” 않습니다. 제한에 도달하면 문장 중간이라도 즉시 중지합니다.

알아야 할 세 가지 포인트가 있습니다:

  1. 토큰은 단어가 아닙니다. 영어의 경우 하나의 토큰은 약 3/4 단어입니다. 한국어는 같은 양의 내용에 더 많은 토큰을 소비하는 경향이 있습니다.
  2. 비용은 토큰으로 계산됩니다. max_tokens는 비용과 지연 시간의 제한자입니다.
  3. 컨텍스트 윈도우에는 입력과 출력이 모두 포함됩니다. 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으로 매개변수 집합을 모으면 코드를 읽기 쉽고, 테스트하기 쉽고, 다른 모델로 전환할 때 조정하기 쉬워집니다.

llmspring-aispring-bootjavaopenai