Amazon EchoとAIエージェントで「話し相手ロボット」を作る|Alexaスキル×Bedrock AgentCore Harness

スマートホーム・IoT

一人暮らしの高齢の母は、話し相手がいると元気が出るようです。そこで「呼びかければ雑談に付き合ってくれるAI」を、家にあるAmazon Echoから使えるように作ってみました。インターフェースはEcho(Alexaスキル)、頭脳はAWS上のAIエージェントです。エージェントの土台には、2026年6月に一般提供(GA)となったAmazon Bedrock AgentCore Managed Harnessを使い、面倒なエージェントの実行基盤づくりをAWSに肩代わりしてもらいます。

過去のやり取りも覚えておいてほしいので、AgentCore Memoryで会話履歴を保存する構成にしています。この記事では全体像から構築手順まで、順を追ってガイドします。

  1. 作るものの全体像
  2. AgentCore Harnessとは
  3. 重要:アカウントは今回専用に新規作成する
  4. 事前準備
  5. ステップ1:AgentCore Harnessを作る(AWSでの作業)
  6. ステップ2:会話を記憶させる(AgentCore Memory・AWSでの作業)
    1. 基本:マネージドメモリ(自動)で何もしなくてよい
  7. (応用)Web検索を足して、天気やニュースにも答えさせる
    1. 手順1:TavilyのAPIキーを取得
    2. 手順2:HarnessにリモートMCPサーバーとして繋ぐ
    3. 手順3:システムプロンプトに検索を促す一文を足す
    4. 注意点
  8. ステップ3:Alexaスキルを作る(Alexa Developer Consoleでの作業)
    1. 呼び出し名(Invocation Name)
    2. インテントとスロットとは
    3. インテントを作る
    4. スロットとサンプル発話を設定する
    5. サンプル発話はたくさん用意する(雑談スキルの勘所)
    6. それでも拾いきれない。だから3層で受け止める
    7. JSON Editorで一括設定するのが速くて確実
  9. ステップ4:LambdaでHarnessを呼び出す(AWSでの作業)
    1. Lambda関数を作る
    2. Lambdaに付けるIAM権限
    3. 環境変数とタイムアウトを設定する
  10. ステップ5:スキルとLambdaをつなぐ(AWS↔Alexaの両方)
    1. 【AWS側】LambdaにAlexaトリガーを追加する
    2. 【Alexa側】エンドポイントにLambda ARNを指定する
  11. ステップ6:Echo実機がなくてもテストできる
    1. Alexaシミュレーター(開発者コンソール)※おすすめ
    2. スマホのAlexaアプリ
    3. VS Code拡張(ASK Toolkit)
  12. ステップ7:Echoで動かしてみる
  13. つまずきやすいポイント
    1. Alexaの8秒タイムアウト
    2. リージョンとモデルアクセス
    3. コストの管理
  14. まとめ

作るものの全体像

登場人物と役割を整理すると、こうなります。

  • Amazon Echo:母が話しかける窓口。マイクとスピーカー。
  • Alexaスキル:Echoで受け取った音声を文字にして、裏側に渡す。
  • AWS Lambda:Alexaスキルのリクエストを受け取り、AIエージェントを呼び出す中継役。
  • Bedrock AgentCore Harness:実際に考えて返事を作るAIエージェント本体。
  • AgentCore Memory:過去の会話を覚えておく記憶領域。

処理の流れは次のとおりです。

  1. 母が「アレクサ、話し相手を開いて」と呼びかける
  2. Echoが音声を認識し、Alexaスキル経由でLambdaにリクエストを送る
  3. Lambdaが発話内容を取り出し、AgentCore Harnessをinvokeする
  4. Harnessがモデルで返事を生成し、Memoryに会話を記録する
  5. Lambdaが返事をAlexaのレスポンスに詰めて返す
  6. Echoが声で返事を読み上げる

ChatGPT APIを直接叩く構成(AlexaとChatGPT APIを組み合わせる記事が参考になります)でも似たことはできますが、今回は「会話履歴の永続化」「ツール実行」「認証・ログ」といった周辺機能をAWSに任せたかったので、AgentCore Harnessを選びました。

AgentCore Harnessとは

AgentCore Harnessをひとことで言うと、「モデル・指示文(システムプロンプト)・ツールを指定するだけで、コードを書かずにAIエージェントが動く仕組み」です。

通常、AIエージェントを作るには「モデルを呼ぶ → ツールを使うか判断 → ツール実行 → 結果をモデルに戻す → また判断…」というループ処理を自分で実装する必要があります。AgentCore Harnessはこのループ処理をAWSがまるごと引き受けてくれるので、開発者はコンソールでの設定やJSON定義だけで済みます。裏側ではAWS製のOSSエージェントフレームワーク「Strands Agents」が使われています。

似たサービスとの違いを、今回の用途に絞って整理しておきます。

  • Bedrock Agents:Bedrock内で完結する従来型。設定ベースだがBedrockモデルのみ。
  • AgentCore Runtime:自分でループを実装する自由度の高い実行環境。コード記述が必要。
  • AgentCore Harness:設定だけで動く。Bedrock / OpenAI / Gemini に対応。今回はこれ。

Harness自体に追加料金はかからず、課金されるのは裏側で使うRuntime(CPU・メモリの従量課金)やMemory、モデル利用料です。CPU課金は実際に処理した時間のみで、モデルの応答待ち(I/O待ち)は無料という設計になっています。

重要:アカウントは今回専用に新規作成する

最初にアカウント設計を決めておきます。ここを曖昧にすると後で詰まります。

Alexaのカスタムスキルは、開発中(未公開)の状態では、スキル開発に使ったAmazon開発者アカウントと「同じアカウントでログインしているEcho」でしか使えません。Amazon公式ドキュメントでも、テストにはEchoに開発者アカウントと同じ認証情報でサインインするよう案内されています(Steps to Build a Custom Skill)。

