🥐

プロンプトの読み込み・レンダリングを標準化する要件と設計

に公開

0. はじめに

PoCフェーズを経て、いざ本番環境でLLMを用いた処理を実装する際に、prompt_util.pyのようなプロンプトの読み込みやレンダリングの実装が散在することはないでしょうか。

ELYZAのSolution事業部では、プロンプトの読み込み・レンダリングのための似て非なるボイラープレート再生産を脱するべく、標準化した社内ライブラリを開発しています。本記事では、その取り組みにおける要件・設計及び、それに付随する設計判断についてご紹介します。コード内でのプロンプトの扱いに課題を感じている方の参考になれば幸いです。

なお、本記事に関係する開発は、曽我部(@sog4be)、堤(@ozro_223)らで取り組み、代表して曽我部が執筆しました。

1. TL;DR

複数のプロジェクトで再利用できる、プロンプトの読み込みとレンダリングのみに焦点を当てた社内ライブラリを開発しました。要点は以下のとおりです。

  • 問題意識:各プロジェクトで似て非なるプロンプト処理の実装が散在し、メンテナンスコストが増大していた
  • 設計思想:TOMLとJinja2を採用しつつ、プラグインアーキテクチャで将来の拡張にも対応。Facadeパターンでシンプルなインターフェースを提供
  • 安全性重視:fail-closeの原則による厳格なバリデーション、機密情報を含む可能性があるデータをログに出力しない設計
  • 型安全性:Pydanticモデルとの統合により、IDEの補完とバリデーションが効く型安全なコンテキスト管理を実現
  • 成果PromptTemplate.from_path()で読み込み、render()でレンダリングという2ステップで完結するシンプルな使用感を達成

2. 背景:塵も積もれば山となる、似て非なるボイラープレート

社内の様々なプロジェクトで、プロンプトにデータを埋め込んでLLMにリクエストを投げる実装が必要になる場面が増え、似たような実装が散在している状況が続いていました。

一つひとつのプロジェクトで見れば、プロンプトの読み込みやレンダリングは比較的小さな実装です。個別に時間を確保して作り込むほどの優先度ではないため、各プロジェクトでは必要最小限の機能だけが実装されていきました。結果として、以下のような設計がプロジェクトごとにバラバラになっていったのです:

  • ファイル形式(YAML / JSON / TOML / 独自形式)
  • プロンプトの定義方法
  • バージョン管理
  • エラーハンドリング

個別最適と全体最適のギャップ

個々のプロジェクトの視点に立てば、YAGNIの原則に沿った判断と言えます。しかし、組織全体で見ると話は変わります。似て非なるボイラープレートが増え続け、テストやメンテナンスがそれぞれ個別に必要となり、全体としての開発効率が下がっていました。

仮に20個のプロジェクトでそれぞれ実装・テスト・レビューに2時間をかけるとすると、合計で40時間の工数が発生します。さらに、似て非なる実装を読み解くコストが各プロジェクトの運用期間中ずっと継続的に発生していくことを考えれば、実際の損失はさらに大きなものになります。

標準化への決断

そこで私たちは、改めて要件を整理し直し、社内の複数プロジェクトで使い回せる標準化したライブラリを整備することにしました。この決断を後押ししたのは、最近のAI・AI開発ツールの進化です。OpenAIのo3やClaude Codeなどの登場により、このような社内ライブラリの開発コストが以前より大幅に下がり、以前は時間的制約で後回しにされていたタスクにも現実的に取り組めるようになったことが、このプロジェクトの実現を可能にしました。

3. 社内のプロジェクトで採用されるために必要なものは何か?

ライブラリが実際に使われるためには、導入コストを最小限に抑えつつ、十分な機能と安全性を提供する必要があります。社内プロジェクトの実情を踏まえて要件定義を行いましたが、ここではその中から主要なものを抜粋してご紹介します。

3.1 機能要件

プロンプトの定義形式

様々なLLMベンダーのAPIに対応できることを必須としました。社内では複数のLLMプロバイダーを使い分けることが多く、その度にプロンプトの形式を変更するのは非効率です。そこで、LiteLLMで採用されているインターフェースに寄せることで、OpenAI、Anthropic、Googleなど異なるベンダー間でも同じプロンプト定義を利用しやすくしました。

ファイル形式

TOMLを採用しました。以前公開した下記のテックブログでも触れたように、TOMLは改行やインデントの扱いが直感的で、複数行にわたるプロンプトの可読性が高いという特徴があります。また、コメント記述が容易であることから、プロンプトの意図やバージョン情報をファイル内に残しやすく、プロンプト管理との相性が良いと判断しました。

