はじめに
Android 用の OSS キーボードアプリ「Sumire」の開発をしている Kazuma.N です。アプリのバージョン 1.4.475 からニューラル言語モデル zenz-v3.1xsmall を搭載しました。本記事では、この zenz を Android 上で実際に動かすまでの手順 を、私が Sumire に組み込んだときの実例を交えながら解説します。
ニューラル言語モデル zenz について
zenz は、Miwa Keita 氏 と Takahashi Naoki 氏が開発・公開している、日本語のかな漢字変換に特化したニューラル言語モデル群 です。
独立行政法人情報処理推進機構(IPA)の 2024 年度 未踏 IT 人材発掘・育成事業「ニューラル言語モデルによる個人最適な日本語入力システムの開発」 に採択されたプロジェクトでも中核技術として用いられており、学術的にも実用的に高い評価を受けています。(PDF)
ベースには GPT-2 系の文字レベル日本語モデルが使われており、MeCab + ipadic-NEologd などで読みを付与した数百万文規模のデータで転移学習が行われています。(Hugging Face)
その結果、従来型のかな漢字変換エンジンや Google 日本語入力 API を上回る精度を達成しつつも、モデルサイズは数十 M パラメータ程度に抑えられており、ローカル環境でのリアルタイム推論に適した設計になっています。(Zenn)
今回の Sumire では、この zenz シリーズの中でも小型の zenz-v3.1-xsmall-gguf を採用し、Android デバイス上で完結するオフラインかな漢字変換 を実現しています。
ノートパソコンで試してみる
まずは Android で zenz を動かす前にノートパソコンで動かすことができるか試しました。
環境
- WSL (Ubuntu) / Windows 11
- Ubuntu 20.04.3 LTS (GNU/Linux 6.6.87.2-microsoft-standard-WSL2 x86_64)
1. WSL にビルドツールをインストールする
sudo apt update
sudo apt install -y git build-essential cmake
2. llama.cpp を clone してビルドする
git clone https://github.com/ggml-org/llama.cpp.git
cd llama.cpp
cmake -B build
cmake --build build --config Release -j$(nproc)
3. zenz をダウンロードして使用するパスに配置する
# ~/models/zenz に配置
~/models/zenz/ggml-model-Q5_K_M.gguf
4. zenz を llama.cpp を使用して実行する
cd ~/llama.cpp/build/bin
./llama-cli \
-m ~/models/zenz/ggml-model-Q5_K_M.gguf \
--ctx-size 256 \
--n-predict 64
このコマンドを実行すると以下のエラーが出ます。
llama_model_load: error loading model: error loading model vocabulary: unknown pre-tokenizer type: 'gpt2-small-japanese-char'
llama_model_load_from_file_impl: failed to load model
簡単に説明すると pre-tokenizer type の gpt2-small-japanese-char が定義されていないことによるエラーです。zenz を使用するために llama.cpp に修正を加えたフォークを使用する必要があります(Zenn)。
5. フォークされた llama.cpp をクローンしてビルドする
git clone https://github.com/azooKey/llama.cpp.git
cd llama.cpp
# CMake でビルド (CPUのみの例)
cmake -S . -B build -DGGML_CUDA=OFF
cmake --build build -j"$(nproc)"
# 実行する
./build/bin/llama-cli \
-m ~/models/zenz/ggml-model-Q5_K_M.gguf \
--ctx-size 256 \
--n-predict 64 \
-p $'\uEE00セイドガタカイ\uEE01'
セイドガタカイ 精度が高い [end of text]
WSL 上で zenz を使用してかな漢字変換ができました。
llama.cpp を Android で使えるようにする
Android で zenz を動かすには NDK で libllama.so をビルドして、 Kotlin から JNI で呼ぶ必要があります。
NDK は 29.0.14206865 を使用しました。(Latest stable version of R29 2025年11月時点)
1. NDK で azooKey/llama.cpp を Android 用にビルドする
mkdir -p ~/Android/Sdk/ndk
cd ~/Android/Sdk/ndk
# まだなら Linux 版 NDK を直接ダウンロード
wget https://dl.google.com/android/repository/android-ndk-r29-linux.zip
unzip android-ndk-r29-linux.zip
# わかりやすくリネーム
mv android-ndk-r29 android-ndk-r29-linux
# ANDROID_NDK をセット
export ANDROID_NDK="$HOME/Android/Sdk/ndk/android-ndk-r29-linux"
# フォークされt llama.cpp に移動する
cd ~/llama.cpp
# NDK を使用してビルドする
cmake -S . -B build-android \
-DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK/build/cmake/android.toolchain.cmake" \
-DANDROID_ABI=arm64-v8a \
-DANDROID_PLATFORM=android-28 \
-DGGML_OPENMP=OFF \
-DGGML_LLAMAFILE=OFF
-- Setting GGML_NATIVE_DEFAULT to OFF
-- Warning: ccache not found - consider installing it for faster compilation or disable this warning with GGML_CCACHE=OFF
-- CMAKE_SYSTEM_PROCESSOR: aarch64
-- Including CPU backend
-- ARM detected
-- Adding CPU backend variant ggml-cpu:
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/llama.cpp/build-android
build-android/bin/ に以下のファイルが作成されます
- libggml.so
- libggml-base.so
- libggml-cpu.so
- libllama.so
- libllava_shared.so
2. Android の新規プロジェクトを作成する
zenz のデモ用のアプリを Android Studio で新規作成します。
プロジェクトを新規作成後に zenz をライブラリとして新規 module を作成しました。
3. llama.cpp のヘッダファイルをコピーする
zenz/src/main/cpp/ に以下のファイルをコピーします。
- ggml-alloc.h
- ggml-backend.h
- ggml-cpu.h
- ggml.h
- llama.h
ZooKeyKanaKanjiConverter からヘッダファイルをコピーさせて頂きました。
4. CMakeLists.txt を作成する
以下のように作成しました。
今回のデモではビルド済みの so ファイルをライブラリとして使用します。ブリッジとして zenz_bridge.cpp を後ほど作成します。
cmake_minimum_required(VERSION 3.22.1)
project(zenz_core LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# CMAKE_SOURCE_DIR = zenz/src/main/cpp を想定
# jniLibs = zenz/src/main/jniLibs/${ANDROID_ABI}
set(LIB_DIR ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
# 事前ビルド済みの .so を IMPORTED ライブラリとして扱う
add_library(ggml SHARED IMPORTED)
set_target_properties(ggml PROPERTIES IMPORTED_LOCATION
${LIB_DIR}/libggml.so)
add_library(ggml-base SHARED IMPORTED)
set_target_properties(ggml-base PROPERTIES IMPORTED_LOCATION
${LIB_DIR}/libggml-base.so)
add_library(ggml-cpu SHARED IMPORTED)
set_target_properties(ggml-cpu PROPERTIES IMPORTED_LOCATION
${LIB_DIR}/libggml-cpu.so)
add_library(llama SHARED IMPORTED)
set_target_properties(llama PROPERTIES IMPORTED_LOCATION
${LIB_DIR}/libllama.so)
# JNI ブリッジ本体(ライブラリ名: zenz)
add_library(zenz SHARED
zenz_bridge.cpp
)
# llama.h / ggml*.h が置いてあるフォルダを include path に追加
# (ggml-backend.h, ggml-cpu.h, ggml.h, llama.h が src/main/cpp にある想定)
target_include_directories(zenz PRIVATE
${CMAKE_SOURCE_DIR}
)
# Android の log ライブラリ
find_library(log-lib log)
# Android の log ライブラリ + llama/ggml にリンク
target_link_libraries(zenz
PRIVATE
llama
ggml
ggml-base
ggml-cpu
${log-lib}
)
5. zenz_bridge.cpp の作成
Kotlin から llama.cpp を使用するために JNI を作成します。この記事では主な関数のみを紹介します。
extern "C"
JNIEXPORT void JNICALL
Java_com_kazumaproject_zenz_ZenzEngine_initModel(
JNIEnv *env,
jobject /* thiz */,
jstring jModelPath
) {
const char *c_model_path = env->GetStringUTFChars(jModelPath, nullptr);
LOGI("initModel: %s", c_model_path);
// 再 init 時のクリーンアップ
if (g_model) {
llama_model_free(g_model);
g_model = nullptr;
g_vocab = nullptr;
}
llama_backend_init();
llama_model_params mparams = llama_model_default_params();
mparams.n_gpu_layers = 0; // Android は CPU 前提
mparams.use_mmap = true;
g_model = llama_model_load_from_file(c_model_path, mparams);
if (!g_model) {
LOGE("Failed to load model");
env->ReleaseStringUTFChars(jModelPath, c_model_path);
return;
}
g_vocab = llama_model_get_vocab(g_model);
if (!g_vocab) {
LOGE("Failed to get vocab");
llama_model_free(g_model);
g_model = nullptr;
env->ReleaseStringUTFChars(jModelPath, c_model_path);
return;
}
env->ReleaseStringUTFChars(jModelPath, c_model_path);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_kazumaproject_zenz_ZenzEngine_generateWithContextAndConditions(
JNIEnv *env,
jobject /* thiz */,
jstring jProfile,
jstring jTopic,
jstring jStyle,
jstring jPreference,
jstring jLeftContext,
jstring jInput
) {
if (!g_model || !g_vocab) {
return env->NewStringUTF("Model not initialized");
}
// JNI 文字列を取得(null 許容)
const char *c_profile = jProfile ? env->GetStringUTFChars(jProfile, nullptr) : nullptr;
const char *c_topic = jTopic ? env->GetStringUTFChars(jTopic, nullptr) : nullptr;
const char *c_style = jStyle ? env->GetStringUTFChars(jStyle, nullptr) : nullptr;
const char *c_preference = jPreference ? env->GetStringUTFChars(jPreference, nullptr) : nullptr;
const char *c_left = jLeftContext ? env->GetStringUTFChars(jLeftContext, nullptr) : nullptr;
const char *c_input = jInput ? env->GetStringUTFChars(jInput, nullptr) : nullptr;
std::string profile = c_profile ? c_profile : "";
std::string topic = c_topic ? c_topic : "";
std::string style = c_style ? c_style : "";
std::string preference = c_preference ? c_preference : "";
std::string left = c_left ? c_left : "";
std::string input = c_input ? c_input : "";
if (c_profile) env->ReleaseStringUTFChars(jProfile, c_profile);
if (c_topic) env->ReleaseStringUTFChars(jTopic, c_topic);
if (c_style) env->ReleaseStringUTFChars(jStyle, c_style);
if (c_preference) env->ReleaseStringUTFChars(jPreference, c_preference);
if (c_left) env->ReleaseStringUTFChars(jLeftContext, c_left);
if (c_input) env->ReleaseStringUTFChars(jInput, c_input);
// Zenz v3 で使われるタグ
const std::string inputTag = u8"\uEE00";
const std::string contextTag = u8"\uEE02";
const std::string profileTag = u8"\uEE03";
const std::string topicTag = u8"\uEE04";
const std::string styleTag = u8"\uEE05";
const std::string preferenceTag = u8"\uEE06";
const std::string outputTag = u8"\uEE01";
// conditions を連結
std::string conditions;
if (!profile.empty()) {
conditions += profileTag + profile;
}
if (!topic.empty()) {
conditions += topicTag + topic;
}
if (!style.empty()) {
conditions += styleTag + style;
}
if (!preference.empty()) {
conditions += preferenceTag + preference;
}
// プロンプト構築
std::string prompt;
if (!left.empty()) {
// 文脈あり: conditions + contextTag + left + inputTag + input + outputTag
prompt = conditions + contextTag + left + inputTag + input + outputTag;
} else {
// 文脈なし: conditions + inputTag + input + outputTag
prompt = conditions + inputTag + input + outputTag;
}
std::string result = pure_greedy_decoding(prompt, /*maxCount=*/32);
return env->NewStringUTF(result.c_str());
}
6. ZenzEngine の作成
JNI 関数を作成したので次に native library を使用するための Kotlin 側でラッパを作成する必要があります。
package com.kazumaproject.zenz
object ZenzEngine {
init {
// CMake の add_library(zenz SHARED ...) と一致させる
System.loadLibrary("zenz")
}
external fun initModel(modelPath: String)
external fun generate(prompt: String): String
external fun generateWithContext(leftContext: String, input: String): String
external fun generateWithContextAndConditions(
profile: String,
topic: String,
style: String,
preference: String,
leftContext: String,
input: String
): String
}
7. zenz の build.gradle を修正する
以下の設定を追加します。
android {
defaultConfig {
ndk {
abiFilters "arm64-v8a"
}
}
// CMake でネイティブライブラリをビルド
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
}
}
}
8. MainActivity で使用する
val modelFile = copyModelFromAssetsIfNeeded(modelAssetFileName)
// モデルの読み込み
ZenzEngine.initModel(modelFile.absolutePath)
// zenz を使用した推論
ZenzEngine.generateWithContextAndConditions(
profile = profile,
topic = topic,
style = style,
preference = preference,
leftContext = leftContext,
input = katakana
)
最後に
今回紹介した手順は、あくまで「Sumire に zenz を組み込んだときの、ひとつの実装例」です。実際には、端末性能やアプリの設計方針、UI/UX の要件によって「どこまでモデルを使うか」「どのタイミングで推論するか」は変わってくるはずです。この記事が、みなさん自身の IME 開発やローカル LLM 活用の出発点・参考例になればうれしいです。
また、素晴らしいモデルと実装例を公開してくださっている
Miwa Keita 氏、Takahashi Naoki 氏、azooKey / Zenzai プロジェクト、そして llama.cpp をはじめとする OSS コミュニティの皆さまに、深く感謝します。これらの成果があってはじめて、Sumire のローカルかな漢字変換も実現できました。
本記事で使用したデモアプリは GitHub で公開しています。
実際にビルドして動かしたり、自分のプロジェクト向けにコードを読み替えたりして、自由に活用してください。
ここまで読んでいただき、ありがとうございました。もし記事や Sumire を通してフィードバックや質問などあれば、X(@KazumaN1172)などで気軽に教えてもらえると、とても励みになります。