そのため、母が普段使っているアカウントとは別に、今回の話し相手AI専用のAmazonアカウントを新しく1つ作り、スキル開発(開発者コンソール)もEchoのログインもすべてそのアカウントに統一するのが、いちばんシンプルで確実です。

この方針のメリットと注意点は次のとおりです。

  • メリット:開発中のスキルがEchoですぐ使える。ベータテストの招待やアカウント間の連携作業が一切不要になる。
  • 注意点1:そのEchoは新規アカウントに紐づくため、母が既存アカウントで使っていた買い物・プライム・既存のリマインダー等はそのEchoからは使えなくなる。話し相手専用機として1台割り当てるのがおすすめ。
  • 注意点2:AWSアカウントとAmazon開発者アカウントは別物。開発者コンソール(developer.amazon.com)へのサインインを新規アカウントに、Lambdaを動かすAWSアカウントは自分の既存のもの、という組み合わせでも問題ない(LambdaのエンドポイントARNでスキルと紐づけるため)。

事前準備

  • 今回専用の新規Amazonアカウント(メールアドレスを1つ用意)
  • そのアカウントでサインインしたAmazon Echo(実機)
  • そのアカウントで作成したAmazon開発者アカウント(developer.amazon.com
  • AWSアカウント(Lambda・AgentCore用)
  • AgentCore Harnessが使えるリージョン(2026年6月のGAで対応リージョンが拡大し、東京 ap-northeast-1 やソウル ap-northeast-2 も対応。この記事ではソウルを使用)
  • 使用するBedrockモデル(例:Claude Sonnet 系)のモデルアクセスを有効化済みであること

リージョンはソウル(ap-northeast-2)を使います。AgentCore HarnessもMemoryもソウルで利用できます(対応リージョンは公式のリージョン表で確認できます)。Lambdaも同じソウルに置くと、Lambdaからのエージェント呼び出しが同一リージョン内で完結します。もちろん東京(ap-northeast-1)や us-east-1 など、他の対応リージョンでも構いません。

ステップ1:AgentCore Harnessを作る(AWSでの作業)

まずはAIエージェント本体を用意します。AWSマネジメントコンソールで「AgentCore」を検索し、リージョンをソウル(ap-northeast-2)に切り替えてから、Harnessのページを開きます。

Harnessの作成には「Quick create harness(クイック作成)」と「Advanced create harness(高度な作成)」がありますが、今回は「高度な作成」をおすすめします。理由は後述しますが、クイック作成だと最初にプレイグラウンド画面が開き、そこでシステムプロンプトを入力しても保存されずデフォルトに戻ってしまうという落とし穴があるためです(筆者はこれで、Lambdaから呼んだときだけ設定が効かない、という現象に悩まされました)。高度な作成なら、作成時にシステムプロンプトもメモリもまとめて設定・保存できます。

「高度な作成」の画面で、次を設定します。

  • ハーネス名harness_ohanashi_friend など(作成後は変更不可。半角英数字とアンダースコアのみ)
  • モデルソース:Bedrock、モデルはClaude Sonnet系(デフォルトでOK)
  • システムプロンプト:話し相手らしく振る舞う指示(下記)
  • メモリ:有効化(過去の会話を覚えさせるため。次のステップ2で解説)

システムプロンプトは、高齢の母向けにこんな方針にしました。

あなたは高齢の女性の、やさしい話し相手です。
次のことを守って会話してください。
- 短く、ゆっくり、わかりやすい言葉で話す(専門用語は使わない)
- 相手の話をよく聞き、共感し、質問を1つだけ添えて会話を続ける
- 健康・薬・お金・投資などの専門的な判断は助言せず、
  「かかりつけのお医者さんや家族に相談してね」と促す
- 明るく穏やかな口調。返事は2〜3文程度にまとめる
- 絵文字は使わず、すべてテキストで返事する

「返事は2〜3文程度」と短くまとめさせているのには理由があります。耳で聞いて疲れない長さにするためです。最後の「絵文字は使わず、すべてテキストで返事する」も地味に大事で、Echoは返事を音声で読み上げるため、絵文字が混じると読み上げが不自然になるのを防いでいます。

なお、上のシステムプロンプトはあくまで例です。実際には、ここに家族の名前や好きなこと、最近の出来事といった家族の情報も書き込んでおくと、母の関心に寄り添った、より自然な対話ができるようになります。

高度なハーネス作成画面。モデルにClaude Sonnet 4.6、システムプロンプトに話し相手の指示を入力している
「高度な作成」画面。ここでシステムプロンプトを入力しておけば、Harness本体に保存され、Lambdaからの呼び出しにも反映される。

画面を下にスクロールするとメモリの設定もあります。メモリはデフォルトで有効になっているので、そのままでOKです(過去の会話を覚えてくれます。詳しくは次のステップ2で説明します)。すべて入力したら「作成」します。

作成が終わると、プレイグラウンドで動作を確かめられます。試しに「最近腰が痛みます」と入力してみると、共感しながら質問を1つ添えて会話を続け、健康の話題ではきちんと「かかりつけのお医者さんや家族に相談してね」と促してくれました。狙いどおりの話し相手になっています。

AgentCoreハーネスプレイグラウンドでの会話テスト。右側にシステムプロンプト、左側に共感的な応答が表示されている
ハーネスプレイグラウンドでの動作確認。右のシステムプロンプトに沿って、やさしく共感し、医療の話題は専門家への相談を促している。モデルはClaude Sonnet 4.6、レイテンシーは約5秒。

画面右上にレイテンシー(この例では約5秒)が表示されている点にも注目です。この応答時間は、Echoで使ったときの体感の速さを考えるうえで参考になります。

ステップ2:会話を記憶させる(AgentCore Memory・AWSでの作業)

「昨日話したこと」を覚えていてくれると、ぐっと話し相手らしくなります。ここでAgentCore Memoryを使いますが、メモリはデフォルトで有効になっているので、基本的に追加の設定は不要です。Harnessがメモリを自動で用意してくれるため、「メモリリソースを自分で作って接続する」作業までは要りません。

基本:マネージドメモリ(自動)で何もしなくてよい

公式ドキュメント(Harness の Memory)によると、Harnessを作成すると既定でAgentCore Memoryインスタンスが自動でプロビジョニングされ、Harnessに配線までされます。既定の戦略はsemantic(事実の抽出)とsummarization(会話の要約)で、生イベントは30日で失効します。

そして、Harnessは呼び出しのたびに会話を自動で保存し、次回以降は同じセッションIDなら過去のやり取りを自動で読み込んでから考えます。しかもマイクロVMのセッションが切れた後でも覚えています。こちらから過去メッセージを渡す必要はなく、新しい発話だけを送ればOKです。ステップ1でメモリを有効にして作成していれば、この時点でメモリはもう効いている、というわけです。

ユーザー単位で記憶を分けたいときは、呼び出し時にactorIdを渡すだけです。記憶はactorId+セッションIDでスコープされるので、actorIdを変えれば別人の記憶として分離されます。今回は母1人なので固定値(例:mother)でOK。将来、父の分も足すなら別のactorIdにすれば混ざりません。このactorIdは、ステップ4のLambdaコードでinvoke_harnessに渡しています。

つまり、「話し相手に過去を覚えさせたい」という今回の目的は、特別な設定なしで達成できます。まずはこのままステップ3に進んで問題ありません。

(応用)Web検索を足して、天気やニュースにも答えさせる

ここは応用です。雑談だけなら不要ですが、「今日の天気は?」「最近のニュースは?」といった最新情報にも答えられると、話し相手としての幅が広がります。AIモデルの知識は学習時点で止まっているので、今日のことを知るにはWeb検索の力を借ります。

今回は、LLM向けの検索API「Tavily」を、HarnessのリモートMCPサーバー機能で繋ぐ方法をとります。この方法ならLambdaのコードを一切変えずにWeb検索を追加でき、月1000回まで無料で試せます。

手順1:TavilyのAPIキーを取得

tavily.comで無料登録し、APIキー(tvly-で始まる文字列)を取得します。クレジットカード不要で、毎月1000回分の無料枠があります。

手順2:HarnessにリモートMCPサーバーとして繋ぐ

AgentCoreコンソールでHarnessの編集画面を開き、「ツール – オプション」の中の「リモートMCPサーバー」をオンにします。エンドポイントURLに、次の形式でTavilyのMCPエンドポイントを入力します(tvly-...の部分を自分のAPIキーに置き換え)。

https://mcp.tavily.com/mcp/?tavilyApiKey=tvly-あなたのAPIキー
ハーネス編集画面。リモートMCPサーバーをオンにし、エンドポイントURLにTavilyのMCPエンドポイントを入力している
「リモートMCPサーバー」をオンにして、TavilyのMCPエンドポイントURLを入力するだけ。APIキーをURLに含めるので、別途の認証ヘッダー設定は不要。

APIキーをURLのクエリパラメータに含めているので、下の「HTTPヘッダー」の認証設定は空のままでOKです。保存すれば、Harnessのエージェントが必要に応じてTavilyの検索ツールを自分で呼べるようになります。

手順3:システムプロンプトに検索を促す一文を足す

検索ツールがあっても、いつ使うかはモデル任せです。最新情報が必要なときに確実に検索させるため、システムプロンプトに一言足しておきます。

- 今日の天気やニュース、最新の出来事など、最新情報が必要なときは、
  ウェブ検索ツールを使って調べてから答える

これだけで、雑談のときは検索せず、最新情報が必要なときだけAIが自分で判断して検索してくれます。方法として賢く、Lambda側で「いつ検索するか」を書く必要がありません。

注意点

  • Lambdaのコード変更は不要。検索はHarness側で完結するので、これまで作ったLambdaはそのまま使えます。
  • APIキーがURLに平文で入ります。個人利用なら許容範囲ですが、キーの取り扱いには注意してください。
  • アウトバウンド通信:HarnessのマイクロVMからmcp.tavily.comへ出られる必要があります。ネットワーク設定がPUBLIC(デフォルト)なら問題ありません。
  • 応答速度:検索が挟まると応答が遅くなります。ただし筆者の環境では、8秒を超えてもEcho経由で応答は返ってきました(Alexaの8秒はあくまで目安のようです)。とはいえ速い方が快適なので、返事を短くする設定をしておくと安心です。まずはプレイグラウンドで動作を確かめ、次にAlexaシミュレーターで速度感を確認するのがおすすめです。

ステップ3:Alexaスキルを作る(Alexa Developer Consoleでの作業)

次にEcho側の窓口を作ります。新規アカウントでサインインしたAlexa Developer Consoleで、カスタムスキルを新規作成します。

「スキルの作成」を押すと、いくつか選択肢が出ます。今回は次のように選びます。

  • スキル名:わかりやすい管理名(例:お話フレンド)。あとで変更できます。
  • プライマリロケール日本語(Japanese (JP))
  • エクスペリエンスのタイプその他(Other)
  • モデルカスタム(Custom)
  • ホスティング独自のプロビジョニング(Provision your own) … バックエンドを自分のLambdaにするため
  • テンプレートスクラッチで作成(Start from Scratch) … 余計なサンプルが入らない状態から作る

作成すると、対話モデルを設定する画面(ビルド)が開きます。ここから「呼び出し名」と「インテント・スロット」を設定していきます。作業の途中は画面上部の「Save」でこまめに保存し、最後に「Build skill(モデルをビルド)」を実行すると設定が反映されます。

呼び出し名(Invocation Name)

母が毎日口にする言葉なので、呼び出し名は言いやすさと親しみやすさを重視して選びます。今回は「お話フレンド」にしました。「話し相手」の意味合いがありつつ、道具を起動する感じが出ないのが気に入っています。これで「アレクサ、お話フレンドを開いて」と話しかければ起動します。

ちなみに、呼び出し名は開発中ならいつでも変更できます(公開・認定後は変更不可)。今回は公開申請せず開発中のまま使うので、まずは仮の名前で動かして、母の反応や言いやすさを見ながら後で調整して構いません。気楽に始めましょう。

設定は、左メニューの「Invocations(呼び出し)」→「Skill Invocation Name」を開き、入力欄に呼び出し名を入れるだけです。ロケールが日本語(Japanese (JP))になっていることも確認しておきましょう。

Alexa Developer Consoleの呼び出し名設定画面。Skill Invocation Nameに呼び出し名を入力する
呼び出し名の設定画面。Skill Invocation Nameに「お話フレンド」を入力する。ロケールは右上でJapanese (JP)を選んでおく。

インテントとスロットとは

ここで出てくる「インテント」「スロット」という言葉が、最初は分かりにくいかもしれません。レストランの店員さんにたとえると腑に落ちます。

  • インテント(Intent)=ユーザーの用件の種類。店員さんがお客さんの用件を「注文したい」「場所を聞きたい」と仕分けるのと同じで、Alexaも話しかけられた内容を用件ごとに仕分けます。今回の用件は「雑談したい」の1つだけなので、ChatIntentというインテントを1つ作ります。
  • スロット(Slot)=用件の中で毎回変わる部分を入れる箱。「〇〇をください」の〇〇のように、中身は毎回変わります。今回は母が話す内容が毎回バラバラなので、その言葉を丸ごと受け取る箱としてqueryというスロットを用意します。
  • スロットタイプ=その箱が受け取れる中身の種類。「日付だけ」「数字だけ」といった型もありますが、母が何を話すか決まっていないので、どんな文章でも丸ごと受け取れるAMAZON.SearchQueryを使うのがコツです。

インテントを作る

左メニューの「Interaction Model」→「Intents」を開き、「+ Add Intent」を押します。「Create custom intent(カスタムインテントを作成)」を選び、名前にChatIntentと入力して作成します。

インテント作成画面。Create custom intentを選び、名前にChatIntentと入力している
カスタムインテントの作成。「Create custom intent」を選び、名前に「ChatIntent」と入力する。

スロットとサンプル発話を設定する

作成したChatIntentの画面で、Intent Slots(インテントスロット)queryという名前のスロットを追加し、スロットタイプをAMAZON.SearchQueryに設定します。

ChatIntentの設定画面。スロットqueryのタイプがAMAZON.SearchQueryに設定されている
ChatIntentの設定。スロットqueryのタイプをAMAZON.SearchQueryにする。

サンプル発話はたくさん用意する(雑談スキルの勘所)

次が今回の一番のポイントです。Sample Utterances(サンプル発話)は「ユーザーはこういう言い方をする」という例文で、Alexaはこれを手がかりに発話をChatIntentに振り分けます。

ここで大事なのは、雑談スキルは「何を言われるか分からない」という点です。母は「最近腰が痛いのよ」「今日は寒いね」など、毎回ちがう言い方で話しかけます。サンプル発話が少ないと、Alexaが「これはChatIntentだ」と判断できず、発話がLambdaに届かず何も応答が返ってこない、という状態になります(筆者は実際にこれでハマりました)。

そこで、日常会話の語尾(「〜ね」「〜だよ」「〜なの」「〜が痛い」など)を広くカバーするよう、サンプル発話を10〜30個ほどたくさん登録しますAMAZON.SearchQueryは、こうしてパターンを与えるほど周辺の言い回しも柔軟に拾ってくれるので、実用上ほとんどの雑談をChatIntentで受けられるようになります。

特に見落としがちなのが「いいえ」「はい」で始まる相槌です。会話では「はい/いいえ+一言」がとても多いので、いいえ {query}はい {query}のような文頭パターンも入れておきます。

それでも拾いきれない。だから3層で受け止める

ここが、雑談スキルを作るうえで最も重要な現実です。サンプル発話をどれだけ増やしても、あらゆる言い回しをChatIntentで拾いきることはできません。これはAMAZON.SearchQueryの限界であると同時に、Alexaが「発話をインテントに分類してから処理する」設計思想(自由発話を丸ごとスキルに渡さない)を持つためです。筆者も、語尾パターンを40個以上入れてなお「はい」「うん」といった短い相槌が拾えず、ここでかなり悩みました。

特にAMAZON.SearchQueryキャリアフレーズ(前後の固定語)が必須で、「はい」「うん」のような一語だけの相槌は原理的に拾えません({query}単独は登録できない)。そこで、発想を変えて3層で発話を受け止めます

  • ChatIntentAMAZON.SearchQuery+語尾パターン):内容のある発話を拾い、queryスロットの中身をそのままAIに送る。
  • AizuchiIntentカスタムスロット):「はい」「いいえ」「うん」「なるほど」など短い相槌を、値を列挙したカスタムスロットタイプ(AizuchiType)で拾う。カスタムスロットなら「どの相槌か」の中身が取れるので、その言葉をAIに送れる。
  • AMAZON.FallbackIntent:上のどちらでも拾えなかった発話を最後に受け止め、「もう一度言ってくれますか?」と聞き返す。

