🍎

iOS 26 Foundation Models × Tool Calling で在庫管理システムと連携してみた

に公開

この記事は2025 ZAICO アドベントカレンダーの4日目の記事です!

はじめに

WWDC25で発表された iOS 26(macOS 26)の目玉機能の一つ、Foundation Models フレームワーク。Apple Intelligence のオンデバイスLLMを Swift から直接利用できるようになりました。

本記事では、このフレームワークの Tool Calling 機能を使って、実際に在庫管理システム ZAICO と連携するiOSアプリを実装した際の知見をまとめます。

https://www.zaico.co.jp/

Foundation Models フレームワークとは

Foundation Models は、Apple Intelligence のオンデバイス言語モデルを Swift から利用するためのフレームワークです。

主な特徴

  • 完全オンデバイス: クラウドAPIへの通信不要
  • プライバシー重視: データが端末外に出ない
  • 低遅延: ネットワーク遅延なし
  • 無料: API利用料が発生しない

対応デバイス

Apple Intelligence対応デバイスで利用可能:

  • iPhone 15 Pro / 15 Pro Max 以降
  • M1チップ以降搭載のiPad / Mac

基本的な使い方

セッションの作成

import FoundationModels

let session = LanguageModelSession {
    """
    You are a helpful assistant.
    Always respond in Japanese.
    """
}

テキスト生成

// 同期的にレスポンスを取得
let response = try await session.respond(to: "こんにちは")
print(response.content)

// ストリーミングでレスポンスを取得
let stream = session.streamResponse(to: "こんにちは")
for try await partial in stream {
    print(partial.content)
}

構造化データの生成(Structured Output)

@Generable マクロを使うことで、型安全な構造化データを生成できます:

@Generable
struct Recipe {
    @Guide(description: "レシピの名前")
    var name: String

    @Guide(description: "調理時間(分)")
    var cookingTimeMinutes: Int

    @Guide(description: "材料リスト")
    var ingredients: [Ingredient]
}

let response = try await session.respond(
    to: "チキンカレーのレシピを教えて",
    generating: Recipe.self
)
let recipe: Recipe = response.content

Tool Calling とは

Tool Calling は、LLMが外部のツール(関数)を呼び出す機能です。ユーザーの質問に応じて適切なツールを選択し、実行結果を元に回答を生成します。

Tool Calling のメリット

  1. リアルタイムデータへのアクセス: LLMの学習データにない最新情報を取得可能
  2. 外部システムとの連携: API呼び出しやデータベース操作が可能
  3. 複雑なタスクの分解: 複数のツールを組み合わせて処理可能
  4. ハルシネーション防止: 実データに基づいた回答が可能

Tool Calling を使わない場合のデメリット

Tool Calling を使わずに在庫管理をチャットボットで実装しようとすると:

  • 毎回すべての在庫データをプロンプトに含める必要がある
  • コンテキストウィンドウの制限に達しやすい
  • データが古くなると不整合が発生
  • 作成・更新・削除といった操作ができない

Tool Calling の実装

Tool プロトコルの実装

Foundation Models の Tool Calling は Tool プロトコルを実装して定義します。

import FoundationModels

struct GetInventoryListTool: Tool {
    // ツール名(LLMが識別に使用)
    let name = "getInventoryList"

    // ツールの説明(LLMがどのツールを使うか判断する材料)
    let description = "Get inventory list. Use updatedAfter/updatedBefore to filter by date."

    // 引数の定義(@Generableで構造化)
    @Generable
    struct Arguments {
        @Guide(description: "Filter by title (optional)")
        var title: String?

        @Guide(description: "Return items updated on or after this date. Format: YYYY-MM-DD")
        var updatedAfter: String?

        @Guide(description: "Return items updated on or before this date. Format: YYYY-MM-DD")
        var updatedBefore: String?
    }

    // 実際の処理
    func call(arguments: Arguments) async throws -> String {
        var params: [String: Any] = [:]
        if let title = arguments.title { params["title"] = title }
        if let after = arguments.updatedAfter { params["updated_after"] = after }
        if let before = arguments.updatedBefore { params["updated_before"] = before }

        // MCPサーバーを呼び出し
        return await MCPClient.shared.callTool("get-inventory-list", params: params)
    }
}

@Generable マクロとは

@GenerableLLMから構造化データ(Structured Output)を取得するためのマクロ です。

仕組み

  1. コンパイル時にJSONスキーマを自動生成
  2. LLMがそのスキーマに沿った出力を生成
  3. 出力を型安全なSwiftオブジェクトに自動パース
@Generable  // ← コンパイル時にスキーマ生成
struct Arguments {
    @Guide(description: "取得する在庫のID")
    var id: Int  // LLMは必ずIntを返す
}

