目次を表示する

Bedrockで Production Ready な AI 機能を作る ── 設計・運用・現場の知恵

Converse API で最初のリクエストを送る ── TypeScript / AWS SDK v3 で 10 行から

Converse API で最初のリクエストを送る ── TypeScript / AWS SDK v3 で 10 行から

Part 1 で地図ができた。ここから手を動かす。架空アプリ helpdesk-ai を、Converse API 1 回で動く最小版から始める章だ。

第 1 章で予告した「Production Ready の 3 つの壁」のうち、Part 2 はまず 壁 1 の半分 ── 「機能を実装で触ってみないと、設計判断ができない」── を壊しにいく。読みながら手を動かせるように、コードは全部コピペで動く形で載せる。

対象:AWS SDK の利用経験あり、IAM ポリシーを書ける Web/バックエンドエンジニア 難易度:★★☆☆☆(章単体) 対象バージョン:@aws-sdk/client-bedrock-runtime v3 系 / Node.js 22+ / TypeScript 5+ 想定読了時間:約 25 分


この章で作るもの

helpdesk-ai第 1 形態 は、社内 FAQ を system プロンプトに直書きしただけのシンプルなチャット関数だ。RAG も Tool Use もまだ使わない。

  • 「経費精算の上限はいくら?」
  • 「VPN がつながらないけど、どこを見ればいい?」

こうした質問に、社内規程の一部を埋め込んだ AI が答える。アーキテクチャ全体は次のとおり。

graph LR
  User[社員] -->|質問| Web[Web UI<br/>Slack ボット風]
  Web -->|HTTPS| Lambda[Lambda / Fastify<br/>chat handler]
  Lambda -->|ConverseCommand| Runtime[bedrock-runtime<br/>エンドポイント]
  Runtime -->|CRIS でルーティング| Claude[Claude Sonnet 4.5<br/>us.anthropic.claude-sonnet-4-5]
  Claude -->|応答| Runtime
  Runtime --> Lambda
  Lambda --> Web
  Web --> User

この章の範囲は Lambda 内のチャットハンドラ ── Lambda → bedrock-runtime → Claude の往復だけだ。Web UI と認証は別章で扱う。


環境を準備する

1. AWS アカウントでモデルを有効化する

Bedrock の すべてのモデルはアカウント単位で「Enable」操作が必要だ。AWS マネジメントコンソールから次の手順を踏む。

  1. リージョンを us-east-1 に切り替える(CRIS の us.anthropic.claude-sonnet-4-5 を使うため)
  2. Bedrock → Model access → Modify model access
  3. Anthropic Claude Sonnet 4.5 にチェック → Submit
  4. ステータスが Access granted になるまで数秒待つ

Claude 系は 利用目的(use case)の申請フォームが出ることがある。社内ユーティリティ / 顧客対応など、用途を 1 文書けば数秒で通る。

モデル ID とリージョンの組み合わせは「Cross-Region Inference Profile(CRIS)」を必ず使う。us.anthropic.claude-sonnet-4-5 のように接頭辞 us. / eu. / apac. が付くものだ。素のリージョン直叩き(in-region)モデル ID は新しい Claude 系では限定的にしか提供されない。詳しくは付録 A で扱う。

2. IAM 権限を最小に絞る

Lambda 実行ロールに、次の最小ポリシーを付ける。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvokeClaudeViaCRIS",
      "Effect": "Allow",
      "Action": ["bedrock:InvokeModel", "bedrock:Converse"],
      "Resource": [
        "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-5-*",
        "arn:aws:bedrock:us-east-2::foundation-model/anthropic.claude-sonnet-4-5-*",
        "arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-sonnet-4-5-*",
        "arn:aws:bedrock:*:*:inference-profile/us.anthropic.claude-sonnet-4-5*"
      ]
    }
  ]
}

重要な落とし穴がここにある。CRIS を経由すると Bedrock は ルーティング先のリージョン分の foundation-model ARN に対する InvokeModel 権限も要求する。inference-profile/... だけ許可すると、実行時に裏で選ばれたリージョンの foundation-model が拒否されて 403 になる。Inference Profile の Resource と、その Profile が含む各リージョンの foundation-model の Resource を両方書くのがセオリーだ。