これで「話しかけたのに無反応」が無くなります。内容のある発話は的確に答え、短い相槌も中身を取ってAIに渡し、それでも漏れたら聞き返して言い直してもらう。話し相手としては、これで十分自然にやり取りできます。

重要なポイント:短い相槌を拾うインテントは、スロット無しにしてはいけません。筆者は最初、スロットを持たないAizuchiIntentで相槌を拾おうとしましたが、その場合Alexaのリクエストにはインテント名しか渡らず、ユーザーが「はい」と言ったのか「うん」と言ったのかが取れませんでした(実機のリクエストで確認済み)。相槌の中身をAIに渡すには、上のように値を列挙したカスタムスロットを必ず持たせます。FallbackIntentも同様に中身は取れないので、こちらは聞き返し役に徹します。

JSON Editorで一括設定するのが速くて確実

サンプル発話を1つずつ画面で入力するのは大変ですし、途中で画面を切り替えると保存前の内容が消えることがあります(これも筆者がハマったポイントです)。そこでおすすめなのが、左メニューの「JSON Editor」で対話モデルを丸ごと貼り付ける方法です。呼び出し名・インテント・スロット・サンプル発話をまとめて設定でき、速くて確実です。

JSON Editorに、次のモデルを貼り付けます。呼び出し名・ChatIntent・スロット・サンプル発話がまとめて設定されます。

