📑

t-stringを使ったLLMアプリケーションのインターフェースについての模索

に公開

はじめに

こんにちは。PKSHA Technology CEO 室ソフトウエアエンジニアの藤岡です。

2025 年 10 月にリリースされた Python 3.14 で、t"" プレフィックスを使った t-string(Template String Literal)が新しく導入されました。

PEP 750 で提案され、標準ライブラリの string.templatelib.Template 型として実装されています。

見た目は f-string にかなり似ていますが、評価結果は str ではなくテンプレート構造を持つオブジェクトになります。

この記事は、PyCon JP 2025 にスポンサーとして現地参加した際に聴講したセッション『【招待講演】PEP 750 の共同著者の青野高大氏による Python 3.14 の新機能の紹介』(発表資料)に触発されて書かれたものです。
t-string を使って自身のユースケースである LLM アプリケーションでどう使えそうかを、実際に触りながら整理しました。

注意

  • t-string のすべての機能を網羅するチュートリアルではない
  • 「こう使うべき」という強い主張というより、「このあたりは手触りが良かった/少し違うかも」という観察が中心

t-string の概要

t-string の位置づけ

PEP 750 は t-string について、ざっくり次のように説明しています。

  • t-string は f-string と同じ構文を持つ
  • 評価結果は str ではなく Template 型のオブジェクト
  • この Template は、静的な文字列部分と補間される部分(中括弧 {} の中身)を分離して保持する

続けて、次のように紹介されています。

  • t-string はカスタム文字列処理のための仕組み
  • .strings, .interpolations, .values などの属性から、構造にアクセスできる
  • Template はイテラブルで、strInterpolation が交互に出てくる

この内容からも t-string は「5 つめの文字列フォーマット構文」というより、“f-string 風の構文で Template オブジェクトを得る仕組み”という理解をしています。

Template / Interpolation の構造

Template 型はおおよそ次のような形をしています。

  • strings: tuple[str, ...]
    静的な文字列部分({} の外側)のタプル
  • interpolations: tuple[Interpolation, ...]
    {} 内のそれぞれの式を表す Interpolation オブジェクトのタプル
  • values: tuple[object, ...]
    各補間の評価済み値のタプル(tuple(i.value for i in interpolations) と同じ)