セキュリティ強化の本格版(IAM 条件キー、Guardrails 強制、PrivateLink)は第 14 章で扱う。ここはまず動かす最小権限版だ。

3. プロジェクトの初期化

mkdir helpdesk-ai && cd helpdesk-ai
npm init -y
npm pkg set type=module
npm i @aws-sdk/client-bedrock-runtime pino
npm i -D typescript @types/node tsx
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext \
  --strict --esModuleInterop --outDir dist

package.jsonscripts に開発用の起動スクリプトを足す。

{
  "scripts": {
    "dev": "tsx src/handlers/chat.ts"
  }
}

依存は @aws-sdk/client-bedrock-runtime と、ロガー用に pino だけ。第 8 章以降で Knowledge Bases や Agents を入れるときに @aws-sdk/client-bedrock-agent-runtime を追加する。


helpdesk-ai 最小版のコード

src/client.tssrc/handlers/chat.ts の 2 ファイルを作る。

src/client.ts ── クライアントの共通初期化

シリーズ全章でこの 1 ファイルを使い回す。retryMode: "adaptive"maxAttempts: 5 を最初から仕込んでおくのがポイント。AWS SDK の adaptive リトライは指数バックオフ + ジッタ + クライアント側スロットル検知が組み込まれており、自前で setTimeout を書く必要がない。

// src/client.ts
import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime";
import pino from "pino";

const region = process.env.AWS_REGION ?? "us-east-1";

export const bedrock = new BedrockRuntimeClient({
  region,
  // adaptive: 429 を検知したらクライアント側で自動的に送信レートを絞る
  retryMode: "adaptive",
  maxAttempts: 5,
});

export const MODEL_ID =
  process.env.BEDROCK_MODEL_ID ?? "us.anthropic.claude-sonnet-4-5";

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  base: { app: "helpdesk-ai" },
});

3 つのポイント。

  1. retryMode: "adaptive" ── standard だと固定の指数バックオフだけ、adaptive だと throttling 検知時にクライアント側で送信レートを能動的に絞る。本番では adaptive 一択
  2. maxAttempts: 5 ── デフォルト 3 は短い。5 にしておけば最大 4 回リトライ
  3. MODEL_ID を CRIS 形式で固定 ── us.anthropic.claude-sonnet-4-5。素の anthropic.claude-sonnet-4-5-20250929-v1:0 のような in-region ID は、新世代 Claude では使えないことが多い

src/handlers/chat.ts ── Converse API を 1 回呼ぶ

// src/handlers/chat.ts
import {
  ConverseCommand,
  type Message,
} from "@aws-sdk/client-bedrock-runtime";
import { bedrock, MODEL_ID, logger } from "../client.js";

// 社内 FAQ を system prompt に直書き(Part 2 後半で Knowledge Bases に移す)
const SYSTEM_PROMPT = `
あなたは社内ヘルプデスクの AI アシスタント「helpdesk-ai」です。
社員からの社内手続きに関する質問に、社内規程に基づいて簡潔に答えてください。

# 知っている社内規程の抜粋

- 経費精算の上限:1 件あたり 10 万円まで(10 万円超は事前申請が必要)
- 在宅勤務手当:月 5,000 円(毎月 25 日に給与と合算支給)
- 有給休暇:入社初年度 10 日、勤続 6 か月以上で付与
- VPN トラブル時の窓口:情シスヘルプデスク(Slack #help-it)

# 回答ルール

- 規程に書かれていない質問には「私の知識には無い」と答え、推測しない
- 個人情報・給与の具体額は答えない
- 回答は 200 文字以内、敬体(です・ます調)で
`.trim();

export type ChatResult = {
  text: string;
  stopReason: string | undefined;
  inputTokens: number | undefined;
  outputTokens: number | undefined;
};

