AIエージェントニュース編集部

BAMLでLLMのJSON出力を型安全に:スキーマ重複とトークン浪費を防ぐ

LLMにJSONを返させる実装で、スキーマをプロンプトにもZodやPydanticにも二重に書いていませんか。肥大したプロンプトでレイテンシが伸び、モデルが閉じ括弧を忘れるだけでパース例外が飛びます。BAMLは、型定義とプロンプトを専用DSLの1ファイルに集約し、TypeScriptやPythonのクライアントをコード生成します。本記事では、BAMLが重複とトークン浪費をどう減らし、壊れた出力をどうパースするかを、コード例とCI組み込みまで含めて整理します。

JSON ModeやInstructorが抱える「スキーマ重複」と「トークン浪費」の限界

構造化出力の定番手段には、ベンダーのJSON ModeやStructured Outputs、Pydanticベースの Instructor、TypeScript型を使う TypeChat、アプリ側のZodによる検証があります。どれも実用的です。ただし、スキーマが複数の場所に散らばりやすい点は共通しています。

典型的な構成を見ます。ZodでスキーマをTypeScriptに書き、zod-to-json-schema などでJSON Schemaに変換してAPIに渡し、プロンプト本文にも「次の形式で出力してください」と自然文で補足します。この3か所が少しでもずれると、フィールド名の揺れや必須項目の欠落として表面化します。しかも原因がスキーマ側にあるのか、プロンプト側にあるのか、モデル側にあるのかが切り分けにくくなります。

トークン面の問題もあります。JSON Schemaは type、properties、required、description といった構造用の記号を大量に含みます。ネストしたオブジェクトや列挙型が増えるほど、入力トークンは膨らみます。BAMLの公式ドキュメントとブログは、型を簡潔な擬似TypeScript風の記法でプロンプトに埋め込み、JSON Schemaより少ないトークンで済むと説明しています。削減率はスキーマの形とモデルのトークナイザに依存するため、自分のスキーマで ctx.output_format の出力を実測して判断してください。

また、プロバイダ側の厳格なスキーマ強制(制約付きデコーディング)は便利ですが、対応モデルや対応するスキーマ機能がベンダーごとに異なります。複数モデルを切り替える運用では、この差異がそのまま移植性の問題になります。

BAMLのアーキテクチャ:専用DSLからTypeScript/Pythonクライアントをコード生成する仕組み

BAMLはBoundaryMLが開発するオープンソースの言語兼ツールチェーンです。.baml ファイルに class、enum、function、client、test を宣言します。baml-cli generate を実行すると、baml_client ディレクトリに型付きクライアントが生成されます。出力先の言語は generator ブロックで指定します。

generator target {
  output_type "typescript"
  output_dir "../"
  version "x.y.z" // 利用中のbamlパッケージのバージョンに合わせる
}

実行時の流れは次のとおりです。アプリは生成された関数を呼び出し、BAMLランタイムがプロンプトをレンダリングします。続いて指定クライアントのLLMへリクエストを送り、返ってきた生テキストを宣言した型にパースして返します。ランタイムはRust製のコアで、各言語バインディングから共通に使われます。そのため、PythonでもTypeScriptでも同じパース挙動になります。

ここで重要なのは、LLM呼び出しが「型付き関数」としてコードベースに現れることです。呼び出し側は string を渡して Invoice を受け取るだけです。プロンプトの組み立て、プロバイダ差分、パースはDSL側に閉じ込められます。TypeChatがTypeScriptの型をそのままスキーマに使う発想を取ったのに対し、BAMLは言語非依存のDSLを間に挟みます。このためPythonとTypeScriptが混在するチームでも、スキーマの置き場を1つにできます。

一方で、Zodのような既存の実行時バリデータとは別の世界になります。既存のZodスキーマ資産は、BAML側の型へ書き写す移行コストが発生します。この点は導入前に見積もってください。

型定義とプロンプトを単一ファイルで管理:BAMLファイルの書き方とバリデーション

請求書テキストから金額や明細を抽出する例です。型、クライアント、関数、テストを1ファイルにまとめます。

enum Currency {
  JPY
  USD
  EUR
}

class LineItem {
  name string
  quantity int
  unit_price float
}

class Invoice {
  vendor string @description("請求元の会社名")
  currency Currency
  total_amount float @assert(non_negative, {{ this >= 0 }})
  items LineItem[]
}

client<llm> Primary {
  provider openai
  options {
    model "gpt-4o-mini"
    api_key env.OPENAI_API_KEY
  }
}

function ExtractInvoice(text: string) -> Invoice {
  client Primary
  prompt #"
    次のテキストから請求書情報を抽出してください。

    {{ ctx.output_format }}

    {{ _.role("user") }}
    {{ text }}
  "#
}

