📚

未経験者がVRAM 16GBでAIキャラの台本生成を動かすまで(第6回・完結) ── ハルシネーションを 4段ロケットで削る話

に公開

第1〜5回からの続き

第1〜5回で、AI キャラの台本生成エンジンを作る過程でぶつかった 9つの壁と、その解決策を書いてきました。

テーマ
第1回 壁① VRAM 16GB に 27B モデルが乗らない
第2回 壁② プロンプトの一文字で別人格 / 壁③ 抽象指示は効かない
第3回 解決① Qwen3:14b / 解決② プロンプトに具体例を埋め込む
第4回 壁④⑤⑥ 「きみ」廃止が引き起こした品質崩壊と再構築
第5回 壁⑦⑧⑨ 通しテストの罠 / 解決③④⑤ 構造的解決と AI 体制最適化

第5回の最後で、Phase 1 が完成しました。
台本生成は 22 秒で 90 点品質、main.py は 933 行 → 33 行、AI 体制も最適化済み。

そして Phase 2 に入った瞬間、最後にして最大の壁が見えてきました。

LLM が嘘をつく

「Dreamcast にバーチャロン従量制課金があった」── そんな存在しない歴史を、Qwen3:14b は自信満々で語り出したのです。
ネット史をテーマにする以上、事実誤認は致命傷でした。

本記事は、そのハルシネーションを段階的に削っていく 4段ロケットの話と、声優委託資料の発見で台本品質の壁をもう一段越えた話、そして台本生成編をクローズする話です。


第21章:壁⑩ ── LLM の幻覚「Dreamcast バーチャロン従量制」事件

Phase 2 着手日、最初に出力された台本

Phase 2 を着手した日、私は当時まだ強気でした。

「Phase 1 で台本生成は 90 点品質まで届いた。
Phase 2 は動画素材を Pexels から自動取得するだけ。あとは UI を整えて、出力確認して終わり。」

ところが、Phase 2 のテーマ候補として「ネット史を語るキャラ」を再確認するため、いくつかのトピックで台本を生成し直していたら、こんな段落が出てきたのです。

[興味] わたしも調べてみたんだよ。Dreamcast には「バーチャロン従量制」という
       時間課金システムがあって、当時としては画期的だったんだよね。

……いやいや、ちょっと待って。

バーチャロン従量制、そんなシステムは存在しません。
セガサターン版・Dreamcast 版『電脳戦機バーチャロン』はパッケージ販売で、時間課金などしていない。
これは LLM がそれっぽい言葉を組み合わせて、それっぽい歴史を捏造した典型例でした。

もう一つの例:

[通常] 1999 年に発売された MMORPG「Wonderland Online」は、当時のユーザー数が
       100 万人を超えていてね。日本のオンラインゲーム黎明期を象徴する作品だった。

Wonderland Online は実在しますが、1999 年発売ではない
MMORPG ではなくフリー MMORPG カテゴリだが、その分類自体も時期で揺れる
100 万人という数字の出典は不明

1 段落の中に事実誤認が 3 つ入っている、という有様でした。

ネット史テーマの致命的な相性問題

ここで気づいたのは、「ネット史を語るキャラ」と「LLM の幻覚」は相性が最悪だということ。

数学・プログラミング・一般的な雑学であれば、LLM が間違えても「うっかり」で済みます。
ところが、ネット史(=過去 30 年程度の Web/ゲーム/SNS の出来事)は:

  • 検証可能な固有名詞(タイトル名、年代、企業名、料金体系)が大量に出てくる
  • 誤りはコメント欄で必ず指摘される(オタクほど詳しい)
  • 「画期的な何々」という表現は、捏造の温床(LLM はそれっぽい修飾語を作るのが得意)

第 4 回までで人格・口調・演技は磨けました。
でも、事実が間違っていたら、出した瞬間に信用を失う

これは、プロンプトでは解決できない問題でした。

14B モデルの限界

「もっと賢いモデルを使えば?」── これは何度も自問自答しました。
具体的には、Phase 2 着手前に 5 モデル比較を実施しました(Sprint B-1)。

候補は以下:

- qwen3:8b              ← 速いけど、長尺で破綻
- qwen3:14b             ← Phase 1 主力(現役)
- qwen3-coder:30b       ← コード偏重、ストーリーテリング苦手
- gpt-oss:20b           ← バランス型、しかし演技不足
- magistral-small:24b   ← 推論強いが、感情表現が硬い