export async function chat(userMessage: string): Promise<ChatResult> {
  const messages: Message[] = [
    { role: "user", content: [{ text: userMessage }] },
  ];

  const response = await bedrock.send(
    new ConverseCommand({
      modelId: MODEL_ID,
      system: [{ text: SYSTEM_PROMPT }],
      messages,
      inferenceConfig: {
        // ✅ helpdesk-ai は対話用途なので 2000 で右サイズ化
        //    詳細は本章の「max_tokens の章」を参照
        maxTokens: 2000,
        temperature: 0.2, // 事実回答中心なので低め
        topP: 0.9,
      },
    }),
  );

  const text = response.output?.message?.content?.[0]?.text ?? "";

  logger.info(
    {
      stopReason: response.stopReason,
      usage: response.usage,
      latencyMs: response.metrics?.latencyMs,
    },
    "converse completed",
  );

  return {
    text,
    stopReason: response.stopReason,
    inputTokens: response.usage?.inputTokens,
    outputTokens: response.usage?.outputTokens,
  };
}

// 開発時の動作確認用
if (import.meta.url === `file://${process.argv[1]}`) {
  const question = process.argv[2] ?? "経費精算の上限はいくらですか?";
  const result = await chat(question);
  console.log("\n--- 回答 ---");
  console.log(result.text);
  console.log("\n--- メタ情報 ---");
  console.log(`stopReason: ${result.stopReason}`);
  console.log(`tokens: in=${result.inputTokens} / out=${result.outputTokens}`);
}

これで動く。本文 30 数行、設定込みで 60 行ほどだ。

動かす

export AWS_REGION=us-east-1
export AWS_PROFILE=your-dev-profile  # 上の IAM ポリシーを持つロール
npm run dev "経費精算の上限はいくらですか?"

期待される出力(モデル応答は実行ごとに揺れるが、概ねこの形になる)。

--- 回答 ---
経費精算の上限は 1 件あたり 10 万円までです。
10 万円を超える場合は事前申請が必要となりますので、申請手続きをお願いします。

--- メタ情報 ---
stopReason: end_turn
tokens: in=312 / out=78

これが helpdesk-ai 第 1 形態の全体だ。


Converse API vs InvokeModel API

ここで一度立ち止まって、なぜ Converse を使うのか整理しておく。Bedrock の data plane API には 2 系統ある。

API役割推奨タイミング
InvokeModel / InvokeModelWithResponseStreamモデル固有の生 JSON を直接送る低レベル APIレガシー資産、Bedrock 未対応の極端なモデル固有パラメータが必要なとき
Converse / ConverseStreamモデル横断の統一 API。AWS が 新規開発で推奨新規開発、マルチモデル切替を見据えるなら基本これ

Converse のキモは モデル固有のプロンプトテンプレートを SDK 側が自動適用する点だ。たとえば Llama 系は <|begin_of_text|><|start_header_id|>system<|end_header_id|>... という独自テンプレートを期待するが、Converse なら system / messages を渡すだけで内部で組み立ててくれる。

❌ 悪い例:InvokeModel でモデル固有 JSON を組み立てる

// ❌ モデルを変えるたびに body の形が変わる。SDK のメリットを捨てている
const response = await bedrock.send(
  new InvokeModelCommand({
    modelId: "us.anthropic.claude-sonnet-4-5",
    contentType: "application/json",
    body: JSON.stringify({
      anthropic_version: "bedrock-2023-05-31",
      max_tokens: 2000,
      system: SYSTEM_PROMPT,
      messages: [{ role: "user", content: userMessage }],
    }),
  }),
);
// パースもモデル固有
const body = JSON.parse(new TextDecoder().decode(response.body));
const text = body.content[0].text;

✅ 良い例:Converse でモデル横断の統一スキーマを使う

// ✅ modelId を nova-pro や mistral-large に差し替えても同じコードで動く
const response = await bedrock.send(
  new ConverseCommand({
    modelId: MODEL_ID,
    system: [{ text: SYSTEM_PROMPT }],
    messages: [{ role: "user", content: [{ text: userMessage }] }],
    inferenceConfig: { maxTokens: 2000, temperature: 0.2 },
  }),
);
const text = response.output?.message?.content?.[0]?.text;

