Trợ lý AI trong ứng dụng Curtain Estimator của tôi chạy hoàn toàn trên điện thoại. Không có dữ liệu nào rời khỏi máy. Người dùng chỉ cần gõ tiếng Anh thông thường là có thể tạo công việc (job), tìm khách hàng và quản lý dự án, và mọi thứ vẫn chạy ngon lành kể cả khi bật chế độ máy bay.
Trong bài này, tôi sẽ kể lại cách mình làm ra nó với llama.rn (thư viện kết nối React Native với llama.cpp) và Qwen 1.7B, một mô hình nhỏ nhưng hóa ra làm được nhiều hơn tôi tưởng.
📝 Cập nhật: Bản hiện tại đã chuyển sang Qwen3.5-2B-GGUF thay cho Qwen3-1.7B ban đầu. Tôi cũng bỏ luôn mẹo chèn
/no_thinkvào tin nhắn, giờ thì tắt chế độ suy nghĩ (thinking mode) đúng cách qua tham sốchat_template_kwargs:chat_response = client.chat.completions.create( model="Qwen/Qwen3.5-27B", messages=messages, max_tokens=32768, temperature=0.7, top_p=0.8, presence_penalty=1.5, extra_body={ "top_k": 20, "chat_template_kwargs": {"enable_thinking": False}, }, )Gọn gàng hơn nhiều so với kiểu lén nhét cờ điều khiển vào prompt.
Vì sao tôi không muốn dùng AI trên đám mây cho việc này#
Cách làm tính năng AI quen thuộc trong ứng dụng di động là: người dùng gõ tin nhắn, ứng dụng gửi nó sang OpenAI, Anthropic hoặc Google, nhận câu trả lời về, và hóa đơn lại nhích lên một chút.
Nhưng với một ứng dụng mà thợ lắp rèm dùng để điều hành việc làm ăn của chính họ, cách làm đó có vấn đề. Tên, địa chỉ, số điện thoại của khách hàng bị gửi cho bên thứ ba. Chi tiết dự án, giá cả và ghi chú thì nằm trên máy chủ của người khác. Rồi còn những câu hỏi về GDPR và nơi lưu trữ dữ liệu mà thật lòng tôi không muốn mất thời gian vào. Chưa kể hóa đơn API cứ tăng dần theo số người dùng.
Cách còn lại là chạy mô hình ngay trên điện thoại của người dùng, và tôi đã chọn cách đó.
Vì sao đến giờ chuyện này mới khả thi#
Nếu là một năm trước thì tôi đã chẳng buồn thử. Nhưng có ba thứ thay đổi gần như cùng lúc. Thứ nhất, mô hình lượng tử hóa (quantized) đã đủ nhỏ: Qwen 1.7B ở mức Q4_K_M chỉ nặng 1.1 GB, nhẹ hơn phần lớn các game, tải về một lần là nằm luôn trong bộ lưu trữ của ứng dụng. Thứ hai, GPU trên điện thoại đã đủ nhanh: llama.cpp dùng Metal trên iOS và Vulkan trên Android, và trên iPhone 14 Pro tôi đo được khoảng 15 token mỗi giây, thừa sức để truyền dần câu trả lời (streaming) theo thời gian thực. Thứ ba, mô hình nhỏ giờ đã thực sự dùng được: Qwen 1.7B làm theo chỉ dẫn có cấu trúc, phân tích được JSON và suy luận qua nhiều bước. Logic nghiệp vụ cần đúng những khả năng đó. Có ai bắt nó làm thơ đâu.
Chọn mô hình#
Tôi đã thử bốn mô hình nhỏ trước khi chốt Qwen3-1.7B.
TinyLlama 1.1B (637 MB) chạy nhanh nhất, nhưng cứ bịa ra ID khách hàng và bỏ sót các trường bắt buộc, nên đầu ra có cấu trúc thì không tin được. Phi-3-mini (1.8 GB) suy luận tốt nhưng nói nhiều không chịu nổi; hỏi một câu đơn giản, nó trả về cả bài văn 200 chữ trong khi tôi chỉ cần 20. Gemma-2B (1.2 GB) nhanh, phân loại chính xác, nhưng gọi hàm lại yếu: nó không sinh ra đều đặn được các thẻ <action> mà hệ thống công cụ của tôi cần. Qwen3-1.7B (1.1 GB) thì vừa khéo: đầu ra có cấu trúc ổn định, làm theo chỉ dẫn chính xác, lại hỗ trợ chuỗi suy luận (chain-of-thought) qua thẻ <think>.
Bản lượng tử hóa Q4_K_M dùng trọng số 4-bit kết hợp phân cụm k-means, nhỏ hơn khoảng 75% so với bản độ chính xác đầy đủ (full precision) mà chất lượng chỉ giảm chừng 5%.
Cài đặt llama.rn#
llama.rn bọc llama.cpp lại để dùng được trong React Native, và cài đặt là phần dễ nhất:
npm install llama.rn
cd ios && pod installCòn bản thân mô hình thì được tải xuống ở lần dùng đầu tiên:
const MODEL_URL = "https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf";
const MODEL_PATH = FileSystem.documentDirectory + "llama-models/Qwen3-1.7B-Q4_K_M.gguf";
const downloadModel = async () => {
const downloadResumable = FileSystem.createDownloadResumable(
MODEL_URL,
MODEL_PATH,
{},
(progress) => {
const pct = progress.totalBytesWritten / progress.totalBytesExpectedToWrite;
setDownloadProgress(pct);
}
);
await downloadResumable.downloadAsync();
};Qua WiFi thì mất 2-3 phút, qua LTE thì khoảng 5-8 phút. Khi tệp đã nằm trên máy, bạn nạp nó lên và bật tăng tốc bằng GPU:
const ctx = await initLlama({
model: MODEL_PATH,
n_ctx: 8192, // 8K context window
n_gpu_layers: 99, // Use GPU for all layers
});Đừng bỏ qua n_gpu_layers: 99. Đẩy toàn bộ việc tính toán sang Metal/Vulkan thay vì để CPU gánh sẽ giúp tốc độ tăng khoảng 5 lần.
Suy luận và truyền dần kết quả#
Chẳng ai muốn ngồi nhìn biểu tượng đang tải xoay vòng suốt mười giây, nên quá trình suy luận (inference) trả câu trả lời ra dần từng token một:
let fullResponse = "";
await llamaContext.completion(
{
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: "Create a job for John Smith" }
],
n_predict: 512,
temperature: 0.7,
top_p: 0.8,
},
(data) => {
// Called for each token
fullResponse += data.token;
setStreamingText(fullResponse);
}
);Trên điện thoại đời mới, token đầu tiên xuất hiện sau khoảng 200ms (đó là thời gian xử lý prompt), sau đó mỗi token mất 50-80ms. So với bất cứ thứ gì phải chờ mạng, cảm giác gần như tức thì.
Gọi hàm theo cách mô hình nhỏ làm được thật#
Cơ chế gọi hàm (function calling) kiểu GPT-4 dựa vào JSON schema, và mô hình nhỏ gặp nó là “ngã”: JSON sai định dạng, thiếu trường, đủ cả. Vì vậy tôi dùng một giao thức đơn giản hơn, dựa trên XML:
const systemPrompt = `You are an AI assistant. When you need to take an action, output:
<action>{"type":"search_customers","query":"Smith"}</action>
Available actions:
- search_customers: {"type":"search_customers","query":"John"}
- create_job: {"type":"create_job","customer_id":5,"alias":"Living Room"}
- update_job: {"type":"update_job","job_id":"J-0042","status":"quoting"}
Rules:
1. ONE action tag per response, at the very end
2. When searching/creating → end with <action>
3. When just chatting → no action tag
`;Thẻ XML thắng vì mấy lý do rất bình thường: <action> và </action> là dấu phân cách rõ ràng, phân tích chỉ cần một biểu thức chính quy (regex), các ví dụ trong prompt kiêm luôn vai trò tài liệu, và cách này vẫn chạy được khi mô hình nói thêm vài câu lan man quanh thẻ.
Phần phân tích thì đơn giản đúng như vẻ ngoài của nó:
function parseAction(text: string): Action | null {
const match = text.match(/<action>([\s\S]*?)<\/action>/);
if (!match) return null;
try {
return JSON.parse(match[1].trim());
} catch {
return null;
}
}Khi mô hình trả về một hành động (action), tôi thực thi nó rồi đưa kết quả ngược lại vào cuộc hội thoại:
const action = parseAction(modelResponse);
if (action) {
const result = await executeAction(action);
// Inject result as a system message
conversationHistory.push({
role: "user",
content: `[TOOL_RESULT] ${result.message}\n\n→ NEXT: Tell the user what happened.`
});
// Continue generation
await generateNextResponse();
}Chừng đó đã đủ để chạy các luồng xử lý nhiều bước đúng nghĩa:
Người dùng: “Create a job for Smith”
- Mô hình:
<action>{"type":"search_customers","query":"Smith"}</action> - Hệ thống:
[TOOL_RESULT] Found: Jane Smith (id:42), Bob Smith (id:89) - Mô hình: “I found two Smiths—Jane and Bob. Which one?”
- Người dùng: “Jane”
- Mô hình:
<action>{"type":"create_job","customer_id":42}</action> - Hệ thống:
[TOOL_RESULT] Job J-0073 created - Mô hình: “Done! Created job J-0073 for Jane Smith.”
Chuỗi suy luận với thẻ <think>#
Mô hình nhỏ làm tốt hơn thấy rõ khi bạn bắt nó suy nghĩ thành lời. Qwen có sẵn chế độ suy nghĩ:
const systemPrompt = `Before each response, wrap your reasoning in <think> tags:
<think>
INTENT: what does the user want?
HAVE: what data do I already have?
NEED: what's still missing?
DECISION: call tool | ask user | just respond
</think>
Then output your actual response.`;Bên trong, mô hình sẽ sinh ra nội dung trông như thế này:
<think>
INTENT: create a job
HAVE: customer query "Smith"
NEED: exact customer_id
DECISION: search first
</think>
<action>{"type":"search_customers","query":"Smith"}</action>Tôi lọc bỏ các thẻ <think> trước khi hiển thị bất cứ thứ gì lên giao diện, nhưng trong lúc phát triển thì vẫn để chúng hiện ra. Độ ổn định khác nhau một trời một vực: mô hình tự lý luận qua từng bước rồi mới chốt một hành động.
Giữ các cuộc hội thoại dài nằm gọn trong 8K#
Một khi kết quả từ công cụ bắt đầu dồn vào, cửa sổ ngữ cảnh (context window) 8K đầy nhanh hơn bạn tưởng. Tầm lượt thứ 30 là tôi chạm trần.
Cách tôi xử lý là nén cuộc hội thoại lại, bằng cách nhờ chính mô hình tự tóm tắt:
const compactPrompt = `Summarize this conversation in under 200 words.
Include: user's goal, customers/jobs created (with IDs), and any unfinished tasks.
${conversationHistory.map(m => `${m.role}: ${m.content}`).join('\n\n')}`;
const summary = await chat([{ role: "user", content: compactPrompt }]);
// Replace history with summary
conversationHistory = [
{ role: "user", content: `[CONTEXT SUMMARY]\n${summary}\n[END SUMMARY]` }
];Một cuộc hội thoại 50 tin nhắn được ép xuống còn khoảng 150 token, mà mô hình vẫn tiếp tục bình thường, không quên mình đã tạo những công việc nào, với ID khách hàng nào.
Lưu phiên trò chuyện#
Các phiên trò chuyện được lưu xuống máy chủ Django của tôi:
interface SessionMessage {
role: "user" | "assistant";
content: string; // Stripped for display
raw: string; // Full with <think> and <action> tags
}
await api.createAIChatSession({
organization_id: orgId,
title: firstUserMessage.slice(0, 60),
messages: [
{ role: "user", content: userText, raw: userText },
{ role: "assistant", content: cleanedResponse, raw: fullModelOutput }
]
});Nhờ lưu cả content lẫn raw, lịch sử trò chuyện hiển thị lại gọn gàng trên giao diện, còn khi mở lại một phiên cũ thì mô hình được nạp lại đầy đủ ngữ cảnh, kể cả các hành động. Người dùng chuyển qua lại giữa các cuộc hội thoại bằng một danh sách thả xuống chứa lịch sử, na ná như ChatGPT.
Số đo hiệu năng trên môi trường production#
iPhone 14 Pro (A16 Bionic, 6GB RAM):
- Nạp mô hình: ~3 giây
- Token đầu tiên: 180-220ms
- Tốc độ sinh: 14-16 token/giây
- Bộ nhớ: ~1.8 GB
Samsung Galaxy S23 (Snapdragon 8 Gen 2, 8GB RAM):
- Nạp mô hình: ~4 giây
- Token đầu tiên: 250-300ms
- Tốc độ sinh: 10-12 token/giây
- Bộ nhớ: ~2.1 GB
iPhone 11 (A13, 4GB RAM):
- Nạp mô hình: ~6 giây
- Token đầu tiên: 400-500ms
- Tốc độ sinh: 6-8 token/giây
- Bộ nhớ: ~2.2 GB (thỉnh thoảng bị sập vì thiếu bộ nhớ)
Kinh nghiệm của tôi là cần từ 6GB RAM trở lên thì mới chạy mượt. Máy 4GB vẫn chạy được, nhưng nhiều khả năng bạn sẽ phải giải phóng mô hình mỗi khi ứng dụng chuyển xuống chạy nền.
Quản lý bộ nhớ#
iOS sẵn sàng buộc đóng ứng dụng của bạn nếu nó ôm quá nhiều bộ nhớ, nên cứ mỗi lần ứng dụng rời khỏi màn hình chính (không còn chạy ở tiền cảnh) là tôi giải phóng mô hình:
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (nextAppState === "background") {
llamaContext?.release(); // Free ~2 GB
} else if (nextAppState === "active") {
loadModel(); // Reload when foregrounded
}
});
return () => subscription.remove();
}, []);Ảnh hưởng tới pin#
Chạy suy luận ngay trên máy thì cũng tốn pin chứ không phải miễn phí. Đây là số liệu tôi đo được khi dùng thực tế:
| Mức độ sử dụng | Hao pin thêm |
|---|---|
| Nhẹ (5-10 truy vấn/ngày) | <1% mỗi ngày |
| Vừa (20-30 truy vấn/ngày) | ~3-4% mỗi ngày |
| Nặng (50+ truy vấn/ngày) | ~6-8% mỗi ngày |
Suy luận bằng GPU tốn điện hơn CPU nhưng xong nhanh hơn nhiều, mà người dùng thì rõ ràng thích được trả lời tức thì, nên tôi vẫn giữ GPU.
Sau hai tháng, với khoảng 200 người dùng bản beta#
Cơ cấu các truy vấn làm tôi hơi bất ngờ:
- “Create a job for [customer name]” - 68% số truy vấn
- “Find jobs for [customer]” - 15%
- “Update job [ID] to [status]” - 9%
- Câu hỏi chung chung về ứng dụng - 8%
91% truy vấn thành công ngay lần đầu. Số còn lại thất bại vì ba lý do: không tìm thấy khách hàng do người dùng gõ sai tên (4%), tràn ngữ cảnh trong những cuộc hội thoại rất dài (3%), và mô hình tự bịa ra ID khách hàng (2%).
Lỗi cuối cùng này trước đây còn tệ hơn nhiều. Chuyện bịa ID gần như biến mất sau khi tôi trả kết quả của công cụ về ở dạng tường minh, kiểu customer_id=42, thay vì một câu văn. Với mô hình nhỏ, bạn phải dựng sẵn “giàn giáo” đó ra thật rõ ràng.
Chi phí bao nhiêu#
Chạy trên máy: $0/tháng tiền hạ tầng, $0 cho mỗi người dùng ngoài lần tải 1.1 GB duy nhất, và có bao nhiêu người muốn cài ứng dụng thì nó cũng mở rộng được bấy nhiêu.
Còn nếu làm bản chạy trên đám mây, ước tính sơ sơ thế này: một cuộc hội thoại trung bình khoảng 2,000 token, 200 người dùng mỗi người 30 cuộc hội thoại một tháng là 6,000 cuộc hội thoại, tức 12M token/tháng. Tính ra khoảng $18/tháng với GPT-3.5, $360/tháng với GPT-4, hoặc $180/tháng với Claude. Ở quy mô này thì chưa đến mức phá sản, nhưng chạy trên máy thì hoàn vốn ngay từ ngày đầu, và kể cả khi lên tới 10,000 người dùng cũng vẫn không tốn đồng nào.
Những giới hạn nên biết#
Năng lực của mô hình. Qwen 1.7B làm các tác vụ có cấu trúc rất tốt, nhưng lại rất dở ở suy luận phức tạp qua nhiều bước, nhớ kiến thức (nó không phải công cụ tìm kiếm), viết sáng tạo và những việc cần sự tinh tế. Cứ thiết kế tính năng xoay quanh những gì mô hình nhỏ làm giỏi là ổn.
Yêu cầu thiết bị. Tối thiểu là 3 GB RAM và 2 GB bộ nhớ trống; thực tế thì bạn nên có 6 GB RAM, chip 64-bit và hỗ trợ GPU. Máy đời cũ như iPhone 8 hay Galaxy S9 sẽ chạy rất chật vật, nên hãy kiểm tra khả năng của thiết bị trước và có phương án dự phòng cho êm.
Cập nhật mô hình. Mỗi lần phát hành mô hình mới là người dùng lại phải tải lại 1.1 GB. Tôi đánh số phiên bản cho mô hình ngay trong đường dẫn lưu trữ:
const MODEL_PATH = `${FileSystem.documentDirectory}llama-models/v2/Qwen3-1.7B-Q4_K_M.gguf`;Mỗi phiên bản mô hình mới thì tăng số trong đường dẫn lên. Mô hình cũ sẽ tự được dọn đi khi người dùng gỡ ứng dụng.
iOS vs Android#
Trên iOS, Metal chạy nhanh hơn Android khoảng 20%, bộ nhớ được quản lý tốt hơn, và mặc định mô hình sẽ được sao lưu lên iCloud (tắt đi bằng NSURLIsExcludedFromBackupKey, trừ khi bạn muốn ngốn dung lượng sao lưu của người dùng). Android dùng Vulkan/OpenCL thì chênh lệch giữa các máy lớn hơn nhiều: một số GPU đời cũ hoàn toàn không hỗ trợ Vulkan và phải chạy bằng CPU, còn dữ liệu lưu trữ thì không được sao lưu tự động. Hãy kiểm thử trên cả hai nền tảng; khoảng cách giữa một máy Android tốt và một máy Android kém còn lớn hơn cả khoảng cách giữa hai nền tảng.
Mẹo gỡ lỗi#
1. Bật ghi nhật ký chi tiết (verbose logging)
const ctx = await initLlama({
model: MODEL_PATH,
n_ctx: 8192,
n_gpu_layers: 99,
verbose: true, // Logs every token + timings
});2. Theo dõi số token
const tokens = fullResponse.split(/\s+/).length * 1.3; // Rough estimate
console.log(`Generated ${tokens} tokens in ${duration}ms`);3. Giám sát bộ nhớ
import { MemoryInfo } from 'react-native-device-info';
const memoryUsage = await MemoryInfo.getUsedMemory();
console.log(`Memory: ${(memoryUsage / 1024 / 1024).toFixed(0)} MB`);4. Kiểm thử khi ngoại tuyến (offline)
Bật chế độ máy bay rồi dùng ứng dụng như thật. Mô hình phải nạp được từ bộ nhớ đệm (cache), quá trình suy luận phải chạy xong, và chỉ có phần lưu lên máy chủ là được phép lỗi, mà có lỗi thì cũng phải lỗi êm.
Tôi muốn đưa nó đi xa hơn thế nào#
llama.cpp đã hỗ trợ mô hình thị giác (LLaVA, Qwen2-VL), và một tính năng kiểu “đây là ảnh vải rèm, thêm vào công việc này giúp tôi” rõ ràng rất hợp với ứng dụng này. Tôi cũng muốn thử tinh chỉnh (fine-tune) một LoRA adapter dựa trên các mô tả công việc cũ; tôi đoán độ chính xác với các truy vấn trong lĩnh vực rèm cửa sẽ tăng 20-30%. Xa hơn nữa là Whisper.cpp để có giao diện giọng nói chạy hoàn toàn ngoại tuyến, và có thể là học liên kết (federated learning), để mô hình tốt dần lên nhờ nhiều người dùng mà không có chút dữ liệu thô nào rời khỏi điện thoại của họ.
Bạn có nên làm theo không?#
Nếu bạn đang làm ứng dụng doanh nghiệp có dữ liệu nhạy cảm, quy trình cần chạy được khi ngoại tuyến, hay các tác vụ có cấu trúc như phân loại và nhập liệu, và nhất là nếu bạn lo chi phí API khi mở rộng quy mô, thì câu trả lời là có, cách này dùng được ngay từ hôm nay.
Còn nếu bạn cần trò chuyện tự do về đủ mọi chủ đề, cứ dùng Claude hoặc GPT-4. Tương tự với tác vụ đòi hỏi nhiều kiến thức (mô hình nhỏ đơn giản là không biết nhiều), khi bạn buộc phải hỗ trợ máy cấu hình thấp, hoặc khi kiến thức chuyên ngành của bạn thay đổi nhanh hơn tốc độ bạn phát hành bản cập nhật mô hình.
Toàn bộ hệ thống này tôi làm mất khoảng 12 tiếng. Chi phí vận hành là $0 mỗi tháng, dùng được cả trên máy bay, và dữ liệu khách hàng không bao giờ rời khỏi tay khách hàng. Khó mà chê được.
Có câu hỏi hay góp ý? Bạn tìm tôi ở @jaredlynskey. Thư viện llama.rn nằm ở github.com/mybigday/llama.rn.
