AIパイプラインのための言語を作った理由
← 全記事昨年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_format と response_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




