【Devlime開発記】Scrum AI導入
1. はじめに
スクラムで開発していると、バックログアイテムやユーザーストーリーの作成に思った以上に時間がかかります。会議で「次のスプリントには予約機能を追加しよう」という結論が出ても、開発者の作業は残っています。タイトルを整理し、As-A / I-Want / So-That形式で内容を作成し、受け入れ条件を記載し、ストーリーポイントや価値スコアまで入力しなければなりません。一つだけならすぐに終わりそうですが、スプリントごとに20件前後繰り返すとなると話は変わります。
そこで、DevlimeのScrumモジュールを開発する際に、この作業を減らしてみることにしました。最初に考えた機能は単純でした。要件を入力するとUserStoryの下書きを作成し、過去の類似作業を探してスコアを参考にできるようにする程度です。3週間ほどで終わると予想していました。
実際に開発してみると、予想とは異なりました。AI APIを接続すること自体は難しくなく、試行錯誤の多くは、どの問題をAIに任せるのかを決める過程で発生しました。この記事では、機能をどのように始め、どこで問題が発生し、どのように構造を変更したのかを整理します。
2. 技術選定の背景
2.1 構造化された出力
最初に作ったのはUserStoryの下書き生成機能でした。ユーザーが入力した要件をClaudeに渡し、JSON形式で受け取った結果を画面に反映する方式です。最初はうまく動作しましたが、常にそうとは限りませんでした。応答の前後に説明文が付いたり、JSONの前にマークダウンのコードブロックが付いたり、特定のフィールドが欠落したりすることがありました。
初期段階では、応答文字列を直接加工して対応しました。バッククォートを削除し、JSONの開始位置を探し、フィールドがなければデフォルト値を埋め、失敗した場合は例外として処理するコードを一つずつ追加しました。その結果、AIを呼び出すコードよりも、応答を復元するコードのほうが長くなりました。プロンプトをさらに調整するだけでは限界があり、モデルの出力形式そのものをアプリケーション側で制御する方法が必要だと判断しました。
そこで、Claude Java SDKが提供する構造化された出力を適用しました。文字列を受け取って直接変換するのではなく、サーバーで使用するDTOをそのまま応答スキーマとして定義する方式です。
public class UserStoryDraftAiSchema {
public boolean supported;
public String rejectionReason;
public List<TitleOption> titleOptions;
public List<NarrativeOption> narrativeOptions;
}
以前作成したパースおよび補正用のコードの大部分を削除できました。コードが減ったこと以上に大きかったのは、サーバーで使用するデータ構造とモデルに要求する出力構造を、一つの型で管理できるようになった点です。この機能で構造化された出力を選んだ理由もここにあります。モデルの性能が優れているからではなく、アプリケーションで不要に処理しなければならないコードを減らせるからです。
2.2 モデル選定
最初は品質を優先し、claude-opus-5を使用しました。結果自体は良好でしたが、実際に画面で使ってみると応答時間が問題になりました。1回のリクエストに6〜7秒ほどかかり、スプリント計画中に何度も呼び出す機能であることを考えると、負担の大きい時間でした。
そこでclaude-haikuに変更しました。品質が大きく低下すると予想していましたが、チームメンバーに両モデルの結果を並べて見せて比較しても、違いを明確に区別できませんでした。今回の機能が求めているのは、複雑な推論や創作ではなく、入力された要件を決められた形式に整理する作業に近かったためです。結果として応答時間と呼び出しコストを同時に削減でき、より優れたモデルよりも機能に合ったモデルを選ぶほうがよいことを確認しました。
2.3 スコア推奨方式
最も大きく方向を変えた部分です。最初は下書き生成と同じ方式で、Claudeにストーリーポイントを尋ねようとしました。結果はうまく出ましたが、なぜ3点なのかを説明できませんでした。同じ要件を何度もリクエストすると3点になることも5点になることもあり、temperatureを下げて結果を固定しても、根拠が生まれるわけではありませんでした。
考えてみれば当然の結果でした。Claudeは、Devlimeチームが過去数か月にどのような作業を行い、その作業を何点と評価したのかを知りません。ストーリーポイントは一般的な正解がある値ではなく、チームの過去の作業を基準に判断する相対的な値だからです。根拠なく提示された数字は参考にならないだけでなく、画面に先に表示されるという理由だけで議論の基準になる危険もありました。
そこで問題を定義し直しました。スコアを生成するのではなく、類似した過去の作業を探し、そのときのスコアを併せて表示する方向に変更しました。
3. 適用プロセス
3.1 生成ではなく検索
完了済みの過去のBacklogItemのタイトルをVoyage AIの埋め込みモデルでベクトル化して保存しました。新しい要件が入力されると、同じ方法で埋め込みを行った後、既存データとのコサイン類似度を比較し、類似度の高い上位3件を推奨対象として使用します。
[저장] 완료된 BacklogItem 제목 → Voyage Embedding → Vector 저장
[검색] 새로운 요구사항 → Voyage Embedding → Cosine Similarity → 유사 항목 Top 3
画面には推奨スコアだけでなく、その根拠となった過去項目のタイトルと当時のスコアも併せて表示します。
추천 참고 항목
1. 예약 화면에 사용자 검색 기능 추가 (Story Point 3)
2. 예약 상세 조회 기능 추가 (Story Point 3)
3. 예약 상태 변경 기능 추가 (Story Point 5)
この構造では、AIは正解を提示しません。過去にこのような作業があり、その当時はこの程度に評価したという情報だけを提供し、最終的な判断はユーザーが行います。根拠を併せて表示できるようになったことが、方式を変更した最大の理由でした。
3.2 呼び出し経路
ReactからAnthropic APIを直接呼び出す方法もありました。ただし、その場合はAPI Keyがクライアントに露出し、呼び出しの制御も難しくなります。
React → Scrum Backend → AI API
フロントエンドはリクエストに必要な値だけを渡します。
const draftUserStory = (params: {
rawInput: string;
productId: string;
epicId?: string;
}) => {
const query = { ...params };
return axios.post(url('/draft-user-story/fetch'), query);
};
プロンプトやモデル選択、出力スキーマ、再試行といったAI関連のポリシーはすべてサーバーで管理します。このようにしておけば、後からモデルを変更しても、フロントエンドへの変更はほとんど発生しません。
3.3 API Keyがない環境
AI機能を使用しない開発者のローカル環境にはAnthropic API Keyがありません。しかし、サーバー起動時にAnthropic Clientを必ず生成するようにすると、Keyがない環境ではアプリケーション自体が起動しない可能性があります。そこでBeanを条件付きで登録しました。
@Bean
@ConditionalOnProperty(name = "ANTHROPIC_API_KEY")
public AnthropicClient anthropicClient() {
return AnthropicOkHttpClient.fromEnv();
}
API Keyが存在する環境でのみClientを生成し、存在しない環境ではAI機能だけを無効化した状態で、その他の機能が正常に動作します。AI機能一つのためにチーム全体の開発環境へ影響を与えないよう、境界を設けたのです。
3.4 スキーマのフィールド順序
構造化された出力に変更した後、予想していなかったのは、スキーマのフィールド順序によって結果が変わるという点でした。最初はタイトル候補を先に作成するよう定義していましたが、「来週の飲み会の場所を決める」のように、要件とは考えにくい入力でもタイトルが先に生成されました。対応可能かどうかを判断するsupportedが後で決定されるため、すでに作られた結果に合わせて、対応可能な入力として処理される場合がありました。
そこで、判断に必要なフィールドを前に移しました。
public boolean supported;
public String rejectionReason;
public List<TitleOption> titleOptions;
public List<NarrativeOption> narrativeOptions;
入力がUserStoryにできる要件かどうかを先に判断し、その結果がtrueの場合にのみ残りの内容を生成するよう順序を変更しました。この過程で、スキーマは単なるDTOではなく、モデルの出力過程を制御するプロンプトの一部であることを知りました。
4. 問題解決
機能を組み込んだ後も問題は続けて発生しました。以下の5つは、実装が完了した後、実際に使用して確認した内容です。
4.1 正常な応答なのに内容が空だった問題
UserStoryの下書きでnarrativeOptionsが空の配列で返される問題がありました。HTTP Statusは200で、サーバーでも例外が発生していなかったため、最初はフロントエンドの問題だと考えました。実際の応答を確認したところ、生成途中で応答が途切れていました。
原因はmaxTokensでした。BacklogItemを基準に2048を設定していましたが、UserStoryでは複数のタイトルと各タイトルの説明、受け入れ条件まで生成する必要があるため、必要な出力量がはるかに多くなります。値を4096に増やして解決しましたが、より重要なのは、HTTP 200だからといってAIの応答が正常に完了したとは限らないという点でした。現在は応答のstop_reasonを確認し、最大出力トークンに達した場合はログを残すように処理しています。
4.2 推奨対象が0件になる問題
スコア推奨機能をリリースした後、特定のプロジェクトでは推奨結果が表示されないというフィードバックがありました。確認すると、類似度検索の対象が0件でした。最初は現在選択しているイシュータイプと同じ完了項目だけを検索するよう制限していましたが、実際のデータでは特定タイプの完了項目が多くなく、フィルタリングすると比較するデータ自体がなくなっていました。
そこでタイプフィルターを削除しました。タイプが異なる作業のスコアを参考にするのは不正確だと思いましたが、この機能で表示するスコアは確定値ではなく参考値です。タイプが異なっていても、タイトルと作業内容が類似していれば、過去の事例として十分に参考にできます。最終的には、精度を少し下げても検索結果が存在するようにする方針を選びました。
4.3 応答を待つ時間
モデルをHaikuに変更した後も、リクエストから応答まで約2〜4秒かかりました。最初はSpinnerだけを表示していましたが、実際に使ってみると、この時間が思った以上に長く感じられました。そこで生成中は、状態メッセージを段階的に変更するようにしました。
요구사항을 분석하고 있습니다.
UserStory 초안을 작성하고 있습니다.
인수 조건을 정리하고 있습니다.
実際のモデルの進行状況と連動しているわけではありません。ただし、ユーザーが何の変化もなくSpinnerだけを見ている状況を減らすのには役立ちました。AI機能のユーザー体験は、モデルの応答品質だけで決まるのではなく、モデルが応答している間にユーザーへ何を見せるかも機能の一部でした。
5. 最終構造
今回の機能を作る中で、最終的に次のような構造になりました。
사용자 → BFF → Scrum API
├─ Claude : UserStory 초안 생성
└─ Voyage : 과거 유사 항목 검색
2つの機能を同じAIの問題としてまとめませんでした。文章を作る問題と、私たちのデータから根拠を探す問題を分離し、UserStoryの下書きはClaudeが、過去の作業検索はVoyageの埋め込みが担当します。最終的な決定はユーザーが行います。役割を分けると、最初よりも構造がむしろ単純になりました。
6. 学んだこと
3週間にわたって開発して最も強く感じたのは、AI機能で難しいのはAPI呼び出しそのものではないということです。Claude APIを呼び出すコードは、思ったよりも早く作成できます。時間がかかったのはその後でした。どの作業を生成モデルに任せ、どの作業を自分たちのデータから探すべきかを決める必要があり、正常なHTTP応答を受け取っても実際の内容が正常かを確認しなければならず、モデルの応答時間をユーザーがどのように受け止めるかについても併せて考える必要がありました。
特にスコア推薦でこの違いを強く感じました。モデルに根拠のない判断を求めれば、もっともらしい答えを作ることはできますが、その答えが私たちのチームの基準に合っているかどうかは別の問題です。反対に、チームの過去データがあるなら、モデルに答えを作らせるよりも、そのデータを検索してユーザーに示すほうが適している場合があります。結局、AIに何をさせるかよりも、この問題を本当にAIが生成すべきなのかを先に確認することが重要でした。
7. まとめ
最初は簡単な機能だと思っていました。要件を入力するとUserStoryを作成し、似た作業を探してスコアを表示すればよいと考えていました。しかし、実際に作ってみると、大きく2回方向転換することになりました。スコア推薦を生成の問題として捉えていたことが1つ目で、検索の精度を高めようとして候補を絞り込みすぎたことが2つ目でした。どちらもコードを間違って書いたことで生じた問題ではなく、最初の問題の定義方法が適切でなかったことが原因でした。
今回の作業を通じて、これからAI機能を作る際には、3つのことを継続的に確認していこうと思います。1つ目は機能に合ったモデルを選ぶこと、2つ目はコストが発生する呼び出しを必ずサーバー側で制御すること、3つ目は機能を作った後にチームメンバーに実際に使ってもらうことです。
最後の点が特に重要だと思います。今回見つかった問題のほとんどは、開発段階では確認できませんでした。空の配列の問題も、韓国語入力の問題も、検索対象が0件になる問題も、すべて実際に機能を使った後になって初めて明らかになりました。
結局、今回のScrum AI開発で最も大きく変わったのは、コードではなく問題の捉え方でした。これからDevlimeにAI機能を追加する際も、まずモデルを選ぶのではなく、どのような問題なのかを定義し、その問題に対して生成、検索、ルールのどの方法が最も適しているのかを確認していこうと思います。
長い文章を読んでくださり、ありがとうございました。
jun