コンテンツへスキップ
AIパイプラインのための言語を作った理由

AIパイプラインのための言語を作った理由

← 全記事
この記事で紹介した製品
Tracker — DOT ファイルから DAG ワークフローを実行するパイプラインオーケストレーションエンジン。人間のゲート、LLM ノード、自動チェックポイントに対応。
Dippin — AIパイプラインワークフローを記述するためのドメイン固有言語 — プロンプト、モデル、分岐に型付き構文を提供し、DOTでは表現できないことを実現。

昨年3月、エンジニアの一人が壊れたパイプラインのデバッグに40分を費やしました。原因はDOTファイルのバックスラッシュ一文字の欠落でした。その文字は、次のような文字列の中に埋まっていました。

tool_command="set -eu\nmkdir -p .ai .ai/drafts .ai/sprints\nif [ ! -f
.ai/ledger.tsv ]; then\n  now=$(date -u +%Y-%m-%dT%H:%M:%SZ)\n  printf
'sprint_id\\ttitle\\tstatus\\tcreated_at\\tupdated_at\\n001\\tBootstrap
sprint\\tplanned\\t%s\\t%s\\n' \"$now\" \"$now\" > .ai/ledger.tsv\nfi\n
printf 'ledger-ready'"

これはシェルスクリプトです。bashで6行:ディレクトリを作り、TSVヘッダーがなければ書き込み、ステータスメッセージを出力する。珍しいことは何もありません。しかしDOT属性の中に入れると、改行は \n、タブは \\t、クォートは \" に変わります。スクリプトはそこにあるのに、読めない。自信を持って編集できない。

私たちはTracker——AIパイプラインのオーケストレーションシステム——を開発しています。Trackerは、LLMエージェント・ツール呼び出し・人間のレビュアーが複雑なタスク(コードレビュー、スプリント実行、API設計)に協働する多段階ワークフローを実行します。これらのパイプラインは有向グラフです。プロンプトとモデルを持つノード、条件付きエッジ、リトライループ、並列ブランチ。

私たちはそれをGraphviz DOTで定義していました。

パイプラインが小さいうちはDOTで十分でした。5ノード、シンプルなエッジ、短いプロンプト。しかしパイプラインは成長しました。マルチモデルコンセンサスを持つ20ノードのワークフロー。テストスイートを実行するシェルスクリプト。マークダウンやJSONスキーマを埋め込んだシステムプロンプト。記述フォーマットは「透明な存在」ではなくなり、最も手を焼く相手になっていました。

プロンプトを書くよりも、エスケープ文字のデバッグに時間を取られるようになっていたのです。

DOTが提供できなかったもの

エスケープ文字列の問題は最も目立つ痛点でしたが、唯一の問題ではありませんでした。

DOTはグラフ記述言語です。ノード、エッジ、属性について知っています。しかしAIパイプラインが何であるかは知りません。claude-sonnet-4-6 が有効なモデル名で、claude-sonet-4-6 がタイプミスであるかどうかについて、言語は何も言いません。あるノードが到達不能であること、リトライループに終了条件がないこと、ツールコマンドが存在しないバイナリを参照していること——これらも教えてくれません。こうした問題は本番環境で、パイプラインが失敗したときに発覚します。あるいはもっと悪く、微妙に間違った出力を出し続けます。

パイプラインのテストも別の問題でした。LLM呼び出しは非決定論的なので、その出力をアサートできません。しかし実行の形状はアサートできます:どのノードが訪問されたか、どの順番で、どのブランチが取られたか。それが必要でした。DOTにはそういう概念がありませんでした。

コストの問題もありました。3つのLLMプロバイダーにファンアウトするパイプラインは、3セットのAPI呼び出しを実行します。合計を見積もるには、誰かが手作業でプロンプトトークンを数え、料金表を調べる必要がありました。20のパイプラインに対してそれはスケールしません。

フォーマットから言語へ

