newmo 技術ブログ

技術で地域をカラフルに

LLMの安定した出力のために - Prompt Linterの活用

newmoでは様々なタクシー業務の内製化・効率化(DX)に取り組んでいます。

タクシーはアプリを利用して呼ぶこともできますが、電話による配車も昔からのお客さまや、事業者の方などから根強い人気があります。

私はるふ(@_ha1f)の所属するMaido(Mobility AI Dispatch Operator)チームでは、この電話による配車の業務効率化のため、一部でAIによる電話応答・配車システムを開発し、実際に運用しています。

取り組みについて詳しくはこちらをご参照ください。

note.com

本稿では、「Prompt Linter」と呼んでいるAI電話配車システムのプロンプト自体の品質管理に関する取り組みついてご紹介します。

AI電話配車システムの設計

LLMを使ったシステムを設計する際、大きく分けて2つのアプローチがあります。

  • すべての判断と処理をLLMに任せる方法
    • LLMが会話を理解し、判断し、配車リクエストまで実行する。エンドツーエンドでLLMが処理を担います。
  • LLMの役割を限定する方法
    • 伝統的なプログラムがオーケストレーターとなり、必要な部分だけLLMに依頼します。LLMは構造化されたデータを返し、その後の処理はシステムが決定論的に実行します。
flowchart TB
   subgraph approach2["アプローチ2: システムがLLMを呼び出す"]
        direction TB
        P2[システム] -->|"task"| L2[LLM]
        L2 -->|"構造化データ"| P2
        P2 --> S4[配車API]
        P2 --> S6[地図API]
        style P2 fill:#f9a825,stroke:#f57f17,stroke-width:3px
    end
    
    subgraph approach1["アプローチ1: LLMがシステムを呼び出す"]
        direction TB
        L1[LLM] -->|"tool call"| S1[配車API]
        L1 -->|"tool call"| S3[地図API]
        style L1 fill:#f9a825,stroke:#f57f17,stroke-width:3px
    end

    

私たちのAI電話配車システムでは、後者のアプローチを選択しました。配車システムのように確実な動作が求められる場面では、LLMの不確実性を限定的な範囲に閉じ込めたかったためです。

実装にはGoogleのAgent Development Kit (ADK)を利用しています。ADKはLLMを使ったエージェントを構築するためのフレームワークで、複数のエージェントを組み合わせたワークフローを定義できます。

具体的には、以下のようなステートマシンアーキテクチャを採用しています。

flowchart LR
  subgraph Sys[ステートマシン]
    subgraph Greeting["Greeting Agent"]
        direction TB
        G_L[LLM] -->|応答| G_U[ユーザー]
        G_U -->|発話| G_L
    end

    subgraph Location["Location Agent"]
        direction TB
        L_L[LLM] -->|応答| L_U[ユーザー]
        L_U -->|発話| L_L
    end

    subgraph Matching["Matching Agent"]
        direction TB
        M_L[LLM] -->|応答| M_U[ユーザー]
        M_U -->|発話| M_L
    end

    
    Greeting -->|遷移| Location
    Location -->|遷移| Matching
    Matching --> More[...]
    
  end
  
  Phone[電話着信] --> Sys
  
  style Phone fill:#4caf50,stroke:#388e3c

会話の各フェーズ(挨拶、場所の確認、配車マッチングなど)ごとに専用のエージェントを用意しています。各エージェントはお客さまとの対話を通じて必要な情報を収集し、遷移条件を満たしたら次のエージェントへ制御を渡します。

たとえばLocation Agentは、お客さまに乗車場所を尋ね、回答を解釈し、場所が特定できるまで対話を続けます。「駅前」と言われたら「どちらの駅でしょうか」と聞き返し、十分な情報が得られたら構造化されたJSONを出力して次のエージェントへ遷移します。

{
  "next_action": "request_ride",
  "pickup_location": "JR 大阪駅前"
}

各エージェントはLLMを使ってお客さまの発話を解釈しますが、状態遷移の判定や配車処理の実行は伝統的なプログラムが担います。LLMが直接配車APIを呼ぶわけではありません。

構造化出力が安定しなかった問題

このアーキテクチャを支えているのが、LLMの構造化出力機能です。伝統的なプログラムはこのJSONを解析して様々な処理を行うため、JSONでなかったり、JSONと平文が混合していたり、JSONの一部であったりするとエラーになってしまいます。

私たちも利用しているGeminiはAPIにJSONスキーマを指定することで、そのスキーマに従ったJSONを出力させることができます。

構造化出力  |  Gemini API  |  Google AI for Developers

ただ、実際に運用してみると、構造化出力が安定しないことがありました。

