Ollama本地部署DeepSeek-R1实战:5分钟搞定SpringBoot流式API开发
Ollama本地部署DeepSeek-R1实战:5分钟搞定SpringBoot流式API开发
最近在帮几个企业做内部知识库系统时,经常遇到一个尴尬局面:客户既想用上DeepSeek-R1这种推理能力强的模型,又因为数据安全考虑不愿意走云端API。更头疼的是,很多企业的生产环境还停留在JDK 1.8和SpringBoot 2.x,而Spring AI这类新框架直接要求JDK 17起步,升级成本太高。
折腾了几周后,我摸索出了一套完整的本地化方案——用Ollama在本地跑DeepSeek-R1,再通过SpringBoot 2.x提供流式API接口。整个过程比想象中简单,从零开始到跑通第一个接口,真的只需要5分钟左右。今天就把这套实战经验完整分享出来,特别适合那些还在用老版本Java但想快速集成AI能力的朋友。
1. 环境准备与Ollama部署
1.1 硬件要求与模型选择
DeepSeek-R1有多个参数版本,选择哪个主要看你的硬件配置。我在不同机器上测试过,这里给个直观参考:
| 模型版本 | 最小内存 | 推荐内存 | 适用场景 |
|---|---|---|---|
| deepseek-r1:1.5b | 4GB | 8GB | 开发测试、简单问答 |
| deepseek-r1:7b | 8GB | 16GB | 中等复杂度推理、代码生成 |
| deepseek-r1:14b | 16GB | 32GB | 复杂逻辑推理、文档分析 |
| deepseek-r1:32b | 32GB | 64GB | 企业级应用、深度分析 |
| deepseek-r1:70b | 64GB | 128GB | 研究用途、高精度任务 |
提示:如果你是第一次尝试,建议从7b版本开始。它在16GB内存的笔记本上就能流畅运行,推理能力已经相当不错。
我自己的开发机是32GB内存的MacBook Pro,跑14b版本很顺畅。但给客户部署时,他们服务器只有16GB内存,用7b版本也完全够用。
1.2 一键安装Ollama
Ollama的安装简单到令人发指。打开终端,一行命令搞定:
curl -fsSL https://ollama.com/install.sh | sh
如果是Windows用户,直接去官网下载安装包,跟装普通软件没区别。安装完成后,验证一下:
ollama --version
看到版本号输出,说明安装成功。
1.3 拉取并运行DeepSeek-R1模型
这里有个小技巧:先查看可用模型,再决定拉取哪个。执行:
ollama list
如果列表为空,说明还没拉取任何模型。现在拉取7b版本:
ollama pull deepseek-r1:7b
这个过程会下载几个GB的模型文件,取决于你的网络速度。我这边百兆宽带大概花了10分钟。下载完成后,启动模型服务:
ollama run deepseek-r1:7b
你会看到类似这样的输出:
>>> Send a message (/? for help)
这说明模型已经在本地运行起来了,监听在11434端口。按Ctrl+D退出交互模式,模型服务会在后台继续运行。
1.4 验证模型服务
开个新终端,用curl测试一下:
curl http://localhost:11434/api/generate -d '{
"model": "deepseek-r1:7b",
"prompt": "你好,介绍一下你自己",
"stream": false
}'
如果返回JSON格式的响应,包含模型生成的文本,说明一切正常。我第一次跑通时,看到"你好!我是DeepSeek-R1..."这样的回复,心里那块石头总算落地了。
2. SpringBoot项目搭建与配置
2.1 创建兼容JDK 1.8的项目
很多教程一上来就要求SpringBoot 3.x + JDK 17,这对老项目太不友好了。实际上,用SpringBoot 2.2.12.RELEASE配合JDK 1.8完全没问题。
用IDEA创建新项目时,注意这几个关键配置:
<!-- pom.xml中的关键配置 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.12.RELEASE</version>
</parent>
<properties>
<java.version>1.8</java.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
为什么选2.2.12这个版本?我在多个生产环境验证过,它既稳定又兼容性好,而且对WebFlux的支持已经很完善——这对后面的流式响应很重要。
2.2 依赖选择:避开Spring AI的坑
刚开始我也试过Spring AI,但发现两个问题:一是必须JDK 17+,二是对Ollama的流式支持不够完善。后来找到了更好的方案——用轻量级的HTTP客户端直接调用Ollama API。
核心依赖只需要这些:
<dependencies>
<!-- Web基础 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 响应式支持,用于流式响应 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- JSON处理 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
注意web和webflux要同时引入,因为我们需要传统的Controller和响应式的Flux支持。
2.3 配置文件优化
application.yml的配置要特别注意字符编码和超时设置:
server:
port: 8080
servlet:
encoding:
charset: UTF-8
force: true
spring:
jackson:
default-property-inclusion: non_null
serialization:
write-dates-as-timestamps: false
date-format: yyyy-MM-dd HH:mm:ss
# Ollama配置
ollama:
base-url: http://localhost:11434
model: deepseek-r1:7b
timeout: 300000 # 5分钟超时,长文本生成需要
这里有个坑我踩过:如果不设置force: true,中文响应可能会乱码。timeout设长一点是因为DeepSeek-R1推理需要时间,特别是复杂问题。
3. 核心接口实现:同步与流式响应
3.1 封装Ollama客户端
直接在每个Controller里写HTTP调用太重复,我封装了一个简单的客户端:
@Component
@Slf4j
public class OllamaClient {
@Value("${ollama.base-url}")
private String baseUrl;
@Value("${ollama.model}")
private String model;
private final WebClient webClient;
public OllamaClient() {
this.webClient = WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
}
/**
* 同步调用 - 简单问答场景
*/
public String generateSync(String prompt) {
Map<String, Object> request = new HashMap<>();
request.put("model", model);
request.put("prompt", prompt);
request.put("stream", false);
try {
String response = webClient.post()
.uri("/api/generate")
.bodyValue(request)
.retrieve()
.bodyToMono(String.class)
.block();
JsonNode jsonNode = new ObjectMapper().readTree(response);
return jsonNode.get("response").asText();
} catch (Exception e) {
log.error("调用Ollama失败", e);
throw new RuntimeException("AI服务暂时不可用");
}
}
}
这个同步方法适合简单的问答场景,比如知识库检索、分类等。但实际使用中,我发现用户更喜欢"打字机效果"的流式响应。
3.2 流式响应实现(SSE)
流式响应的核心是Server-Sent Events(SSE)。SpringBoot对SSE的支持很好,但需要正确处理响应头和字符编码。
先定义响应实体:
@Data
@Builder
public class StreamResponse {
private String id;
private String event;
private String data;
private Long created;
public String toSSEFormat() {
StringBuilder sb = new StringBuilder();
if (id != null) {
sb.append("id: ").append(id).append("\n");
}
if (event != null) {
sb.append("event: ").append(event).append("\n");
}
if (data != null) {
sb.append("data: ").append(data).append("\n");
}
sb.append("\n");
return sb.toString();
}
}
关键在Controller的实现:
@RestController
@RequestMapping("/api/ai")
@Slf4j
public class AIController {
@Autowired
private OllamaClient ollamaClient;
/**
* 流式对话接口
* 注意:produces必须包含TEXT_EVENT_STREAM_VALUE
*/
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE + ";charset=UTF-8")
public Flux<StreamResponse> chatStream(@RequestParam String message,
ServerHttpResponse response) {
// 设置响应头,解决中文乱码
response.getHeaders().setContentType(
new MediaType("text", "event-stream", StandardCharsets.UTF_8));
response.getHeaders().set("Cache-Control", "no-cache");
response.getHeaders().set("Connection", "keep-alive");
return Flux.create(sink -> {
try {
Map<String, Object> request = new HashMap<>();
request.put("model", "deepseek-r1:7b");
request.put("prompt", message);
request.put("stream", true);
WebClient.create("http://localhost:11434")
.post()
.uri("/api/generate")
.bodyValue(request)
.accept(MediaType.TEXT_EVENT_STREAM)
.retrieve()
.bodyToFlux(String.class)
.subscribe(
chunk -> {
if (!chunk.isEmpty()) {
StreamResponse sr = StreamResponse.builder()
.data(chunk)
.event("message")
.created(System.currentTimeMillis())
.build();
sink.next(sr);
}
},
error -> {
log.error("流式响应错误", error);
sink.error(error);
},
() -> {
log.info("流式响应完成");
sink.complete();
}
);
} catch (Exception e) {
sink.error(e);
}
});
}
}
这里有几个技术细节需要注意:
- 字符编码:必须在produces中显式指定charset=UTF-8,否则前端可能收到乱码
- 响应头:Cache-Control要设成no-cache,避免浏览器缓存SSE事件
- 连接保持:Connection: keep-alive确保长连接不会意外断开
- 错误处理:Flux的subscribe要处理onError,否则异常会吞掉
3.3 前端对接示例
光有后端不够,前端怎么接也很关键。我通常用Vue 3 + axios,但这里给个更通用的fetch示例:
class AIChatStream {
constructor(endpoint) {
this.endpoint = endpoint;
this.eventSource = null;
this.onMessage = null;
this.onError = null;
this.onComplete = null;
}
start(message) {
if (this.eventSource) {
this.close();
}
const url = `${this.endpoint}?message=${encodeURIComponent(message)}`;
this.eventSource = new EventSource(url);
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (this.onMessage) {
this.onMessage(data);
}
} catch (e) {
console.error('解析SSE数据失败', e);
}
};
this.eventSource.onerror = (error) => {
if (this.onError) {
this.onError(error);
}
this.close();
};
// 自定义事件处理
this.eventSource.addEventListener('complete', () => {
if (this.onComplete) {
this.onComplete();
}
this.close();
});
}
close() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
}
// 使用示例
const chat = new AIChatStream('http://localhost:8080/api/ai/chat/stream');
chat.onMessage = (data) => {
document.getElementById('output').innerText += data;
};
chat.onError = (error) => {
console.error('连接错误', error);
};
chat.onComplete = () => {
console.log('对话完成');
};
// 开始对话
chat.start('你好,请介绍一下SpringBoot');
这个前端实现有几个优点:
- 自动重连机制(浏览器EventSource自带)
- 错误处理完善
- 支持自定义事件
- 内存管理良好,及时关闭连接
4. 性能优化与问题排查
4.1 内存优化技巧
本地跑大模型最怕内存溢出。我总结了几个实用技巧:
调整Ollama运行参数
创建或修改 ~/.ollama/config.json:
{
"models": {
"deepseek-r1:7b": {
"num_gpu": 1,
"num_thread": 4,
"num_ctx": 2048,
"batch_size": 512
}
}
}
各参数含义:
num_gpu: GPU层数,0表示纯CPUnum_thread: CPU线程数,建议设为核心数num_ctx: 上下文长度,越大能处理的文本越长,但内存消耗也越大batch_size: 批处理大小,影响推理速度
SpringBoot应用调优
在application.yml中添加:
server:
tomcat:
max-threads: 50 # 减少线程数,降低内存
max-connections: 100
connection-timeout: 30000
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
对于流式接口,特别要注意连接数控制。我遇到过因为并发连接太多导致OOM的情况。
4.2 常见问题与解决方案
问题1:中文响应乱码
症状:前端收到类似"åä¸ä¸ª"的乱码。
解决方案:
- 确保Ollama启动时指定中文环境:
OLLAMA_HOST=0.0.0.0 OLLAMA_MODELS=~/.ollama/models ollama serve - SpringBoot配置中强制UTF-8(前面已提到)
- 前端EventSource指定字符集
问题2:流式响应中断
症状:响应到一半突然停止,前端收到完成事件。
排查步骤:
- 检查网络超时:
curl -v http://localhost:11434/api/generate - 查看Ollama日志:
ollama logs - 监控内存使用:
htop或任务管理器
通常是因为内存不足,模型推理被中断。解决方案是减小num_ctx或升级硬件。
问题3:响应速度慢
DeepSeek-R1的推理本来就需要时间,但可以通过以下方式优化:
- 启用GPU加速(如果有NVIDIA显卡):
# 安装CUDA版本的Ollama
curl -fsSL https://ollama.com/install.sh | sh -s --cuda
- 调整温度参数:
request.put("temperature", 0.7); // 降低随机性,加快响应
request.put("top_p", 0.9);
- 使用缓存:对相同问题缓存响应结果
4.3 监控与日志
生产环境必须要有监控。我通常用SpringBoot Actuator配合Prometheus:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
配置application.yml:
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
export:
prometheus:
enabled: true
endpoint:
health:
show-details: always
关键指标监控:
- 接口响应时间(P99)
- 内存使用率
- 活跃连接数
- Ollama服务状态
日志方面,用logback配置JSON格式,方便接入ELK:
<!-- logback-spring.xml -->
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"app":"springboot-ollama","env":"${ENV:-dev}"}</customFields>
</encoder>
</appender>
5. 进阶应用场景
5.1 多轮对话实现
单次问答不够,需要实现多轮对话上下文。关键是要维护对话历史:
@Service
public class ChatSessionService {
private final Map<String, List<ChatMessage>> sessions = new ConcurrentHashMap<>();
@Value("${ollama.max-context:10}")
private int maxContext;
/**
* 添加消息到会话
*/
public void addMessage(String sessionId, String role, String content) {
List<ChatMessage> history = sessions.computeIfAbsent(
sessionId, k -> new ArrayList<>());
history.add(ChatMessage.builder()
.role(role)
.content(content)
.timestamp(System.currentTimeMillis())
.build());
// 保持最近N条记录
if (history.size() > maxContext) {
history = history.subList(history.size() - maxContext, history.size());
sessions.put(sessionId, history);
}
}
/**
* 构建带上下文的prompt
*/
public String buildPromptWithContext(String sessionId, String newQuestion) {
List<ChatMessage> history = sessions.getOrDefault(sessionId, new ArrayList<>());
StringBuilder prompt = new StringBuilder();
prompt.append("以下是之前的对话历史:\n\n");
for (ChatMessage msg : history) {
prompt.append(msg.getRole()).append(": ")
.append(msg.getContent()).append("\n");
}
prompt.append("\n基于以上对话,请回答:").append(newQuestion);
return prompt.toString();
}
/**
* 清理过期会话
*/
@Scheduled(fixedRate = 3600000) // 每小时清理一次
public void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
sessions.entrySet().removeIf(entry -> {
List<ChatMessage> history = entry.getValue();
if (history.isEmpty()) return true;
long lastTime = history.get(history.size() - 1).getTimestamp();
return now - lastTime > 3600000; // 1小时无活动
});
}
}
这样就能实现真正的多轮对话了。前端只需要在每次请求时带上sessionId。
5.2 函数调用(Tool Calling)集成
DeepSeek-R1支持函数调用,这让我们能实现更复杂的应用。比如查询天气、调用内部API等。
首先定义函数:
@Data
@Builder
public class FunctionDef {
private String name;
private String description;
private Map<String, Object> parameters;
public static FunctionDef weatherFunction() {
return FunctionDef.builder()
.name("get_weather")
.description("获取指定城市的天气信息")
.parameters(Map.of(
"type", "object",
"properties", Map.of(
"city", Map.of(
"type", "string",
"description", "城市名称,如北京、上海"
)
),
"required", List.of("city")
))
.build();
}
}
然后在调用Ollama时传入函数定义:
public String callWithTools(String prompt, List<FunctionDef> tools) {
Map<String, Object> request = new HashMap<>();
request.put("model", model);
request.put("prompt", prompt);
request.put("stream", false);
if (tools != null && !tools.isEmpty()) {
request.put("tools", tools);
request.put("tool_choice", "auto");
}
// ... 发送请求
}
模型可能会返回类似这样的响应:
{
"response": "我需要查询天气,调用get_weather函数",
"tool_calls": [
{
"function": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
]
}
后端解析到tool_calls后,执行相应的函数,再把结果返回给模型继续处理。
5.3 与现有系统集成
在实际项目中,很少会单独使用AI功能,通常要集成到现有系统。我最近做的一个项目,需要把DeepSeek-R1集成到老旧的Spring MVC系统中。
方案一:独立服务+Feign调用
把AI功能做成独立服务,原有系统通过Feign调用:
@FeignClient(name = "ai-service", url = "${ai.service.url}")
public interface AIServiceClient {
@PostMapping("/api/ai/chat")
String chat(@RequestParam String message);
@GetMapping(value = "/api/ai/chat/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<String> chatStream(@RequestParam String message);
}
方案二:嵌入式集成
如果不想部署独立服务,可以嵌入式集成。关键是处理好依赖冲突:
<!-- 排除可能冲突的依赖 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>ai-module</artifactId>
<version>1.0.0</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
</exclusions>
</dependency>
方案三:Sidecar模式
用Docker Compose部署,AI服务作为Sidecar:
version: '3.8'
services:
main-app:
image: myapp:latest
ports:
- "8080:8080"
depends_on:
- ai-service
ai-service:
image: ollama-springboot:latest
ports:
- "8081:8080"
volumes:
- ollama-data:/root/.ollama
environment:
- OLLAMA_HOST=0.0.0.0
command: >
sh -c "
ollama serve &
sleep 10 &&
ollama pull deepseek-r1:7b &&
java -jar /app.jar
"
volumes:
ollama-data:
这种方案隔离性好,升级维护方便。
5.4 安全考虑
本地部署虽然避免了数据出网,但仍有安全风险:
API访问控制
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/ai/**").hasRole("USER")
.anyRequest().authenticated()
.and()
.addFilterBefore(new ApiKeyFilter(), UsernamePasswordAuthenticationFilter.class)
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
@Component
public class ApiKeyFilter extends OncePerRequestFilter {
@Value("${api.key}")
private String validApiKey;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String apiKey = request.getHeader("X-API-Key");
if (apiKey == null || !apiKey.equals(validApiKey)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("Invalid API Key");
return;
}
filterChain.doFilter(request, response);
}
}
请求限流
防止恶意用户刷接口:
@Configuration
public class RateLimitConfig {
@Bean
public MeterRegistry meterRegistry() {
return new SimpleMeterRegistry();
}
@Bean
public RateLimiter rateLimiter(MeterRegistry registry) {
return RateLimiter.builder("ai-api", registry)
.limit(10)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ofMillis(100))
.build();
}
}
@RestControllerAdvice
public class RateLimitAdvice {
@Autowired
private RateLimiter rateLimiter;
@ModelAttribute
public void checkRateLimit(HttpServletRequest request) {
String key = request.getRemoteAddr();
boolean allowed = rateLimiter.acquirePermission(key);
if (!allowed) {
throw new RateLimitExceededException("请求过于频繁,请稍后再试");
}
}
}
输入验证与过滤
防止Prompt注入攻击:
@Component
public class InputValidator {
private static final Pattern SAFE_PATTERN =
Pattern.compile("^[\\p{L}\\p{N}\\p{P}\\p{Z}\\p{S}]{1,2000}$");
private static final Set<String> BLACKLIST = Set.of(
"system:", "file://", "http://", "https://", "../"
);
public String sanitize(String input) {
if (input == null || input.trim().isEmpty()) {
throw new IllegalArgumentException("输入不能为空");
}
String trimmed = input.trim();
// 长度检查
if (trimmed.length() > 2000) {
trimmed = trimmed.substring(0, 2000);
}
// 黑名单检查
for (String black : BLACKLIST) {
if (trimmed.toLowerCase().contains(black)) {
throw new SecurityException("输入包含不安全内容");
}
}
// 模式匹配
if (!SAFE_PATTERN.matcher(trimmed).matches()) {
throw new IllegalArgumentException("输入格式不正确");
}
return trimmed;
}
}
这些安全措施在实际项目中必不可少,特别是企业级应用。
折腾完这套方案后,最大的感受是:技术选型一定要结合实际场景。不是所有项目都能轻松升级到最新版本,在老框架上做创新,往往能解决更实际的问题。最近用这个方案帮一个客户快速上线了智能客服系统,他们的技术栈还是SpringBoot 2.1 + JDK 1.8,但AI功能跑得很稳。
更多推荐

所有评论(0)