バリデーションレイヤーを載せたYAMLスキーマを書くこともできました。しかしバリデーションはエラーを捕まえるだけです——スタイルを正規化するフォーマッター、実行パスを歩くシミュレーター、プロンプトトークンを読むコスト見積もり、エディター上で診断を表示するLSPは手に入りません。そのすべてには、ツールチェーン全体が共有できる型付きデータモデルを生成する文法とパーサーが必要です。

同じシェルスクリプトをDippinで書くと、こうなります。

tool EnsureLedger
  label: "Ensure Ledger"
  command:
    set -eu
    mkdir -p .ai .ai/drafts .ai/sprints
    if [ ! -f .ai/ledger.tsv ]; then
      now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
      printf 'sprint_id\ttitle\tstatus\tcreated_at\tupdated_at\n001\tBootstrap sprint\tplanned\t%s\t%s\n' "$now" "$now" > .ai/ledger.tsv
    fi
    printf 'ledger-ready'

コロンの後にインデントしてスクリプトを書くだけです。エスケープもクォートも \n も不要です。プロンプトにも同じルールが適用されます:ヘッダー、箇条書き、埋め込みコードブロック、JSONの例を含む複数行のマークダウンを、ドキュメントに書くように書けます。

完全なパイプラインの例を示します。ドキュメントが下書きされ、レビューされ、公開されるか、修正のために戻されます。

workflow ReviewPipeline
  goal: "Draft, review, and publish a document"
  start: Start
  exit: Exit

  defaults
    provider: anthropic
    model: claude-sonnet-4-6

  agent Draft
    label: "Write Draft"
    prompt:
      Write a clear, concise technical document based on the
      provided requirements. Focus on accuracy and readability.

  agent Review
    label: "Review Draft"
    auto_status: true
    prompt:
      Review the draft for accuracy, clarity, and completeness.
      Return success if it meets standards, or fail with feedback.

  agent Publish
    label: Publish

  edges
    Start -> Draft
    Draft -> Review
    Review -> Publish  when ctx.outcome == "success"
    Review -> Draft    when ctx.outcome == "fail"
    Publish -> Exit

条件付きエッジは意図をそのまま表しています:レビューが通れば公開、失敗すれば下書きに戻る。

言語があってこそのツーリング

設定フォーマットはデータを保存します。言語には、クエリ・チェック・変換できる構造があります。

Dippinには39の診断チェックが含まれています。9つは構造エラーを捕まえます:存在しないノードを参照している、出力エッジのないstartノードを宣言しているなど。30はセマンティックな問題を捕まえます:未知のモデル名、タイムアウトのないツールコマンド、名前空間プレフィックスなしに変数を参照している条件など。すべての診断にはコード・説明・修正提案があります。dippin explain DIP108 を実行すると、何が問題でどう直すかを教えてくれます。

Dippinのシナリオテストでは、コンテキスト値を注入して実行パスをアサートできます。どのノードが訪問されたか、されなかったかが分かります。テストは決定論的です——基盤となるLLM呼び出しが非決定論的でも関係ありません。私たちのCIはプッシュのたびに dippin check を実行しており、壊れたパイプラインは本番に届く前にビルドを失敗させます。

次にコスト見積もりです。dippin cost はプロンプトトークンを数え、モデルごとの料金を適用し、リトライループを考慮します。

$ dippin cost complexity_cleanup.dip
═══ Cost Estimate ═════════════════════════════════════════
                                Min Expected      Max
  ──────────────────────── ──────── ──────── ────────
  TOTAL                       $0.65    $0.65    $2.66

dippin optimize は、より安価なモデルで同じ仕事ができる箇所を提案します。私たちのコードレビューパイプラインは、その提案に従った結果、期待コストが$0.65から$0.02に下がりました。

LSPサーバーは入力中にエラーを検出します。セマンティックdiffツールは生のテキストdiffではなく「このノードでモデルがopusからsonnetに変更されました」と報告します。マイグレーションツールは既存のDOTファイルを構造的同等性の検証付きで変換します。WASMプレイグラウンド、ファイルウォッチャー、シンタックスハイライトも備えています。

実際の現場で

