こんにちは、たなたつです。

最近はローカルでも実行できる大規模言語モデル (LLM) がよく話題になってますね。

僕は普段 Apple プラットフォームに関わっていることが多いので、Swift から簡単にいろいろなローカル LLM が利用できたらいいなーと思い、調査してみましたが好みに合うものが見つかりませんでした。

そこで、Swift から簡単にローカル LLM を利用できるライブラリ「LocalLLMClient」を作成してみました。
もちろん、macOS アプリや iOS アプリから利用できます。

LocalLLMClient でできること

  • 複数バックエンドのサポート
    • llama.cpp と Apple MLX が裏で動きます。同じ I/F でバックエンドが切り替えられます。
  • iOS/macOS サポート
    • iPhone と Mac で動作します。
  • ストリーミング API
    • Swift Concurrency を使って良い感じに生成結果が受け取れます。
  • マルチモーダル対応
    • テキストだけでなく、画像も対応してます。

MLX の方が動作が早いから MLX を使いたいけど、新しいモデルへの対応が遅いから llama.cpp も使いたいケースがあったので、それを実現しました。

各バックエンドが対応している VLM (Visual Language Model) もサポートしているので、「この写真に何が写ってる?」のような使い方もできます。
iOS でも iPhone 16 Pro であれば、Qwen 2.5 VL 3B 4bit はギリギリ動作しました。

使い方

最新の情報はGitHubを見てください。

Swift Package として提供しているので、依存として追加してください。
モジュールとして分割されているので、必要なものを利用します。

  • LocalLLMClient: 共通 I/F
  • LocalLLMClientLlama: llama.cpp バックエンド
  • LocalLLMClientMLX: Apple MLX バックエンド
  • LocalLLMClientUtility: LLM モデルダウンローダー等のユーティリティ

テキスト生成

簡単な使い方を紹介します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import LocalLLMClient
import LocalLLMClientLlama
import LocalLLMClientUtility

// モデルのダウンロード(例: Gemma 3)
let ggufName = "gemma-3-4B-it-QAT-Q4_0.gguf"
let downloader = FileDownloader(source: .huggingFace(
    id: "lmstudio-community/gemma-3-4B-it-qat-GGUF",
    globs: [ggufName]
))

try await downloader.download { progress in
    print("Download progress: \(progress)")
}

// クライアントの初期化
let modelURL = downloader.destination.appending(component: ggufName)
let client = try await LocalLLMClient.llama(url: modelURL, parameter: .init(
    context: 4096,      // テキストコンテキストサイズ
    temperature: 0.7,   // ランダム性(0.0〜1.0)
    topK: 40,           // トップKサンプリング
    topP: 0.9,          // トップP(nucleus)サンプリング
    options: .init(responseFormat: .json) // レスポンス形式
))

let prompt = """
猫が主役の壮大な物語のあらすじの冒頭を考えてください。
形式はJSONで、以下のようにしてください。
{
    "title": "<title>",
    "content": "<content>",
}
"""

// テキスト生成
let input = LLMInput.chat([
    .system("You are a helpful assistant."),
    .user(prompt)
])

for try await text in try await client.textStream(from: input) {
    print(text, terminator: "")
}

ちなみに結果はこれでした。普通に面白そう。

1
2
3
4
{
  "title": "月影の爪痕",
  "content": "遥か昔、人間がまだ星を夢見ていた頃、世界は猫によって支配されていた。猫たちはその知性と優雅さで、各地の王国を治め、人間を可愛らしいペットとして飼育していた。しかし、猫の世界にも陰謀と秘密が渦巻いていた。強力な魔法に支配された猫の血統である『月影の猫』は、その力を使って世界を支配しようと企んでいた。主人公のミケは、その陰謀を阻止するため、旅に出ることを決意する。彼女は、伝説の猫の賢者と出会い、古の知識と魔法の力を手に入れる。しかし、月影の猫たちの追手がミケを執拗に追いかけ、猫と人間が共存する世界を揺るがす戦いが始まる。月影の猫たちの陰謀を阻止するため、ミケは自分の運命を受け入れ、世界を救う壮大な冒険へと旅立つ。"
}

マルチモーダル

画像を入力する例を紹介します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import LocalLLMClient
import LocalLLMClientMLX
import LocalLLMClientUtility

// モデルファイルのダウンロード(例: Qwen2.5 VL 3B)
let downloader = FileDownloader(source: .huggingFace(
    id: "mlx-community/Qwen2.5-VL-3B-Instruct-abliterated-4bit",
    globs: .mlx
))

try await downloader.download { progress in
    print("Download progress: \(progress)")
}

let client = try await LocalLLMClient.mlx(url: downloader.destination)

// 画像を含む入力の作成
let input = LLMInput.chat([
    .user("この写真に何が写っているかを歌で伝えて", attachments: [.image(<画像>)]),
])

// ストリーミングせずにテキストを一気に取得
print(try await client.generateText(from: input))

下記の写真で試すとこうなりました。

WWDC Goods1
この写真の歌詞は以下のようになります:

墓碑の翼たたむ
遠い彼方に
愛を結ぶ
願いが宿る

この歌詞は、墓碑に描かれた翼をたたむ天使の姿を表現しています。遠くに隠れる愛を結ぶ願いが写っている墓碑の上に描かれています。天使の姿が描かれた墓碑の上に描かれている願いが宿っているというイメージを表現しています。

その他

LocalLLMClientUtility にある FileDownloader には、すでにダウンロード済みの場合はモデルのダウンロードをスキップしてくれる機能や、iOS アプリに便利なバックグラウンドダウンロード (アプリをバックグラウンドにしてもモデルのダウンロードを継続する) 機能などがあります。

サンプルアプリもあるので、ぜひ実際に動かしながら試してみてください。

おわりに

普段関わっている Apple プラットフォームはプライバシーなどに配慮し、できる限りオンデバイスで動かそうとする思想があり、僕もそれに強く賛同しています。
また、ちょっと AI で遊びたい時に、ミスって無限にお金が溶けるのが怖い臆病者なので、ローカル LLM の開発や提供、活用のために頑張っている方々には感謝しかないです。ありがとう!

Swift で AI と遊びたくなった時の選択肢として、LocalLLMClient を使ってもらえたら嬉しいです。

プルリクエストももちろん大歓迎です!

⭐ も良ければ…