1
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 Agentで「チームごとの利用量」をリクエスト単位でタグ付けする

1
Posted at

はじめに

複数チームで 1 つの AIエージェントを共用していると、「結局どのチームがどれだけ叩いているのか?」が見えにくくなりがちです。

2026/5 のアップデートで、Amazon Bedrock の InvokeModel / InvokeModelWithResponseStream でもリクエスト単位の推論呼び出しのタグ付けをサポートするようになりました。これまで Converse / ConverseStream だけだったものが両系統そろった形です。

この記事では、Bedrock AgentCore Runtime 上で動かす Strands Agent から、1 つのエージェントのまま、リクエストごとにタグを付け替えて「チーム A のリクエストには team=team-a、チーム B には team=team-b」と利用量を出し分ける方法を試してみます。こうすることで、Amazon Bedrock のモデル呼び出しログ内で、それらのタグ別に使用状況を分析できるようになります。

忙しい人のための要約

  • Amazon Bedrock のリクエストメタデータ機能が InvokeModel / InvokeModelWithResponseStream にも対応し、Converse 系と同じくリクエスト単位で team などのタグを付与できるようになった
  • 1 エージェントでチーム別にタグを出し分けることで、チームごとの利用量を分析できるようになる
  • Strands Agent(内部で ConverseStream を使用)からは BedrockModeladditional_args={"requestMetadata": {...}} で渡せる
  • AgentCore Runtime ではリクエストのペイロード、もしくは認証済みの Cognito グループ(cognito:groups)からチーム名を取り出し、その値でメタデータを組み立てる
  • 利用にはモデル呼び出しログの有効化が前提
  • Cost Explorer からは見られないので、コスト分析にはログ基盤での集計が必要
  • タグは呼び出しログ(CloudWatch Logs / S3)に記録され、そこから Logs Insights / Athena などで集計する

リクエストメタデータタグ付けとは

Amazon Bedrock のリクエストメタデータは、推論呼び出し 1 回ごとにキー・バリュー形式のタグを付与できる機能です。付与したタグはモデル呼び出しログに記録され、チーム・アプリ・環境・実験といった切り口で利用量を後から分析できます。「同じエージェントだが、来たリクエストのチームに応じてタグを変える」という使い方ができます。

API ごとにタグの渡し方が分かれています。

API タグの渡し方
Converse / ConverseStream リクエストボディの requestMetadata フィールド
InvokeModel / InvokeModelWithResponseStream X-Amzn-Bedrock-Request-Metadata HTTPヘッダー

制限は両者共通で、1 リクエストあたり最大 16 エントリ、キー・バリューともに最大 256 文字です。詳細は公式ドキュメントを参照してください。

全体像はこんな構成になります。

何がうれしいのか

Strands × AgentCore の構成でチーム別の利用量を見ようとすると、チームごとにエージェントを分けるか、IAM プリンシパルやインファレンスプロファイルで切り分ける必要があります。リクエストメタデータなら、エージェントは 1 つのままチーム名というラベルを呼び出しに添えるだけで済みます。

向いていそうなのは次のようなケースです。

  • 社内の複数チームが共通のエージェント基盤を使っていて、チャージバック(部門別の費用按分)の材料がほしい
  • 同じエージェントで本番・検証・実験を回していて、環境ごとにトークン消費を比べたい
  • 機能フラグや A/B の実験ごとに、どれくらいモデルを使ったか追いたい

team のほかに environmentfeature のような、値の種類が少なく集計しやすいキーを選んでおくと後段の分析が楽になります。

やってみた

モデル呼び出しログを有効化する

ここで使うリクエストメタデータは Amazon Bedrock のモデル呼び出しログに記録されます。ログが有効なリージョンでしか記録されないため、有効にしていない場合は以下のドキュメントを参考に有効化しておいてください。今回は CloudWatch Logs に出力する設定で確認しています。

AgentCore CLI を入れる

新しい AgentCore CLI は npm パッケージとして配布されています。Node.js 20+ と Python 3.10+、そして CDK でデプロイするため AWS CDK が必要です。

npm install -g @aws/agentcore
agentcore --version

エージェントの作成