Interpolation は、1 つの {...} に対応するオブジェクトで、たとえば次の属性を持ちます。

  • value : 評価済みの値
  • expression : 中括弧内の元の式文字列(例 "pi"
  • conversion : !s / !r / !a に対応する文字列または None
  • format_spec : :.2f のようなフォーマット指定(解釈は処理側に委ねられる)

REPL で触ると、イメージが掴みやすいです。

from string.templatelib import Template

name = "World"
tmpl: Template = t"Hello {name}!"

print(tmpl)
# Template(strings=('Hello ', '!'), interpolations=(Interpolation('World', 'name', None, ''),))

print(tmpl.strings)         # ('Hello ', '!')
print(tmpl.values)          # ('World',)
print(tmpl.interpolations)  # (Interpolation('World', 'name', None, ''),)

なお、Template はイテラブルなので、文字列と Interpolation が交互に取れます。

from string.templatelib import Interpolation

for part in tmpl:
    if isinstance(part, Interpolation):
        print("value:", part.value, "expr:", part.expression)
    else:
        print("literal:", repr(part))

基本的な使用例

t-string だけでは何も「レンダリング」されません。
どう文字列化するかはユーザーが決める、というのが PEP の設計意図です。

たとえば「補間部分だけ str() して連結する」最小のレンダラーは次のように書けます。

from string.templatelib import Template, Interpolation

def render(template: Template) -> str:
    parts: list[str] = []
    for part in template:
        if isinstance(part, Interpolation):
            parts.append(str(part.value))
        else:
            parts.append(part)
    return "".join(parts)

自前のレンダリング関数を挟める点は t-string の大きな特徴です。

同時に裏返しとして、「ユーザーが定義しないと文字列化できない」強固なプロセスが t-string の肝でセキュリティ面での利点にもなっています。

既存の文字列構文・データ型との比較

比較の観点

この記事では、次の 3 つの観点で t-string を比較します。

  1. 補間データにアクセスできるか(補間された「値」や「式の文字列」を後から取り出せるか)
  2. 文字列化処理の方法(文字列化処理はあるか?カスタム文字列処理は定義できるか)
  3. インターフェースを一般化しやすいか(sanitize(x) -> str のような関数インターフェースで扱いやすいか)

文字列フォーマット構文(%, format, f-string)との比較

ざっくりまとめると、次のようになります。

種類 補間データの取得 文字列化処理の方法 インターフェース の一般化しやすさ
%/.format 不可 構文で文字列化・カスタム文字列処理はなし 構文なので関係なし
f-string 不可 構文で文字列化・カスタム文字列処理はなし 構文なので関係なし
t-string Templateから取得可能 文字列化時に任意の処理が可能 Template -> str で揃えやすい

f-string では、{} の中身は評価されてしまい、「何という式が、どの値になったのか」を後から知る手段がありません。

t-string はここを Template と Interpolation に分解して持ち続けることで、後段の処理に情報を渡せる点が大きく違います。

データモデル(dataclass, Pydantic)との比較

構造化データを扱うという意味では dataclass や Pydantic も候補になります。
こちらのまとめは次の通りです。

種類 補間データの取得 文字列化処理の方法 インターフェース の一般化しやすさ
dataclass 可能(属性アクセス) メソッドを定義し、任意の文字列加工が可能 クラスごとに インターフェース が異なるため困難
Pydantic 可能(属性アクセス) バリデータ・メソッドで任意の文字列加工が可能・厳密な型で評価可能 クラスごとに インターフェース が異なる・標準ライブラリではない
t-string Templateから取得可能 文字列化時に任意の処理が可能 Template -> str で揃えやすい
# dataclass example
from dataclasses import dataclass

@dataclass
class Greeting:
    name: str

    def render(self) -> str:
        if not self.name.strip():
            raise ValueError("name is empty")
        return f"Hello, {self.name}!"
# Pydantic example
from pydantic import BaseModel, field_validator

class GreetingModel(BaseModel):
    name: str

    @field_validator("name")
    def validate_name(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("name is empty")
        return v

    def render(self) -> str:
        return f"Hello, {self.name}!"

Pydantic などのデータ型はバリデーションや型付けをデータ構造側に寄せられる点や自作関数で文字列化処理を定義できる点が強みです。

一方で、次のような課題は避けられません。

  • インターフェースは各クラスごとに違う(Greeting, GreetingModel, …)
  • 共通の「テンプレート処理関数」を用意しようとしても、プロトコルや ABC を設計する手間がかかる

比較して、「インターフェース の一般化のしやすさ」は t-string のみが持つ大きな強みだと感じました。

  • 「テンプレートと値の関係」を Template 型に閉じ込める
  • その上で sanitize(tmpl: Template) -> str のような関数インターフェースを 1 つ決めてしまえる

LLM アプリケーションへの適用検討

サンプル翻訳 API の構成

ここからは、簡単な翻訳 API を題材に考えてみます。

  • FastAPI ベースの /Translate エンドポイント
  • リクエストボディとして text と target_language を受け取る
  • Azure OpenAI Service にプロンプトを投げて結果を返す

シンプルな構成です。

# server.py(一部)
app = FastAPI(lifespan=lifespan)

class TranslationRequest(BaseModel):
    text: str
    target_language: str


# API エンドポイント
@app.post("/translate")
async def translate(body: TranslationRequest, config: ConfigDep):
    translated = await translate_text(
        body.text,
        body.target_language,
        aoai_endpoint=config.AZURE_OPENAI_ENDPOINT,
        aoai_api_key=config.AZURE_OPENAI_API_KEY,
    )
    return {"translated_text": translated}


# translate.py(一部)
async def translate_text(
    text: str, target_language: str, aoai_endpoint: str = "", aoai_api_key: str = ""
) -> str:
    """指定された言語にテキストを翻訳する。

    Args:
        text (str): 翻訳対象のテキスト。
        target_language (str): 翻訳先の言語コード(例: 'en'、'ja'、'fr' など)。
        aoai_endpoint (str, optional): Azure OpenAI のエンドポイント URL。
        aoai_api_key (str, optional): Azure OpenAI の API キー。

    Returns:
        str: 翻訳されたテキスト。
    """

    client = AsyncOpenAI(
        base_url=aoai_endpoint,
        api_key=aoai_api_key,
    )

    prompt = build_prompt(text, target_language)
    response = await client.responses.create(
        model="gpt-5",
        reasoning={"effort": "low"},
        input=prompt,
    )

    return response.output_text

従来のプロンプト生成方法

従来のプロンプトビルダーでは、文字列処理は f-string または format 関数で書くのが一般的です。
この際、build_content では text / target_language をその場で文字列に埋め込む形となります。

# translate.py(一部)
def build_prompt(text: str, target_language: str) -> list[dict[str, str]]:
    def build_content(text: str, target_language: str) -> str:
        # 文章や変換言語が空の場合は翻訳不要とみなす
        if not text.strip():
            return ""
        if not target_language.strip():
            return ""
        return f"Translate the following text to {target_language}:\n\n{text}"

    system_prompt = "You are a helpful assistant that translates text."
    return [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": build_content(text, target_language)},
    ]

この状態でも実用上は困りませんが、次のような場面を考えると、もう少し改良したくなります。

  • 「補間される値に対して、共通のサニタイズや検証を挟みたい」
  • 「後からログとして “ベースプロンプト” と “変数” を別々に記録したい」

t-string を導入するポイント

翻訳 API の例では、t-string の組み込みを 2 パターン試してみました。

それぞれの流れは大まかに次の通りです。

  • Template として組み立てる
  • 検査や整形を挟む
  • 最後だけ str に落とす

1 つ目のパターンは、最小限に「空文字を弾く」程度の処理だけを Template ベースで書いたパターンです。

from string.templatelib import Template, Interpolation


# パターン1: Template を使った最小限のプロンプト生成
def build_prompt(text: str, target_language: str) -> list[dict[str, str]]:
    def build_content(template: Template) -> str:
        values: list[str] = []

        for item in template:
            if isinstance(item, Interpolation):
                value = str(item.value)
                # 例: 空文字は「翻訳不要」とみなして早期リターンする
                if value.strip() == "":
                    return ""
                values.append(value)
            else:
                # item は静的な文字列
                values.append(item)

        return "".join(values)

    system_prompt = "You are a helpful assistant that translates text."
    # 引数 text / target_language がスコープにあるので、このタイミングで t-string を評価できる
    user_prompt_template: Template = t"Translate the following text to {target_language}:\n\n{text}"
    user_prompt = build_content(user_prompt_template)

    return [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt},
    ]

ここでは、次のように位置付けています。

  • build_prompt のインターフェースは従来どおり text / target_language を取る
  • 内部の build_content でのみ t-string を使って Template を組み立てる
  • build_content() の中で Interpolation を見ながら検査・連結する

という形で、t-string をあくまで「プロンプト生成の内側」に限定しています。

2 つ目のパターンは、プロンプトインジェクションの検査や前処理を差し込みたい場合に、Template を引数に取るヘルパーを足していく形です。

# パターン2: Template ベースで前処理を挟む

def check_prompt_injection(prompt_template: Template) -> bool:
    """プロンプトインジェクションの可能性がある場合に True を返す想定の関数。"""
    ...


def sanitize_prompt(prompt_template: Template) -> Template:
    """前処理済みの Template を返す(不要な部分を削る・正規化するなど)。"""
    ...


def build_prompt(text: str, target_language: str) -> list[dict[str, str]]:
    def build_content(template: Template) -> str:
        if check_prompt_injection(template):
            raise ValueError("Potential prompt injection detected in the input text. %s", str(template))

        sanitized_t = sanitize_prompt(template)

        values: list[str] = []
        for item in sanitized_t:
            if isinstance(item, Interpolation):
                value = str(item.value)
                if value.strip() == "":
                    return ""
                values.append(value)
            else:
                values.append(item)

        return "".join(values)

    system_prompt = "You are a helpful assistant that translates text."
    user_prompt_template: Template = t"Translate the following text to {target_language}:\n\n{text}"
    user_prompt = build_content(user_prompt_template)

    return [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt},
    ]

