本地运行 LLM:何时应该自托管模型
1. 概述
调用云上 LLM 供应商的 API 是将 AI 引入应用最快的方式。越来越多团队想要在自己的基础设施上运行模型,可能是笔记本电脑、内部服务器或租用的 GPU。
在这篇文章中,我们将了解“本地运行 LLM”实际上包含什么以及为什么这个趋势备受关注。之后你将搭建一个本地模型,从 Spring Boot 应用中调用它,并为集成部分编写测试。最后是帮助你决策何时应该自托管、何时不应该的标准。
2. “本地运行 LLM”是什么意思?
2.1. 三个基本组件
自托管 LLM 系统通常包含三层:
- Model weights:模型的权重文件。你通常从公共仓库下载开放权重模型。
- Inference runtime:将权重加载到 RAM 或 VRAM 并生成 token 的软件。例如 Ollama、llama.cpp 或 vLLM。
- API layer:供应用调用的 HTTP 端点。许多 runtime 提供兼容 OpenAI 标准的 API,因此客户端代码几乎无需修改。
你的应用只能看到第三层。这很重要,这样以后你可以在本地和云之间切换,而不需要重写业务逻辑。
2.2. 量化和内存
决定“我的机器能跑吗”的因素是内存。你可以粗略估计权重所需的内存:
权重内存 ≈ 参数数量 × 每个参数的字节数
16-bit (FP16): 2 字节/参数
8-bit: 1 字节/参数
4-bit: ~0.5 字节/参数
除了权重,你还需要空间用于 KV cache。这部分会随着上下文长度和并行请求数增加。量化是一种降低数值精度的技术,例如从 16-bit 降到 4-bit,使模型体积减小许多倍。代价是答案质量会略有下降。对于许多任务如分类、摘要或信息抽取,这种下降通常是可以接受的。
3. 为什么自托管 LLM 受到关注?
有四个主要动力:
- 隐私和合规:客户数据、合同或内部源代码无需离开你的基础设施。
- 可预测的成本:云 API 按 token 计费。在高负载和稳定的情况下,一台 GPU 的固定成本可能比按 token 支付更便宜。
- 低延迟和离线运行能力:没有网络往返,不依赖第三方的速率限制,可以在隔离环境中运行。
- 开放权重模型越来越好:小模型已经足够完成许多专业任务。你也可以针对自己的领域对它们进行微调。
注意: “本地”不一定是你的笔记本电脑。一个私有 VPC 中的 GPU 集群也算是自托管。核心点是你控制模型和数据。
4. 用 Ollama 搭建本地模型
Ollama 是开始的简单选择。它打包了 runtime、管理模型并提供 HTTP API。你可以用 Docker Compose 运行它:
services:
ollama:
image: ollama/ollama
ports:
- "11434:11434"
volumes:
- ollama-data:/root/.ollama
restart: unless-stopped
volumes:
ollama-data:
卷 ollama-data 保留已加载的模型。这样你就不必在每次容器重启时重新下载。端口 11434 是 Ollama API 的默认端口。
接下来,你下载一个模型并尝试调用:
docker compose up -d
docker compose exec ollama ollama pull llama3.2
curl http://localhost:11434/api/chat -d '{
"model": "llama3.2",
"messages": [{"role": "user", "content": "摘要:订单延迟送达 3 天,客户要求退款。"}],
"stream": false
}'
你可以用任何适合你硬件的模型替换 llama3.2。设置 "stream": false 时,结果以单个 JSON 返回,形式如下:
{
"model": "llama3.2",
"message": { "role": "assistant", "content": "客户因订单延迟 3 天而要求退款。" },
"done": true,
...
}
如果你的机器有 NVIDIA GPU,你需要安装 NVIDIA Container Toolkit 并为容器分配 GPU。否则,模型将在 CPU 上运行。这样也可以,但速度会慢很多。
5. 从 Spring Boot 调用本地模型
这部分的例子是一个现实场景:为客户支持消息起草回复。消息数据完全保留在内部基础设施中。
5.1. 依赖和配置
你只需要 Spring Web 中内置的 RestClient。不需要额外的 AI 库:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
在 application.properties 中声明配置:
local-llm.base-url=http://localhost:11434
local-llm.model=llama3.2
local-llm.timeout=60s
超时需要设置得宽松一点。本地运行的模型,尤其是在 CPU 上,可能需要数十秒才能生成较长的答案。
5.2. Properties 和 RestClient
@ConfigurationProperties(prefix = "local-llm")
public record LocalLlmProperties(String baseUrl, String model, Duration timeout) {}
@Configuration
@EnableConfigurationProperties(LocalLlmProperties.class)
public class LlmClientConfig {
@Bean
RestClient ollamaRestClient(RestClient.Builder builder, LocalLlmProperties props) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
factory.setReadTimeout(props.timeout());
return builder
.baseUrl(props.baseUrl())
.requestFactory(factory)
.build();
}
}
这里的两种超时有不同的作用。连接超时很短(5 秒),可以快速检测 Ollama 何时未运行。读取超时很长,以等待模型完成答案生成。
5.3. DTO 和 service
public record ChatMessage(String role, String content) {}
public record ChatRequest(String model, List<ChatMessage> messages, boolean stream) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ChatResponse(ChatMessage message, boolean done) {}
@Service
public class SupportReplyService {
private static final String SYSTEM_PROMPT =
"你是客户支持员工。请礼貌、简洁地用中文回答。";
private final RestClient ollama;
private final String model;
public SupportReplyService(RestClient ollamaRestClient, LocalLlmProperties props) {
this.ollama = ollamaRestClient;
this.model = props.model();
}
public String draftReply(String customerMessage) {
ChatRequest request = new ChatRequest(model, List.of(
new ChatMessage("system", SYSTEM_PROMPT),
new ChatMessage("user", customerMessage)
), false);
ChatResponse response = ollama.post()
.uri("/api/chat")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(ChatResponse.class);
if (response == null || response.message() == null) {
throw new IllegalStateException("LLM 未返回内容");
}
return response.message().content().strip();
}
}
draftReply 的处理流程如下:
- Service 组合一个
system消息来指导语气,然后将客户消息添加为user消息。 stream设置为false,所以 Ollama 在一个 response 中返回完整答案。@JsonIgnoreProperties忽略 service 不需要的统计字段,如处理时间或 token 数。- 空 response 被转换为明确的异常,而不是暴露难以理解的
NullPointerException。
6. 集成测试
你不应该让单元测试依赖真实模型,因为那样会很慢且结果不确定。相反,使用 MockRestServiceServer 来验证 service 发送正确的请求并读取正确的响应:
class SupportReplyServiceTest {
private MockRestServiceServer server;
private SupportReplyService service;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder().baseUrl("http://localhost:11434");
server = MockRestServiceServer.bindTo(builder).build();
var props = new LocalLlmProperties("http://localhost:11434", "llama3.2", Duration.ofSeconds(60));
service = new SupportReplyService(builder.build(), props);
}
@Test
void givenCustomerMessage_whenDraftReply_thenReturnsAssistantContent() {
server.expect(requestTo("http://localhost:11434/api/chat"))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.model").value("llama3.2"))
.andExpect(jsonPath("$.stream").value(false))
.andExpect(jsonPath("$.messages[1].content").value("我的订单在哪里?"))
.andRespond(withSuccess("""
{"model":"llama3.2",
"message":{"role":"assistant","content":" 我们正在检查您的订单。 "},
"done":true,
"total_duration":123456}
""", MediaType.APPLICATION_JSON));
String reply = service.draftReply("我的订单在哪里?");
assertThat(reply).isEqualTo("我们正在检查您的订单。");
server.verify();
}
}
这个测试检查四个点:
- 请求被发送到正确的端点
/api/chat,使用 POST 方法。 - 正文包含正确的模型名称和
stream=false。 - 客户消息位于第二条消息中。
- Service 忽略陌生字段
total_duration并修剪答案开始和结束处的多余空白。
如果你想测试答案的质量,你应该将其分离为一个与真实模型一起运行的单独测试集,例如使用 Testcontainers。该测试集应定期运行,而不是在每次构建时运行。
7. 保持本地和云之间的可切换性
Ollama 也支持在 /v1/chat/completions 处兼容 OpenAI 的端点。如果你按这个标准编写客户端,在本地和云之间切换只需改变配置:
# application-local.properties
llm.base-url=http://localhost:11434/v1
llm.model=llama3.2
# application-cloud.properties
llm.base-url=https://api.your-provider.example/v1
llm.model=your-cloud-model
llm.api-key=${LLM_API_KEY}
你应该在一个单独的接口后面放置 LLM 调用,例如 ReplyDrafter。这样你可以对敏感数据使用本地模型,对需要高质量的任务调用云模型,而业务逻辑无需知道模型在哪里运行。
8. 何时应该和不应该自托管?
8.1. 应该自托管的情况
- 数据敏感,或法律法规不允许将数据发送给第三方。
- 负载大且稳定,按 token 计费的成本超过拥有 GPU 的成本,包括运维成本。
- 任务范围狭窄且重复,如分类票据、从发票中提取字段或总结日志。小模型通常能很好地完成这些任务。
- 需要离线运行,在隔离环境或边缘端运行。
- 需要深度微调,或需要完全控制模型版本以确保结果随时间稳定。
8.2. 不应该自托管的情况
- 你需要最好的推理能力来完成复杂任务。云上的大模型通常仍然在这方面占优势。
- 流量低或不规则。GPU 大部分时间闲置基本肯定比按 token 付费更贵。
- 你的团队没有运维 GPU、监控、自动扩展和运行时安全更新的经验。
- 你处于概念验证阶段。用云 API 先验证价值,然后再优化成本。
一种简单的成本比较方式:
自托管成本/月 = 硬件(摊销或租赁)+ 电费/托管费 + 运维小时成本
云成本/月 = 月 token 数 × 单位价格
仅当自托管成本在实际负载水平明显低于云成本时才应自托管,而不是基于你期望的负载。
8.3. 常见错误
注意: Ollama 默认只在 localhost 上监听且没有身份验证。如果你向网络暴露端口,请将其放在有身份验证的反向代理后面。不要直接暴露到互联网。
- 凭直觉评估模型:试几个例子看起来不错就推上生产。你需要为自己的特定任务有一个固定的评估数据集。
- 忘记 KV cache:模型可能刚好装入 VRAM,但当上下文很长或有许多并行请求时会耗尽内存。
- 超时设置过短:启动后的第一次调用必须等待模型加载,所以通常很慢。你可以在应用启动时“预热”模型。
- 为生产选择了错误的 runtime:Ollama 对开发和中等负载非常方便。对于许多并发用户,考虑针对吞吐量优化的 runtime,如 vLLM。
9. 总结
自托管 LLM 不是“更便宜”或“更好”的默认选择。这是一种权衡:你获得对数据的控制权、可预测的成本和低延迟,代价是需要承担运维责任并通常接受更小的模型。
在本文中,你用 Docker 搭建了 Ollama 并通过 Spring Boot 用 RestClient 调用它。你还为集成部分编写了测试,无需真实模型,并学会了如何设计以通过配置在本地和云之间切换。最实用的方法通常是结合两者:对敏感数据和重复任务使用本地模型,对需要最高能力的工作使用云模型。