第 5 章で「モデル選定はワークロード次第」と書いた。Converse を使っておけば、MODEL_ID を環境変数で差し替えるだけで Claude → Nova → Mistral と切り替えられる。これは Production Ready の基礎体力だ。


max_tokens を最初から徹底する

ここからが本章の中心。コード上ですでに maxTokens: 2000 を入れたが、これは「とりあえずの値」ではなく 設計判断だ。

なぜ「未設定」が一番危険なのか

AWS 公式は次のように明言している(Optimize your applications for scale and reliability on Amazon Bedrock)。

max_tokens 未設定は、スロットリングの最も多い原因

理由は仕組みの一行で説明できる。Bedrock の TPM(Tokens Per Minute)クォータは「入力 + 出力上限」で予約計上されるmax_tokens を渡さないとモデル固有の最大値(Claude Sonnet 4 系では数万トークン規模、具体値は最新の公式 docs を要確認)が予約される。つまり 1 リクエストごとに「使いもしないクォータ」を数万トークン分も吐き出してしまう。

実例:アカウントの Sonnet 4 系 TPM クォータが 数十万〜百万トークン規模(具体値は申請やデフォルト値で変動する。最新値は Service Quotas を参照)だとする。仮にデフォルト max が 64K、TPM クォータが 400K の状況を考えると、

  • max_tokens をデフォルト最大に任せると → 同時に走れるのは数リクエスト
  • max_tokens を 2K に絞ると → 同時に走れるのは 約 100 倍 のリクエスト数

桁が違う。「動いた」の段階では気付かないが、ユーザーが付いた瞬間に ThrottlingException で詰まる。

max_tokens 設計の指針

helpdesk-ai のような用途別に、目安はこのあたり。

用途推奨 max_tokens理由
短文サマリ・分類256〜500出力が短い
対話(チャット)1500〜25001 ターンの応答は 1〜2 段落程度
構造化抽出(JSON)1000〜3000スキーマサイズに合わせる
コード生成(1 関数〜1 ファイル)4000〜8000関数 1 つで 2K〜4K になることも
大規模ドキュメント生成16000〜例外。事前に PoC でトークン数を実測してから

helpdesk-ai のチャットは 200 文字以内ルールで運用するため、2000 でも余裕がある。むしろ Chain-of-Thought の中間出力で消費される分を考慮して、対話は 1500〜2500 を初期値にすると良い。

❌ 悪い例 → ✅ 良い例

// ❌ inferenceConfig を渡し忘れている。実はモデル最大値(数万トークン規模)が TPM 予約されている
await bedrock.send(
  new ConverseCommand({
    modelId: MODEL_ID,
    system: [{ text: SYSTEM_PROMPT }],
    messages,
  }),
);

// ❌ デフォルトに任せている(同上)
await bedrock.send(
  new ConverseCommand({
    modelId: MODEL_ID,
    system: [{ text: SYSTEM_PROMPT }],
    messages,
    inferenceConfig: { temperature: 0.2 }, // maxTokens が無い
  }),
);

// ✅ 用途に応じて maxTokens を必ず設定
await bedrock.send(
  new ConverseCommand({
    modelId: MODEL_ID,
    system: [{ text: SYSTEM_PROMPT }],
    messages,
    inferenceConfig: { maxTokens: 2000, temperature: 0.2 },
  }),
);

シリーズ全章のコードで maxTokens を明示するのは、これが理由だ。Day 1 から癖にする。

この設計判断はこの章で終わらない。第 13 章 観測で「max_tokens がスロットリングと観測でどう効くか」を CloudWatch メトリクス越しに見せ、第 17 章 アンチパターン #6max_tokens 未設定を「9 個のアンチパターン」の 1 つとして集約する。3 章またぎで繰り返す。


レスポンスの読み方 ── stopReasonusage