test BasicInvoice {
  functions [ExtractInvoice]
  args {
    text #"
      株式会社サンプル 御中 / 請求元: Example Corp
      Widget x3 @ 1200 JPY, Cable x1 @ 800 JPY
      合計 4400 JPY
    "#
  }
}

{{ ctx.output_format }} が、宣言した型から自動生成される出力形式の指示に置き換わります。スキーマを手でプロンプトへコピーする必要はありません。クラスにフィールドを足せば、プロンプトにも自動で反映されます。@description はモデルへのヒントとして出力形式の中に差し込まれます。

バリデーションは @assert と @check で書きます。@assert が失敗するとエラーになり、@check は失敗しても例外にせず、結果を付随情報として返します。「落とすべき制約」と「警告にとどめる制約」を型の定義側で分けられます。上の total_amount のように、値域チェックを宣言に近い場所に置けるのが利点です。

TypeScript側の呼び出しは次のとおりです。

import { b } from "./baml_client";

const invoice = await b.ExtractInvoice(rawText);
console.log(invoice.items.length, invoice.currency);

戻り値は生成された Invoice 型です。補完も型チェックも通常のTypeScriptと同じように効きます。モデル切り替えは client の宣言を差し替えるだけで済みます。フォールバックやリトライも client 側の設定(fallback、retry_policy)で宣言できます。詳細な書式は利用中バージョンの公式ドキュメントで確認してください。

部分抽出とストリーミング対応:壊れたJSONを修復しながらパースする堅牢性

BAMLのパース層は、公式に「スキーマ整合パース(Schema-Aligned Parsing)」と呼ばれています。厳密なJSONパーサに渡す方式ではなく、宣言した型をヒントに、緩い出力から値を取り出す方式です。公式ドキュメントでは、Markdownのコードフェンスで囲まれたJSON、末尾カンマ、クォートなしのキー、JSONの前後に付いた説明文などを許容する挙動が紹介されています。型に合わない値は、できる範囲で型へ寄せる動作もあります。たとえば数値が文字列で返ってきた場合などです。

この方式の利点は、JSON Modeやfunction callingに対応しないモデルでも使えることです。ローカルモデルや小型モデルでは、厳密なJSONを出させること自体が難しい場合があります。そうしたモデルでも、パース側が寛容になることで成功率を上げられます。ただし、修復はあくまで救済であり、意味的な正しさの保証ではありません。必須フィールドが欠落していれば、当然エラーになります。業務上重要な値には、@assert と後段のアプリ側検証を併用してください。

ストリーミングでは、未完成のJSONから部分的な型を取り出せます。

const stream = b.stream.ExtractInvoice(rawText);

for await (const partial of stream) {
  // partial.items は途中状態。要素やフィールドが未確定の場合がある
  console.log(partial.vendor, partial.items?.length);
}

const final = await stream.getFinalResponse();

partial では各フィールドが省略可能な型として扱われます。UIに「明細が1行ずつ増えていく」表示を作りたい場合に向いています。最終結果は getFinalResponse() で、宣言した完全な型として受け取ります。途中状態を業務ロジックに使わず、表示専用に限定するのが安全です。

VS Code拡張でのリアルタイムプレビューとCI組み込みによるスキーマ検証フロー

BAMLにはVS Code拡張があります。.baml ファイルのシンタックスハイライトに加え、Playgroundで関数を選んでテストケースを実行できます。拡張はレンダリング後のプロンプトを、実際にAPIへ送る前に確認できる点が特に有用です。「ctx.output_format が実際にどんな文字列になるか」「何トークン相当か」を目で見て調整できます。ここでスキーマ重複によるトークン浪費の有無を確認できます。プロンプトが肥大していないかは、この段階で実測するのが確実です。

CIでは、次の2段階を組み込むのが現実的です。

  1. baml-cli generate を実行し、git diff --exit-code で生成物とコミット内容のずれを検出する
  2. アプリ側の単体テストで、生成クライアントを使った関数にモック応答や録画済みの応答を与え、パース結果を検証する

1つ目は、.baml を変更したのにクライアントの再生成を忘れる事故を防ぎます。生成物をコミットしない運用なら、ビルド前に generate を走らせる手順をパイプラインに入れます。2つ目は、LLMの実呼び出しを避けて、決定的にテストするためです。.baml 内の test ブロックを実APIで走らせる運用もありますが、コストと非決定性があります。そのため、PRごとに回すか夜間バッチに寄せるかは、予算と許容できる不安定さで決めてください。CLIからテストを実行する方法は、バージョンごとにコマンド体系が変わる可能性があります。導入時に baml-cli --help で確認してください。

導入の最初の一歩としては、既存コードのうち「Zodスキーマ+長いプロンプト+リトライ処理」が最も複雑な1関数を選び、BAMLに移植するのが現実的です。移植前後でプロンプトのトークン数、パース失敗率、行数を比べれば、チーム全体へ広げるかどうかを実測値で判断できます。

関連記事