詳細な比較レポートは docs/design/llm_comparison_2026-04-29.md に残しましたが、結論は:

どのローカル LLM も、ハルシネーション傾向は本質的に同じ。
モデルを大きくしても、固有名詞の捏造は減るが消えない。
14B クラスは速度と品質のバランスでベスト。モデル変更で解決する問題ではない

この結論を得たのが Phase 2 の出発点でした。
モデルを変えるのではなく、モデルの周りを変える ── ハルシネーション対策は、アーキテクチャの話だったのです。


第22章:第一段ロケット ── Web 検索を統合する(B-3)

「事実を渡せば嘘をつかなくなる」仮説

最初に試したのは、生成前に LLM へ事実を渡すというシンプルな方法でした。

[従来]
ユーザー入力(トピック) → LLM → 台本

[改善案]
ユーザー入力(トピック) → Web 検索 → 検索結果 + トピック → LLM → 台本

検索バックエンドには DuckDuckGo(ddgs パッケージ)を採用。
理由は API キー不要・無料・利用規約が個人開発に優しいこと。

# backend/services/web_search.py(抜粋)
from ddgs import DDGS

def search_web(query: str, max_results: int = 5) -> list[dict]:
    with DDGS() as ddgs:
        results = ddgs.text(query, max_results=max_results, region="jp-jp")
    return [
        {"title": r["title"], "snippet": r["body"], "url": r["href"]}
        for r in results
    ]

そして、構成案生成 + 台本生成のプロンプトに、検索結果を埋め込みました。

# 台本生成プロンプトの一部
prompt_block = f"""【参考情報(Web 検索結果)】
{format_web_results(web_results)}

上記情報のうち、確証が取れるものは引用して構いません。
ただし、以下の制約を厳守してください:
- 検索結果に書かれていない年代・数値・固有名詞は捏造しない
- 「らしい」「と聞いたことがある」など曖昧表現は使わない
- 不確実な事実は触れない方を選ぶ
"""

効果と限界

これで、よくある固有名詞の誤りは減りました。
たとえば「Wonderland Online」のような特定タイトルの初出年は、Web 検索で 1 位に出る Wikipedia 記事から正しく拾うようになりました。

ところが、LLM はまだ嘘をつきます

[興味] このゲームは当時、サーバー負荷で頻繁に落ちることで有名でね……
       ユーザー間では「メンテナンス神社」と呼ばれていたんだよ。

メンテナンス神社、出典なし。
「ユーザー間で〜と呼ばれていた」という民俗学的な記述は、Web 検索結果からほぼ拾えないのに、LLM はそれっぽく作る。

つまり Web 検索は 「数値と固有名詞の正確化」には効くが、「叙述の捏造」には効かない

第一段では届かなかったので、第二段ロケットを点火します。


第23章:第二段ロケット ── Wikipedia + Chroma の RAG(B-2)

なぜ Web 検索だけでは足りないか

Web 検索の問題点を整理すると、こうなります:

  1. 検索結果の選別が雑:DuckDuckGo の上位 5 件にゴシップサイトが混じる
  2. 断片的:検索結果の snippet は 200 字程度、文脈が足りない
  3. 時系列がブレる:同じ事象でも記事ごとに年代が違うことがある
  4. 「叙述」を担保できない:民俗学的な記述・体験談的な記述は Web に薄い

これを解決するために、Wikipedia の特定記事を丸ごと取り込んで RAG する 方針を立てました(Sprint B-2)。

設計書は docs/design/phase2_b2_rag_design.md に残しています。アーキテクチャはこんな感じ:

ユーザー入力(トピック)


[Wikipedia API] ── 関連記事 5〜10 本を取得


[テキスト分割] ── 1 段落 = 1 chunk


[nomic-embed-text] ── ローカル埋め込み


[Chroma ベクトル DB] ── キャッシュ + 類似検索


ユーザー入力に関連する chunk Top-K


LLM プロンプトに埋め込み

nomic-embed-text を選んだ理由

