Bedrock Flows6ヶ月運用で学んだ、LLMワークフロー自動化の地味な落とし穴

AWS Bedrock Flowsを本番運用6ヶ月。状態管理、エラーハンドリング、コスト爆発で実際に直面した課題と、チームで実装した対策を赤裸々に共有します。

Bedrock Flows本番6ヶ月、ワークフロー自動化の地味だけど痛い落とし穴

先日プロジェクトで、AWS Bedrock Flowsを本番導入してちょうど6ヶ月が経ちました。正直なところ、最初は「LLMのワークフロー自動化がこんなに難しいのか」と驚かされ続けた半年でした。チームで何度も「これどうするん?」って立ち止まったポイントを、今のうちに共有しておきたいんですよ。

個人的には、Bedrock Flowsの可能性は本当に感じています。ただ、ドキュメントとか参考記事では絶対に書かれていない、実務的な痛みが山ほどあるんです。今日はそこに正面から向き合った話を書きます。

Bedrock Flowsって何なのか、まず整理する

ちょっと前提をおさえておくと、Bedrock Flowsは2024年後半に本格リリースされたAWSの生成AI構成管理サービスです。簡単に言うと、複数のLLMモデルやAPIを組み合わせてワークフローを組み立てることができるビジュアル環境なんですよね。

うちのチームは、複雑なドキュメント処理パイプラインをこれで実装しました。入力ドキュメント→テキスト抽出→分類→サマリー→承認フロー、みたいな感じで、各ステップで異なるLLMモデル(ClaudeとかLlama)を使い分けています。

2026年の今時点で、もうBedrock FlowsなしでマルチステップのLLMワークフロー組むのは考えられないくらい便利なんですけど、本番に持ち込んだ時点で「あ、これ設計難しいな」って気づきました。

地雷1:状態管理が想像以上に複雑

これが一番大きい問題でした。Bedrock Flowsって、複数のステップを直列や並列で実行できるんですけど、ステップ間の状態を正しく引き継ぐのが地味に難しい。

最初の実装では、各ステップの出力をそのまま次のステップに渡していました。ドキュメント抽出→Claude 3.5でサマリー→承認待ち、みたいな流れです。ところが、本番で「なぜか二重処理されている」みたいなバグが何度か起きたんですよ。

原因は、ステップが失敗してリトライされた時、前のステップの出力が古いままになってたこと。つまり、状態の一貫性が失われていたわけです。

# 簡略化したFlow定義の例
flow:
  steps:
    - name: extract
      type: bedrock_invoke
      model: claude-3-5-sonnet
      input: "${input.document}"
      retry:
        max_attempts: 3
        backoff_multiplier: 2
    
    - name: classify
      type: bedrock_invoke
      model: llama2
      input: "${steps.extract.output}"
      # ここで status を明示的に追跡する
      state:
        document_id: "${input.doc_id}"
        extraction_version: "${steps.extract.execution_id}"

うちが落ち着いた方法は、各ステップに明示的な「実行ID」と「バージョン」を付けることです。そうすることで、ステップがリトライされた時に古い結果を参照してしまうのを防げるんです。更にDynamoDBに状態を保存して、外部でも検証できるようにしました。

実装してから気づいたんですけど、これって簡易的なイベントソーシングパターンなんですよね。複雑なワークフローを堅牢にしようと思ったら、こういう設計は必須になります。

地雷2:エラーハンドリングと再試行の地獄

LLMの出力って、構造化されていないことがほとんどなんです。ClaudeでJSONを返すように指示しても、たまに余計なテキストが混じったり、フォーマットが微妙にズレたりするんですよ。

最初、こういった「形式はいいけど内容がおかしい」エラーをどう処理するか、全然分かってませんでした。

# 最初のアプローチ:ダメなコード
def process_flow_step(output):
    try:
        result = json.loads(output)
        return result
    except json.JSONDecodeError:
        # 単純にリトライ:これ何度やっても同じ結果
        raise RetryableError("Invalid JSON")

これだと、形式エラーが起きるたびにリトライが走るんですけど、同じLLMに同じプロンプトを投げてるので、95%の確率で同じエラーが返ってくるんですよね。結果として、タイムアウトまで何十回もリトライが走って、コストも掛かるし時間も掛かる。

今は、こういう「一時的ではないエラー」を検出して、別のステップに流すようにしてます。

