返事が空だった ローカルLLMのJSONは考え中の欄にあった

公開: 2026-11-03
開発日記OllamaローカルLLMJSONスキーマエージェント制御

11月1日、ミニ(Mac mini)でローカルに曲を作るアプリ(mini-music-app)を実機で動かし始めました。依頼文をローカルのLLMに渡して曲の設計図(JSON)を作らせ、その設計図で音楽生成モデルのACE-Stepに曲を作らせる構成です。

動かして最初の39分で、LLMの出力を受ける側を3回直しました。 15:32、15:35、16:11のコミットです。どれもモデルの賢さの問題ではなく、受け取る側の作りの問題でした。

15:3015:4516:0016:1515:32 #2236返事の欄が空 → thinking欄も読む15:35 #2238(3分後)format: json → JSONスキーマ16:11 #2240上限120秒 → 600秒。6か所を整理36分2026-11-01(JST)。点はコミットの時刻
実機で動かした初日に、LLMの出力の受け方を3回直した。間隔は3分と36分。

何を作っていたか

アプリの流れは次のとおりです。

  1. 「4:16 EDM piano-forward up-tempo ...」のような1行の依頼を受ける
  2. Ollama経由のQwen系LLMが、title・bpm・duration・key_scale・lyrics・acestep_prompt などを持つJSONを返す
  3. アプリがそのJSONを検証して、ACE-Stepに渡す
  4. LLMと音楽エンジンは同時にメモリへ載せない。LLMの解放を確認できないときは、音楽エンジンを起動しない

直した3回は、すべて2と3の間(LLMの返事を受け取って検証するところ)です。

15:32 返事の欄が空だった

実機で動かすと、設計図を読み取れませんでした。応答を見ると、通常の回答欄(response)は空で、完成したJSONは別の欄(thinking)に入っていました。検証記録にも「実機でQwenの通常回答が空で、完成した曲設計JSONが補助出力フィールドへ入ることを確認」と残してあります。

直したのは受け取る側の1行です。

# 空の回答だけを補完し、JSON以外の文章は取り込まない。
content = result.get("response") or result.get("thinking")
return json.loads(content)

or なので、response が空のときだけ thinking を読みます。response に何か入っていれば、そちらが優先です。thinking が文章なら json.loads が失敗して、従来どおり拒否になります。リクエスト側に think(先に考えるかどうかの指定)は入れていません。モデルの生成モードには触らず、受け取る側だけで吸収した形です。

手元で再現した

MacBook Airで、本物の Pipeline.design(設計図を作る関数)を、Ollamaとの通信だけ偽物に差し替えて動かしました。修正の前後のコードを、コミットごとに取り出して同じ4通りの応答を流した結果です。

LLMの応答#2236の前#2236の後
response にJSON読める読める
response が空、thinking にJSON読めない読める
response が空、thinking が文章読めない読めない
両方とも空読めない読めない

プロジェクトの指定(Python 3.11〜3.12)の外のPython 3.13で動かしています。確かめられたのは受け取る側の挙動だけで、モデルが実際にそう返すかは、この再現では見ていません。

公式文書には載っていなかった

Ollamaの公式API文書(GitHubのmainにある docs/api.md)を、2026-11-03に原文のまま取得して thinking で検索しました。

つまり今回の読み方は、文書に書かれた仕様ではなく、実機の応答に合わせたものです。なぜこのモデルがJSONを thinking に入れたのか、think を指定すると response に戻るのかは、確認していません。

15:35 形式の指定をJSONスキーマに変えた

欄を読めるようになると、今度は中身の問題が見えました。実機で、必須の項目が抜けたり、型が揺れたりしていました。送っていた format: "json"(JSONで返せ、という指定)を、JSONスキーマに置き換えました。

RECIPE_SCHEMA = {
    "type": "object",
    "properties": {
        "bpm": {"type": "integer", "minimum": 60, "maximum": 200},
        "duration": {"type": "number", "minimum": 10, "maximum": 120},
        # title・key_scale・lyrics などは省略
    },
    "required": ["title", "bpm", "duration", "key_scale",
                 "vocal_language", "acestep_prompt", "lyrics"],
    "additionalProperties": False,
}

公式文書の format の項には、json か JSONスキーマを渡せると書かれています。送るリクエストでは、format の中身が文字列の json から、必須7項目を持つスキーマに変わりました(再現スクリプトで確認)。

