📑

Railsモノリス上でAIエージェントを1年かけて育てた話

に公開

(この記事は、自分の雑記をOpus4.5に清書してもらいながら書きました。)

お久しぶりです!!
業務委託で大変お世話になっている「株式会社スタディスト」のAdvent Calendarに参加したので、スタディストで自分が関わった機能開発を紹介させてください。

背景

Railsモノリスのアプリケーション上に、生成AIを使った 「おまかせ編集アシスタント」 という機能をつくりました。主にバックエンドリードとして携わっています。

https://help.teachme.jp/hc/ja/articles/48234760227737

  • 2025年3月:Closed Beta リリース
  • 2025年6月末:正式リリース

開発期間でいうと、Betaまでは半年程度、安定するまでは一年程度かかりました。

この記事では、プロダクトに組み込む上でやったこと、つまずいたこと、そして今振り返ってどう思っているか、をまとめます。

最初の判断:Rubyで全部つくるか、マイクロサービスにするか

最初は、Python/Streamlit/Langchain(+LangGraph?)製のプロトタイプを、AIエンジニアリング室の室長の方がつくっていました。自分の最初の仕事は、その振る舞いや内部実装のインターフェースを理解して、プロダクトに組み込むこと でした。

ここで判断が必要でした。

  • Rubyで全部つくりきるか
  • PythonやTypeScript製のマイクロサービスを組み込むか

判断基準

  • データのアクセスパターン
  • どこまで複雑なことをやろうとしているか

そのときの状況

  1. AWS Bedrockクライアント経由でのLLM呼び出しは、すでにRails上で実装できていた
  2. Python/Streamlit製のプロトタイプを見る限り、ライブラリなしでも十分できそうだった
  3. データのアクセスパターン(今で言うとコンテキストエンジニアリングへのRailsデータの注入)の方が、その後も柔軟性を保ちたい部分だった。この部分を分ける判断はつかなかった

ということで、Railsの中で一からSemi Agenticな振る舞いを実装することにしました。

ここでいうとSemi Agenticというのは、メッセージ履歴や生成したマニュアル履歴を保持しながらマルチターンでユーザーを対話できる振る舞いです。複雑なLoopやConditionalなNodeはないですが、LangGraphライクなコンセプトのインスタンスは用意していました。

実際やってどうだったか

データアクセス・バックエンド部分

ここは概ね予想通りでした。初期実装から姿形変わることなくメンテできています。

LangGraphライクなインターフェース(ノードとエッジを使った状態遷移管理)を自前で実装し、Redisで状態管理を行う設計にしました。この設計は今でも変わらず動いています。

                                    │
                                    │ graph.invoke(state)
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                              Graph                                          │
│                    (ノードとエッジで状態遷移を管理)                             │
│                                                                             │
│   ┌─────────┐      ┌─────────────────┐      ┌──────────┐      ┌─────────┐  │
│   │  START  │ ───▶ │ AppendLatest    │ ───▶ │ Bedrock  │ ───▶ │ Json    │  │
│   │         │      │ ManualNode      │      │ Node     │      │ Extract │  │
│   └─────────┘      └─────────────────┘      └──────────┘      └─────────┘  │
│                                                   │                 │       │
│                                                   │                 │       │
│                                                   ▼                 ▼       │
│                                             ┌──────────┐      ┌─────────┐  │
│                                             │ Streaming│      │  EXIT   │  │
│                                             │ Response │      │         │  │
│                                             └──────────┘      └─────────┘  │
└─────────────────────────────────────────────────────────────────────────────┘

一番辛かったのはフロントエンドとの通信部分

ストリーム部分をActionCableで実装したのですが、これが一番辛かった。

何事もそうですが、プロトタイプ時には問題なく動いていたものを 本番環境に近い場所で動かすにあたって、いろんな問題が出てきました。

本番環境で出てきた問題たち

  • 生成するマニュアルデータが長大なケース:Nginx timeoutに引っかかる
  • アプリケーションサーバーのキャパシティを占有する
  • サーバーリスタート後の再接続に対して接続が途切れる
  • お客様環境特有のネットワーク特性(そもそもWebSocketが使えないなど)