LLMを本番品質に育てる PromptOps:”100回の試行錯誤”を支えた仕組みと文化

3.2 非機能要件

fail-closeの原則(必須)

プロンプト定義ファイルの記述ミスやデータ受け渡し時のバグには適切なエラーを出すこととし、意図しないfail-open(エラーを無視して処理を継続してしまうこと)は避けるようにします。これにより、誤ったプロンプトがLLMに送信されることを防止します。

セキュリティへの配慮(必須)

プロンプトやそれに埋め込むデータには個人情報や機密情報が含まれることがあるため、エラー発生時のログにこれらの中身を出力しないことを徹底します。

拡張性の確保(推奨)

現時点ではTOMLとJinja2を採用していますが、将来的にJSONやYAMLを使いたいプロジェクトや、Jinja2以外のテンプレートエンジンを使いたいケースにも対応できるよう設計します。後方互換性を保ったまま新機能を追加できる構造を目指しました。

構造化された例外設計(推奨)

処理のフェーズ(ファイル読み込み、パース、バリデーション、レンダリング)に沿って例外を階層化することで、デバッグを容易にします。データの中身を隠蔽しつつも、エラーの種類や発生箇所は明確に示すことで、セキュリティと開発効率のバランスを取ることを意図しています。

4. 設計と判断

4.1 結論

現時点では下記のような設計に落ち着きました。(詳細は割愛します。)

アーキテクチャ図:

  • Core / Infra / Domain の3層構造で分離
  • ユーザーにはPromptTemplateというFacadeのみを公開
  • Renderer / Loader は将来的な拡張のために抽象化

シーケンス図:

4.2 設計判断

上記の設計に至るまでに検討した、主要な設計上の判断について説明します。

① プラグインアーキテクチャの採用

現時点ではJinja2を採用していますが、テンプレートエンジンは将来的に変わる可能性があります。一方で、不要な依存関係を増やさずライブラリを軽量に保ちたいという要求もありました。

そこで、エントリーポイントの仕組みを用いてRendererを動的に探索する設計としました。これにより、ライブラリ本体に変更を加えることなく、ユーザーが独自のRendererを追加できるようになります。(実装の例はAppendixに記載しています。)

② fail-closeの原則による安全性の確保

プロンプト定義ファイルの記述ミスやデータ受け渡し時のバグを早期に発見するため、fail-closeの原則を採用しました。

実装例:

  • Pydanticによる厳格なバリデーション - プロンプトのデータクラスはPydanticのBaseModelで定義し、読み込み時に自動的に検証します

    from pydantic import BaseModel, field_validator
    
    class PromptConfig(BaseModel):
        version: str
        messages: list[Message]
    
        @field_validator('version')
        def validate_version(cls, v):
            if not v.startswith('v'):
                raise VersionMismatchError(f"Invalid version format: {v}")
            return v
    
  • 未定義変数の柔軟な制御 - エラー/警告/何もしない の3段階から選択可能(PoC段階でも使えるよう柔軟性を確保)

    # strictモード(本番推奨):未定義変数でエラー
    strict_template = PromptTemplate.from_path(path)
    
    # debugモード(開発時):未定義変数を明示的に表示
    debug_template = PromptTemplate.from_dict({
        "engine_options": {"undefined": "debug"},
        # ...
    })
    
    # allowモード(PoC時):未定義変数を空文字列として扱う
    allow_template = PromptTemplate.from_dict({
        "engine_options": {"undefined": "allow"},
        # ...
    })
    
  • Pydanticモデルとの統合 - 社内でのPydantic採用が多いため、辞書型に加えてPydanticモデルのインスタンスも受け取り可能

    from pydantic import BaseModel
    
    class Context(BaseModel):
        user_name: str
        query: str
    
    # 型安全なコンテキスト
    context = Context(user_name="Alice", query="Python question")
    messages = template.render(context)  # IDE補完が効く
    

この仕組みにより、開発者は読み込み後やレンダリング後のプロンプトを逐一確認する必要がなくなり、本来の開発に集中できます。

③ Facadeパターンによるシンプルなインターフェース

内部の複雑なアーキテクチャを隠蔽し、ユーザーにはPromptTemplateという単一のエントリーポイントのみを提供します。

なぜFacadeパターンなのか:

  • 学習コストの低減 - 複数のクラス(Loader、Renderer、Factory)を理解する必要がない
  • 将来の変更に強い - 内部実装が変わってもユーザーコードは影響を受けにくい
  • 段階的な機能追加 - 高度な機能は徐々に公開できる
