【第2回】Claude API Python 実践入門 — プロンプト設計とメッセージ履歴で会話を制御する

AI

前回はClaude APIのストリーミングレスポンスの仕組みと基本的なセットアップを紹介しました(第1回はこちら)。今回は一歩踏み込んで、チャットボットの核心部分である「会話履歴の管理」と「システムプロンプトによるキャラクター設計」を扱います。

この辺りを理解すると、単発の質問応答アプリから脱却して、文脈を保持した会話ができるアプリが作れるようになります。個人的にここが一番楽しいと思っている部分です。

  • Claude APIが会話履歴をどう扱っているか(ステートレスの仕組み)
  • システムプロンプトでキャラクター・役割を定義する方法
  • Python で会話履歴を管理するクラスの実装
  • トークン増加への対処(スライディングウィンドウ・要約)
  • CLIで動く簡易チャットアプリの完成形

Claude APIが会話履歴をどう扱っているか

まず前提として押さえておきたいのですが、Claude API(Messages API)はサーバー側で会話状態を保持してくれるセッション機能をデフォルトでは持っていません。会話の文脈を維持したい場合、クライアント側で履歴を保持して、リクエストのたびに必要な分を送り直す設計になります。公式SDKの使い方もそういう前提です。

つまり、こういうイメージです。

# 1回目のリクエスト
messages = [
    {"role": "user", "content": "Pythonって難しいですか?"}
]

# 2回目のリクエスト(1回目の内容を含めて送る)
messages = [
    {"role": "user",      "content": "Pythonって難しいですか?"},
    {"role": "assistant", "content": "慣れれば直感的に書けますよ。"},
    {"role": "user",      "content": "どこから勉強すればいいですか?"},
]

これを知らずにAPIを叩くと「なんで前の話覚えてないんだ」となります。毎回まっさらな状態で送られてくるので、Claudeからすれば初対面です。最初にハマりやすいポイントなので先に押さえておくのが重要です。

余談ですが、この設計は「ステートレス」という概念に沿っていて、スケーリングしやすくなる代わりに履歴管理はクライアント側の責任になります。

システムプロンプトの役割と書き方

システムプロンプトは、messages配列とは別にsystemパラメータとして渡します。ここでClaudeの人格・口調・役割を定義します。

ロールを設定することで、Claudeの振る舞いとトーンをユースケースに合わせて絞り込めます。たった一文でも効果は出るので、まず試してみるのが早いです。

import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-opus-4-6",
    max_tokens=1024,
    system="あなたはPython専門のコードレビュアーです。簡潔に、でも的確に指摘してください。",
    messages=[
        {"role": "user", "content": "このコードどうですか?\n\nfor i in range(len(lst)):\n    print(lst[i])"}
    ]
)
print(response.content[0].text)

個人的な感覚では、「〜してください」と書くより「あなたは〜な人物です」と書いた方がキャラクターが安定しやすい印象があります。ちゃんと比較検証したわけじゃないんですが、自分はだいたいロール宣言から始めるようにしています。

システムプロンプトを書くときのポイント

  • ロール(役割)を最初に書く:「あなたは〜です」から始める
  • 制約・禁止事項も書いておく:「〜については答えないでください」
  • 出力フォーマットの指示も入れられる:「常にMarkdownで返してください」など
  • 長くなりすぎないようにする:詳細な指示はユーザーターンに書く方が効くこともある

会話履歴を管理するクラスをPythonで実装する

ここが今回のメインです。会話履歴をリストで持ち、ターンごとに追記していくシンプルな実装を作ります。

import anthropic
from typing import Optional

class ChatSession:
    def __init__(self, system_prompt: Optional[str] = None, model: str = "claude-opus-4-6"):
        self.client = anthropic.Anthropic()
        self.model = model
        self.system = system_prompt
        self.messages: list[dict] = []

    def chat(self, user_input: str) -> str:
        # ユーザー発言を履歴に追加
        self.messages.append({"role": "user", "content": user_input})

        kwargs = {
            "model": self.model,
            "max_tokens": 2048,
            "messages": self.messages,
        }
        if self.system:
            kwargs["system"] = self.system

        response = self.client.messages.create(**kwargs)
        assistant_text = response.content[0].text

        # アシスタントの返答も履歴に追加
        self.messages.append({"role": "assistant", "content": assistant_text})
        return assistant_text

    def reset(self):
        # 履歴をクリアして会話をリセット
        self.messages = []

使い方はこんな感じです。

session = ChatSession(
    system_prompt="あなたは親切なPythonメンターです。初心者に寄り添った説明を心がけてください。"
)

