본문으로 건너뛰기
  1. 게시물/

React Native에서의 온디바이스 AI: llama.cpp로 Qwen 1.7B 실행하기

· loading · loading ·
인재덕
작성자
인재덕
A Kiwi living in Korea

최근 Curtain Estimator 앱에 완전히 온디바이스에서 실행되는 AI 어시스턴트를 출시했습니다. API 호출 없음, 클라우드 의존성 없음, 완벽한 프라이버시. 사용자는 자연어로 작업을 생성하고, 고객을 검색하고, 프로젝트를 관리할 수 있습니다—완전히 오프라인으로.

이 글에서는 llama.rn(llama.cpp의 React Native 바인딩)과 Qwen 1.7B(놀라울 정도로 강력한 소형 언어 모델)를 사용하여 어떻게 구축했는지 설명합니다.

클라우드 AI의 프라이버시 문제
#

대부분의 모바일 앱 AI 기능은 이렇게 작동합니다:

  1. 사용자가 메시지를 입력
  2. 앱이 OpenAI/Anthropic/Google에 전송
  3. 응답이 돌아옴
  4. 비용이 누적됨

커튼 설치 비즈니스 앱의 경우, 이는 다음을 의미합니다:

  • 고객 이름, 주소, 전화번호 → 제3자에게 전송
  • 프로젝트 세부사항, 가격, 메모 → 외부 서버에 저장
  • 규정 준수 문제 (GDPR, 데이터 거주 요구사항)
  • 사용량에 따라 증가하는 월별 API 비용

대안: 사용자의 휴대폰에서 직접 AI 모델을 실행.

왜 지금 가능해졌는가
#

1년 전만 해도 이것은 비현실적이었습니다. 하지만 세 가지가 변했습니다:

1. 양자화된 모델이 매우 작음 Qwen 1.7B의 Q4_K_M 양자화는 단 1.1 GB—대부분의 게임보다 작습니다. 한 번 다운로드하면 앱 저장소에 저장됩니다.

2. 모바일 GPU가 빠름 llama.cpp는 Metal(iOS)과 Vulkan(Android)을 활용하여 GPU 가속을 합니다. iPhone 14 Pro에서 약 15 tokens/초를 달성합니다—실시간 스트리밍에 충분합니다.

3. 소형 모델이 똑똑해짐 Qwen 1.7B는 구조화된 지침을 따르고, JSON을 파싱하며, 다단계 추론을 수행할 수 있습니다. 비즈니스 로직에 완벽하며, 창작 글쓰기용은 아닙니다.

적합한 모델 선택
#

Qwen3-1.7B를 결정하기 전에 네 가지 소형 모델을 테스트했습니다:

TinyLlama 1.1B (637 MB) 매우 빠르지만 구조화된 출력에 어려움을 겪습니다. 고객 ID를 자주 할루시네이션하거나 필수 필드를 잊어버렸습니다.

Phi-3-mini (1.8 GB) 추론 능력은 강하지만 너무 장황합니다. 간단한 쿼리에 200단어 이상의 응답을 생성하는데 20단어면 충분했습니다.

Gemma-2B (1.2 GB) 분류에서 빠르고 정확하지만 함수 호출이 약합니다. 내 도구 시스템에 필요한 <action> 태그를 안정적으로 출력하지 못했습니다.

Qwen3-1.7B (1.1 GB) ✅ 최적의 균형점: 신뢰할 수 있는 구조화된 출력, 지침을 정확히 따르며, <think> 태그를 사용한 사고 연쇄 추론을 지원합니다.

Q4_K_M 양자화는 k-means 클러스터링과 함께 4비트 가중치를 사용합니다—전체 정밀도보다 75% 작으며 품질 손실은 약 5%에 불과합니다.

통합: llama.rn 설정
#

llama.rn 라이브러리는 React Native용으로 llama.cpp를 래핑합니다. 설치는 간단합니다:

npm install llama.rn
cd ios && pod install

그런 다음 모델 다운로드를 구성합니다:

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();
};

모델은 처음 사용할 때 다운로드되며 진행 상황을 추적합니다. WiFi에서 2-3분, LTE에서 약 5-8분이 걸립니다.