対策としてやったこと

  • Job側に処理を移動
  • Streamingが途切れたときに、HTTP Pollingにフォールバックする仕組み
  • WebSocketの接続が確立できないときは、最初からHTTP Pollingで通信を行う

今は、50:50でWebSocketとHTTP Pollingが使われています(ターン数が母数)。

ここらへんを入れたことで、ようやく本番環境でも安定して動く仕組みになっていきました。

リリース後にやってきたこと

通信部分以外は、リリースしたあとも継続的に改善を重ねてきました。

JSON処理・エラー対策

LLMからのレスポンスはJSONで返ってくるのですが、これが不完全だったり壊れていたりすることが多々ありますよね。。

  • invokeconverse 移行によるJSON Parseエラー対策
  • LLMからの不完全なJSONを修復してから後続処理へ
  • JSON Repair Errorの局所的リトライ実装(リフレクションパターン)
  • 画像内テキストのクォート対応:エスケープ指示追加
  • Sonnet 4使用によるJSON Repair Errorボリューム削減(モデルが賢くなると減る)

この分野に関しては、最近は「SOTAモデルのプロバイダー側がスキーマ完全やJSON準拠を保証する」のが一般的になってきています。現在開発できたら随分楽だったと感じています。

リトライ・フォールバック機構

エラー時の復旧機能もかなり充実させました。

  • 5xx系キャパシティエラー時の別モデルフォールバック
  • モデルダウングレードからリージョンスイッチへの変更
  • ValidationException発生時の古いメッセージ削除とリトライ

Bedrockクライアント最適化

API利用の効率化も進めました。

  • BedrockParameterFactory 実装による効率的なパラメータ管理
  • count tokens APIによる正確なトークン数管理
  • max_tokens 増加によるtruncation回避

監視・ログ強化

LLM Opsの前段階、エラーモニターにも地道に取り組みました。

  • ユーザー視点の待機時間計測・監視
  • Datadog instrumentによるActiveJob監視
  • Sentryを使用したレスポンスタイム計測


(AI機能のエラーを見る会、を週1でやることで、チームで共通目標ができてきました)

今ふりかえってどうか

今は、実はLLM部分の実行環境の分離も試みています。

なぜ今、分離を検討しているのか

理由はいくつかあります。

1. 抽象化の業界水準ができてきた

Tool CallやMCPなどで、LLM実行環境とデータを持つ部分のやり取りの抽象化が、業界水準として確立されてきました。

そのおかげで、データのアクセスパターンのインターフェースで悩んでいたような部分(設計の手戻りリスク)が随分と軽減されています。

2. フロントエンドの期待値が確立されてきた

フロントエンドがもつサーバーとのリアルタイム通信の期待値(生成AIを組み込むなら、こんなUXだろう)というのが確立されてきました。

  • ステータスアップデート
  • Chain of Thought
  • Human in the Loop
  • などなど

TypeScriptでそこらへんを抽象化したライブラリ(Vercel AI SDK、それをラップしたMastraなど)が、一番開発効率とメンテナンス性が高いと感じています。

3. TelemetryのFirst Class Citizen問題

TelemetryのDrop In的にTypeScriptとPythonがFirst Class Citizenで、RubyでそれをやっていくだけのROIが見込めなさそう、というのもあります。

これが他の人に当てはまるわけではない

あらためて言う必要もないですが、これは自分たちのチームが、このタイミングで通ってきた道です。

一年前の判断(Railsで全部やる)は、そのときの状況では正しかったと思っています。 データアクセスの柔軟性を保てたことで、機能開発のスピードを落とさずに進められました。

でも、状況は変わります。 業界の抽象化が進んだ今、同じ判断をするかと言われると、違う選択をすると思います。

似たような状況でAIエージェントをプロダクトに組み込もうとしている方がいらっしゃったら、**「今の状況で何が柔軟性を保ちたい部分なのか」**を見極める参考になれば幸いです。

Discussion