{
  "interactionModel": {
    "languageModel": {
      "invocationName": "お話フレンド",
      "intents": [
        { "name": "AMAZON.CancelIntent", "samples": [] },
        { "name": "AMAZON.HelpIntent", "samples": [] },
        { "name": "AMAZON.StopIntent", "samples": [] },
        { "name": "AMAZON.NavigateHomeIntent", "samples": [] },
        { "name": "AMAZON.FallbackIntent", "samples": [] },
        {
          "name": "AizuchiIntent",
          "slots": [
            { "name": "aizuchi", "type": "AizuchiType" }
          ],
          "samples": [ "{aizuchi}" ]
        },
        {
          "name": "ChatIntent",
          "slots": [
            { "name": "query", "type": "AMAZON.SearchQuery" }
          ],
          "samples": [
            "{query} です",
            "{query} ます",
            "{query} ました",
            "{query} でした",
            "{query} ません",
            "{query} ですか",
            "{query} ますか",
            "{query} でしょう",
            "{query} ください",
            "{query} だよ",
            "{query} だね",
            "{query} だな",
            "{query} なの",
            "{query} なのよ",
            "{query} のよ",
            "{query} のね",
            "{query} んだ",
            "{query} んだよ",
            "{query} んです",
            "{query} かな",
            "{query} かしら",
            "{query} ね",
            "{query} よ",
            "{query} な",
            "{query} わ",
            "{query} よね",
            "{query} かも",
            "{query} てる",
            "{query} てます",
            "{query} ている",
            "{query} たい",
            "{query} ちゃう",
            "{query} ちゃった",
            "{query} たり",
            "いいえ {query}",
            "はい {query}",
            "うん {query}",
            "ええ {query}",
            "そう {query}",
            "{query} かった",
            "{query} そう",
            "{query} みたい"
          ]
        }
      ],
      "types": [
        {
          "name": "AizuchiType",
          "values": [
            { "name": { "value": "はい" } },
            { "name": { "value": "いいえ" } },
            { "name": { "value": "うん" } },
            { "name": { "value": "ううん" } },
            { "name": { "value": "ええ" } },
            { "name": { "value": "そう" } },
            { "name": { "value": "そうです" } },
            { "name": { "value": "そうね" } },
            { "name": { "value": "なるほど" } },
            { "name": { "value": "わかった" } },
            { "name": { "value": "わかりました" } },
            { "name": { "value": "本当" } },
            { "name": { "value": "ありがとう" } },
            { "name": { "value": "おはよう" } },
            { "name": { "value": "こんにちは" } },
            { "name": { "value": "こんばんは" } }
          ]
        }
      ]
    }
  }
}

