maybe daily dev notes

私の開発日誌

AIエージェント開発 - コンテキストエンジニアリング観点でのTODOツールの良さを語りたい

AIエージェント自体の開発に関する記事です。

7月頃から、コンテキストエンジニアリングという言葉を目にする機会が増えました。これは私のAWS Summit Japanの登壇でも軽く触れましたが、エージェントへのコンテキストの与え方を工夫することで、トークン効率やタスク遂行性能、安定性など様々な面を改善しようという手法です。

Hackernewsを見ても、最近関連する投稿がやたらと増えていることが見て取れます。

おそらく出どころはエージェント開発の勘所をまとめた 12 factor agents だと思われます。この図の通り、非常に広い範囲に及ぶチューニング方法のため、正直エージェントに関するほとんどの改善施策はコンテキストエンジニアリングと言えるのではと思います。個人的には、もはやこの言葉に存在意義があるのか疑問に思うこともあります。笑

この記事では、AIエージェントにTODOツールを与えることの良さを、コンテキストエンジニアリング観点でまとめます。TODOツールとは、エージェントがタスクリストを作成し、一つずつタスクを遂行していくためのツールです。このようなTODOツールを活用するエージェントとして、Claude Codeが有名です。

Claudeのドキュメントには明示的に書かれていませんが、Claude Codeの利用者であれば、そのようなツールが使われている様子を目にしたことがあるでしょう。これこそがTODOツールの例です。

TODOツールの概要

本記事では、AIエージェントのもつTODOツールを、以下のようなツール群と定義します。

  • updateTodoList ツール
    • TODOリストを更新または新規作成するツールです
    • ツールはファイルやDBなどに永続化され、エージェントのコンテキスト外で管理されます
    • 以下のような入力を期待します:
type Input = {
  tasks: {
    title: string
    status: 'pending' | 'inProgress' | 'completed'
  }[]
}
  • getTodoList ツール
    • 現在のTODOリストを取得するツールです

単純な2つのツールからなる仕組みですね。Claude Codeでも、似たようなツールを使ってTODOリストを管理しています。

TODOツールの何が良いか

上記のように単純な仕組みではありますが、これによりAIエージェントの挙動に関する多くの問題を解決できます。 以下に列挙していきましょう。

1. 計画立て → 実行の流れを促進できる

多くのエージェント用途において、まず全体像の計画を立ててから実行を始めることで性能が改善することはよく知られています。TODOツールがあることで、ユーザーがあまり指示せずとも、大抵このステップを踏んでくれるようになります。

Bedrockのログ機能を使うと分かりますが、 Claude CodeのTODOツールは長大なプロンプトで、TODOツールの使い方や使うべき状況、使うべきでない状況を説明しています。これくらいの説明があると、計画→実行の流れをエージェントが自ずと必要に応じて適用してくれるようになるようです。

これはどちらかというとプロンプトエンジニアリング的な観点なのですが、プロンプトもコンテキストの一種なので、コンテキストエンジニアリングに含まれますね (迫真)。

2. 計画に沿った実行を強制できる

TODOツールを使うことを選んだエージェントは、各タスクを遂行していくごとに、TODOリストを更新していきいます。

TODOリストの更新はツール内で実行されるので、エージェントの入力をコードでバリデーションし、エージェントにフィードバックできます。このバリデーションにより、エージェントの動作に、以下の大きな影響を及ぼすことができます。

例えば、以下のバリデーションです:

  1. 並行禁止: 同時に複数のタスクが inProgress 状態にならないようにする
    • これにより、エージェントが複数のタスクを同時に進めてしまうような状況を防げます
    • 例えばエージェントがtask1の進行中にtask2も進めようとした場合、「task1が進行中のため、TODOリストの更新に失敗しました」とtool resultに与えることで、エージェントの軌道修正を図れるでしょう。
  2. 順序強制: 直前のタスクが completed 状態でない場合は、そのタスクはinProgress 状態にならないようにする
    • これにより、当初の計画の順序を無視して、デタラメにタスクに取り組むような状況を防げます
    • 例えばエージェントがtask1の完了後にtask3を進めようとした場合、「task2が未完了のため、TODOリストの更新に失敗しました」とtool resultに与えることで、エージェントの軌道修正を図れるでしょう。

AIエージェントは基本的に物忘れが激しく、直前の入力に強く影響されがちです。そのため、このような形でエージェントの実行計画にレールを敷けることの有効性は、容易に想像できるでしょう。

個人的には、このツールによるコンテキストエンジニアリングが一番好きです。似たような手法は、多くの場面で活用できる機会があります *1

3. 未完了のタスクの完了を促せる

エージェントがTODOリストにpendingなタスクを残した状態で自分のターンを終わらせた場合、自動的にターンを再開させることも容易です。例えば、「未完了なタスクがあるので完遂してください」などとプロンプトを渡し、ターンを再開させればよいでしょう。

TODOリストをコンテキスト外で管理していれば、ユーザーの手を煩わせることなく、プログラムで自動的に実行可能であることがポイントです。

ただし、ユーザーの確認が必要などの理由でエージェントが意図してターンを終えた場合もありうるため、何らかの方法でこの挙動はエージェント側から回避できるようにもすべきでしょう。(私はこの利点を机上では思いついたのですが、その懸念があるので特にこの処理は実装していません。)

やるとしたら、これもコンテキストエンジニアリングの一種ですね!

まとめ

TODOツールの魅力をコンテキストエンジニアリング観点で紹介しました。

私の自作コーディングエージェント (Remote SWE Agents) においても、TODOツールを実装してからは、自走力が確かに増したことを実感しています。 TODOツールは実装が簡単な割に、複雑なタスクを扱う多くのAIエージェントで効果的に活用できますので、オススメです。

また、Remote SWEはAWS Summit Japanでも展示し*2、以前記事を書いたときから大きくアップグレードされています (Web UIの追加やリモートMCP対応など)。 Devin的な体験をOSS & セルフホストで実現したいという方、ぜひお試しください!

tmokmss.hatenablog.com

*1:Summitのセッションでは、PR作成ツールの例を紹介しました。複数の問題を一網打尽にできた、気持ちの良いチューニングです。

*2:クラスメソッドの方がレポート記事を書いていただきました!

AIエージェントアプリのコンテキスト長上限回避方法まとめ

LLMの基盤モデルには、コンテキスト長の上限があります。例えばClaude4では20万トークンが上限で、これを超えるトークン数を入力するとエラーになります。

一方、昨今のAIエージェントアプリでは、複雑なタスクを任せる場合、エージェントとユーザー・ツールとの間で多くのやり取りが生じます。この結果、コンテキスト長が上限を超えるほどメッセージ履歴が長くなることも珍しくありません。上限を超えた後もエージェントとのやり取りを継続するためには、何らかの方法で上限を回避する方法があります。

本記事では、メッセージ履歴が上限を超えるほど長くなったときに、その上限を回避する方法の選択肢をまとめます。

方法1. Sliding window

最も単純な方法で、直近N件のメッセージのみをコンテキストに渡す方法です。窓がスライドするようにエージェントから見えるメッセージのスライスがズレていくので、sliding windowと呼ばれたりします。

これにより、(1メッセージで大きなトークンを消費するメッセージがないとすれば) 全体のトークン長が上限を超えることは防ぐことができます。

ただし、注意点はいくつかあります:

1. プロンプトキャッシュとの相性

メッセージを追加するたびにウィンドウをずらす場合、メッセージ配列におけるプロンプトキャッシュが実質無効化されてしまいます。これは非常に非効率です。

このため、実際はLLM APIコールのたびに1つずつずらすのではなく、何回かをまとめてずらす必要があるでしょう。

