Claude APIでチャットボットを実装する:ストリーミングと会話履歴の正解
Claude API(Messages API)でチャットボットを作る手順。ストリーミング応答、会話履歴の持ち方、システムプロンプト、社内文書RAG、コストと落とし穴を実コードで。
「チャットボットなんて、APIを叩いて返すだけでしょ」
僕も最初はそう思っていました。実際、Messages APIに一行投げて返事をもらうだけなら、5分で動きます。問題はそこからです。
返事が10秒間まったく出てこなくて「壊れた?」とユーザーに離脱される。会話の3往復目で前の話を忘れる。社内文書を渡したつもりが、ボットが堂々と嘘をつく。気づいたら今月のAPI請求が想定の4倍。——僕が最初のチャットボットで全部踏んだ地雷です。
「APIを叩くだけ」と「使われるチャットボット」の間には、けっこう深い谷があります。今日はその谷の埋め方を、コピペで動くコードと一緒に書きます。
この記事の要点
- チャットボットの体感速度はストリーミング応答で決まる。返事を全部待たず、1文字ずつ流す
- Claude APIに記憶はない。会話履歴を毎回まるごと送り直すのが基本。だから履歴の持ち方がコストと品質を左右する
- 嘘を減らすにはシステムプロンプトと、社内文書を渡す簡単なRAG。「資料に無ければ無いと答えて」を仕込む
- モデルは用途で選ぶ。チャットの主力は速くて安い
claude-haiku-4-5、賢さが要る場面だけclaude-sonnet-4-6 - 公開前に必ず詰めるのはレート制限・コスト・エラー表示。賢さより先に、ここで事故る
まず全体像:チャットボットは「記憶のない相手」との往復
最初に勘違いしやすい点を1つ。Claude APIは、前回の会話を覚えていません。
LINEの友だちなら昨日の話を覚えていますが、Messages APIは毎回「はじめまして」の状態です。だから会話を続けるには、これまでのやり取り全部を、毎回リクエストに添えて送り直す。これがチャットボット実装の土台になる考え方です。
リクエストはこんな形をしています。
{
"model": "claude-haiku-4-5",
"max_tokens": 1024,
"system": "あなたは丁寧なサポート担当です。日本語で簡潔に答えてください。",
"messages": [
{ "role": "user", "content": "パスワードを忘れました" },
{ "role": "assistant", "content": "アカウント画面の「パスワード再設定」から手続きできます。" },
{ "role": "user", "content": "メールが届きません" }
]
}
messages 配列が会話履歴そのものです。user(ユーザー)と assistant(ボット)が交互に並び、いちばん下に最新の質問が来る。system(システムプロンプト)は配列の外に置く別枠で、ボットの性格と禁止事項を書く場所です。
ここを押さえると、残りの実装は「この配列をどう育て、どう速く返すか」の話に集約されます。
ストリーミングで「待たされ感」を消す
チャットボットの第一印象は、賢さより速さです。正確には、最初の1文字が出るまでの時間。
普通にレスポンスを待つと、長い回答ほど無言の時間が伸びます。10秒画面が固まれば、ユーザーは中身を読む前に閉じます。そこでストリーミングを使い、生成された端から文字を流す。ChatGPTやClaudeの画面で文字がパラパラ出てくる、あの挙動です。
Messages APIのストリーミングは、サーバーから細かいイベントが連続で届く仕組み(SSE)です。本文の断片は content_block_delta というイベントの delta.text に入っています。TypeScript SDKならそこを拾うだけ。Next.jsのAPIルートで、ブラウザへ素のテキストを流す最小例です。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
export async function POST(request: Request) {
const { messages } = await request.json();
// SDKのstreamヘルパー。返答が生成されるたびにイベントが届く
const stream = client.messages.stream({
model: "claude-haiku-4-5",
max_tokens: 1024,
system: "あなたは丁寧なサポート担当です。日本語で簡潔に答えてください。",
messages,
});
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
// 本文の断片(text_delta)だけを取り出してブラウザへ流す
stream.on("text", (text) => controller.enqueue(encoder.encode(text)));
stream.on("end", () => controller.close());
stream.on("error", (err) => controller.error(err));
},
});
return new Response(readable, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
stream.on("text", ...) はSDKが用意した便利メソッドで、本文だけを渡してくれます。content_block_delta を自分で判定したい場合は for await (const event of stream) でイベントを回し、event.type === "content_block_delta" && event.delta.type === "text_delta" を見ればOK。どちらでも結果は同じです。
ブラウザ側は、このテキストをチャンクごとに受け取って吹き出しに足していきます。Reactでの受信側はこんな具合です。
async function sendMessage(history: Message[], input: string) {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [...history, { role: "user", content: input }] }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let answer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
answer += decoder.decode(value, { stream: true });
// ここで answer を state に反映して吹き出しを更新する
}
return answer;
}
ポイントは { stream: true }。マルチバイトの日本語が断片の境目で割れても、文字化けせずに繋いでくれます。地味ですが、これが無いと「あ」が「あ?」になります。
会話履歴の持ち方:全部送ると財布が死ぬ
さっき「履歴は毎回まるごと送る」と書きました。素直にやると、会話が長引くほど送るトークンが雪だるま式に増えます。Claudeの料金は送ったトークン量で決まるので、履歴の管理=コスト管理です。
持ち方を整理すると、こうなります。
| 方式 | 何をする | 向く場面 | 注意点 |
|---|---|---|---|
| 全部送る | 履歴を全件そのまま添付 | 数往復で終わる短い会話 | 長くなるとコストと遅延が増える |
| 直近N件に絞る | 古い発言を捨てて最近だけ残す | FAQ、サポート窓口 | 昔の前提を忘れることがある |
| 要約して圧縮 | 古い会話を1段落に要約して持つ | 長い相談・伴走 | 要約にもAPI料金がかかる |
| 永続化して再開 | DBに保存し後から続ける | ログイン制のサービス | 個人情報の保存範囲を決める |
実務では「直近N件+必要なら要約」の組み合わせが扱いやすいです。直近10往復だけ残すなら、こう書けます。
const MAX_TURNS = 10; // user+assistant のペア数
function trimHistory(messages: Message[]): Message[] {
// 末尾から MAX_TURNS*2 件だけ残す。先頭が user で始まるよう調整
const trimmed = messages.slice(-MAX_TURNS * 2);
while (trimmed.length && trimmed[0].role !== "user") trimmed.shift();
return trimmed;
}
会話をまたいで再開させたいなら、DBに保存します。元記事でも使っている形ですが、messages をJSONで丸ごと持つのがいちばん簡単です。
import { db } from "@/lib/database";
export async function saveConversation(userId: string, messages: Message[]) {
return db.conversation.upsert({
where: { id: `${userId}-current` },
update: { messages: JSON.stringify(messages), updatedAt: new Date() },
create: { id: `${userId}-current`, userId, messages: JSON.stringify(messages) },
});
}
export async function loadConversation(userId: string): Promise<Message[]> {
const conv = await db.conversation.findUnique({ where: { id: `${userId}-current` } });
return conv ? (JSON.parse(conv.messages as string) as Message[]) : [];
}
保存するなら、最初に「何を保存しないか」を決めてください。クレジットカード番号やパスワードを履歴ごとDBに焼き込むと、後で消すのが大仕事になります。
システムプロンプトとRAGで「嘘」を減らす
賢いモデルでも、知らないことは平気で創作します。社内の制度や自社製品の仕様は、Claudeの学習データに入っていない。だから「根拠」を渡す必要があります。
二段構えで効きます。1つはシステムプロンプトで振る舞いを縛ること。もう1つが、社内文書を検索して質問と一緒に渡す**RAG(検索拡張生成)**です。難しく聞こえますが、やることは「関連しそうな文書を探して、system に貼り付けてから質問する」だけ。
import { searchDocuments } from "@/lib/vector-search";
async function answerWithDocs(query: string, history: Message[]) {
// 1. 質問に関連する社内文書を検索(上位5件)
const docs = await searchDocuments(query, { limit: 5 });
const context = docs.map((d) => `---\n# ${d.title}\n${d.content}`).join("\n");
// 2. 「資料に無ければ無いと言え」を必ず仕込む
const system = `次の社内文書だけを根拠に、日本語で答えてください。
文書に答えが無い場合は推測せず「資料には見つかりませんでした」と答え、
サポート窓口への問い合わせを案内してください。
${context}`;
// 3. 文書を添えてストリーミングで回答
return client.messages.stream({
model: "claude-sonnet-4-6", // 根拠の読解が要るのでSonnet
max_tokens: 1024,
system,
messages: history,
});
}
肝は2番のひと言、「資料に無ければ無いと言え」です。これが無いと、検索が空振りしたときにモデルが想像で埋めます。サポート品質の問題にも、法務の問題にもなる。実装で最初に固めるべき一文です。
ベクトル検索の中身(searchDocuments)は、文書をベクトル化してDBに入れておき、質問を同じ方法でベクトル化して近いものを引く、という仕組みです。文書が数十件ならキーワード一致でも十分。最初から大げさな仕組みは要りません。
コピペで動く最小チャット(APIキー1つで動く)
UIやDBを用意する前に、「履歴を送り直す」「ストリーミングで流す」の2点だけを手元で確かめましょう。環境変数 ANTHROPIC_API_KEY を設定し、node chat.mjs で動きます。ターミナルがそのままチャット画面になります。
import Anthropic from "@anthropic-ai/sdk";
import { createInterface } from "node:readline/promises";
const client = new Anthropic(); // 環境変数 ANTHROPIC_API_KEY を自動で読む
const rl = createInterface({ input: process.stdin, output: process.stdout });
// これが会話履歴。毎回まるごと送り直す
const messages = [];
console.log("チャット開始(exit で終了)\n");
while (true) {
const input = await rl.question("あなた: ");
if (input.trim() === "exit") break;
messages.push({ role: "user", content: input });
process.stdout.write("ボット: ");
let answer = "";
// ストリーミングで1文字ずつ表示する
const stream = client.messages.stream({
model: "claude-haiku-4-5",
max_tokens: 1024,
system: "あなたは親切なアシスタントです。日本語で簡潔に答えてください。",
messages,
});
stream.on("text", (text) => {
answer += text;
process.stdout.write(text);
});
await stream.finalMessage(); // 完了を待つ
// ボットの返事も履歴に積む(次の往復で文脈になる)
messages.push({ role: "assistant", content: answer });
console.log("\n");
}
rl.close();
console.log(`会話を ${messages.length} 件保存しました。`);
npm install @anthropic-ai/sdk を済ませてから実行してください。動かすと分かりますが、messages.push でボットの返事を履歴に積んでいるから、2往復目以降にちゃんと文脈が効きます。試しに「前の質問は何だっけ?」と聞いてみると、記憶の正体が messages 配列だと体感できます。
このスケルトンの model を claude-sonnet-4-6 に変えれば賢くなり、system を書き換えればキャラが変わり、messages をDBに保存すれば会話が再開できる。チャットボットの全機能が、この30行の延長線上にあります。
レート制限とコスト:公開前に必ず詰める
動くと嬉しくて公開したくなりますが、ここで一拍置きます。僕が請求額に殴られたのもこの段階でした。
コストは送受信したトークンで決まります。チャットの主力には速くて安い claude-haiku-4-5(入力 $1 / 出力 $5 ・100万トークンあたり)を使い、RAGの読解など賢さが要る場面だけ claude-sonnet-4-6(入力 $3 / 出力 $15)に上げる。この使い分けだけで請求額がかなり変わります。最新の価格は必ず公式の料金ページで確認してください。
加えて効くのがプロンプトキャッシュです。システムプロンプトに長い社内文書を毎回貼るRAGでは、同じ前半部分をキャッシュすると、2回目以降の入力料金が大きく下がります。固定の文脈が長いボットほど効果が出ます。
レート制限は、短時間に送れるリクエスト数とトークン数の上限です。超えると 429 エラーが返ります。対策は3つ。
- 429が返ったら、少し待って自動で再試行する(指数バックオフ)
- 1ユーザーあたりの送信間隔を空け、連打を弾く
max_tokensを用途に対して大きくしすぎない(無駄に上限を消費しない)
そしてエラー表示。ネットワークが切れたときに空の吹き出しだけ残る、二重送信で回答が混ざる、といった崩れは信頼を一発で削ります。送信ボタンの無効化、AbortController でのキャンセル、タイムアウト、「混み合っています。少し待って再送してください」の文言。この4点は公開前のチェックリストに必ず入れてください。
API設計をもう一段しっかり固めたいならClaude Code API開発を、外部のフォームやCRMへ通知を飛ばす連携はClaude Code Webhook実装を先に読むと、後から作り直す範囲が減ります。改善のための計測はClaude Code分析実装が参考になります。
よくある質問
Q. Claude APIキーをフロントエンドに置いてもいい? ダメです。ブラウザに置くと誰でも盗めて、あなたの請求で使われます。必ずAPIルート(サーバー側)でキーを持ち、ブラウザはそのルートを叩くだけにしてください。
Q. どのモデルを選べばいい?
チャットの主力は速くて安い claude-haiku-4-5 で始め、文書の読解や込み入った判断が要る場面だけ claude-sonnet-4-6 に上げる。僕はこの二段構えに落ち着きました。claude-sonnet-4-20250514 のような旧IDは引退予定なので、現行IDに移行を。
Q. ストリーミングは必須?
体感速度のために強く推奨します。短い定型返答だけなら通常の messages.create() でも十分ですが、回答が長くなるボットほど、最初の1文字が早く出るストリーミングの差が効きます。
Q. RAGはベクトルDBが必須? いいえ。文書が数十件ならキーワード一致でも実用になります。件数が増えて検索精度が足りなくなってから、ベクトル検索に移れば十分です。
Q. 会話履歴はどこまで保存していい? パスワードやカード番号などの機微情報は履歴に残さない設計が前提です。保存するなら、何を保存しないか・いつ消すか・削除要求にどう応えるかを先に決めてください。
実際に試した結果
この記事のコードを試した範囲では、最小チャットはNode.js 20系で @anthropic-ai/sdk を入れるだけで動きました。ストリーミングは stream.on("text", ...) が素直で、自分で content_block_delta を判定するより事故が少なかったです。
順番として効いたのは、賢さを詰める前に履歴の長さ・コスト・エラー表示を先に通すことでした。僕が最初に請求額で殴られたのは、まさにここを後回しにしたからです。claude-haiku-4-5 を主力にして、賢さが要る場面だけ claude-sonnet-4-6 に上げる。これだけでコストの不安はかなり消えます。
「APIを叩くだけ」から一歩進んで、ストリーミング・履歴・根拠の3点を押さえれば、実際に使われるチャットボットになります。実装レビューや社内導入の相談は研修・相談から受け付けています。次に学ぶ教材を探すなら教材一覧もどうぞ。公式の一次情報はClaude Messages APIドキュメントが確実です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。