
マルチターン会話とシステムプロンプト——APIで対話アプリを作る【CCA Foundations対策】
CCA Foundations対策 · Claude API編 第2回(全17回)
Anthropic Academyの「Claude APIを使用した構築」コースをもとに解説しています。
1回限りの質問に答えるだけなら前回の実装で十分。でも実際のアプリでは「前のやり取りを踏まえた返答」が必要になる。そこで生じる最初の疑問が「Claudeはどうやって会話を記憶しているのか」だ。
答えを先に言うと、Claudeは何も記憶していない。記憶しているように見えるのは、毎回全履歴を送っているから。この仕組みを正確に理解することが、マルチターン会話を実装するうえで一番大事なポイント。
この記事でわかること:
- Claude APIがステートレスである理由と、それが設計に与える影響
- 会話履歴を正しく管理する実装パターン
- 履歴が長くなったときに起きる問題と対処法
- システムプロンプトの役割と、効果的な書き方の原則
- Temperatureの使い分けと誤解されやすい挙動
Claude APIはステートレス
最初に押さえておきたいのが、Claude APIは会話履歴を保持しないという点。
リクエストをするたびに、Claudeはその内容を完全に独立したものとして処理する。前のやり取りは一切覚えていない。
試しにこういうことをするとどうなるか:
# 1回目のリクエスト
response1 = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
messages=[{"role": "user", "content": "機械学習とは何ですか?"}]
)
# 2回目のリクエスト(前の文脈を引き継ごうとしている)
response2 = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
messages=[{"role": "user", "content": "もう1文追加してください"}]
)
print(response2.content[0].text)
# 「機械学習」とは無関係な文章が返ってくる
空は青く、風が心地よく吹いていました。
「もう1文追加して」という指示に対して、Claudeは何について書けばいいかわからないので、脈絡のない文を返してくる。
なぜステートレスなのか
単に「設計の都合」ではなく、このステートレス設計には重要な意味がある。
- スケールしやすい — サーバー側で状態を持たなくていいため、どのサーバーがリクエストを処理しても同じ結果になる
- 制御が明示的 — 何を「覚えさせるか」を開発者が完全にコントロールできる
- マルチエージェント設計に直結 — サブエージェントは親の会話履歴を自動では引き継がない。文脈を渡したければ、プロンプトに明示的に含める必要がある
この「明示的に渡す」という考え方は、複雑なエージェントシステムを設計するときにも重要な原則になる。
サブエージェントへの文脈の渡し方
マルチエージェント構成では、親エージェントの会話履歴はサブエージェントに自動では引き継がれない。サブエージェントを呼び出すときは、必要な文脈をプロンプトに明示的に含める必要がある。
# 親エージェントが持っている文脈
parent_context = {
"project": "プロジェクトA",
"progress": "60%完了",
"issue": "予算が20%超過している"
}
# ❌ 悪い例:文脈を渡さずにサブエージェントを呼び出す
sub_messages = [
{"role": "user", "content": "リスク対策案を3つ提案してください"}
]
# → サブエージェントはプロジェクトAの状況を知らないので汎用的な回答しか返せない
# ✅ 良い例:必要な文脈をプロンプトに含める
sub_messages = [
{"role": "user", "content": f"""
【コンテキスト】
プロジェクト:{parent_context['project']}
進捗:{parent_context['progress']}
課題:{parent_context['issue']}
上記の状況を踏まえて、具体的なリスク対策案を3つ提案してください。
"""}
]
sub_response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
messages=sub_messages,
)
注意点: 親の全履歴をそのままサブエージェントに渡すのは避ける。トークン消費が増えるだけでなく、サブエージェントにとって不要な情報が混入してタスクの精度が下がることがある。必要な情報だけを要約して渡すのが効率的な設計。
📋 試験ガイドより
公式試験ガイドのDomain 5(Context Management & Reliability)では、ステートレス設計を前提としたコンテキスト管理と、サブエージェントへの文脈の明示的な受け渡しが取り上げられている。
会話履歴を手動で管理する
解決策はシンプル。すべてのやり取りをリストとして保持し、毎回のリクエストにそのリストをそのまま渡す。
1回目のリクエスト
送信: [user: "機械学習とは?"]
受信: [assistant: "機械学習とは..."]
2回目のリクエスト
送信: [user: "機械学習とは?"]
[assistant: "機械学習とは..."]
[user: "もう1文追加してください"] ← 全履歴を毎回送る
受信: [assistant: "また、機械学習は..."]
messages = []
# ユーザーの発言を追加
messages.append({"role": "user", "content": "機械学習とは何ですか?"})
# Claudeのレスポンスを取得
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
messages=messages
)
answer = response.content[0].text
# Claudeの発言も履歴に追加
messages.append({"role": "assistant", "content": answer})
# 次の発言を追加して、履歴ごと送る
messages.append({"role": "user", "content": "もう1文追加してください"})
response2 = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
messages=messages # 全履歴を渡す
)
print(response2.content[0].text)
また、機械学習はデータの量が増えるほど予測精度が向上する傾向があります。
今度は機械学習についての続きが返ってくる。
ヘルパー関数でスッキリさせる
毎回 append と content[0].text を書くのは冗長なので、ヘルパー関数にまとめる。
import anthropic
from dotenv import load_dotenv
load_dotenv()
client = anthropic.Anthropic()
model = "claude-sonnet-4-6"
def add_user_message(messages: list, text: str) -> None:
messages.append({"role": "user", "content": text})
def add_assistant_message(messages: list, text: str) -> None:
messages.append({"role": "assistant", "content": text})
def chat(messages: list, system: str | None = None) -> str:
params = {
"model": model,
"max_tokens": 1000,
"messages": messages,
}
if system:
params["system"] = system
response = client.messages.create(**params)
return response.content[0].text
使い方:
messages = []
add_user_message(messages, "機械学習とは何ですか?")
answer = chat(messages)
print(answer)
add_assistant_message(messages, answer) # ← これを忘れずに
add_user_message(messages, "もう1文追加してください")
final_answer = chat(messages)
print(final_answer)
# 1回目
機械学習とは、データから自動的にパターンを学習し、予測や判断を行うAI技術の一分野です。
# 2回目(前の文脈を踏まえた続き)
また、機械学習はデータの量が増えるほど予測精度が向上する傾向があります。
よくある実装ミス: add_assistant_message を省略してしまうパターン。これを忘れると、Claudeは自分が何を答えたか知らない状態でリクエストを受け取る。会話が途中から噛み合わなくなる典型的なバグ。
会話履歴が長くなったときの問題
ステートレス設計の副作用として、会話が長くなるほどトークン消費が増えるという問題がある。
1ターン目: 送信 100 tokens
2ターン目: 送信 100 + 150 = 250 tokens(1ターン目の全履歴込み)
3ターン目: 送信 250 + 200 = 450 tokens
...
長い会話では、過去の発言がそのままトークンを消費し続ける。対策としてよく使われるのは「古い履歴を要約して圧縮する」アプローチ。ただし要約には注意が必要で、数値・日付・固有名詞などを曖昧な表現に変換してしまうと、後の会話でその情報が使えなくなる。
「lost in the middle」効果
会話履歴が長くなったときに起きるもう一つの問題が、lost in the middle 効果。
モデルは入力の最初と最後の部分は比較的安定して処理できるが、中間に埋まった情報は見落とされやすいという傾向がある。
[システムプロンプト] ← 処理しやすい(先頭)
[1ターン目の会話]
[2ターン目の会話] ← 埋もれやすい(中間)
[3ターン目の会話]
[最新のユーザー発言] ← 処理しやすい(末尾)
重要な情報(ユーザーが最初に伝えた制約・数値・要件など)を中間に埋めてしまうと、後の会話でClaudeがその情報を参照しなくなることがある。重要な事実はシステムプロンプトか最新のメッセージに置くのが安全な設計。
📋 試験ガイドより
公式試験ガイドのDomain 5では、「lost in the middle」効果と、重要情報をシステムプロンプトまたは末尾メッセージに配置する設計戦略が取り上げられている。
システムプロンプトでキャラクターを設定する
単なる「賢いAPI」ではなく、特定の役割を持つアシスタントを作りたいときに使うのがシステムプロンプト。
system パラメータに文字列で渡す:
system_prompt = """
あなたはフレンドリーなカフェのスタッフです。
メニューや店舗情報に関する質問には丁寧に答えてください。
関係のない話題は「申し訳ありませんが、カフェに関するご質問のみ承っております」と断ってください。
"""
response = client.messages.create(
model=model,
max_tokens=1000,
messages=[{"role": "user", "content": "今日のおすすめは何ですか?"}],
system=system_prompt
)
print(response.content[0].text)
システムプロンプトなしとありで、返ってくる回答がこれだけ変わる:
# システムプロンプトなし
今日のおすすめについてですが、何のおすすめをお探しでしょうか?
レストラン、観光スポット、商品など、もう少し詳しく教えていただけますか?
# システムプロンプトあり(カフェスタッフとして回答)
本日のおすすめはキャラメルラテです!甘さと苦みのバランスが絶妙で、
スタッフ一押しの一杯となっております。ぜひお試しください☕
同じ質問でも、役割を与えるだけで回答のトーンと内容が大きく変わる。
効果的なシステムプロンプトの書き方
システムプロンプトはただ「役割を書けばいい」というものではない。曖昧な指示は曖昧な動作を生む。
曖昧な指示(避けたい):
- 丁寧に対応してください
- 関係ない質問には適切に断ってください
- 親切にしてください
具体的な指示(推奨):
- 敬語を使い、文末は「〜です」「〜ます」で統一する
- カフェのメニュー・営業時間・場所以外の質問には、
必ず「申し訳ありませんが、カフェに関するご質問のみ承っております」と答える
- 価格を聞かれた場合は「詳細はカウンターでお尋ねください」と伝える
「丁寧に」「適切に」といった曖昧な言葉は、Claudeの解釈に委ねることになる。具体的な条件・言葉・例を書くほど、想定どおりの動作に近づく。
📋 試験ガイドより
公式試験ガイドのDomain 4 Task 4.1では、曖昧な指示より明示的な条件・カテゴリ定義の方が出力品質が安定する理由が設計判断のポイントとして取り上げられている。
実装上の注意: system=None をAPIに渡すとエラーになる。値がある場合だけ params に追加する:
def chat(messages: list, system: str | None = None) -> str:
params = {
"model": model,
"max_tokens": 1000,
"messages": messages,
}
if system: # Noneのときはキー自体を追加しない
params["system"] = system
response = client.messages.create(**params)
return response.content[0].text
Temperatureで出力のランダム性を制御する
前回の記事で触れたように、Claudeはテキストを生成するとき「次にどのトークンが来るか」を確率で計算し、その確率に基づいてサンプリングしている。Temperature はこのサンプリングの確率分布を調整するパラメータ(0〜1の小数)。
| Temperature | 挙動 | 向いているタスク |
|---|---|---|
| 0.0〜0.3 | 最も確率の高いトークンを優先。出力が安定・一貫 | コード生成・データ抽出・事実確認 |
| 0.4〜0.7 | バランス型 | 要約・教育コンテンツ・問題解決 |
| 0.8〜1.0 | 低確率トークンも選ばれやすくなり、多様性が増す | ブレスト・クリエイティブライティング・マーケティングコピー |
デフォルトは 1.0。
def chat(messages: list, system: str | None = None, temperature: float = 1.0) -> str:
params = {
"model": model,
"max_tokens": 1000,
"messages": messages,
"temperature": temperature,
}
if system:
params["system"] = system
response = client.messages.create(**params)
return response.content[0].text
messages = []
add_user_message(messages, "映画のあらすじを1つ考えてください")
predictable = chat(messages, temperature=0.0)
creative = chat(messages, temperature=1.0)
# temperature=0.0(何度実行してもほぼ同じ)
主人公の若き探偵が、謎の失踪事件を追ううちに巨大な陰謀に巻き込まれていくサスペンス。
# temperature=1.0(実行するたびに変わる)
1回目: 火星に移住した人類が、地球に残した記憶をめぐって争うSFドラマ。
2回目: 江戸時代の料理人が現代にタイムスリップし、グルメ界を席巻するコメディ。
3回目: 孤島に流れ着いた音楽家が島民と交流しながら最後の交響曲を完成させる物語。
注意点: Temperatureを上げても「必ず違う出力になる」わけではない。あくまで低確率トークンが選ばれやすくなるだけなので、たまたま同じ出力になることもある。逆に temperature=0 でも、内部の処理によって微妙に出力が変わる場合がある。「0=完全に固定」ではなく「0=最も安定しやすい」と理解しておくのが正確。
よくある誤解まとめ
| 誤解 | 実際 |
|---|---|
| Claudeは前の会話を覚えている | ステートレス。毎回全履歴を渡しているだけ |
add_assistant_message は省略できる |
省略するとClaudeが自分の返答を知らない状態になる |
| システムプロンプトに「丁寧に」と書けば丁寧になる | 曖昧な指示は曖昧な動作を生む。具体的な条件・言葉で書く |
| temperature=0 なら毎回まったく同じ出力になる | 最も安定しやすいが、完全に固定されるわけではない |
system=None を渡しても問題ない |
APIエラーになる。値がある場合のみキーを追加する |
まとめ
- Claude APIはステートレス。会話履歴は自分でリストに蓄積して毎回渡す
add_assistant_messageの呼び忘れは会話が噛み合わなくなる典型的なバグ- 会話が長くなるほどトークン消費が増え、中間の情報が見落とされやすくなる(lost in the middle効果)
- システムプロンプトは曖昧な指示より具体的な条件・言葉・例で書く方が意図どおりに動く
system=NoneはAPIエラー。値がある場合のみパラメータに追加する- Temperature(0〜1)で出力の安定性と多様性を調整。「0=完全固定」ではなく「0=最も安定しやすい」
次回はレスポンスのストリーミング配信と構造化出力(プリフィル・JSONの安定生成)を扱う。