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 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 (binding React Native cho 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 message, giờ thì tắt 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 cloud 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 quantize đã đủ 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ộ nhớ 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 để stream câu trả lời 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, parse được JSON và suy luận qua nhiều bước. Business logic 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 tag <action> mà hệ thống tool 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ợ chain-of-thought qua tag <think>.
Bản quantize Q4_K_M dùng trọng số 4-bit kết hợp k-means clustering, nhỏ hơn khoảng 75% so với bản 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 load nó lên kèm GPU acceleration:
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.
Streaming inference#
Chẳng ai muốn ngồi nhìn vòng xoay loading suốt mười giây, nên câu trả lời được stream ra 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ì.
Function calling mà mô hình nhỏ làm được thật#
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
`;Tag 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, parse chỉ cần một biểu thức 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 tag.
Phần parse 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 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.”
Chain-of-thought với tag <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 thinking mode:
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 tag <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 action.
Giữ các cuộc hội thoại dài nằm gọn trong 8K#
Một khi kết quả từ tool bắt đầu dồn vào, 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 job nào, với ID khách hàng nào.
Lưu phiên chat#
Các phiên chat được lưu xuống backend 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ử chat 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 action. Người dùng chuyển qua lại giữa các cuộc hội thoại bằng một dropdown lịch sử, na ná như ChatGPT.
Benchmark từ production#
iPhone 14 Pro (A16 Bionic, 6GB RAM):
- Load mô hình: ~3 giây
- Token đầu tiên: 180-220ms
- Streaming: 14-16 token/giây
- Bộ nhớ: ~1.8 GB
Samsung Galaxy S23 (Snapdragon 8 Gen 2, 8GB RAM):
- Load mô hình: ~4 giây
- Token đầu tiên: 250-300ms
- Streaming: 10-12 token/giây
- Bộ nhớ: ~2.1 GB
iPhone 11 (A13, 4GB RAM):
- Load mô hình: ~6 giây
- Token đầu tiên: 400-500ms
- Streaming: 6-8 token/giây
- Bộ nhớ: ~2.2 GB (thỉnh thoảng crash 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 unload mô hình mỗi khi ứng dụng chuyển xuống background.
Quản lý bộ nhớ#
iOS sẵn sàng kill ứ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 foreground 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 inference 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 |
Inference 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 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 context 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ả tool 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 scale được bấy nhiêu.
Còn nếu làm bản cloud, ướ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 version 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 version 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 backup lên iCloud (tắt đi bằng NSURLIsExcludedFromBackupKey, trừ khi bạn muốn ngốn dung lượng backup 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 bộ nhớ thì không được backup 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 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 offline
Bật chế độ máy bay rồi dùng ứng dụng như thật. Mô hình phải load được từ cache, inference phải chạy xong, và chỉ có phần lưu xuống backend 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 job 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ử fine-tune một LoRA adapter dựa trên các mô tả job 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 offline, và có thể là 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 offline, 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 scale lên, 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 chat 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.