agentcore create で Strands + Bedrock のプロジェクトを雛形から作ります。

agentcore create \
  --name teamagent \
  --framework Strands \
  --protocol HTTP \
  --model-provider Bedrock \
  --memory none
cd teamagent

生成されるディレクトリは次のような構成です。

teamagent/
  agentcore/
    agentcore.json     # プロジェクトとエージェントの構成
    aws-targets.json   # デプロイ先のアカウント/リージョン
    .env.local
  app/
    teamagent/
      main.py          # エージェントのエントリポイント
      pyproject.toml

ここではチームごとにメタデータを差し替えたいので、main.py を次のように書き換えます。

Strands の BedrockModel は内部で ConverseStream API をデフォルトで呼び出します。requestMetadataConverse 系のトップレベルのフィールドなので、BedrockModeladditional_args にそのまま載せれば渡せそうです(実際、Strands はリクエスト組み立て時に additional_args をリクエストのトップレベルへ展開します)。

from bedrock_agentcore.runtime import BedrockAgentCoreApp
from strands import Agent
from strands.models import BedrockModel

app = BedrockAgentCoreApp()

# 許可するチームだけをタグに通す(任意の文字列がそのままタグに乗らないように)
ALLOWED_TEAMS = {"team-a", "team-b"}


@app.entrypoint
def invoke(payload):
    team = payload.get("team", "unknown")
    if team not in ALLOWED_TEAMS:
        team = "unknown"

    prompt = payload.get("prompt", "")

    model = BedrockModel(
        model_id="global.amazon.nova-2-lite-v1:0",
        region_name="ap-northeast-1",
        # リクエストメタデータにチーム名を載せる
        additional_args={
            "requestMetadata": {
                "team": team,
                "environment": "prod",
            }
        },
    )

    agent = Agent(model=model)
    result = agent(prompt)
    return {"team": team, "result": str(result.message)}


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

リクエストごとに Agent を新規生成しても、デプロイされるエージェント(AgentCore Runtime)は 1 つです。これで「単一エージェントでチーム別にタグを出し分ける」というゴールは満たせました。

ただし上のコードはペイロードの team を信用しています。agentcore invoke は基本的に {"prompt": ...} というペイロードを送るため、ここに team を載せるには AWS SDK(後述の InvokeAgentRuntime)から任意の JSON を渡す形になるため、いずれにせよ呼び出し側で自由に詐称できてしまいます。チャージバックの材料にするなら、もう一段固い作り方にしたいところです。

Cognito グループをタグ源にする

より安全なのは、認証済みの Cognito グループcognito:groups クレーム)をタグ源にする方法です。

AgentCore Runtime に Cognito を JWT authorizer として設定しておくと、ランタイムが入口で Authorization: Bearer <token> を検証してからエージェントを呼びます。検証済みの Authorization ヘッダーは、エントリポイントの第 2 引数 contextRequestContext)の request_headers 経由で受け取れます(ただし後述のとおり、ヘッダーをエージェントに転送するには requestHeaderAllowlist の設定が必要です)。あとはトークンのペイロード部から cognito:groups を読むだけです。署名検証は authorizer が入口で済ませているので、コード側はデコードしてクレームを読むだけで済みます。

main.py を次のように変更します。

import base64
import json

from bedrock_agentcore.runtime import BedrockAgentCoreApp
from strands import Agent
from strands.models import BedrockModel

app = BedrockAgentCoreApp()

# 許可するチームだけをタグに通す(未知の値は unknown に倒す)
ALLOWED_TEAMS = {"team-a", "team-b"}


def _team_from_jwt(context) -> str:
    """検証済み JWT の cognito:groups からチーム名を取り出す。"""
    headers = getattr(context, "request_headers", None) or {}
    auth = headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return "unknown"

    token = auth.split(" ", 1)[1]
    try:
        # JWT のペイロード部をデコードしてクレームを読む
        payload_b64 = token.split(".")[1]
        payload_b64 += "=" * (-len(payload_b64) % 4)
        claims = json.loads(base64.urlsafe_b64decode(payload_b64))
    except Exception:
        return "unknown"

    # Cognito グループをチーム名として使う。複数ある場合は先頭の1つを取る。
    groups = claims.get("cognito:groups", [])
    team = groups[0] if groups else "unknown"
    return team if team in ALLOWED_TEAMS else "unknown"