// JSONが途切れている
{
  "next_action": "request_ri
  
  
// JSONとそれ以外が混ざっている
どちらの駅でしょうか? ```json{
  "next_action": "continue_conversation"
}```

// JSONでない
配車を依頼します

原因として、GeminiやAPIの制約もあったのですが、プロンプトにも問題があることがわかりました。

安定した構造化出力のために

当初は会話の継続 or JSON出力という構成でした。

これを会話を継続する場合でもJSONの中に閉じ込め、常にJSONを出力するようにしました。

具体的には、対話に使うmessageをJSONのフィールドとして受け取っています。

{
  "next_action": "continue_conversation",
  "message": "どちらの駅でしょうか"
}

これにより、会話なら普通の文、確定したらJSONという分岐よりも安定するようになりました。

さらに見つかった問題として、上の変更をしたにも関わらずプロンプトに「完全な住所がわかってからJSON出力してください」という指示が残っていることがわかりました。

常に以下のschemaに従ったJSONを出力してください

(中略)

# Workflow
- 完全な住所がわかってからrequest_rideのJSON出力してください

これは決して「完全な住所がわからないならJSON出力をするな」ということではないですが、そういった誤解を招く表現であることに間違いはありません。

この指示を削除することで、より出力を安定化させることができました。

プロンプトの品質問題

プロンプトは自然言語で書かれます。プログラミング言語と違って構文エラーにはなりませんが、だからこそ曖昧さや矛盾が入り込みやすいという問題があります。

コンパイラは構文エラーを教えてくれますが、プロンプトの論理的な矛盾は誰も教えてくれません。そしてLLMは、矛盾したプロンプトを与えられても動いてしまいます。

ある程度の柔軟性をよしなに処理してくれるのはLLMの利点の一つではありますが、出力が不安定になるだけの曖昧性は排除するべきです。

先に述べたような矛盾は、プロンプトが長くなるほど発見が難しくなります。数百行のプロンプトの中で、離れた場所にある2つの指示が矛盾していることに気づくのは容易ではありません。

Prompt Linterという考え方

こうした問題は、人間のレビューでも見落としやすいものです。特にプロンプトが長くなってくると、全体を俯瞰して矛盾を発見するのは困難になります。

ソースコードであれば、Linterやコンパイラがこうした問題を検出してくれます。では、プロンプトに対しても同じことができないでしょうか。

そこで私たちは「Prompt Linter」というアプローチを試みました。

アイデアはシンプルです。LLMは自然言語の理解が得意なのだから、プロンプト自体のレビューもLLMにやらせればいいのではないか、ということです。

flowchart LR
  subgraph PL[Prompt Linter]
    direction LR
    Prompt[検証対象の<br>プロンプト] --> LLM[LLM]
    Rules[チェック<br>ルール] --> LLM
    LLM --> Report[レポート]
    Report -->|改善| Prompt
  end

Prompt Linterは、検証したいプロンプトと一緒に「こういう観点でチェックしてほしい」というルールをLLMに渡します。LLMは自然言語としてプロンプトを読み、問題がないかをチェックして、レポートを返します。

ESLintの設定ファイルに相当するのが「チェックルール」です。どのような問題を検出したいかを、自然言語で定義します。

※ プロンプト自体をLLMに書かせるアプローチはよく使われていると思います。私たちも活用はしていますが、構造自体も大きく変更する必要があったりして、完全に任せられる状態には至っていません。Prompt Linterを活用しつつ、手書きで改善しています。

チェックの観点

私たちは実際にこのようなチェックルールを作りました。(以下は一例です)

## 第一原則:LLMの正確な実行を実現する

### 明確性(Clarity)

- **曖昧さの排除**: 解釈の余地がない具体的な指示
- **判断基準の明示**: 数値や具体例を含む明確な基準
- **優先順位の明示**: 競合する要求がある場合の優先順位

### 一貫性(Consistency)

- **内部無矛盾**: 指示間で矛盾がない
- **用語の統一**: 同じ概念は同じ言葉で表現
- **形式の統一**: パターンや構造を一貫させる

(略)

## 第二原則:保守と拡張を可能にする

(略)

## 避けるべきアンチパターン

1. **過剰な例示**: 同じパターンの例を冗長に列挙
2. **強調表現の乱用**: CRITICAL、IMPORTANT、必ずなどの過度な使用
3. **暗黙の前提**: 文脈なしには理解できない指示
4. **相反する最適化**: 矛盾する要求の同時指定
5. **過度な抽象化**: 具体性を欠く指示(「適切に」「賢く」など)

当初は論理的な矛盾の検出が主な目的でしたが、考えてみるとそれだけでなく、プロンプトもソースコードと同じ特徴を持つのではないかと思い至りました。

ソースコードのレビューでは、単に「動くかどうか」だけでなく、保守性、拡張性、一貫性といった観点も重視されます。

プロンプトも同様に、将来の変更を見据えた品質が重要なはずです。

こういったメンテ性等もチェックルールに盛り込むことにしました。

Prompt Linterの運用をしたい!

私たちのチームでは多くのプロンプトをProductionで運用しています。少ない人数でこれらの品質を保証し続けるのはかなり難しいですが、Prompt Linterを使って明らかな論理矛盾や曖昧な表現を機械的に発見できるようになったことで、プロンプトの品質は確実に向上しました。

現在はルール自体も改善中で、まだ手元で適宜実行している段階ですが、将来的にはCIに組み込んで、プロンプトの変更があるPRでは自動的にチェックが走るようにしたいと考えています。ソースコードのLintと同じように、マージ前の品質チェックとして機能させるのが目標です。

私たちはLangfuseを使ってプロンプトのバージョン管理をしていますが、上記の理由からただバージョン管理するだけでなく、ソースコードと同じようなlint・レビューのような仕組みも将来的には広がっていくと考えています。特に、DSPyなどLLMが生成するプロンプトに対してもこういった品質管理は重要になっていくと感じています。

また、プロンプトには、プロンプト圧縮など、他にも解決すべき課題があります。こういったフォーマットも合わせていい感じに管理しやすくなるといいですね。

まとめ

LLMを活用したシステムを安定運用させるには、プロンプトの品質も重要です。 ソースコードに対して行うのと同じように、品質を担保する仕組みがあるべきではないでしょうか。

「Prompt Linter」は、LLMを使ってプロンプト自体をレビューするアプローチです。バージョン管理だけでなく、品質の継続的な維持を目指します。プロンプトエンジニアリングがますます重要になる中で、こうした仕組みの重要性も高まっていくのではないかと考えています。

AI電話配車チームは評価基盤についても、面白い取り組みを実施しています。良ければあわせて御覧ください。

tech.newmo.me

※ 記事中のJSONやプロンプトはサンプルであり、わかりやすく言い換えてあります