0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Bedrock AgentCore × Strands Agents で AI 英会話アプリを個人開発してリリースした

0
Last updated at Posted at 2026-07-09

英会話の練習相手が欲しいだけなのに、既存アプリは月額課金が重かったり、会話の自由度が低かったりして「自分の欲しい形」にたどり着けなかった。特に「言い淀んでも待ってくれる」「文法を直しつつ自然に会話を続けてくれる」相手が欲しかった。

この記事では、Amazon Bedrock AgentCore と Strands Agents SDK を使って、AI と音声で英会話練習ができるアプリを個人開発した過程を書く。想定読者は「Bedrock AgentCore を実際に触ってみたい人」と「Strands Agents でエージェントを組んだコードを見たい人」。


自分の環境

項目 バージョン / 内容
Python 3.12.4
strands-agents 0.1.6
bedrock-agentcore 0.1.2
boto3 1.34.144
FastAPI 0.111.0
React Native (Expo) SDK 51
利用モデル Anthropic Claude 3.5 Sonnet (Bedrock 経由)
デプロイ先 AgentCore Runtime

音声認識・音声合成はスマホ側のネイティブ API(Expo Speech / expo-av)を使い、会話ロジックだけを AgentCore 上のエージェントに任せる構成にした。


実装

1. Strands Agents でエージェントを定義する

会話の役割(英会話コーチ)と、文法チェック用のツールを持たせたエージェントを作る。Strands Agents はツール定義がシンプルで、Python の関数に @tool を付けるだけで済む。

# agent/coach_agent.py
from strands import Agent, tool
from strands.models import BedrockModel

# Bedrock 経由で Claude 3.5 Sonnet を呼び出すモデル定義
model = BedrockModel(
    model_id="anthropic.claude-3-5-sonnet-20240620-v1:0",
    region_name="us-west-2",
    temperature=0.7,
)


@tool
def check_grammar(sentence: str) -> str:
    """
    英文の文法チェック結果を返す簡易ツール。
    実際の判定はモデル自身の推論に任せ、ここでは前処理のみ行う。
    """
    trimmed = sentence.strip()
    if not trimmed:
        return "空の文章です。もう一度話してみてください。"
    if len(trimmed.split()) < 2:
        return "短すぎる文章です。もう少し詳しく話してみましょう。"
    return f"'{trimmed}' を文法チェック対象として受け付けました。"


SYSTEM_PROMPT = """
あなたはフレンドリーな英会話コーチです。
ユーザーの発話に対して、まず英語で自然に返答し、
その後に簡潔な日本語で文法アドバイスを1文添えてください。
ユーザーが言い淀んでも急かさず、次の話題につながる質問を1つ添えてください。
"""

coach_agent = Agent(
    model=model,
    tools=[check_grammar],
    system_prompt=SYSTEM_PROMPT,
)


def run_conversation_turn(user_utterance: str) -> str:
    """1ターン分の会話を実行し、エージェントの応答テキストを返す"""
    response = coach_agent(user_utterance)
    return str(response)

@tool デコレータを付けた関数は、エージェントが必要と判断したタイミングで自動的に呼び出される。今回はモデルにほぼ判断を任せる設計にしたため、ツール自体は最小限にした。

2. AgentCore Runtime にデプロイする

Strands のエージェントをそのまま AgentCore にホストする。bedrock-agentcore SDK の BedrockAgentCoreApp でラップし、エントリーポイントを定義する。

# agent/app.py
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from coach_agent import run_conversation_turn

app = BedrockAgentCoreApp()


@app.entrypoint
def invoke(payload: dict) -> dict:
    """
    AgentCore Runtime から呼ばれるエントリーポイント。
    payload 例: {"user_utterance": "I go to school yesterday."}
    """
    user_utterance = payload.get("user_utterance", "")
    if not user_utterance:
        return {"error": "user_utterance is required"}

    reply = run_conversation_turn(user_utterance)
    return {"reply": reply}


if __name__ == "__main__":
    app.run()

デプロイは agentcore CLI から行う。ローカルでの動作確認とデプロイの流れは以下の通り。

# ローカルでエントリーポイントを起動して疎通確認
python agent/app.py &
curl -X POST http://localhost:8080/invocations \
  -H "Content-Type: application/json" \
  -d '{"user_utterance": "I go to school yesterday."}'

# 動作確認できたら AgentCore にデプロイ
agentcore configure --entrypoint agent/app.py
agentcore launch

# デプロイ後のエンドポイントを呼び出す(region / agent-id は実際の値に置き換え)
agentcore invoke '{"user_utterance": "Can you help me practice ordering coffee?"}'

agentcore launch は内部でコンテナイメージのビルドと ECR への push、AgentCore Runtime へのデプロイまでを一括で行ってくれる。初回は数分かかるが、2 回目以降はレイヤーキャッシュが効いて短縮される。

3. FastAPI 経由で AgentCore を呼び出す薄いバックエンド