例えば上記の図では、sliding windowを適用する際にMessage 51からではなく71から始めることで、50-30=20件まではプロンプトキャッシュが有効な状態・かつウィンドウ長が50件以内に収まるようにトークンを入力できるようにしています。

2. トークン数かメッセージ件数か

先ほど「1メッセージで大きなトークンを消費するメッセージがないとすれば」という条件をつけましたが、この条件が満たされない場合は、この方法はうまく機能しません。極端な例を挙げると、1メッセージのトークン数が平均50kトークンを超える場合は、Claudeなら4メッセージだけで上限を超えることになります。

ユースケースが決まっており、1メッセージの平均トークン数が既知の場合はこのままでも良いですが、そうではない場合は改善が必要です。より適応的にするためには、トークン数の合計でsliding windowのサイズを決めると良いでしょう。例えばAnthropic APIでは、あるメッセージのトークン数を計算するAPIがあるため、容易に実現できます。BedrockだとこのようなAPIは現状ありませんが、Converse APIのレスポンスに含まれる消費トークン数の情報から、各メッセージのトークン数を概算することが可能です (実装はやや複雑です)。

各メッセージのトークン数がわかれば、例えばトークン数の合計を計算することで、sliding windowの長さを調節することができます。

方法2. Middle-out

Sliding windowはメジャーな方法ですが、用途によっては、前方のメッセージが重要であり、コンテキストから除外したくない場合もあります。例えば、1つのセッションで1つの問題解決を行うコーディングエージェントでは、最初のメッセージで問題が与えられることが多く、このとき序盤のやり取りは特に重要となります。

このような場合に適するのがmiddle-outです。OpenRouterのドキュメントでそのように言及されていました。

これはメッセージ履歴の中央部をコンテキストから除外する方法です。下図ではMessage 11~59が除外されていることに注目してください。

パラメータとして、前方のメッセージを取る件数と、後方のメッセージを取る件数の2つがあります。大抵は、後方のほうを多めにするのが良さそうです。 また、sliding windowと同じく、プロンプトキャッシュの考慮は必要ですし、トークン数で件数を動的に調節するのも有効です。

Sliding windowは、Middle-outの前方件数を0にしたものとみなせます。Middle-outを実装すれば自ずとSliding windowも実装できたことになるので、お得ですね。

ただし、「序盤のやり取りが特に重要」かどうかはユースケースに依存するため、例えば1セッション内で複数の異なるタスクを指示するユーザーにとっては、この方法が非効率となりえる点は注意です。

方法3. Condenser

方法1/2ともに、コンテキストから除外されるメッセージは、LLMにとっては完全に忘れ去られる(=存在しなかった)のと同義です。これでは、ユーザーとのそれまでのやり取りや過去のツールの実行結果を忘れてしまい、あまり賢くない印象を与えてしまうリスクがあります。

この対策として、メッセージを除外するのではなく、要約して圧縮する方法がCondenserです。由来はOpenHandsがこう呼んでいましたが、Amazon Q Developer CLIでは似た機能がcompactコマンドとして提供されています。

実装は1や2と比べて複雑になりますし、要約のため追加のLLM APIコールが必要になりますが、忘却対策には効果的かもしれません。

補足. 長期記憶

忘却対策の別解として、長期記憶の導入が考えられます。これは例えば以下のような方法です:

  1. 会話履歴をDBに保存する。ユーザーがメッセージを送るたびに過去の会話履歴から関連するメッセージを取り出し、コンテキストに含める。例えばMastraのsemantic recallのような機能です。
  2. 遂行すべきタスクなど、重要な情報をファイルとして保存する。このファイルの内容は、常にコンテキストに載せるようにする。例えばclaude-task-masterというMCPサーバーがあります。

直接的にコンテキスト長制御の方法となるわけではありませんが、例えばSliding windowと長期記憶を併用すれば、コンテキストを良い感じに管理できるはずです。

まとめ

エージェントアプリでは頻出の悩みである、コンテキスト長上限の回避方法をいくつかまとめました。これを実装すれば、無限に一つのエージェントとやり取りができるようになるので、ぜひ実装してみてください。コンテキスト長制御は、Amazon Bedrockだとトークン数のQuota回避にも役立つので、おすすめです。

最近はこういった下回りはエージェント開発フレームワークに任せれば良い説もありますが、仕組みを知っているだけでも、エージェント開発時だけでなく利用時にも役立つはずです。

ちなみに私の開発するRemote SWE Agentsでは、middle-outをトークン数ベースで実装しています。このエージェントも、Claude 4により更に自走力が上がっているので、ぜひお試しください。

tmokmss.hatenablog.com

AWSの安価でスケーラブルなウェブアプリ構成 2025年度版

3年前、趣味で開発するウェブアプリ向けの安価なAWSアーキテクチャについて記事を書きました。当時流行りの話題だった記憶です。

趣味Webサービスをサーバーレスで作る ― 格安編 - maybe daily dev notes

最近はAWSにも新たに色々なサービスが出てきて、以前とは一味違う構成を取れるようになっています。この記事では、アップデートされた格安かつスケーラブルなウェブアプリ向けAWSアーキテクチャを紹介します。

コード

本記事で紹介するアーキテクチャのリファレンス実装は、以前と同じリポジトリに公開しています。

github.com

主な機能は下記です:

  • Next.js App RouterをAWS Lambda上にデプロイ
  • CloudFront + Lambda関数URLによるレスポンスストリーミング対応
  • クライアントからサーバー、DBまでの型安全性
  • Aurora Serverless V2でScale to zerosするRDBMS
  • 非同期/Cronジョブ機構とクライアントへのリアルタイム通知
  • Cognitoによる認証認可 (SSR対応)
  • CDKのコマンド一発でデプロイ

それではDive deepしていきましょう。

利用技術の説明

以下に上記アーキテクチャの要点を説明します。

Next.js on Lambda

CloudFront + Lambda fURL + Lambda Web Adapterの構成で、Next.js on AWS Lambdaを実現しています。この構成の詳細は以下の資料もご覧ください。

speakerdeck.com

この構成であれば、Next.js App Routerでは重要なレスポンスストリーミングの要件も達成できます。

別のアドバンスドな構成として cdklabs/cdk-nextjsという方法もあるのですが、今回は見送っています。今の構成でも、CloudFrontのキャッシュを活用すればLambda上のNext.jsだけでも効率的に動作するように思うので、複雑性に対して得られるメリットが少ないかなと感じています。

E2Eの型安全

今回は以下のライブラリも使い、クライアントからサーバー・データベースまでの型安全性が実現されています:

以前、Page Routerの時代はtRPCを使ったサンプルを作ったこともあったのですが、App RouterになってE2E型安全の実現がさらに楽になりました。ページのレンダリング時には直接DBを叩くような書き方ができますし、ミューテーションの処理もServer Actionで容易です。クライアントからのクエリだけはtRPCのほうが扱いやすかった印象ですが、Server Actionを使っても可能ではあります (クライアントのJSから実行・結果の取得ができるため)。

また、Server Actionを素で使うと、認証認可といった処理が煩雑になりがちです。こうした処理をミドルウェア的に簡単に挿入するため、next-safe-actionを利用しています。認証認可処理が実装されたAction Clientを作成し、それを使ってServer Actionを定義するだけです。tRPCのprocedureと似た書き心地ですね。

next-safe-actionは、React Hook Formと容易に統合できる点も魅力です。adapter-react-hook-formというライブラリを使えば簡単です(コード例。) フォームとServer Actionの統合方法としてはConformも人気ですが、個人的には慣れ親しんだReact Hook Formの体験を保ちたいので、こちらの方法を好んでいます。

サーバーレスの利便性

