こんにちは~ 浮田です。
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が、ほぼ完成品で付いてくること。
これ、自前で作ろうとすると地味に大変なんです。ざっくり挙げるだけでも、
と、「ちゃんと動く」まで持っていくと数日溶けます。
Amplify AI Kit は、この一式(チャットUI+ストリーミング中継+会話履歴の保存+認証)を繋がった状態で渡してくれます。
今回作ったのは、タブで2つの人格を切り替えられるAIチャットです。
しかも、2つの会話履歴は別々に・ユーザーごとに保存されます。後で書きますが、これがAmplifyだと驚くほど簡単に実現できました。
完成したものはこんな感じです。
筆者の環境は以下のとおりです。
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回だけデプロイして終了
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 の配線も、全部自動で用意されます。
地味にすごいのが、a.conversation() ルートはマルチターンの会話文脈を自動で保持してくれることです。「さっき名前を伝えたら、後の質問でちゃんと覚えている」あれです。
仕組みはシンプルで、
つまり、「メモリ」という独立した部品があるわけではなく、会話履歴の保存=そのまま記憶になっているわけです。自分でメッセージ履歴を保持してプロンプトに詰め直す、みたいなコードは一切書いていません。
ただし注意点もあって、
実際に「私の名前は浮田です。覚えておいて」→(別の話を挟む)→「私の名前は?」と試すと、ちゃんと覚えていました。
さらに今回は過去の会話を一覧から開き直して続きを話せるようにもしたので、リロードしても文脈ごと再開できます。
実際に覚えてるか試しました。
フロント側(src/App.tsx まわり)は、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行も書いていないということです。全部 Amplify AI Kit が肩代わりしてくれました
何をやってくれたかを分解すると——
バックエンド側。
a.conversation() を書いただけで、Amplify が裏で、
を生成してくれます。
冒頭で「自前だと数日溶ける」と書いた中継部分が、丸ごと不要になりました。
フロント側は 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段階の罠でした(やみくもにデプロイせず、先に潰せたのは結果的に正解でした)。
# 東京で使えるモデルを確認
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が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;
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、推論プロファイル、ストリーミング、認証)を、ハマったおかげで少しだけ理解できました。
きれいに動いただけだったら、たぶん何も身につかなかったと思います。