@app.entrypoint
def invoke(payload, context):
    # context からチーム名を取り出す
    team = _team_from_jwt(context)
    prompt = payload.get("prompt", "")

    model = BedrockModel(
        model_id="global.amazon.nova-2-lite-v1:0",
        region_name="ap-northeast-1",
        # リクエストメタデータにチーム名を載せる
        additional_args={
            "requestMetadata": {
                "team": team,
                "environment": "prod",
            }
        },
    )
    agent = Agent(model=model)
    result = agent(prompt)
    return {"team": team, "result": str(result.message)}


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

ここで第 2 引数を context という名前にしているのがポイントです。SDK は params[1] == "context" のときだけコンテキストを渡す実装になっているため、引数名を変えると context が渡ってきません。また、Authorization ヘッダーはランタイムが常にエージェント側へ転送する(request_headers["Authorization"] で読める)ようになっています。

Cognito を準備する

今回のやり方は Cognito のユーザープールとアプリクライアント、そしてチームに対応するグループが先に存在していることが前提です。そのため Cognito は別途用意します。

まだ無ければ、検証用に最小構成で用意します。

REGION=ap-northeast-1

# 1. ユーザープールを作成
POOL_ID=$(aws cognito-idp create-user-pool --region $REGION \
  --pool-name teamagent-pool \
  --query 'UserPool.Id' --output text)

# 2. アプリクライアントを作成(シークレットなし・パスワード認証を許可)
CLIENT_ID=$(aws cognito-idp create-user-pool-client --region $REGION \
  --user-pool-id "$POOL_ID" \
  --client-name teamagent-client --no-generate-secret \
  --explicit-auth-flows ALLOW_USER_PASSWORD_AUTH ALLOW_REFRESH_TOKEN_AUTH \
  --query 'UserPoolClient.ClientId' --output text)

# 3. チームに対応するグループを作成(このグループ名がそのままタグの値になる)
aws cognito-idp create-group --region $REGION \
  --user-pool-id "$POOL_ID" --group-name team-a
aws cognito-idp create-group --region $REGION \
  --user-pool-id "$POOL_ID" --group-name team-b

# 4. ユーザーを作成し、パスワードを確定してグループに追加
aws cognito-idp admin-create-user --region $REGION \
  --user-pool-id "$POOL_ID" --username alice --message-action SUPPRESS
aws cognito-idp admin-set-user-password --region $REGION \
  --user-pool-id "$POOL_ID" --username alice \
  --password 'Passw0rd!example' --permanent
aws cognito-idp admin-add-user-to-group --region $REGION \
  --user-pool-id "$POOL_ID" --username alice --group-name team-a

aws cognito-idp admin-create-user --region $REGION \
  --user-pool-id "$POOL_ID" --username bob --message-action SUPPRESS
aws cognito-idp admin-set-user-password --region $REGION \
  --user-pool-id "$POOL_ID" --username bob \
  --password 'Passw0rd!example' --permanent
aws cognito-idp admin-add-user-to-group --region $REGION \
  --user-pool-id "$POOL_ID" --username bob --group-name team-b

echo "userPoolId=$POOL_ID  appClientId=$CLIENT_ID"

ここで出力された userPoolId / appClientId を次の authorizer 設定で使うため、メモしておいてください。

authorizer の設定

インバウンドの JWT authorizer は agentcore/agentcore.json の runtime エントリに書きます。agentcore create 後であれば該当エントリに次のフィールドを足して agentcore deploy で反映できます。

{
  "name": "teamagent",
  "build": "CodeZip",
  "entrypoint": "main.py",
  "codeLocation": "app/teamagent/",
  "runtimeVersion": "PYTHON_3_14",
  "networkMode": "PUBLIC",
  "protocol": "HTTP",
+   "authorizerType": "CUSTOM_JWT",
+   "authorizerConfiguration": {
+     "customJwtAuthorizer": {
+       "discoveryUrl": "https://cognito-idp.ap-northeast-1.amazonaws.com/<userPoolId>/.well-known/openid-configuration",
+       "allowedClients": ["<appClientId>"]
+     }
+   },
+   "requestHeaderAllowlist": ["Authorization"]
}