サーバーレスサービスを主に構成されるため、リクエストが増えても自動でスケールアウトしますし、またアイドル時のコストはゼロになります。(ただしVPCを使うため、NATのみ例外。後述。)

またデータベースについても、Aurora Serverless v2が登場したことで、アイドル時のコストをゼロにできるようになりました。 以前はAWSでScale to zeroするDBといえばDynamoDBだったのですが、RDBMSでこれができるようになったのは大きいです。やはり開発体験としてはNoSQLより慣れていて円滑なことが多いため。

AppSync Eventsによるクライアントへのリアルタイム通知

2024年末にGAしたAppSync Eventsにより、リアルタイム通知の実装が非常に手軽になりました。これは例えば、クライアントへのジョブ完了通知などに利用できます。

従来はAPI Gateway WebSockets APIなどを利用していた部分かと思いますが、AppSync EventsではコネクションIDの管理が不要になる他、ブロードキャストの実装も容易です。

個人的には、API Gateway WebSocketをこの用途で使うと実装が煩雑になるため、これまではリアルタイム通知でなくポーリングによる実装をすることが多かったです。しかしながら、AppSync Eventsでは多くの面倒な部分をサービス(とAmplify library)側で隠蔽してくれるため、より気軽にリアルタイム通知要件を実装できるようになりました。

使い所は意外と多いので、ぜひ考えてみてください。

Next.js App RouterとCognitoログインの統合

従来のSPAではCognitoログインを統合する場合、Amplify UIコンポーネントを使うのが一般的でした。しかしながら、こちらはクライアントコンポーネントのためSSRと相性が良くない上、トークンをhttp-onlyなCookieに保存することも困難でした。

現在はこの状況が大きく改善されており、App RouterでCognitoをうまく扱うためのライブラリAmplify Next.js adapterが提供されています。これはUIコンポーネントでなく、Cognito Managed Loginを活用してログイン機能を提供します。また、Cookieを経由してサーバーサイドでトークンやユーザー情報を取得するのも容易です。

Amplify Next.js adapterとnext-safe-actionを組み合わせることで、Server ActionをCognitoの認証で保護することも簡単にできます (コード例)。

EventBridge Schedulerによるcronジョブ機構

ウェブアプリでは、しばしばジョブのスケジュール実行機能 (毎日朝4時に集計実行など) が必要になりがちです。今回はEventBridge Schedulerを使っています。

EventBridge Schedulerは従来のEventBridgeルールと比べて、cronのタイムゾーンが指定できたりタイムウィンドウを指定できたりと、上位互換のような存在です。

SchedulerのCDK L2が最近GAしたので、スケジュールもCDKで便利に一元管理できます。コード例

CDKによる一撃デプロイ

上記のシステムを、AWS CDKのコマンド一発でデプロイできるようにしています。この性質は初期構築を楽にするだけでなく、環境複製を容易にする意味でも重要です。

これを実現する上での難点としては、Next.jsのビルド時に、Cognito User Pool IDなどデプロイ時に決定される値が必要になることです。つまり、初回デプロイ前にNext.jsをビルドできないため、デプロイ手順が煩雑になりえます。

この問題は、デプロイ時にコンテナイメージをビルドできるコンストラクト(ContainerImageBuild)を使うことで解消しています。以前紹介したdeploy-time-buildを改良したものです。

別解としてはnext-runtime-envというライブラリを使うことで、ランタイムの環境変数を静的ファイルに注入することができます。しかし、こちらは環境変数周りの扱いが完全には透過的でなく、特殊な実装が必要になる (コード例) ため、今回は避けています。

コスト

コストのブレークダウン表はREADMEにまとめてあります。100ユーザーで月額8.5ドルという見積もりです。もちろんこれはざっくりした概算で、実際の値はユースケース次第となります。

また、上記は無料枠を含めていません。無料枠をフル活用できる場合は、Cognito, Lambda, NAT Instanceなど多くのサービスが無料範囲に収まるため、月額5ドル未満となるでしょう。

このアーキテクチャの欠点

良い話ばかりだとアレなので、何点か既知の欠点を挙げておきます:

VPCが必要のため、NAT Gatewayのコストがかかる

Aurora Serverless v2においてもVPCは必要のため、Lambdaのネットワーク疎通のためにNAT Gatewayやそれに類する機能が必要となります。NAT Gatewayは小規模利用には少々コストが目立ちがちです。

小規模なユースケースではNAT Gatewayの代わりにNATインスタンスを使うことで、毎月のコストを3USD (t4g.nano)に抑えることができます。無料枠に含まれるインスタンスを使うことも可能でしょう。

あるいはそもそもVPCなしで利用可能なAurora DSQLも存在します。2025/05現在ではプレビュー状態のため今回は利用を見送っていますが、GAされれば良い代替手段となるはずです。

コールドスタート

本アーキテクチャでは、Lambda, Auroraのコールドスタートの影響を受けます。アイドル状態からアクセスしたとき、およそ以下の待ち時間が生じます:

  • Lambda: 1〜3秒程度
  • Aurora: 10秒程度

頻繁なアクセスのあるアプリであれば深刻な問題になることは少ないですが、アクセス頻度の少ないアプリ (5分に1回未満など) の場合、アクセスのたびにコールドスタートが生じてユーザー体験が悪化するリスクがあります。 特に今回はSPAなどとは違いS3から静的ファイルを配信するわけでもないため、Lambdaが起動するまでは一切ページが描画されない点も、ユーザーに不安をもたらすポイントです。

こうしたコールドスタート問題の緩和策は、今回は以下が考えられます:

  • Lambda: 定期的(2分おきなど)にLambdaにリクエストするwarmerを設置する (provisioned concurrencyよりは安価)
  • Aurora: 自動停止に関するパラメータを調整する (停止状態に移行するまでの時間を伸ばすなど)

ある程度のアクセス頻度があれば問題になることは少ないと思われますが、考慮点として挙げました。

大規模利用時のコスト

ウェブアプリへのリクエスト数が増えLambdaの利用量が増えてくると、コスト効率が悪化する場合が考えられます。Lambdaはリクエストごとに計算リソースが分離しており、I/Oバウンドな処理ではリソースが遊びがちのためです (もちろん良し悪しはあります)。

このため、利用量が大きくなると、ECSなどに移行することが必要になるかもしれません。とはいえ移行自体はそれほど困難ではないはずです。アプリ自体はNext.jsをそのまま利用でき、またステートレスな部分のみの移行のため。

必要な移行作業はLambda + 関数URLの部分を、ALB + ECSなどに置き換えるだけです。この場合scale to zeroしないためアイドル時のコストは増えますが、大規模な利用であればこのコストは支配的な要因にはならないでしょう。

関連情報

今回は私の開発したサンプルを紹介しましたが、その他にもフルスタックなウェブアプリを構築する新しい手段はいくつかAWSから提供されています。

Amplify Gen2も良いスターティングポイントとなると思います。私はプレーンなCDKの方が好きなので上記のサンプル実装を作成しましたが、好みの問題でしょう。

また、オーストラリアの同僚が、最近NX plugin for AWSというツールを公開しました。これはTanStack RouterやtRPCで構築、あるいはPython Fast APIも選択できるなど、実装上の違いはあるものの、ウェブアプリの開発体験を高めたいという目的は同じです。

awslabs.github.io

AWS PDKと同じメンバーが作っており、PDKでの学びが活かされてるそうです。日本語ドキュメントも用意されているので、ぜひお試しください: クイックスタートガイド | @aws/nx-plugin

まとめ

最新のAWSサービス群を活用して、フルスタックなウェブアプリを構築するアーキテクチャを紹介しました。

個人的には今のところかなり開発体験が良いので、ウェブアプリはしばらくこの構成で作ろうと思います。

