摘要: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 更需要大模型?

一个现实的场景:你的 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-urlapi-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:runcurl 测试 → 嵌入你的业务 Service。10 分钟从零到看到 AI 回复。

三条底线: ① 永远有备选模型(DeepSeek 挂了切 Kimi)② 永远限制并发(用 Semaphore,别让费用失控)③ 永远不硬编码 API Key(环境变量 + 不上传 Git)。

Logo

AtomGit AI 社区提供模型库、数据集、Agent、Token等资源

更多推荐