パイプラインの作者は、文字列のエスケープではなくロジックに集中できます。新しいチームメンバーが .dip ファイルを読めば、説明なしにワークフローを理解できます。

誰かが変更をプッシュすると、CIは構造を検証し、セマンティクスをチェックし、コスト差分を見積もります。変更は通るか通らないか、それだけです。

TrackerとDippin言語のフィードバックループは密接です。先週、TrackerはLLM APIに構造化JSONを返すよう強制する必要がありました。3つのプロバイダーすべてがこれをサポートしていますが、それぞれ有効化するために特定のAPIパラメーターが必要です。

DOTでこれを追加しようとすれば、属性の規約を発明し、どこかにドキュメント化し、人々が正しく使ってくれることを祈るだけでした。Dippinでは、response_formatresponse_schema をファーストクラスフィールドとして追加し、ミスを捕まえる4つのlintルールを加えました。Trackerアダプターは自動的にそれらを拾い上げました。

すべてが同じ型付きモデルを読むので、response_format を追加すると、リンター・フォーマッター・コスト見積もり・LSPがすべて即座に理解しました。文法を一箇所変えるだけで、すべてのツールが追いつく。

サブグラフコンポジション

問題

パイプラインは繰り返します。質問を生成し、回答を収集し、完全性を評価する3ステップのインタビューループは、API設計ワークフロー・オンボーディングフロー・要件収集に繰り返し登場します。コンポジションがなければ、そのパターンが必要なすべてのワークフローに同じノードとエッジをコピーします。パターンが変わると5箇所を更新して6箇所目を見落とします。

サブグラフの仕組み

サブグラフノードは、あるワークフローを別のワークフローの中に埋め込みます。グラフ内の他のノードと同じように見えます——ラベルを持ち、エッジで他のノードに接続し、リトライロジックや条件付きルーティングに参加します。しかしLLM呼び出しやシェルコマンドを実行する代わりに、別の .dip ファイルを参照します。

subgraph Interview
  label: "Requirements interview"
  ref: interview_loop.dip
  writes: requirements_summary
  params:
    topic: "API design"
    focus: "resources, auth, consumers, scale"

ref はワークフローファイルを指します。params にはキーと値のペアを渡します。interview_loop.dip の中では、それらの値が ${params.topic}${params.focus} として利用できます——コンテキスト変数で使われるのと同じ補間構文ですが、親と子のワークフローが干渉しないよう専用の名前空間に入っています。

参照されるワークフローは、完全な自己完結型の .dip ファイルです。独自のstartノード・exitノード・エッジ・ノード定義を持ちます。独立して検証・lint・フォーマット・コスト計算できます。埋め込まれていることを知らず、気にしません——単なるワークフローです。

ツールチェーンへの影響

サブグラフノードは、親ワークフローのツールチェーンパスに対して不透明です。dippin lint が親に対して実行されると、参照ファイルがディスクに存在するか(DIP126)をチェックし、2つのサブグラフノードが同じファイルを参照している場合(DIP109、名前空間衝突のリスク)に警告します。サブグラフをインライン展開はしません。子ワークフローは、dippin lint を直接実行したときに独自のlintパスを受けます。

シミュレーターはサブグラフをアトミックなステップとして扱います。ノードに入り出たことを記録し、refパスをログに記録し、次のエッジに進みます。子ワークフローに再帰しません。これは意図的な設計です:シミュレーターは完全に展開された実行ツリーではなく、親パイプラインの制御フロートレースを提供します。ランタイム展開はオーケストレーターの仕事です。

フォーマッターはサブグラフフィールドを固定順序(label、ref、params)で出力し、paramキーをアルファベット順にソートします。これによりdiffが整理され、ラウンドトリップが決定論的になります。

コスト見積もりは親ワークフローのノードを合算します。子ワークフローのコストは dippin cost を実行したときに個別に見積もります。これによりコストレポートはファイル単位にスコープされ、チームが予算を考える方法と一致します:「このワークフロー全体でいくらか?」ではなく「このワークフローはいくらか?」という問いに答えます。