Tool Calling での役割

Tool の Arguments@Generable を付けることで:

  • LLMが「どんな引数を渡すべきか」をスキーマから理解できる
  • 引数が正しい型で渡される(Int, String, Optional など)
  • ハルシネーション(構造的なミス)を防止

@Guide(description:) は各プロパティの意味をLLMに伝えるためのアノテーションです。説明が具体的であるほど、LLMは適切な値を生成できます。

複数ツールの定義例

在庫管理システムでは CRUD 操作それぞれにツールを定義します:

// 在庫詳細取得
struct GetInventoryTool: Tool {
    let name = "getInventory"
    let description = "指定したIDの在庫詳細を取得します。"

    @Generable
    struct Arguments {
        @Guide(description: "取得する在庫のID")
        var id: Int
    }

    func call(arguments: Arguments) async throws -> String {
        return await MCPClient.shared.callTool("get-inventory", params: ["id": arguments.id])
    }
}

// 在庫作成
struct CreateInventoryTool: Tool {
    let name = "createInventory"
    let description = "新しい在庫を作成します。品名は必須です。"

    @Generable
    struct Arguments {
        @Guide(description: "品名(必須)")
        var title: String

        @Guide(description: "数量(オプション)")
        var quantity: Int?

        @Guide(description: "単位(オプション)")
        var unit: String?

        @Guide(description: "カテゴリ(オプション)")
        var category: String?
    }

    func call(arguments: Arguments) async throws -> String {
        var params: [String: Any] = ["title": arguments.title]
        if let quantity = arguments.quantity { params["quantity"] = quantity }
        if let unit = arguments.unit { params["unit"] = unit }
        if let category = arguments.category { params["category"] = category }
        return await MCPClient.shared.callTool("create-inventory", params: params)
    }
}

// 在庫更新
struct UpdateInventoryTool: Tool {
    let name = "updateInventory"
    let description = "既存の在庫情報を更新します。IDは必須です。"

    @Generable
    struct Arguments {
        @Guide(description: "更新する在庫のID(必須)")
        var id: Int

        @Guide(description: "新しい品名(オプション)")
        var title: String?

        @Guide(description: "新しい数量(オプション)")
        var quantity: Int?
    }

    func call(arguments: Arguments) async throws -> String {
        var params: [String: Any] = ["id": arguments.id]
        if let title = arguments.title { params["title"] = title }
        if let quantity = arguments.quantity { params["quantity"] = quantity }
        return await MCPClient.shared.callTool("update-inventory", params: params)
    }
}

// 在庫削除
struct DeleteInventoryTool: Tool {
    let name = "deleteInventory"
    let description = "指定したIDの在庫を削除します。"

    @Generable
    struct Arguments {
        @Guide(description: "削除する在庫のID")
        var id: Int
    }

    func call(arguments: Arguments) async throws -> String {
        return await MCPClient.shared.callTool("delete-inventory", params: ["id": arguments.id])
    }
}

実装のポイント

1. @Guide で引数の意味を明示する

@Generable
struct Arguments {
    @Guide(description: "取得する在庫のID")
    var id: Int
}

@Guide(description:) は LLM がツールを呼び出す際の引数の意味を理解するために重要です。説明が不十分だと、期待通りの値が渡されない可能性があります。

2. エラーを文字列で返す

Tool の call メソッドでエラーを throw すると、ユーザーへの表示が難しくなります。エラー情報も文字列として返すことで、LLM がエラー内容をユーザーに伝えることができます:

func call(arguments: Arguments) async throws -> String {
    do {
        let result = try await fetchData()
        return result
    } catch {
        // throw せずに文字列で返す
        return "エラー: \(error.localizedDescription)"
    }
}

3. セッションにツールを登録

let session = LanguageModelSession(
    tools: [
        GetInventoryListTool(),
        GetInventoryTool(),
        CreateInventoryTool(),
        UpdateInventoryTool(),
        DeleteInventoryTool()
    ]
) {
    """
    ZAICO inventory assistant. Always respond in Japanese.

    TOOLS:
    - getInventoryList: Get inventory (supports date filter)
    - getInventory: Get detail by ID
    - createInventory: Create new inventory
    - updateInventory: Update by ID
    - deleteInventory: Delete by ID
    """
}

MCP サーバーとの連携

MCPサーバーについては弊社エンジニアが書いた記事を参考に実装
https://zenn.dev/zaico/articles/12b88e3aca88fc

MCP (Model Context Protocol) とは

MCP は、LLM と外部データソースを接続するための標準プロトコルです。今回は在庫管理API を MCP サーバーでラップし、iOS から HTTP 経由で呼び出す構成を取りました。

