Chạy LLM local: khi nào nên tự host model
1. Tổng quan
Gọi API của một nhà cung cấp LLM trên cloud là cách nhanh nhất để đưa AI vào ứng dụng. Ngày càng nhiều đội ngũ lại muốn tự chạy model trên hạ tầng của mình, có thể là laptop, server nội bộ hoặc GPU thuê riêng.
Trong bài này, chúng ta sẽ tìm hiểu “chạy LLM local” thực chất gồm những gì và vì sao xu hướng này được quan tâm. Sau đó bạn sẽ dựng một model local, gọi nó từ ứng dụng Spring Boot và viết test cho phần tích hợp. Cuối bài là các tiêu chí để quyết định khi nào nên tự host và khi nào không.
2. “Chạy LLM local” nghĩa là gì?
2.1. Ba thành phần cơ bản
Một hệ thống LLM tự host thường gồm ba lớp:
- Model weights: file trọng số của model. Bạn thường tải các model open-weight từ kho công khai.
- Inference runtime: phần mềm nạp weights vào RAM hoặc VRAM và sinh token. Ví dụ là Ollama, llama.cpp hay vLLM.
- API layer: một HTTP endpoint để ứng dụng gọi vào. Nhiều runtime cung cấp API tương thích chuẩn OpenAI, nên code phía client gần như không phải đổi.
Ứng dụng của bạn chỉ thấy lớp thứ ba. Đây là điểm quan trọng để sau này bạn chuyển đổi giữa local và cloud mà không phải viết lại nghiệp vụ.
2.2. Quantization và bộ nhớ
Yếu tố quyết định “máy mình có chạy nổi không” là bộ nhớ. Bạn có thể ước lượng thô bộ nhớ cho weights như sau:
Bộ nhớ weights ≈ số tham số × số byte mỗi tham số
16-bit (FP16): 2 byte / tham số
8-bit: 1 byte / tham số
4-bit: ~0.5 byte / tham số
Ngoài weights, bạn còn cần chỗ cho KV cache. Phần này tăng theo độ dài context và số request chạy song song. Quantization là kỹ thuật giảm độ chính xác số học, ví dụ từ 16-bit xuống 4-bit, để model nhỏ đi nhiều lần. Đổi lại, chất lượng câu trả lời giảm đi một chút. Với nhiều tác vụ như phân loại, tóm tắt hay trích xuất thông tin, mức giảm này thường chấp nhận được.
3. Vì sao tự host LLM đang được quan tâm?
Có bốn động lực chính:
- Quyền riêng tư và tuân thủ: dữ liệu khách hàng, hợp đồng hay mã nguồn nội bộ không phải rời khỏi hạ tầng của bạn.
- Chi phí dự đoán được: API cloud tính tiền theo token. Khi tải lớn và đều đặn, chi phí cố định của một máy GPU có thể rẻ hơn.
- Độ trễ và khả năng chạy offline: không có round-trip qua Internet, không phụ thuộc rate limit của bên thứ ba, chạy được trong môi trường air-gapped.
- Model open-weight ngày càng tốt: các model nhỏ đã đủ dùng cho nhiều tác vụ chuyên biệt. Bạn cũng có thể fine-tune chúng cho domain riêng.
Lưu ý: “Local” không bắt buộc là laptop của bạn. Một cụm GPU trong VPC riêng cũng được tính là tự host. Điểm cốt lõi là bạn kiểm soát model và dữ liệu.
4. Dựng model local với Ollama
Ollama là lựa chọn đơn giản để bắt đầu. Nó đóng gói runtime, quản lý model và cung cấp HTTP API. Bạn có thể chạy nó bằng Docker Compose:
services:
ollama:
image: ollama/ollama
ports:
- "11434:11434"
volumes:
- ollama-data:/root/.ollama
restart: unless-stopped
volumes:
ollama-data:
Volume ollama-data giữ lại các model đã tải. Nhờ vậy bạn không phải tải lại mỗi lần container khởi động lại. Port 11434 là port mặc định của Ollama API.
Tiếp theo, bạn tải một model và gọi thử:
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": "Tóm tắt: đơn hàng giao trễ 3 ngày, khách muốn hoàn tiền."}],
"stream": false
}'
Bạn có thể thay llama3.2 bằng bất kỳ model nào phù hợp với phần cứng của mình. Với "stream": false, kết quả trả về là một JSON duy nhất có dạng:
{
"model": "llama3.2",
"message": { "role": "assistant", "content": "Khách hàng yêu cầu hoàn tiền do đơn hàng giao trễ 3 ngày." },
"done": true,
...
}
Nếu máy có GPU NVIDIA, bạn cần cài NVIDIA Container Toolkit và cấp GPU cho container. Nếu không, model sẽ chạy bằng CPU. Cách này vẫn được nhưng chậm hơn nhiều.
5. Gọi model local từ Spring Boot
Ví dụ trong phần này là tình huống thực tế: soạn nháp câu trả lời cho tin nhắn chăm sóc khách hàng. Dữ liệu tin nhắn ở lại hoàn toàn trong hạ tầng nội bộ.
5.1. Dependency và cấu hình
Bạn chỉ cần RestClient có sẵn trong Spring Web. Không cần thêm thư viện AI nào:
<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>
Khai báo cấu hình trong application.properties:
local-llm.base-url=http://localhost:11434
local-llm.model=llama3.2
local-llm.timeout=60s
Timeout cần đặt rộng tay. Model chạy local, nhất là trên CPU, có thể mất hàng chục giây để sinh một câu trả lời dài.
5.2. Properties và 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();
}
}
Hai loại timeout ở đây có vai trò khác nhau. Connect timeout ngắn (5 giây) giúp phát hiện nhanh khi Ollama chưa chạy. Read timeout dài để chờ model sinh xong câu trả lời.
5.3. DTO và 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 =
"Bạn là nhân viên chăm sóc khách hàng. Trả lời lịch sự, ngắn gọn, bằng tiếng Việt.";
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 không trả về nội dung");
}
return response.message().content().strip();
}
}
Luồng xử lý của draftReply như sau:
- Service ghép một message
systemđể định hướng giọng văn, rồi thêm tin nhắn của khách vào messageuser. streamđược đặt làfalse, nên Ollama trả về toàn bộ câu trả lời trong một response.@JsonIgnorePropertiesbỏ qua các trường thống kê mà service không dùng tới, như thời gian xử lý hay số token.- Response rỗng được chuyển thành exception rõ ràng, thay vì để lộ ra một
NullPointerExceptionkhó hiểu.
6. Test phần tích hợp
Bạn không nên để unit test phụ thuộc vào một model thật, vì cách đó chậm và kết quả không xác định. Thay vào đó, hãy dùng MockRestServiceServer để kiểm tra rằng service gửi đúng request và đọc đúng response:
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("Đơn hàng của tôi đâu rồi?"))
.andRespond(withSuccess("""
{"model":"llama3.2",
"message":{"role":"assistant","content":" Chúng tôi đang kiểm tra đơn hàng của bạn. "},
"done":true,
"total_duration":123456}
""", MediaType.APPLICATION_JSON));
String reply = service.draftReply("Đơn hàng của tôi đâu rồi?");
assertThat(reply).isEqualTo("Chúng tôi đang kiểm tra đơn hàng của bạn.");
server.verify();
}
}
Test này kiểm tra bốn điểm:
- Request được gửi đúng endpoint
/api/chatbằng method POST. - Body có đúng tên model và
stream=false. - Tin nhắn của khách nằm ở message thứ hai.
- Service bỏ qua trường lạ
total_durationvà cắt bỏ khoảng trắng thừa ở đầu và cuối câu trả lời.
Nếu muốn kiểm tra chất lượng câu trả lời, bạn nên tách thành một bộ test riêng chạy với model thật, ví dụ bằng Testcontainers. Bộ test đó nên chạy định kỳ, không chạy trong mỗi lần build.
7. Giữ khả năng chuyển đổi giữa local và cloud
Ollama cũng hỗ trợ endpoint tương thích OpenAI tại /v1/chat/completions. Nếu bạn viết client theo chuẩn này, việc chuyển giữa local và cloud chỉ còn là đổi cấu hình theo profile:
# 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}
Bạn nên đặt việc gọi LLM sau một interface riêng, ví dụ ReplyDrafter. Như vậy bạn có thể dùng model local cho dữ liệu nhạy cảm và gọi cloud cho các tác vụ cần chất lượng cao, mà phần nghiệp vụ không cần biết model đang chạy ở đâu.
8. Khi nào nên và không nên tự host?
8.1. Nên tự host khi
- Dữ liệu nhạy cảm, hoặc quy định pháp lý không cho phép gửi dữ liệu ra bên thứ ba.
- Tải lớn và ổn định, nên chi phí theo token vượt chi phí sở hữu GPU, kể cả khi đã tính công vận hành.
- Tác vụ hẹp và lặp lại như phân loại ticket, trích xuất trường từ hóa đơn hay tóm tắt log. Model nhỏ thường làm tốt các việc này.
- Cần chạy offline, trong môi trường air-gapped hoặc ở edge.
- Cần fine-tune sâu, hoặc cần kiểm soát hoàn toàn phiên bản model để kết quả ổn định theo thời gian.
8.2. Không nên tự host khi
- Bạn cần năng lực suy luận tốt nhất cho các tác vụ phức tạp. Các model lớn trên cloud thường vẫn vượt trội ở mảng này.
- Lưu lượng thấp hoặc thất thường. GPU nằm không phần lớn thời gian thì gần như chắc chắn đắt hơn trả tiền theo token.
- Đội ngũ chưa có kinh nghiệm vận hành GPU, giám sát, autoscaling và cập nhật bảo mật cho runtime.
- Bạn đang ở giai đoạn thử nghiệm ý tưởng. Hãy kiểm chứng giá trị bằng API cloud trước, rồi mới tối ưu chi phí.
Một cách so sánh chi phí đơn giản:
Chi phí tự host / tháng = phần cứng (khấu hao hoặc thuê) + điện/hosting + giờ công vận hành
Chi phí cloud / tháng = số token / tháng × đơn giá token
Chỉ nên tự host nếu chi phí tự host thấp hơn rõ rệt ở mức tải thực tế, không phải mức tải bạn hy vọng.
8.3. Lỗi hay gặp
Lưu ý: Ollama mặc định chỉ lắng nghe trên localhost và không có xác thực. Nếu bạn mở port ra mạng, hãy đặt nó sau reverse proxy có xác thực. Không bao giờ expose trực tiếp ra Internet.
- Đánh giá model bằng cảm tính: thử vài câu thấy ổn rồi đưa lên production. Bạn cần một bộ dữ liệu đánh giá cố định cho đúng tác vụ của mình.
- Quên KV cache: model nạp vừa VRAM nhưng hết bộ nhớ khi context dài hoặc khi nhiều request chạy song song.
- Timeout quá ngắn: lần gọi đầu tiên sau khi khởi động phải chờ nạp model nên thường rất chậm. Bạn có thể “warm up” model khi ứng dụng khởi động.
- Chọn sai runtime cho production: Ollama rất tiện cho phát triển và tải vừa. Với nhiều người dùng đồng thời, hãy cân nhắc các runtime tối ưu cho throughput như vLLM.
9. Kết luận
Tự host LLM không phải là “miễn phí hơn” hay “tốt hơn” một cách mặc định. Đây là một đánh đổi: bạn có quyền kiểm soát dữ liệu, chi phí dự đoán được và độ trễ thấp, đổi lại bạn phải gánh trách nhiệm vận hành và thường chấp nhận model nhỏ hơn.
Trong bài, bạn đã dựng Ollama bằng Docker và gọi nó từ Spring Boot bằng RestClient. Bạn cũng đã viết test cho phần tích hợp mà không cần model thật, và biết cách thiết kế để chuyển đổi giữa local và cloud qua cấu hình. Cách tiếp cận thực tế nhất thường là kết hợp cả hai: model local cho dữ liệu nhạy cảm và tác vụ lặp lại, cloud cho những việc cần năng lực cao nhất.