print(session.chat("リスト内包表記って何ですか?"))
print(session.chat("さっきの例で、条件を追加するにはどうすれば?"))  # 文脈が引き継がれる

2回目の「さっきの例で」という部分、これが機能するのはちゃんと履歴を引き継いでいるからです。履歴なしで送ったら「さっきって何?」状態になります。

トークン数を意識した会話履歴の切り詰め

問題が一つあって、会話が長くなるとどんどんトークンを消費します。毎回会話履歴全体を再送信する構造上、ターン数が増えるほど1リクエストあたりのトークン量が膨らんでいきます。

Claudeのモデルにはコンテキストウィンドウ(入力+出力の上限)があります。モデルによって異なりますが、Sonnet系には200k前後のものがあります。上限を超えた場合、APIは黙ってトランケートするのではなく、エラー(invalid_request_error 系)が返ってくることが多いので、最初からエラーハンドリングを入れておくのが安全です。

手軽な対策として、「直近N件だけ保持するスライディングウィンドウ」があります。

class ChatSessionWithWindow(ChatSession):
    def __init__(self, max_turns: int = 10, **kwargs):
        super().__init__(**kwargs)
        self.max_turns = max_turns  # user+assistantで1ターン = 2件

    def _trim_messages(self):
        # max_turns * 2 件を上限に古いものを削除
        limit = self.max_turns * 2
        if len(self.messages) > limit:
            self.messages = self.messages[-limit:]

    def chat(self, user_input: str) -> str:
        result = super().chat(user_input)
        self._trim_messages()  # レスポンス取得後にトリムする
        return result

ただしこの方法だと、切り捨てられた古い情報は完全に消えます。「最初に自己紹介してもらったのに途中で忘れる」みたいな現象が起きます。完璧な解決策ではないので、用途に合わせて調整が必要です。

サマリーを使った長期記憶(発展)

より洗練された方法は、古い会話をClaudeに要約させてシステムプロンプトに埋め込む手法です。概念としてはこんな感じ。

def summarize_history(client, messages: list[dict]) -> str:
    """古い会話履歴をClaudeに要約させる"""
    summary_prompt = "以下の会話を200字以内で要約してください。重要な情報(名前・決定事項・前提条件)は保持すること。\n\n"
    for m in messages:
        summary_prompt += f"{m['role']}: {m['content']}\n"

    response = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=300,
        messages=[{"role": "user", "content": summary_prompt}]
    )
    return response.content[0].text

これをシステムプロンプトの末尾に「これまでの会話の要約: …」として追記する形で使います。正直、ここの実装はまだ自分でもベストプラクティスを探っているところです。要約の粒度とかタイミングとか、いろいろ調整が必要で難しい。

なお、Claude API側でも長い会話を圧縮する仕組み(context compaction、ベータ)が言及されています。仕様が変わる可能性もあるので、採用する場合は公式ドキュメントを確認しつつ、アプリ側でも要約戦略を持っておくのが安心かなと思います。

CLIで動く簡易チャットアプリの完成形

最終的にCLIで対話できる簡単なチャットアプリにまとめるとこんな形になります。

def main():
    session = ChatSessionWithWindow(
        max_turns=8,
        system_prompt="あなたはフレンドリーなAIアシスタントです。日本語で返答してください。",
    )

    print("チャット開始('quit' で終了)")
    while True:
        user_input = input("You: ").strip()
        if user_input.lower() in ("quit", "exit", "q"):
            break
        if not user_input:
            continue

        response = session.chat(user_input)
        print(f"Claude: {response}\n")

if __name__ == "__main__":
    main()

このままターミナルで動かすと、ちゃんと文脈を保持した会話ができます。「さっきの話の続きで」みたいな指示も通るようになるので、これだけでもかなり実用的な感じになります。

まとめ

今回の内容を整理するとこんな感じです。

  • Claude APIはステートレスなので、会話履歴はクライアント側で管理する必要がある
  • システムプロンプト(systemパラメータ)でClaudeの役割・口調・制約を定義できる
  • ChatSessionクラスで履歴を保持することで、文脈のある会話が実現できる
  • 長期の会話ではトークン増加が課題になる。スライディングウィンドウや要約で対処する

仕組み自体はシンプルで、「履歴をリストに貯めて毎回送る」これだけです。ただ実用レベルにしようとすると履歴の切り詰め方や要約戦略など考えることが出てくるので、徐々に改善していく感じが楽しいです。

タイトルとURLをコピーしました