다운로드 후 GPU 가속으로 로드합니다:

const ctx = await initLlama({
  model: MODEL_PATH,
  n_ctx: 8192,      // 8K context window
  n_gpu_layers: 99, // Use GPU for all layers
});

n_gpu_layers: 99가 중요합니다—연산을 CPU 대신 Metal/Vulkan으로 오프로드하여 약 5배의 속도 향상을 제공합니다.

스트리밍 추론
#

사용자는 10초간 로딩 스피너가 아닌 실시간 응답을 기대합니다. llama.rn은 토큰별 스트리밍을 지원합니다:

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);
  }
);

토큰이 스트리밍되면서 UI가 실시간으로 업데이트됩니다. 최신 폰에서:

  • 첫 번째 토큰: 약 200ms (프롬프트 처리)
  • 이후 토큰: 각 약 50-80ms

네트워크 기반 API에 비해 즉각적으로 느껴집니다.

소형 모델을 위한 함수 호출
#

GPT-4의 함수 호출은 JSON 스키마를 사용합니다. 소형 모델은 이에 어려움을 겪습니다—잘못된 JSON을 출력하거나 필수 필드를 누락합니다.

대신, 더 간단한 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
`;

왜 순수 JSON 대신 XML 태그를 사용하는가?

  • 명확한 구분자: <action></action>은 모호하지 않은 시작/끝 마커
  • 쉬운 파싱: 간단한 정규식, JSON 스키마 검증 불필요
  • 자체 문서화: 예제가 정확한 형식을 보여줌
  • 관대함: 모델이 추가 텍스트를 넣어도 작동

모델이 action을 출력하면, 파싱합니다:

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;
  }
}

그런 다음 실행하고 결과를 대화에 다시 주입합니다:

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();
}

이를 통해 다단계 워크플로우가 가능합니다. 예를 들어:

사용자: “Create a job for Smith”

  1. 모델: <action>{"type":"search_customers","query":"Smith"}</action>
  2. 시스템: [TOOL_RESULT] Found: Jane Smith (id:42), Bob Smith (id:89)
  3. 모델: “Smith가 두 명 있습니다—Jane과 Bob. 누구를 원하시나요?”
  4. 사용자: “Jane”
  5. 모델: <action>{"type":"create_job","customer_id":42}</action>
  6. 시스템: [TOOL_RESULT] Job J-0073 created
  7. 모델: “완료! Jane Smith의 작업 J-0073을 생성했습니다.”

<think> 태그를 사용한 사고 연쇄
#

소형 모델은 명시적인 추론 단계가 도움이 됩니다. Qwen은 사고 모드를 지원합니다:

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.`;

모델의 내부 추론:

<think>
INTENT: create a job
HAVE: customer query "Smith"
NEED: exact customer_id
DECISION: search first
</think>
<action>{"type":"search_customers","query":"Smith"}</action>

UI에서 <think> 태그를 제거하지만 개발 중에는 디버깅을 위해 표시합니다. 이것은 신뢰성을 극적으로 향상시킵니다—모델이 스스로 로직을 정리합니다.

대화 및 컨텍스트 처리
#

8K 컨텍스트 윈도우는 도구 결과로 빠르게 채워집니다. 약 30턴 후에 한계에 도달합니다.

