
Google Mantisからツールの作り方——構造化プロンプト生成器のサンプル
界隈で話題のMantis
前回の記事で、Googleが出したMantisを実機で動かして「導入しない」という結論を出した。MantisはLLMのパイプラインで脆弱性を検出するツールで、人間のセキュリティレビューの工程を機械化した11ノードの設計だった。
この記事は、そのMantisの「作り方」から学ぶ話だ。脆弱性スキャンの知識は不要。LLMを使ったツールを初級者向けに組む方法を、実際に動くサンプルで説明する。
サンプルは楽曲プロンプト生成器
作るツールはこれだ。ユーザが自由書式で「雨の夜に聴きたい、少し寂しいけど前を向けるようなピアノのバラードを作りたい」と入力すると、楽曲再生フロントエンドが使える構造化プロンプト——歌詞、スタイル、タイトルの3フィールド——を生成するもの。
なぜ3フィールドか。楽曲再生フロントエンドは、歌詞をどう表示するか、スタイルを生成エンジンにどう渡すか、タイトルをプレイリストにどう出すか、をそれぞれ別の経路で扱う。だからユーザの自由な文章を、この3つの「名」に振り分けて渡すのが正しい形になる。
Mantisが教えてくれる4つの原則
Mantisの設計から、LLMツールに共通する4つの原則を抽出した。
名と内容の分離
決定論ゲート(fail-closed)
budget(リトライの上限)
exit codeが権威
この4つを、サンプルのコードにそのまま反映していく。
原則1: 名と内容の分離
LLMに「歌詞・スタイル・タイトルを作れ」とだけ言うと、出力の形が毎回変わる。今日は歌詞が先、明日はタイトルが先。今日は箇条書き、明日は散文。フロントエンドが受け取れない。
Mantisのplannerがplan.jsonを書くときの設計がここに対応する。plannerは各調査項目にtitle、target_files、questionという固定のフィールド名(名)を割り当て、内容だけを書き換える。下流のresearcherはplan全体を読まず、自分の担当項目だけを受け取る。
サンプルでは、システムプロンプトにスキーマを固定する。
SYSTEM = """You are a song prompt generator for a music player front-end.
Convert the user's free-form request into a structured song prompt with exactly 3 fields.
Respond with JSON only, no prose, matching this schema:
{
"title": "a short song title",
"style": "music style, genre, mood, tempo, and instruments",
"lyrics": "the song lyrics"
}
"""
フィールドの「名」はコード側が固定し、LLMには「内容」だけ埋めてもらう。これが分離だ。
原則2: 決定論ゲート(fail-closed)
ここが最重要だ。LLMは「生成しました」と言う。でも本当に3フィールド全部が揃っているかは、LLM自身に聞いてもわからない。自己申告は信用しない。
Mantisの設計思想にこうある。プロンプトは防御の最も弱い層だ。助言でしかなく、強制力がない。だから判断はPydanticスキーマやツールラッパーのような、決定論的なコードがやる。
サンプルではgate関数が、LLMの出力を検証する。
def gate(output):
text = output.strip()
if text.startswith("```"):
text = text.split("```", 2)[1]
if text.startswith("json"):
text = text[4:]
text = text.rsplit("```", 1)[0]
try:
obj = json.loads(text)
except Exception as e:
return False, None, f"JSON として読めない: {e}"
if not isinstance(obj, dict):
return False, None, "JSON がオブジェクトでない"
for field in ("title", "style", "lyrics"):
v = obj.get(field)
if not isinstance(v, str) or not v.strip():
return False, obj, f"フィールド '{field}' が無い・空"
return True, obj, "3フィールド全部が非空"JSONとして読めるか、3フィールド全部が「存在して非空」か、をコードが検証する。fail-closedとは、検証が通らない限り成功扱いにしないことだ。
原則3: budget(リトライの上限)
LLMは確率的だ。同じプロンプトでも、たまに壊れた出力を返す。だからリトライする。でも無限にリトライすると、壊れた出力を返し続ける場合に永遠に止まる。
Mantisはbudget controllerで、トークン数・LLM呼び出し回数・グラフのステップ数に上限を設ける。サンプルでは試行回数を3回に固定する。
MAX_RETRIES = 2 # budget: 初回1回 + 再実行最大2回 = 3試行3回試してもゲートを通らなければ、諦めてabortする。待機を繰り返さない。
原則4: exit codeが権威
Mantisの成功判定はexit codeが権威だ。ログの文字列を信用しない。失敗行にも「Pipeline completed」という部分文字列が含まれる罠があるため、ログのgrepで成功を判定すると失敗を成功と誤読する。
サンプルでは、exit codeを4段階で使う。
0 = 成功(ゲート通過・JSON出力済み)
1 = ゲート失敗でabort(3試行しても通らない)
3 = 引数不足でabort
呼び出し側はexit codeだけで成功・失敗を判定できる。ログの散文を読む必要がない。
実機で動かす
実際に動かした。ユーザプロンプトはこれだ。
「雨の夜に聴きたい、少し寂しいけど前を向けるようなピアノのバラードを作りたい。都市のネオンがテーマ」
出力はこうだった。
[RUN ] 試行 1/3
[GATE] PASS — 3フィールド全部が非空
{
"title": "Neon Rain",
"style": "emotional piano ballad, gentle, bittersweet yet hopeful, slow tempo, solo piano, soft rain ambience, subtle strings, urban night atmosphere",
"lyrics": "[Verse 1] 雨粒が窓を叩く ネオンの光が揺れてる…"
}試行1回でゲートが通過し、exit code 0で終了した。自由書式の文章が、フロントエンドが受け取れる3フィールドに転換された。
ゲートが実際に拒否する場面
ゲートの価値は、正常なときではなく壊れたときに現れる。gate関数に壊れた出力を直接渡して検証した。
正常な3フィールド → PASS
lyricsが空文字 → FAIL(「フィールド 'lyrics' が無い・空」)
lyricsが欠落 → FAIL
JSONでない散文(「生成しました!タイトルはAです」)→ FAIL(「JSON として読めない」)
コードフェンス(```json)付きの正常JSON → 剥げてPASS
「lyricsが空文字」のケースが核心だ。LLMが「生成しました」と言っていても、lyricsが空ならゲートはFAILを返す。自己申告を信用しない、という原則が、ここで初めて意味を持つ。
まとめ
Mantisから学んだのは、脆弱性スキャンの知識ではなく、LLMツールの作り方だった。
名と内容を分離する。フィールド名はコードが固定し、LLMは内容だけ埋める。
決定論ゲートを挟む。LLMの自己申告を信用せず、コードが検証する。
budgetでリトライに上限を設ける。無限待機をしない。
exit codeを権威にする。ログの散文を信用しない。
この4つを揃えると、LLMの確率的な出力を、フロントエンドが安心して受け取れる構造化データに変換できる。プロンプトの巧みさではなく、境界での強制が、LLMツールの確実性を決める。