気軽なインド・ラダック旅。突然のレー空港封鎖により、図らずして北インドの秘境巡りができた話

先日妻と、インド映画「きっとうまくいく (3 idiots)」の聖地であるパンゴンツォの観光に行きました。ちょうどその最中、印パ情勢悪化による近隣の空港封鎖と、それに対するレー観光協会による陸路移動プランを経験できたので、その顛末を思い出としてまとめておきます。(私は今回初インドだったので、諸々誤りがあればすみません!)

インド・ラダック・レー、パンゴンツォ

地名の整理です。ラダックはインドの最北にある地域の名前*1です。パキスタンや中国にほど近いため、政情的にはやや不安定なようです。(行った限りではあまり感じませんでしたが)

外務省の危険レベルでは、4月末時点でインド全域と変わらないレベル1だったので、旅行は決行しました。

レーはラダックの主要な都市で、デリーとつながる空港があります。標高3500mという富士山9合目並の高地にありますが、人も多く商業も盛んで賑やかな都市です。

レーのとある街角。富士山の標高から更に富士山のような山が見えるのがヒマラヤの恐ろしさ。

パンゴンツォはレーから車で4時間くらいの湖の名前です。ヒマラヤ山脈を背景に藍と水色の湖面が映える、世界有数の景勝地です。映画きっとうまくいくでランチョーが想い人と湖面で再開するシーンは、あまりにも有名ですね。

それはこの近辺をタクシーで回っている最中に知らされました。

知らせは突然に

7日の昼下がり、パンゴンツォ周辺の観光をひとしきり終え、そろそろレーに帰るかチベット仏教寺院めぐりでもするかとしていたところ、突然運転手さんのスマホ着信音が社内に鳴り響きます。 ラダックの旅行代理店Hidden Himalaya Sachiさんから、と電話を代わってもらうと、以下の事実を伝えられました:

  • インド・パキスタン情勢の変化により、レー空港が今から利用できなくなること
  • 利用不可の期間は2日間と言われているが、さらに伸びる可能性も大いにあること
  • 陸路での移動手段をレー観光協会で検討中であること

翌日の飛行機でレー空港からデリーに戻る予定だったため、これは寝耳に水でした。4月末頃から印パの関係悪化はなんとなくニュースで聞いていたため、不安も募ります。 とにかくレーに戻らねば、取れる手段も限られてしまうだろうということで、急いでレーに向かいました。

このとき僕もネットで移動方法を調べてみましたが、Googleマップではラダックを抜けるのに25時間以上かかるルートしか提示されず、絶望していました*2。Sachiさんがいなければデリーに戻るまで非常に苦労しただろうと、Hidden Himarayaを代理店に選んだ妻にも感謝しかありません。

陸路移動計画

2時間後レーに戻ると、Sachiさんから対面で説明を受けることができました。なんと、早くも観光協会による移動計画が固まったとのこと。詳細は以下でした:

  • 翌朝、観光協会のバスに乗ってもらう
  • バスはレーからマナリまで13時間程度で着く
  • マナリからデリーへは、普通の夜行バスで12時間程度で着く

結果的にちょうど1日程度でレーからデリーに行けるという、願ったり叶ったりの提案でした!しかも普段はもっと料金の高い道を、₹2000程度で運んでくれるということで、レー観光協会の器量や親切さ・柔軟性など感じて好きになりました。

地図や写真の位置情報を参考に、以下の地図上に経路を書き起こしています。Googleマップにも書かれてない道があったり、平均高度が3000〜4000mだったり、あちこちで橋が崩落?してたりなど、大変秘境じみた道です。

この時点で16時頃だったので、翌日の24時間耐久陸路移動に備え、妻の勧めで軽食や酔い止め薬を買い揃えました。

酔い止めのAvomine。かなり効きました。しかも10錠で₹62と安い。

この後ホテルで20分程度停電があり、その後一晩Wi-Fiが使えなくなるなど、戦時下の心細さのようなものがうっすら分かりました。一方で、高山病に苦しむ妻にホテルのシェフが温かいスープを持ってきてくれるなど、ラダックの民の優しさも感じられたのは良かったです。

いざ行かん

翌朝は6時にレーの中央マーケット入口に集合とのことだったので、早起きして向かいます。ここまでは「バス」と聞いて、大型バス1台のようなものを想像していたのですが、実際は15人乗りのバンが20台程度集まっていました!希望者を全員運べるよう計らってくれたのでしょうか、それは頼もしい圧巻の光景でした (写真は撮り忘れた)。

インド製のForce Traveller

このバンが非常にタフで、この日舗装もされていないボコボコの悪路をものともせず走り続けていました。将来インド車もありかもしれません。

集合場所の係員に名前を伝えるとすぐに割当の車へ案内され、しばしのうちに発車しました。この辺りのスムーズさも、急に決まった計画とは思えないほどで、普段インドへ(勝手に)抱いているイメージを覆すものだったことを覚えています。

我々の車は他に日本人のお兄さん1人、ムンバイからの親子2人、ムンバイからのファミリー6人といった様子です。レーに観光にくる時点でというフィルタはありそうですが、総じて車内の治安は良かったです。むしろかなり親切な方々でした!

13時間はあっという間に

それから朝6:30頃にレーを出発し、マナリに着いたのは19:30頃でした。およそ13時間掛かったことになりますが、道中割と寝られたり (酔い止めの副作用?)、景色が飽きないものだったりしたおかげで、体感ではあっという間でした。

いくつかハイライトを書いておきます。

景色の変化

道中は風向きや標高の影響か、気候や植生が目まぐるしく変化しました。

ある道では乾燥した岩山・岩石砂漠のような風景が広がっていると思えば、

少し走ると雪景色に様変わりします。

(道中寝てたので)気づいたら景色変わってるーーということもあったのですが、極端に変わりゆく景色がどういう仕組みなのか説明できず、高校で触れた地理の知識をおさらいしたくなりました。

シンクーラ湖・山頂

3つ目のトイレ休憩で、シンクーラ(シンクラー?)の山頂に停車しました。どうやらインドでは名物らしく、ムンバイの青年が盛り上がってました。

きれいな丸い湖があったり、山頂の印があったり、チベット仏教タルチョがめちゃくちゃ張り巡らされていたりと、たしかにありがたみがあります。

この機会じゃないと一生来られなかったであろう場所の一つです。

なお妻は、シンクーラは中国語でお疲れ様だからいわばお疲れ山だと喜んでいました。

BRO (インド辺境道路組織) の看板文言

Border Roads Organization (BRO)というのはインドの辺境地域の道路を整備する政府組織のようです。この組織が道路脇に安全運転を呼びかける看板を多数設置しているのですが、この呼びかけ文句がインドジョーク的な感じで興味深かったです (押韻が好きそう)。

写真撮れなかったので転載mm

後で調べると、この地域ではちょっとした名物らしいことが分かりました。3分に1個くらいのペースで目に入る上、看板ごとに文言が変わるので、ぼーっと意味を考えてみるのは暇つぶしに良いかもしれません。

3号線終盤でついにトンネルを通過

シンクーラを通過してしばらくすると、3号線に繋がります。3号線は除雪作業のためレーからは封鎖されていたようなのですが、ここまで南下してくると利用できるようでした。

ここまでの道路は舗装されていないところも多く、揺れや砂埃など、なかなかハードな乗り心地だったのですが、3号線はさすが国道で、スムーズな乗り心地。ずっと快適になりました。

更に嬉しかったのは、ここに来て始めてトンネルが現れたことです。これまではくねくねと山肌を川沿いに削り出した道路がほとんどだったのですが、このAtalトンネルはヒマラヤ山脈を貫く9kmの道路です。2020年竣工ということで、インドの最新技術を感じました!直線の道路は乗り心地も快適で、かなりのショートカットになっているのではと思います。