해결책: 대화 압축. 모델에게 요약을 요청합니다:

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]` }
];

50개 메시지 대화가 약 150 토큰으로 압축됩니다. 모델은 생성된 작업이나 고객 ID를 잃지 않고 요약에서 계속할 수 있습니다.

세션 지속성
#

채팅 세션은 Django 백엔드에 저장됩니다:

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 }
  ]
});

이중 저장(content vs raw)을 통해:

  • UI 재생: 채팅 히스토리에서 깔끔한 메시지 표시
  • 정확한 복원: action을 포함한 전체 컨텍스트로 재개

사용자는 ChatGPT와 유사하게 히스토리 드롭다운을 통해 대화 간 전환할 수 있습니다.

성능: 프로덕션 벤치마크
#

iPhone 14 Pro (A16 Bionic, 6GB RAM):

  • 모델 로드: 약 3초
  • 첫 번째 토큰: 180-220ms
  • 스트리밍: 14-16 tokens/초
  • 메모리: 약 1.8 GB

Samsung Galaxy S23 (Snapdragon 8 Gen 2, 8GB RAM):

  • 모델 로드: 약 4초
  • 첫 번째 토큰: 250-300ms
  • 스트리밍: 10-12 tokens/초
  • 메모리: 약 2.1 GB

iPhone 11 (A13, 4GB RAM):

  • 모델 로드: 약 6초
  • 첫 번째 토큰: 400-500ms
  • 스트리밍: 6-8 tokens/초
  • 메모리: 약 2.2 GB (저메모리에서 간헐적 크래시)

권장: 원활한 경험을 위해 6GB+ RAM. 4GB에서도 작동하지만 앱이 백그라운드로 갈 때 모델을 언로드해야 할 수 있습니다.

메모리 관리
#

앱이 너무 많은 메모리를 사용하면 iOS가 앱을 종료합니다. 백그라운드 진입 시 모델을 언로드합니다:

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();
}, []);

이렇게 하면 메모리 경고를 방지하고 앱 응답성을 유지합니다.

배터리 영향
#

온디바이스 추론은 전력을 소비합니다. 실제 테스트 결과:

사용 패턴추가 배터리 소모
가벼운 사용 (하루 5-10개 쿼리)하루 <1%
보통 사용 (하루 20-30개 쿼리)하루 약 3-4%
많은 사용 (하루 50개+ 쿼리)하루 약 6-8%

GPU 가속은 더 빠르지만 CPU보다 더 많은 전력을 사용합니다. 이 트레이드오프는 가치가 있습니다—사용자는 즉각적인 응답을 선호합니다.

실제 결과
#

약 200명의 베타 사용자와 함께 2개월 프로덕션 운영 후:

가장 흔한 사용 사례:

  1. “Create a job for [customer name]” - 쿼리의 68%
  2. “Find jobs for [customer]” - 15%
  3. “Update job [ID] to [status]” - 9%
  4. 앱에 대한 일반적인 질문 - 8%

성공률: 91%의 쿼리가 첫 시도에 성공적으로 완료

실패 모드:

  • 고객 미발견 (사용자가 이름 오타) - 4%
  • 컨텍스트 오버플로우 (매우 긴 대화) - 3%
  • 할루시네이션된 데이터 (모델이 고객 ID 생성) - 2%

명시적 customer_id=42 필드가 포함된 구조화된 도구 결과를 추가한 후 할루시네이션 문제가 극적으로 개선되었습니다. 소형 모델에는 이런 구조가 필요합니다.

비용 비교
#

온디바이스 (현재):

  • 인프라: $0/월
  • 사용자당 비용: $0 (일회성 1.1 GB 다운로드)
  • 확장 가능: 무제한 사용자

클라우드 대안 (추정):

  • 평균 대화: 약 2,000 토큰
  • 200 사용자 × 30 대화/월 = 6,000 대화
  • 6,000 × 2,000 = 1,200만 토큰/월
  • OpenAI GPT-3.5: $18/월
  • OpenAI GPT-4: $360/월
  • Anthropic Claude: $180/월

온디바이스 접근 방식은 즉시 비용을 회수했습니다. 대규모(10,000 사용자)에서도 비용은 $0을 유지합니다.

알아야 할 제한사항
#

1. 모델 능력 Qwen 1.7B는 구조화된 작업에 뛰어나지만, 다음에서는 부족합니다:

  • 복잡한 추론 (다중 홉 질문)
  • 사실적 지식 (검색 엔진이 아님)
  • 창의적 콘텐츠 생성
  • 미묘한 언어 이해

소형 모델이 잘하는 것 중심으로 기능을 설계하세요.

2. 디바이스 요구사항 최소: 3 GB RAM, 2 GB 여유 저장공간 권장: 6 GB RAM, 64비트 프로세서, GPU 지원

구형 기기 (iPhone 8, Galaxy S9)는 어려움을 겪습니다. 기능 감지와 우아한 폴백을 고려하세요.

3. 모델 업데이트 모델을 업데이트하려면 사용자가 1.1 GB를 다시 다운로드해야 합니다. 저장 경로에 버전을 지정했습니다:

const MODEL_PATH = `${FileSystem.documentDirectory}llama-models/v2/Qwen3-1.7B-Q4_K_M.gguf`;

새 모델 버전을 출시할 때 경로를 증가시킵니다. 이전 모델은 앱 삭제 시 자동으로 제거됩니다.

플랫폼 차이점: iOS vs Android
#

iOS (Metal):

  • 더 빠른 추론 (Android보다 약 20% 빠름)
  • 더 나은 메모리 관리
  • 모델이 기본적으로 iCloud에 백업됨 (NSURLIsExcludedFromBackupKey로 비활성화 가능)

Android (Vulkan/OpenCL):

  • 기기 간 더 큰 변동성
  • 일부 구형 GPU는 Vulkan을 지원하지 않음 (CPU로 폴백)
  • 저장소가 자동으로 백업되지 않음

두 플랫폼 모두에서 테스트하세요—성능이 크게 다를 수 있습니다.

디버깅 팁
#

1. 상세 로깅 활성화

const ctx = await initLlama({
  model: MODEL_PATH,
  n_ctx: 8192,
  n_gpu_layers: 99,
  verbose: true, // Logs every token + timings
});

2. 토큰 수 추적

const tokens = fullResponse.split(/\s+/).length * 1.3; // Rough estimate
console.log(`Generated ${tokens} tokens in ${duration}ms`);

3. 메모리 모니터링

import { MemoryInfo } from 'react-native-device-info';

const memoryUsage = await MemoryInfo.getUsedMemory();
console.log(`Memory: ${(memoryUsage / 1024 / 1024).toFixed(0)} MB`);

4. 오프라인 테스트

비행기 모드를 활성화하고 모든 것이 작동하는지 확인하세요. 모델은 캐시에서 로드되어야 하고, 추론은 완료되어야 하며, 백엔드 지속성만 정상적으로 실패해야 합니다.

다음 단계
#

멀티모달 지원 llama.cpp는 비전 모델(LLaVA, Qwen2-VL)을 지원합니다. 상상해보세요: “커튼 원단 사진입니다—작업에 추가해주세요.”

파인튜닝 도메인 특화 데이터(과거 작업 설명, 고객 상호작용)로 LoRA 어댑터를 훈련합니다. 정확도를 20-30% 향상시킬 수 있습니다.

음성 인터페이스 Whisper.cpp와 결합하여 온디바이스 음성 인식. 완전한 오프라인 음성 어시스턴트.

연합 학습 원시 데이터를 공유하지 않고 사용자 간 모델 개선을 집계합니다. 프라이버시를 보존하는 개인화.

이것을 구축해야 할까요?
#

적합한 경우:

  • 민감한 데이터를 다루는 비즈니스 앱
  • 오프라인 우선 워크플로우
  • 구조화된 작업 (분류, 데이터 입력, 검색)
  • 대규모에서 비용에 민감한 경우

적합하지 않은 경우:

  • 개방형 대화 (Claude/GPT-4 사용)
  • 지식 집약적 작업 (소형 모델은 훈련 데이터가 제한됨)
  • 저사양 기기 지원이 필요한 경우
  • 빠르게 변하는 도메인 지식

마무리
#

온디바이스 AI는 즉각적인 응답, 오프라인 기능, 제로 한계 비용을 제공합니다. 프라이버시는 덤입니다.

기술 스택:

  • llama.rn: llama.cpp의 React Native 바인딩
  • Qwen 1.7B Q4_K_M: 컴팩트한 지시 따르기 모델
  • 커스텀 도구 시스템: XML 기반 함수 호출
  • 스트림 우선 아키텍처: 토큰별 UI 업데이트

전체 구현: 약 12시간 개발, $0/월 인프라 비용, 오프라인 작동, 완벽한 프라이버시.

민감한 데이터를 다루는 비즈니스 앱을 구축하고 있다면, 이제는 실용적인 선택지입니다. 도구가 작동하고, 성능이 충분하며, 비용을 이기기 어렵습니다.


질문이 있으신가요? 피드백? 저는 @jaredlynskey입니다. llama.rn 라이브러리는 github.com/mybigday/llama.rn에 있습니다.