埋め込みモデルには nomic-embed-text(Ollama 経由)を採用。
理由は 3 つ:

  1. オフライン稼働:API キー不要、ローカル GPU(VRAM 1GB 程度)で動く
  2. 日本語対応の精度:OpenAI text-embedding-3 ほどではないが、Wikipedia 日本語記事の類似検索には十分
  3. 次元数 768:Chroma に格納するのに丁度よい(高次元すぎるとメモリを食う)
# backend/services/rag_indexer.py(抜粋)
import ollama

def embed(text: str) -> list[float]:
    response = ollama.embeddings(model="nomic-embed-text", prompt=text)
    return response["embedding"]


def index_wikipedia_articles(topic: str, db: chromadb.Client) -> int:
    """トピック関連の Wikipedia 記事を Chroma に投入する。"""
    articles = wikipedia_client.fetch_related_articles(topic, max_articles=10)
    collection = db.get_or_create_collection("wikipedia_isa")

    chunks = []
    for article in articles:
        for i, paragraph in enumerate(split_into_paragraphs(article.text)):
            chunks.append({
                "id": f"{article.title}::{i}",
                "embedding": embed(paragraph),
                "document": paragraph,
                "metadata": {"title": article.title, "url": article.url},
            })

    collection.add(...)
    return len(chunks)

RAG を構成案 + 台本生成の両方に統合

ここで重要な設計判断がありました。

RAG は「台本生成だけ」に効かせるのか、「構成案生成」にも効かせるのか?

検証してみると、構成案生成段階で RAG を効かせる方が、最終品質が高いことが分かりました。
なぜなら、構成案の段階で「事実から外れた切り口」を選んでしまうと、台本生成段階でいくら事実を渡しても、もう挽回できないからです。

# api/scripts.py(構成案生成)
relevant_chunks = rag_retriever.retrieve(
    query=request.topic,
    top_k=8,
    db=chroma_db,
)
prompt = build_outline_prompt(
    topic=request.topic,
    rag_context=format_rag_chunks(relevant_chunks),
)
# api/scripts.py(台本生成、選ばれた構成案 + RAG)
relevant_chunks = rag_retriever.retrieve(
    query=f"{request.topic} {request.selected_outline}",
    top_k=12,
    db=chroma_db,
)
prompt = build_script_prompt(..., rag_context=format_rag_chunks(relevant_chunks))

効果

第一段(Web 検索)では届かなかった「叙述の捏造」が、明らかに減りました。
たとえば「メンテナンス神社」のような語は出にくくなり、出てもWikipedia に書かれた表現の再構成になるので、検証可能になります。

ただし、これでも 100% ではありません。
Wikipedia に書かれていない領域(個人ブログ・SNS 体験談ベースのエピソード)は、依然として LLM の創作対象です。

ここで第三段ロケットの登場です。


第24章:第三段ロケット ── LLM 自己ファクトチェック(B-4)

「書いた本人に検閲させる」アプローチ

ここで採用したのは、LLM 自身に書いた台本を批判させるという、ある意味メタな手法でした。

台本生成完了


[同じ LLM(qwen3:14b)に問い直す]
   「以下の台本に、事実誤認・年代誤り・捏造の疑いがある記述があれば、
    箇条書きで指摘してください。確証がない場合は『不確実』と明記してください。」


LLM が警告リストを返す


RAG でその警告を裏取り


最終的に「警告レベル(高/中/低)」を付与して UI に表示

実装の中核はこんな感じです。

# backend/services/factcheck.py(抜粋)
def factcheck_script(script_text: str, rag_db) -> list[Warning]:
    # 1. LLM に自己批判させる
    self_critique_prompt = build_self_critique_prompt(script_text)
    critique_response = ollama.chat(
        model="qwen3:14b",
        messages=[{"role": "user", "content": self_critique_prompt}],
    )
    raw_warnings = parse_warnings_jsonl(critique_response["message"]["content"])

    # 2. 各警告を RAG で裏取り
    enriched = []
    for w in raw_warnings:
        relevant_chunks = rag_retriever.retrieve(
            query=w.claim, top_k=5, db=rag_db,
        )
        w.rag_evidence = relevant_chunks
        w.confidence = compute_confidence(w, relevant_chunks)
        enriched.append(w)

    return enriched

「自己批判の精度」が鍵

最初のうち、自己ファクトチェックは過剰検出しがちでした。
台本中の 平凡な表現にまで「不確実」マークを付けまくる時期があり、UI が警告だらけになって機能しない。