en.wikipedia.org

トイレ事情

トイレは長距離移動において喫緊の課題となり得ます。今回は3時間に1回程度休憩があったため、私自身には無理のない体験でした (水分や食事の摂取を制限していたのもあり)。

公衆トイレは基本的に (日本の山も同じようなものですが) ボットントイレの穴だけがあるような状態でした。トイレットペーパーを持参すると重宝しそうです。

トイレの例。白黒&低解像度にしておきます

トイレは休憩地によりある場所とない場所、あっても雪で埋まってボットンの穴が塞がっている場所、色々ありました。この辺り女性の方が大変になりがちですが、妻は山中での活動に慣れているとのことで、うまく対処していたようです。トイレがない場所も死角は多いので、皆なんとかやってたのだと思います。

マナリ → デリー

上記以外にも、ザンスカールのパン屋で買った惣菜パン的なものがめちゃくちゃ美味かったり、パトラッシュ似のインド犬がお座りしながら食べ物をねだってきたり、ドライバーさんが車の異音を検知して予防的にその場で修理を始めたり、車が故障した鶏卵商人を目的地まで輸送する緊急クエストが発生したりなど、大小色々おもしろイベントがありました。

そんなこんなで19:30頃無事マナリにたどり着き、リキシャで繁華街に移動して飯など食べた後、夜行バスに乗り込みます。

壺入りのビリヤニ

夜行バスは一人₹800程度で乗れますが、日本の夜行バスより遥かに座席の縦間隔が広く快適でした。シートをフラットにして足伸ばしても、足置きに脚が届かないレベルです (それはそれで困る)。

あと、なぜかバスのクラクションがメロディになっており、車内にも響き渡るのでそこそこうるさかったです。

www.youtube.com

とはいえ今回も酔い止めのおかげか、(昼間かなり寝た割に)思ったより寝ることができ、朝7時、気づいたらデリーでした!

旅程の変化

レー空港封鎖による旅程の変化をまとめます。なお妻が全て計画を立ててくれました🥳 。-が元々の旅程、+が新たな旅程です。あえてインド式日付表記 (DD/MM/YY) で書いてみます笑

飛行機による移動速度を失ったことで、ヴァーラーナスィー*3の予定が吹き飛びましたが、8日の陸路の旅は楽しいものだったので良しとします。早めにデリーに着けたので、最終的には予定通り帰国できました。

05/05/25 レー空港着
06/05/25 パンゴンツォの宿着
07/05/25 パンゴンツォ観光
-08/05/25 レー→デリー & デリー→ヴァーラーナスィー
+08/05/25 レー→マナリ & マナリ→デリー(夜行バス)
-09/05/25 ヴァーラーナスィー→デリー
+09/05/25 デリー観光
10/05/25 デリー→羽田

最後に、素晴らしい移動プランを計画・完遂いただいたラダックの方々には、改めて感謝しかありません!ぜひ皆様も情勢が落ち着いたらラダックへ行ってみてください!

今月のもなちゃん

浅草橋のペットホテルでインスタデビューしてました。

www.instagram.com

*1:英語表記ではLadakhと末尾にhがつくのがオシャレだと思います。

*2:3号線(NH-3, Leh-Manali Highway)という直行ルートがあるのですが、毎年5月上旬頃に除雪完了で開通するとのことで、ちょうど状態が不透明な時期でした。

*3:遠藤周作リスペクト表記

Devin的な自律型開発エージェントを自作して得られた知見をいくつか

先日Devinリスペクトのクラウド型開発エージェントを公開しました。

tmokmss.hatenablog.com

今回は、この開発から得られた知見をいくつか紹介します。コーディングエージェントに興味のある方や、一般のAIエージェント開発について知りたい方にも役立てていただければ嬉しいです!

開発エージェントの基本的な仕組み

エージェントの仕組みは、Claude Codeを特に参考にしています。SWE Bench (verified) でも五本指に入る実力 (2025/4現在) であり、ClaudeのAnthropicが出している安心感もあるためです。

Claude Codeの仕組みは上記ドキュメントから推測できる (利用可能なツールなど) ほか、npmのパッケージの中にプロンプトなど多くの情報が含まれているため、盛んにリバースエンジニアリングもされているようです:

私も上記のドキュメントを参考にしつつ、Bedrock Converse APIのTool Useをベースとして、まずは以下のツールを持つエージェントを作成しました:

  • executeCommandツール: 任意のコマンドをbashで実行可能なツール

これだけです!実はこれでも開発エージェントは「それなりに」機能します。ファイル検索はgrepで、表示はcatで、編集はsedで、GitHubはgh CLIで、ウェブ検索はcurlでといった形で、多くの開発作業をbashだけでカバーできるためです。最低限の開発エージェントの形は、このようなものなのだろうと思います。

しかしながら、実はこれだけでは今のLLMでは挙動が安定しません。実際に、以下のような誤作動が頻繁に生じました:

  1. sedのコマンドの記述が不正で、編集に失敗する (人間でもsedは難しい!)
  2. curlでHTMLを取得するも、不要なタグやヘッダーが含まれてしまい、トークン効率が悪い
  3. GitHubレポジトリに書き込み権限がないときに、うまくforkしてくれない

他にも様々な挙動の不安定さが見られます。これらを安定化させることを狙って、目的別のツールを追加していきました。結果的には、今のところ以下のツール群となっています

  1. ci: GitHub PRのCI実行状況をポーリングして、完了したらステータスやログなどを返すツール
  2. cloneRepository: GitHubレポジトリをローカルにcloneするツール (書き込み権限の有無を確認し、必要に応じてforkする)
  3. fileEdit: path, oldString, newStringを指定する例のツール
  4. webBrowser: Playwrightを使ったbrowser-use的なもの。ページのメインコンテンツをMarkdownに変換する機能も持つ。
  5. reportProgress: Slackに現在の進捗状況を送信するツール
  6. sendImage: Slackに画像を送信するツール
  7. executeCommand: 任意のコマンドをbashで実行可能なツール

今後も必要に応じてツールを増やしていきますが、基本的な機能はexecuteCommandツールでほぼカバーできるため、ツールの追加は安定化が目的になると思います。機能の拡充はMCPでユーザーにやってもらえば良いかなという考えです。

また、安定化という意味ではツール周りのプロンプトはとても重要であることもわかりました。基本的には、具体的に書けば書くほど良い印象です (固定プロンプトのトークン数は結局プロンプトキャッシュで安くなるので気にしない)。その他大事なことは全てClaudeのドキュメントに書かれてそうでした: Tool use with Claude - Anthropic

Claude Codeのツール群も、こうした実験を経て今のラインナップになっているのだろうと思いますし、今後も変わっていくのでしょう。エージェント開発は終わりなき探求ですね。

ワークフロー vs エージェント

AI活用方法としてのワークフロー vs エージェントはよく議論の的となりますが、今回の開発でもしばしば考えることがありました。

「ワークフロー」と言うとDifyのようなノーコードの絵も思い浮かびますが、対エージェントの文脈においては、ルールベースで次のアクションを決める仕組み のことをワークフローと呼ぶほうが適切と思います。(c.f. 「エージェント」はLLMが判断して次の行動を決める。※ この定義はおそらく一般的ではないため、この記事限りとします。)

この意味では、今回のシステムにおいてワークフローはエージェントを補完する方法として、大いに活用できました。例えば上記のciツールもワークフローの一つであり、以下の処理フローを実行しています(コード):

  1. 指定されたPRのCIステータスを確認
  2. CIが完了してなければ5秒待って1に戻る
  3. CIがFailしていたらエラーログを取得する
  4. CIの結果を返す