貼り付けたら「Save Model」で保存し、「Build skill」でビルドします。

なお、スクラッチで作成するとHelloWorldIntentという初期インテントが残っている場合がありますが、上のJSONで丸ごと置き換えるので気にしなくて大丈夫です。ビルドが成功すれば、対話モデルは完成です。これで「アレクサ、お話フレンドを開いて」で起動したあと、母が自由に話した言葉がqueryスロットに入り、Lambdaへ渡されるようになります。

ステップ4:LambdaでHarnessを呼び出す(AWSでの作業)

ここからは作業場所が変わります。ステップ3までのAlexaスキルの設定はAlexa Developer Consoleでの作業でしたが、このステップ4以降はAWSマネジメントコンソールでの作業になります。Alexaスキルのエンドポイントに設定するLambda(AWSのサーバーレス実行環境)を作ります。役割は「Alexaのリクエストから発話を取り出し、AgentCore Harnessをinvokeして、返ってきた返事をAlexaのレスポンスに詰める」ことです。

Lambda関数を作る

AWSマネジメントコンソールで「Lambda」を開き、リージョンをソウル(ap-northeast-2)に切り替えて(HarnessもソウルなのでLambdaも同じリージョンに揃えます)、次のように関数を作成します。

  • 「関数の作成」→「一から作成」
  • 関数名:ohanashi-friend(任意)
  • ランタイム:Python 3.13(新しめのバージョンでOK)
  • アーキテクチャ:x86_64(デフォルトのまま)