このパターンでは Template を引数とする次の関数のインターフェースをそろえておき、チェック系の処理(インジェクションの簡易検査)や軽い整形・フィルタリングをまとめます。

  • check_prompt_injection(template: Template) -> bool
  • sanitize_prompt(template: Template) -> Template

これらをすべて「Template を受け取る関数」として統一することで、責務を分離しつつ処理の流れを整理できます。
そのうえで、実際に文字列として連結する部分は build_content() の中だけに閉じ込める構成です。

t-string を導入する際のポイントは次のとおりです。

  • 外から見えるインターフェース(build_prompt(text, target_language))は変えない
  • プロンプトの中身だけ Template で扱う
  • 「検査/整形/連結」を Template 対応のヘルパー関数に寄せていく

このような形が、LLM アプリケーションでは扱いやすいと感じました。

t-string 適用で得られた知見

Templateのインターフェースの可能性

t-string を触っていて一番しっくりきた点のひとつは、「テンプレートを受け取って、文字列化までを担当する関数のインターフェースを揃えやすい」ことでした。

たとえば、ユーザーの入力を検証するサニタイズの関数は次のように定義できます。

from string.templatelib import Template, Interpolation

def sanitize_prompt(template: Template) -> str:
    parts: list[str] = []
    for part in template:
        if isinstance(part, Interpolation):
            value = str(part.value)
            # ここにプロンプト向けの検証・加工ルールを集約できる
            # 例: 空文字を弾く、長すぎる値を切る、など
            parts.append(value)
        else:
            parts.append(part)
    return "".join(parts)