スマホアプリから直接 AgentCore を叩くのではなく、FastAPI を間に挟んで認証とレート制限をかけている。

# backend/main.py
import os
import json
import boto3
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="English Coach Backend")

AGENT_RUNTIME_ARN = os.environ["AGENTCORE_RUNTIME_ARN"]
client = boto3.client("bedrock-agentcore", region_name="us-west-2")


class TurnRequest(BaseModel):
    user_utterance: str
    session_id: str


@app.post("/conversation/turn")
async def conversation_turn(req: TurnRequest) -> dict:
    if not req.user_utterance.strip():
        raise HTTPException(status_code=400, detail="user_utterance is empty")

    try:
        response = client.invoke_agent_runtime(
            agentRuntimeArn=AGENT_RUNTIME_ARN,
            runtimeSessionId=req.session_id,
            payload=json.dumps({"user_utterance": req.user_utterance}).encode("utf-8"),
        )
        body = json.loads(response["response"].read())
    except Exception as exc:
        raise HTTPException(status_code=502, detail=f"agent invocation failed: {exc}")

    return {"reply": body.get("reply", "")}

runtimeSessionId にユーザーごとのセッション ID を渡すことで、AgentCore 側が会話の文脈を保持してくれる。フロントエンドは会話開始時に UUID を発行し、以降のリクエストで使い回すだけでよい。


ハマったところ

1. invoke_agent_runtime のレスポンスが StreamingBody で一度しか読めない

最初、レスポンスをログ出力してからパースしようとして body.read() を2回呼び、2回目が空文字になって json.loads が失敗した。

# NG: read() を2回呼ぶと2回目は空になる
raw = response["response"].read()
print(raw)  # デバッグ用に1回目の read()
body = json.loads(response["response"].read())  # ここが空文字で失敗する

# OK: 1回だけ read() して変数に保持する
raw = response["response"].read()
print(raw)
body = json.loads(raw)

boto3StreamingBody はファイルのようなオブジェクトで、一度読み切るとカーソルが戻らない。エラーメッセージは json.decoder.JSONDecodeError: Expecting value とだけ出るので、原因が読み取り済みストリームだと気づくまで時間がかかった。

2. Strands の @tool 関数の docstring を省略するとツール選択の精度が落ちる

check_grammar を実装した当初、docstring を書かずにいたところ、エージェントがツールをほぼ呼び出さず素の応答だけを返す状態になった。

# NG: docstring がないとツールの用途がモデルに伝わらない
@tool
def check_grammar(sentence: str) -> str:
    trimmed = sentence.strip()
    ...

# OK: docstring に用途と引数の説明を書く
@tool
def check_grammar(sentence: str) -> str:
    """
    英文の文法チェック結果を返す簡易ツール。

    Args:
        sentence: チェック対象のユーザー発話(英文)
    """
    trimmed = sentence.strip()
    ...

Strands はツールの docstring をそのままモデルへのツール説明として渡す仕様になっている。関数名だけでは意図が伝わらず、モデルが「呼ぶべきタイミング」を判断できていなかった。

3. AgentCore のセッション ID を使い回さないと毎回文脈が消える

会話が1ターンごとに前の内容を忘れる不具合が出て、最初は Claude のモデル設定を疑った。実際の原因はフロント側で session_id を毎回 uuid4() で生成し直していたことだった。

# NG: リクエストごとに新しい session_id を発行すると文脈が保持されない
import uuid
session_id = str(uuid.uuid4())  # 毎回呼ばれると意味がない

# OK: 会話開始時に1回だけ発行し、以降は使い回す
# (React Native 側で useState に保持し、conversation 終了までそのまま使う)

AgentCore Runtime は runtimeSessionId をキーに会話状態を保持しているため、ID が毎回変わると新規セッション扱いになり文脈が引き継がれない。FastAPI 側では気づけず、React Native 側の実装を見直して解決した。


結果

指標 数値
開発期間(要件定義〜リリース) 約 6 週間(副業ペース)
1ターンあたりの応答時間(平均) 約 2.1 秒(Bedrock 呼び出し込み)
AgentCore の月間呼び出し回数(自分のテスト利用) 約 800 回
Bedrock 利用料金(Claude 3.5 Sonnet、1ヶ月) 数百円程度(自分のテスト範囲)
クラッシュ報告(リリース後2週間) 0 件

応答時間 2.1 秒は「間が空きすぎて不自然」と感じるラインの手前で収まっている印象で、体感の会話テンポとしては許容範囲だった。ただし込み入った長文を投げると 3 秒を超えることがあり、ここは今後モデルの max_tokens 調整や会話履歴のトリミングで改善したい。

料金面は個人のテスト利用の範囲なので、公開後にユーザーが増えた場合のコスト試算はまだ検証中。AgentCore Runtime 自体の課金体系(呼び出し回数・実行時間ベース)も踏まえて、今後継続的にモニタリングする予定。


参考リンク


関連リンク

AutoTrader 実装学習キット (FastAPI × React Native)

by ぽん (@pon_freelance)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?