Engineering

Apple Foundation Models で意図分解を4層に積み直したら 3B モデルのクセが見えた

  • Apple Foundation Models
  • Swift
  • macOS
  • On-device LLM
  • Guided Generation
  • Vigilare

ラビー合同会社の Vigilare macOS 版に、自然言語アシスタント機能の試作を入れています。リリース前の社内ブランチでの実験で、たとえば「期限切れのタスクを明日に」と入力すると reschedule 意図と日付を抽出し、Swift 側で対象を絞って一括更新する、というものです。本記事は macOS 26 から使える Apple Foundation Models(以降 Apple FM)の Guided Generation を 4 層に再帰分解した試作の記録で、3B オンデバイスモデルの hallucination を Swift の post-validation で構造的に潰すところまで踏み込みます。

時系列だけ先に整理させてください。Vigilare 側で Apple FM を触り始めたときの知見を、現職である株式会社スタディストの Tech Blog で一度別記事として整理しました。

ZennApple Foundation Models よもやま話

本記事はそこから半歩進めて、ラビー側でもう一段深掘りした試作の話です。VigilareKit の構造そのものについては別途記事があるので、4 層パイプラインがどのレイヤーに乗ったかは合わせて読むと位置関係が分かります。

ブログVigilare の macOS と iOS で共有するコアを VigilareKit に切り出したVigilare は macOS と iOS の両方を同じ Apple Reminders 連携と同じタスク管理ロジックで動かしています。共通する部分を VigilareKit という Swift Package に切り出して、Models / Stores / Services / UseCases / ViewModels / DesignSystem を共有し、Views だけプラットフォーム別に持つ構造にしました。何を共有して何を別物にしたか、判断の軸を扱います。

スタディスト側で書いた「LLM は短い intent 分類だけに使い、実処理は Swift で決定論的にやる」3 層パイプラインを Vigilare に持ち込むと、シンプルなケース(「期限切れを明日に」「今日のタスクある?」など)は素直に動きました。ところがもう少し凝った発話、たとえば複合条件・複数意図・破壊的な操作を投げると、3B オンデバイスモデルの限界が顔を出します。

「期限切れを明日にして。あと プロジェクトA の今日期限のタスクって何かあるっけ?」

これを 1 pass で詰めさせる @Generable 構造体は次の形です。

@Generable
public struct AssistantIntent {
  @Guide(description: "Use .search when the user wants to SEE or COUNT reminders. Use .reschedule ONLY when the user clearly asks to CHANGE a due date. Use .unknown when ambiguous.")
  public var verb: AssistantVerb            // .search / .reschedule / .unknown

  @Guide(description: "Date phrase used to NARROW which reminders. Empty if not mentioned.")
  public var dateScope: String              // 例「今日」「期限切れ」

  @Guide(description: "Topic / project keywords. App strips suffixes like 系.")
  public var keywords: [String]             // 例 ["プロジェクトA"]

  @Guide(description: "ONLY for .reschedule: target NEW due date phrase. Empty otherwise.")
  public var newDueDatePhrase: String       // 例「明日」「来週」
}

これに上の発話を渡すと、本来 search 意図のはずの後半まで reschedule に分類されてしまいます。Swift 側でその直後に検索クエリを組み立てる前段 step なので最終的な誤実行までは進みませんでしたが、後半の「何かあるっけ?」が動詞分類の段階で破壊的な動詞に塗り潰されているのが観察できました。

本記事は、この挙動を観察するためだけのスタンドアロンな試作パッケージを作り、Guided Generation を意図 → 動詞/条件 → 詳細 と 4 層に再帰的に積み直したらどう変わるかを side-by-side で見ていきます。途中で 3B モデルの頑固な hallucination にぶつかり、最終的に Swift 側の post-validation で構造的に潰すところまで扱います。

3層では詰まる発話パターン

スタディスト側の記事では ActionPlan の配列で複数意図を扱うパターンを出しました。これは「複数アクションを順に並べる」までは綺麗に動きます。ただ、もう一歩進んで以下のような構造化が要る発話だと、1 pass で全部詰めるのが厳しくなってきます。

  • 並列の意図 — 「A を変更、B も見たい」のように動詞も対象も別
  • チェーン — 「期限切れを明日に、その中の 会議資料 はさらに来週に」のように後段が前段の結果を参照
  • 複合条件 — 「今日期限の 会議資料 か プロジェクトA」のように AND と OR が混在
  • 動詞による必要情報の差 — create はタイトル + 期限だけで充分、reschedule は対象選定 + 新期限が必要

