0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

生成したUIの7割が空っぽ…犯人はプロンプトじゃなくJSONスキーマだった — ローカルLLMでGenerative UI × RAG

0
Last updated at Posted at 2026-07-07

さいしょに(TL;DR)

社内の手順書を答えてくれるRAGを作ってたんですが、回答がぜんぶ「ベタっとした文章」で返ってくるのがどうにも気に入らなくて。手順なのに番号も付かない、チェック項目なのにクリックもできない。もったいないなと。

そこで、回答を文章じゃなくて「UIの部品(ブロック)のJSON」でLLMに作らせて、フロント側で手順だったり、チェックリストだったり、表だったり、対話的な切り分けだったりに描き分けるようにしました。いわゆる Generative UI(GenUI)ってやつです。

  • 埋め込みも生成もぜんぶ手元の ollama で完結させてます。社内の手順書をクラウドに送らない「完全ブラインド」がそもそもの縛り。
  • 途中で、qwen3 が作ったブロックの71%が中身カラっぽで捨てられるという、なかなかの決定力不足に悩まされました。ボックス内までは運ぶのにシュートが枠に行かない、みたいな。
  • で、犯人はプロンプトじゃなくてJSONスキーマでした。スキーマを直したら 破棄率 71% → 0%。後述します。
  • 最後に「GenUI と、ただのMarkdown、結局なにが違うの?」に答えるために、左右に並べて同時に描画するビューも作りました。

順番に書いていきます。


1. なんでわざわざ Generative UI なのか

チャットボットの回答って、だいたい「Markdownの文章」で返ってきますよね。手順も、表も、トラブルシューティングの枝分かれも、ぜんぶ文字の列に潰れる。

でも、回答の中身によって見せ方って本当は違うはずなんです。

  • 「VPNの接続手順は?」→ 番号がついた手順がいい
  • 「月次バックアップの確認項目は?」→ ポチポチ消せるチェックリストがいい
  • 「パスワードのルールは?」→ 要件と「これはダメ」という警告
  • 「印刷できない」→ 症状で枝分かれする、対話っぽい切り分け

この「どの見せ方にするか」をLLM自身に選ばせちゃおう、というのが Generative UI です。

genui-rag のトップ画面:検索窓とサンプル質問、左に GenUI・右に Markdown を並べる対比ビュー

ただ、LLMに自由なHTMLを吐かせるのはナシ

GenUI で一番やっちゃいけないのが、LLMに好き放題HTMLやJSXを書かせること。XSSの穴になるし、描画も安定しません。攻撃的なのはいいけど無秩序なのはダメ、みたいな話です。

なので、こっちで先に「使っていいブロックの種類」を有限に決めておいて、その中から選ばせる。フロントは type を見て、登録済みのコンポーネント(Pattern Registry と呼んでます)に割り当てて描画するだけ。中身に応じて出し分けられる柔軟さと、絶対に壊れない安全さ、その両取りがしたかった。要は規律あるフォーメーションです。


2. 全体の流れ

質問
  → Ruri で埋め込み(ローカル)
  → ベクトル検索(index.npz / コサイン類似度 top-k)
  → qwen3 が「どのUIブロックで見せるか」を考えて JSON を生成
      (JSON Schema で構造をガチガチに縛る)
  → フロントが type ごとにコンポーネントへ割り当てて描画

スタックはわざと地味にしてます。

役割 使ったもの
埋め込み kun432/cl-nagoya-ruri-base(日本語向けの Ruri / 768次元)
生成 qwen3:8b(ollama、JSONスキーマ強制)
ベクトル検索 numpy だけ(vectors @ qv の内積。正規化してあるのでこれがコサイン類似度になる)
サーバ FastAPI と、素のHTML/JS(フレームワークなし)

ベクトルDBすら使ってません。index.npz(numpy の savez)に固めただけ。プロトタイプなのでこれで普通に速いです。


3. UIブロックの設計(9種類)

フロントで描けるブロックは9つ用意しました。

type 用途 GenUIならではか
markdown 説明文
steps 順番のある手順
checklist 確認項目(クリックで完了トグル)
table 比較・対照
callout 注意・禁止・補足(info/warning/tip)
citation 根拠の出典
decision 対話的な切り分け(決定木)
action 押せるボタン(コピー/URL/通知)
form 足りない情報を聞き返すフォーム

decision(決定木)は、A2UI 方式の「フラットなノードの配列 + ID参照」で持たせてます。ネストを深くするとLLMが安定して出してくれないので、木をあえて平らに潰してあります。深いパスを通そうとするとミスが出る、シンプルに横に展開、みたいな。

