【AWS】コラム

Amplify AI Kit で「人格切り替えAIチャット」を作ってみた

作成者: ゼネックコミュニケーション|Aug 20, 2026, 8:00:55 AM

 こんにちは~ 浮田です。  

■  TL;DR

Amplify AI Kit はストリーミングチャットも会話履歴も認証も、ほぼコードを書かずに動きます。 公式ドキュメントに記載されていた、「数時間で完成」は半分本当。

実際は、Bedrockのモデル指定が通らない罠と、ライブラリ側のバグ(空のAssistantが毎回残る)で、時間も溶けました。

この記事は、きれいに動いた話より「どこで詰まって、どう抜けたか」を中心に書いています。

 

  はじめに  

「なんか簡単に動くAIアプリ、作れないかな〜」

そう思って見つけたのが AWS Amplify AI Kit「TypeScriptだけで、数時間でフルスタックのAIアプリが作れる」と聞いて、半信半疑で触ってみたら……本当に動くものができてしまいました。

この記事は、Amplify AI Kit で「普通のチャット」と「ことわざ翻訳機」を切り替えられるAIチャットアプリを作り、AWSにデプロイするまでの体験記です。

きれいに動いた部分より、詰まった部分のほうが多いので、これから同じことをやる人(=3ヶ月前の自分)の助けになればと思って、ハマりどころも全部書きました。

 

■  Amplify AI Kit とは    

ひとことで言うと、TypeScriptだけでAIアプリのフロントからバックエンドまで作れるツールキットです。

特にありがたいのが、「AIの返事が少しずつ流れてくる」ストリーミングチャットUIが、ほぼ完成品で付いてくること。

これ、自前で作ろうとすると地味に大変なんです。ざっくり挙げるだけでも、

  • バックエンド: Bedrockが返すトークンの断片を、ブラウザまでリアルタイムに中継する経路(WebSocket等)を用意する
  • フロント: 届いた断片をメッセージに逐次追記して描画、自動スクロール、生成中の状態管理、Markdownの途中描画……

と、「ちゃんと動く」まで持っていくと数日溶けます。

Amplify AI Kit は、この一式(チャットUI+ストリーミング中継+会話履歴の保存+認証)を繋がった状態で渡してくれます。

 

■   作るもの

今回作ったのは、タブで2つの人格を切り替えられるAIチャットです。

  • 普通のチャット … なんでも答える汎用アシスタント
  • ことわざ翻訳機 … 「仕事で失敗した」と言うと、ぴったりのことわざを返してくれる

しかも、2つの会話履歴は別々に・ユーザーごとに保存されます。後で書きますが、これがAmplifyだと驚くほど簡単に実現できました。

完成したものはこんな感じです。

 

 環境とセットアップ  