これにより、エージェントはこのツールを呼ぶだけで安定してCIの結果を取得できます。エージェントとは違い確率的な要素はないため、安定性がワークフローの強みです。 一方で柔軟さには欠けることが弱みでしょう。例えば極端な話、上記のciツールではGitHub以外のCIに対応できません。

エージェントを開発する際は、これらのトレードオフを頻繁に考えることになりました。今回得られたベストプラクティスのようなものとしては、まずはできるだけ汎用的なツールを与え柔軟さを確保しながら、安定性が求められる重要な処理だけをワークフローとして別のツールに切り出すアプローチが良いように思います。

また別の観点として、ワークフロー内部の処理ではLLMによる判断が不要なため、トークン効率の高さも魅力になります。例えば上記のCI確認をエージェントに任せた場合、ポーリングのたびにLLMのコールが必要になるなど、明らかに非効率です。

こうしたPros/Consを考慮しながら、ワークフローとエージェントをバランスよく組み入れて使うことが、実用的なエージェント開発の肝になるのだと思います。

余談: ToolChoice

エージェントの安定性を増すための別の方法として、ToolChoiceオプションを使って、特定ツールの使用を強制させることが可能です。

例えば、今回はまだ実装してませんが、ユーザーへ定期的に進捗報告 (Slackへのメッセージ送信) することに使えます:

  1. 最後にメッセージを送った時刻を管理する
  2. 1がN分以上経過していたら、ToolChoiceでreportProgressツールの使用をエージェントに強制する

これにより、単にプロンプトで指示するよりも、安定した定期報告が期待されます。このような仕組みを色々活用してうまく制御することで、実用的なエージェントに近づけることができるのだと思います。

MCPサーバーとの連携

先月頃から急にMCPが流行り始め、今後のエージェント系アプリでは当たり前の要件になる予感がしています。本システムでも対応しており、MCPクライアントとして各種MCPサーバーに接続することが可能です。MCPクライアントを実装する情報はまだ巷に少ない気がするので、以下に軽くまとめておきます。

基本的には、ここに書いてある情報が全てです: For Client Developers - Model Context Protocol

書かれてあるとおりにMCPClientクラスを実装すれば、1. MCPサーバーを別プロセスとして起動 2. サーバーに接続しツール一覧を取得 3. サーバーのツールを呼び出し といったことが可能になります。サーバーのプロセス管理といった面倒事はMCP SDK内に隠蔽されているので、こちら側の実装はかなり単純になります。

ただし上記ドキュメントのコードはAnthropic SDK向けに書かれているため、利用するLLMのSDK (今回はAWS SDKConverse API)に合わせて、いくつか変更が必要です:

  1. Client.listTools の返り値をBedrockのtoolSpec形式に変換する コード
  2. Client.callTool の返り値をBedrockのToolResultContentBlock形式に変換する コード

他のSDKを使う場合でも、似たような方法で対応できると思います。

また、MCPClientを初期化する際に、MCPサーバーを起動するコマンドが必要です。これの指定方法はMCPの標準では規定されてませんが、 claude_desktop_config.json 形式がデファクトのようです (ほとんどのMCPサーバーのREADMEで言及されているため)。以下のようなJSONを読み込んで初期化するようにすれば、ユーザーにとって馴染み深い利用体験になると思います。

{
  "mcpServers": {
    "fetch": {
      "command": "uvx",
      "args": ["mcp-server-fetch"]
    }
  }
}

プロンプトキャッシュ

先日BedrockでもPrompt cachingがサポートされましたが、これは待望でした!大抵のエージェントアプリにおいて、コスト効率化のために欠かせない機能だからです。

ここでおさらいですが、Bedrock Claude Sonnet 3.7では、入力トークンに対して以下のコストが適用されます:

  • Input (通常): $0.003 / 1kトークン (100%とする)
  • Input (Cache write): $0.00375 / 1kトークン (125%)
  • Input (Cache read): $0.0003 / 1kトークン (10%)

キャッシュヒットした場合は、コストが10分の1になるというのが肝です。

エージェントアプリでは、メッセージ履歴をアペンドする形でLLMを呼ぶ形になるため、トークン数が蓄積しやすいです。また、エージェントがツールを呼ぶたびに、APIコール数が2つ増え(toolUse + toolResult)、入力トークン数も高騰しがちです。エージェントは思ったよりも自由自在にツールを呼びますし、呼び出し方に失敗して何度かリトライすることもしばしばです。

これがキャッシュなしだと恐ろしいコストになるのですが、キャッシュありだと20〜40%程度に抑えられる印象です。大きな差が出るので、マストで活用すべき機能と思います。

Tip: キャッシュを有効に使うための前提として、入力プロンプトを静的にする必要があります。例えば、システムプロンプトに new Date().toString() (秒単位の現在時刻) を入れると、毎秒プロンプトが変わるためキャッシュが効きません。この場合回避策としては、日付だけ入れたり (1日間はキャッシュが効く)、cachePointの後のブロックに動的な指示を入れるなどが考えられます。プロンプトが動的になる要因は色々あり得るので、アプリごとに検討が必要になるでしょう。

嬉しいことに、cache writeのコストが1.25倍でreadが0.1倍なので、一度でもキャッシュヒットすれば元が取れることになります!ほとんどのLLMアプリで恩恵を享受できると思われるので、要チェックです 📝

まとめ

エージェント開発に関するいくつかの(ごく一部!)知見を共有しました。あらためて、GitHubへのリンクを貼っておきます!固定費ゼロから始めることができるため、ぜひお試しください

github.com

この辺り、同僚の淡路さん@gee0awaBedrock Engineerという高機能なエージェントアプリを開発されているので、情報共有しながら進めています。この場を借りてお礼申し上げます!

Devin的な自律型開発エージェントをAWS上に作ってみた!

協働的AIチームメイトを謳うソフトウェア開発エージェント、Devin が注目を集めています。日本コミュニティでの勉強会は参加者が1000人を超えるほどです(!) 今回はDevin的な動きを実現するセルフホスト型のソリューションを開発してみたので、その紹介です。

TL;DR;

こちら↓にソースコード (IaC + Agent + Bolt app) を公開しています。

github.com

主な機能は以下です:

  • クラウド上で並列して動作できるソフトウェア開発エージェント
  • サーバーレス構成のため、料金の前払いは不要で固定費もほぼゼロ
  • MCPサーバーとの統合が可能
  • プロンプトキャッシュやコンテキスト長制御によるコスト効率化
  • OSSのレポジトリもフォークして開発可能
  • .clinerulesCLAUDE.md などからリポジトリ固有の知識を自動読み込み

AWSアカウントとGitHubアカウント、Slackワークスペースがあれば誰でも使えるので、試してみてください!

動く様子

開発の背景

これを作った理由ですが、主に3点あります:

  1. 興味本位: AI開発エージェントというのを自作してみたかったのですが、そういえばDevin的なものはまだOSSだとないなと思いモチベを高めることができました。 (旧OpenDevinことOpenHandsはローカルでの動作を想定しているとのことで、思ったよりDevinではない印象を受けた)
  2. 普段使い用: Devinはツテで触れて魅了されたのですが、料金は前払いで500USD/月と、いかんせん個人で使うにはお高いです。AWSで動かせれば、会社のアカウントで云々できるので、好都合でした。
  3. AWSサービスのデモとして: 本システムのアーキテクチャを考えると、ちょうど去年にGAしたマネージドPub/Subである AWS AppSync Eventsと相性が良さそうに思えました。これを取り込んだアーキテクチャであれば、布教にも貢献できそうだという目論見もあります。