{
  "type": "decision",
  "title": "印刷できない時の切り分け",
  "start": "n1",
  "nodes": [
    {
      "id": "n1",
      "question": "印刷ジョブは表示されますか?",
      "options": [
        { "label": "表示される", "next": "n2" },
        { "label": "表示されない", "next": "n3" }
      ]
    },
    {
      "id": "n3",
      "answer": "印刷キューを開き滞留ジョブを削除して再送信してください"
    }
  ]
}

4. 寄り道その1:生成モデルどれにする?(qwen3:8b vs gemma4:12b vs qwen3:14b)

GenUI の生成役に何を使うか。同じ5つの質問(プリンター障害 / VPN / パスワード / バックアップ / PC初期設定)を各モデルに通して測りました。W杯前のテストマッチで、3人の候補を同じ相手にぶつけてみる、みたいなノリです。

先に断っておくと、計測した端末は MacBook Pro(Apple M3・メモリ24GB) です。無印M3とはいえメモリ24GBなので、開発機としてはそこそこ恵まれている方だと思います。それでもこの結果です。逆に M3 Ultra や M4 Max にメモリ128GB みたいなバケモンスペックをお持ちなら話は別で、14b どころかもっと大きいモデルでも余裕で走ると思います。以下のレイテンシは「このくらいの端末だとこうだった」という数字として読んでください。

モデル JSON成功 期待した型に当たったか 平均レイテンシ
qwen3:8b 5/5 4/5 43秒
gemma4:12b 3/5 3/5 121秒
qwen3:14b 5/5 5/5 77秒

まず gemma4:12b は早々に代表落ち。約2.8倍遅いうえに、5問中2問でJSONが壊れて、しかも158秒/214秒もかけた末に空応答。ボールは持てるんだけど持ちすぎて、最後はロストして攻撃が終わる、あのタイプ。出力はリッチで魅力はあるんですが、インタラクティブなUIを作る用途では「速くて確実にパースできる」が最優先なので、ベンチにも入れませんでした。

採用は qwen3:8b。速くてJSONが安定してます。品質だけ見れば qwen3:14b が満点(5/5の5/5)なんですが、こっちは技術はあるけど少し鈍足のベテランで、77秒かかる。トーナメントの過密日程を最後まで走りきれるのは 8b のほうだろうと、即戦力のスピードスターを先発に選びました。8b 続投です。

ちなみに、ここで学んだのは「うまさより、速さ × JSONが壊れないこと」。テクニックはあるけど遅くてミスするタイプは、GenUI という連戦では使いづらかった、という話です。


5. 寄り道その2:空ブロック71%問題

qwen3:8b に決めたはいいんですが、けっこう重たい問題が残ってました。

作ったブロックの71%が「中身カラ」で捨てられる。

5クエリで、生成21ブロックに対して採用がたった6。とくにパスワードの質問はひどくて、9個作って採用1個。チャンスの数は作るのに、ことごとく枠を外す。グループリーグでシュート数だけ相手を上回って0-0、みたいなやつです。ボール保持率71%、決定機ゼロ。

で、これが実際どう困ったかというと、地味にぜんぶ痛いんですよね。

  • RAGなのに根拠(citation)が全消し。出典ブロックがことごとくカラ判定で捨てられて、回答に「どの手順書から持ってきたか」が一切出ない。社内手順書RAGで根拠を出せないのは、VARもゴールラインテクノロジーもなしで「今のはゴールです」と言い張るようなもので、誰も信用してくれない。
  • 肝心の「これはダメ」が消える。パスワード質問で、手順(steps)は残るのに禁止事項の警告(callout)が捨てられる。「こう設定してね」だけ出て「使い回し厳禁」が消える。手順書としてむしろ危ない。終了間際にDFが気を抜いて失点する、あの感じ。
  • 遅いのに薄い、という二重の損。21ブロックぶん時間をかけて生成してるのに、画面に残るのは6個。90分フルに走ったのに枠内シュート1本、みたいな徒労感がある。
  • 売りが一番先に壊れる。「内容に応じて出し分ける」のがGenUIのウリなのに、よりにもよって出し分けた特殊ブロックほどカラで落ちる。鎌田の頭を1ミリかすめただけでもゴールはちゃんと記録に残る大会の裏で、こっちは看板のブロックが中身ごとまるっと消えてノーゴール扱い。1ミリどころか丸ごと無し、です。

最初に疑ったやつ(ハズレ)

最初はプロンプトを疑いました。実際こう書いてあったので。

重要: callout と citation と markdown は必ず text に本文を入れること。
空のブロックは作らないこと。

