Claude API でチャットボットを作った。最初の5ターンは完璧だった。ユーザーの質問に的確に答え、文脈を踏まえた自然な会話が続く。しかし10ターンを超えたあたりから異変が起きる——AIが3ターン前に伝えた情報を忘れ、同じ説明を繰り返し始め、トークン消費が1リクエストあたり数万トークンに膨らんでいく。
これは Claude API で会話システムを構築した開発者のほぼ全員が直面する問題です。Messages API はステートレスなので、会話の継続は開発者側で全てのメッセージ履歴を毎回送信することで実現します。この単純なアプローチは短い会話では問題ないが、本番環境では3つの致命的な壁にぶつかる。コンテキストウィンドウの枯渇、重要情報の記憶喪失、そして不適切な入出力の安全性リスクです。
ここでは複数の本番プロジェクトで試行錯誤しながら確立した統合アーキテクチャを解説します。単なる API の使い方ではなく、「なぜこの設計にしたのか」「他のアプローチを選ばなかった理由」まで踏み込む。
本番会話システムに必要な3つの記憶層
人間の記憶が「短期記憶」「エピソード記憶」「意味記憶」の3層で機能しているように、本番の会話システムにも3つの記憶層が必要です。
第1層:ワーキングメモリ(直近の会話) — 直近N件のメッセージをそのまま保持します。会話の即座の文脈を提供し、代名詞の解決や直前の話題への参照を可能にします。保持件数が多すぎるとトークンコストが爆発し、少なすぎると文脈断絶が起きます。
第2層:エピソード記憶(会話の要約) — 古くなったメッセージ群をClaude自身に要約させ、圧縮された形で保持します。「ユーザーは先ほどPythonの非同期処理について質問し、asyncio.gatherの使い方を理解した」のような粒度で、会話の流れを失わずにトークンを節約します。
第3層:セマンティック記憶(ユーザーの属性・好み) — ベクトルDBに保存される長期的な情報。ユーザーの技術レベル、好みのプログラミング言語、過去の質問傾向などを蓄積し、セッションをまたいでパーソナライズされた応答を提供します。
公式ドキュメントには「全メッセージを送信してください」としか書かれていないが、実際の本番環境ではこの3層を組み合わせないと、コストとユーザー体験のどちらかを犠牲にすることになる。
スライディングウィンドウ + 要約バッファの実装
第1層と第2層を統合する実装パターンを見ていこう。ポイントは、古いメッセージを単に捨てるのではなく、要約に変換してから圧縮することです。
import Anthropic from "@anthropic-ai/sdk";
interface ConversationMemory {
summary: string; // 第2層: これまでの会話要約
recentMessages: Array<{ // 第1層: 直近のメッセージ
role: "user" | "assistant";
content: string;
}>;
totalTurns: number;
}
const client = new Anthropic();
const MAX_RECENT_MESSAGES = 10; // 直近5ターン分(user + assistant)
const SUMMARY_TRIGGER = 8; // この件数を超えたら要約を生成
async function chat(
memory: ConversationMemory,
userMessage: string
): Promise<{ response: string; updatedMemory: ConversationMemory }> {
memory.recentMessages.push({ role: "user", content: userMessage });
memory.totalTurns++;
// 直近メッセージが閾値を超えたら要約を生成して圧縮
if (memory.recentMessages.length > MAX_RECENT_MESSAGES) {
try {
memory.summary = await compressToSummary(
memory.summary,
memory.recentMessages.slice(0, -SUMMARY_TRIGGER)
);
memory.recentMessages = memory.recentMessages.slice(-SUMMARY_TRIGGER);
} catch (error) {
// 要約生成に失敗しても会話は継続(古いメッセージを単純に切り捨て)
console.error("Summary generation failed, truncating:", error);
memory.recentMessages = memory.recentMessages.slice(-MAX_RECENT_MESSAGES);
}
}
const systemPrompt = buildSystemPrompt(memory.summary);
try {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 2048,
system: systemPrompt,
messages: memory.recentMessages,
});
const assistantMessage =
response.content[0].type === "text" ? response.content[0].text : "";
memory.recentMessages.push({ role: "assistant", content: assistantMessage });
return { response: assistantMessage, updatedMemory: memory };
} catch (error) {
if (error instanceof Anthropic.RateLimitError) {
await new Promise((r) => setTimeout(r, 2000));
return chat(
{ ...memory, recentMessages: memory.recentMessages.slice(0, -1) },
userMessage
);
}
throw error;
}
}
async function compressToSummary(
existingSummary: string,
messagesToCompress: Array<{ role: string; content: string }>
): Promise<string> {
const conversationText = messagesToCompress
.map((m) => `${m.role}: ${m.content}`)
.join("\n");
// 要約にはHaikuを使いコスト削減(品質差はほぼ体感できない)
const response = await client.messages.create({
model: "claude-haiku-4-5",
max_tokens: 500,
messages: [
{
role: "user",
content: `以下の会話履歴を、重要な情報(ユーザーの質問意図、解決した問題、未解決の話題、ユーザーが示した好み)を保持しながら簡潔に要約してください。
既存の要約:
${existingSummary || "(なし)"}
新しい会話:
${conversationText}
要約(300文字以内):`,
},
],
});
return response.content[0].type === "text"
? response.content[0].text
: existingSummary;
}
function buildSystemPrompt(summary: string): string {
const base = `あなたはユーザーの技術的な質問に答えるアシスタントです。
丁寧かつ実践的に回答し、コード例を含めてください。`;
if (\!summary) return base;
return `${base}
【これまでの会話の要約】
${summary}
上記の要約を踏まえて、文脈に沿った回答をしてください。ただし要約の内容を繰り返す必要はありません。`;
}なぜ claude-haiku-4-5 を要約に使うのか。要約タスクは入力が長く出力が短い典型的なパターンで、Haiku の低コスト・高速な特性が最も活きる。Sonnet で要約すると品質は若干上がるが、コストが約5倍になり、要約精度の差は本番環境ではほぼ体感できません。モデルの使い分けは会話システムのコスト最適化の要です。
ベクトルDB を使った長期メモリの構築
セッションをまたぐ記憶を実現するには、第3層のセマンティック記憶が必要になります。ここでは pgvector を使った実装を示す。重要なのは「何を記憶するか」の選別ロジックです。全ての会話を保存すると検索ノイズが増え、逆に応答品質が下がる。
import Anthropic from "@anthropic-ai/sdk";
import pg from "pg";
const client = new Anthropic();
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// 会話から記憶すべき情報を抽出する
async function extractMemories(
conversation: Array<{ role: string; content: string }>
): Promise<Array<{ content: string; category: string; importance: number }>> {
const conversationText = conversation
.map((m) => `${m.role}: ${m.content}`)
.join("\n");
try {
const response = await client.messages.create({
model: "claude-haiku-4-5",
max_tokens: 1000,
messages: [
{
role: "user",
content: `以下の会話から、将来の会話で役立つ情報を抽出してJSON配列で返してください。
抽出基準:
- ユーザーの好み(preference): 使用言語、フレームワーク、コーディングスタイル
- 事実情報(fact): プロジェクト名、技術スタック、チーム規模
- 文脈情報(context): 現在取り組んでいる課題、学習段階
抽出不要:
- 一般的な技術質問の内容(検索すれば分かる情報)
- 挨拶や雑談
- 一時的な話題
会話:
${conversationText}
JSON配列:
[{"content": "...", "category": "preference|fact|context", "importance": 0.0-1.0}]`,
},
],
});
const text = response.content[0].type === "text" ? response.content[0].text : "[]";
const jsonMatch = text.match(/\[[\s\S]*\]/);
if (\!jsonMatch) return [];
const parsed = JSON.parse(jsonMatch[0]);
// 重要度が低い情報は捨てる(ノイズ削減)
return parsed.filter(
(m: { importance: number }) => m.importance >= 0.5
);
} catch (error) {
console.error("Memory extraction failed:", error);
return []; // 抽出失敗時も会話は継続できる
}
}
// ベクトル検索で関連する記憶を取得
async function retrieveRelevantMemories(
userId: string,
query: string,
limit: number = 5
): Promise<string[]> {
try {
const embedding = await generateEmbedding(query);
// similarity * importance の複合スコアでランキング
const result = await pool.query(
`SELECT content, importance,
1 - (embedding <=> $1::vector) AS similarity
FROM user_memories
WHERE user_id = $2
AND created_at > NOW() - INTERVAL '90 days'
ORDER BY (1 - (embedding <=> $1::vector)) * importance DESC
LIMIT $3`,
[JSON.stringify(embedding), userId, limit]
);
return result.rows.map((row: { content: string }) => row.content);
} catch (error) {
console.error("Memory retrieval failed:", error);
return []; // 検索失敗時は空配列でフォールバック
}
}
async function generateEmbedding(text: string): Promise<number[]> {
const response = await fetch("https://api.openai.com/v1/embeddings", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "text-embedding-3-small",
input: text,
}),
});
if (\!response.ok) {
throw new Error(`Embedding API error: ${response.status}`);
}
const data = await response.json();
return data.data[0].embedding;
}ここで注意すべきは similarity * importance による複合スコアリングです。単純なベクトル類似度だけで検索すると、「Pythonが好き」という些細な好みと「現在Kubernetesへの移行プロジェクトを主導している」という重要な文脈が同じ重みで扱われてしまう。重要度をフィルタリングではなくランキングに使うことで、関連性と重要性のバランスを取っています。
また、created_at > NOW() - INTERVAL '90 days' で古い記憶を自動的に減衰させています。ユーザーの技術スタックや関心は変化するため、古い記憶が検索結果を汚染するのを防ぐ目的です。
人格と応答トーンの一貫性を保つ設計
会話が長くなると、AIの応答トーンがブレ始める。最初は丁寧語だったのに途中からカジュアルになったり、専門用語の使い方が一貫しなかったりします。原因はシステムプロンプトの設計にあります。
私が本番で使っているアプローチはペルソナレイヤリングです。システムプロンプトを「基本人格」「応答ルール」「コンテキスト適応」の3層に分割します。
interface PersonaConfig {
basePersonality: string;
responseRules: string[];
adaptiveRules: Record<string, string>;
}
function buildLayeredSystemPrompt(
persona: PersonaConfig,
userLevel: "beginner" | "intermediate" | "advanced",
conversationMood: "casual" | "professional" | "urgent"
): string {
// Layer 1: 基本人格(変更不可)
let prompt = `# あなたの人格\n${persona.basePersonality}\n\n`;
// Layer 2: 応答ルール(常に適用)
prompt += `# 応答ルール\n`;
for (const rule of persona.responseRules) {
prompt += `- ${rule}\n`;
}
// Layer 3: コンテキスト適応(動的に変化)
prompt += `\n# 現在の対話コンテキスト\n`;
prompt += `- ユーザーの技術レベル: ${userLevel}\n`;
if (userLevel === "beginner") {
prompt += `- 専門用語は必ず平易な言葉で言い換えてから使ってください\n`;
prompt += `- コード例には1行ごとにコメントを付けてください\n`;
} else if (userLevel === "advanced") {
prompt += `- 前提知識の説明は省略し、本質的な部分に集中してください\n`;
prompt += `- パフォーマンスやエッジケースにも言及してください\n`;
}
if (persona.adaptiveRules[conversationMood]) {
prompt += `- ${persona.adaptiveRules[conversationMood]}\n`;
}
return prompt;
}
// 使用例
const techAdvisor: PersonaConfig = {
basePersonality:
"実務経験豊富なシニアエンジニア。正確さを重視しつつ、実装の現実的なトレードオフも率直に伝える。わからないことは正直に言う。",
responseRules: [
"コードを示す場合は、エラーハンドリングを必ず含める",
"断定ではなく提案の形で回答する",
"一度に3つ以上の選択肢を提示しない(選択疲れを防ぐ)",
"ユーザーが誤解している場合、まず相手の理解を認めてから訂正する",
],
adaptiveRules: {
casual: "リラックスした口調で、ときどき個人的な経験も交える",
professional: "簡潔かつ構造的に回答する",
urgent: "最短の解決策を最初に提示し、詳細は後回しにする",
},
};なぜ3層に分けるのか。単一のシステムプロンプトだと、コンテキストに応じた調整を加えるたびにプロンプト全体を書き換える必要があり、意図しない人格変化が起きやすい。レイヤーを分離することで、基本人格を固定したまま、状況に応じた振る舞いだけを動的に調整できる。
入力バリデーションと出力フィルタリングの多層防御
本番の会話システムで最も見落とされがちなのが安全性です。ユーザーがプロンプトインジェクションを試みたり、個人情報を意図せず入力したりするケースに備える必要があります。
import Anthropic from "@anthropic-ai/sdk";
interface SafetyCheckResult {
safe: boolean;
reason?: string;
sanitizedInput?: string;
}
const client = new Anthropic();
async function validateInput(userInput: string): Promise<SafetyCheckResult> {
// Step 1: 正規表現ベースの高速フィルタ(APIコール不要)
const patterns = {
creditCard: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/,
injection:
/ignore\s+(all\s+)?previous\s+instructions|system\s*prompt|you\s+are\s+now/i,
};
if (patterns.creditCard.test(userInput)) {
return {
safe: false,
reason: "クレジットカード番号が含まれています。個人情報は入力しないでください。",
};
}
if (patterns.injection.test(userInput)) {
// インジェクション試行はブロックせず無害化する
const sanitized = userInput.replace(patterns.injection, "[filtered]");
return { safe: true, sanitizedInput: sanitized };
}
// Step 2: 長文や疑わしいパターンのみClaude Haikuで意味チェック
if (userInput.length > 500 || containsSuspiciousPatterns(userInput)) {
try {
const response = await client.messages.create({
model: "claude-haiku-4-5",
max_tokens: 100,
messages: [
{
role: "user",
content: `以下のユーザー入力を安全性の観点で分類してください。
回答は "safe" または "unsafe: [理由]" のみ。
入力: "${userInput.slice(0, 1000)}"`,
},
],
});
const result =
response.content[0].type === "text" ? response.content[0].text : "safe";
if (result.startsWith("unsafe")) {
return { safe: false, reason: result.replace("unsafe: ", "") };
}
} catch (error) {
// 安全性APIが失敗しても会話は継続(可用性優先)
console.error("Safety check failed:", error);
}
}
return { safe: true, sanitizedInput: userInput };
}
function containsSuspiciousPatterns(input: string): boolean {
const keywords = [
"jailbreak", "bypass", "override", "roleplay as",
"pretend you", "act as if", "DAN mode",
];
const lower = input.toLowerCase();
return keywords.some((kw) => lower.includes(kw));
}
// 出力のフィルタリング(応答後に実行)
function sanitizeOutput(output: string): string {
let sanitized = output;
// APIキー・トークンのパターンを除去
sanitized = sanitized.replace(
/\b(sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36}|AKIA[A-Z0-9]{16})\b/g,
"[REDACTED]"
);
// 内部パス情報の除去
sanitized = sanitized.replace(
/\/(?:home|Users|var|etc)\/[^\s"']+/g,
"[PATH_REDACTED]"
);
return sanitized;
}ここで意識しているのは2段構えのフィルタリングです。正規表現による高速チェックを第1段として全リクエストに適用し、意味的な判定が必要なケースだけ Claude Haiku に分類させる。安全性チェック自体のコストを最小限に抑える設計になっています。
安全性設計で犯しがちな間違いは「全ブロック」か「全許可」の二択にしてしまうことです。プロンプトインジェクションの疑いがある入力も、無害化してから処理することで、正当なユーザーの利便性を損なわずに済む。
トークンコストを月額70%削減する3つの施策
本番会話システムのコストは、放置すると驚くほど膨らむ。月間200万ターンの会話で月額$8,000を超えていたケースが、以下の3つの施策で$2,400まで削減できました。
施策1: Prompt Caching の活用 — システムプロンプトと長期メモリ部分は会話中ほぼ変わらないため、キャッシュが効く。
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 2048,
system: [
{
type: "text",
text: systemPromptBase, // 基本人格+応答ルール(変化しない部分)
cache_control: { type: "ephemeral" },
},
{
type: "text",
text: dynamicContext, // 会話要約+長期メモリ(ターンごとに変化)
},
],
messages: memory.recentMessages,
});
// キャッシュヒット時は入力トークンコストが90%削減される施策2: モデルルーティング — 全ての応答に Sonnet を使う必要はありません。簡単な確認応答や要約生成には Haiku を使い、複雑な技術的質問のみ Sonnet に振り分けることで、平均コストを40%削減できます。分類自体も Haiku で行えば分類コストは無視できるレベルです。
施策3: 要約の段階的圧縮 — 前述のスライディングウィンドウで要約を生成する際、要約自体も定期的に再圧縮します。50ターンの会話では、初期の要約は数行にまで凝縮され、ユーザーの根本的な目的と解決すべき問題だけが残る。これによりシステムプロンプトのトークン量を一定に保てる。
本番で遭遇する7つの落とし穴
実際に運用して初めてわかる問題を、対策とともに列挙します。
1. コンテキストウィンドウの silent degradation — APIはウィンドウを超えたときにエラーを返すが、その手前で送信すると、モデルが末尾のメッセージに注意を集中し、冒頭のシステムプロンプトを軽視する現象が起きます。使用率は70%以下に保つのが経験則です。
2. 要約の情報損失(Summarization Drift) — 要約を繰り返すたびに、微妙だが重要な情報が失われていく。「ユーザーはReact 18のSuspenseで困っている」が「ユーザーはReactで困っている」に劣化します。対策として、重要なキーワードやエンティティを要約とは別に構造化データとして保持することを勧める。
3. メモリ汚染(Hallucinated Memories) — Claude が推測した情報を長期メモリに保存してしまうケース。「Pythonが好きですか?」への推測回答を事実として保存するのは危険です。ユーザーが明示的に述べた事実のみを抽出するよう、プロンプトで厳密に制約する必要があります。
4. 安全性フィルターの過剰ブロック — フィルターを厳しくしすぎると、セキュリティの議論や脆弱性修正の質問までブロックしてしまう。技術系チャットボットではドメイン固有のホワイトリストを用意し、セキュリティ用語を含む正当な質問を許可します。
5. ストリーミング中のエラーリカバリ — SSEストリーミングの途中でネットワークエラーが発生すると、ユーザーには不完全な応答が表示されます。クライアント側で応答完了を検知し、不完全な場合は自動リトライする仕組みが不可欠です。
6. マルチターンの tool use 状態管理 — Tool Use を組み合わせた会話で、ツール実行結果を次のターンに正しく引き継ぐのは予想以上に複雑です。ツール結果をメッセージ履歴に含めると、要約時に構造が崩れやすい。ツール実行結果は要約対象から除外し、結果のテキスト表現だけを保持するのが安全なアプローチです。
7. 時刻・タイムゾーンの不整合 — 「さっき」「昨日」という相対時間表現の解決にはユーザーのタイムゾーンが必要です。これを忘れるとUTC基準で回答し、日本のユーザーに9時間ズレた情報を返すことになります。
監視メトリクスと品質改善サイクル
本番運用で追跡すべきメトリクスは4つに絞れます。
- 会話継続率: 10ターン以上会話を続けたユーザーの割合。低下していればコンテキスト管理に問題がある
- 要約精度スコア: 定期的にサンプリングし、要約が元の会話の重要情報を保持しているか評価する
- 安全性フィルター発動率: 高すぎれば過剰ブロック、低すぎれば見逃しの可能性
- トークンコスト/ターン: 会話が長くなっても一定に収束していれば、コンテキスト管理が正しく機能している
A/Bテストでは、要約タイミング(8メッセージ vs 12メッセージ)、長期メモリの取得件数(3件 vs 5件 vs 7件)、モデルルーティングの閾値を段階的に調整します。最も効果が大きかったのは要約タイミングの調整で、8メッセージで要約を開始するパターンが、コストと会話品質のバランスで最良の結果を示しました。
全体統合: 安全な会話ループの完成形
ここまでのコンポーネントを統合した、本番対応の会話ループを示す。
async function productionChatLoop(
userId: string,
memory: ConversationMemory,
userInput: string
): Promise<{ response: string; updatedMemory: ConversationMemory }> {
// 1. 入力の安全性チェック
const inputCheck = await validateInput(userInput);
if (\!inputCheck.safe) {
return {
response: `申し訳ありませんが、この入力は処理できません。${inputCheck.reason}`,
updatedMemory: memory,
};
}
const safeInput = inputCheck.sanitizedInput || userInput;
// 2. 長期メモリから関連情報を取得
const longTermMemories = await retrieveRelevantMemories(userId, safeInput);
// 3. コンテキスト付きシステムプロンプトを構築
const systemPrompt = buildContextualSystemPrompt(
memory.summary,
longTermMemories
);
// 4. 会話を実行(スライディングウィンドウ+要約バッファ)
const result = await chat(memory, safeInput);
// 5. 出力の安全性フィルタリング
result.response = sanitizeOutput(result.response);
// 6. 長期メモリに保存すべき情報を非同期で抽出(応答を遅延させない)
extractMemories(memory.recentMessages.slice(-4)).then((memories) => {
for (const mem of memories) {
saveToVectorDB(userId, mem).catch(console.error);
}
});
return result;
}会話データの分析には Claude API チャットボット構築の基礎 で紹介しているロギングパターンが応用できます。ベクトルDBを使ったメモリの永続化については pgvector を使った本番メモリ実装 で詳細を解説しています。APIの認証・セキュリティの基本は Claude API 本番セキュリティガイド も参照してほしい。
次にやるべきことは1つです。まずスライディングウィンドウ + 要約バッファのコードを実装し、10ターンの会話で要約が正しく生成されるかを確認します。そこから第3層の長期メモリを段階的に追加し、最後に安全性フィルターを統合します。一度に全てを実装しようとすると問題の切り分けが困難になるため、レイヤーごとの段階的な構築を勧める。