筆者の環境は以下のとおりです。

  • Node.js v20 / npm 10
  • AWS CLI 2.x(aws configure 済み、リージョンは東京 ap-northeast-1
  • AWSアカウント、Bedrock のモデルアクセス

 プロジェクトの作成  

npm create amplify@latest -- --yes

これで amplify/ フォルダ(バックエンド定義)と package.json が生成されます。

注意点として、これはバックエンドだけです。

フロント(React)は含まれないので、後から Vite で足します。
※Vite(ヴィート)は、ウェブ開発を速く行うための次世代フロントエンド開発・ビルドツールです。

ちなみに最初、ネットの記事を見て amplify push でデプロイしようとして止まりました。amplify push は Amplify Gen 1 のコマンドで、今の Gen 2 では使いません。Gen 2 は次のコマンド体系です。

npx ampx sandbox # 開発用のクラウド環境(変更を自動反映)

npx ampx sandbox --once # 1回だけデプロイして終了

 

   実装

  バックエンド: 会話ルートを2つ定義する

amplify/data/resource.ts に、AIチャットの本体を書きます。

a.conversation() が会話ルートで、これ1つで「マルチターン会話+履歴の永続化+ストリーミング」が用意されます。

今回のキモは、人格ごとにルートを分けたこと。chat(普通)と proverb(ことわざ)の2つを並べるだけです。

---

import { type ClientSchema, a, defineData } from '@aws-amplify/backend';

const MODEL = 'Claude Haiku 4.5' as const;

const schema = a.schema({

// ① 普通のAIチャット

chat: a

.conversation({

aiModel: a.ai.model(MODEL),

systemPrompt: [

'あなたは親しみやすい日本語のAIアシスタントです。',

'ユーザーの質問や雑談に、わかりやすく自然に答えてください。',

].join('\n'),

})

.authorization((allow) => allow.owner()),

// ② ことわざ翻訳機

proverb: a

.conversation({

aiModel: a.ai.model(MODEL),

systemPrompt: [

'あなたは「ことわざ翻訳機」です。',

'ユーザーの悩みや状況に、ぴったりの日本のことわざを1つ選び、',

'ことわざ・意味・一言アドバイスを簡潔に答えてください。',

].join('\n'),

})

.authorization((allow) => allow.owner()),

});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({

schema,

authorizationModes: { defaultAuthorizationMode: 'userPool' },

});

---

会話ルートは「ログインしたユーザーが使う」前提です。なので全体の認証方式を 'userPool'(Cognitoでのログイン)にして、さらに各会話に allow.owner() を付けて「自分の会話は自分だけが見られる」ようにしています。

ここが感動ポイントでした。proverb: の数行を足しただけで、「別人格・別履歴のチャット」がもう1つ増える。裏側の DynamoDB テーブルも AppSync の配線も、全部自動で用意されます。

   会話の「記憶」はAmplifyが面倒を見てくれる

地味にすごいのが、a.conversation() ルートはマルチターンの会話文脈を自動で保持してくれることです。「さっき名前を伝えたら、後の質問でちゃんと覚えている」あれです。

仕組みはシンプルで、

  1. 会話ごとに、やり取りしたメッセージが DynamoDB に永続化される
  2. 新しいメッセージを送ると、裏のハンドラがその会話の過去メッセージをまとめて読み込み、Bedrock に渡す
  3. だからモデルは「これまでの流れ」を踏まえて返事できる

つまり、「メモリ」という独立した部品があるわけではなく、会話履歴の保存=そのまま記憶になっているわけです。自分でメッセージ履歴を保持してプロンプトに詰め直す、みたいなコードは一切書いていません。

ただし注意点もあって、

  • 記憶は会話単位。会話Aは会話Bの内容を知らない(新しい会話を始めると、まっさらな状態から)
  • 会話横断の長期メモリ(要約して覚えておく等)は標準では無いので、欲しければ自作が必要(※ 自前で実装するほか、Bedrock AgentCore Memory のような長期メモリ用のサービスを組み合わせる手もあるようです。今回は試していません。)
  • 会話がすごく長くなると、モデルのコンテキスト上限の都合で古い発言が落ちることはある(普通の長さなら問題なし)

実際に「私の名前は浮田です。覚えておいて」→(別の話を挟む)→「私の名前は?」と試すと、ちゃんと覚えていました。

さらに今回は過去の会話を一覧から開き直して続きを話せるようにもしたので、リロードしても文脈ごと再開できます。

実際に覚えてるか試しました。

   フロント: 認証もチャットUIも既製品

フロント側(src/App.tsx まわり)は、Amplify既製のコンポーネントを並べるだけです。

  • <Authenticator> … ログイン/サインアップ画面(Amplify製)
  • <AIConversation> … チャットUI本体(Amplify製、ストリーミング込み)
  • useAIConversation('chat' | 'proverb') … データ取得フック(Amplify製)

tsx

const client = generateClient<Schema>({ authMode: 'userPool' });

const { useAIConversation } = createAIHooks(client);

function Chat({ mode }: { mode: 'chat' | 'proverb' }) {

const [{ data: { messages }, isLoading }, sendMessage] = useAIConversation(mode);

return (

<AIConversation

messages={messages}

isLoading={isLoading}

handleSendMessage={sendMessage}

/>

);

}

上にタブを置いて、押されたモードで useAIConversation を切り替えるだけ。チャットの吹き出しも入力欄もストリーミング表示も、1行も書いていません。

   デプロイ

npx ampx sandbox --once # バックエンドをAWSへ。amplify_outputs.json が生成される

npm run dev # フロント起動(http://localhost:5173)

npx ampx sandbox --once は初回4分ほどで CloudFormation スタックを作成し、接続情報(amplify_outputs.json)を吐きます。これをフロントが読み込むので、フロント起動より先に必須です。

 

   動作確認  

ブラウザで開いて、Create Account → メールで届く確認コードを入力 → ログイン。

「仕事でミスして落ち込んでる」と入れると、ことわざ翻訳機がことわざを返してくれて、タブを切り替えると普通のチャットで雑談もできる。返事が少しずつ流れてくる、あのストリーミングもちゃんと動く。

本当に数時間でここまで来たことに少し感動しました。

 

 ストリーミングは、1行も書いていない  

ここで強調したいのが、あの「返事が少しずつ流れてくる」ストリーミングのために、私はコードを1行も書いていないということです。全部 Amplify AI Kit が肩代わりしてくれました

何をやってくれたかを分解すると——

バックエンド側

a.conversation() を書いただけで、Amplify が裏で、

  • Bedrock のストリーミングAPI(ConverseStream)を呼んでトークンの断片を受け取るハンドラ
  • その断片を AppSync のサブスクリプション(WebSocket)でブラウザへリアルタイム配信する経路
  • やり取りを DynamoDB に保存する処理

を生成してくれます。

冒頭で「自前だと数日溶ける」と書いた中継部分が、丸ごと不要になりました。

フロント側は useAIConversation フックが、届いた断片を正しい順番に組み立ててメッセージに逐次反映し、生成完了を検知してローディングを解除する——という処理を内部で全部やってくれます。

私が書いたのは実質これだけ。

<AIConversation

messages={visibleMessages}

isLoading={isLoading}

handleSendMessage={sendMessage}

/>

ストリーミング固有の処理はゼロ。これはすごいな~と思いました。

ですが、但し書きを1つ。

ストリーミング周りで唯一こちらが手を入れたのが、後述する「空メッセージの除去」でした。そしてそれはAI Kit 自身のストリーミング実装のバグを回避するコードです。

なので実態としては、ストリーミングはタダで手に入ったけど、そのぶんAI Kitのバグも1つ踏んだ、という感じです。ラクできる代わりにライブラリのバグも込みで引き受ける。

まあそういうものだよな、と。

 

 「保存された会話」を取り出して、一覧表示する  

a.conversation() を作ると、DynamoDB に2種類のテーブルができます。

テーブル

中身

LINEで例えると

ConversationChat

会話そのもの(スレッド/部屋) が1件=1レコード

トークルーム

ConversationMessageChat

その部屋での発言1つ1つ

吹き出し

 

各メッセージは「どの会話(部屋)に属すか」(conversationId)を持っています。部屋があって、その中に発言がぶら下がるというわけです。

 

 なぜ勝手に保存されるのか

a.conversation() を定義しただけで、Amplify がこの2テーブルと読み書きAPIを自動生成します。だからチャットすれば、メッセージは何もしなくてもメッセージテーブルに保存される。「保存」は元々できていた、というのはこれです。

チャットするには「どの部屋を開くか」を決める必要があります。最初のコードはこうでした。

useAIConversation('chat'); // ← どの部屋か(id)を渡していない

ライブラリの中身はこういうルールでした。

const conversation = id
? await clientRoute.get({ id }) // idがある → その部屋を開く
:

await clientRoute.create(); // idが無い → 新しい部屋を作る ★

idを渡していないので、毎回 ★ の「新しい部屋を作る」が走る

しかもこのフックは画面を開く/リロードするたびに動く

→ つまり「開くたびに、空っぽの新しい部屋を1個作っては放置」していた。

作った部屋を一覧表示する画面も無かったので、古い部屋は見えないままDBに溜まって9個になっていた、というわけです。

 

 一覧・復元を実装する  

やることは2つだけでした。

① 過去の会話を一覧取得

const { data: conversations } = await client.conversations.chat.list();

② 選んだ会話をIDで復元

useAIConversation('chat', { id: 選んだ会話のid });

これで「サイドバーに過去の会話が並び、クリックすると続きから話せる」UIになりました。

list() / get() / create() / delete() が会話ルートに最初から生えているので、ChatGPT風の履歴サイドバーがほぼこれだけで作れます。

空の会話が増える問題には、起動時に最新の会話を自動で開く(一覧が空のときだけ新規作成する)という方針で対処しました。これで、ただ開いただけで空会話が増えることはなくなります。

ハマりどころとして、復元した会話をストリーミング表示している最中に「新規作成された会話ID」を裏で差し替えると、コンポーネントが作り直されて表示が途切れる。なので新規IDは「一覧の更新とハイライト」だけに使い、表示中の会話そのものは差し替えない、という工夫が要りました。

 

 詰まったところ  

きれいに動いた話はここまで。ここからが、この記事のいちばん書きたかった部分です。

  ① モデル指定が、そのままでは通らない

個人で安く遊びたいだけなので、「とりあえず一番安い Haiku 系でいいや」と軽い気持ちで a.ai.model('Claude 3.5 Haiku') と書きました。

が、デプロイする前に「そういえば東京でこのモデル使えるんだっけ?」と気になって、Bedrock の対応状況を CLI で確認。すると、東京では素直に使えないと判明します。そこから芋づる式に出てきたのが、3段階の罠でした(やみくもにデプロイせず、先に潰せたのは結果的に正解でした)。

  1. Claude 3.5 Haiku は、東京リージョンでは素の指定(on-demand)では使えなかった(2024年11月の提供開始時、配備は米国リージョン中心。東京を起点に呼ぶにはクロスリージョン推論プロファイルが要るが、私の環境ではその経路も見当たらなかった)
  2. 代わりに Claude 3 Haiku を試すと、今度は 「Legacy 扱いで、30日使ってないとアクセス拒否」
  3. じゃあ最新の Claude Haiku 4.5 だ、と思ったら 「on-demand では呼べない。推論プロファイルを使え」 CLIで一個ずつ潰しました。

# 東京で使えるモデルを確認

aws bedrock list-foundation-models --by-provider anthropic --region ap-northeast-1 `
--query "modelSummaries[].modelId" --output json

# 推論プロファイルを確認

aws bedrock list-inference-profiles --region ap-northeast-1

結論として、Amplify では a.ai.model('Claude Haiku 4.5') とフレンドリー名で指定すれば、内部で global.anthropic.claude-haiku-4-5-...(global推論プロファイル)にマップされ、必要な権限も自動で付くとわかりました。

自分で推論プロファイルIDを書く必要はなかった。最初からこれにすれば良かったんです。

もしCLIで気づかず、そのままデプロイしていたら?

気になったので、わざと Claude 3.5 Haiku のままデプロイして試してみました。

すると——TypeScriptの型チェックも通り、ampx sandbox のデプロイも普通に成功してしまう。

'Claude 3.5 Haiku' は Amplify の対応表にある正規の名前なので、型でもデプロイでも弾かれないんです。エラーが出るのは実際にチャットを送信した瞬間で、なかなか返信がかえって来ません。CloudWatch のログにこう残っていました:

ERROR ... Sending Bedrock Converse Stream request

ERROR ... ValidationException: The provided model identifier is invalid.(HTTP 400)

つまり「ビルドもデプロイも素通りして、実行時に初めてコケる」エラーでした。だからこそ、デプロイ前にCLIで確認しておく価値がありました。

  ② 返信がマークダウンで返ってきて読みづらい  

動いたのはいいけど、返事が ## 見出し **太字** - 箇条書き のまま、改行もされず一塊で表示される。

原因は、<AIConversation> は素のままだと返信をプレーンテキストとして表示するから。

Markdownを描画するには messageRenderer を渡します。

import ReactMarkdown from 'react-markdown';

const MESSAGE_RENDERER = {

text: ({ text }: { text: string }) => <ReactMarkdown>{text}</ReactMarkdown>,

};

<AIConversation /* ... */ messageRenderer={MESSAGE_RENDERER} />

ここで一度 messageRenderer をインラインの で渡したら、Reactに「useMemoの依存配列のサイズが変わった」と怒られました。レンダラはモジュールレベルの定数にして参照を固定したら直りました。

  ③ 空っぽの「Assistant(AIの返信)」が毎回2つ出る

返信のたびに、中身が空のAssistantが1つ余分に表示される。

最初は「React.StrictMode が開発時に副作用を2回実行するせいだろう」と思って StrictMode を外しました。

——直りませんでした。

仕方なくライブラリ(@aws-amplify/ui-react-ai@1.5.1)の useAIConversation のソースを読みに行ったら、バグでした

送信時に楽観的更新で「空のアシスタント仮メッセージ(id: 'temp-id-2')」を追加するのに、本物のストリーム応答はサーバー採番の別idで追加される。だから仮メッセージが差し替えられず、取り残されて空のまま居座る

こちら側で、本物の応答が来たら仮メッセージを除去する回避策を入れて解決しました。

const hasRealAssistant = messages.some(
(m) => m.role === 'assistant' && m.id !== 'temp-id-2',
);
const visibleMessages = hasRealAssistant
? messages.filter((m) => m.id !== 'temp-id-2')
: messages;

  補足:なぜHostingに載せてもストリーミングが動くのか  

Amplify Hosting(SSR)には「HTTPレスポンスのストリーミングが効かない(バッファリングされる)」という制約があります。なので「AIの返事を少しずつ流す」のもダメなんじゃ? と思いがちですが、Amplify AI Kit はそもそもHTTPストリーミングを使っていません。

返事は AppSync のWebSocket(GraphQLサブスクリプション)経由で届きます。HTTPストリーミングの制約を避けるための定番回避策が「WebSocketを使う」で、AI Kit は最初からその方式なんです。だからHostingのHTTPストリーミング制約とは無関係に動きます。

余談ですが、私は以前ストリーミングのAIチャットを作ろうとして「Amplifyじゃできない」と思い込んで諦めたことがあります。Amplify HostingではHTTPのレスポンスストリーミングに制約がある、という話を知っていたからです。でも実は Amplify AI Kit はHTTPストリーミングを使わず、AppSyncのWebSocketで返事を流している。だから何の問題もなく動く。当時の自分に教えてあげたい。

 

 感想

「数時間でAIアプリ」は、半分本当でした。

確かに数時間で動くものはできた。でも実際は、モデルアクセスの罠やライブラリのバグで、結構時間も溶けました。

ただその「詰まって、調べて、抜けた」過程こそが一番の収穫でした。

Amplifyが隠してくれている部分(Bedrock、推論プロファイル、ストリーミング、認証)を、ハマったおかげで少しだけ理解できました。

きれいに動いただけだったら、たぶん何も身につかなかったと思います。