Converse のレスポンスには本文以外に 2 つの重要フィールドがある。本番運用では本文よりこちらを見る時間の方が長い。

stopReason ── なぜ生成が止まったか

意味取るべき対応
end_turnモデルが自然に応答を完了正常。そのまま返す
max_tokensinferenceConfig.maxTokens に達して打ち切られた要警戒。本文が途切れている。maxTokens を上げるか、出力を分割する設計に変える
tool_useモデルがツール呼び出しを要求Tool Use ループへ(第 7 章で扱う)
stop_sequencestopSequences で指定した文字列に当たった正常。意図したストップなら OK
content_filteredGuardrails 等で出力が遮断された第 11 章で扱う
guardrail_intervenedGuardrails が介入第 11 章で扱う

max_tokens で打ち切られた応答をそのままユーザーに返すと、文が途中で終わっていて気付かないことがある。本番では if (stopReason === "max_tokens") の分岐を必ず置く。

if (response.stopReason === "max_tokens") {
  logger.warn(
    { question: userMessage, outputTokens: response.usage?.outputTokens },
    "response truncated by max_tokens",
  );
  // 設計判断:
  //  - リトライ(より大きな maxTokens で)
  //  - そのまま返す + 末尾に「…」を付ける
  //  - エラーにする
}

usage ── 入出力トークン数

{ inputTokens: 312, outputTokens: 78, totalTokens: 390 }

3 つの数字を必ずログに出す。理由は 3 つ。

  1. コスト計算:第 15 章 / 付録 B で Application Inference Profile + タグでコスト按分するが、その前段として「1 リクエストいくらか」を逐次知るため
  2. キャパシティ計画:1 リクエストの平均 outputTokens を知らないと、適切な maxTokens が決められない
  3. 異常検知inputTokens がいつもより跳ねていたら、誰かが巨大ペイロードを投げている

第 13 章 観測では、この usage を CloudWatch カスタムメトリクスに送って EMF(Embedded Metric Format)でダッシュボード化する手順を扱う。今はログに出すだけで OK。

metrics.latencyMs

Converse のレスポンスには metrics.latencyMs(モデル側で計測したレイテンシ)も含まれる。クライアント側の往復時間とのギャップを見ると、ネットワーク遅延とモデル遅延を分離して観察できる。


モデル ID は CRIS(Cross-Region Inference Profile)を使う

最後にもう一度モデル ID の話。MODEL_IDus.anthropic.claude-sonnet-4-5 のように us. 接頭辞付きにしたのは、以下のような理由がある。

CRIS の何が嬉しいか

sequenceDiagram
  participant App as helpdesk-ai
  participant CRIS as CRIS Profile<br/>us.anthropic.claude-sonnet-4-5
  participant USE1 as us-east-1<br/>Sonnet 4.5
  participant USE2 as us-east-2<br/>Sonnet 4.5
  participant USW2 as us-west-2<br/>Sonnet 4.5

  App->>CRIS: ConverseCommand(modelId="us.anthropic...")
  Note over CRIS: 各リージョンの<br/>余裕度を見て選ぶ
  CRIS->>USE2: 今は us-east-2 が空いている
  USE2-->>CRIS: response
  CRIS-->>App: response<br/>(課金は呼び出し元リージョン単価)
  • スロットリング耐性:1 つのリージョンが詰まっていても別リージョンが受ける。In-region 直叩きの 2〜3 倍のスループットが取れる
  • 新世代モデルの提供形態:新しい Claude 系(Sonnet 4.5 以降など)は CRIS が標準提供形態で、in-region ID は限定的にしか公開されない
  • 追加料金なし:CRIS のルーティング自体に料金は乗らない。課金は呼び出し元リージョンの単価で計算される

Geographic CRIS と Global CRIS の違い(さわりだけ)

CRIS には 2 系統ある。本格的な使い分けは付録 A に譲るとして、ここでは要点だけ。

種類接頭辞動き価格
Geographicus. / eu. / apac.地理境界内でリージョン横断標準価格
Globalglobal.全商用リージョンから最適選択約 10% 安い、スループット最大

