调用 LLM API:temperature、top_p 和 max tokens

1. 总览

调用 LLM API 时,除了 prompt 外,你还需发送一些文本生成参数。最常用的三个参数是 temperature、top_p 和 max_tokens。它们决定了答案是稳定还是富有创意,以及答案有多长。

本文通过一段小的 Java 模拟代码解释每个参数的机制。然后你将使用 Spring AI 配置它们,并发现被截断的答案。读完本文后,你将能够为不同类型的任务选择合适的参数值。

2. 模型如何选择 token

在每一步,LLM 计算所有可能出现的下一个 token 的得分(logit)。softmax 函数将这些 logit 转换为概率。然后模型根据该分布采样一个 token。

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):token“绿色”占据 83%。模型几乎总是选择最可能的答案。答案稳定且可预测。
  • T 较高(1.5):概率分布更均匀。“紫色”从 2% 增加到 13%。答案更多样化,但也更容易跑题。
  • T = 0:在理论上总是选择概率最高的 token(贪心采样)。

注意: temperature = 0 不 保证不同调用间的结果完全相同。GPU 上的浮点计算和提供商的 batch 处理方式仍可能导致细微差异。不要编写比较字符串来测试真实 LLM 输出的单元测试。

2.2. Top_p:截断低概率尾部

top_p(nucleus sampling)工作方式不同。模型按概率降序排列 token。它保留最小的集合,使其总概率达到阈值 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。token“紫色”被完全排除,无论 temperature 有多高。

两个参数之间的主要区别:

  • temperature 改变分布的形状。
  • top_p 改变被允许选择的候选集合。

注意: 大多数提供商建议只调整其中一个参数,将另一个保持在默认值(top_p = 1.0)。同时调整两个会让你很难判断哪个变化导致了结果变化。

3. Max tokens:限制输出长度

max_tokens 是模型在回答中可以生成的 token 数量的上限。它不是目标长度。模型不会“努力填满”这个数字。当达到限制时,它立即停止,即使中间被打断。

有三点你需要理解:

  1. Token 不等于单词。 在英文中,一个 token 大约等于 3/4 个单词。包含音调符号的中文通常为相同内容消耗更多 token。
  2. 费用按 token 计算。 max_tokens 是成本和延迟的上限。
  3. Context window 包含输入和输出。 如果 prompt 已经占用了 context window 的大部分,剩余空间就很小了。

被截断时,API 返回 finish_reason 为 length 而非 stop。来自 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 AI 从 Spring Boot 3.x 调用 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 token 的输出。API 密钥从环境变量中获取,不要硬编码到文件中。

4.2. 为每种任务创建一个参数配置文件

每种任务需要不同的参数集。与其将数字“魔法值”散布在整个代码中,不如将它们集中到一个枚举中:

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,意味着只调整 temperature,正如第 2.2 节建议的那样。

4.3. 为每个请求覆盖参数并检测截断

Spring Boot 会自动配置 ChatClient.Builder bean。将其注入到你的 service 中:

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() + " 个 token 处被截断");
        }
        return result.getOutput().getText();
    }
}

逐部分分析:

  • .options(...) 覆盖 application.properties 中的值,仅对此请求有效。
  • .chatResponse() 返回带有元数据的完整响应,而不是仅返回字符串(如 .content())。
  • getFinishReason() 告诉你停止的原因。Spring AI 可能返回大写的 LENGTH,所以代码使用 equalsIgnoreCase。
  • getUsage().getCompletionTokens() 给出实际输出 token 数量。这个数字对于日志和成本跟踪很有用。

异常定义很简单:

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 越低,概率越集中在首选 token 上,而 top_p = 0.8 排除 token“紫色”。

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_completion_tokens 而不是 max_tokens。Spring AI 通过 OpenAiChatOptions.builder().maxCompletionTokens(...) 支持此字段。

注意: 对于 reasoning 模型,内部“思考”token 也计入输出限制。如果设置过低,你可能会收到空内容和 finish_reason = length。

7. 总结

本文介绍了调用 LLM API 时的三个核心参数:

  • temperature 改变概率分布的锐度。低值给出稳定答案,高值给出多样化答案。
  • top_p 限制候选 token 集合。你应该只调整其中一个参数,而不是两个。
  • max_tokens 是长度和成本的上限。始终检查 finish_reason 以检测截断的答案。

使用 Spring AI,在 application.properties 中设置默认值,并通过 .options(...) 为每个请求覆盖。将参数组合集中在像 GenerationProfile 这样的枚举中,使代码易于阅读、易于测试,也便于在迁移到另一个模型时修改。

llmspring-aispring-bootjavaopenai