当 Java 遇见大模型:Spring Boot 集成 ChatGPT/DeepSeek API 实现智能业务助手
摘要:Java 开发者可能是最后一批用上大模型的人——不是因为不想用,而是因为生态不兼容。OpenAI 的 SDK 是 Python 的、LangChain 是 Python 的、连社区教程都是 Python 的。但生产环境里的订单系统、CRM、审批流全部跑在 Spring Boot 上。本文不教你在 Java 里复刻一个 LangChain,而是用 Spring Boot 原生方式做三件事:调用大模型 API(ChatGPT / DeepSeek / Kimi / 通义千问)、流式输出 + SSE 推前端、Function Calling 让 AI 调用你的业务方法。最终你会得到一个可直接嵌入现有项目的
llm-spring-boot-starter,含 Prompt 管理、Token 成本追踪、多模型降级和生产 Checklist。
目录
- 一、为什么 Java 更需要大模型?
- 二、调用大模型 API 的三层模型
- 三、最简调用:HTTP Client 直连 API
- 四、Streaming 流式响应 + SSE 推前端
- 五、Function Calling:让 AI 调用你的业务方法
- 六、生产级 Starter 封装
- 七、完整项目搭建
- 八、避坑指南
- 九、生产环境 Checklist
- 十、总结
一、为什么 Java 更需要大模型?
一个现实的场景:你的 Spring Boot 订单系统跑了 3 年,忽然产品经理说"加一个 AI 智能客服,用户输入自然语言自动查订单、退款、转人工"。
你一看文档:OpenAI SDK → Python、LangChain → Python、LlamaIndex → Python。Java 这边只有社区维护的 Spring AI(还在 milestone 阶段),以及一堆质量参差不齐的 SDK。
但事实是:Java 调用大模型根本不复杂。 OpenAI 系的 API(ChatGPT / DeepSeek / Kimi / 通义千问 / 智谱)全部兼容同一个 HTTP 接口格式,核心就是发一个 JSON、收一个 JSON。
| 你做过的 | 类比到 LLM 调用 |
|---|---|
RestTemplate.postForObject() |
就是调用 /v1/chat/completions |
| 对接微信支付回调 | 解 JSON 字段 → choices[0].message.content |
| 订单防重提交 | Token 预算控制 + 幂等 Key |
| 接口限流 | LLM 并发限制 + 多模型降级 |
核心认知: Java 开发者不需要学 Python 生态。LLM API 就是一个 HTTP 服务,你用已有的 Spring Boot 技能就能驾驭——而且能直接嵌入生产系统的鉴权、限流、监控体系。
模型怎么选?一张表讲清楚:
模型 输入/1M tokens 输出/1M tokens 适合场景 DeepSeek V3 ¥1 ¥2 首选——中文最流畅,Function Calling 完善,成本极低 通义千问 Qwen-Max ¥2.5 ¥10 需要多模态识图的场景 Kimi K2.6 ¥0.6 ¥2.5 128K 长上下文场景(本文场景不需要) GPT-5 nano $0.05 $0.40 OpenAI 最新轻量模型,英文场景首选 月成本估算(日均 500 次对话): DeepSeek ≈ ¥15/月 · Kimi ≈ ¥30/月 · GPT-5 nano ≈ $5(≈¥36)/月。推荐 DeepSeek V3 做主模型 + Kimi 做备选降级。
阅读导航:
- 想 5 分钟跑通第一个调用 → 跳 §3 最简调用
- 要做 AI 客服/对话功能 → 重点看 §4 Streaming + SSE
- 要做 AI 调用业务 API → 看 §5 Function Calling
- 要直接集成到项目 → 跳 §7 完整项目搭建
二、调用大模型 API 的三层模型
┌──────────────────────────────────────────────────┐
│ 业务层(你的代码) │
│ 智能客服 / 订单分析 / 审批摘要 / 邮件生成 │
└────────────────────┬─────────────────────────────┘
│
┌────────────────────┴─────────────────────────────┐
│ 能力层(本文封装) │
│ 多模型路由 / Streaming / Function Calling │
│ Prompt 模板 / Token 计数 / 重试 & 降级 │
└────────────────────┬─────────────────────────────┘
│
┌────────────────────┴─────────────────────────────┐
│ 传输层(HTTP Client) │
│ RestClient / WebClient / OkHttp │
│ POST /v1/chat/completions → JSON 解析 │
└──────────────────────────────────────────────────┘
一次 LLM 调用的 HTTP 数据流:
你的代码 LLM API
│ │
│ POST /v1/chat/completions │
│ {"model":"deepseek-chat", │
│ "messages":[...], │
│ "stream":false} │
│──────────────────────────────>│
│ │
│ 200 OK │
│ {"choices":[{"message":{ │
│ "content":"你好!..."}}]} │
│<──────────────────────────────│
三、最简调用:HTTP Client 直连 API
不需要任何第三方 LLM SDK,Spring Boot 3.2+ 内置的 RestClient 就够用。
3.1 模型配置
# application.yml
llm:
# 主模型(默认使用)
primary:
provider: deepseek
api-key: ${DEEPSEEK_API_KEY}
base-url: https://api.deepseek.com/v1
model: deepseek-chat
# 备选模型(primary 挂了自动切换)
fallback:
provider: moonshot
api-key: ${MOONSHOT_API_KEY}
base-url: https://api.moonshot.cn/v1
model: kimi-k2.6
3.2 ChatService:最简调用 + 自动降级
// src/main/java/com/example/llm/service/ChatService.java
package com.example.llm.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.*;
@Service
public class ChatService {
private static final Logger log = LoggerFactory.getLogger(ChatService.class);
private final RestClient primaryClient;
private final RestClient fallbackClient;
private final ObjectMapper mapper;
private final LlmProperties props;
public ChatService(LlmProperties props, ObjectMapper mapper) {
this.props = props;
this.mapper = mapper;
this.primaryClient = buildClient(props.getPrimary());
this.fallbackClient = buildClient(props.getFallback());
}
private RestClient buildClient(LlmProperties.ModelConfig config) {
return RestClient.builder()
.baseUrl(config.getBaseUrl())
.defaultHeader("Authorization", "Bearer " + config.getApiKey())
.defaultHeader("Content-Type", "application/json")
.build();
}
/**
* 同步调用 LLM,主模型失败自动降级到备选。
*/
public String chat(String systemPrompt, String userMessage) {
Map<String, Object> body = buildRequestBody(
props.getPrimary().getModel(), systemPrompt, userMessage);
String result = doChat(primaryClient, body, "primary");
if (result != null) return result;
// 主模型挂了,切换备选(改用备选的 model 名)
body.put("model", props.getFallback().getModel());
return doChat(fallbackClient, body, "fallback");
}
private String doChat(RestClient client, Map<String, Object> body, String label) {
try {
String resp = client.post()
.uri("/chat/completions")
.body(body)
.retrieve()
.body(String.class);
JsonNode root = mapper.readTree(resp);
return root.path("choices").get(0)
.path("message").path("content").asText();
} catch (Exception e) {
log.warn("[{}] 调用失败: {}", label, e.getMessage());
return null;
}
}
private Map<String, Object> buildRequestBody(
String model, String systemPrompt, String userMessage) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", model);
body.put("temperature", 0.7);
body.put("max_tokens", 2048);
List<Map<String, String>> messages = new ArrayList<>();
if (systemPrompt != null) {
messages.add(Map.of("role", "system", "content", systemPrompt));
}
messages.add(Map.of("role", "user", "content", userMessage));
body.put("messages", messages);
return body;
}
}
3.3 配置类
// src/main/java/com/example/llm/config/LlmProperties.java
package com.example.llm.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "llm")
public class LlmProperties {
private ModelConfig primary;
private ModelConfig fallback;
public static class ModelConfig {
private String provider;
private String apiKey;
private String baseUrl;
private String model;
private int maxTokens = 2048;
public String getProvider() { return provider; }
public void setProvider(String v) { this.provider = v; }
public String getApiKey() { return apiKey; }
public void setApiKey(String v) { this.apiKey = v; }
public String getBaseUrl() { return baseUrl; }
public void setBaseUrl(String v) { this.baseUrl = v; }
public String getModel() { return model; }
public void setModel(String v) { this.model = v; }
public int getMaxTokens() { return maxTokens; }
public void setMaxTokens(int v) { this.maxTokens = v; }
}
public ModelConfig getPrimary() { return primary; }
public void setPrimary(ModelConfig c) { this.primary = c; }
public ModelConfig getFallback() { return fallback; }
public void setFallback(ModelConfig c) { this.fallback = c; }
}
3.4 调用示例
// 在任意 Service 或 Controller 中构造器注入
private final ChatService chatService;
public YourService(ChatService chatService) {
this.chatService = chatService;
}
// 一次调用
String answer = chatService.chat(
"你是一个客服助手,用中文简短回答。",
"我的订单号 ORD-12345,什么时候发货?"
);
// → "您的订单 ORD-12345 预计 2026-07-10 发货,物流单号 SF-99887766。"
DeepSeek vs ChatGPT 切换: 只需改
application.yml中的base-url和api-key,代码零改动。所有兼容 OpenAI 接口格式的厂商(Kimi / 通义千问 / 智谱 / 百川)都可以用同一套代码。为什么不用 Spring AI? Spring AI 1.0 已于 2025 年正式发布,在向量检索、ETL、评估等功能上覆盖完整。但如果你的需求就是调 API + 流式 + Function Calling,原生 RestClient 仍然更轻量——零额外依赖,零学习成本,代码完全可控。选型建议:简单调用 → RestClient;需要向量库/RAG/多模型编排 → Spring AI。
三个关键提醒: ① RestClient 默认不设超时,生产须配置 connectTimeout ② 降级逻辑放在调用层,你能自定义"什么时候算挂了" ③
doChat返回 null 代表失败,但要区分 network error(切备选)和 4xx(可能是 Key 错了,不必切)
四、Streaming 流式响应 + SSE 推前端
同步调用的问题是:用户要等 5-10 秒才能看到完整回复。流式调用让 AI “边说边推”——体验和 ChatGPT 一样。
什么时候用流式? 用户交互场景(客服/对话)→ 必须流式,体验差别巨大。后台批处理场景(批量摘要/报表生成)→ 同步调用更简单,也无需 SseEmitter 开销。
4.1 核心原理
同步: Client ──POST──→ API ──等10秒──→ 完整返回
流式: Client ──POST(stream=true)──→ API ──逐 token 返回──→ SSE 推前端
↑ │
└──── "你" "好" "," "我" "是" "Kimi" ──┘
HTTP 流式响应的格式(SSE):
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: {"choices":[{"delta":{"content":","}}]}
...
data: [DONE]
4.2 服务端实现(SseEmitter)
// src/main/java/com/example/llm/service/StreamingChatService.java
package com.example.llm.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
@Service
public class StreamingChatService {
private final LlmProperties props;
private final ObjectMapper mapper;
// 复用单个 HttpClient(线程安全)
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final ExecutorService executor = Executors.newFixedThreadPool(10);
private final Semaphore rateLimiter = new Semaphore(5);
public StreamingChatService(LlmProperties props, ObjectMapper mapper) {
this.props = props;
this.mapper = mapper;
}
@PreDestroy
public void destroy() { executor.shutdownNow(); }
/**
* 流式调用 LLM,通过 SseEmitter 逐 token 推送前端。
*/
public SseEmitter streamChat(String system, String user, long timeoutSec) {
SseEmitter emitter = new SseEmitter(timeoutSec * 1000L);
executor.submit(() -> {
try {
rateLimiter.acquire();
doStream(emitter, system, user);
} catch (Exception e) {
send(emitter, "error", e.getMessage());
emitter.complete();
} finally {
rateLimiter.release();
}
});
return emitter;
}
private void doStream(SseEmitter emitter, String system, String user)
throws Exception {
var cfg = props.getPrimary();
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", cfg.getModel());
body.put("stream", true);
body.put("temperature", 0.7);
List<Map<String, String>> msgs = new ArrayList<>();
if (system != null && !system.isBlank()) {
msgs.add(Map.of("role", "system", "content", system));
}
msgs.add(Map.of("role", "user", "content", user));
body.put("messages", msgs);
String json = mapper.writeValueAsString(body);
var request = HttpRequest.newBuilder()
.uri(URI.create(cfg.getBaseUrl() + "/chat/completions"))
.header("Authorization", "Bearer " + cfg.getApiKey())
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.timeout(Duration.ofSeconds(120))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
var response = httpClient.send(request,
HttpResponse.BodyHandlers.ofInputStream());
try (var reader = new BufferedReader(
new InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
String line;
int tokenCount = 0;
while ((line = reader.readLine()) != null) {
if (line.isEmpty() || !line.startsWith("data: ")) continue;
if ("data: [DONE]".equals(line)) break;
String data = line.substring(6);
JsonNode node = mapper.readTree(data);
JsonNode delta = node.path("choices").get(0).path("delta");
JsonNode contentNode = delta.path("content");
if (contentNode.isMissingNode()) continue;
String content = contentNode.asText();
tokenCount++;
send(emitter, "token", content);
}
send(emitter, "done", "tokens=" + tokenCount);
} finally {
emitter.complete();
}
}
private void send(SseEmitter emitter, String name, String data) {
try {
emitter.send(SseEmitter.event().name(name).data(data));
} catch (Exception ignored) {
// 前端断开连接时忽略
}
}
}
4.3 Controller 暴露 SSE 接口
// src/main/java/com/example/llm/controller/ChatController.java
package com.example.llm.controller;
import com.example.llm.service.ChatService;
import com.example.llm.service.StreamingChatService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
@RestController
@RequestMapping("/api/ai")
public class ChatController {
private final ChatService chatService;
private final StreamingChatService streamingService;
public ChatController(ChatService chatService,
StreamingChatService streamingService) {
this.chatService = chatService;
this.streamingService = streamingService;
}
/** 同步调用 */
@PostMapping("/chat")
public Map<String, String> chat(@RequestBody ChatRequest req) {
String reply = chatService.chat(req.system(), req.message());
return Map.of("reply", reply);
}
/** 流式调用 — 前端用 EventSource 接收 */
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestParam String message,
@RequestParam(defaultValue = "60") long timeout) {
return streamingService.streamChat(
"你是一个智能助手。", message, timeout);
}
public record ChatRequest(String system, String message) {}
}
前端调流式接口(浏览器原生 API,无需任何库):
const eventSource = new EventSource('/api/ai/chat/stream?message=你好');
eventSource.addEventListener('token', (e) => {
document.getElementById('output').textContent += e.data;
});
eventSource.addEventListener('done', (e) => {
console.log('完成,' + e.data);
eventSource.close();
});
eventSource.addEventListener('error', (e) => {
console.error('出错:' + e.data);
eventSource.close();
});
五、Function Calling:让 AI 调用你的业务方法
这是 “AI 客服” 落地的关键——用户说 “查我的订单”,AI 不是凭空回答,而是调 OrderService.findById() 拿到真实数据后再回复。
5.1 整体流程
用户: "查询订单 ORD-2024"
↓
LLM 判断: 需要调用 get_order 函数,参数 orderId="ORD-2024"
↓
你的代码: Order order = orderService.findById("ORD-2024")
↓
返回给 LLM: {"status":"已发货","tracking":"SF-123"}
↓
LLM 生成: "您的订单已发货,物流单号 SF-123,预计明天到达。"
5.2 Tool 定义 + 执行引擎
// src/main/java/com/example/llm/service/FunctionCallingService.java
package com.example.llm.service;
import com.fasterxml.jackson.databind.*;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.function.Function;
@Service
public class FunctionCallingService {
private final LlmProperties props;
private final ObjectMapper mapper;
// 复用 HttpClient
private final HttpClient httpClient = HttpClient.newHttpClient();
// tool 名称 → 执行函数
private final Map<String, Function<JsonNode, String>> tools = new HashMap<>();
// tool 名称 → JSON Schema 定义
private final Map<String, Map<String, Object>> toolDefs = new HashMap<>();
public FunctionCallingService(LlmProperties props, ObjectMapper mapper) {
this.props = props;
this.mapper = mapper;
}
/** 注册一个 tool */
public void registerTool(String name, String description,
Map<String, Object> parameters,
Function<JsonNode, String> executor) {
toolDefs.put(name, Map.of(
"type", "function",
"function", Map.of(
"name", name,
"description", description,
"parameters", parameters)));
tools.put(name, executor);
}
/**
* 带 Function Calling 的对话。
* 循环:发请求 → LLM 返回 function_call → 执行业务方法 → 结果追加到 messages → 再发请求
*/
public String chatWithTools(String systemPrompt, String userMessage) {
List<Map<String, Object>> messages = new ArrayList<>();
if (systemPrompt != null) {
messages.add(Map.of("role", "system", "content", systemPrompt));
}
messages.add(Map.of("role", "user", "content", userMessage));
for (int round = 0; round < 5; round++) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", props.getPrimary().getModel());
body.put("messages", messages);
body.put("tools", new ArrayList<>(toolDefs.values()));
String resp = callApi(body);
JsonNode root;
try { root = mapper.readTree(resp); }
catch (Exception e) { return e.getMessage(); }
JsonNode choice = root.path("choices").get(0);
JsonNode msg = choice.path("message");
// 如果 LLM 直接回复(不调函数)
String content = msg.path("content").asText(null);
if (content != null && !content.isBlank()) {
return content;
}
// LLM 要调函数
JsonNode toolCalls = msg.path("tool_calls");
if (toolCalls.isEmpty()) {
return "没有返回有效内容。";
}
// 把 LLM 的 tool_calls 作为 assistant 消息追加
messages.add(mapper.convertValue(msg, Map.class));
// 执行每个 tool 调用
for (JsonNode tc : toolCalls) {
String fnName = tc.path("function").path("name").asText();
String fnArgs = tc.path("function").path("arguments").asText();
Function<JsonNode, String> fn = tools.get(fnName);
String result;
if (fn != null) {
try {
result = fn.apply(mapper.readTree(fnArgs));
} catch (Exception e) {
result = "错误: " + e.getMessage();
}
} else {
result = "未找到函数: " + fnName;
}
// 把函数执行结果作为 tool 消息追加
messages.add(Map.of(
"role", "tool",
"tool_call_id", tc.path("id").asText(),
"content", result));
}
}
return "达到最大工具调用轮次。";
}
private String callApi(Map<String, Object> body) {
try {
var cfg = props.getPrimary();
String json = mapper.writeValueAsString(body);
var request = HttpRequest.newBuilder()
.uri(URI.create(cfg.getBaseUrl() + "/chat/completions"))
.header("Authorization", "Bearer " + cfg.getApiKey())
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
return httpClient.send(request,
HttpResponse.BodyHandlers.ofString()).body();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
5.3 注册业务 Tool 示例
// 在 @Configuration 类中注册 tool
@Bean
public CommandLineRunner registerTools(FunctionCallingService fcs,
OrderService orderService) {
return args -> {
// Tool 1: 查询订单
fcs.registerTool(
"get_order",
"根据订单号查询订单状态、金额和物流信息",
Map.of(
"type", "object",
"properties", Map.of(
"orderId", Map.of("type", "string",
"description", "订单号,格式 ORD-开头")
),
"required", List.of("orderId")
),
argsNode -> {
String orderId = argsNode.path("orderId").asText();
Order order = orderService.findById(orderId);
return String.format("订单号=%s, 状态=%s, 金额=%.2f, 物流=%s",
order.id(), order.status(), order.amount(), order.tracking());
}
);
// Tool 2: 退款
fcs.registerTool(
"refund_order",
"对指定订单发起退款",
Map.of(
"type", "object",
"properties", Map.of(
"orderId", Map.of("type", "string",
"description", "要退款的订单号"),
"reason", Map.of("type", "string",
"description", "退款原因")
),
"required", List.of("orderId")
),
argsNode -> {
String orderId = argsNode.path("orderId").asText();
String reason = argsNode.path("reason").asText("用户要求");
String refundId = orderService.refund(orderId, reason);
return "退款成功,退款单号: " + refundId;
}
);
};
}
调用示例:
String reply = functionCallingService.chatWithTools(
"你是电商客服助手,可以用 get_order 和 refund_order 工具。",
"我的订单 ORD-12345 一直没到,帮我退款。"
);
// → "已为您发起退款,退款单号 RF-99887,预计 3-5 个工作日到账。"
为什么限制 5 轮工具调用? 实际场景中,查订单→发现已退款→结束,2 轮就够。但也可能查订单→发现异常→查物流→联系仓库→生成工单,需要 4 轮。5 轮覆盖 95% 场景,同时防止 AI 陷入死循环。
六、生产级 Starter 封装
把上面的能力封装成一个 llm-spring-boot-starter,用 @EnableLlm 一键引入。
6.1 目录结构
llm-spring-boot-starter/
├── pom.xml
└── src/main/java/com/example/llm/
├── LlmAutoConfiguration.java
├── EnableLlm.java
├── config/LlmProperties.java
├── service/
│ ├── ChatService.java
│ ├── StreamingChatService.java
│ └── FunctionCallingService.java
├── manager/
│ ├── PromptManager.java
│ └── TokenTracker.java
└── interceptor/
└── LlmRetryInterceptor.java
6.2 自动配置类
// LlmAutoConfiguration.java
package com.example.llm;
import com.example.llm.config.LlmProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(LlmProperties.class)
@ComponentScan(basePackages = "com.example.llm")
public class LlmAutoConfiguration {
}
6.3 启用注解
// EnableLlm.java
package com.example.llm;
import org.springframework.context.annotation.Import;
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Import(LlmAutoConfiguration.class)
public @interface EnableLlm {
}
6.4 Prompt 模板管理
// manager/PromptManager.java
package com.example.llm.manager;
import org.springframework.stereotype.Component;
import java.util.*;
@Component
public class PromptManager {
private final Map<String, String> templates = new LinkedHashMap<>();
public PromptManager() {
// 从 classpath:prompts/ 加载,或从数据库加载(生产推荐)
templates.put("customer_service", """
你是电商智能客服助手。
- 使用中文回复
- 如果用户问订单,调用 get_order 工具
- 如果用户要退款,先确认订单状态再调用 refund_order
- 语气亲切、专业
""");
templates.put("report_summary", """
你是一个数据分析助手。请用不超过200字总结以下数据的关键趋势。
数据:{{data}}
""");
}
public String get(String name) {
return templates.getOrDefault(name, "");
}
public String render(String name, Map<String, String> vars) {
String tpl = get(name);
for (var entry : vars.entrySet()) {
tpl = tpl.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
return tpl;
}
}
6.5 Token 成本追踪
// manager/TokenTracker.java
package com.example.llm.manager;
import org.springframework.stereotype.Component;
import java.util.*;
@Component
public class TokenTracker {
private long totalPromptTokens = 0;
private long totalCompletionTokens = 0;
private double totalCostCents = 0.0;
// 各模型价格(美分 / 1M tokens): [输入价, 输出价]
private static final Map<String, double[]> PRICING = Map.of(
"deepseek-chat", new double[]{14, 28},
"gpt-5-nano", new double[]{5, 40},
"kimi-k2.6", new double[]{60, 250}
);
public void record(String model, long promptTokens, long completionTokens) {
totalPromptTokens += promptTokens;
totalCompletionTokens += completionTokens;
double[] price = PRICING.getOrDefault(model, new double[]{50, 200});
totalCostCents += (promptTokens * price[0] + completionTokens * price[1]) / 1_000_000.0;
}
public Map<String, Object> getReport() {
return Map.of(
"promptTokens", totalPromptTokens,
"completionTokens", totalCompletionTokens,
"costCents", String.format("%.2f", totalCostCents),
"costYuan", String.format("%.4f", totalCostCents / 100.0 * 7.2)
);
}
}
七、完整项目搭建
项目结构
llm-spring-boot-demo/
├── pom.xml
├── src/main/resources/
│ └── application.yml
└── src/main/java/com/example/demo/
├── DemoApplication.java
├── controller/ChatController.java
├── service/
│ ├── OrderService.java
│ └── ToolRegistry.java
└── config/
└── LlmConfig.java
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>llm-spring-boot-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
</project>
DemoApplication.java
package com.example.demo;
import com.example.llm.EnableLlm;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@EnableLlm
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
启动命令
# 设置环境变量
export DEEPSEEK_API_KEY=sk-your-key
export MOONSHOT_API_KEY=sk-your-fallback-key
# 启动
mvn spring-boot:run
# 测试同步调用
curl -X POST http://localhost:8080/api/ai/chat \
-H "Content-Type: application/json" \
-d '{"system":"你是助手","message":"解释一下Spring Boot"}'
# 测试流式调用(用 curl -N 保持连接)
curl -N http://localhost:8080/api/ai/chat/stream?message=你好
八、避坑指南
| 坑 | 现象 | 解法 |
|---|---|---|
| 环境变量未设置 | 启动报 NPE | 确认 export 了 API Key |
@ConfigurationProperties 不生效 |
yml 配置读不到 | 加 @EnableConfigurationProperties(LlmProperties.class) |
| DeepSeek API 地址变更 | 404 Not Found | 确认 base-url: https://api.deepseek.com/v1 |
| 流式响应 504 超时 | Nginx 代理截断了 SSE | 加 proxy_buffering off; |
| Function Calling 不支持 | tools 字段被忽略 |
确认模型支持(DeepSeek V3 / GPT-4 以上) |
| 并发调用达上限 | 429 Too Many Requests | Semaphore 限流 + 指数退避重试 |
| Token 超限 | 返回截断内容 | 控制 max_tokens + 自动裁剪历史消息 |
| DeepSeek 返回中文乱码 | 显示问号 | HTTP 响应用 UTF-8 读取即可 |
九、生产环境 Checklist
┌─ 上线检查 ──────────────────────────────────────────────┐
│ │
│ □ [密钥] API Key 走环境变量,不硬编码不提交 Git │
│ □ [降级] 主模型挂了自动切备选(DeepSeek → Kimi → GPT) │
│ □ [限流] Semaphore 控制并发,防费用失控 │
│ □ [重试] 429/5xx 指数退避重试,最多 3 次 │
│ □ [成本] TokenTracker 实时统计,每日预算告警 │
│ □ [超时] HttpClient 设 connectTimeout + requestTimeout │
│ □ [Idempotent] 带 requestId,重复请求返回缓存结果 │
│ □ [Prompt] 模板存数据库,运营可改不需发版 │
│ □ [监控] 调用次数 / P99延迟 / 费用 接 Prometheus │
│ □ [日志] 只记录脱敏内容,不记录 API Key │
│ │
└──────────────────────────────────────────────────────────┘
十、总结
┌─────────────────────────────────────────────────────────────────┐
│ Spring Boot 集成大模型 — 五层能力速查 │
├────────────┬──────────────────────┬──────────────────────────────┤
│ 层级 │ 能力 │ 一句话总结 │
├────────────┼──────────────────────┼──────────────────────────────┤
│ 成本先行 │ 模型选型表 │ DeepSeek V3 首选,月成本 < ¥20 │
│ 选模型 │ DeepSeek→Kimi→GPT降级 │ Function Calling 用 DeepSeek │
├────────────┼──────────────────────┼──────────────────────────────┤
│ 基础调用 │ RestClient POST JSON │ 10 行代码发请求、解 response │
│ │ 多模型自动降级 │ 不用任何第三方 LLM SDK │
├────────────┼──────────────────────┼──────────────────────────────┤
│ 流式输出 │ SSE + SseEmitter │ 前端用 EventSource 原生接收 │
│ │ 并发 Semaphore 控制 │ Nginx 要关 proxy_buffering │
├────────────┼──────────────────────┼──────────────────────────────┤
│ Function │ tools 字段注册 Schema │ AI 调用你的 Service 方法 │
│ Calling │ 最多 5 轮 tool_use │ 查订单/退款/查询 真实落地 │
├────────────┼──────────────────────┼──────────────────────────────┤
│ 生产化 │ Prompt 模板 + Token 追踪│ 一键 @EnableLlm 引入项目 │
│ │ 降级/重试/限流/监控 │ Java 生态的生产级健壮性加持 │
└────────────┴──────────────────────┴──────────────────────────────┘
核心认知: LLM API 就是一个 HTTP 服务。Java 的优势不在于"调用方式有多简洁",而在于调用之后——鉴权、限流、降级、监控、事务一致——这些生产级能力是现成的。接入 LLM 只是增加一个 HTTP 调用点,你已有的运维体系零成本复用。
一键启动: 复制 §7 完整项目 → 设置两个环境变量 →
mvn spring-boot:run→curl测试 → 嵌入你的业务 Service。10 分钟从零到看到 AI 回复。三条底线: ① 永远有备选模型(DeepSeek 挂了切 Kimi)② 永远限制并发(用 Semaphore,别让费用失控)③ 永远不硬编码 API Key(环境变量 + 不上传 Git)。
更多推荐

所有评论(0)