# 改善版
def process_flow_step(output, step_config):
    try:
        result = json.loads(output)
        return result
    except json.JSONDecodeError as e:
        # 形式エラーは一時的ではないと判定
        if is_malformed_not_transient(e):
            # LLMに修正を指示する別ステップに流す
            return {
                "status": "requires_correction",
                "original_output": output,
                "next_step": "fix_json_format",
                "error": str(e)
            }
        else:
            raise RetryableError("Temporary error")

def is_malformed_not_transient(error):
    # JSONデコードエラーはほぼ確実に形式問題
    return isinstance(error, json.JSONDecodeError)

この修正だけで、本番のエラーリトライが70%減りました。地味ですけど、コストに直結します。

地雷3:モデル間の出力形式のズレ

うちはClaudeとLlamaを使い分けてるんですけど、同じプロンプトを投げても返ってくるフォーマットが微妙に違うんですよ。特にテーブルデータとか構造化情報を扱う時。

ClaudeはMarkdown形式のテーブルで返すことが多いんですけど、Llamaは改行で区切ったテキストを返すことがあります。後段のステップが「Claudeの出力を想定」で実装されていると、Llamaが使われた時点で破綻するんですね。

# Bedrock Flowsの型定義例
steps:
  - name: extract_with_model_selector
    type: bedrock_invoke
    model: "${input.preferred_model}"
    # モデルごとに異なる出力仕様
    output_schema:
      type: conditional
      branches:
        - condition: "${steps.model_selector.model == 'claude'}"
          schema:
            format: markdown_table
            fields: [name, value]
        - condition: "${steps.model_selector.model == 'llama'}"
          schema:
            format: text_lines
            fields: [name, value]
  
  - name: normalize_output
    type: bedrock_invoke
    model: claude-3-5-sonnet
    input: |
      以下のデータを標準的なJSON形式に統一してください。
      元のフォーマット: ${steps.extract_with_model_selector.detected_format}
      データ: ${steps.extract_with_model_selector.output}

つまり、複数モデルを使う時は「出力の正規化ステップ」が必須になるんです。これをサボると、本番で「なぜか3回に1回失敗する」みたいなダンピング状態になります。

実装アーキテクチャ:Bedrock Flowsの運用構成

実際にチームで運用してる構成を図示しておきます。こういう監視・状態管理があるかないかで、本番の安定性が大きく変わるんですよ。

graph TB
    subgraph "入力レイヤー"
        A["S3: ドキュメント入力"]
        B["EventBridge: トリガー"]
    end
    
    subgraph "Bedrock Flows実行"
        C["Flow Execution Engine"]
        D["Step: テキスト抽出<br/>Claude 3.5"]
        E["Step: ドキュメント分類<br/>Llama 2"]
        F["Step: 出力正規化<br/>Claude 3.5"]
        G["Step: QA検証"]
    end
    
    subgraph "状態管理・エラーハンドリング"
        H["DynamoDB: Flow State"]
        I["SQS: 失敗キュー"]
        J["SNS: エラー通知"]
    end
    
    subgraph "出力・監視"
        K["S3: 処理結果"]
        L["CloudWatch: ログ・メトリクス"]
        M["X-Ray: トレース"]
    end
    
    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    F --> G
    D --> H
    E --> H
    F --> H
    G --> H
    G -->|失敗時| I
    G -->|成功時| K
    I --> J
    C --> L
    C --> M
    H --> L

この構成の重要なポイントは:

  1. DynamoDBに全ステップの状態を保存 - リトライやデバッグの際に状態を検証できます。ステップが途中で失敗した時、「どこまで進んだのか」を外部から正確に把握できるんです

  2. SQS失敗キュー - 一時的でないエラーはここにルーティングして、別のハンドラーで対処します。同じリトライの繰り返しを避けられる

  3. CloudWatch Logs Insightsで監視 - LLMの出力パターンを分析して、モデル選定の参考にします。異常なパターンも早期発見できる

コスト最適化:地味だけど効く工夫

本番6ヶ月で気づいたのは、ワークフローの実行パターンによって、LLMのコストが劇的に変わるってことです。

最初の3ヶ月、月のBedrock請求額が予想より30%多かったんですよ。原因調査してみたら、いくつか発見がありました:

1. プロンプトの冗長性

各ステップで、前のステップの全出力を次のステップに渡していました。ところが、実際には「抽出されたテーブルの3列だけ必要」みたいなことがほとんど。つまり、不要なテキストをずっと送信してた。

# 修正前:全出力を渡す
step_input = steps.extract.output  # 数KB