ここで、プロンプトを以下のように調整しました。

【検閲ルール】
- 「事実誤認」「年代誤り」「捏造」の疑いがあるものだけ報告する
- 創作的な比喩・キャラクター演出・修飾的な形容詞は対象外
- 一般常識(例:インターネットは1990年代から普及した)は対象外
- 確証が必要な記述(数値、固有名詞、特定タイトルの発売年)に集中する

これで誤検出が激減し、1 動画あたり 5〜8 件の警告が出るようになりました。
そのうち約 6〜7 割は、RAG で裏取りすると実際にあいまい・誤りであることが確認できる警告でした。

UI への警告表示

警告は色付きバッジで台本表示時に見えるようにしました。

[High]   「Dreamcast バーチャロン従量制」── RAG 上に該当記述なし、捏造の可能性が高い
[Medium] 「ユーザー数 100 万人」── 数値の出典が確認できない
[Low]    「2003 年頃」── 概数表現、許容範囲だが要確認

これで初めて、人間が事前に確認できる状態になりました。


第25章:第四段ロケット ── 警告ベースの自動リビジョン(Sprint 3.5-6)

「警告を見たら直す」をフィードバックループに

ここで、ユーザー(私)から強めの指摘がありました。

ファクトチェック後の対応ができていない
警告を出すだけで、台本本文は元のままじゃないか。
警告があるなら、それをもとに台本を書き直す機構が必要では?」

確かに、警告を出して終わりでは「人間が手動で直さないと使えない」状態です。
これは Phase 2 のスコープに対しては不十分でした。

そこで Sprint 3.5-6 として、自動リビジョン機構を追加しました。

台本生成完了

ファクトチェック → 警告リスト

警告リストを LLM に渡して台本本文を書き直す(リビジョン)

リビジョン後の台本を再ファクトチェック

警告がゼロまたは Low のみ → 完了
警告がまだ High/Medium → さらにもう 1 周(最大 2 周)

実装

# backend/api/factcheck.py(リビジョンエンドポイント、抜粋)
@router.post("/revise-script/{script_id}")
def revise_script(script_id: str):
    warnings = crud.get_factcheck_warnings(script_id)
    high_medium = [w for w in warnings if w.level in {"high", "medium"}]
    if not high_medium:
        return {"status": "no_revision_needed"}

    revision_prompt = build_revision_prompt(
        original_script=crud.get_script(script_id)["script_text"],
        warnings=high_medium,
    )
    new_script = llm.chat(prompt=revision_prompt, model="qwen3:14b")

    crud.update_script_text(script_id, new_script)
    crud.increment_revision_count(script_id)
    return {"status": "revised", "new_script": new_script}

リビジョンプロンプトの肝は、警告を「修正指示」として渡すことでした。

【ファクトチェック警告】
- 「Dreamcast バーチャロン従量制」: 該当事象なし、削除または別の事実に置き換え
- 「ユーザー数 100 万人」: 出典なし、概数表現に変更または記述削除

上記警告を踏まえ、台本を書き直してください。
- 該当箇所のみを修正、それ以外の文体・段落構成は保持する
- 修正の結果、段落数や全体の長さが大きく変わらないようにする

動作確認

実機で 3 つの動画題材で試した結果:

題材A:1 周目で警告 7 件 → リビジョン後 2 件(Low のみ)
題材B:1 周目で警告 5 件 → リビジョン後 1 件(Low のみ)
題材C:1 周目で警告 9 件 → 1 周後 4 件 → 2 周後 1 件(Low のみ)

「Dreamcast バーチャロン従量制」級の捏造は、自動リビジョンで消えるようになりました


第26章:壁⑪⑫⑬ ── 「ふぅ……」事件と、声優委託資料の発見

壁⑪ 「ふぅ……」の機械的演出

Phase 2 後半、Phase 3 の準備としてキャラクター仕様を見直していたら、別の問題が見えました。

LLM の出力で、感情タグ [通常][興味] が付いた段落の冒頭に、こんな表現が頻発していたのです。

[通常](間)ふぅ……あの頃の Web 文化、覚えてる?
[興味] うわ……これは興味深いね。
[通常] (間)ねえ、知ってる?

