powered by TechFeed
表示モード
Deep Dive

プロンプトの変数名を1文字変えただけで本番が壊れる — CI上で静的解析する`promptctl`が解決する「プロンプト管理」の盲点

7月30日、Emmimalが「Prompt Engineering Is Solved—Prompt Management Isn't」と題した記事を公開した。この記事では、プロンプトテンプレートの変数名変更が本番環境でサイレントに障害を引き起こす問題と、それをCI上で静的解析によって防ぐツールpromptctlの設計・実装について詳しく紹介されている。以下に、その内容を紹介する。

7月30日、Emmimalが「Prompt Engineering Is Solved—Prompt Management Isn't」と題した記事を公開した。この記事では、プロンプトテンプレートの変数名変更が本番環境でサイレントに障害を引き起こす問題と、それをCI上で静的解析によって防ぐツールpromptctlの設計・実装について詳しく紹介されている。以下に、その内容を紹介する。


変数名を1文字変えただけで本番が壊れる

プロンプトテンプレート内の変数{ticket}{ticket_id}にリネームしたとする。Gitのdiffはクリーン、ユニットテストはパス(LLMクライアントをモックしているので.format()は実行されない)、コードレビューも通過、デプロイも完了。

そして本番に出た瞬間、ticket=を渡し続けているすべての呼び出し箇所がKeyErrorで落ちる。

Pythonにとってプロンプトテンプレートは「ただの文字列」だ。文字列にはコントラクト(インターフェース定義)がない。リンター、型チェッカー、テストランナーのいずれも、テンプレートの呼び出し元が期待するプレースホルダーと一致しているかを検証しない。これはバグではなく、現在のPythonエコシステムにおけるプロンプト管理のギャップそのものだ。

記事の著者Emmimalはこの問題を「仮説」ではなく実際のテストリポジトリ(プロンプト1つ、呼び出し元3つ)を作って再現し、静的解析ツールpromptctlを実装した。


promptctl:3パス、ゼロ依存の静的解析ツール

promptctlはLLM呼び出しもAPIキーも不要で、Pythonの組み込みモジュール(aststring.Formatter、集合演算)だけで動作する。外部ネットワーク通信は一切ない。

CIパイプラインとの統合を前提に設計されており、promptctl checkは問題があれば終了コード1、問題がなければ0を返す。

Pass 1:PromptDiff — 何が変わったか

_PROMPTで終わる文字列定数をASTで検出し、string.Formatter().parse()で変数名を抽出。ベースライン(前回レビュー済みのJSON)と集合演算で差分を取る。

def _extract_prompt_vars(source: str) -> Dict[str, Set[str]]:
    tree = ast.parse(source)
    found: Dict[str, Set[str]] = {}
    for node in tree.body:
        if not isinstance(node, ast.Assign):
            continue
        if not (isinstance(node.value, ast.Constant)
                and isinstance(node.value.value, str)):
            continue
        for target in node.targets:
            if isinstance(target, ast.Name) and target.id.endswith("_PROMPT"):
                template = node.value.value
                variables = {
                    name for _, name, _, _ in
                    string.Formatter().parse(template) if name
                }
                found[target.id] = variables
    return found

テンプレートを実行しないため、処理は数ミリ秒以内で完了する。

{ticket}{ticket_id}の変更を加えた後の実際の出力:

$ promptctl diff
Prompt: customer_router
Contract Diff
Removed: {ticket}
Added:   {ticket_id}
Breaking Change: YES

ブレーキング変更の判定ロジックはシンプルだ。「変数の追加」は既存の呼び出し元を壊さない。「変数の削除」が起きたとき(リネームは実質的に削除+追加)に既存呼び出し元がKeyErrorを投げる。

@property
def is_breaking(self) -> bool:
    return len(self.removed_vars) > 0

Pass 2:Contract Validation — 誰が壊れているか

変更の検出だけでは不十分だ。次のパスは「どの呼び出し元が壊れているか」を特定する。リポジトリ内の全.pyファイルをast.walkでスキャンし、SOME_PROMPT.format(...)パターンのキーワード引数と、更新後のプロンプトが要求する変数を照合する。

for node in ast.walk(tree):
    if not (isinstance(node, ast.Call)
            and isinstance(node.func, ast.Attribute)):
        continue
    if node.func.attr != "format":
        continue
    ...
    missing = required_vars - provided_vars

呼び出し元ごとに「提供している変数」「不足している変数」「ステータス」が一覧で出力される。

Pass 3:Impact Analysis — 影響範囲のトレース

3つ目のパスは、指定したプロンプトシンボルをリポジトリ全体からast.walkでトレースし、参照箇所を列挙する。変更前に「どのファイルが影響を受けるか」を把握するための入口として機能する。


「プロンプト管理」と「プロンプトリポジトリ」は別問題

記事が強調するのは、これが変更管理の問題であり、リポジトリ整理の問題ではないという点だ。

プロンプトをフォルダに整理して命名規則を守るのはCRUD作業に過ぎない。大半のチームはそれをすでにうまくやっている。壊れるのは「変更時」だ——何が変わったか、下流の呼び出し元が新しいコントラクトを満たしているか、そのPRをマージしても安全かどうか。

プロンプトが300個あるエージェントネットワークでなくても、プロンプト1つ・呼び出し元1つ・未検証の編集1回があれば同じ問題が発生する。

また、記事はツールの対象外を明示している点が実用的だ:

  • プロンプトに呼び出し元が1つしかない場合:コントラクト違反は通常のバグであり、クロスファイルの協調問題ではない
  • プロンプトがデータベースやCMSに格納されている場合:ファイルツリーへの静的解析は機能しない
  • プロンプトの品質評価をしたい場合:このツールは「文章がうまく書けているか」を一切見ない

ベースラインはJSON一枚のチェックインファイルで管理する。外部DBもヒストリーサービスも不要で、更新には意図的なコミットが必要なため変更ログが自動的に残る。


なぜ今この問題か

LLMエコシステムにはプロンプトレジストリ、評価フレームワーク、トレーシングプラットフォームが溢れている。しかしPRレビュー時の最も基本的な問いに答えるツールが欠落していた——「このプロンプトの変更は、現在呼び出しているコードを壊しているか?」

TerraformはHCLのシンタックスをterraform validateで検証し、KubernetesマニフェストはJSON Schemaで検証する。Pythonコードはruffmypyでチェックする。プロンプトテンプレートだけがその検証フローから外れていた。

ソースコードはGitHubで公開されている。

詳細はPrompt Engineering Is Solved—Prompt Management Isn'tを参照していただきたい。