┌──────────────────┐     HTTP      ┌──────────────────┐     REST      ┌──────────────────┐
│   iOS App        │ ───────────▶  │   MCP Server     │ ───────────▶  │    在庫管理API     │
│  (Foundation     │               │   (Express)      │               │                  │
│   Models)        │ ◀───────────  │                  │ ◀───────────  │                  │
└──────────────────┘               └──────────────────┘               └──────────────────┘

なぜ HTTP サーバーが必要?

通常、MCP サーバーは stdio(標準入出力)で通信しますが、iOS アプリからは直接利用できません。そのため、Express で HTTP エンドポイントを作成し、iOS から REST API として呼び出せるようにしています。

MCP HTTP サーバーの実装(TypeScript)

import express from "express";
import axios from "axios";

const app = express();
app.use(express.json());

// 在庫管理 API クライアント
class InventoryApiClient {
  private client: AxiosInstance;

  constructor(baseUrl: string, apiToken: string) {
    this.client = axios.create({
      baseURL: baseUrl,
      headers: {
        "Authorization": `Bearer ${apiToken}`,
        "Content-Type": "application/json",
      },
    });
  }

  async getInventories(params?: { title?: string; updated_after?: string }) {
    const response = await this.client.get("/api/v1/inventories", { params });
    return response.data;
  }

  async createInventory(data: { title: string; quantity?: number }) {
    const response = await this.client.post("/api/v1/inventories", data);
    return response.data;
  }
}

// ツール呼び出しエンドポイント
app.post("/mcp/tools/:toolName", async (req, res) => {
  const { toolName } = req.params;
  const params = req.body;

  try {
    let result;

    switch (toolName) {
      case "get-inventory-list":
        const { updated_after, updated_before, ...apiParams } = params;
        let inventories = await apiClient.getInventories(apiParams);

        // 日付フィルタリング(サーバー側で実行)
        if (updated_after || updated_before) {
          inventories = inventories.filter((inv: any) => {
            const updatedDate = inv.updated_at.split("T")[0];
            if (updated_after && updatedDate < updated_after) return false;
            if (updated_before && updatedDate > updated_before) return false;
            return true;
          });
        }

        result = {
          content: [{ type: "text", text: formatInventoryList(inventories) }]
        };
        break;

      case "create-inventory":
        const created = await apiClient.createInventory(params);
        result = {
          content: [{
            type: "text",
            text: `在庫を作成しました。ID: ${created.data_id}`
          }]
        };
        break;

      // ... 他のツール
    }

    res.json(result);
  } catch (error) {
    res.status(200).json({
      content: [{ type: "text", text: `エラー: ${error.message}` }],
      isError: true
    });
  }
});

app.listen(3001);

iOS 側の MCP クライアント

class MCPClient {
    static let shared = MCPClient()

    let mcpBaseURL = URL(string: "http://localhost:3001")!
    let apiToken = "your_api_token"

    func callTool(_ toolName: String, params: [String: Any] = [:]) async -> String {
        do {
            let url = mcpBaseURL.appendingPathComponent("mcp/tools/\(toolName)")
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization")
            request.httpBody = try JSONSerialization.data(withJSONObject: params)

            let (data, response) = try await URLSession.shared.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse else {
                return "エラー: サーバーに接続できませんでした"
            }

            struct MCPResponse: Codable {
                struct Content: Codable { let type: String; let text: String }
                let content: [Content]
                let isError: Bool?
            }

            let result = try JSONDecoder().decode(MCPResponse.self, from: data)
            return result.content.first?.text ?? "結果がありません"
        } catch {
            return "エラー: \(error.localizedDescription)"
        }
    }
}

実装で工夫したポイント

1. 日付計算のシステムプロンプトへの埋め込み

LLM は現在日付を知らないため、「昨日の在庫」「先週更新されたもの」といった相対的な日付指定に対応できません。セッション作成時に今日の日付と日付マッピングを埋め込むことで解決しました:

private func setupSession() {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd"
    let calendar = Calendar.current
    let now = Date()

    let today = formatter.string(from: now)
    let yesterday = formatter.string(from: calendar.date(byAdding: .day, value: -1, to: now)!)

    // 先週(月曜〜日曜)の計算
    let weekday = calendar.component(.weekday, from: now)
    let daysFromMonday = (weekday == 1) ? 6 : weekday - 2
    let thisMonday = calendar.date(byAdding: .day, value: -daysFromMonday, to: now)!
    let lastWeekMonday = calendar.date(byAdding: .day, value: -7, to: thisMonday)!
    let lastWeekSunday = calendar.date(byAdding: .day, value: 6, to: lastWeekMonday)!
    let lastWeekStart = formatter.string(from: lastWeekMonday)
    let lastWeekEnd = formatter.string(from: lastWeekSunday)

    // 今月
    let thisMonthStart = formatter.string(from: calendar.date(from: calendar.dateComponents([.year, .month], from: now))!)

    session = LanguageModelSession(tools: [...]) {
        """
        ZAICO inventory assistant. Always respond in Japanese.

        TODAY: \(today)
        YESTERDAY: \(yesterday)

        DATE MAPPING (use these for date queries):
        - "昨日" = updatedAfter: \(yesterday), updatedBefore: \(yesterday)
        - "今日" = updatedAfter: \(today), updatedBefore: \(today)
        - "先週" = updatedAfter: \(lastWeekStart), updatedBefore: \(lastWeekEnd)
        - "今月" = updatedAfter: \(thisMonthStart), updatedBefore: \(today)
        """
    }
}

これにより「昨日更新された在庫を見せて」といった自然な指示に対応できます。

2. レスポンスフォーマットの制御

LLM のレスポンス形式はシステムプロンプトで指示できます:

"""
RESPONSE FORMAT:
- Display each inventory item as: "ID: [id] - [title] (更新: [date])"
- One item per line
- Do not use JSON format
"""

3. タップ可能な在庫リスト

レスポンスをパースしてタップ可能なUIに変換しています:

struct ZaicoMessageBubble: View {
    let message: ZaicoMessage
    var onInventoryTap: ((Int, String) -> Void)?

    var body: some View {
        if let items = parseInventoryList(message.content) {
            VStack(alignment: .leading, spacing: 4) {
                ForEach(items, id: \.id) { item in
                    Button {
                        onInventoryTap?(item.id, item.name)
                    } label: {
                        HStack {
                            Text("[\(item.index)]")
                                .foregroundColor(.secondary)
                            Text("ID: \(item.id)")
                                .foregroundColor(.blue)
                            Text("- \(item.name)")
                            Spacer()
                            Image(systemName: "chevron.right")
                                .font(.caption)
                                .foregroundColor(.secondary)
                        }
                    }
                    .buttonStyle(.plain)
                }
            }
            .padding(12)
            .background(Color.secondary.opacity(0.2))
            .cornerRadius(16)
        } else {
            Text(message.content)
                .padding(12)
                .background(Color.secondary.opacity(0.2))
                .cornerRadius(16)
        }
    }

    private func parseInventoryList(_ content: String) -> [InventoryItem]? {
        let pattern = #"(?:\[(\d+)\]|(\d+)\.)\s*ID:\s*(\d+)\s*-\s*(.+)"#
        guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }

        let lines = content.components(separatedBy: "\n")
        var items: [InventoryItem] = []

        for line in lines {
            let range = NSRange(line.startIndex..., in: line)
            if let match = regex.firstMatch(in: line, range: range) {
                // パース処理...
            }
        }

        return items.isEmpty ? nil : items
    }
}

タップすると詳細取得のリクエストが自動で送信されます:

ZaicoMessageBubble(message: message) { id, name in
    inputText = "ID \(id) の「\(name)」の詳細を表示して"
    Task { await sendMessage() }
}

注意点と制限事項

Context Window の制限(4,096トークン)

Foundation Models の Context Window は 4,096トークン に固定されています。これは入力と出力の合計であり、GPT-4(128K)やClaude(200K)と比べるとかなり小さいです。

言語 トークン目安
英語 3-4文字 = 1トークン
日本語 1文字 = 1トークン

日本語は英語の約3-4倍のトークンを消費します。そのため、システムプロンプトは英語で記述することをお勧めします:

// ❌ 日本語だとトークンを多く消費
"""
ZAICOの在庫管理アシスタントです。常に日本語で回答してください。
"""

// ✅ 英語でシステムプロンプトを書き、回答だけ日本語に
"""
ZAICO inventory assistant. Always respond in Japanese.
"""

上限に達すると GenerationError が発生します。長い会話や大量のデータを扱う場合は、セッションリセット機能を実装しておきましょう。

まとめ

iOS 26 の Foundation Models フレームワークと Tool Calling を使うことで:

  • オンデバイスで LLM を利用した対話型アプリが作れる
  • 外部システム連携 が自然言語インターフェースで実現できる
  • プライバシーを保ちながら 高度な機能を提供できる

特に Tool Calling は、単なるチャットボット以上の価値を提供します。ユーザーは「昨日更新された在庫を見せて」と話しかけるだけで、システムが適切な API を呼び出し、結果を分かりやすく表示してくれます。

触ってみた感想

現状だとContext Windowの制限がきつくやり取りするデータ次第ではすぐに上限に達してしまう印象が強かったです。

参考リンク

ZAICO Developers Blog

Discussion