# 修正後:必要な部分だけ抽出
step_input = extract_relevant_fields(steps.extract.output, ['table', 'metadata'])

これだけで約15%コスト削減できました。

2. キャッシュの活用

Bedrock APIで同じプロンプト・モデルの組み合わせが何度も実行されてることに気づきました。例えば、「このドキュメントタイプの分類」は毎日何百回も走ります。

# Bedrock Flowsでプロンプトキャッシュを有効化
steps:
  - name: classify
    type: bedrock_invoke
    model: llama2
    # システムプロンプトを共有キャッシュに
    system_prompt_cache_control: "ephemeral"
    system:
      - type: text
        text: |
          あなたはドキュメント分類エキスパートです。
          以下のカテゴリのいずれかに分類してください:
          ...[長い分類ガイドライン]
        cache_control: "ephemeral"

これで20%削減できて、キャッシュが効かない場合とで明らかに応答時間も速くなります。

監視とメトリクス:本番運用で見るべき数値

本番運用で必須になるメトリクスをまとめておきます。うちの改善の軌跡が見えると思います。

xychart-beta
    title Bedrock Flows本番運用の改善トレンド(6ヶ月推移)
    x-axis [1月, 2月, 3月, 4月, 5月, 6月]
    y-axis "成功率 (%)" 80 --> 100
    line [87, 89, 91, 93, 95, 96]

より詳しい数値を表にすると:

メトリクス初月現在改善率
ステップ成功率87%96%+10.3%
エラーリトライ発生率13%4%-69%
平均実行時間(秒)4538-15%
出力形式エラー率8%1%-87.5%
月額Bedrock費用$28k$24k-14%

見てわかる通り、最初の設計の甘さが結構大きかったんです。特に「ステップ成功率」と「エラーリトライ発生率」の改善が顕著。これは、さっき説明した状態管理とエラーハンドリングの改善が直結してます。

月額費用も削減できたので、設計と運用の改善って財務的にもちゃんと効いてくるんですよね。

運用で気づいた小さな工夫

CloudWatch Logs Insightsで異常検知

fields @timestamp, @duration, step_name, model_used, output_length
| filter step_name = "classify"
| stats avg(output_length) as avg_len, max(output_length) as max_len by model_used
| filter max_len > avg_len * 2

これで「いつもより長い出力が返ってきた」みたいなパターンを検出できます。LLMの出力が不安定になった時の早期発見に便利なんですよ。

モデルの切り替え戦略

2026年の今、Bedrock上で利用できるモデルが増えてます。コストと性能のトレードオフを考えながら、ステップごとに最適なモデルを選ぶのが重要です。

モデルコスト速度推奨用途
Claude 3.5 Sonnet複雑な判断・QA検証
Claude 3 Haikuシンプルな分類
Llama 2 70Bテキスト抽出
Mistral 7B軽量な処理

うちは、ステップの難度に応じてモデルを動的に選択するようにしてます。「簡単な分類」だったらHaikuを使う、みたいに。そうすることで、複雑な処理にコストを集中できるんですよ。

まとめ:Bedrock Flowsを本番で運用するために

Bedrock Flowsは本当に便利なんですけど、本番に持ち込むには設計と運用の準備が絶対に必要です。ここまで書いた内容で一番大事な3つを言うなら:

  1. 状態管理を最初から考える - DynamoDBに全ステップの状態を保存して、外部から検証可能にしておく。これがないと本番で詰みます。リトライやデバッグの時に「どこまで進んだのか」が分からなくなるんです

  2. エラーハンドリングは「一時的」と「恒久的」を区別する - LLMの形式エラーは一時的ではないので、同じプロンプトを何度も投げてもムダです。形式エラーを検出したら、修正ステップに流すとか、手動レビューに回すとか、別の対応が必要

  3. 複数モデルを使う場合は、出力の正規化ステップが必須 - モデルごとに出力フォーマットが異なるので、後段で統一する仕組みが必要です。これをサボると、本番で「3回に1回失敗する」みたいな再現性の低いバグになります

あと、正直に言うと、最初の設計段階で完璧を目指す必要はないと思います。本番に小さく投入して、実際のエラーパターンを見ながら改善していく。その際に、状態管理とログだけはちゃんとやっておくと、後々の改善が楽になりますよ。

皆さんがBedrock Flowsを導入する時は、この6ヶ月分の失敗をスキップできるといいなって思います。正直、地味な問題ばっかりですけど、これらを放置すると本番の安定性と開発効率に直結するんですよね。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事