呼び出し側はプロンプトを使いたい時に、Template 型のオブジェクトを渡すだけで済みます。

prompt = sanitize_prompt(
    t"Translate the following text to {target_language}:\n\n{text}"
)

このように Template を渡すインターフェースだけを意識すれば、次のようなことはすべて sanitize_prompt() の中に集約できます。

  • プロンプトに対する検証
  • 軽いガード(長さ・禁止文字など)
  • 将来追加したい前処理

Template という共通の型があることで、処理の所在が分かりやすくなります。

また、実装の中身をもう少し育てたい場合は、次のように発展させられます。

  • 外から見えるインターフェースは sanitize_prompt(template: Template) -> str のまま維持しつつ、
  • 内部で Template -> Template の小さな変換関数をいくつか組み合わせる

という書き方に発展させることもできます。

LLM プロンプトという限定された領域でも、「Template を受け取る関数インターフェースでそろえる」だけで前処理やガードの置き場を整理しやすくなるのが、t-string を試して感じたポイントでした。

t-string 適用で見えた課題

アルゴリズムのインターフェースとして用いる場合の課題

一度試してみて、あまりしっくりこなかった形がこれです。

# あまり良くなさそうだった例
async def translate_text(template: Template) -> str:
    ...

一見単純ですが、次の点で抽象化が少し強すぎると感じました。

  • 「翻訳アルゴリズムのインターフェース」が、いきなり t-string 前提になってしまう
  • 呼び出し側は、Template の構造(どんな {} があるか)を知っている必要がある
  • 「LLM 以外の実装(辞書ベース翻訳など)」に差し替えにくい

format関数的な利用を試みた際の制約

t-string は f-string と同様に式をその場で評価します。そのため、次のような「後から .format() する」スタイルは採用できません。

# これは 動く
prompt = "Translate the following text to {target_language}:\n\n{text}"
prompt.format(target_language=target_language, text=text)

# target_language や text がまだ定義されていなければ NameError になる
tmpl = t"Translate the following text to {target_language}:\n\n{text}"

この点は f-string と同じで、「式の評価タイミング」は変わっていません。

「テンプレートだけ先に定義して、あとから値を流し込みたい」というユースケースに対しては、何かしらの工夫をする必要がありそうです。

評価タイミングと責務分離の難しさ

Template は「文字列化を遅延させる」ことはできます。
ただし、次の点を設計しないと責務がわかりにくくなる場面もありました。

  • Python の式の評価そのものは遅延されない
  • どのタイミングで「外部から渡ってきた値」を Template に流し込むか

たとえば次のレイヤのどこで Template を扱うか・プロンプトを管理するかを決める必要があります。

  • プロンプトビルダーのような LLM の input を組み立てるレイヤ
  • アプリケーションのユースケースレイヤ
  • アルゴリズムレイヤ(翻訳や要約などの処理そのもの)

どこまで Template を持ち上げるか、どこで str に落とすかという設計は、t-string 特有の悩みどころだと感じます。

まとめ

この記事では、Python 3.14 で入った t-string(Template String Literal)について次の観点を探索・整理しました。

  • データ構造がどうなっているか
  • 他の文字列化構文・データ構造との違いがどこにありそうか
  • シンプルな翻訳 API を題材に、t-string を組み込んでみるとどう感じたか

Python 3.14 が広く使われ始めるのはまだ先の話ですが、プロンプトを処理するライブラリや自作コードの共通インターフェースとして t-string が今後普及していく可能性は十分にあると感じました。

参考資料

PKSHAテックブログ

Discussion