なぜインライン展開しないのか

初期設計ではパース時にサブグラフをインライン化していました——パーサーが参照ファイルを読み込み、衝突を避けるためにノードIDにプレフィックスを付け、ノードとエッジを親グラフに継ぎ込む方式です。概念的にはシンプルでしたが、問題が生じました。

  • lintの診断が、展開後のグラフの行番号を指すため、作者が編集できるファイルのどこにも対応していませんでした。
  • 同じサブグラフが2回現れると、コスト見積もりが2倍になりました。
  • フォーマッターはインライン化されたグラフを元の2ファイル構造に戻せませんでした。
  • エラーメッセージが混乱を招きました:「node Interview_Assess has no fallback」は、作者が interview_loop.dip で「Assess」と命名していれば意味をなしません。

IRレベルでサブグラフを不透明に保つことで、これらすべてを回避できます。各ファイルは自己完結したユニットです。ツーリングはファイル単位で動作します。展開はランタイムが担います。

ランタイムとの契約

Dippinはサブグラフ——ref、params、そこへのエッジとそこからのエッジ——を定義します。ランタイム(私たちの場合はTracker)は、参照ファイルを読み込み、paramsを代入し、子ワークフローを親パイプラインの一部として実行する責任を持ちます。IRはランタイムが必要なすべてを提供します。

cfg := node.Config.(ir.SubgraphConfig)
cfg.Ref    // "interview_loop.dip"
cfg.Params // {"topic": "API design", "focus": "resources, auth, ..."}

アダプターはこれらのフィールドを読み取り、パイプラインエンジンに渡します。特別なプロトコルも登録ステップも不要です。ファイルが存在してパースできれば、実行されます。

実際の例

api_design.dip はOpenAPI仕様・SDKサンプル・エラーカタログを生成する20ノードのパイプラインです。そのステップのひとつに要件インタビューがあります。インタビューロジック(質問生成・回答収集・評価・不完全なら繰り返し)を埋め込む代わりに、interview_loop.dip を参照します。

subgraph Interview
  label: "Requirements interview"
  ref: interview_loop.dip
  writes: requirements_summary
  params:
    topic: "API design"
    focus: "resources, auth, consumers, scale, integrations, real-time needs"

interview_loop.dip はトピックとフォーカスエリアによってパラメーター化されています。同じファイルをユーザーリサーチワークフロー・オンボーディングパイプライン・サポートトリアージフローから参照できます——それぞれ異なるparamsを渡すだけです。インタビューロジックはひとつの場所に存在します。

インタビューパターンが変わった場合——たとえば評価ステップに信頼度スコアを追加したとき——interview_loop.dip を一度更新します。それを参照するすべてのワークフローは、次回の実行時に変更を反映します。

サブグラフがまだできないこと

サブグラフはフラットな文字列paramsを持つファイルベースの参照です。モジュールレジストリ・バージョンピニング・型チェック済みパラメーター契約はありません。DIP109は名前空間の衝突を警告しますが防止はしません。再帰的サブグラフ(自身を参照するサブグラフ)は検出も禁止もされておらず——ランタイムはループするでしょう。

これらは本物の制限です。しかしプロジェクトの現状にとって正しいトレードオフでもあります。ファイルベースのアプローチは、パッケージシステムを発明せずに標準ツール——エディター・git・CI——で動作します。制限が問題になったとき、対処します。今のところそうなっていません。

試してみる

Dippinはオープンソースです。

エスケープ文字列が時間を食い、サイレントなパイプラインエラーが自信を蝕んでいたから作りました。条件付きルーティング・人間のチェックポイント・ツール呼び出し・リトライロジックを持つ多段階LLMワークフローを定義しているなら、同じ悩みを解消できるかもしれません。

go install github.com/2389-research/dippin-lang/cmd/dippin@latest

他の記事

14 ページ · hugo 0.148.2 · 1ee4278 · 構築済み Aug 10 22:58
2389 Radio
2389 RADIO ステーションを選択