これらを 1 つの @Generable 構造体で表現しようとすると、Optional フィールドが増え、ネストが深くなり、3B のオンデバイスモデルが後半フィールドで集中力を切らすのが見えてきます。長文の QA/retrieval で位置依存の性能低下を示した Lost in the Middle (Liu et al., TACL 2024) と同じ感触が、長い JSON を順に埋めていく構造化出力でも観察されます。前の方の決定に引っ張られた誤りが、後半に伝播していくイメージです(論文自体は構造化出力を直接扱ったものではなく、ここは類推です)。

理屈の側は Decomposed Prompting (Khot et al., ICLR 2023)Plan-and-Solve (Wang et al., ACL 2023) など、分割すれば精度が上がる系列の論文がすでに揃っています。スタディスト記事で書いた応用編は、これを Tool Calling 文脈で 3 層化したものとして読み直せます。

4層に積み直す

Guided Generation を再帰的に積み直します。

[Layer 0] Multi-Intent Splitter
   1 文 → 単一意図 chunk の配列

[Layer 1] Action / Filter Decomposer    (chunk ごと)
   1 chunk → 動詞部分 span + 条件部分 span に切る

[Layer 2A] Action Extractor              (span が空でなければ)
   動詞 span → verb + mutation 詳細

[Layer 2F] Filter Extractor              (span が空でなければ)
   条件 span → dateHints + keywordHints + statusHints

各層が見るのは前段の浅い出力だけです。Layer 2 まで降りた時点では、LLM への入力は元の発話の数文字から十数文字の断片になっていて、@Generable 出力スキーマもフィールド数が一桁です。1 pass で巨大スキーマを埋めさせる構造に比べ、各 LLM 呼び出しの認知負荷を大幅に下げられます。

代わりに LLM 呼び出し回数は増えます。1 文に 2 chunk あって、両方 reschedule + filter ならコール数は 1 (Layer 0) + 2 (Layer 1) + 2 (Layer 2A) + 2 (Layer 2F) = 7。試作の実機で prewarm() 後の体感が 約 5 秒 でした。各層を chunk 単位で並列化すれば更に縮められますが、現状は順次でも実用域です。

各層の LanguageModelSession は使い捨てで、層ごとに instructions を絞っています。1 つの transcript に履歴を積むと、本来避けたい recency bias がそのまま戻ってきてしまうので、層境界で context をリセットする設計にしました。

観察用パッケージとして切り出す

挙動を確認するために、Vigilare 本体とは別のスタンドアロンな Swift Package を手元で切り出しました。swift run だけで起動できる SwiftUI アプリで、左にフラット 1-pass、右に 4 層階層を並べて、同じ入力をどう分解するか side-by-side で見られます。

flowchart LR
  Input["User Input<br/>「期限切れを明日にして。<br/>あと プロジェクトA の今日期限の<br/>タスクって何かあるっけ?」"]
  subgraph Flat["Flat (1-pass)"]
    F1["FlatIntent<br/>verb: reschedule<br/>dateScope:「期限切れ」<br/>keywords: [プロジェクトA]<br/>newDueDatePhrase:「明日」"]
    F2["後半の search 意図が<br/>reschedule に分類される"]
    F1 --> F2
  end
  subgraph Hier["Hierarchical (4-layer)"]
    H0["Layer 0<br/>2 chunks に分割"]
    H1A["Layer 1 [0] 動詞 + 条件"]
    H2A["Layer 2A [0] reschedule / 明日<br/>Layer 2F [0] dateHints = [期限切れ]"]
    H1B["Layer 1 [1] 動詞 + 条件"]
    H2B["Layer 2A [1] search<br/>Layer 2F [1] 今日 / プロジェクトA"]
    H0 --> H1A --> H2A
    H0 --> H1B --> H2B
  end
  Input --> Flat
  Input --> Hier

UI に毎ステップの中間 JSON と経過 ms を並べると、LLM がどこで何を間違えるかが触って確認できる解像度で見えてきます。本体に組み込む前に、別パッケージとして実験できる単位に切り出しておくのは、後から論点を絞り込むときに効きました。

走らせて見えた頑固な bias

実機で動かして即座に出た問題が 2 つありました。

1 つめは Layer 0 と Layer 1 の精度です。発話を 2 chunk に分けるべきところを 1 chunk のまま返したり、Action/Filter の span 抽出で filterSpan を空にしたり。これらは instructions に few-shot 例を増やし、「区切り signal は「。」「あと」「それから」」「ほぼ全ての発話に filterSpan がある」と具体規範を入れることで安定しました。