# ユーザーが見るのはこれだけ
from prompting import PromptTemplate

# シンプルな使い方
template = PromptTemplate.from_path("prompt.toml")
messages = template.render(context)

# 内部では Loader → Config → Renderer → Result という
# 複雑な処理が行われているが、ユーザーは意識する必要がない

④ セキュリティを考慮したログ設計

プロンプトやデータには機密情報が含まれる可能性があるため、すべてのログでデータやプロンプトの中身を出力しないようにしました。

一方で、デバッグ効率も重要です。そこで例外を構造化し、データの中身を隠蔽しつつもエラーの種類や発生箇所は明確に特定できるようにしています。

PromptError                         # すべてのpromptingモジュール例外の基底クラス
├── EngineError                     # 抽象的なレンダラー/エンジンエラー
│   └── Jinja2RenderError           # Jinja2レンダリング失敗のラッパー
└── FatalPromptError                # 回復不可能なエラーの基底クラス
    ├── LoadError                   # 外部ソースから生のプロンプトデータをロードする際のエラー
    │   ├── UnsupportedFormatError  # サポートされていないファイル形式が検出された際に発生
    │   └── ParseError              # TOML/YAMLパースが失敗した際に発生
    ├── SchemaError                 # プロンプト定義がスキーマに違反している際に発生
    │   └── VersionMismatchError    # プロンプト定義のバージョンがサポートされていない際に発生
    └── RenderError                 # テンプレートレンダリング中の一般的なエラー(ユーザーのミス)
        ├── UndefinedVariableError  # コンテキストで提供されていない変数への参照
        └── Jinja2SyntaxError       # Jinja2テンプレート内の構文エラー

5. できるようになったこと

上述の設計を元に実装したライブラリによって、下記のような利用が可能になりました。

5.1 シンプルなユースケース

プロンプト定義ファイル(quickstart.toml):

version = "v1"
engine = "jinja2"

[[messages]]
role = "system"
content = "You are a helpful assistant specialized in {{ domain }}."

[[messages]]
role = "user"
content = "Hello. Please tell me about {{ topic }}."

利用方法:

from pathlib import Path
from prompting import PromptTemplate

# プロンプトテンプレートを読み込み
template = PromptTemplate.from_path(Path("quickstart.toml"))

# 変数を埋め込んでレンダリング
messages = template.render({
    "domain": "Python programming",
    "topic": "decorators"
})

# レンダリング結果の確認
for msg in messages:
    print(f"[{msg.role}]: {msg.content}")

# 出力:
# [system]: You are a helpful assistant specialized in Python programming.
# [user]: Hello. Please tell me about decorators.

5.2 型安全なコンテキスト管理

Pydanticモデルを使用することで、型チェックとIDEの補完が効くようになります。

型定義とレンダリング:

from pathlib import Path
from pydantic import BaseModel
from prompting import PromptTemplate

# コンテキストの型を定義
class QuestionContext(BaseModel):
    domain: str
    topic: str

# 型チェック付きテンプレートの作成
template = PromptTemplate.from_path(
    Path("quickstart.toml"),
    context_model=QuestionContext
)

# IDEの補完とバリデーションが有効
context = QuestionContext(
    domain="Python programming",
    topic="decorators"
)
messages = template.render(context)

5.3 複数のコンテキストでのレンダリング

会話履歴のような複数のメッセージを、それぞれ異なるコンテキストでレンダリングすることも可能です。

プロンプト定義ファイル(multi_messages.toml):

version = "v1"

[[messages]]
role = "system"
content = "Hello {{ assistant_name }}, you are a helpful assistant."

[[messages]]
role = "user"
content = "Hi {{ assistant_name }}, I'm {{ user_name }}. I have a {{ query }}."

[[messages]]
role = "assistant"
content = "Hello {{ user_name }}! I understand you have a {{ query }}. Let me help you with that."

複数コンテキストでのレンダリング:

from pathlib import Path
from prompting import PromptTemplate

template = PromptTemplate.from_path(Path("multi_messages.toml"))

# 各メッセージに異なるコンテキストを提供
contexts = [
    {"assistant_name": "Claude"},
    {"assistant_name": "Claude", "user_name": "Alice", "query": "question about Python"},
    {"user_name": "Alice", "query": "question about Python"}
]

messages = template.render(contexts)

6. おわりに