書いてあるのに守ってくれない。プロンプトでいくら念押ししても効かない。監督がベンチからいくら叫んでも伝わらないやつです。

本当の犯人はスキーマだった

ollama の format(JSON Schema 強制)に渡してたスキーマが、こうなってました。

# 旧: 全ブロック共通の、でかいプロパティ袋。required は type だけ。
"items": {
    "type": "object",
    "properties": { "type": {...}, "title": {...}, "text": {...}, "items": {...}, ... },
    "required": ["type"],   # ← こいつが元凶
}

required["type"] だけ。つまり {"type": "steps"} っていう中身カラのブロックが、スキーマ的には「合法」 だったんです。

ollama(中身は llama.cpp)の制約デコードって、スキーマが許す形しか出さない代わりに、スキーマが許しちゃえばカラのブロックも平気で出してくる。qwen3 はブロックを多めに「計画」しといて中身を埋めきれず、後段の _has_content チェックが7割を「ノーゴール」と判定して消していた、というオチでした。オランダ戦で得点者が小川航基から鎌田に訂正されたみたいに、後からの再判定で記録がごっそり書き換わるわけです。ただしうちの場合は加点じゃなくて、ひたすら取り消しのほうですが。

つまり「プロンプトでお願いする」問題じゃなくて、「スキーマが空を許しちゃってる」という構造の問題だったわけです。ルールがゆるいから抜けられる。

対策:型ごとに分ける(discriminated union)

スキーマを「型ごとに分岐して、本文フィールドを必須にする」形に作り替えました。oneOfconst のディスクリミネータで、型ごとに requiredminItems/minLength を課す。

# 各ブロックは「type を const で固定して、本文フィールドを required + 下限つき」にする。
# 例: steps は items が1つ以上、各要素1文字以上を必須に。
STEPS_SCHEMA = {
    "type": "object",
    "properties": {
        "type": {"const": "steps"},
        "items": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1},
    },
    "required": ["type", "items"],
}
# callout なら text(minLength:1)、table なら rows(minItems:1)… と9種ぜんぶ同じ要領で書く。

RESPONSE_SCHEMA = {
    "type": "object",
    "properties": {
        "summary": {"type": "string", "minLength": 1},
        "blocks": {"type": "array", "minItems": 1, "items": {"oneOf": _BLOCK_SCHEMAS}},
    },
    "required": ["summary", "blocks"],  # _BLOCK_SCHEMAS = [STEPS_SCHEMA, CALLOUT_SCHEMA, ...]
}

こうすると、制約デコードがカラのブロックを構造的に「作れなくなる」。後から捨てるんじゃなくて、入口で止める。守備は最終ラインで体を張るより、そもそも崩されない形を作るほうが強い、という。

本実装の前に、実機でちょっと試した(これ大事)

oneOf / minItems / minLength を ollama(llama.cpp)が本当にちゃんと見てくれるかは環境次第なので、いきなり本番に入れずに、小さいスキーマで実機で試してから入れました。

# steps(minItems:2) と callout(minLength:5) だけの最小スキーマで試す
# → 結果: steps=3項目 / callout=48文字。カラ無し。ちゃんと効いてた

うちの ollama 0.30.7 では oneOf / minItems / minLength ぜんぶ効いてました。

結果

旧(required=type だけ) 新(型ごとに必須化)
生成 → 採用 21 → 6 20 → 20
破棄率 71% 0%
citation 全部カラで消えてた 全部残った
パスワード質問 steps 1個だけ(不十分) steps + callout×3 + citation(充分)

破棄率 71% → 0%。しかも qwen3:8b の速度は据え置き(30〜73秒)。LLM が「計画して埋めきれず捨てる」っていう無駄打ちも消えました。決定力不足が一晩で解消、移籍金ゼロ。

ここで学んだこと:構造化出力が言うこと聞かないときは、プロンプトを盛る前に、まずスキーマの required を疑ったほうがいいです。「型だけ必須」は空ブロックを量産します。型ごとに分けて minItems/minLength まで課すと効きました。


6. 寄り道その3:体感速度をどう稼ぐか

UIブロックの一括生成はけっこう重くて、30〜70秒かかります。1分間まったく画面が動かないと、それだけで体験が悪い。アディショナルタイムをずっと待たされる感じ。

なので SSE(Server-Sent Events)で2段階に分けて配信することにしました。

sources(即出す)→ summary(文章1〜2文・軽い生成で先に出す)→ block × N(重いUI生成のあと)→ done

検索で当たった出典名はすぐ出す。文章のサマリは軽いLLM呼び出しなので数秒。そのあいだに裏で重いUIブロック生成を走らせて、できたら描く。「とりあえず何か出てる」状態を先に作るだけで、体感はずいぶん変わりました。先制点があると落ち着く、みたいな。