requestHeaderAllowlistAuthorization を入れるのが必須です。 AgentCore Runtime は、インバウンドの Authorization ヘッダーを既定ではエージェントのコンテナに転送しません(authorizer がトークン検証に使うだけ)。そのため requestHeaderAllowlistAuthorization を明示的に許可しないと、エージェント側の context.request_headersAuthorization が現れず、_team_from_jwt() が常に unknown を返してしまいます。この一行を入れて初めて、検証済みのトークンがエージェントに渡され、cognito:groups を読めるようになります。

新規にエージェントを足す場合は、agentcore add agent のフラグで同じ内容を生成することもできます。

agentcore add agent \
  --name teamagent \
  --language Python --framework Strands --model-provider Bedrock --memory none \
  --authorizer-type CUSTOM_JWT \
  --discovery-url "https://cognito-idp.ap-northeast-1.amazonaws.com/<userPoolId>/.well-known/openid-configuration" \
  --allowed-clients "<appClientId>" \
  --request-header-allowlist "Authorization"

デプロイする

agentcore deployagentcore.json / aws-targets.json を読み、コードを CodeZip としてパッケージし、CDK 経由で AgentCore Runtime や IAM ロールなどを作成します。

agentcore deploy

呼び出す

Cognito からアクセストークンを取得し、agentcore invoke --bearer-token で渡します。--bearer-tokenAuthorization: Bearer <token> として送られ、CUSTOM_JWT authorizer の検証に使われます。さらに前述の requestHeaderAllowlistAuthorization を入れてあれば、同じヘッダーがエージェント側にも転送され、そこから cognito:groups を読み取れます。

# グループ team-a に所属するユーザー alice のアクセストークンを取得
TOKEN=$(aws cognito-idp initiate-auth \
  --client-id <appClientId> \
  --auth-flow USER_PASSWORD_AUTH \
  --auth-parameters USERNAME=alice,PASSWORD='********' \
  --query 'AuthenticationResult.AccessToken' --output text)

# トークンを付けて呼び出す(team はトークンの cognito:groups から決まる)
agentcore invoke --bearer-token "$TOKEN" "今日の天気を一言で"

team-b 用のユーザーで取り直したトークンを使えば、同じエージェントでも team=team-b のタグが付きます。

呼び出しの流れを整理すると次のようになります。

結果を確認する

タグはモデル呼び出しログのトップレベル requestMetadata に記録されます。実際にログを生で覗くと、こんな形で入っていました(抜粋)。

{
  "operation": "ConverseStream",
  "modelId": "global.amazon.nova-2-lite-v1:0",
+   "requestMetadata": { "environment": "prod", "team": "team-a" },
  "input": { "inputTokenCount": 70 },
  "output": { "outputTokenCount": 7 }
}

team がリクエストごとに乗っているのが分かります。今回は CloudWatch Logs にモデル呼び出しログを出す設定にしているので、CloudWatch Logs Insights でチーム別に集計します。

コスト按分が目的なので、トークン数を集計します。ここで一点注意ですが単価はモデルごとに違うため、team だけで合計するとモデルを混ぜて数えてしまいます。チャージバックに使うなら、teammodelId の両方で分けて集計し、モデルごとの単価を掛ける必要があります(入力と出力でも単価が違うので分けて合計します)。

まずは team × modelId でトークンを集計します。

fields input.inputTokenCount as inTok, output.outputTokenCount as outTok
| filter ispresent(requestMetadata.team)
| stats sum(inTok) as input_tokens, sum(outTok) as output_tokens, count(*) as invocations
        by requestMetadata.team, modelId
| sort requestMetadata.team asc, modelId asc

今回はチームごとに 2 モデル(Nova 2 Lite と Nova Micro)を呼んでみました。実行すると、team × modelId の単位で分かれて集計されます。