本記事では、ELYZAのSolution事業部におけるプロンプトの読み込み・レンダリングの実装標準化の取り組みについてご紹介しました。

ELYZAは『未踏の領域で、あたりまえを創る』というミッションのもと、これからも LLMを活用した社会課題解決を推進してまいります。

そのために、機械学習エンジニアやソフトウェアエンジニアなど、様々な職種で一緒に事業を前に進めてくれる仲間を募集しています。記事内容に共感いただけた方は、ぜひ下記をご覧ください。

https://open.talentio.com/r/1/c/elyza/homes/2507

https://herp.careers/v1/elyza0/LZgyR-u_N6Pj

https://herp.careers/v1/elyza0/yEe0Rc0YfJhk

https://herp.careers/v1/elyza0/MKnBbDYcK_Su

https://herp.careers/v1/elyza0/GLKcUhhGR-WZ


Appendix A. パフォーマンス改善

ライブラリ内で行われているパフォーマンス改善Tipsを紹介します。

A.1 テンプレートのキャッシング

Jinja2レンダラーでは、コンパイル済みテンプレートをLRUキャッシュで保持しています。

# src/prompting/infra/renderer/jinja2.py より抜粋
from functools import lru_cache

class Jinja2Renderer(AbstractRenderer):

    @staticmethod
    @lru_cache(maxsize=256)  # 最大256個のテンプレートをキャッシュ
    def _compile(text: str, undefined_cls: Hashable) -> Template:
        """テンプレートのコンパイル結果をキャッシュ."""
        env = Jinja2Renderer._get_env(undefined_cls)
        return env.from_string(text)

A.2 Environmentのキャッシング

Jinja2のEnvironmentオブジェクトも、Undefinedポリシーごとにキャッシュするようにしています。特に大量のテンプレートを扱うシステムで有効です。

@staticmethod
@lru_cache(maxsize=8)  # Undefinedポリシーの種類分
def _get_env(undefined_cls: type[Undefined]) -> Environment:
    """Environmentオブジェクトをキャッシュして再利用."""
    return Environment(
        undefined=undefined_cls,
        autoescape=False
    )

Appendix B. プラグインレンダラの実装例

ライブラリの利用者が、独自のテンプレートエンジンを追加する方法を具体例で示します。

B.1 カスタムレンダラーの実装

まず、独自のレンダラーを実装します。ここではMustache風の簡易テンプレートエンジンを例にします。

# my_custom_renderers/renderers.py
from typing import Any
from collections.abc import Mapping
from prompting.infra.renderer.base import AbstractRenderer

class MustacheRenderer(AbstractRenderer):
    """Mustache風の簡易テンプレートレンダラー."""

    name: str = "mustache"

    def __init__(self, text: str, **options: object) -> None:
        self._template = text
        self._strict = options.get("strict", True)

    def __call__(self, context: Mapping[str, Any]) -> str:
        """{{variable}} 形式の変数を置換."""
        result = self._template

        import re
        pattern = re.compile(r'\\{\\{(\\w+)\\}\\}')

        for match in pattern.finditer(self._template):
            var_name = match.group(1)
            if var_name in context:
                result = result.replace(
                    match.group(0),
                    str(context[var_name])
                )
            elif self._strict:
                raise KeyError(f"Undefined variable: {var_name}")

        return result

    async def arender(self, context: Mapping[str, Any]) -> str:
        """非同期レンダリング(同期版のラッパー)."""
        from asyncio import to_thread
        return await to_thread(self.__call__, context)

B.2 エントリーポイントの設定

パッケージの pyproject.toml でエントリーポイントを定義します。

# my_custom_renderers/pyproject.toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "my-custom-renderers"
version = "0.1.0"
dependencies = ["prompting>=1.0.0"]

[project.entry-points."prompting.renderers"]
mustache = "my_custom_renderers.renderers:MustacheRenderer"
mako = "my_custom_renderers.renderers:SimpleMakoRenderer"

B.3 カスタムレンダラーの使用

パッケージをインストールすると、自動的に利用可能になります。

# パッケージのインストール
pip install ./my_custom_renderers
from prompting import PromptTemplate

# Mustacheレンダラーを使用
config = {
    "version": "v1",
    "engine": "mustache",  # entry_pointsで登録した名前
    "messages": [
        {
            "role": "system",
            "content": "You are {{assistant_name}}."
        }
    ]
}

template = PromptTemplate.from_dict(config)
messages = template.render({"assistant_name": "ELYZA"})
print(messages[0].content)  # "You are ELYZA."
株式会社 ELYZA

Discussion