7. 「GenUI と Markdown」を左右で並べて見せる

GenUI を作ると、絶対に「で、ただのMarkdownと何が違うの?」って聞かれます。なので、同じ回答を左右に同時に描画するビューを作りました。

  • 左 🧩 GenUI:操作できる動的なUI
  • 右 📄 Markdown:本物のチャットボットと同じく、Markdown を描画した静的なテキスト(生ソースじゃなくて、ちゃんと描画した後のやつ)

CSS グリッドで2カラム、画面が狭いと縦積みに。

#compare {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}
@media (max-width: 760px) {
  #compare {
    grid-template-columns: 1fr;
  }
}

ここで気をつけたのが、フェアに比較すること。steps / table / callout あたりは両側ほぼ同じに見えます。そこは正直に「差はないです」と認める。差が出るのは、状態や対話を持つブロックだけ。

  • decision(対話的な切り分け)→ Markdown 側だと全部の分岐がベタっと展開された入れ子リストに潰れて、選べない
  • action(押せるボタン)/ form(聞き返し)→ Markdown 側だとただの文字列や下線になって、操作できない
  • checklist のクリック完了トグル → Markdown 側の は押せない

「描画すれば同じになる部分」と「GenUIにしかできない部分」が、一目で分かります。GenUI を盛って売り込むんじゃなくて、本当に効く場面だけをちゃんと際立たせるのが誠実かなと思ってます。誇大広告のスポンサーロゴはいらない。

実際の対比がこれ。

「パスワードのルールを教えて」 — steps / checklist / callout / citation。手順とか確認項目は、左右でほぼ同じに見えます(=正直、差はない)。

パスワード質問の対比。左 GenUI は手順・チェックリスト・警告・出典に分かれ、右 Markdown は同じ内容が番号付きリストに並ぶ

「プリンタが印刷できない」 — ここで差がはっきり出ます。左 GenUI は「印刷ジョブは表示されますか?」と聞いてきて、[表示される] [表示されない]押して1問ずつ切り分けられる。右 Markdown は同じ決定木が全分岐ベタ展開の入れ子リストに潰れて、選べない

プリンタ切り分けの対比。左 GenUI は押せる選択肢付きの対話的な決定木、右 Markdown は全分岐が深い入れ子リストに展開されて操作できない

この決定木の対比が、GenUI を作る意味を一番わかりやすく見せてくれてる気がします。


8. 完全ブラインド(ぜんぶローカル)という縛り

このプロトタイプ、埋め込みも生成もぜんぶ手元の ollama で動いてます。業務内容(社内の手順書)をクラウドに一切送らない「完全ブラインド」がそもそもの要件でした。

その縛りがあるので、

  • モデルは手元で動く qwen3:8b / Ruri に限られる(だから速度がボトルネックになる)
  • 賢い大型クラウドモデルに頼れないぶん、スキーマ設計で出力品質を担保するしかなかった

逆に言うと、空ブロック71%問題を「スキーマで入口から潰す」やり方に行き着いたのは、この縛りのおかげでもあります。クラウドの賢いモデルだったら、雑なスキーマでもなんとなく動いちゃって、根本原因に気づかなかったかもしれない。潤沢な資金で殴れない分、ユース育成と戦術で勝つしかない、みたいな話です。


9. まとめ

  1. GenUI の鉄則は「自由HTMLを吐かせない/有限の検証済みブロックから選ばせる」。柔軟さと安全さの両取りはここに尽きます。
  2. 構造化出力が言うこと聞かないときは、スキーマを疑う。プロンプトで「空を作るな」とお願いするより、スキーマで「空を作れなく」する。型ごとに required を課す判別ユニオン + minItems/minLength が効きました。
  3. 制約デコードの挙動は、本実装の前に実機でちょっと試す。oneOf/minItems が効くかは推論エンジン次第なので。
  4. GenUI の価値は誠実に見せる。Markdownと並べて、本当に差が出る場面(対話・状態・操作)だけを際立たせる。
  5. モデル選定は賢さより速さ×JSONが壊れないこと(インタラクティブ用途は特に)。

ローカルLLM + 構造化出力 + 有限ブロック、っていう組み合わせは、「そんなに賢くないモデルでも、設計のしかたで実用的なUIを安定して出させる」良い練習問題でした。資金力じゃなくて規律と設計で勝つ、というのは個人的にすごく好きな勝ち方です。


この記事のコードは社内検証用のプロトタイプを元にしています。手順書のデータはぜんぶ合成のサンプルです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?