Привет, Хабр! Продолжаем серию статей о разработке мобильных приложений с помощью Capacitor. Если вы не читали предыдущие части, лучше начать с них:
В этой части разберём, как запустить языковую модель прямо на телефоне — без сервера, без API-ключей и без постоянного интернета.
Зачем локальный AI
На первый взгляд может показаться, что локальная модель на телефоне — это лишняя сложность. Если уже есть ChatGPT, Claude или Gemini, зачем запускать всё на устройстве? Но у облачного подхода есть ограничения, которые в некоторых сценариях становятся критичными.
-
Приватность. Пользователь может вводить заметки, сообщения, документы или даже медицинские данные. Не всегда разумно отправлять это на внешний сервер.
-
Офлайн. Приложение должно работать в метро, в самолёте и в местах с плохой связью.
-
Задержка. Даже быстрый сетевой запрос добавляет лишнее время, а в интерактивных сценариях это чувствуется.
-
Стоимость. API-запросы требуют бюджета, и при росте аудитории это быстро становится заметной статьёй расходов.
Локальный вывод закрывает все эти проблемы сразу. За это приходится платить памятью, батареей и размером модели, но современные компактные модели уже умеют работать достаточно аккуратно даже на потребительских устройствах.
Какую модель выбрать
Для мобильных устройств лучше всего подходят квантизированные модели небольшого размера. Сейчас разумно смотреть на несколько семейств: Gemma, Qwen и Phi. У каждого варианта есть свои сильные стороны, и универсального ответа здесь нет.
Gemma 3
Gemma 3 — понятный и уже хорошо проверенный вариант. Для простых сценариев можно смотреть на две модели:
|
Модель |
Параметры |
Размер файла |
Для кого |
|---|---|---|---|
|
Gemma 3 270M |
270 млн |
~400 MB |
Быстрые задачи, слабые устройства |
|
Gemma 3 1B |
1 млрд |
~1.2 GB |
Баланс качества и скорости |
Эти модели работают через LiteRT в формате .task, поэтому их удобно использовать в Android-приложениях.
Gemma 4
Gemma 4 — более свежее поколение, ориентированное в том числе на edge-сценарии. Для мобильных устройств интересны модели E2B и E4B.
|
Модель |
Эфф. параметры |
RAM |
Что нового |
|---|---|---|---|
|
Gemma 4 E2B |
2.3 млрд |
< 1.5 GB |
Текст, изображения и аудио |
|
Gemma 4 E4B |
4.5 млрд |
~3 GB |
Более высокое качество на мощных устройствах |
Что здесь важно:
-
Модель стала быстрее и экономичнее.
-
Появилась мультимодальность.
-
Поддерживается function calling.
-
Используется формат
.litertlm, который пришёл на смену.task.
Модели доступны на Hugging Face: Gemma 4 E2B и Gemma 4 E4B.
Qwen3
Qwen3 от Alibaba тоже хорошо смотрится в on-device сценариях. У младших моделей есть LiteRT-сборки, и это делает их удобными для мобильных приложений.
|
Модель |
Параметры |
Для кого |
|---|---|---|
|
Qwen3-0.6B |
600 млн |
Очень лёгкие сценарии |
|
Qwen3-1.7B |
1.7 млрд |
Баланс между качеством и скоростью |
У Qwen3 есть удобная особенность: модель может работать в обычном режиме или в режиме thinking. Это полезно, когда простые запросы нужно обрабатывать быстро, а сложные — чуть глубже. Также у модели есть поддержка tool use и хорошая мультиязычность.
Phi-4 mini
Phi-4 mini — вариант от Microsoft, который больше ориентирован на рассуждения и агентские сценарии. Несмотря на небольшой размер, модель показывает хорошие результаты и поддерживает LiteRT-LM, function calling и длинный контекст.
Если нужна модель для сложной логики, она вполне может быть хорошим выбором. Но по памяти и скорости это уже не самый лёгкий вариант.
DeepSeek
DeepSeek часто упоминают в разговорах про эффективные модели, но для локального мобильного сценария он сейчас не подходит в том же смысле, что Gemma или Qwen. Для этой статьи важен именно on-device вариант, а не облачный клиент. Поэтому DeepSeek здесь лучше рассматривать как отдельную облачную историю.
Что выбрать
Если нужен короткий ориентир, я бы смотрел так:
-
Агентский чат с минимальными требованиями — Gemma 4 E2B или Qwen3-1.7B.
-
Самый лёгкий вариант — Qwen3-0.6B.
-
Акцент на рассуждения — Phi-4 mini.
-
Мультимодальность — Gemma 4 E2B или E4B.
-
Широкая совместимость со старыми устройствами — Gemma 3 1B.
На Android такие модели обычно работают через LiteRT или LiteRT-LM, а на iOS проще всего опираться на Apple Intelligence, если устройство и версия системы это позволяют.
Плагин @capgo/capacitor-llm
Для интеграции локальных моделей удобно использовать плагин @capgo/capacitor-llm. Он оборачивает нативные inference-движки в привычный Capacitor-интерфейс и позволяет работать с AI почти так же, как с обычным сервисом в приложении.
Установка стандартная:
npm install @capgo/capacitor-llmnpx cap sync
Плагин рассчитан на современные версии Capacitor. В документации Capgo отдельно указаны методы createChat, sendMessage, getReadiness, setModel и downloadModel, а также события для стриминга токенов и прогресса загрузки.
Основные методы
getReadiness(): Promise<{ ready: boolean }>setModel(options: { model: string }): Promise<void>downloadModel(options: { url: string }): Promise<void>createChat(options?: { instructions?: string }): Promise<{ chatId: string }>sendMessage(options: { chatId: string; text: string }): Promise<void>
Ответы приходят через события. Это удобно, если хочется обновлять интерфейс по мере генерации текста:
CapacitorLlm.addListener('textFromAi', (event: { text: string }) => { ... })CapacitorLlm.addListener('aiFinished', () => { ... })CapacitorLlm.addListener('downloadProgress', (event: { progress: number }) => { ... })CapacitorLlm.addListener('readinessChange', (event: { ready: boolean }) => { ... })
Схема работы получается простой: UI отправляет запрос в плагин, тот передаёт его нативному движку, а результат возвращается в приложение по стримингу.
Настройка Android
Минимальная версия SDK
Для Android стоит сразу проверить minSdkVersion. В проекте лучше держать его не ниже 24, иначе можно быстро упереться в ограничения платформы.
ext { minSdkVersion = 24}
Где взять модель
Для Gemma 3 модель можно брать в формате .task, а для Gemma 4 — в формате .litertlm. Это важная разница: у них разный runtime и разный способ упаковки.
Есть два нормальных способа доставки модели в приложение:
-
Встроить модель в APK или AAB. Это удобно для тестов и демо, но сильно увеличивает размер сборки.
-
Скачать модель при первом запуске. Это лучше для production, если вы готовы показать пользователю понятный экран загрузки.
Пример загрузки:
await CapacitorLlm.downloadModel({ url: 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm'})
Если модель кладёте в assets, путь указывается без assets/. Например:
await CapacitorLlm.setModel({ model: 'gemma-4-E2B-it-int4.litertlm' })await CapacitorLlm.setModel({ model: 'gemma3-1b-it-int4.task' })
Настройка iOS
На iOS ситуация проще, если использовать Apple Intelligence. Это системный путь: не нужно хранить отдельный файл модели и не нужно вручную управлять её загрузкой.
Что нужно учитывать
-
iOS 18.2 и выше.
-
Поддерживаемое устройство.
-
Язык устройства и доступность функции могут влиять на readiness.
Если Apple Intelligence недоступна, getReadiness() вернёт false, и приложение должно это корректно обработать. Лучше сразу предусмотреть заглушку или облачный fallback.
Если вы хотите экспериментировать с кастомными моделями на iOS, это уже отдельная история и не самый стабильный путь. Для прикладного приложения я бы не делал на это ставку как на основную ветку.
Пишем чат-интерфейс: минимальный пример
Прежде чем переходить к production-архитектуре, соберём простой self-contained чат — чтобы разобраться, как плагин работает в принципе. Возьмём React, но логика переносится на любой фреймворк. Если вас интересует сразу полноценный вариант с FSD и Zustand — можно перейти к следующему разделу.
Сервис для модели
Вынесем работу с плагином в отдельный сервис. Так код будет проще тестировать и переиспользовать.
import { CapacitorLlm } from '@capgo/capacitor-llm'export type MessageRole = 'user' | 'assistant'export interface ChatMessage { role: MessageRole text: string}export class LlmService { private chatId: string | null = null async initialize(systemPrompt?: string): Promise<void> { const { ready } = await CapacitorLlm.getReadiness() if (!ready) { throw new Error('LLM не готова. Проверьте поддержку устройства.') } const { chatId } = await CapacitorLlm.createChat({ instructions: systemPrompt, }) this.chatId = chatId } async sendMessage(text: string): Promise<void> { if (!this.chatId) { throw new Error('Чат не инициализирован. Сначала вызовите initialize().') } await CapacitorLlm.sendMessage({ chatId: this.chatId, text }) } onToken(callback: (token: string) => void) { return CapacitorLlm.addListener('textFromAi', ({ text }) => callback(text)) } onFinished(callback: () => void) { return CapacitorLlm.addListener('aiFinished', callback) }}export const llmService = new LlmService()
Хук состояния
import { useState, useEffect, useRef } from 'react'import { llmService, ChatMessage } from './llm-service'export function useChat() { const [messages, setMessages] = useState<ChatMessage[]>([]) const [isGenerating, setIsGenerating] = useState(false) const [isReady, setIsReady] = useState(false) const currentResponseRef = useRef('') useEffect(() => { // Системный промпт сильно влияет на поведение модели: чем конкретнее роль и ограничения, // тем предсказуемее ответы. Не жалейте времени на его итерацию. llmService .initialize('Ты — полезный ассистент. Отвечай кратко и по делу.') .then(() => setIsReady(true)) .catch(console.error) const tokenListener = llmService.onToken((token) => { currentResponseRef.current += token setMessages((prev) => { const updated = [...prev] if (updated.at(-1)?.role === 'assistant') { updated[updated.length - 1] = { role: 'assistant', text: currentResponseRef.current, } } return updated }) }) const finishListener = llmService.onFinished(() => { setIsGenerating(false) currentResponseRef.current = '' }) return () => { tokenListener.then((l) => l.remove()) finishListener.then((l) => l.remove()) } }, []) const sendMessage = async (text: string) => { if (!isReady || isGenerating) return setMessages((prev) => [...prev, { role: 'user', text }]) setMessages((prev) => [...prev, { role: 'assistant', text: '' }]) setIsGenerating(true) await llmService.sendMessage(text) } return { messages, isGenerating, isReady, sendMessage }}
Компонент чата
import React, { useState } from 'react'import { useChat } from '../model/use-chat'export function Chat() { const { messages, isGenerating, isReady, sendMessage } = useChat() const [input, setInput] = useState('') const handleSend = async () => { if (!input.trim()) return const text = input setInput('') await sendMessage(text) } if (!isReady) { return ( <div className="flex items-center justify-center h-full"> <p className="text-gray-500">Загружаем модель...</p> </div> ) } return ( <div className="flex flex-col h-full"> <div className="flex-1 overflow-y-auto p-4 space-y-3"> {messages.map((msg, i) => ( <div key={i} className={`max-w-[80%] rounded-2xl px-4 py-2 text-sm ${ msg.role === 'user' ? 'ml-auto bg-blue-500 text-white' : 'mr-auto bg-gray-100 text-gray-900' }`} > {msg.text || (isGenerating ? '▌' : '')} </div> ))} </div> <div className="p-4 border-t flex gap-2"> <input className="flex-1 border rounded-xl px-3 py-2 text-sm outline-none" value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && !isGenerating && handleSend()} placeholder="Напишите сообщение..." disabled={isGenerating} /> <button className="px-4 py-2 bg-blue-500 text-white rounded-xl text-sm disabled:opacity-50" onClick={handleSend} disabled={isGenerating || !input.trim()} > {isGenerating ? '...' : 'Отправить'} </button> </div> </div> )}
Скачивание модели
Если модель скачивается при первом запуске, обязательно показывайте прогресс. Иначе пользователю будет казаться, что приложение зависло.
import React, { useState } from 'react'import { CapacitorLlm } from '@capgo/capacitor-llm'const MODEL_URL = 'https://your-cdn.com/gemma3-1b-it-int4.task'export function ModelDownload({ onComplete }: { onComplete: () => void }) { const [progress, setProgress] = useState(0) const [isDownloading, setIsDownloading] = useState(false) const startDownload = async () => { setIsDownloading(true) const listener = await CapacitorLlm.addListener( 'downloadProgress', ({ progress }) => setProgress(Math.round(progress)) ) try { await CapacitorLlm.downloadModel({ url: MODEL_URL }) onComplete() } finally { listener.remove() setIsDownloading(false) } } return ( <div className="flex flex-col items-center justify-center h-full gap-6 p-8"> <h2 className="text-xl font-semibold text-center"> Для работы нужно скачать AI-модель </h2> <p className="text-gray-500 text-sm text-center"> Gemma 3 1B (~1.2 GB), загрузка нужна только один раз </p> {isDownloading ? ( <div className="w-full max-w-xs"> <div className="h-2 bg-gray-200 rounded-full overflow-hidden"> <div className="h-full bg-blue-500 transition-all duration-300" style={{ width: `${progress}%` }} /> </div> <p className="text-center text-sm text-gray-500 mt-2">{progress}%</p> </div> ) : ( <button className="px-6 py-3 bg-blue-500 text-white rounded-xl" onClick={startDownload} > Скачать модель </button> )} </div> )}
Практика на проекте
Пример выше показал минимальный рабочий вариант — класс LlmService и хук useChat. Для небольшого изолированного чата этого достаточно. В реальном приложении с несколькими экранами, тестами и доменной логикой удобнее выстроить более явную архитектуру: вынести плагин за интерфейс LlmGateway, управлять состоянием через Zustand и отделить инициализацию от UI. Именно это и разберём на примере моего пет проекта PaperFlow.
PaperFlow — сервис для хранения и отслеживания документов. Пользователь сканирует паспорта, договоры и гарантии, а приложение напоминает о сроках действия. В такой задаче локальный AI особенно полезен: он может отвечать на вопросы о документах, не отправляя их на сервер.
Структура фичи
Если придерживаться FSD, удобно вынести всё в отдельный срез в features.
src/ features/ document-assistant/ model/ types.ts contracts.ts context-builder.ts use-cases.ts store.ts hooks.ts api/ llm-gateway.ts ui/ AssistantSheet.tsx MessageBubble.tsx index.ts
Такой подход изолирует AI-логику от остального приложения. Доменные сущности не знают про ассистента, а сам ассистент можно выключить, не затронув остальной код.
Контракт
Сначала описываем интерфейс, чтобы UI не зависел от конкретного плагина.
export type AssistantMessage = { role: 'user' | 'assistant' text: string}export interface LlmGateway { initialize(systemPrompt: string): Promise<void> sendMessage(text: string): Promise<void> onToken(cb: (token: string) => void): Promise<() => void> onFinished(cb: () => void): Promise<() => void> getReadiness(): Promise<boolean>}
Реализация шлюза
import { CapacitorLlm } from '@capgo/capacitor-llm'import { LlmGateway } from '../model/contracts'export const createCapacitorLlmGateway = (): LlmGateway => { let chatId: string | null = null return { async getReadiness() { const { ready } = await CapacitorLlm.getReadiness() return ready }, async initialize(systemPrompt) { const { chatId: id } = await CapacitorLlm.createChat({ instructions: systemPrompt, }) chatId = id }, async sendMessage(text) { if (!chatId) throw new Error('LLM не инициализирована') await CapacitorLlm.sendMessage({ chatId, text }) }, async onToken(cb) { const listener = await CapacitorLlm.addListener('textFromAi', ({ text }) => cb(text)) return () => listener.remove() }, async onFinished(cb) { const listener = await CapacitorLlm.addListener('aiFinished', cb) return () => listener.remove() }, }}
Контекст из документов
import { Document } from '@/entities/documents'const formatDocument = (doc: Document): string => { const parts = [`— "${doc.title}"`] if (doc.categoryId) { parts.push(`категория: ${doc.categoryId}`) } if (doc.expiresAt) { const expiresDate = new Date(doc.expiresAt).toLocaleDateString('ru-RU') const daysLeft = Math.ceil( (new Date(doc.expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24) ) if (daysLeft <= 0) { parts.push(`истёк ${expiresDate}`) } else { parts.push(`истекает ${expiresDate} (через ${daysLeft} дн.)`) } } else { parts.push('без срока действия') } if (doc.tagIds.length > 0) { parts.push(`теги: ${doc.tagIds.join(', ')}`) } return parts.join(' ')}export const buildDocumentContext = (documents: Document[]) => { const active = documents.filter((d) => !d.archived) const today = new Date().toLocaleDateString('ru-RU') const docList = active.length > 0 ? active.map(formatDocument).join('\n') : 'документов пока нет' const systemPrompt = `Ты — ассистент приложения PaperFlow для работы с документами.Сегодня: ${today}.Документы пользователя:${docList}Правила:1. Отвечай только по документам из списка.2. Если документа нет в списке, честно скажи об этом.3. Отвечай кратко и по делу.4. На вопросы вне темы документов вежливо отказывай.`.trim() return { systemPrompt }}
Юз-кейсы и стор
import { LlmGateway } from './contracts'import { buildDocumentContext } from './context-builder'import { Document } from '@/entities/documents'export const initializeAssistant = async ( gateway: LlmGateway, documents: Document[]): Promise<void> => { const ready = await gateway.getReadiness() if (!ready) { throw new Error('LOCAL_LLM_NOT_READY') } const { systemPrompt } = buildDocumentContext(documents) await gateway.initialize(systemPrompt)}export const sendAssistantMessage = async ( gateway: LlmGateway, text: string): Promise<void> => { await gateway.sendMessage(text)}
import { create } from 'zustand'import { createCapacitorLlmGateway } from '../api/llm-gateway'import { initializeAssistant, sendAssistantMessage } from './use-cases'import { AssistantMessage } from './contracts'import { Document } from '@/entities/documents'type AssistantStatus = 'idle' | 'initializing' | 'ready' | 'generating' | 'unavailable'type AssistantStore = { status: AssistantStatus messages: AssistantMessage[] initialize: (documents: Document[]) => Promise<void> send: (text: string) => Promise<void> appendToken: (token: string) => void finishGeneration: () => void}const gateway = createCapacitorLlmGateway()export const useAssistantStore = create<AssistantStore>((set, get) => ({ status: 'idle', messages: [], initialize: async (documents) => { set({ status: 'initializing' }) try { await initializeAssistant(gateway, documents) await gateway.onToken((token) => get().appendToken(token)) await gateway.onFinished(() => get().finishGeneration()) set({ status: 'ready' }) } catch { set({ status: 'unavailable' }) } }, send: async (text) => { if (get().status !== 'ready') return set((s) => ({ status: 'generating', messages: [ ...s.messages, { role: 'user', text }, { role: 'assistant', text: '' }, ], })) await sendAssistantMessage(gateway, text) }, appendToken: (token) => { set((s) => { const messages = [...s.messages] const last = messages.at(-1) if (last?.role === 'assistant') { messages[messages.length - 1] = { role: 'assistant', text: last.text + token, } } return { messages } }) }, finishGeneration: () => { set({ status: 'ready' }) },}))// История чата не сохраняется между сессиями автоматически — каждый createChat начинается с чистого листа.// Если нужна персистентность, сохраняйте messages в AsyncStorage или SQLite и передавайте// историю заново в системный промпт при следующем запуске.
Подключение к экрану
import { useEffect } from 'react'import { useDocumentsStore } from '@/entities/documents'import { useAssistantStore } from './store'export const useDocumentAssistant = () => { const documents = useDocumentsStore((s) => s.documents) const { status, messages, initialize, send } = useAssistantStore() useEffect(() => { if (status === 'idle') { initialize(documents) } }, [status, documents, initialize]) return { status, messages, send }}
import { useState } from 'react'import { useDocumentAssistant } from '@/features/document-assistant'import { AssistantSheet } from '@/features/document-assistant'export function HomeToolbar() { const [open, setOpen] = useState(false) const { status } = useDocumentAssistant() return ( <> <button onClick={() => setOpen(true)} disabled={status === 'initializing' || status === 'unavailable'} className="p-2 rounded-full bg-gray-100" aria-label="Открыть ассистента" > Открыть ассистента </button> <AssistantSheet open={open} onClose={() => setOpen(false)} /> </> )}
Пока модель инициализируется (status === 'initializing'), кнопка задизейблена. Пользователь видит чат в состоянии загрузки:
Как только status переходит в ready — чат готов к работе, и ответы на вопросы о документах приходят полностью локально:
Итог
Локальный AI в Capacitor-приложении — это уже не эксперимент, а вполне рабочий инструмент. Он помогает сохранить приватность, работать офлайн и не платить за каждый запрос к API.
Мы прошли путь от выбора модели до готовой фичи: разобрали настройку под Android и iOS, написали базовый чат, встроили ассистента в реальное приложение и обернули всё в нормальную архитектуру с разделением ответственности. Компактные модели — Gemma 4 E2B, Qwen3-1.7B, Phi-4 mini — уже сегодня способны решать практические задачи прямо на устройстве.
В следующей части займёмся CI/CD для Capacitor. На этом у меня все. Пишите любые интересующие вас вопросы в комментарии и в личку.
Ссылки:
ссылка на оригинал статьи https://habr.com/ru/articles/1039318/