Java 后端接入大模型:从 Token、并发到推理成本的完整估算方法
摘要:大模型接口不是普通 HTTP 接口,Java 后端接入前不能只看 QPS,还要看输入 Token、输出 Token、首字延迟、整体耗时、重试和峰值并发。本文用一套可落地的估算方法,说明如何在 Spring Boot 服务里记录 LLM 调用耗时、Token 用量和成本,并给出上线前的限流、缓存、降级和监控检查清单。示例代码用于工程设计说明,成本单价请以你使用的模型供应商最新官方计费为准。
1. 为什么大模型接口不能按普通 HTTP 接口估算
很多后端系统接第三方接口时,习惯先问两个指标:
- QPS 能到多少;
- 平均 RT 是多少。
接大模型接口时,这两个指标还不够。
原因是 LLM 调用的成本和耗时都不是固定值。一条请求可能只问一句话,也可能带上几千字上下文;一次回答可能只有 100 个字,也可能输出一整段代码。输入越长、输出越长,推理时间和费用通常都会上升。
更准确的估算要看这几个变量:
| 指标 | 含义 | 为什么重要 |
|---|---|---|
| 输入 Token | Prompt、上下文、历史消息、检索结果 | 影响请求成本和模型处理时间 |
| 输出 Token | 模型实际生成的内容长度 | 影响成本、接口耗时和用户等待时间 |
| 首字延迟 | 从请求发出到第一个 token 返回 | 影响流式输出体验 |
| 总耗时 | 完整回答生成完成的时间 | 影响接口超时和线程占用 |
| 峰值并发 | 同一时间挂起的 LLM 请求数 | 影响连接池、线程池和预算消耗 |
| 重试次数 | 超时、限流或网络异常后的再次调用 | 直接放大成本和流量 |
所以,Java 后端接入大模型前,至少要回答三个问题:
2. 一个最小成本公式
大模型接口常见计费方式是按输入 Token 和输出 Token 分别计费。不同供应商、不同模型、不同缓存策略的价格不一样,但估算公式大体可以抽象成:
单次调用成本 = 输入 Token 数 * 输入单价 + 输出 Token 数 * 输出单价
如果要估算一天成本:
日成本 = 日调用次数 * 单次平均成本 * 重试放大系数
如果要估算峰值预算:
峰值小时成本 = 峰值小时调用次数 * 单次 P95 成本 * 重试放大系数
这里建议不要只用平均值。上线前至少记录三组口径:
| 口径 | 用途 |
|---|---|
| avg | 估算日常预算 |
| p95 | 估算高峰期和容量风险 |
| max | 发现异常 Prompt、超长上下文和失控输出 |
一个简单示例:
平均输入:1200 tokens
平均输出:500 tokens
输入单价:每 1000 tokens 0.001 元
输出单价:每 1000 tokens 0.004 元
单次成本 = 1200 / 1000 * 0.001 + 500 / 1000 * 0.004
= 0.0012 + 0.002
= 0.0032 元
这只是示例价格,不代表任何供应商真实报价。工程里不要把价格写死在代码里,建议放到配置中心,按模型维度维护。
3. 环境和前置条件
本文示例按下面技术栈理解:
| 项目 | 版本/说明 |
|---|---|
| JDK | 17 或 21 |
| Spring Boot | 3.x |
| HTTP Client | JDK HttpClient、OkHttp、WebClient 均可 |
| 调用方式 | 普通非流式接口示例 |
| 成本数据 | 来自模型接口返回的 usage 字段,或网关侧统计 |
验证状态说明:
- Token 计费公式来自主流大模型 API 的通用计费模型,具体字段名和单价以供应商官方文档为准。
- Java 示例代码用于说明后端工程落地方式,未绑定某一个供应商 SDK。
- 如果你的接口经过 LiteLLM、OpenRouter、自建模型网关或公司内部网关,优先在网关层统一记录 Token 和费用。
4. 推荐先定义一份调用指标模型
不要等接完模型再补监控。建议先定义 LLM 调用记录模型,后续无论换供应商、换模型还是接入网关,都能沉淀统一指标。
java
`import java.math.BigDecimal;
import java.time.Instant;
public class LlmCallMetric {
private String requestId;
private String bizType;
private String provider;
private String model;
private int promptTokens;
private int completionTokens;
private int totalTokens;
private long firstTokenLatencyMs;
private long totalLatencyMs;
private boolean success;
private String errorCode;
private BigDecimal estimatedCost;
private Instant createdAt;
// getter/setter 省略
}
`
字段建议不要只记 totalTokens。输入和输出要拆开,因为两者价格通常不同,优化手段也不同:
- 输入 Token 高:重点优化 Prompt、历史消息、RAG 返回片段、系统提示词;
- 输出 Token 高:重点限制
max_tokens,优化回答格式,控制模型啰嗦程度; - 总耗时高:重点看模型、供应商、网络、输出长度和是否流式返回;
- 失败率高:重点看限流、超时、鉴权、网关错误和重试策略。
5. 用配置维护模型价格,不要写死在代码里
可以先用 application.yml 做最小配置,生产环境建议迁移到配置中心。
llm:
cost:
models:
gpt-4o-mini:
input-price-per-1k: 0.001
output-price-per-1k: 0.004
qwen-plus:
input-price-per-1k: 0.0008
output-price-per-1k: 0.002
guardrail:
max-prompt-tokens: 6000
max-completion-tokens: 1200
daily-budget: 200
对应的配置类可以这样写:
java
`import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "llm.cost")
public class LlmCostProperties {
private Map<String, ModelCost> models = new HashMap<>();
public Map<String, ModelCost> getModels() {
return models;
}
public void setModels(Map<String, ModelCost> models) {
this.models = models;
}
public static class ModelCost {
private BigDecimal inputPricePer1k;
private BigDecimal outputPricePer1k;
public BigDecimal getInputPricePer1k() {
return inputPricePer1k;
}
public void setInputPricePer1k(BigDecimal inputPricePer1k) {
this.inputPricePer1k = inputPricePer1k;
}
public BigDecimal getOutputPricePer1k() {
return outputPricePer1k;
}
public void setOutputPricePer1k(BigDecimal outputPricePer1k) {
this.outputPricePer1k = outputPricePer1k;
}
}
}
`
成本计算服务:
java
`import java.math.BigDecimal;
import java.math.RoundingMode;
public class LlmCostCalculator {
private final LlmCostProperties properties;
public LlmCostCalculator(LlmCostProperties properties) {
this.properties = properties;
}
public BigDecimal calculate(String model, int promptTokens, int completionTokens) {
LlmCostProperties.ModelCost cost = properties.getModels().get(model);
if (cost == null) {
throw new IllegalArgumentException("Unknown model cost config: " + model);
}
BigDecimal inputCost = BigDecimal.valueOf(promptTokens)
.divide(BigDecimal.valueOf(1000), 6, RoundingMode.HALF_UP)
.multiply(cost.getInputPricePer1k());
BigDecimal outputCost = BigDecimal.valueOf(completionTokens)
.divide(BigDecimal.valueOf(1000), 6, RoundingMode.HALF_UP)
.multiply(cost.getOutputPricePer1k());
return inputCost.add(outputCost).setScale(6, RoundingMode.HALF_UP);
}
}
`
这个服务不依赖具体模型供应商。只要你能拿到 promptTokens 和 completionTokens,就能统一估算成本。
6. 在调用层记录耗时、Token 和异常
大多数模型 API 会在响应里返回 usage 信息,例如:
{
"model": "example-model",
"choices": [
{
"message": {
"role": "assistant",
"content": "..."
}
}
],
"usage": {
"prompt_tokens": 1200,
"completion_tokens": 500,
"total_tokens": 1700
}
}
Java 调用层可以按下面方式记录指标。这里用伪客户端接口表示真实模型调用,方便把重点放在指标采集上。
java
`import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
public class LlmClientWrapper {
private final RawLlmClient rawLlmClient;
private final LlmCostCalculator costCalculator;
private final LlmMetricRepository metricRepository;
public LlmClientWrapper(RawLlmClient rawLlmClient,
LlmCostCalculator costCalculator,
LlmMetricRepository metricRepository) {
this.rawLlmClient = rawLlmClient;
this.costCalculator = costCalculator;
this.metricRepository = metricRepository;
}
public String chat(String requestId, String bizType, String model, String prompt) {
Instant start = Instant.now();
LlmCallMetric metric = new LlmCallMetric();
metric.setRequestId(requestId);
metric.setBizType(bizType);
metric.setProvider("model-gateway");
metric.setModel(model);
metric.setCreatedAt(start);
try {
LlmResponse response = rawLlmClient.chat(model, prompt);
long totalLatencyMs = Duration.between(start, Instant.now()).toMillis();
int promptTokens = response.usage().promptTokens();
int completionTokens = response.usage().completionTokens();
BigDecimal cost = costCalculator.calculate(model, promptTokens, completionTokens);
metric.setPromptTokens(promptTokens);
metric.setCompletionTokens(completionTokens);
metric.setTotalTokens(response.usage().totalTokens());
metric.setTotalLatencyMs(totalLatencyMs);
metric.setEstimatedCost(cost);
metric.setSuccess(true);
return response.content();
} catch (Exception ex) {
metric.setSuccess(false);
metric.setErrorCode(ex.getClass().getSimpleName());
metric.setTotalLatencyMs(Duration.between(start, Instant.now()).toMillis());
throw ex;
} finally {
metricRepository.save(metric);
}
}
}
`
RawLlmClient 和 LlmMetricRepository 可以先定义成接口:
java
`public interface RawLlmClient {
LlmResponse chat(String model, String prompt);
}
public interface LlmMetricRepository {
void save(LlmCallMetric metric);
}
public record LlmResponse(String content, Usage usage) {
}
public record Usage(int promptTokens, int completionTokens, int totalTokens) {
}
`
如果你使用的是流式接口,还要额外记录首字延迟。典型做法是在收到第一个 chunk 时记录 firstTokenLatencyMs,在流结束时记录 totalLatencyMs。
7. 上线前先做一张容量估算表
接入大模型前,建议至少准备一张这样的估算表:
| 业务场景 | 日调用量 | 峰值 QPS | 平均输入 Token | 平均输出 Token | P95 耗时 | 单日预算 |
|---|---|---|---|---|---|---|
| 智能客服摘要 | 10000 | 5 | 1500 | 300 | 4s | 按配置计算 |
| 文章标题生成 | 2000 | 2 | 800 | 120 | 2s | 按配置计算 |
| 代码解释助手 | 1000 | 1 | 3000 | 800 | 8s | 按配置计算 |
然后按下面公式换算峰值并发:
峰值并发数 ≈ 峰值 QPS * P95 接口耗时秒数
例如:
峰值 QPS = 5
P95 耗时 = 8 秒
峰值并发数 ≈ 40
这意味着同一时间可能有 40 个请求挂在模型调用上。如果你是同步阻塞调用,就要检查:
- Tomcat/Undertow 工作线程是否会被占满;
- HTTP Client 连接池是否足够;
- 上游接口超时时间是否合理;
- 网关是否有整体超时;
- 用户是否能接受 8 秒等待。
对于耗时较长的任务,例如报告生成、代码解释、长文摘要,通常不建议一直同步阻塞。更稳的方式是改成异步任务:
用户提交任务
-> 立即返回 taskId
-> 后端异步调用 LLM
-> 前端轮询或 WebSocket 获取结果
8. 成本失控通常不是模型贵,而是上下文失控
LLM 成本失控最常见的几个原因:
| 问题 | 典型表现 | 处理方式 |
|---|---|---|
| 历史消息无限追加 | 对话越久越慢、越贵 | 做消息窗口和摘要压缩 |
| RAG 返回片段过多 | 每次输入 token 很高 | 限制 topK,做重排和截断 |
| System Prompt 过长 | 所有请求都带一大段固定文本 | 拆分场景 Prompt,删除无效规则 |
| 输出不受控 | 模型生成长篇解释 | 设置 max_tokens 和输出格式 |
| 异常重试过多 | 失败时成本倍增 | 区分可重试和不可重试错误 |
上线前可以加一层 Prompt 预算检查:
java
`public class LlmRequestGuard {
private final int maxPromptTokens;
private final TokenEstimator tokenEstimator;
public LlmRequestGuard(int maxPromptTokens, TokenEstimator tokenEstimator) {
this.maxPromptTokens = maxPromptTokens;
this.tokenEstimator = tokenEstimator;
}
public void checkPrompt(String prompt) {
int estimatedTokens = tokenEstimator.estimate(prompt);
if (estimatedTokens > maxPromptTokens) {
throw new IllegalArgumentException(
"Prompt too long, estimated tokens=" + estimatedTokens
+ ", max=" + maxPromptTokens);
}
}
}
`
TokenEstimator 可以先做粗略估算,生产环境最好使用模型供应商推荐的 tokenizer 或网关侧真实 usage 回填。
java
`public interface TokenEstimator {
int estimate(String text);
}
public class SimpleTokenEstimator implements TokenEstimator {
@Override
public int estimate(String text) {
if (text == null || text.isBlank()) {
return 0;
}
return Math.max(1, text.length() / 2);
}
}
`
注意:text.length() / 2 只是非常粗的兜底估算,不适合做精确计费。它的价值是提前拦截明显过长的请求,真实成本仍应以接口返回 usage 为准。
9. 超时、重试和降级要提前定规则
大模型接口慢,不一定是异常。模型输出越长,耗时越长,这是正常现象。但后端系统不能无限等待。
建议按业务类型设置不同超时:
| 场景 | 建议策略 |
|---|---|
| 在线问答 | 短超时 + 流式输出 + 失败提示 |
| 后台摘要 | 中等超时 + 异步任务 + 可重试 |
| 批量生成 | 队列削峰 + 并发限制 + 预算控制 |
| 核心交易链路 | 尽量不要强依赖 LLM 实时返回 |
重试策略要特别谨慎。不是所有错误都应该重试:
| 错误类型 | 是否建议重试 |
|---|---|
| 网络瞬断 | 可以有限重试 |
| 429 限流 | 等待退避后重试,或降级 |
| 5xx 服务异常 | 可以有限重试 |
| 400 参数错误 | 不重试 |
| 上下文超长 | 不重试,先裁剪输入 |
| 余额不足 / 权限错误 | 不重试,告警处理 |
一个简单的降级顺序:
优先使用高质量模型
-> 超时或预算紧张时切换便宜模型
-> 仍失败时返回模板化兜底结果
-> 非核心功能可提示稍后重试
如果你通过模型网关统一调用,可以把降级规则放在网关层;如果是业务服务直连模型供应商,至少也要在业务服务里把超时、重试和预算保护写清楚。
10. 监控指标建议
LLM 接入上线后,建议至少看下面几类指标:
llm_request_total{bizType,model,success}
llm_latency_ms{bizType,model}
llm_prompt_tokens{bizType,model}
llm_completion_tokens{bizType,model}
llm_estimated_cost{bizType,model}
llm_error_total{bizType,model,errorCode}
如果用 Micrometer,可以在保存指标时同步打点:
java
`import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
public class LlmMetricsRecorder {
private final MeterRegistry meterRegistry;
public LlmMetricsRecorder(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
public void record(LlmCallMetric metric) {
Tags tags = Tags.of(
"bizType", metric.getBizType(),
"model", metric.getModel(),
"success", String.valueOf(metric.isSuccess())
);
meterRegistry.counter("llm_request_total", tags).increment();
meterRegistry.timer("llm_latency", tags).record(
java.time.Duration.ofMillis(metric.getTotalLatencyMs())
);
meterRegistry.summary("llm_prompt_tokens", tags).record(metric.getPromptTokens());
meterRegistry.summary("llm_completion_tokens", tags).record(metric.getCompletionTokens());
meterRegistry.summary("llm_estimated_cost", tags).record(metric.getEstimatedCost().doubleValue());
}
}
`
告警可以先从三类开始:
- 费用告警:小时成本或日成本超过阈值;
- 性能告警:P95/P99 耗时持续升高;
- 稳定性告警:限流、超时、5xx、余额不足等错误增加。
11. 上线前检查清单
最后给一份 Java 后端接入大模型前的检查清单:
| 检查项 | 是否必须 | 说明 |
|---|---|---|
| Token usage 记录 | 是 | 至少记录输入、输出、总 Token |
| 成本配置化 | 是 | 不要把模型单价写死在代码里 |
| 超时配置 | 是 | 区分在线接口和后台任务 |
| 重试上限 | 是 | 防止失败请求放大成本 |
| Prompt 长度限制 | 是 | 防止上下文失控 |
| 输出长度限制 | 是 | 控制耗时和费用 |
| 业务维度监控 | 是 | 按 bizType 统计,不要只看总量 |
| 降级策略 | 建议 | 高峰、超时、预算紧张时要有退路 |
| 异步任务化 | 视场景 | 长耗时生成任务不要长期阻塞请求线程 |
| 日预算告警 | 建议 | 早期尤其重要 |
如果只能先做三件事,优先做:
12. 总结
Java 后端接入大模型,真正的难点不是把 HTTP 请求调通,而是把成本、延迟、并发和失败策略纳入工程治理。
普通接口通常按 QPS 和 RT 估算容量;LLM 接口还必须把 Token、输出长度、重试、峰值并发和预算一起算进去。否则 Demo 阶段看起来没问题,一旦流量上来,就容易遇到接口变慢、线程被占满、账单超预期、重试放大故障这些问题。
更稳的落地方式是:先统一记录 usage 和耗时,再配置化管理模型单价和预算,最后按业务场景设计限流、缓存、异步化和降级策略。
如果你关注 Java 后端、Spring Boot、MCP/Agent 落地和线上问题排查,可以关注我的 CSDN 专栏,后面会继续拆解更接近生产环境的 Java AI 工程实践。