2 つめが本題です。Layer 2F の statusHints に、入力に存在しない言葉が混入する現象です。

たとえば「期限切れの 会議資料」というフィルター span を Layer 2F に投げると、Apple FM 3B はなぜか statusHints: ["完了"] を返してきます。「完了」という文字列は入力のどこにも無いのに。

これは「ステータスっぽい言葉」を分類カテゴリの引力で埋めにいく hallucination です。instructions に「入力にない hint を出すな」を CRITICAL で書いても直りません。

更に踏み込んで、statusHints[String] ではなく [StatusKind] の closed enum 配列に絞れば、入力に無い値は構造的に弾けるはず、と組み直したところ、逆に毎回 .completed を 1 つ必ず入れてくる挙動に変わりました。closed な選択肢を強制されると「何か 1 つ選ばないと不安」という別の bias が顔を出します。format 制約が推論性能を下げる現象は Let Me Speak Freely? (Tam et al., EMNLP 2024) が直接実測しており、JSONSchemaBench (Geng et al., 2025) も JSON Schema の構造的制約下での coverage 評価としてこの方向を補強しています。

Swift 側で span に潰す

ここでスタディスト記事の原則「LLM は提案、Swift が判断」に立ち戻ります。

instructions のチューニングで bias を消そうとするのではなく、LLM 出力を Swift 側で post-validation する。これは元々パイプラインの第 2 の柱として置いていた Swift 決定論層を、もう一段徹底するだけです。

static func gateFilters(
  _ raw: ExtractedFilters,
  span: String
) -> ExtractedFilters {
  ExtractedFilters(
    dateHints: raw.dateHints.filter { !$0.isEmpty && span.contains($0) },
    keywordHints: raw.keywordHints.filter { !$0.isEmpty && span.contains($0) },
    statusHints: raw.statusHints.filter { span.contains($0.rawValue) }
  )
}

StatusKindrawValue を「完了」「未完了」「ドロップ」「待機」という日本語 trigger 文字列そのものに揃えておくと、この span.contains($0.rawValue) が「入力に literal で含まれているか」のチェックに直結します。LLM が .completed を入れてきても、span に「完了」が無ければ Swift が drop します。

これで Layer 2F の挙動が安定し、試作パッケージのテストが全部 pass に揃いました。

同じ「LLM 出力を Swift 側で検証する」発想は、本体側の IntentTranslator でも踏んでいます。LLM が返してくる newDueDatePhrase(「明日」「来週」「明後日」)を Swift の day offset に翻訳するときに、String.contains ベースで揃えているため、順序を間違えると「明後日」が「明日」に潰れます

public static func dayOffset(from phrase: String) -> Int? {
  let normalized = phrase.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
  guard !normalized.isEmpty else { return nil }

  // ORDER MATTERS: longer / more specific phrases first
  if contains(normalized, anyOf: ["day after tomorrow", "明後日", "あさって"]) { return 2 }
  if contains(normalized, anyOf: ["yesterday", "昨日"]) { return -1 }
  if contains(normalized, anyOf: ["tomorrow", "明日"]) { return 1 }
  if contains(normalized, anyOf: ["today", "今日", "本日"]) { return 0 }
  if contains(normalized, anyOf: ["next week", "来週"]) { return 7 }
  // ...

  // 既知の語に当たらなければ nil を返し、呼び出し側で .noOp に降ろす
  return nil
}

ここで nil を返したら、ActionExecutor 側は「いい感じのデフォルト」を埋めずに .noOp(.rescheduleWithoutTargetDate) に降ろします。LLM が .reschedule を選んだのに新期限が読み取れない、というケースで Swift が雰囲気で「今日」や「明日」を当て込まないようにする決定論的なフォールバックです。

設計の柱が 1 本増えた形で並べ直すと、こうなります。

役割
1. 階層分割LLM 出力スキーマを浅く保つ。recency bias と locality bias を回避
2. Swift 決定論層LLM 出力を hint として受け、決定論的に変換・実行
3. Swift post-validationLLM の hallucinated 出力を span 単位で literal substring check し、棄却

3B モデルの bias は instructions では消せない場面があり、3 本目が決定打になりました。スタディスト記事で「実処理は Swift」と書いた部分が、もう一段「LLM の出力すら Swift で検証して打ち消す」まで踏み込む形に育ったことになります。

なぜ Verifier に LLM を使わなかったか