データレジデンシー要件があれば Geographic、無ければ Global がコスト・スループットの両面で有利。helpdesk-ai は国内利用想定で us. を使うが、これは「apac. に切り替えできる選択肢を残している」ということでもある。設定 1 行で切り替えられる。

IAM の落とし穴(再掲)

CRIS を使うときは、IAM ポリシーで inference-profile の Resource とその Profile が含む各リージョンの foundation-model の Resource を両方 許可する必要がある。これは最初の IAM ポリシー JSON で書いたとおりだ。これを忘れて「inference-profile の Resource だけ書いて 403 で詰まる」のが、CRIS 導入時の トップ 1 のハマりどころなので強調しておく。


動かす手順のまとめ

ここまでの作業を 1 枚にしておく。

# 1. プロジェクト作成
mkdir helpdesk-ai && cd helpdesk-ai
npm init -y && npm pkg set type=module
npm i @aws-sdk/client-bedrock-runtime pino
npm i -D typescript @types/node tsx

# 2. ファイル配置(前掲の 2 ファイル)
#    src/client.ts
#    src/handlers/chat.ts

# 3. AWS 側準備
#    - Bedrock コンソールで Claude Sonnet 4.5 を Enable
#    - 上掲 IAM ポリシーを実行ロール/ユーザーに付与

# 4. 環境変数
export AWS_REGION=us-east-1
export AWS_PROFILE=your-dev-profile
# 任意:別モデルに差し替えるなら
# export BEDROCK_MODEL_ID=us.anthropic.claude-haiku-4-5

# 5. 実行
npm run dev "在宅勤務手当はいくらですか?"

出力例:

{"level":30,"time":1717459200000,"app":"helpdesk-ai",
 "stopReason":"end_turn","usage":{"inputTokens":315,"outputTokens":62,
 "totalTokens":377},"latencyMs":847,"msg":"converse completed"}

--- 回答 ---
在宅勤務手当は月 5,000 円です。毎月 25 日に給与と合算して支給されます。

--- メタ情報 ---
stopReason: end_turn
tokens: in=315 / out=62

これで helpdesk-ai は最低限の問いに答えられるようになった。


次章への接続

これでチャットは動いた。だが、本物のチャット UI には 2 つ足りないものがある。

  • Streaming:800ms 待たされてから 1 ブロックで応答が出る今の形では、UX として弱い。ConverseStream で typing 風に逐次表示する
  • Tool Use:「私の今月の残休暇は?」のように AI が能動的に外部 API を呼ぶ必要がある質問が、システムプロンプトだけでは答えられない。toolConfig で関数呼び出しを定義する

次章でこの 2 つを入れて、helpdesk-ai を 対話 UI として最低限見せられる形に持っていく。RAG(社内規程 PDF の取り込み)は第 8 章、Agents による多段ステップは第 9 章で扱う。

階段は急ぐと足を滑らす。1 段ずつだ。


章末まとめ

  • helpdesk-ai 最小版を Converse API 1 回で動く形まで作った。本体コードは 30 数行
  • Converse を使う:モデル固有 JSON を捨て、messages / system / inferenceConfig の統一スキーマで書く。modelId 差し替えだけでモデル横断
  • モデル ID は CRIS(us. eu. apac. global.)を使う。In-region 直叩きは新世代 Claude では限定的。IAM では Profile と各リージョンの foundation-model を両方許可する
  • max_tokens を必ず設定する。未設定はモデル最大値(数万トークン規模)が TPM 予約され、AWS 公式「スロットリングの最大原因」。対話なら 1500〜2500 を初期値に
  • レスポンスは stopReasonusage を必ず見るmax_tokens で打ち切られた応答をそのまま返すと文が途中で終わっていることがある
  • クライアントは retryMode: "adaptive" + maxAttempts: 5 を最初から仕込んでおく。本番想定では standard ではなく adaptive 一択
  • 次章で Streaming と Tool Use を入れて、helpdesk-ai を対話 UI として見せられる形にする