受信後の検証(normalize、設計図の各項目を確かめる関数)は残しました。スキーマは送る側の指定で、返ってきたものが守っているかは別の話だからです。この検証が何を通して何を弾くかも、手元で動かして確かめました。

LLMが返した設計図現在の検証
lyrics が無い弾く(「楽曲設計のlyricsが不正です」)
acestep_prompt が無い弾く
bpm が文字列の "120"通る(整数の120になる)
bpm が "速い"弾く
duration が "30秒"弾く
duration が300通る(#2240の前は「10〜120で指定してください」で弾いた)

型の揺れのうち、数値を文字列で返す揺れは、検証が黙って通すことが分かりました。今は困っていないので変えていません。厳密にするなら、スキーマに任せず検証側で型を見る必要があります。

スキーマを入れて欠落や揺れが減ったかは、実機でまだ測っていません。 入れた理由は実機で見た欠落と揺れで、入れた後の結果は記録がない、という状態です。

16:11 上限値は6か所に書いてあった

その36分後、4:16 EDM piano-forward up-tempo male rap female lead vocals(4分16秒=256秒)の依頼が、120秒の上限で失敗しました。実ログで確認した、と検証記録にあります。上限を600秒にすることにして、変える前に 120 を検索しました。

git grep -n "\b120\b" 35948f2a -- 'mini-app/mini-music-app/*.py' 'mini-app/mini-music-app/static'

結果は7件です。そのうち1件は timeout=120(音声を取りに行く待ち時間)で、曲の長さの上限ではありません。数字だけで検索すると、別の意味の同じ数字が混ざります。 上限としての120は6か所でした。

#2238の時点: 上限が「10〜120」と書かれていた6か所UIスライダーAPILLMへの指示文JSONスキーマ設計の検証入力の検証app.pycontrol.pymusic.pymusic.pymusic.pymusic.py10〜12010〜12010〜12010〜12010〜12010〜120#2240の後: 数値が書かれているのは4か所(すべてmusic.py)UI入力欄APILLMへの指示文JSONスキーマ設計の検証入力の検証app.pycontrol.pymusic.pymusic.pymusic.pymusic.py説明文のみ入力の検証を呼ぶ10〜60010〜60010〜60010〜600破線は、数値を持たない場所。上限は「10〜600」の文字が書かれた場所を数えている。
長さの上限が書かれていた場所。上がスキーマを足した直後、下が600秒に変えた後。

#2240で、UIは数値のスライダーをやめて文字の入力欄にし(画面の説明文に「10秒〜10分」と書くだけ)、APIは music.py の入力の検証(parse_duration)を呼ぶ形に寄せました。変更後に 600 で検索すると、こちらも待ち時間の600(2件)が混ざって6件ヒットしますが、上限としての数値は music.py の4か所です。

ここで気づいたことがあります。スキーマを足したことで、同じ上限を書く場所が1つ増えていました。 LLMへの指示文(文章)、スキーマ、検証の3つが食い違うと、LLMは上限の内側のつもりで返しても検証で弾かれる、あるいはその逆が起きます。#2240では4か所を手で揃えました。スキーマの上限と検証の上限が一致していることを確かめるテストは、今はありません(スキーマについては、必須項目に acestep_prompt が入っているかを見るテストが1件あるだけです)。

三つの木の堰が並ぶ霧の川。手前の堰へ、小さな巻物が流れて近づいている

持ち帰ったこと

  1. LLMの返事は、「どの欄にあるか」と「中身が正しいか」を別々に疑う。 今回は欄の問題が先に出て、中身の問題が後から出た
  2. 形式を縛るスキーマを足しても、受信後の検証は残す。 文字列の "120" が検証を黙って通ったように、2つの層は守る範囲が違う
  3. 同じ値(上限・形式)が書かれた場所を、数字ではなく意味で探す。 120 の検索には、別の意味の timeout=120 が混ざった
  4. 効果は測ってから書く。 スキーマの効果と、thinking に入る理由は、未確認のまま残した

次にやることは、上限を music.py の定数1つにまとめて、指示文・スキーマ・検証を同じ値から作る形にすることと、スキーマの上限と検証の上限が一致するテストを足すことです。まだ着手していません。

関連する記事です。