作成したら、次に呼び出しのコードを書いていきます。Harnessの呼び出しには boto3 の bedrock-agentcore クライアントの invoke_harness を使います。主なパラメータは次のとおりです。

  • harnessArn:作成したHarnessのARN(コンソールのHarness detailsで確認)
  • runtimeSessionId:会話を継続するためのセッションID。同じIDを使い続けると文脈がつながる
  • messages:ユーザーの発話(role=user)を渡す
  • actorId:Memoryをユーザー単位で分ける識別子(例:mother

Lambdaのコード例です。AlexaとLambdaがやり取りするのは決まった形のJSON(Pythonのdict)なので、専用のSDKを使わず、標準のdict操作だけで書けます。こうすると追加ライブラリのレイヤーが不要になり(boto3はLambdaランタイムに標準で含まれます)、下のコードをLambdaのエディタに貼り付けてデプロイするだけで動きます。先ほどの3層構造(ChatIntentAizuchiIntentAMAZON.FallbackIntent)を、そのまま分岐で受けています。

import os
import re
import logging
import boto3

logger = logging.getLogger()
logger.setLevel(logging.INFO)

# AgentCore Harness の設定(環境変数で渡す)
HARNESS_ARN = os.environ["HARNESS_ARN"]
REGION = os.environ.get("AWS_REGION", "ap-northeast-2")
ACTOR_ID = os.environ.get("ACTOR_ID", "mother")

agentcore = boto3.client("bedrock-agentcore", region_name=REGION)


def ask_agent(user_text, session_id):
    """AgentCore Harness を呼び出して返事のテキストを得る"""
    resp = agentcore.invoke_harness(
        harnessArn=HARNESS_ARN,
        runtimeSessionId=session_id,   # 会話を継続するためのセッションID
        actorId=ACTOR_ID,              # Memory をユーザー単位で分離
        messages=[
            {"role": "user", "content": [{"text": user_text}]}
        ],
    )
    # レスポンスはイベントストリーム形式。
    # contentBlockDelta の text を順に結合して返答テキストを組み立てる
    parts = []
    for event in resp["stream"]:
        delta = event.get("contentBlockDelta", {}).get("delta", {})
        if "text" in delta:
            parts.append(delta["text"])
    return "".join(parts) or "うまく聞き取れませんでした。もう一度お願いします。"


def build_response(speech, reprompt, end_session=False):
    """Alexa が期待するレスポンス JSON(dict)を組み立てる"""
    response = {
        "outputSpeech": {"type": "PlainText", "text": speech},
        "shouldEndSession": end_session,
    }
    if reprompt is not None:
        response["reprompt"] = {
            "outputSpeech": {"type": "PlainText", "text": reprompt}
        }
    return {"version": "1.0", "response": response}


def lambda_handler(event, context):
    request = event.get("request", {})
    request_type = request.get("type")

    # スキル起動時(「アレクサ、お話フレンドを開いて」)
    # AI にあいさつを生成させる。メモリがあれば前回の会話を踏まえてくれる
    if request_type == "LaunchRequest":
        raw_session = event.get("session", {}).get("sessionId", "default")
        session_id = re.sub(r"[^a-zA-Z0-9-_]", "_", raw_session)
        try:
            answer = ask_agent(
                "会話を始めます。やさしく短く挨拶して、近況を一言たずねてください。"
                "前に話したことがあれば、それに触れてくれると嬉しいです。",
                session_id,
            )
        except Exception as e:
            logger.error(e)
            answer = "こんにちは。今日はどんなお話をしましょうか?"
        return build_response(answer, "どうぞお話しください。")

    if request_type == "IntentRequest":
        intent = request.get("intent", {})
        intent_name = intent.get("name")

        # 「やめて」などの終了系
        if intent_name in ("AMAZON.StopIntent", "AMAZON.CancelIntent"):
            return build_response("またお話ししましょうね。", None, end_session=True)

        # Alexa のセッションIDを AgentCore 用に整形(英数字・ハイフン・アンダースコアのみ許可)
        raw_session = event.get("session", {}).get("sessionId", "default")
        session_id = re.sub(r"[^a-zA-Z0-9-_]", "_", raw_session)

        # (1) 内容のある発話:中身をそのまま AI に送る
        if intent_name == "ChatIntent":
            slots = intent.get("slots", {})
            user_text = slots.get("query", {}).get("value", "")
            try:
                answer = ask_agent(user_text, session_id)
            except Exception as e:
                logger.error(e)
                answer = "ごめんなさい、少し調子が悪いみたい。またあとで話しましょう。"
            return build_response(answer, "ほかにもお話ししましょうか?")

        # (2) 短い相槌:カスタムスロットから実際の言葉を取り出して AI に送る
        if intent_name == "AizuchiIntent":
            slots = intent.get("slots", {})
            user_text = slots.get("aizuchi", {}).get("value", "")
            try:
                answer = ask_agent(user_text, session_id)
            except Exception as e:
                logger.error(e)
                answer = "うんうん。それで、どうされましたか?"
            return build_response(answer, "ほかにもお話ししましょうか?")

        # ヘルプ
        if intent_name == "AMAZON.HelpIntent":
            speech = "なんでも好きにお話ししてください。聞いていますよ。"
            return build_response(speech, speech)

        # (3) どのインテントでも拾えなかった発話:聞き返して言い直してもらう
        if intent_name == "AMAZON.FallbackIntent":
            speech = "ごめんなさい、うまく聞き取れませんでした。もう一度言ってくれますか?"
            return build_response(speech, speech)

    # セッション終了など、その他のリクエスト
    return build_response("またね。", None, end_session=True)

Alexaのセッションが続いている間は同じsessionIdが使われるので、それをruntimeSessionIdに流用すると、その会話の中で文脈がつながります。さらにactorIdでMemoryに保存されるので、セッションをまたいだ「昨日の話」も引き継げます。

起動時(LaunchRequest)も固定のあいさつではなく、AIにあいさつを生成させています。メモリが効いていれば「この前は腰の話をしましたね。その後いかがですか?」のように、前回の会話を踏まえたあいさつになり、ぐっと話し相手らしくなります。ただし起動時にHarnessを呼ぶ分、あいさつが返るまで数秒かかります。反応の速さを優先するなら、起動時は固定のあいさつにしておく手もあります(その場合はHarnessを呼ばず即答できます)。

つまずきポイント:AlexaのセッションIDをそのままruntimeSessionIdに渡すと、ValidationExceptionで失敗します。AgentCoreのruntimeSessionIdは英数字・ハイフン・アンダースコアのみ(正規表現 [a-zA-Z0-9][a-zA-Z0-9-_]*)を許可しますが、AlexaのセッションID(amzn1.echo-api.session.xxxx)にはドットが含まれるためです。上のコードのように、使えない文字をアンダースコアに置換してから渡します。

もう一つの重要ポイント:レスポンスはストリーミング形式です。invoke_harnessの戻り値は、返事のテキストがそのまま入っているのではなく、resp["stream"]というイベントストリームで少しずつ返ってきます。テキストは各イベントのcontentBlockDelta.delta.textに分割して入っているので、上のコードのようにストリームをイテレートして結合します(Bedrockの標準的なストリーム形式と同じ構造です)。resp["messages"]のような形では取れないので注意してください。実際のチャンク構造を確認したいときは、for event in resp["stream"]:の中でlogger.info(event)を出すと、CloudWatchログで中身を確認できます。

コードを貼り付けたら「Deploy」を押します。Lambdaの「ランタイム設定」でハンドラがlambda_function.lambda_handlerになっていることを確認してください(ファイル名がlambda_function.pyの場合)。また、Lambdaのタイムアウトはデフォルトの3秒だと足りないので、設定タブから30秒程度に延ばしておきます(AIの生成やWeb検索に時間がかかることがあるため、余裕を持たせておきます)。

Lambdaに付けるIAM権限

これもAWSコンソールでの作業です。LambdaがHarnessを呼べるように、Lambdaの実行ロール(設定 > アクセス権限 から辿れます)に権限を許可します。ここが今回のハマりどころでした。invoke_harnessを実行するには、bedrock-agentcore:InvokeHarnessbedrock-agentcore:InvokeAgentRuntimeの両方をHarnessのARNに対して許可する必要があります(公式ドキュメントにも「Harnessリソースと、その裏のAgentCore Runtimeリソースの両方の権限が必要」と明記されています)。片方だけだとAccessDeniedExceptionで失敗します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "bedrock-agentcore:InvokeHarness",
        "bedrock-agentcore:InvokeAgentRuntime"
      ],
      "Resource": "arn:aws:bedrock-agentcore:ap-northeast-2:<ACCOUNT_ID>:harness/<HARNESS_NAME>-xxxx"
    }
  ]
}

