本地运行 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 受到关注?

有四个主要动力:

  1. 隐私和合规:客户数据、合同或内部源代码无需离开你的基础设施。
  2. 可预测的成本:云 API 按 token 计费。在高负载和稳定的情况下,一台 GPU 的固定成本可能比按 token 支付更便宜。
  3. 低延迟和离线运行能力:没有网络往返,不依赖第三方的速率限制,可以在隔离环境中运行。
  4. 开放权重模型越来越好:小模型已经足够完成许多专业任务。你也可以针对自己的领域对它们进行微调。

注意: “本地”不一定是你的笔记本电脑。一个私有 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 调用它。你还为集成部分编写了测试,无需真实模型,并学会了如何设计以通过配置在本地和云之间切换。最实用的方法通常是结合两者:对敏感数据和重复任务使用本地模型,对需要最高能力的工作使用云模型。

llmaiself-hostingspring-bootollama