调用 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 数量的上限。它不是目标长度。模型不会“努力填满”这个数字。当达到限制时,它立即停止,即使中间被打断。
有三点你需要理解:
- Token 不等于单词。 在英文中,一个 token 大约等于 3/4 个单词。包含音调符号的中文通常为相同内容消耗更多 token。
- 费用按 token 计算。
max_tokens是成本和延迟的上限。 - 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 这样的枚举中,使代码易于阅读、易于测试,也便于在迁移到另一个模型时修改。