HarnessのARNは、AgentCoreコンソールのHarness detailsで確認できます。CloudWatchログのAccessDeniedExceptionのメッセージにもARNが出るので、それをそのまま貼るのが確実です。

環境変数とタイムアウトを設定する

最後に、Lambdaの「設定」タブで環境変数を登録します(これもAWS側の作業です)。

  • HARNESS_ARN:ソウルに作ったHarnessのARN(AgentCoreコンソールのHarness detailsで確認)
  • ACTOR_IDmother

リージョンはLambdaがソウルにあればAWS_REGIONが自動でap-northeast-2になり、コードのデフォルトと一致するので、あらためて設定する必要はありません。ここまででAWS側の準備は完了です。

Harnessを作り直したときの注意:もしHarnessを新しく作り直した場合(例:クイック作成から高度な作成に切り替えたなど)、HarnessのARNが変わります。そのときは、この環境変数HARNESS_ARNと、先ほどのIAMポリシーのResource両方を新しいARNに更新してください。片方でも古いARNのままだと、Lambdaが古いHarnessを呼んだり、権限エラー(AccessDeniedException)になったりします。使わなくなった古いHarnessは、コスト削減のため削除しておくとよいでしょう。

ステップ5:スキルとLambdaをつなぐ(AWS↔Alexaの両方)

AlexaスキルとLambdaを相互に紐づけます。この作業はAWSコンソールとAlexa Developer Consoleの両方を行き来します。順番に見ていきましょう。

まず、スキルIDを控えておきます。Alexa Developer Consoleの左メニュー「Endpoint」を開くと、「AWS Lambda ARN」を選んだときに「Your Skill ID」としてamzn1.ask.skill.xxxxが表示されます。これを「Copy to Clipboard」でコピーしておきます。

【AWS側】LambdaにAlexaトリガーを追加する

AWSのLambdaの画面で「トリガーを追加」を押し、一覧から「Alexa」を選びます(以前は「Alexa Skills Kit」という名前でしたが、現在のUIでは「Alexa」と表示されます)。「スキルID検証」を有効(推奨)にし、スキルIDの欄に先ほどコピーしたスキルIDを貼り付けて「追加」を押します。

Lambdaのトリガー追加画面。Alexaを選び、スキルID検証を有効にしてスキルIDを入力している
LambdaにAlexaトリガーを追加。スキルID検証を有効にし、Alexa側のスキルIDを貼り付ける。最後に「追加」を押すのを忘れずに。

つまずきポイント:この「追加」ボタンを押し忘れると、トリガーが登録されず、あとでAlexa側の保存時に「The trigger setting for the Lambda … is invalid. Error code: SkillManifestError」というエラーが出ます。追加後、Lambdaの「関数の概要」図にAlexaのアイコンが表示されていれば成功です。

【Alexa側】エンドポイントにLambda ARNを指定する

Alexa Developer Consoleの「Endpoint」画面で「AWS Lambda ARN」を選び、「Default Region」の欄に作成したLambda関数のARN(arn:aws:lambda:ap-northeast-2:...:function:ohanashi-friend)を貼り付けます。