requestMetadata.team modelId input_tokens output_tokens invocations
team-a apac.amazon.nova-micro-v1:0 28 6 1
team-a global.amazon.nova-2-lite-v1:0 70 7 1
team-b apac.amazon.nova-micro-v1:0 32 8 1
team-b global.amazon.nova-2-lite-v1:0 68 14 1

コスト額まで算出する

トークンが (team, model) で出れば、あとはモデルごとの単価を掛けるだけです。単価は AWS Pricing API(または料金ページ)から取得します。

# 例: Nova 2.0 Lite の東京リージョンの単価を取る場合
aws pricing get-products --service-code AmazonBedrock --region us-east-1 \
  --filters \
    'Type=TERM_MATCH,Field=regionCode,Value=ap-northeast-1' \
    'Type=TERM_MATCH,Field=model,Value=Nova 2.0 Lite'

今回使った 2 モデルの東京リージョン・オンデマンド単価は次のとおりでした(2026-06-01 時点)。

modelId 入力単価(USD/1K) 出力単価(USD/1K)
global.amazon.nova-2-lite-v1:0 0.00036 0.00301
apac.amazon.nova-micro-v1:0 0.000042 0.000168

Logs Insights は 1 行ごとに単価を変えられないので、モデルを 1 つに絞ってから単価を直書きし、モデル単位でコストを出します(モデルの数だけクエリを実行します)。

-- Nova 2 Lite 分
fields input.inputTokenCount as inTok, output.outputTokenCount as outTok
| filter ispresent(requestMetadata.team) and modelId = "global.amazon.nova-2-lite-v1:0"
| stats sum(inTok) as input_tokens, sum(outTok) as output_tokens,
        sum(inTok) / 1000 * 0.00036 + sum(outTok) / 1000 * 0.00301 as cost_usd
        by requestMetadata.team
| sort cost_usd desc
-- Nova Micro 分(modelId と単価だけ差し替える)
fields input.inputTokenCount as inTok, output.outputTokenCount as outTok
| filter ispresent(requestMetadata.team) and modelId = "apac.amazon.nova-micro-v1:0"
| stats sum(inTok) as input_tokens, sum(outTok) as output_tokens,
        sum(inTok) / 1000 * 0.000042 + sum(outTok) / 1000 * 0.000168 as cost_usd
        by requestMetadata.team
| sort cost_usd desc

2 本の結果を合わせると、(team, model) ごとのコストと、チーム合計が出ます。

requestMetadata.team modelId cost_usd
team-a global.amazon.nova-2-lite-v1:0 0.00004627
team-a apac.amazon.nova-micro-v1:0 0.000002184
team-b global.amazon.nova-2-lite-v1:0 0.00006662
team-b apac.amazon.nova-micro-v1:0 0.000002688
requestMetadata.team 合計 cost_usd
team-a 0.00004845
team-b 0.00006931

単価はモデル・リージョン・推論方式で変わります。同じモデルでも通常 / batch / プロンプトキャッシュの read・write などで usagetype と単価が分かれます。プロンプトキャッシュを使う場合は cacheReadInputTokenCount / cacheWriteInputTokenCount も別単価で集計に加えてください。最新の単価は AWS Pricing API や料金ページで確認するのが確実です。

(補足)繰り返し使う場合は Skill 化する

今回は単価の取得(Pricing API)とコスト集計(Logs Insights)を、手元から MCP(aws-mcp)経由で実行しました。これらは毎回ほぼ同じ手順なので、一連の流れを Skill としてまとめておくと、「チーム別の今月のコストを出して」の一言で同じ集計を再実行できるため運用が楽になります。

後片付け

不要になったら、ローカル構成からリソースを外してから再デプロイで AWS 側を削除できます。

agentcore remove all
agentcore deploy

まとめ

  • InvokeModel 系もリクエストメタデータに対応し、Converse 系と合わせてリクエスト単位の利用量タグ付けが一通りそろいました
  • Strands × AgentCore でも、Cognito グループ由来のチーム名を requestMetadata に載せるだけで、1 エージェントのままチーム別に利用量を分解できました

チャージバックの材料づくりや、共用エージェントの利用実態の可視化に手軽に使えるので、共通基盤を運用している方は試してみてはいかがでしょうか。

参考リンク

1
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
1
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?