サーバー前提のオーケストレーション系、たとえば TRINITY (Xu et al., ICLR 2026) では、複数 LLM に Thinker / Worker / Verifier のロールを動的に振る形で検証を組み込みます。発想は近いものの、on-device 3B では Verifier に LLM を充てると次のようなコストになります。

LLM コール数 (デフォルト文言, 2 chunk)prewarm 後の体感
LLM Verifier を chunk ごとに後段で挟む7 → 145 秒 → 10 秒以上
Swift gate(採用)7 のまま5 秒維持

on-device では LLM コール 1 回がレイテンシ予算を直接食うので、Verifier に LLM を充てた瞬間に on-device で動かす意義の半分が失われます。意味検証は階層分割(Layer 0/1/2 の構造)に閉じ込めて、Verifier は構文と literal 一致だけを μs オーダーで見る、という分業に倒したのが今回の選択でした。

Faithfulness 系(FActScore (Min et al., EMNLP 2023)AlignScore (Zha et al., ACL 2023))や LLM ベースの NER 評価(NER4all (Hiltmann, Dröge, Dresselhaus, 2025)llmNER (Villena et al., 2024))は隣接領域として観点を提供しています。NER 系の論文自体は entity 抽出の精度比較が主題ですが、「LLM が生成した entity が input の span に literal 一致するか」を後処理で sanity check する発想は同じ系統です。on-device latency 制約の側からは SLM Meets LLM (Hu et al., 2024) が verifier 軽量化を扱っており、こちらは verifier がまだ SLM ニューラル分類器に留まる立場です。コード生成領域の Deterministic AST hallucination detection (2026) は LLM 不要・後処理のみで high precision を主張する系列で、構造として近いです。

副産物として見えた verb 振り分け

検証中、もう一つ気付いたことがあります。動詞によって必要な分解深さが違うんです。

  • create — タイトル + 期限を抽出するだけで充分。filter 不要、対象も「全タスク」で良い
  • search / reschedule — 対象を絞る必要がある。filter span が必須
  • delete — 対象を絞ったうえで safety guard を強化する必要がある

つまり Layer 0 で verb を確定した瞬間に、以降の sub-pipeline 深さも、適切な backend LLM も verb 依存で決められます。create は浅い 1 コールで終わるなら平均レイテンシを下げられますし、delete のような取り返しがつかない動詞は重い backend (Apple PCC や Claude) に振って precision を稼げます。

ちょうど WWDC26 で公開された LanguageModel プロトコル (Session 339)LanguageModelSession(model: anyLanguageModel) で backend を透過的に差し替えられる設計になっており、verb-conditional な backend routing を API レベルでそのまま書けます。Anthropic 公式 Swift package ClaudeForFoundationModels も beta で公開されていて、Claude を Foundation Models 経由で呼ぶ素地は揃っています(プロダクション用の App Attest 認証モードは “coming soon” 扱い)。

産業実装としては vLLM Semantic Router や LangGraph routing pattern が intent ベース routing をすでに実用化していて、論文側でも Toward Super Agent System (Yao et al., 2025) が近隣にあります。OS 提供の 3B SLM を on-device 側に置く hybrid を Vigilare の本格化フェーズで実装したい方向です。

次に試したいこと

今回の試作は観察用 Playground と本体ブランチでの実機検証までで、リリース版にはまだ載せていません。本体に組み込むときに踏みたいステップを残しておきます。

  • chunk 単位の並列化で 5 秒の体感を縮める。Layer 1 と Layer 2 は chunk が独立しているので TaskGroup でそのまま並べられる
  • preview → confirm の安全弁を破壊的な動詞(reschedule の大量適用、delete)側で太くする。現状は Swift 側で対象件数を出して人間が確認する形だが、件数の閾値で動詞ごとに confirm の強さを変えたい
  • verb-conditional な backend routing を LanguageModel プロトコル経由で試す。create は on-device 3B のまま、delete は Apple PCC や Claude に振り、gateFilters 相当の post-validation はどの backend でも共通で挟む
  • StatusKind の rawValue を増やすときに、Swift 側 trigger 辞書と Layer 2F の instructions / few-shot を 1 箇所で管理できるよう、定義の単一ソース化を整える

3B オンデバイスモデルは万能ではありませんが、各 LLM 呼び出しを浅いスキーマに絞り、出力を Swift 側の決定論層が hint として受け、LLM の hallucination は Swift 側の post-validation で span 単位に潰す、という 3 本柱で囲めば、preview → confirm という安全弁と組み合わせて十分実用に届く、というのが今回の感触でした。本体組み込みでぶつかった話は続編に回します。