Alexa Developer ConsoleのEndpoint画面。AWS Lambda ARNを選び、Default RegionにソウルのLambda ARNを入力している
Alexa側のエンドポイント設定。「Default Region」にソウルのLambda ARNを入力する。North Americaなどの他リージョン欄は空でよい(自動でDefault Regionにフォールバックする)。

この画面には「North America」「Far East」などリージョン別の欄もありますが、「Default Region」だけ埋めれば動きます。該当リージョンの欄が空なら、AlexaはDefault Regionのエンドポイントを使うためです。

入力できたら「Save」を押します。「Skill Manifest Saved Successful」と表示されれば紐づけ成功です。最後にモデルをビルド(Build skill)すると変更が反映されます。これで「Echo(Alexa)→ Lambda → Harness」の経路がすべてつながりました。

ステップ6:Echo実機がなくてもテストできる

「動作確認のたびにEchoに話しかけるのは大変そう…」と思うかもしれませんが、Echo実機がなくても開発中のスキルはテストできます。開発段階ではむしろPCだけで完結させたほうが効率的です。方法は主に3つあります。

Alexaシミュレーター(開発者コンソール)※おすすめ

Alexa Developer Consoleの「Test(テスト)」タブにあるシミュレーターが一番手軽です。デバイス不要で、ブラウザ上からテキスト入力でも音声でもスキルと対話できます(Test with the Alexa Simulator)。今回のように会話を続けるマルチターン構成の確認もできます。

画面下の「Skill I/O」で、AlexaとLambdaの間でやり取りされるリクエスト/レスポンスのJSONもそのまま見られるので、「発話がqueryスロットに正しく入っているか」「Lambdaが期待どおり返しているか」といったデバッグに重宝します。開発中の動作確認は、ほぼこれで完結します。

スマホのAlexaアプリ

開発者アカウントと同じアカウントでスマホのAlexaアプリにログインしていれば、開発中のスキルがアプリ上でも有効になり、アプリのマイクボタンから話しかけてテストできます。実際の音声認識の感触を確かめたいときに便利です。細かく中身を見たいならシミュレーターのほうが向いています。

VS Code拡張(ASK Toolkit)

VS Codeを使っているなら、ASK Toolkit拡張機能でエディタを離れずにシミュレーターでテストできます(Test Skills in Visual Studio Code)。

注意点:シミュレーターやアプリでテストする場合も、裏側のLambda → AgentCore Harnessは実際に呼び出されます。そのため、AWS側(Lambda、Harness、モデルアクセス、IAM権限)が動く状態になっていることが前提です。ここが未完成だとシミュレーターでもエラーになります。逆に言えば、Echo実機が必要になるのは「母が実際に声で使う本番運用」の段階だけで、開発・テストはPCだけで進められます。

ステップ7:Echoで動かしてみる

ここで新規アカウント方式が効いてきます。開発者コンソールと同じアカウントでサインインしたEchoなら、開発中のスキルをそのまま呼び出せます。「アレクサ、お話フレンドを開いて」と話しかけてみましょう。あいさつが返ってきたら、あとは自由に話しかけるだけです。

公開申請をしなくても、同一アカウントのEchoなら開発中のまま日常的に使えます。母専用機として運用するなら、これで十分実用になります。

つまずきやすいポイント

Alexaの8秒タイムアウト

Alexaには「応答は8秒以内」という目安があり、AIの生成は時間がかかりがちなので気になるところです。ただ、この制限は厳密ではないようで、筆者の環境ではWeb検索を挟んで8秒を超えても、応答は返ってきました。とはいえ、遅すぎると母を待たせてしまうので、応答は速いに越したことはありません。速くする工夫としては次のようなものがあります。

  • 返事を短くさせる:システムプロンプトで「2〜3文で」と指定し、生成トークン数を抑える(今回すでに実施)。
  • 軽量・高速なモデルを選ぶ:雑談用途なら応答速度重視のモデルにする。

リージョンとモデルアクセス

AgentCore Harnessは2026年6月のGAで対応リージョンが広がり、東京(ap-northeast-1)やソウル(ap-northeast-2)でも使えるようになりました(最新の対応状況は公式のリージョン表を確認)。日本向けなら日本から近いソウルや東京がおすすめです。ポイントは、LambdaとHarnessを同じリージョンに揃えること。別リージョンにまたがると往復のレイテンシーが増え、Alexaの8秒制限に効いてきます。また、使うモデルのモデルアクセスを事前に有効化しておかないと呼び出しで失敗するので注意してください。

コストの管理

Harness自体は無料ですが、Runtime・Memory・モデル利用料は従量課金です。CPU課金は処理時間のみ(応答待ちは無料)とはいえ、使わないHarnessは削除する、Memoryの読み込み件数を絞る、といった運用でムダを抑えられます。マイクロVMのアイドルタイムアウト(デフォルト15分)などの上限もAdvanced configurationsで調整できます。

まとめ

  • Echo(Alexaスキル)+ Lambda + AgentCore Harness で、話し相手AIが作れる
  • 会話履歴は AgentCore Memory に actorId 単位で保存でき、過去のやり取りを引き継げる
  • 今回専用のAmazonアカウントを新規作成し、スキル開発もEchoも同じアカウントに統一すると、開発中のスキルがそのまま実用でき、連携作業が不要になる
  • 応答速度は「短い返事」「速いモデル」で快適にできる(Alexaの8秒は厳密な制限ではなく、超えても応答は返ってきた)

置物になりがちなEchoが、母にとっての気軽な話し相手になってくれれば何よりです。AgentCore Harnessのおかげで、面倒な実行基盤づくりに時間を取られず、「どんな話し相手にするか」というプロンプト設計に集中できました。まずは小さく作って、母の反応を見ながら口調や記憶の使い方を調整していこうと思います。

コメント

タイトルとURLをコピーしました