はじめに
生成 AI アプリを作っていると「今使っているモデル、別のに変えたら回答の質は上がるのか?」が気になります。
とはいえ全ユーザーをいきなり切り替えるのは怖いので、一部のトラフィックだけ新モデルに回して比べたいとき、A/B テストが手軽にできればと思うことがあります。
モデル ID やプロンプトのような「頻繁に差し替えたい値」をコード外に出す先として、AppConfig のフィーチャーフラグがちょうど使えます。
今回はそこに新しい実験機能を組み合わせて、control = Amazon Nova Lite、treatment = Claude Haiku 4.5 のモデル A/B を、土台は CloudFormation、実験操作は CLI で実際に回し、同じ質問への応答がユーザーごとに変わるところまで確認します。
AppConfig experimentation とは
前提として、AppConfig のフィーチャーフラグは「アプリの動作を切り替える設定値を、コードのデプロイなしで配る」仕組みです。
アプリは、同じホスト上に同居させた AppConfig Agent(Lambda 拡張や ECS/EKS のサイドカーなど)に localhost 経由で問い合わせて、フラグの値(今回なら使用するモデル ID)を受け取ります。
今回の experimentation は、このフラグの値を「ユーザーごとに出し分けて、どちらが良いか比べる」ための A/B テスト機能です。
実験では大きく 3 つを決めます。
- control: 比較のベースになる現行の設定(例: Nova Lite)
- treatment: 試したい変更案(例: Claude Haiku)。最大 5 つ
- エクスポージャー: 全ユーザーのうち何%を実験に参加させるか
実験を開始すると、AppConfig がユーザーごとに control / treatment を振り分けてフラグの値を返してくれるので、アプリは受け取ったモデル ID で Bedrock を呼ぶだけで A/B テストになります。
振り分けはユーザーごとに固定されるので、同じユーザーが呼ぶたびにモデルが変わることはありません。
それ、前からできたのでは?
ここまで読んで 「ランダムに%で分けるだけなら、前からフラグでできたのでは?」 と思いませんでしたか?
AppConfig のフィーチャーフラグには以前からマルチバリアントフラグの split 演算子があり、(split by::$userId pct::50 seed::"...") と書けば「ユーザー ID のハッシュで 50% をランダムかつ一貫して(スティッキーに)振り分ける」ことができました。
つまりランダム分割やスティッキー割り当ても、実験機能の新規要素ではありません。
では experimentation は何を足したのか?
ひとことで言うと 「実験」をマネージドな運用単位にしたという認識です。
| できること | 従来のフラグ(split) |
実験機能(experimentation) |
|---|---|---|
| ランダムに%で分割し、ユーザーごとに固定 | できる | できる(ここは同じ) |
| 割合(%)を増やす | 設定を書き換えて再デプロイ | コマンド一発。開始・停止・履歴も残る |
| 誰が比較対象かの区別 | なし(自前で管理) | control とそれ以外を自動で区別 |
| 進捗の可視化 | 自前 | 実験ダッシュボードが付く |
| 結果の統計分析(コンバージョン比較など) | 自前 | 自前(ここは同じ) |
ざっくりと「実験としての運用・可視化」が experimentation で新しくマネージドになったというイメージでしょうか。
裏を返すと、単純な出し分けだけなら従来どおり split ルールで十分(しかも実験時間課金もかかりません)で、実験のライフサイクル管理と可視化が欲しいときに experimentation を使う、という住み分けにな理想という感想です。
やってみた
control=Nova Lite・treatment=Claude Haiku 4.5 のモデル A/B を回します。
土台は CloudFormation、実験操作は CLI(2.35.21)、Agent はローカルの Docker でざっくり確認します。
手順1: 土台となるリソースを CloudFormation でデプロイする
実験の実体は「フラグの値(modelId)をユーザーごとに出し分けること」なので、まず土台になるフィーチャーフラグが必要です。
AppConfig ではアプリケーション → 環境・設定プロファイル → フラグ、という階層でリソースを作ります。
ここは変更が少ない土台なのでサクッと IaC で作ります。
なお 実験定義・実験ランは 2026 年 7 月時点で CloudFormation に未対応です。
この手順で作るのは実験の「入れ物」までで、実験そのものは手順2 以降で CLI で作っていきます。
フラグには modelId 属性を持たせ、デフォルト値は control と同じ Nova Lite にしておきます。
実験に参加しないユーザーにはこのデフォルト値が配られるので、実験の外では今までどおり Nova Lite で動く、という安全な状態になります。
appconfig-bedrock.yaml(クリックで展開)
AWSTemplateFormatVersion: "2010-09-09"
Description: AppConfig experimentation x Bedrock model A/B - base infrastructure
Resources:
# Application
App:
Type: AWS::AppConfig::Application
Properties:
Name: llm-ab
# Environment
Env:
Type: AWS::AppConfig::Environment
Properties:
ApplicationId: !Ref App
Name: demo
# Configuration
Profile:
Type: AWS::AppConfig::ConfigurationProfile
Properties:
ApplicationId: !Ref App
Name: llm-flags
LocationUri: hosted
Type: AWS.AppConfig.FeatureFlags
# Feature Flag
FlagVersion:
Type: AWS::AppConfig::HostedConfigurationVersion
Properties:
ApplicationId: !Ref App
ConfigurationProfileId: !Ref Profile
ContentType: application/json
Content: |
{
"flags": {
"llm-config": {
"name": "llm-config",
"attributes": { "modelId": { "constraints": { "type": "string", "required": true } } }
}
},
"values": { "llm-config": { "enabled": true, "modelId": "apac.amazon.nova-lite-v1:0" } },
"version": "1"
}
# Deployment Strategy
NoBake:
Type: AWS::AppConfig::DeploymentStrategy
Properties:
Name: llm-ab-nobake
DeploymentDurationInMinutes: 0
FinalBakeTimeInMinutes: 0
GrowthFactor: 100
ReplicateTo: NONE
# Deployment
Deploy:
Type: AWS::AppConfig::Deployment
Properties:
ApplicationId: !Ref App
EnvironmentId: !Ref Env
ConfigurationProfileId: !Ref Profile
ConfigurationVersion: !Ref FlagVersion
DeploymentStrategyId: !Ref NoBake
Outputs:
ApplicationId: { Value: !Ref App }
EnvironmentId: { Value: !Ref Env }
ConfigurationProfileId: { Value: !Ref Profile }
# デプロイ
aws cloudformation deploy --template-file appconfig-bedrock.yaml --stack-name llm-ab
# 実験定義で使うので出力値を環境変数に取り込んでおく
read APP_ID ENV_ID PROFILE_ID < <(aws cloudformation describe-stacks --stack-name llm-ab \
--query "Stacks[0].Outputs" --output json \
| python3 -c "import sys,json;o={x['OutputKey']:x['OutputValue'] for x in json.load(sys.stdin)};print(o['ApplicationId'],o['EnvironmentId'],o['ConfigurationProfileId'])")
手順2: CLI で実験定義を作る
実験定義には CloudFormation リソースが無い(手順6 で詳しく触れます)ので、create-experiment-definition を CLI から呼びます。
control に Nova Lite、treatment に Claude Haiku のモデル ID を入れ、対象は「日本のユーザー」にします。
aws appconfig create-experiment-definition \
--application-identifier "$APP_ID" \
--name llm-model-ab \
--hypothesis "Claude Haikuに切り替えると、Nova Liteより回答の満足度が上がるはず" \
--audience-rule '(eq $country "JP")' \
--environment-identifier "$ENV_ID" \
--configuration-profile-identifier "$PROFILE_ID" \
--flag-key llm-config \
--control '{"Description":"現行のNova Lite","Weight":50,
"FlagValue":{"Enabled":true,"AttributeValues":{"modelId":{"StringValue":"apac.amazon.nova-lite-v1:0"}}}}' \
--treatments '[{"Description":"Claude Haiku 4.5","Weight":50,
"FlagValue":{"Enabled":true,"AttributeValues":{"modelId":{"StringValue":"jp.anthropic.claude-haiku-4-5-20251001-v1:0"}}}}]'
レスポンスでは control に c、treatment に t1 というキーが自動採番され、これを次のオーバーライドで使います。
一応 AWS マネージメントコンソールの AppConfig → 実験 からもイメージを確認しておきましょう。
手順3: 実験を開始
start-experiment-run で実験を開始します。
ここでは 50% のユーザーを実験に入れつつ、動作確認用にテストユーザー 2 人をオーバーライドで各モデルに固定割り当てしておきます。
課金はこの API を呼んだ瞬間から始まります。
aws appconfig start-experiment-run \
--application-identifier "$APP_ID" \
--experiment-definition-identifier llm-model-ab \
--exposure-percentage 50 \
--treatment-overrides '{"Inline":{"demo-user-haiku":"t1","demo-user-nova":"c"}}'
こちらも AWS マネージメントコンソールの AppConfig → 実験 から、実験が開始されていることを確認しておきましょう。
手順4: Agent を立てて、割り当てられたモデルで Bedrock を呼ぶ
実験の配信には AppConfig Agent が必要です。
Agent はアプリと同じマシンで動く補助プロセスとして、AppConfig からフラグを取得・キャッシュし localhost:2772 への HTTP で返してくれます。
「このユーザーは Nova か? Haiku か?」という実験の割り当てを解決するのもこの Agent の仕事です。
本番では Lambda 拡張や ECS/EKS サイドカーとして動かしますが、今回は手元確認なので Docker で起動します。
docker run -d -p 2772:2772 -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \
-e AWS_REGION=ap-northeast-1 public.ecr.aws/aws-appconfig/aws-appconfig-agent:2.x
アプリ側のコードはこれだけです。
Agent から modelId を受け取り、そのまま Bedrock の Converse に渡します。
アプリはどのモデルを使うかを一切知りません — そこが AppConfig 側の実験に切り出されているのがポイントです。
ask.py(クリックで展開)
import json, urllib.request, boto3
AGENT = "http://localhost:2772/applications/llm-ab/environments/demo/configurations/llm-flags"
brt = boto3.client("bedrock-runtime", region_name="ap-northeast-1")
def ask(entity_id, prompt):
# 1. Agent からこのユーザー向けの modelId を取得(Context ヘッダーで entityId と country を渡す)
req = urllib.request.Request(
AGENT,
headers={"Context": f"entityId={entity_id},country=JP"}
)
cfg = json.load(urllib.request.urlopen(req))["llm-config"]
# 2. 受け取った modelId でそのまま Bedrock を呼ぶ
resp = brt.converse(
modelId=cfg["modelId"],
messages=[{"role": "user", "content": [{"text": prompt}]}],
inferenceConfig={
"maxTokens": 120,
"temperature": 0.3
},
)
return cfg["_variant"], cfg["modelId"], resp["output"]["message"]["content"][0]["text"]
if __name__ == "__main__":
prompt = "S3の署名付きURLとは何か、初心者向けに3文で説明して。"
# オーバーライドで各モデルに固定割り当てした2人に、同じ質問を投げる
for uid in ["demo-user-nova", "demo-user-haiku"]:
variant, model_id, text = ask(uid, prompt)
print(f"\n=== {uid} variant={variant} model={model_id} ===")
print(text)
python3 ask.py で実行します(Agent 起動中・認証情報がシェルに入っている前提)。
オーバーライドした 2 人に同じ質問を投げると、割り当てられたモデルで応答が返り、中身がはっきり違いますね。
レスポンスの _variant でどちらの割り当てを受けたかが分かるので、これを実際の評価(クリック率や満足度スコア)と一緒にログへ出せば、後段の分析で control / treatment を比較できそうです。
手順5: 一般ユーザーの振り分けを確認
オーバーライドなしの一般ユーザーは、エクスポージャーと重みに従ってランダムに割り当てられます。
ここでは課金もあるので Bedrock は呼ばず、Agent が返す _variant と modelId だけを見て、割り当ての分布を確認します。
ユーザー 20 人分を取得してみます。
BASE="http://localhost:2772/applications/llm-ab/environments/demo/configurations/llm-flags"
for i in $(seq 1 20); do
curl -s -H "Context: entityId=user-$i,country=JP" "$BASE" \
| python3 -c "import sys,json; d=json.load(sys.stdin)['llm-config']; print(f'user-$i:', d['_variant'], d['modelId'])"
done
今回はデフォルト値も Nova Lite なので、__default__(実験対象外)と __c__(control に当選)はどちらも Nova Lite になります。
同じモデルでも意味は別物で、実験の測定対象に入るのは __c__ のユーザーだけ、という点は分析時に効いてきます。
なおエクスポージャーは増やす方向にしか変更できず、問題が起きたら下げるのではなく停止する運用へなる点に留意が必要です。
手順6: 停止と後片付け
# 実験を停止(課金もここで停止)
aws appconfig stop-experiment-run --application-identifier "$APP_ID" \
--experiment-definition-identifier llm-model-ab --run 1
# 実験定義はデフォルトだとアーカイブ扱いで残るので、DESTROY で完全削除
aws appconfig delete-experiment-definition --application-identifier "$APP_ID" \
--experiment-definition-identifier llm-model-ab --delete-type DESTROY
# CloudFormation で作った土台も削除
docker rm -f llm-agent
aws cloudformation delete-stack --stack-name llm-ab
今回は実験開始から停止まで約 10 分だったので、切り上げ 1 時間分の $0.90 + わずかな Bedrock 呼び出し料金で収まりましたが微妙にかかりますね。
気をつけるポイント
- 結果指標の統計分析は組み込まれていないので、
_variantと評価をログ・メトリクスに出して CloudWatch などで比較する部分は自前 - 勝ったモデルの全展開は、実験を停止する前にフラグの
modelIdを勝者に更新・デプロイしておくとシームレスです。先に止めるとデフォルト値(今回は Nova Lite)に一旦戻る - 配信は AppConfig Agent 前提(Lambda 拡張・EC2・ECS/EKS サイドカー対応)です。Bedrock を SDK で直呼びしている構成では、モデル ID を Agent 経由で受け取る形への小さな改修が要ります
まとめ
モデルのランダム A/B 自体は従来の split ルールでもできましたが、experimentation は ざっくりとをマネージドにしてくれる。
単純な出し分けなら split で十分だが、段階的に広げながら測る運用なら experimentation、の住み分けという印象です。
ただし結果の統計分析は自前なので、_variant をどう評価と紐づけるかはセットで考える必要がありそうです。