これらは第 2 回・第 3 回で書いた Few-Shot 例から学習された表現でした。
Few-Shot 例は元々、感情タグの参考用に「演技がにじむ表現」として書いていたのですが、それが全部の段落で機械的に再生産されるようになっていた。

特に問題だったのは:

  • [通常] は「淡々と語る」状態のはずなのに、毎回「ふぅ……」「(間)」が付く
  • [興味] は「やや前のめり」のはずなのに、毎回「うわ」「うわぁ」が付く

これは、Phase 1 から徐々に蓄積していったプロンプトの油汚れでした。

壁⑫ 声優委託資料の発見

ここで、ユーザー(私)から、次のような共有がありました。

「実は、声優委託のために録音台本サンプルと感情仕様書を作成していました。
プロジェクトの docs/Isa_Recording_Materials/ に置いてあります。
プロンプトの参考になる部分があるかもしれません。」

これがゲームチェンジャーでした。
資料を読み込んでみると、感情の 4 段階が比率付きで明文化されていたのです。

イーサ・メモリアの感情表現比率:
[通常]      :60%(基底状態、淡々と語る)
[興味]      :25%(前のめり、やや音量大)
[専門モード] :10%(ギャップ、論理的に詳細解説)
[驚き]      : 5%(感情の山場、声を張る)

[通常] では「ふぅ」「うわ」「(間)」を絶対に使わない(淡々と語るのが基底)
[興味] では「うわ」「うん」を控えめに(過剰な感嘆は無し)
[専門モード] でのみ「(間)」を許容(思考の停止を表現するため)
[驚き] でのみ「えっ」「うわぁ」が許容

そして、B/C/D パートの録音台本(段落ごとの実際のサンプル文)も載っていました。
これは Few-Shot 例として最高の素材です。

壁⑬ prompts.py v4 へのリライト

ここでようやく、プロンプトの構造そのものを書き直す判断をしました。

構造項目 v3(旧) v4(新)
感情タグ 4 種列挙のみ 比率指定 + タグ別演技指示
Few-Shot 自前作成例 録音台本ベース(B/C/D パート)
「ふぅ」等の禁止 全体 CORE_RULES タグ別に細分化
「(間)」の禁止 なし [専門モード] のみ許容

具体的には、prompts.py の CORE_RULES から「ふぅ……禁止」を抜いて、感情タグ別の演技マトリクスに移しました。

# backend/prompts.py v4(抜粋)
EMOTION_DIRECTIVES = """
【感情タグ別演技指示(録音台本準拠)】

[通常] 60% — 基底状態、淡々と
  - 「ふぅ」「うわ」「(間)」を絶対に書かない
  - 主語省略を多用、文末は「だよね」「だね」を控えめに
  - 例:「2003 年頃の Flash サイトって、独特の文化があったんだよね。」

[興味] 25% — やや前のめり
  - 「うわ」「うん」は控えめ(段落 2 つに 1 回まで)
  - 文末は「だよ!」「だよね、これ!」など軽い感嘆
  - 例:「あれは画期的だったよね、技術的にも文化的にも。」

[専門モード] 10% — ギャップ、論理的詳細
  - 「(間)」許容(思考の停止を表現)
  - 用語を正確に、修飾語を最小化
  - 例:「(間)ここで重要なのは、HTTP/1.1 の Keep-Alive が……」

[驚き] 5% — 山場
  - 「えっ」「うわぁ」許容
  - 文末は「!」「!?」を許容
  - 例:「えっ、それ本当?信じられないな……!」
"""

効果

リライト後に同じトピックで生成し直したところ、台本の自然さが目に見えて変わりました。

[Before(v3)]
[通常](間)ふぅ……Flash アニメの黄金期って、1998 年から 2008 年頃かな。
[通常](間)あの頃の作品はね、今でも Web Archive で見られるんだよ。
[通常](間)ねえ、覚えてる?

[After(v4)]
[通常] Flash アニメの黄金期って、1998 年から 2008 年頃かな。
[通常] あの頃の作品はね、今でも Web Archive で見られるんだよ。
[通常] 一番有名だったのは、ニコニコ動画よりも前に流行ったあの作品だね。

「(間)」「ふぅ……」「ねえ、」が消え、淡々と語る本来の [通常] 状態に戻りました。