これらのモチベで、なんとか公開できるレベルまで持って行くことができました!このエージェント自体の開発でもドッグフーディングしてたのですが、Sonnet 3.7が賢いこともあり、自分でも驚くほどうまくPull requestを出してくれることもあります。

アーキテクチャ

AWSアーキテクチャはこんな感じです。

基本的には固定費ゼロのサーバーレス構成です。APIGW+Lambda上のSlack Boltアプリがユーザーのメッセージを受けて、必要に応じてEC2インスタンスを起動し、インスタンス内でエージェントが動作します。各エージェントは専用のインスタンスを持つため、作業環境は完全に分離されています。

BoltのLambdaからEC2にメッセージを受け渡すために、AppSync Eventsを利用しています。AppSync EventsはAmplify librariesを使うとサブスクライバー側の実装が非常に楽になるのと、APIGW WebSocketのように接続IDの管理など手間不要なのが魅力的です。この魅力については、また別記事にまとめたいと思います。

なぜEC2?と気になる方もいるかもしれません。その他の選択肢としては、ECS FargateやCodeBuildが考えられます。しかし、FargateはDocker in Dockerができないこと (開発環境でdocker composeを使えないのは不便です)、CodeBuildではEBSでファイルシステムの永続化 (インスタンスを一時停止してから再開するときに、前回の作業状況を保持したいため) が困難という欠点があります。これらが理由でEC2を選択しました。今回の用途では、インスタンスはたいてい起動後1日で削除されるため、あまりEC2のツラミは顕在化していません。一点、初期化処理をユーザーデータで都度実行するために起動が遅い問題はありますが、AMIで改善予定です。

インフラコストはREADMEにまとめています。基本的には利用したセッション数 (Slackの1スレッドごとに1セッションと数えます) に比例し、使わなければコストはほぼゼロです。トータルではLLMが支配的ですが、Bedrockでもプロンプトキャッシュが使える様になったため、(実際に私が使った限りでは)他のソリューションと比べても競争力のある価格になっています。

使い方

今回はCDKで一撃構築、とまではいかず、少し大変です (SlackやGitHubが絡むため。) できるだけ楽になるようには注意しているので、ぜひ挑戦してみてください!

手順はREADME.mdにまとめています。

とりあえず試す用途であれば、以下のセットアップがおすすめです:

  1. Slackのワークスペースは個人用のものを利用
  2. GitHubへの認証はPATを利用
    • GitHub Appは設定がやや煩雑なので、まずはPATがおすすめです。ただし、他の人と共用するなら、マシンユーザーを作ったほうが良いかもしれません (アクセス制御観点 + PRの作成者が曖昧になるため。) 現状は本システムはシングルテナントで使う想定のため、あまりGitHub Appを使うメリットはありません (マシンユーザーが不要なことくらい)。

早ければ20分程度でセットアップできると思います。その他細かな使い方はいくつかREADME.mdに書いているので、ご参照ください。書いてない機能も多いので、何かあればIssueをください

作ってみた感想

いくつかつらつらと、開発に関する感想です。

1. エージェントの性能について

エージェントの性能 (今回は特にタスクの遂行能力) は、このシステムを実用化する上では最も重要な指標です。2月頃から力を入れて取り組んでいたのですが、BedrockがSonnet 3.7とReasoningをサポートして以来、性能が飛躍的に向上したように感じました。実際、AnthropicのブログではSWE Benchが62%とのことで、コーディング能力のリーダーボードにおいてもかなり上位です。

結果として、OpenHandsなどと比べて工夫のない実装ではあるものの、私の使った限りではそれなりの遂行能力を見せてくれています。LLMの種類だけでエージェント性能がおよそ決まってしまうのだとしたら、今後はUXやコスト効率が競争力になるのかもしれません。

2025年3月時点では、大抵のツールがSonnet 3.7を使っている (Amazon Q CLI, Claude Code, Devin) 事実もありますしね。

2. 開発タスクにおけるAI活用について

従来の開発エージェントツールを使っていた頃は、あまり性能が高くなく、自分でやったほうが速いなと感じる場面もほとんどでした。しかし、Sonnet 3.7登場以降は、「レベルの高い合格点を超えるコードをオールウェイズ出してくれる」というほどではないですが、それに近い感じがし始めてます。ここまで来ると活用しようというモチベも湧くものです。

とはいえ、曖昧なタスクを丸投げするにはまだ心もとないことが多いので、私自身はたいてい以下のような使い方をしています。

  1. 要件や仕様を明確にしたGitHub Issueを作成。どのファイルを見れば良いか、関連ファイルのパスも明示する。タスク遂行に必要な知識は全て与えるイメージ。
  2. 1のGitHubリンクをエージェントに渡し、PRのCIがパスするまで、作業させる

1について、曖昧な指示で良い感じにやってくれれば理想的なのはもちろんなのですが、それだと現実的にはズレた作業をすることも多く、リカバリーのほうが時間&料金の面で高コストなので、必要な情報はすべてこちらで考えて与えるようにしています。それでもドキュメントの細部を読んだりテストコード含めて実装する手間は省けるので、楽になっている感じはあります。

3. クラウド型エージェントの魅力

個人的には、ローカルで動くエージェントよりは、Devinのようなクラウド型エージェントが好きです。これは個人差ある部分だと思います。私自身は仕事を短時間に集中してやるというよりは、ダラダラ働いてしまうタイプなので、Issueの作成だけをパソコンでガッと済ませて、あとはタスクをSlackから投げつつ、スマホで様子見したりフィードバックしたりといった働き方は気に入っています。

さらに、クラウド型エージェントの魅力は、エージェントの並列化が容易なことです。このため、理想的に開発エージェントが使えるようになれば、下図の時短が実現できるのでしょう。

今はワンパスで終わらないことも多いので、下図のような状況もまたリアルですが (結局自分で全部やってもあまり差がないパターン!)。

今のところの経験から言えば、雑に丸投げして良い結果が返ってくる勝算は高くない (これはDevinでもそうです) ため、↑の理想形くらいがしばらくの目指すべき姿なのかなと思っています(全部緑にはならない)。そもそも私自身はWriting is thinkingを信じているので、書かないと思考がまとまらないだろうと思い込んでいる節もあるんですが。

とはいえローカル型(Clineなど)とクラウド型は排他なものではないので、良い感じの使い分け方を見つけていきたいです!私はAmazon Q Developerを使ってます🥳

まとめ

ということで、Devin的なクラウド型自律開発エージェントを作ってみた話でした!他にも技術的に得られた知見は多いので、また別の場でシェアできればと思います!

N度目の産卵からの換羽中で眠そうなもなちゃんです。

Aurora DSQLをPrismaで使う

次世代のサーバーレスRDBであるAurora DSQLを、TypeScript用ORMのPrismaと一緒に使う話です。DSQLドンドン使っていきたい!

DSQL x Prismaは実現できるのか

そもそも、今のPrismaはDSQLを扱えるでしょうか?

幸いにもDSQLは多くの面でPostgres互換を実現しているため、PrismaからもPostgresデータベースとして接続可能です。schema.prismadatasource.providerpostgresql にすればOKです。

// schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

ちょっとした model を追加してみましたが、正常にマイグレーションやデータ読み書きを実行できることが確認できます。

model User {
  id String @id
}

しかしながら、ひとつ考慮事項がありました。それは、上記の 環境変数 DATABASE_URL をどう渡すか? です。この記事では、主にこの点についてフォーカスします。

DSQLと環境変数 DATABASE_URL の課題

DSQLでは、データベース接続で利用するパスワード文字列として、動的に生成することを前提とした authentication token (以下トークンと呼ぶ) を利用します。以下は動的にトークンを取得してデータベースURLを生成する例です:

import { DsqlSigner } from '@aws-sdk/dsql-signer';

const hostname = 'example.dsql.us-east-1.on.aws';
const signer = new DsqlSigner({
  hostname,
  expiresIn: 24 * 3600 * 7, // 期限は最長1週間
});
const token = await signer.getDbConnectAdminAuthToken();
const url = `postgres://admin:${encodeURIComponent(token)}@${hostname}:5432/postgres`;

一方Prismaでは、パスワード文字列は静的に環境変数として渡されることを(暗に)前提としています。参考: Connection URLs

この2つの食い違いが、DSQL x Prismaの利用体験を損ねる可能性があります。以下では、どのように実装すれば良い感じになるかを見ていきましょう。

実装例の紹介

いくつか考えた・見つけた実装のアイデアを紹介します。

実装例1. 動的にPrismaClientを生成する

動的に環境変数 DATABASE_URL を設定した後、PrismaClientを初期化する方法です。

// prisma.ts
import { DsqlSigner } from '@aws-sdk/dsql-signer';
import { PrismaClient } from '@prisma/client';

const hostname = 'example.dsql.us-east-1.on.aws';

async function generateToken() {
  const signer = new DsqlSigner({
    hostname,
  });
  return await signer.getDbConnectAdminAuthToken();
}

export const getClient = async () => {
  const token = await generateToken();
  process.env.DATABASE_URL = `postgres://admin:${encodeURIComponent(token)}@${hostname}:5432/postgres`; 
  // PrismaClientのコンストラクタ内で、上記環境変数が参照される
  return new PrismaClient();
};

// 呼び出し側
import { getClient } from './prisma';

const main = async () => {
  const prisma = await getClient();
  await prisma.user.findMany();
}

これは最もstraightforwardな方法だと思います。欠点としては、従来の使い方と比べて、以下が追加で必要になる点です:

  1. PrismaClientを非同期関数越しに取得する必要がある
    • 静的な環境変数を使っていたならただの変数として取得できるので、やや使い勝手は変わります
    • 例: await prisma.user.findManyawait (await getClient()).user.findMany など
  2. 再接続の処理が必要になる
    • トークンの期限が切れるとデータベースに新たに接続できなくなります (既存の接続は利用できるようです)
    • このため、期限切れ前にPrismaClientを再度初期化する必要があります
    • なおトークン期限は最長1週間まで指定できるので、AWS Lambdaなど、比較的短命なランタイムでは気にする必要がないかもしれません

参考までに、再接続も意識したコード (getClient 関数のみ抜粋) は以下のようになるでしょう:

let client: PrismaClient | undefined = undefined;
let lastEstablieshedAt = 0;
export const getClient = async () => {
  if (client) {
    // トークンの期限切れより前に更新する (以下は例として1時間)
    if (Date.now() - lastEstablieshedAt < 1 * 3600 * 1000) {
      return client;
    } else {
      await client.$disconnect();
    }
  }
  lastEstablieshedAt = Date.now();
  const token = await generateToken();
  process.env.DATABASE_URL = `postgres://admin:${encodeURIComponent(token)}@${hostname}:5432/postgres`;
  client = new PrismaClient();
  return client;
};

ちなみに、top-level awaitを使えばPrismaClient自体をexportできるため、もう少し使いやすくなります。しかし再接続の実装は難しくなるかもしれません。

// prisma.mts
const getClient = async () => {
  // 省略
  return new PrismaClient();
};

// 呼び出し側はこちらを使う
import { prisma } from './prisma.mts';
export const prisma = await getClient();

実装例2. authentication tokenを環境変数として埋め込む

2つ目は、動的なトークンを静的なものとして扱う方法です。以下のような仕組みを作れば実現できるはずです (未実装)。

トークンを取得するLambdaを作成し、そのLambdaからアプリケーション本体 (これもLambdaにあるとする) の環境変数を書き換えます。 このLambdaをトークンが期限切れするよりも前に定期的に呼び出します。

アプリケーションがECSの場合は、Secrets Managerから動的に環境変数を埋め込めるため、以下のような構成もありでしょう (タスク定義を直接更新しない):

この方法の利点は、Prisma視点ではデータベースURLの文字列が環境変数から得られる静的な文字列となるため、従来と全く同じ使用感を実現できる点です。

一方、欠点は以下が考えられます:

  • Lambdaの環境変数に認証情報を直書きすることを推奨しない組織もある
  • トークンを更新するたびにコールドスタートが生じる
    • せいぜい数時間おきなので、大した影響はないだろうが
  • トークンを更新する仕組みの実装・管理が必要
    • 認証情報のローテーションの仕組みと似たようなもので、一度作ればほぼ管理不要だが、一定の面倒くささはあり
    • CDKコンストラクトなどが再利用可能モジュールがあると嬉しいかも?

とはいえアプリ側の実装が単純になるのはやはり嬉しいものです。

実装例3. PrismaのDBドライバーを node-postgres に差し替える

PostgresのPrismaでは、DBドライバーをPrisma独自のものでなく、node-postgres (pg) に差し替えることができます。node-postgresではパスワードとしてasync関数を指定できるため、素直に今回の処理を実装できます。以下はコード例です:

// schema.prisma
generator client {
  provider = "prisma-client-js"
  // feature flagを有効化
  previewFeatures = ["driverAdapters"]
}

// index.ts
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import { DsqlSigner } from '@aws-sdk/dsql-signer';

const hostname = 'example.dsql.us-east-1.on.aws';

const pool = new Pool({
  host: hostname,
  user: 'admin',
  database: 'postgres',
  port: 5432,
  ssl: true,
  password: async () => {
    const signer = new DsqlSigner({
      hostname,
    });
    return await signer.getDbConnectAdminAuthToken();
  },
});
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const users = await prisma.user.findMany();

個人的には、この方法が最も単純で美しいように思います。新規接続のたびにpassword関数が呼び出されるため、期限切れの問題も自然と解決されています。

注意点としては、DBドライバーそのものが置き換わるため、Prismaの動作に影響が生じうることです。私自身はPrismaとnode-postgresを合わせて利用したことがないため、どの程度挙動に差異があるのかは不明です。まだpreviewの機能でもあることから、漠然とした不安のある選択肢だとは思います。

実はDSQL以前からあった普遍的な問題なのだ

諸々見てきましたが、実はこの問題、DSQLに限った話ではありません。

例えば以下に挙げる場合において、開発者たちは以前から同じ問題に直面していたはずです:

  • RDSのIAM認証で接続する
  • DB認証情報を環境変数でなく外部ストアから取得する
  • DB認証情報をSecrets Managerなどでローテーションする

Prismaでは3年以上前からこの問題について指摘されていますが、まだ解決はしていないようです。

Prisma特有の困難として、クエリエンジンがRustで書かれているために、JavaScriptでpasswordを取得するasync関数を書かれても、それを実行しづらいという点が挙げられています。最近はPrismaRust部分をTypeScriptに移行するという話もある (全く別の文脈ですが) ので、そこに少し期待したいですね。

We’re addressing this by migrating Prisma’s core logic from Rust to TypeScript and redesigning the ORM to make customization and extension easier.

まとめ

Aurora DSQLとPrismaを併用する上で、特に認証情報 (authentication token)をどう扱うかについて考えました。Prisma特有?の困難は見えましたが、接続さえできれば普通に使えるので、今後サーバーレスPostgresとして活用していければと思います。

なお、検証に用いたコードはこちらに公開しています: aurora-dsql-prisma-example

今月のもなちゃん

引っ越しで内装が様変わりする中、かつての安息の地をディスプレイ下に見出した様子です。

ではまた!