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

11月1日、ミニ(Mac mini)でローカルに曲を作るアプリ(mini-music-app)を実機で動かし始めました。依頼文をローカルのLLMに渡して曲の設計図(JSON)を作らせ、その設計図で音楽生成モデルのACE-Stepに曲を作らせる構成です。
動かして最初の39分で、LLMの出力を受ける側を3回直しました。 15:32、15:35、16:11のコミットです。どれもモデルの賢さの問題ではなく、受け取る側の作りの問題でした。
何を作っていたか
アプリの流れは次のとおりです。
- 「4:16 EDM piano-forward up-tempo ...」のような1行の依頼を受ける
- Ollama経由のQwen系LLMが、
title・bpm・duration・key_scale・lyrics・acestep_promptなどを持つJSONを返す - アプリがそのJSONを検証して、ACE-Stepに渡す
- 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 で検索しました。
- 生成API(
/api/generate)の項には、thinkパラメータの説明がある。考える機能を持つモデルが、答える前に考えるかどうかの指定 - 応答の
thinking欄の説明は、チャットAPIのmessageの側にあり、生成APIの応答の項には見つからなかった
つまり今回の読み方は、文書に書かれた仕様ではなく、実機の応答に合わせたものです。なぜこのモデルが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か所でした。
#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件あるだけです)。

持ち帰ったこと
- LLMの返事は、「どの欄にあるか」と「中身が正しいか」を別々に疑う。 今回は欄の問題が先に出て、中身の問題が後から出た
- 形式を縛るスキーマを足しても、受信後の検証は残す。 文字列の
"120"が検証を黙って通ったように、2つの層は守る範囲が違う - 同じ値(上限・形式)が書かれた場所を、数字ではなく意味で探す。
120の検索には、別の意味のtimeout=120が混ざった - 効果は測ってから書く。 スキーマの効果と、
thinkingに入る理由は、未確認のまま残した
次にやることは、上限を music.py の定数1つにまとめて、指示文・スキーマ・検証を同じ値から作る形にすることと、スキーマの上限と検証の上限が一致するテストを足すことです。まだ着手していません。
関連する記事です。
- LLMのJSONの受け方 — 受け口を3層に分ける型、手元で確かめた通る・弾くの一覧、同じ上限の数え方
- AI作曲の当たりは、seedとguidanceの組み合わせにあった — 同じ音楽生成モデルACE-Stepで、歌入りの曲を再現できる形にするまで
- 歌入りAI曲の再現レシピ — ACE-Stepに渡す条件(プロンプト・seed・guidance)の参照用まとめ
- 作っているもの — 開発日記の元になっているプロダクト