第27章:学び

長い Phase 2 でした(2026-04-28 〜 2026-04-30 前半)。
ハルシネーション対策と感情タグ整理で、台本生成は実用レベルまで届きました。

1. ハルシネーションは「層」で削る

LLM のハルシネーションを完全に消す」という単発のソリューションは存在しません。
代わりにあるのは、多段ロケットで段階的に削るというアプローチでした。

第一段:Web 検索   → 固有名詞の精度を底上げ(50%減)
第二段:RAG       → 叙述の捏造を半減(更に 30%減)
第三段:自己批判   → 残った警告を可視化
第四段:自動修正   → 警告を本文に反映

各段はそれ単独では物足りないけれど、積み重ねると実用ラインに届きます。

これは、完璧主義を諦めて、段階的に積むという、Phase 1 から続く方針の延長でもありました。

2. 「真の問題」は別の場所にあることがある

「ふぅ……」事件で痛感したのは、「機械的な演出」の真因はプロンプトの構造そのものだったということ。

CORE_RULES に「ふぅ禁止」を書き足しても、Few-Shot 例で再生産されるなら効果ゼロです。
真の解決は、プロンプトの構造を「感情タグ別に分割する」ことでした。

これは、第 4 回・第 5 回で書いた「構造的な問題には構造的な解決を」と全く同じ教訓です。
そして、何度同じパターンを踏んでも、毎回新鮮に気づき直すのが個人開発でした。

3. 仕様書は外部資料からも来る

声優委託のために作っていた録音台本資料が、最終的にプロンプトの正典になりました。
これは予想していなかった出来事でした。

これからの個人開発では、「自分が別目的で作った資料が、別の場所で正典になる」可能性を意識して、ドキュメントを集約してアクセス可能な状態に保っておくべきだと痛感しました。

イーサ・メモリアの場合、docs/design/Isa_Character_Design.md v3.4 が、プロンプト・録音台本・Live2D 表情マッピングの 3 つの正典を兼ねるようになりました。


連載のまとめ(第1〜6回)

6 回にわたって、AIキャラの台本生成エンジン開発でぶつかった13 個の壁と、その解決策を書いてきました。

テーマ
第1回 壁① VRAM 16GB に 27B モデルが乗らない
第2回 壁② プロンプトの一文字で別人格 / 壁③ 抽象指示は効かない
第3回 解決① Qwen3:14b / 解決② プロンプトに具体例を埋め込む
第4回 壁④⑤⑥ 「きみ」廃止が引き起こした品質崩壊と再構築
第5回 壁⑦⑧⑨ 通しテストの罠 / 解決③④⑤ 構造的解決
第6回(本記事) 壁⑩⑪⑫⑬ ハルシネーション 4段ロケット + 録音台本ベースの v4 リライト

ここまでで、「LLM の台本生成エンジン編」 はクローズとなります。

ですが、プロジェクト「イーサ・メモリア」自体はまだ完成していません。
ここからは別シリーズとして、動画レンダリング編 を書きます。

具体的には:

  • 動画合成ライブラリ選定の設計転換(MoviePy → Remotion)
  • Live2D Web SDK 統合の苦闘(Cubism Core バージョン地獄)
  • マルチトラック・タイムライン編集 UI の設計

「LLM 台本」が「Live2D + 字幕 + ナレーション付き 1080p MP4」になるまでの旅を、別シリーズとして始めます。
よろしければ引き続きどうぞ。


おわりに

Phase 2 が完了しました。

項目 結果
ハルシネーション 4段ロケット 動作 ✓
Pexels 視覚アセット自動取得 動作 ✓
prompts.py v4(感情タグ別演技指示) 動作 ✓
Live2D 統合・タイムライン編集 Phase 3 へ意図的に移管

Phase 1 のテーマが「未経験者が VRAM 16GB で AI キャラの台本生成を動かすまで」だったとすれば、Phase 2 のテーマは「書いた台本が嘘をつかないようにするまで」でした。

質問・アドバイス・「同じことで詰まった」等、コメントいただけると嬉しいです。
特に「LLM ハルシネーション対策」「RAG の構成」「自動リビジョン機構」は、他の方の事例や工夫を聞いてみたいです。

次の連載(動画編)第 1 回でお会いしましょう。


🔗 関連:

Discussion