株式会社エブリーのブログ - TECH PLAY

TECH PLAY

株式会社エブリー

株式会社エブリー の技術ブログ

全481件

目次 はじめに 前提 結論 理由1: プログラムの呼び出しを1つのシェルコマンドに合成できる 理由2: 修正が次のツール呼び出しから反映される MCPを選ぶ場面 まとめ 参考文献 はじめに こんにちは、ヘルシカでiOSエンジニアをしている谷口恭一です。 Claude Codeに開発の作業を任せるようになって私が必要だと感じたのは、人が確かめなくてもエージェントが作業を最後まで進められる状態でした。結果を毎回自分で確かめて、指示し直すのをやめたかったからです。そのためにClaude Codeに足そうと考えたのは、2つの仕組みでした。1つは、エージェントがルールから外れないようにするガードレール。もう1つは、エージェントが作業し、ビルドやテストで検証し、通らなければ修正する、というフィードバックループです。このループが人を介さずに回れば、エージェントは自律的に作業を最後まで進められます。こうしたモデルの周りの仕組みを設計することは、ハーネスエンジニアリングと呼ばれています( OpenAI 、2026-02-11)。 Claude Code自体もモデルの周りのハーネスですが、本記事では、Claude Codeに自分たちで足すプログラム・スキル・フック・権限ルールをハーネスと呼びます。 本記事では、ハーネスのプログラムの例として、エージェント用のタスク管理のプログラムを使います。バックログの項目を決まった形式で読み書きするプログラムで、 backlog というコマンドで動くとします。Claudeはこれを使って、次のように作業を進めます。 backlog show-next-items で、次にやる項目を取り出す backlog take-item で項目を自分の担当にして、実装する テストが通ったら、コミットのハッシュとテスト結果を記録に付けて、 backlog move-item --to=done で完了にする このプログラムには、ガードレールとフィードバックループの両方が入っています。人の判断を待っている項目は、 take-item が担当にさせません。完了にするときに完了条件に達していなければ、何が足りないかを添えて差し戻し、Claudeはそれを読んで直します。 こうしたプログラムをClaude Codeから使えるようにする方法は2つあります。 インターフェース Claudeからの見え方 MCPサーバー MCPツールとして加わる。名前は mcp__<server>__<tool> CLI 組み込みのBashツールから実行するコマンド スキル(使い方などの説明)とフック(決まったタイミングでClaude Codeが実行する処理)は、どちらのインターフェースとも組み合わせられるので、違いが出るのはプログラムのインターフェースです。では、このプログラムはMCPとCLIのどちらで作るのがよいのでしょうか。 前提 本記事が扱うのは、開発者がローカル環境で自分の開発のために使う、ガードレールとフィードバックループのためのプログラムです。SlackやGitHub、社内のAPIなどにつなぐためのMCPサーバーは、外部サービスと連携するためのもので、この話の範囲の外にあります。 また、本記事でCLIと呼ぶのは呼ばれるたびに起動し、最新のコードを実行するコマンドです。CLIでも、常駐プロセス(デーモン)に処理を任せる作りでは、古いコードが動き続けることがあります。状態をローカル環境でファイルなどに都度書き出す仕組みにすることなどによって、常駐プロセスを持たない作りにしています。 調査したドキュメントと仕様の記述は2026年10月13日時点のものです。 結論 エージェントが自律的にフィードバックループを回し、失敗したらハーネスを自己改善する。この仕組みを作るなら、プログラムのインターフェースはCLIのほうが良いと私は考えています。 理由は2つです。1つ目に、Claude CodeがCLIをパイプや条件分岐でつなぎ、中間結果を絞り込んでから読むように動作するようになってきたからです。MCPツールでは、中間結果が毎回すべてコンテキストに戻ることが弱点だと考えています。2つ目に、CLIの場合はエージェントが修正した自分自身のコードが、次のツール呼び出しから確実に反映されることにあります。stdioのMCPサーバーは、仕組みを足さない限り再接続するまで古いコードで動き続けてしまうので、自己改善のループにおける弱点だと考えています。 理由1: プログラムの呼び出しを1つのシェルコマンドに合成できる 私の環境では、Claudeが && や | でいくつものコマンドをつないだ1つのシェルコマンドを組み、Claude Codeがそれを1回のBashツールの呼び出しで実行することが増えました。Claudeのモデル性能の向上により、暗号のような長いコマンドを一気に正確に実行する様子を見たことがある人は多いかも知れません。CLIはこのシェルコマンドの中に組み込めます。次の例は、テストが通ったときだけ、今のコミットのハッシュを付けて項目を完了にするシェルコマンドです。 npm test && backlog move-item B012 --to=done --evidence="$(git rev-parse --short HEAD) とテスト" --json | jq '{status, unblocked}' テストが失敗すれば && の後ろは実行されず、項目は完了になりません。テストが通ったときだけ backlog を呼び、その結果から、新しいステータスと、この項目を待っていて着手できるようになった項目を取り出して読みます。 Claudeは現状これらのコマンドの組み合わせをその場で自律的に考える能力があり、モデル側に柔軟に効率的なコマンド実行を任せてしまうことは大きなメリットがあると考えています。 ClaudeはMCPツールも、互いに関係しない呼び出しならモデルの1回の応答で並列に呼び出せます。しかし、前の結果による条件分岐や中間結果の絞り込みなどは結局できないことになります。よってMCPツールの結果はいったんすべてClaudeのコンテキストに戻り、Claudeが読んでから次の呼び出しを組む流れが強制されてしまいます。 ツールを1つずつ呼ぶと、呼び出しのたびに推論が1回必要になり、中間結果がコンテキストにたまります( Introducing advanced tool use on the Claude Developer Platform 、 Code execution with MCP )。Anthropicの対策は、エージェントが書いたコードの中からツールを呼ばせる、Claude APIのProgrammatic Tool Callingです。Claude Codeでこれに相当するのは、Bashツールから実行するCLIです。 フィードバックループでは、この差が回数に比例して積み重なります。「テストが通ったら完了にし、必要な部分を取り出して読む」を項目ごとに繰り返すからです。CLIなら1つのシェルコマンドで済み、Claudeが読むのは絞り込んだ結果だけになります。これらすべてを1つのスクリプトにして再利用可能にする設計も考えられますが、シンプルなツールをモデル側がうまく組み合わせるほうが柔軟性の観点から良いのではないかと考えています。 シェルコマンドの権限ルールは、 && や | で区切った部分ごとに判定されます。自作のCLIのほかに、 jq のように一緒に使うコマンドも権限ルールに入れておきます。長いシェルコマンドは、権限プロンプトで人が読んで判断しにくくなる点には注意が必要です。 理由2: 修正が次のツール呼び出しから反映される ハーネス自身の自己改善までエージェントに任せたい場合のことを考えた時に、修正がすぐに適応されるかという観点が重要になると考えています。 エージェントが作業の中で失敗したときに、原因になったハーネス(プログラム・スキル・フック・権限ルール)をエージェント自身が修正し、修正したハーネスで作業を続ける流れを作るイメージです。作業を回すループの外側で、エージェントがハーネスを直すループが回ります。 例のタスク管理のプログラムで考えます。エージェントが、人の判断を待っている項目を完了にしてしまったとします。原因は、 move-item が人の判断待ちかどうかを確かめていなかったことです。エージェントはこの確認を backlog のコードに足し、テストを通します。CLIなら、次に backlog move-item を呼んだときから新しいルールで判定されます。stdioのMCPサーバーなら、再接続するまで古いルールのまま動くので、同じ失敗が起き得ます。 このループを自律的に回すには、エージェントが修正したものが、実行中のセッションにその場で反映される必要があります。修正のたびに人がMCPサーバーの再接続やClaude Codeの再起動をするなら、ループはそこで人を待ちます。 stdioのMCPサーバーは、Claude Codeが自動では再接続しません( MCP )。 list_changed 通知でツールの一覧は再取得されますが、実行中のプロセスは再起動されず、コードの修正を反映するには /mcp からの再接続が要ります。定期実行のように人が見ていないセッションでは再接続する人がいないので、自己改善のループはそこで止まります。 MCPでも、仕組みを足せば再接続なしに新しいコードで動かせます。プロキシやファイル監視でサーバーを入れ替える方法や、CLIをラップするMCPサーバー(ラッパー)を作り、 tools/call を受けるたびにラップしたCLIを起動する方法です。ただし、どちらも管理するプロセスが1つ増えます。また、ツールを追加したり名前を変えたりしたときは、Claude Codeに確実には反映されません。ラッパーの場合は中身が結局CLIになるので、理由1の合成はできないまま、CLIとMCPサーバーの2つを持つことになります。CLIなら、何も足さずに次の呼び出しから反映されます。 Claude CodeのBashツールは、シェルコマンドごとに別のプロセスで実行します( ツールリファレンス )。常駐プロセスを持たないCLIなら、修正すれば次のツール呼び出しから新しいコードで動きます。ビルドが要るCLIでも、エージェントがビルドとインストールまで済ませれば同じです。フックから呼ぶCLIも同じです。プロジェクトの .claude/ に置いたスキルと設定(フックや権限ルール)も、再起動なしで実行中のセッションに反映されます。プログラム・スキル・フックのどこをエージェントが修正しても、次のツール呼び出しから新しいハーネスで動きます。 MCPを選ぶ場面 ユーザー単位の認可が要るときは、MCPのほうが向いていると考えます。MCPの仕様では、HTTPでつなぐサーバーはOAuth 2.1で認可し、ユーザーごとにアクセストークンを発行して、スコープで権限を絞れます。複数のユーザーが使うサービスなら、これは大きな利点です。また、そもそも暗号のようなBash実行をAuto Modeで許していることが前提であり、適切な権限管理をしたい場合はやはりMCPを使うべきだと思います。手元の環境をCLIで用意し、どうしても必要な時にMCP化を検討するという原則が最も適切なのではないかと私は考えています。「AIから呼ぶからMCPが良い」と私は最初は思っていましたが、そうではないと後から思いました。 まとめ エージェントが自律的にフィードバックループを回し、失敗したらハーネスも自分で直していくような仕組みを目指すなら、プログラムのインターフェースはCLIのほうが良いと私は考えています。CLIなら、Claudeがその場でシェルコマンドを組み、中間結果を絞り込んでから読めるからです。また、エージェントがハーネスを修正すると、次のツール呼び出しから反映されるからです。このような軽量なハーネス用CLIを設計するときは、結果をJSONで返し、常駐プロセスを持たない作りにしておくと、この2つの利点を活かせます。 一方で、ユーザー単位の認可が要るときはMCPが向いています。どちらを選ぶかは、目的によるという結論になると思います。Claude Codeを使う時に同じような迷いがあった場合、1つの参考にしていただけると嬉しいです。 参考文献 OpenAI, Harness engineering: leveraging Codex in an agent-first world , 2026-02-11 Anthropic, Introducing advanced tool use on the Claude Developer Platform , 2025-11-24 Anthropic, Code execution with MCP: Building more efficient agents , 2025-11-04 Claude Codeドキュメント: MCP / ツールリファレンス
目次 はじめに 背景 UserMatching(UM)と社内システム 社内システムの課題 目指したこと 業務に入り込む進め方 ヒアリングで業務を知る ヒアリングから始めた理由 どう進めたか 分かったこと 設計でつまずいたこと AIが作った設計を判断できなかった やりたいことが広がりすぎた ひとりで考え込んでしまった どう乗り越えたか 自分で判断できるように、進め方を決めた 広がった範囲を、目的に合わせて絞った ひとりで抱えず、考えをチームに見せた 設計を終えて実装へ AIの時代に大切だと感じたこと 設計の進め方を持っておく 人に見てもらう チームで一緒に考える おわりに はじめに こんにちは、UserMatching事業でエンジニアをしている新卒2年目の惟高です! 今は、ビジネス側のメンバーが毎日使う社内システムの作り直しを担当しています。業務のヒアリングから要件の整理、設計まで進めて、ちょうど実装に入ったところです。 この記事では、その中でつまずいたことと、どう乗り越えたか、AIを使って開発する中で大切だと感じたことをまとめていきます。 対象読者 : 社内の業務改善やツールの作り直しに関わっているエンジニアの方 分かること : エンジニアが業務に入り込んで要件から設計まで進めたとき、どこでつまずき、どう乗り越えたか 背景 UserMatching(UM)と社内システム デリッシュキッチンやトモニテでは、ユーザーがプレゼントに応募するためのLP(応募フォームのページ)を公開しています。 https://lp.delishkitchen.tv/campaign/ceres.html https://lp.tomonite.com/campaign/speee_renxa このLPの設定は、ビジネス側のメンバーが社内システムから行っています。案件ごとの設定の流れは、おおよそ次のとおりです。 社内システムでの設定の流れ 社内システムの課題 今の社内システムは、もともとエンジニアがやっていた設定作業を、そのまま画面にする形で作られてきました。そのため、ビジネス側よりもエンジニア目線に寄った作りになっていて、たとえば次のような課題が積み上がっていました。 覚えるルールが多い : 設問の回答の列名や送付先のフォルダ名など、社内システムの外の仕組みを知らないと正しく入力できない 業務と合わない : 業務ではQAや、別の人が見直すWチェックを経て公開するが、それぞれがどこまで進んだかを社内システムで追えない 操作が煩雑 : LPを作り直すたびに、変換の設定と送付の設定を複製し直す必要がある 事故が起きやすい : 列名を間違えても保存できてしまい、データを送ってから初めて気づく 一つひとつは小さな不便ですが、それが重なって使う人の負担が大きくなり、実際にミスにも繋がっていました。そこで、社内システムを見直すことになりました。 目指したこと 目指したのは、ビジネス側がエンジニアに確認しなくても、迷わず設定できる社内システムです。 最初は、今の社内システムを改修して対応するつもりでしたが、要件をまとめて設計を進めてみると、DBの構造もAPIもほとんど作り直しになることが分かりました。 それなら新しく作ったほうがよいと考え、設計の途中でシステムそのものを作り直すことに決めました。 業務に入り込む進め方 使いやすい社内システムにするには、画面の操作だけを見ていても足りません。そこで今回は、ビジネス側の業務に入り込んで、課題をどう解決するか、要件としてどうまとめるかから考えることにしました。 こうした動き方をするエンジニアは、FDE(Forward Deployed Engineer)と呼ばれています。もともとはPalantirで生まれた職種で、顧客の現場に入って課題をつかみ、それを解決するものを作ります。 振り返ると、今回の進め方はこれに近いものでした。 https://jobs.lever.co/palantir/2e6b0ac8-83e9-4be5-a3aa-cf319f751728 進め方は、大きくヒアリングと設計に分かれます。設計では、Claude Codeを使って要件の整理からDB、API、画面の設計までを進めました。 ここからは、それぞれで何をして、どこでつまずき、どう乗り越えたかを順に書いていきます。 ヒアリングで業務を知る ヒアリングから始めた理由 課題や改善案は、すでにある程度集まっていました。でも、本当に使いやすいものにするには、社内システムを触っている時間の前後で、どんな作業をしているのかも知っておきたいと思いました。 社内システムの操作については、実際に使っているところを横で一緒に見ながら話を聞きました。前後の作業についても、実際にやっているところを観察しながら、分からない点をその場で質問させていただくのが、理解を深めるには一番だと思います。 ただ、問い合わせやエラーの対応、追加の実装も並行していたので、そこまでの時間は取れず、前後の作業はヒアリングや資料をもとにキャッチアップすることにしました。 どう進めたか ヒアリングしたのは以下の3つです。 社内システムの中の作業 : 実際に使っているところを横で一緒に見ながら、課題点などを話した 前後の作業 : 業務の内容をスプレッドシートにまとめてもらい、分からない点を聞いた 業務全体 : Zoom Revenue Acceleratorに残っている商談の録画をベースに企業とのやり取りを把握し、商談中に何ができたら便利かなどを考えた 分かったこと 私自身も社内システムを使っていたので、使いにくい所はある程度分かっていました。そこにビジネス側の話が加わって、システムを使う前後の作業まで一通りつかめました。 特に、次の2つがはっきりしました。 手間のかかる操作 : どの設定に時間がかかり、どこで漏れが起きやすいか 設定の背景 : 設定の値が、ビジネス側や企業側のどんな事情で決まっているか 2つ目は、聞いてみて初めて分かったことです。設定がどういう事情で決まっているかが分かると、その設定をどの単位で持てばよいかも決めやすくなり、DBの設計にそのまま使えました。 業務全体で見れば、まだ分かっていない所もありました。それでも、今回やりたいのは社内システムの改善です。そのために必要なことは十分に把握できたと判断して、設計に進みました。 設計でつまずいたこと 要件の整理は、これまでも小さな範囲ではやったことがありました。ただ、いくつもの要件が絡み合う整理や、システム全体にかかわるDBの設計は今回が初めてで、設計の段階では以下のような点につまずきました。 AIが作った設計を判断できなかった Claude Codeと一緒にDBの設計を始めると、設計書もテーブル定義の案も、あっという間にできあがりました。 ところが、量が増えるにつれて私の理解が追いつかなくなりました。見直してみると、文書ごとに同じテーブルの説明が食い違っていたり、今の社内システムに実際にはある機能を「無い」と書いていたりする箇所がいくつも見つかりました😭 どんな画面で何を操作するのかは、なんとなくイメージできていましたが、それをもとにデータの持ち方を決めるところまでは考えられていませんでした。 何が正しいかを自分で決められないまま、AIが出してくるものを受け取ってしまってました... やりたいことが広がりすぎた 業務を知れば知るほど、直したい所も増えていきました。気づけば、社内システムの作り直しの中に、データ変換の仕組みの作り直しまで入れて考えていて、本来やりたかった改善から離れていました。 ひとりで考え込んでしまった 要件を整理して、どのDBがあるべき状態なのかということをしばらくひとりで考えていました。 AIと話していると設計はどんどん進むので、人に相談しなくても何とかなる気がしており、なかなか相談できずに進めてしまってました。 どう乗り越えたか それぞれのつまずきに、次のように向き合いました。 自分で判断できるように、進め方を決めた AIが作った設計を判断できなかったのは、自分の理解より先に、AIがどんどん先へ進んでしまっていたからでした。そこで、自分の理解に合わせて進むように、Claude Codeとの進め方を決めました。 画面の操作から決める : 利用者が画面で何をするかを先に決め、それをもとにAPI、DBの順に決める 判断は1つずつ : 決めることは、1回に1つだけ聞いてもらう 説明できるまで決めない : 理由を自分の言葉で説明できない判断は、決まったことにしない 今の作りは確かめてから書く : 今の社内システムについて書くときは、実際のコードを読んでから書く どれも、自分の理解を置いていかないための決めごとです。この形で進めるようにしてからは、大きくつまずくことなく、一歩ずつ進められるようになりました。 AIと一緒に設計していて一番つらかったのは、一度に大量の情報が出てきて、頭がパンクしてしまうことでした。 1回に1つずつ、自分で説明できるところまで確かめながら進めることで、それがかなり軽くなったのだと思います。 広がった範囲を、目的に合わせて絞った やりたいことが広がりすぎていることには、自分ではなかなか気づけませんでした。気づけたのは、朝会で「全部を良くしようとして範囲が広がっている」と言われたときです。 そこで、目的の「ビジネス側が迷わず設定できる社内システム」に焦点を当て直して、そのために必要なものだけに範囲を絞りました。 ひとりで抱えず、考えをチームに見せた ひとりで考え込むのをやめて、自分が理想だと思うDBの形をいったん作り、チームに見てもらう場を作りました。 話してみると、自分が考えていなかったことがいくつも出てきました。たとえば「このシステムを、いずれ社外の企業にも使ってもらうとしたらどうなるか」という問いは、社内で使うことしか考えていなかった私からは出てこないものでした。今は社外向けの仕組みは作らないと決めましたが、必要になったときに広げられる形にはしてあります。 設計を終えて実装へ その後は、設計で決めたことを1件ずつ記録に残しながら進められるようになり、DB、API、画面の設計を終えて、今は実装を進めています。 AIの時代に大切だと感じたこと 業務を知ることは、やはり大事でした。今回も、社内システムの前後の作業まで聞いていたおかげで、設定の持ち方を業務に合わせて決められました。 Uberでも、エンジニアを財務や人事などの部署に2週間ずつ送り込み、社員の作業を横で見ながら仕組みを作り直していると、Business Insiderで報じられています。 https://www.aol.com/articles/ubers-cto-embedded-top-ai-152402016.html ただ、業務を知っているだけでは足りませんでした。ここから書くことは、エンジニアとしては当たり前のことかもしれませんが、AIで何でも速く進められるようになった今、改めて大事だと感じたことです。 設計の進め方を持っておく AIは設計書をすぐに書いてくれますが、それが正しいかを決めるのは自分です。何から順に決めるかという進め方を持っていないと、AIが出したものをそのまま受け入れてしまいます。 今回の私が、まさにそうでした。設計の進め方や、それを支える設計の知識は、これからも積極的に身につけていきたいと思います。 人に見てもらう 範囲が広がっていることに気づけたのは、AIとのやり取りではなく、朝会での一言でした。AIと話していると、ひとりでも議論が進んでいるように感じます。でも、自分の考えの外にあることは、AIを使ってもなかなか出てきません。経験の豊富なシニアエンジニアなどからアドバイスをいただいて、何を意識すべきかを学ぶことが必要だと感じました。 チームで一緒に考える AIを使うと手が速くなるので、ひとりでかなり先まで進められてしまいます。今回、考えをいったん形にしてチームに見せたことで、自分では出てこなかった問いをもらえました。もっと早く見せていれば、遠回りせずに済んだと思います。 おわりに 新卒2年目の私にとって、ヒアリングから設計までを一通り任せてもらえたことは、非常に大きな経験だったと感じました! つまずいたときも、朝会や夕会でチームから適宜アドバイスをいただいたおかげで、設計の進め方や人と一緒に進めるやり方を身をもって学ぶことができました! 今はここまでの設計をもとに、AIで開発するための環境を整えて実装を進めており、環境づくりには、弊社のエンジニアが書いた次の記事を参考にしています。 https://tech.every.tv/entry/2026/09/18/120200 今後は、社内システムだけでなくビジネス側の業務全体まで理解を広げ、改善する範囲も広げていき売り上げに貢献できるエンジニアに成長していきたいと考えています。 実装が一区切りしたら、そこで得た知見もまた記事で共有したいと思います。 最後までお読みいただきありがとうございました!
はじめに こんにちは!株式会社エブリーで1ヶ月間、iOS エンジニアとしてインターンをしていた木村風葉です。ヘルシカの iOS チームで、食事記録まわりの改善や無料会員向けの機能追加など、いくつかの機能開発に取り組みました。 この記事では、インターンの後半に取り組んだ「 Jev 」という新しいAIを使った検証ツールづくりについて書きます。Jevは、文章を生成する代わりに「選択肢のどれを選ぶか」を確率付きで返すAIです。これを使ってiOSシミュレータ上のアプリを自動で触り回らせ、不具合を探す仕組みを作りました。 なお、ヘルシカの開発チームでは、AI コーディングツールの Claude Code を日常的に使っています。この記事で Jev と比べている「Claude」は、基本的にこの Claude Code を指します。 はじめに Jev とは 作ったもの:jev-qa きっかけ 構成 1ステップの流れ Jev の判断をそのまま使わない 探索モード:ゴールを決めずに触り回る 気になる点を「スクショの名前」で記録する 「123456 の次は 123457」を試せる探索 比較と評価 「Claude がやったほうが早くない?」問題 料金 Claude と Jev の使い分け 見つかったもの 課題 失敗談:課金画面の購入ボタンを押しかけた 「わかりにくい表示」の判定が当てにならない まとめ 参考資料 Jev とは Jev は、TypeSafe AI が開発した「System One Model」と呼ばれる AI モデルです。ChatGPT や Claude のような LLM(大規模言語モデル)は文章を生成しますが、Jev は文章を生成しません。 状態(state)と、型の決まった質問(questions)を渡すと、それぞれの質問に対する答えを確率付きで返します。 docs.typesafe.ai 質問の型は次の3つです。 型 返ってくるもの 例 noul 真である確率(0〜1) 「エラーが表示されているか」→ 0.93 choice 選んだ選択肢と、全選択肢の確率 「次に押すボタンはどれか」→ 「続行(3/4)」 score 段階評価と、各段階の確率 「健康的な度合い(悪い/普通/良い)」 たとえば、1日の食事内容を渡して「栄養バランスが取れているか」「一番の課題は何か」を聞くと、次のような形で答えが返ってきます(イメージです)。 { " answers ": { " is_balanced ": { " noul ": 0.12 } , " main_issue ": { " choice ": " veg ", " confidence ": 0.81 , " probabilities ": { " carb ": 0.11 , " fat ": 0.08 , " veg ": 0.81 } } } } 主な特徴は次のとおりです。 速い : 1回の判断が数百ミリ秒程度で返ってきます 安い : 入力は 100万トークンあたり $0.042 で、出力は無料です(2026年10月時点) 入力はテキストだけ : 画像は渡せません 今回は、社内で検証用に一時的に用意していただいた環境を使って試しました。 作ったもの:jev-qa きっかけ 検証用の環境を用意していただいて最初に思いついたのは、 アプリの動作確認 です。オンボーディング(初回の登録の流れ)を毎回手で進めるのが大変だったので、「Jev に画面を判断させながら、高速に操作させられないか」と考えました。 ただ、オンボーディングは画面の順番が決まっています。決まった手順を毎回 AI に判断させるより、手順を書いたテストスクリプト(XCUITest など)のほうが速くて確実です。そこで、目的を次のように広げました。 オンボーディングをjevで通せるなら、その先の画面も触らせて、不具合探しにも使えるのでは? 手順が決まっていない状態でアプリ中を触り回るのであれば、毎回その場で判断が必要になります。ここなら、速くて安い Jev の出番があるのではないかと考えました。 構成 作ったツールは jev-qa と名付けました。(今思えばヘルシカに限定した名前の方が良かったかも...) 主な構成は次のとおりです。 AXe : iOS シミュレータの画面情報を読み取り、タップや入力を行う CLI ツール jev_qa.py : 画面の情報を Jev に渡し、返ってきた判断をもとに操作する Python スクリプト 設定ファイル(YAML) : アプリ固有のルールや、検証したい操作の流れ(シナリオ) github.com 1ステップの流れ Jev には画像を渡せないので、スクリーンショットではなく アクセシビリティの情報 (画面上のボタンや文字の情報をテキストにしたもの)を渡します。1ステップの流れは次のとおりです。 1ステップの流れ Jev には、1回の呼び出しで次の質問をまとめて聞いています。 質問 型 答え operation choice タップ/入力/下にスクロール/上にスクロール/戻る/待つ/完了/行き詰まり target choice 操作する要素の番号 is_error_shown noul エラー表示が出ているか goal_achieved noul ゴールを達成したか ここでハマったのが、 Jev は質問ごとに別々に判断していて、ほかの質問の選択肢を見ていない という点です。最初は要素の一覧を target の選択肢にだけ入れていたため、 operation を判断するときに画面の中身が分かっていませんでした。そのため、要素の一覧は状態(state)にも入れるようにしました。 Jev の判断をそのまま使わない 動かしているうちに、Jev の判断をそのまま使うと困る場面がいくつも出てきました。たとえば、 同じボタンを押し続ける、自信が無いのに「戻る」を選ぶ 、といった動きです。そこで、ツール側で次のような補正をかけるようにしました。 自信度が 0.7 未満の「戻る」は実行しない 画面が変わらなかったのに同じ要素を押そうとしたら、次に確率が高い要素にする 購入・削除・退会・サインインなどのボタンは押さない 課金画面(paywall)が出たら、Jev に判断させずに左上の × を押す また、Jev が「ゴールを達成した」と答えても、それだけでは完了にしないようにしました。自己申告だけで完了にすると、失敗しているのに完了扱いになることが連発したためです。代わりに、シナリオの段階ごとに「この画面にこの文字が出ていたら達成」という条件( done_when )を書き、ツールが確認します。 シナリオの例(イメージ) name : 体重を記録する start : relaunch : true # アプリを再起動してから始める screen : home inputs : [ "60.5" ] # 入力欄に入れてよい値 stages : - goal : 体重の記録画面を開く done_when : screen : weight_record max_steps : 6 - goal : 体重を 60.5 kg で保存する done_when : texts_all : [ "60.5" ] screen : home max_steps : 8 ゴールを段階に分けたのは、Jev が「この画面で次に何を押すか」を選ぶのは得意な一方、長い手順の中で「今どこにいて、何をすべきか」を判断するのは苦手だったためです。判断させる範囲を小さくするほど、動きが安定しました。 こうして、体重の記録、運動の記録、設定画面の行き来、日付の切り替えなど、いくつかのシナリオを通せるようになりました。 jev-qa が運動記録にウォーキング30分を追加している様子 探索モード:ゴールを決めずに触り回る 決まったフローの確認よりも Jev が活きそうなのが、 ゴールを決めずにアプリ中を触り回る探索 です。秒数を指定すると、まだ押していないボタンを優先して押し続けるモードを作りました。 python3 jev_qa.py --explore 240 # 240秒、ゴールを決めずにアプリ中を触る 固まった、アプリの外に出た、完全に行き詰まった、といった場合は、アプリを再起動して続けます。最初の4分間の探索では、83ステップで65種類のボタンを触りました。 気になる点を「スクショの名前」で記録する 探索の目的の1つは、不具合や、ユーザーにとってわかりにくい表示を見つけることです。ただ、Jev は文章を出せないので、「何がおかしいか」を説明させることはできません。そこで、 気になる点があったら、スクリーンショットを ISSUE_<種類>_step<番号>.png という名前で残す ことにしました。何かあったことさえ分かれば、あとから人や Claude が見て判断できます。 種類 判定する方法 エラー表示・ わかりにくい表示 Jev( noul の確率が高いとき) ボタンの読み上げ名が画像のファイル名になっている、仮の値( null など)が出ている スクリプト 固まった・アプリの外に出た・行き詰まった ツール ここでいう 「わかりにくい表示」 は、Jev に「画面に、ユーザーにとってわかりにくい・不自然な表示があるか」を noul で聞き、その確率が高かったときに記録するものです。質問文には、説明のない数値や記号、意味の通じない文言、不自然な空欄などを例として書いています。 「123456 の次は 123457」を試せる探索 探索を何回かに分けて流すと、毎回同じところばかり触ってしまいます。そこで、画面と操作の記録を、実行をまたいで残すようにしました。 最初は「一度押したボタンは次から避ける」という単純な仕組みにしました。しかし、それでは不十分でした。たとえば、ボタン 1→2→3→4→5→6 と進んだ後、6 の代わりに 7 を押すルート(1→2→3→4→5→7)も試してほしいのに、1 が「押した」扱いになると、その先の分岐を試せなくなります。 そこで、画面と遷移をグラフ(画面A --[押したボタン]--> 画面B)として記録し、次のルールで選ぶようにしました。 分岐を残したまま「済み」にしない探索 その画面に、まだ押していないボタンがあれば、それを押す 全部押し済みなら、 押した先に未探索の分岐が一番近く残っているボタン をもう一度押す 先が全部探索済みなら、戻るかスクロールする 途中のボタンは、その先がすべて探索済みになるまで「済み」にしません。これで、1→2→3→4→5→6 を試した後に、1〜5 を通り直して 7 を試す動きになりました。 比較と評価 「Claude がやったほうが早くない?」問題 シナリオが通るようになってきた一方で、運動の記録(ウォーキング30分を追加する)シナリオを動かしたとき、 1回に47秒 かかりました。オンボーディングのときより、1つ1つの操作が明らかに遅く感じました。 試しに、Jev を使わずに Claude に同じシナリオの操作をさせてみたところ、 19.5秒 で終わりました。正直、「 これなら Jev を使う意味なくない? 」と思いました。 まず、どこに時間がかかっているのかを測りました。 Jev の判断 操作 画面の変化待ち 1ステップの平均 0.33秒 1.16秒 2.01秒 Jev の判断は1ステップあたり0.3秒ほどで、全体の1割程度でした。時間の大半は、画面の読み取りと操作、 つまり Jev 以外の部分でかかっていました。 原因を調べると、AXe がタップのたびに画面の向きを確認していて約0.6秒かかることや、画面の読み取りが要素の数に比例して遅くなることが分かりました。入力の手順を整理したり、操作後の固定の待ち時間をやめて画面の変化を見て進むようにしたりして、運動の記録のシナリオは 47.1秒 → 約33秒 になりました。 また、Claude の19.5秒は、何度も動かして画面の流れやボタンの名前を把握したうえで、 あらかじめ完璧な手順を書いたスクリプト を実行した時間でした。初めて見る画面で同じことはできません。Claude が1手ずつ画面を見て判断する場合は、1手ごとに数秒以上かかり、費用も Jev より桁違いに高額になります。 整理すると、次のようになります。 やり方 速さ 初めて見る画面 画面が変わったとき 費用 決め打ちのスクリプト ◎ × 事前に作る必要がある × 壊れる ほぼ0 Jev が1手ずつ判断 ○ ○ ○ ほぼ0 Claude が1手ずつ判断 △ ◎ ◎ 高い ここから、 決まった操作フローの確認なら決め打ちのスクリプトのほうがよく、Jev は手順が決まっていない「探索」に向いている 、という方向性が見えてきました。 料金 Jev は入力が安く、出力が無料なので、料金はほとんどかかりませんでした(1ドル150円で計算)。 実行 入力トークン 料金 1ステップ 約1,500 約0.01円 シナリオ1本(4〜9ステップ) 7,500〜17,000 約0.05〜0.1円 探索 4分 12.5万〜14.5万 約0.8〜0.9円 4分間アプリを触り回って1円以内 です。シナリオ6本を1日10回流しても、月に200円もかかりません。(もちろん1日10回回す必要はないですが...) Claude と Jev の使い分け 最後に、Jev を使わずに Claude だけで2分間の探索をさせ、Jev と比べてみました。 Claude が直接操作 Jev による探索 1操作あたり 約7秒 約2〜3秒 2分で触れた数 15操作 40〜50操作 料金 Claude の利用量 約0.5円 見つけやすいもの 意味を伴う違和感(表示の順番、仕様と合っているか など) エラー表示、固まる、行き詰まる この結果から、次のような使い分けがよいと考えています。 決まった操作フローの確認 : 決め打ちのスクリプト(速くて確実) 広く速く触って、異常を見つける : Jev(速くて安い) 見つかったものの意味を判断する、原因を調べる、シナリオを書く : Claude Jev は Claude の代わりになるものではなく、 大量に、速く、安く判断したい部分を受け持つ部品 だと感じました。 見つかったもの 数日間の試行で、手で触っていたときには気づいていなかった不具合や、仕様を確認したほうがよさそうな点がいくつか見つかりました。 見つかったものは、大きく次の3種類でした。 動作の不具合 : アプリが応答しなくなる、エラーが表示されるといった、ユーザーの操作が止まってしまうもの 表示の違和感 : 画面の状況と文言が合っていないなど、仕様として意図どおりかを確認したいもの アクセシビリティの問題 : 画面の読み上げで、ボタンの役割が伝わりにくいもの 特に役に立ったのは、普段あまり通らない操作の組み合わせで起きるものを拾えたことです。探索モードは人が普段やらないようなパターンも網羅的に走査できるので、手での確認では通りにくい道筋を自然に通ってくれました。見つけたときにはスクリーンショットと操作の記録が残っているので、再現手順をすぐに確認でき、そのまま原因の調査につなげられました。 アクセシビリティの問題は、最初から探していたわけではなく、ツールを作っている途中で気づいたものです。このツールは画面を読むのにアクセシビリティの情報を使っていて、これは iPhone の読み上げ機能(VoiceOver)が読み上げる情報と同じです。つまり、ツールで読んだボタンの名前が画像のファイル名になっていれば、VoiceOver でもそのまま読み上げられているということです。ボタンに意味のある名前が付いていれば、Jev や XCUITest もボタンを見分けやすくなるので、テストのしやすさにもつながると感じました。 課題 失敗談:課金画面の購入ボタンを押しかけた 探索モードでは、ヒヤッとすることもありました。 探索中に課金画面(paywall)が開いたのですが、Webで表示される画面だったため中身の読み込みがツールの確認より遅れ、ツールが課金画面に気づけませんでした。ツールは後ろにあるホーム画面の情報をもとにボタンを押すため、その位置によっては課金画面の購入ボタンを押す可能性があります。 シミュレータはAppleアカウントにサインインしていなかったので実際に有料プランの購入を完了させることはありませんが、AIに操作させるときは「押してはいけないものを押す」前提で守りを固める必要がある、と実感しました。対策として、次の3つを入れました。 押す直前に、押す位置の要素が今の画面の情報に含まれているかを確かめる。含まれていなければ押さない サインイン画面が出たら、Jev に判断させず「キャンセル」だけを押す 探索モードでは、課金画面の入口になるボタンを押さない 「わかりにくい表示」の判定が当てにならない Jev による「わかりにくい表示」の判定は誤検知が多く、正直なところあまり役に立ちませんでした。一方で、エラー表示の判定や、スクリプトでの判定は役に立ちました。表示の意味まで踏み込んだ判断は、今のところ Claude や人が見たほうが確実です。ツールの実行をClaudeに持たせればツールの検知した「わかりにくい表示」が誤検知かどうかQA後に判定してくれるので、そのような実行方法の方が振り返りやすいと思います。 まとめ Jev を使って、iOS シミュレータ上のアプリを自動で触り回る検証ツールを作りました。 取り組みを通じて、次のことを学びました。 AI に操作させるときの速さは、AI の判断以外で決まることが多い 。推測せずに測ったら、Jev の判断は全体の1割程度でした AI に判断させる範囲は小さくする 。長いゴールを段階に分けると、動きが安定しました AI の自己申告をそのまま信じない 。完了の判定や、押してはいけないボタンは、ツール側のルールで決めます 道具ごとに得意なことが違う 。決め打ちのスクリプト・Jev・Claude を役割で使い分けるのがよいと考えています 今後は、Jev がアプリを操作し、失敗したときは Claude が原因を調べて設定を直す、という仕組みも作ってみたいです。その際は、テストを通すために Claude が合格の条件を緩めてしまわないよう、Claude に直させてよい範囲を決めておく必要があります。 エブリーのインターンでは、複数の機能開発だけでなく、新しい技術を試して開発のやり方そのものを改善する取り組みにも挑戦できました。実際にチームに入り、新機能の実装からリリースまでの一連の流れに関われたのは、とても貴重な機会でした。 社員の皆さんは明るく接しやすい方ばかりで、気軽にランチにも誘っていただきました。おかげで、楽しく充実した1ヶ月を過ごせました。大変お世話になりました。 新しい技術を試しながらプロダクトの開発に関わりたい方は、ぜひエブリーのインターンに応募してみてください! ※28卒インターンの募集は終了しました 参考資料 docs.typesafe.ai thenewstack.io github.com
Claude Code ぶん回して仕様駆動開発を実践してみた 〜 設計書3.6万行と6つの落とし穴 〜 この記事のポイント 問題提起 背景 前提 システムの全体像 開発体制と Claude Code の使い方 設計時のぶん回し 設計書を Fix してから実装する 依頼は「まず既存の仕組みを調べて」から始める 選択肢は「影響範囲」の列を立てて比べる 決まったことは設計書の冒頭とメモリに残す アーキテクチャ選定で分かったこと 画面設計:Excel と MD3 の方針だけで作る 実装時のぶん回し 実装計画も、人間がレビューしてから走らせる テストは先に書かせ、失敗を見てから実装させる OpenAPI と design.md で「正」を分ける インフラ:検証環境と本番環境の差異に苦戦 新本番環境想定の負荷試験をやり切る アプリ動作確認:エミュレータを Claude Code に操作させる 実装計画に「セルフフィードバック」を組み込む Crashlytics の調査 問題点と解決策 問題点 1:尤もらしい誤りと「存在しない懸念」が紛れ込む 解決策 1:「推測」と「確定」を分けて書かせ、出典を残させる 問題点 2:AI は足し算が得意で、引き算が苦手 解決策 2:方針とスコープで「引き算」を仕組みにする 問題点 3:ドキュメントが膨大になり、人間のレビューが追いつかない 解決策 3:人間が読むもの(design.md)と AI が読むもの(plan.md)を分ける 問題点 4:設計書と実装がズレていく 解決策 4:実装結果を設計書に書き戻すまでを「完了」にする 問題点 5:リポジトリやセッションをまたぐと前提が抜け落ちる 解決策 5:共有メモリと HANDOFF.md でコンテキストを受け渡す 問題点 6:Git 操作で作業ツリーやブランチ運用を壊しかける 解決策 6:Git のルールを CLAUDE.md に明文化し、コミットは人間が握る まとめ 問題提起について 誰が読んでも共通のドメイン知識を持てる product-docs 最後に  こんにちは、開発本部 開発2部 RetailHUB NetSuperグループに所属するホーク🦅アイ👁️( @every_hawk_eyes )です。本記事で紹介するプロジェクトでは、エンジニアリーダーとしてフロントエンド・バックエンド・インフラ・アプリ開発のすべてに関わりながら、プロダクトマネジメントも少し担っていました。 この記事のポイント 2026 年 6〜8 月の 3 か月間、3〜7 名のチームで、ネットスーパーの Server API、AWS基盤、ピッキングアプリを、Claude Code を中心に新規開発 設計書を Claude Code に書かせ、人間がレビューで Fix してから実装する、仕様駆動開発(SDD)に近い進め方をとった。1 つの機能だけで設計書・計画書は約 3.6 万行に達し、ボトルネックは人間のレビューへ デザイナー不在の中、注釈付き既存画面スクショと Material Design 3 の方針だけでアプリ開発 AI の尤もらしい誤り、足し算の設計、膨らむドキュメント、設計書と実装のズレ、コンテキストの断絶、Git 事故という 6 つの落とし穴を、解決策と併せて紹介 問題提起 AI に任せてもそれなりのものが作れる時代でも、平準化されないエンジニアの価値とは、 AI と質疑を重ねて設計を詰め切る力と、チーム内議論での発言力・決断力ではないか AI の尤もらしい誤りを疑うためのドメイン知識とシステム全体の理解を得るには、 誰が読んでも共通のドメイン知識を持てるドキュメント整備が肝要なのではないか 背景  弊社では Claude を非エンジニアも含めた全社に展開しており、我々のチームでも今年 3 月に Cursor から Claude Code へ移行しました。  2026 年 6〜8 月には、新しい小売チェーン向けにネットスーパーを立ち上げる新規開発がありました。Server API、AWS インフラ、店舗の作業者が使う Android のピッキングアプリまでを、最小 3 名、いちばん多い時期でも 7 名(1 か月間のみ)で作る必要があり、アプリのデザイナーもいません。そこで、設計から実装・テスト・障害調査まで、Claude Code を「ぶん回す」前提で進めました。  本記事では、うまくいったやり方に加えて、 現場で実際に起きた問題点と、それに対する解決策について を紹介します。 前提 システムの全体像 システムの全体像。既存の小売は、別のクラウドサービスの VM で動くネットスーパー本体(PHP)と DB を使う。新規小売は、AWS に移したネットスーパー本体(PHP / ECS Fargate)と RDS を使う。両方の本体から Go の Server API を呼び出し、Server API は両方の DB を読み書きする。ネットスーパーから独立したピッキングシステムは、Server API から注文データを受け取って Aurora DSQL に保存し、Server API へは EventBridge のカスタムバスと API Destination で非同期に連携する  ネットスーパー本体は、別のクラウドサービスの VM で長年動いてきた PHP のシステムです。本体は極力改修しない方針で、新しい処理は Go の Server API に寄せています。今回は、主に次の 2 つの対応が必要でした。 インフラを AWS に 1 本化するための、ネットスーパー本体の AWS へのマイグレーション。今回の新規小売が、移行先の AWS で本番を立ち上げる最初の小売になった(9 月 1 日にローンチ) ネットスーパーとは独立したプロダクトとして開発しているピッキングシステムに、注文単位でピッキングする新モードを追加する開発  呼び出しの向きは「ネットスーパー本体 → Server API → ピッキングサーバー」を基本とし、逆向きは EventBridge 経由の非同期に限っています。1 つの機能が 4〜5 リポジトリにまたがる構成です。 開発体制と Claude Code の使い方 項目 内容 人数 最小 3 名〜最大 7 名(7 名体制は 1 か月間のみ)。インフラは専任を置かず全員が兼務 設計書 product-docs(Markdown の設計書を GitHub で管理するリポジトリ。名前は本記事での仮のもの) デザイナー 不在(Figma などのデザインツールも不使用) Claude Code VSCode 拡張版やターミナル経由。プラグインは superpowers を中心に使用  Claude Code は次のように設定して使いました。 メインのセッションは、その時点の最新の上位モデルに、Effort を Extra high にして実行していた。サブエージェントのモデルは特に指定せず、Claude Code の既定に任せました 2026 年 8 月 14 日に Auto モードが default 化されてからは、常に Auto モードでプロンプトを実行してました。それに合わせて、以前の記事 で紹介した Hooks による macOS の通知は、Claude Code から質問が来たとき(AskUserQuestion)だけ鳴るように変えました それまでは「Edit automatically」で使いながら、 .claude/settings.json の許可ルールを都度追加して整えてきたので、Auto モードに切り替えることに不安はありませんでした  ツールの構成や挙動は、2026 年 6〜8 月時点のものです。 設計時のぶん回し 設計書を Fix してから実装する  今回いちばん効果があったのは、 いきなりコードを書かせない ことでした。複数リポジトリにまたがる機能をそのまま実装させると、仕様の解釈違いや影響範囲の見落としが後から大量に出てきます。そこで、superpowers のワークフローに乗せて、次の流れで進めました。 ① 要件と関連資料を渡し、「まず既存の仕組みを調べて」と頼む ② brainstorming:コード調査 → 質問 → 選択肢の比較 → 人間が方針を選ぶ ③ 設計書(DD)を書かせ、チームがレビューして Fix する ④ writing-plans:実装計画(PD)を実装リポジトリごとに作らせ、人間がレビューする ⑤ TDD とタスクごとのセルフレビューで実装させる ⑥ 実装で判明した差分を DD / PD に書き戻させる  これはいわゆる仕様駆動開発(SDD)です。Birgitta Böckeler 氏の整理でいえば、実装後も設計書に書き戻して保守に使い続ける「spec-anchored」に近い運用です。superpowers の基本のワークフローとの違いは、設計書を専用のリポジトリに置いてチームでレビューすることと、実装計画を実装リポジトリごとに分けることです。 参考: Understanding Spec-Driven-Development: Kiro, spec-kit, and Tessl (Birgitta Böckeler 氏、2025 年 10 月 15 日。SDD を spec-first / spec-anchored / spec-as-source の 3 段階に整理しています) 参考: obra/superpowers (GitHub。README の「The Basic Workflow」に各スキルの役割が書かれています)   AI に任せるのは「調べる・書く」まで、「決める」のは人間 と線を引き、設計書も実装計画も、人間が OK を出すまで次の工程に進ませないことがポイントです。 依頼は「まず既存の仕組みを調べて」から始める  「こう作ってほしい」と実装方法から指示するより、 先に既存の仕組みを調べさせる ほうが、既存の作りに沿った無理のない案が出てきます。 選択肢は「影響範囲」の列を立てて比べる  方針を決める前に、2〜3 個の選択肢を「処理の流れ・メリット・デメリット」付きで並べさせ、人間が選びます。判断が割れる論点は、ここでチームでも議論します。比較の表には 変更対象の影響範囲 の列も必ず立てさせています。「1 行追加するだけ」の改修案が、実は 10 以上の小売が共有し、毎日使われる画面への変更だったことがあるためです。 決まったことは設計書の冒頭とメモリに残す  議論で決まったことがチャットに埋もれていると、AI は次のセッションでそれを参照できません。設計書の冒頭に「決定事項サマリー」を置き、レビューで受けた指摘は「なぜ」と「次からどうするか」を付けて Claude Code のメモリに残すようにしました。こうしておくと、一度受けた指摘を毎回言い直す必要がなくなります。 アーキテクチャ選定で分かったこと  EventBridge を使った 2 つの仕組み(バッチの起動と、ピッキングサーバーからの非同期連携)は、どちらも選択肢を並べて比べ、 採用しなかった案とその理由 を設計書に残しました。その中で分かったことは次の 3 つです。 前提から見直す。NAT Gateway の新設コストを避けるためにバッチのタスクをパブリックサブネットに置いていたが、見直すと NAT Gateway はすでにあり、そもそもの前提が成り立っていなかった 非同期にするなら、受け手の作法まで決める。EventBridge の API Destination は応答待ちが最大 5 秒で、リトライするのは 5xx と一部の 4xx だけなので、受け手は 5 秒以内に返し、再送しても直らないエラーはリトライされない 4xx で返す。同じイベントが何度届いても結果が変わらないようにもしておく 非同期がいつも正解ではない。ECS のタスク起動に 1 分ほどかかるため、データ連携の開始時に台帳を作る処理は同期にした 参考: Amazon EventBridge のターゲットとしての API 送信先 (AWS ドキュメント。5 秒のタイムアウトと、リトライするエラーコードの一覧が載っています) 画面設計:Excel と MD3 の方針だけで作る  デザイナーもデザインツールも使わず、インプットは既存アプリのスクショに要望の吹き出しを描き足した Excel と、Material Design 3(MD3)に沿うという方針だけでした。Claude Code は画像を読めるので、スクショを渡して画面ごとの設計書に MD3 のコンポーネント、色・文字のトークン、アクセシビリティまで書かせ、アプリ開発エンジニアがレビューしてから実装しました。これでデザイナー不在でも「エンジニアのなんとなくの見た目」にはなりませんでしたが、 現場の作業者が使う業務アプリ だから成立した面も大きいです。 実装時のぶん回し 実装計画も、人間がレビューしてから走らせる  設計書が Fix したら、superpowers の writing-plans で実装計画 plan.md を作ります。作業を 1 ステップ 2〜5 分の単位に分け、ファイル・コード・テストの方法まで書く方針なので、1 本で 2,000 行を超えることも珍しくありません。plan.md も 人間がレビューしてから実装に入ります 。plan.md を書き終えるなり実装を始めさせると、ファイルの数・タスクの順序・コミットの粒度といった、こちらの方針が反映されるべき判断を後から直すコストが大きいためです。 テストは先に書かせ、失敗を見てから実装させる  AI は「テストが通るように実装を寄せる」だけでなく「実装に合わせてテストを直す」こともやりがちなので、 先に失敗を見る 手順を挟むことが大事でした。 OpenAPI と design.md で「正」を分ける  API の型は OpenAPI、業務的な意味は design.md を正とすることで、サーバー担当とアプリ担当が I/F だけ先に合意し、それぞれ Claude Code で並行して実装を進められました。 インフラ:検証環境と本番環境の差異に苦戦  移行先の AWS の本番環境は、Claude Code に 今春既に構築済の検証環境 の Terraform を棚卸しさせ、「検証環境 のこのリソース → 本番ではこの名前・この設定で作る」という対応表を設計書にまとめさせたうえで、約 2 週間で構築しました。この期間は単に AWS リソース作成だけで終わらず、セキュリティ対策や、本番環境特有の WEB アプリケーション側の設定ファイルの設定差異を調整する作業も行いました。 新本番環境想定の負荷試験をやり切る  ローンチ前の負荷試験を1人でやり切る必要があったためここでも Claude Code をぶん回してやり切りました。 目標値は推定ではなく、既存の小売の実アクセスや過去の注文実績データから出しました 環境はコスト節約のため AWS リソース適用を 3 モードで切り替えられるように工夫しました ワーカーの枯渇を見逃さないように外部依存のモックには本番相当の遅延を入れるようにしました ボトルネックは Web ではなく DB という結果が得られ、インデックスの欠落を見つけることができました アプリ動作確認:エミュレータを Claude Code に操作させる  画面をまたぐ動作確認は、Claude Code に Android エミュレータを adb で操作させ、スクリーンショットを見て判定させました。弊社のテックブログには、iOS シミュレータと Maestro MCP で試した記事 もありますが、今回は MCP を使わず素の adb です。カメラを使う画面も、エミュレータの仮想カメラに QR コードの画像を貼れば、実機なしで確認できます。一方で、ラベルプリンターやハンディ端末のスキャナーはエミュレータでは確かめられないので、widget テストと人間による実機確認で補いました。 参考: カメラのサポート (Android Studio のドキュメント。エミュレータの仮想シーンに QR コードなどの画像を取り込む手順が載っています) 実装計画に「セルフフィードバック」を組み込む  実装計画の最後には、ブランチ全体の差分をレビューさせるタスクを毎回入れ、 設計書に書かれていない実装上の判断 (失敗時の扱い、エッジケース、競合、再試行など)を重点的に見させています。人間のレビュー指摘が、設計書で合意済みの部分よりもこうした隙間に集中していたためです。このレビューで、「何も選ばずに適用すると全件分の重いリクエストが飛ぶ」という設計書自体の欠陥が見つかったこともあります。 Crashlytics の調査  Firebase MCP 経由で Crashlytics の FATAL を調べさせると、 別々に見えていた 3 件(計 69 events)が、1 つの共通実装に起因している ことを突き止めてくれました。 問題点と解決策  ここからが本題です。3 か月ぶん回してみて実際に困ったことと、取り入れた解決策を組にして紹介します。 問題点 1:尤もらしい誤りと「存在しない懸念」が紛れ込む  AI が書いた設計書は体裁が整っていて、それらしく読めてしまいます。しかし実際のレビューでは、指摘に対して次のような回答が返ってくる場面がありました。 AI が出したけど誤っていた内容で、削除するのを失念していました。 AI が「こういう問題が起こりうる」と言い始めて、調査したらそんなものはなかった、というやり取りがあって、そのやり取りをわざわざ設計書に入れてしまっていました。  インフラでも、EFS のバーストクレジットが尽きるまでの日数や、試験環境の維持費(月 $3〜20 → 実測で月 $1.3)の見積もりが、尤もらしい根拠付きで大きく外れていました。AI は「知らないこと」を知らないとは言わず、それらしく埋めてしまいます。そして 書いた本人も AI の出力をすべて把握しきれていない まま PR が出てしまう、というのが一番怖いところでした。 解決策 1:「推測」と「確定」を分けて書かせ、出典を残させる 「 事実と推測を区別する 」をメモリにフィードバックとして残す 設計書には「未確定事項」の節を設け、確定していないことはそこに書かせる 決定事項には、いつ・どの議論で決まったかを添えさせる AI との試行錯誤の経緯は設計書に書かせない。残すのは 決まった結論と、その理由 だけ 業務の事実確認が必要な設計書は、業務を知るメンバーを必ず Reviewer に入れる  加えて、PR を出す前に「 この設計書の内容を、自分の言葉で説明できるか 」を作成者自身が確かめることが大事だと感じています。 問題点 2:AI は足し算が得意で、引き算が苦手  AI に設計させると、「この問題も起こりうるので対策を」「ここも新規 API を作って」と、機能と考慮事項がどんどん足されていきます。たとえばパッキング完了の書き戻しの設計書は、当初「受け取った側がピッキング結果を取りに行く API を新設する」案でした。これにレビューで「本当に新規で実装する必要がある? EventBridge にリクエストボディで渡せばいいのでは?」と指摘が入り、API を新設せずに済みました。AI は、工数や運用コストの観点で「そもそも作らなくていいのでは」と 引き算 をするのが苦手で、このレビューはまさにそれを人間が担った例です。 解決策 2:方針とスコープで「引き算」を仕組みにする 「無改修で済む案があるなら、既存コードを直す案より優先する」をメモリに残し、AI の提案の前提にする 判断が割れる論点は、設計書より先に選択肢比較を作らせ、影響範囲の列を見て人間が決める 設計書の冒頭には「スコープに含むもの」だけでなく「 スコープ外 」を必ず書かせ、外した論点は「残課題」の節に担当者付きで残す  「やらないこと」を書く場所をあらかじめ用意しておくと、AI が出してきた懸念を「今回はやらない、でも忘れない」と判断しやすくなります。 問題点 3:ドキュメントが膨大になり、人間のレビューが追いつかない  AI は疲れないので、設計書も計画書もいくらでも書けます。設計書の PR は 3 か月で 68 本とリポジトリ別で最も多く、注文別ピッキング関連だけで設計書・計画書は 約 3.6 万行 、最も大きい設計書 PR は 1 本で 追加 12,670 行 に達しました。  人間がこれをすべて精読するのは現実的ではなく、「書いてあるけど誰もちゃんと読んでいない」箇所から、問題点 1 の誤りがそのまま素通りしてしまいます。SDD のツールを試した Böckeler 氏も「正直、これだけの Markdown ファイルより、コードをレビューしたい」と書いています。SDD はレビューのボトルネックを消すのではなく、コードから設計書という上流へ移すのだと思います。 解決策 3:人間が読むもの(design.md)と AI が読むもの(plan.md)を分ける ドキュメント 主な読み手 内容 人間のレビュー design.md(設計書) 人間 何を・なぜ作るか(業務的な意味、方針、影響範囲、未確定事項、非ゴール) しっかり読んで議論する plan.md(実装計画) AI どうやって作るか(ファイル単位のタスク、テスト、コマンド) ファイルの数・タスクの順序・コミットの粒度を中心に見て、OK を出してから実装させる  人間のレビュー力は有限なので、精読の優先度を design.md に置き、設計書は画面・サブ機能の単位に分けて 1 本の PR を小さく保つようにしました。実際、レビューの往復が多かった設計書 PR の上位 4 本は、 いずれも追加 400 行未満 でした。  PR が大量にある中、具体的なレビュー方針は次のように分けて実施しました。 設計書(DD) :議論すべき論点や、Fix が必要な未決事項を残した状態があっても PR を出し、チームで決めきりました 実装(編集量が大量ではないもの) :今まで通りの担当 Reviewer が全て目視でレビューをしました 実装(編集量が多いもの) :AI にレビューさせました。ただし任せきりにはせず、結果をまず Reviewer へ報告させ、その内容を吟味し AI と会話して、コメント投稿させていました 問題点 4:設計書と実装がズレていく  実装して初めてわかる仕様の穴は、必ず出てきます。実装中に Claude Code が「計画にない判断」をして解決すると、それが設計書に戻ってきません。設計書が古くなると、次の設計で AI がその古い前提を読み込んでしまい、設計駆動の良さが逆に仇になります。 解決策 4:実装結果を設計書に書き戻すまでを「完了」にする 実装が終わったら「計画からの逸脱」「実装で判明した仕様の穴」「残課題」を設計書に追記するところまでを、タスクの完了条件にする 設計書の冒頭にステータス欄(「実装済(日付)」「未リリース」など)を置き、実装のたびに更新させる  なぜ計画から外れたかを一番よく知っているのは、実装したセッションの Claude Code なので、その場で書き戻させるのが最も安上がりです。 問題点 5:リポジトリやセッションをまたぐと前提が抜け落ちる  1 つの機能が複数のリポジトリにまたがるため、A リポジトリで決めたことを B リポジトリの Claude Code セッションは知りません。長い作業では、途中でコンテキストが要約されて細部も落ちます。開発人数が増減する中で、人間同士の引き継ぎに加えて「AI への引き継ぎ」も必要になりました。 解決策 5:共有メモリと HANDOFF.md でコンテキストを受け渡す 置き場所 スコープ 内容 CLAUDE.md 全リポジトリ共通 / リポジトリ 前提・ルール(Git の禁止事項、ドキュメントの置き場所など) メモリ リポジトリ レビューで受けた指摘や過去の経緯(「なぜ」と「次からどうするか」付き) 共有メモリ リポジトリ横断 他リポジトリに影響する変更(API 仕様、DB スキーマ、インフラ、設計判断など) HANDOFF.md 作業単位 セッションをまたぐ作業の引き継ぎ(いまの状態、次にやること、引き継ぎプロンプト)  共有メモリには、作業完了時に Skill で「影響先」「内容」「対応が必要なこと」を書き出させ、関連リポジトリで作業を始めるときに読ませています。「設計書のここがまだ実装に追従していない」といった申し送りも書かせているので、問題点 4 のズレを追跡する台帳にもなります。セッションの残りが少なくなったら、その時点の状態を HANDOFF.md に書き出させてから新しいセッションに切り替えると、細部が落ちる前に区切れます。 問題点 6:Git 操作で作業ツリーやブランチ運用を壊しかける  AI は良かれと思って Git を操作します。設計書リポジトリの master への直接コミット、並行して動いていた別セッションの作業とぶつかるブランチの切り替え、別ブランチの中身を読むつもりの git checkout <ref> -- . で未コミットの 27 件のローカル変更を消す、ということが実際に起きました。未ステージの変更は Git では復元できず、残る手段は VS Code のローカル履歴だけです。 解決策 6:Git のルールを CLAUDE.md に明文化し、コミットは人間が握る ## 作業ツリーを壊さない(全リポジトリ共通・最優先) - 調査・参照のためにユーザの作業ツリーへ書き込むコマンドを実行しない。 他コミットの内容は ` git show <ref>:<path> ` / ` git worktree add ` などで読む。 - 明示的な GO なしに実行禁止: ` git checkout -- . ` / ` git restore ` / ` git reset --hard ` / ` git clean ` / ` git switch ` / ` git stash ` - 作業ツリーには常に未コミット変更がある前提で動く。触れる前に ` git status ` を見る。  あわせて、 git add -A は使わない、設計書リポジトリのコミット・push・PR は人間が行う、並行作業は git worktree add でディレクトリを分ける、といったルールもメモリに残しています。「AI を信用しない」のではなく、 AI が壊せない構造にしておく のがポイントです。 まとめ  3 か月間、少人数のチームで、Server API・インフラ・Android のピッキングアプリまでを、Claude Code をぶん回して開発しました。デザイナー不在でアプリを作り切れたのも、AWS へのマイグレーションと並走しながら本番構築から負荷試験までを回せたのも、AI 駆動ならではだと思います。  一方で、ぶん回してみてわかったのは、 AI 駆動開発のボトルネックは AI ではなく、人間のレビューと意思決定 だということです。AI は調べて書くのは圧倒的に速いですが、尤もらしい誤りを混ぜ、足し算で膨らみ、決めたことを忘れ、ときには Git を壊します。だからこそ、 設計書を先に書き、人間がレビュー・議論して Fix してから実装する 人間が読むもの(design.md)と AI が読むもの(plan.md)を分ける 決めたこと・やらないこと・ズレたことを、AI が読める Markdown に残し続ける AI が壊せない構造(ルールと worktree)を先に作っておく という「 人間が決めて、AI がぶん回す 」ための仕組みづくりが、結果的に一番の生産性向上につながりました。 問題提起について  AI に任せきっても、それほど悪くないものができあがってしまうくらい、モデルの精度は高くなっています。その中で、エンジニアの存在意義や能力の差が平準化されるのでは、という危惧もあります。ただ、今回の開発プロジェクトを振り返ると、私は 決してそうではない と実感しています。理由は 2 つあります。 AI と設計を詰め切る力は、一朝一夕では身につかない :superpowers の brainstorming で AI と質疑を繰り返し、設計を Fix させる力は簡単には身につかないと思います。その議論をチームで行うときの発言力や決定力にも、差が大きく出てくるはずです AI を疑うには、ドメインとシステムの理解が要る :ハルシネーションによる誤りや、尤もらしい実装が出てきたときに、まず疑ってセルフフィードバックできることが重要です。そのためには、プロジェクトのユビキタス言語やドメイン知識、システム全体の仕様をしっかり理解しておかなければなりません 誰が読んでも共通のドメイン知識を持てる product-docs  設計書を置いている product-docs は、AI、エンジニア、問い合わせ担当、セールス担当の 4 種の読み手に役立つことを目指したリポジトリです。Markdown を中心に、Claude Code を通して誰が読んでも共通のドメイン知識を持てるよう、都度書きためています。書き足しているのはエンジニアなので設計書が多いですが、既存システムの仕様やアーキテクチャをまとめたドキュメントもあります。  このリポジトリをチームでフル活用したおかげで、ドメイン駆動での開発が円滑に進み、スポットで参加したエンジニア 3 名にもパフォーマンスを発揮してアウトカムを出してもらえたのではないかと感じています。 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv
はじめに こんにちは 開発本部 開発1部 デリッシュリサーチでデータエンジニアをしている吉田です。 今回は、Unity CatalogのData Classificationで機密データを含むカラムを自動検出し、付与されたタグをもとにABACのcolumn maskポリシーでマスクするまでを試した話を紹介します。 注意点 本記事の検証に使ったデータは、すべてFakerで生成した架空のものです。 実在する人物や実環境のデータは含んでいません。 また、テストに使ったテーブルは検証用に新しく作ったものです。 Data Classificationは インテリジェントなスキャンでコストを最適化 とあるため、本番のテーブルでは異なる間隔でスキャンされる可能性があります。 背景 データ基盤には、メールアドレスや氏名のような機密データを含むカラムが含まれる可能性があります。 分析に使う人が増えるほど、こうしたカラムは必要な人以外にはマスクして見せる必要があります。 しかし、カラムごとに人手で対応する運用には以下のような辛さがあります。 テーブルが増えるたびに、機密データを含むカラムを人が探して印を付ける必要がある カラム名から中身が判断できないカラム(自由記述のメモなど)で抜けが出る 印を付けたあと、カラムごとにマスクの設定を追従させる必要がある そこで、機密データの検出とタグ付けを自動化するData Classificationと、タグをもとにマスクを一括適用できるABACを組み合わせ、人手を介さずにマスクまで処理できるかを調査しました。 Data Classificationとは Data Classificationは、カタログ配下のテーブルをAIエージェントがスキャンし、機密データを含むカラムを検出してgoverned tagを付ける機能です。 https://docs.databricks.com/aws/ja/data-governance/unity-catalog/data-classification カタログ単位で有効化し、スキャン対象のスキーマを絞れる 検出できるクラスは class.email_address や class.name など90種類以上ある 日本向けには class.jp_my_number (マイナンバー)と class.jp_pension_number (基礎年金番号)がある 検出結果はsystemテーブル system.data_classification.results にカラム単位で保存される 確信度(HIGH / LOW)と一致したレコードの割合を持つ 新しいテーブルやカラムは、通常は作成から24時間以内にスキャンされる 自動タグ付けを有効にする前に検出済みだったカラムは次のスキャンでタグが付き、それ以降の検出には検出と同時にタグが付く 既存テーブルへのデータ追加がいつスキャンされるかは、ドキュメントに記述がない スキャンにはサーバレスコンピュートが必要 ABACのcolumn maskポリシーとは ABAC(属性ベースのアクセス制御)は、governed tagを条件にしてrow filterやcolumn maskをカタログ、スキーマ、テーブルの単位で一括適用する仕組みです。 https://docs.databricks.com/aws/ja/data-governance/unity-catalog/abac/ ポリシーは CREATE POLICY で作成し、 MATCH COLUMNS has_tag('class.email_address') のようにタグで対象カラムを指定します。 カラムごとにマスク関数を紐付ける方式と違い、ポリシーを一度書けばあとから追加されたテーブルにも適用されます。 Data Classificationが付ける class.* のタグをそのまま条件に書けるため、検出からマスクまでを自動化できます。 検証の流れ 検証は以下の流れで行いました。 合成データ Fakerで生成した架空の会員データやイベントログを、検証専用のカタログにテーブルとして作成する ABACポリシー カラムの型ごとにマスク関数とポリシーを用意し、手動でタグを付けたカラムで動作を確認する Data Classification カタログで有効化してスキャンし、自動で付いたタグでポリシーが有効になることを確認する ドキュメント上はスキャンまで最大24時間かかるため、ポリシー側を先に手動タグで確認する順序にしています。 テストデータ 以下の9テーブルを合成データで作成しました。 値はすべて Faker('ja_JP') とseed固定の乱数で生成しています。 テーブル レコード数 確かめたいこと 値の例 users_plain 200 漢字やかなの氏名、全角の電話番号、都道府県から始まる住所、郵便番号といった日本語の値が検出されるか。ハッシュ化済みのメールカラムを誤検出しないか name : 坂本 加奈、 name_kana : タナカ ユウタ、 phone_zenkaku : 090-0000-3891、 address : 千葉県葛飾区芝浦14丁目11番7号、 email_sha256 : a56e5ab9…(64桁) events_nested 200 map や struct 、 array の中に入った機密データが検出されるか event_params : {"email": {"string_value": "rikamatsumoto@example.com", "int_value": null}, "count": {"string_value": null, "int_value": 3}} 、 geo : {"country": "日本", "region": "神奈川県", "city": "横浜市磯子区"} 、 items : [{"item_name": "サラダ", "buyer_email": "rikamatsumoto@example.com"}] misleading 200 カラム名が email で中身が整数のカラムや、自由記述のメモに紛れたメールアドレスをどう扱うか email : 142188、 memo : 配送先変更の依頼。担当は鈴木 拓真さん、連絡先pyamada@example.org。 users_sparse 500 8割がNULLのカラムでも検出されるか email : 8割がNULL、 birthday : 6割がNULLで、 0001-01-01 のような異常値も5%混ぜる jp_numbers 200 マイナンバーと基礎年金番号の形式が日本向けクラスとして検出されるか。マイナンバーは検査用数字の正誤で検出が変わるか my_number_valid : 207016914928、 my_number_invalid : 検査用数字だけ誤った12桁、 my_number_mixed : 半分のレコードだけ検査用数字が正しい12桁、 pension_number : 2419-269849 ratio_test 1,000 メールの混入率が1レコード、1%、5%、20%と低いカラムでも検出されるか one_in_1000 : 999レコードが「問題なし」などの普通の文字列で1レコードだけメール、 one_in_1000_null : 999レコードがNULLで1レコードだけメール late_arrival 100 有効化したあとに追加したテーブルがいつスキャンされるか。スキャン後にカラムを追加した場合に再評価されるか contact_email 、 full_name 、あとから追加した phone ratio_1m 1,000,000 実データ相当のレコード数で、混入率が1レコード、10レコード、0.01%、0.1%、1%のカラムが検出されるか p0001 : 100レコードだけ user979104@example.com のようなメールで、残りは普通の文字列 names_test 200 氏名の表記(よくある名前の「姓 名」、スペース無し、姓のみ、名のみ、ローマ字、「様」付き、ひらがな)で検出が変わるか common_space : 山田 舞、 romaji : Mikako Sasaki、 hiragana : はせがわ まあや メールアドレスは example.com などの予約ドメイン、電話番号は 090-0000-XXXX の帯に固定し、実在の値と衝突しないようにしています。 Fakerは項目ごとに独立した値を返すため、かなは漢字の氏名と対応せず、住所も実在しない都道府県と市区町村の組み合わせになります。 マスク関数とポリシーの作成 まず、 users_plain の email と birthday 、 jp_numbers の my_number_valid に手動でタグを付け、ポリシーの動作を確認します。 ALTER TABLE poc_data_classification.synthetic.users_plain ALTER COLUMN email SET TAGS ( ' class.email_address ' ); ポリシーが呼ぶ関数の引数の型は、対象カラムの型と一致している必要があるため、マスク関数はカラムの型ごとに用意します。 CREATE OR REPLACE FUNCTION poc_data_classification.synthetic.mask_string(v STRING) RETURNS STRING RETURN CASE WHEN v IS NULL THEN NULL WHEN v LIKE ' %@% ' THEN concat ( ' ***@ ' , substring_index(v, ' @ ' , -1 )) ELSE ' *** ' END ; ポリシーもクラスごとではなく、カラムの型ごとに作ります。 同じユーザーの同じカラムに異なるcolumn maskが解決されると、クエリがエラーになるためです。 同じ関数を同じ引数で呼ぶポリシーであれば、クラスごとに分けて1つのカラムに複数当たっても問題なく読めました。 それでも、自由記述のカラムには複数のクラスが付くことがあるので、STRINGカラム向けのクラスは OR でまとめて1本のポリシーにし、1つのカラムに解決されるマスクを常に1つにしています。 CREATE OR REPLACE POLICY mask_pii_string ON SCHEMA poc_data_classification.synthetic COLUMN MASK poc_data_classification.synthetic.mask_string TO `account users` FOR TABLES MATCH COLUMNS ( has_tag( ' class.email_address ' ) OR has_tag( ' class.phone_number ' ) OR has_tag( ' class.name ' ) OR has_tag( ' class.location ' ) OR has_tag( ' class.jp_my_number ' ) OR has_tag( ' class.jp_pension_number ' ) ) AS c ON COLUMN c; マスクの確認 ポリシーを作成した直後にSELECTすると、反映を待つことなくタグを付けたカラムだけがマスクされていました。 user_id | email | name | birthday --------+-----------------+-----------+----------- 1 | ***@example.net | 坂本 加奈 | 1900-01-01 2 | ***@example.com | 石井 陽一 | 1900-01-01 ポリシーはスキーマの所有者である自分自身にも適用されます。 次に、同じポリシーに EXCEPT で管理者グループを追加しました。 TO `account users` EXCEPT `unity-catalog-admins` 置き換えた直後に同じクエリを流すと、管理者グループに属する自分には素の値が返りました。 user_id | email | name | birthday --------+-----------------------------+-----------+----------- 1 | miturukobayashi@example.net | 坂本 加奈 | 1981-04-27 2 | naoto63@example.com | 石井 陽一 | 1972-05-31 なお、column maskポリシーが付いたテーブルではタイムトラベルが使えません。 VERSION AS OF を指定したクエリは以下のエラーになりました。 [COLUMN_MASKS_FEATURE_NOT_SUPPORTED.TIME_TRAVEL] Column mask policies for `poc_data_classification`.`synthetic`.`users_plain` are not supported: Time travel for table with column mask policies Data Classificationの有効化 手動で付けたタグを外し、Data Classificationを有効化します。 有効化はUIのほか、Databricks CLIの data-classification コマンドからも行えます。 スキャン対象を検証用のスキーマだけに絞るため、CLIで included_schemas を指定しました。 databricks data-classification create-catalog-config catalogs/poc_data_classification \ --json '{"included_schemas": {"names": ["synthetic"]}}' 有効化しただけでは検出は行われますが、タグは自動では付きません。 返ってきた設定の auto_tag_configs は空で、自動タグ付けはクラスごとに有効にする必要がありました。 databricks data-classification update-catalog-config \ catalogs/poc_data_classification/config auto_tag_configs \ --json '{"auto_tag_configs": [ {"classification_tag": "class.email_address", "auto_tagging_mode": "AUTO_TAGGING_ENABLED"}, ... ]}' このとき、governed tagとして存在する class.* のキーをすべて指定すると、 class.de_passport が検出クラスとして見つからないというエラーになりました。 governed tagのキーの一覧と検出できるクラスの一覧は一致しないため、 サポートされる分類タグ をもとに指定する必要があります。 検出結果 検出されたカラムと検出されなかった主なカラムを、確かめたかったことごとに整理します。 確信度はすべてHIGHでした。 確かめたかったこと カラム 検出クラス 一致率 結果 メールアドレス email class.email_address 1.0 検出 漢字の氏名 name class.name 0.04 検出 カナの氏名 name_kana class.name 0.27 検出 電話番号(ハイフン、数字のみ、全角) phone_* class.phone_number 1.0 3カラムとも検出 郵便番号、都道府県から始まる住所 postcode , address class.location なし, 0.04 検出 生年月日 birthday class.date_of_birth なし 検出 メールアドレスのSHA-256 email_sha256 なし 検出されず マスク済みメール( m***@example.net ) email_masked class.email_address 1.0 検出。3回目のスキャンでは検出されず カラム名が email で中身が整数 misleading.email なし 検出されず 自由記述のメモ misleading.memo class.email_address , class.phone_number , class.name 0.35, 0.33, 0.08 3クラスを検出 map の中のメール event_params class.email_address 1.0 検出 struct の中の地域名 geo class.location なし 検出 array<struct> の中のメール items class.email_address 1.0 検出 8割がNULLのメール users_sparse.email class.email_address 0.19 検出 1,000レコード中1レコードだけメール(残りは普通の文字列) ratio_test.one_in_1000 class.email_address 0.001 検出 1,000レコード中1レコードだけメール(残りはNULL) ratio_test.one_in_1000_null class.email_address 0.001 検出 1,000レコード中1%、5%、20%がメール ratio_test.p01 , p05 , p20 class.email_address 0.01, 0.05, 0.2 検出 100万レコード中1%、0.1%、0.01%がメール ratio_1m.p01 , p001 , p0001 class.email_address 0.0103, 0.0012, 0.0001 検出。0.01%のカラムは3回目のスキャンでは検出されず 100万レコード中10レコード、1レコードだけメール ratio_1m.ten_in_1m , one_in_1m なし 検出されず よくある名前だけの「姓 名」 names_test.common_space class.name なし 検出 姓名(スペース無し)、姓のみ、名のみ、「様」付き、ひらがな names_test.* class.name 0.15, 0.01, なし, 0.02, 0.17 すべて検出 ローマ字の氏名 names_test.romaji class.name 0.99 検出 スキャン後に追加したカラムの電話番号 late_arrival.phone class.phone_number 1.0 次の日次スキャンで検出 スキャン後に追加したレコードのメール misleading.status , jp_numbers.memo class.email_address 0.5, 0.33 日次スキャンでは再評価されず、フルスキャンで検出 マイナンバー(検査用数字がすべて正しい) jp_numbers.my_number_valid class.jp_my_number 1.0 検出 マイナンバー(半分だけ正しい) jp_numbers.my_number_mixed class.jp_my_number 0.5 検出 マイナンバー(すべて誤り) jp_numbers.my_number_invalid なし 検出されず 基礎年金番号形式 pension_number class.jp_pension_number 1.0 検出 会員IDや整数のコードカラム、作成日時には何も付きませんでした。 タグはネストの中のフィールドではなく、カラムそのものに付きます 100万レコードの表では、全レコードではなくサンプルを見ています 一致率が混入率からずれ、100レコードのカラムで保存されたサンプル値は1件でした。1万レコード前後を抜き出して判定していそうです 検出結果はスキャンのたびに変わることがあります 100万レコード中0.01%のカラムとマスク済みメールのカラムは、3回のスキャンのうち最後のフルスキャンで検出から外れました 検出から外れても、付いていたタグはそのまま残りました。誤検出で付いたタグも自然には消えず、外すのは人の作業になりそうです 一致率は、見たレコードのうち検出器が一致したレコードの割合です 日付や struct のカラム、よくある名前だけの氏名のカラムではNULLでした マイナンバーは、検査用数字(12桁目のチェックデジット)まで検証されています 検査用数字が正しいカラムは一致率1.0、半分だけ正しいカラムは0.5で、すべて誤ったカラムは検出されませんでした 12桁の数字が並んでいるだけでは class.jp_my_number は付きません 自動タグでのマスクの確認 自動で付いたタグで、もう一度各テーブルをSELECTしました。 users_plain では検出された10カラムがすべてマスクされ、ハッシュカラムだけが残りました。 misleading.memo には3つのクラスが付きましたが、STRINGカラム向けのクラスを1本にまとめてあるので衝突せずにマスクされました。 一方、 events_nested は、タグの付いていないカラムだけを選ぶクエリでも失敗しました。 [DATATYPE_MISMATCH.CAST_WITHOUT_SUGGESTION] Cannot resolve "poc_data_classification.synthetic.mask_string(event_params)" due to data type mismatch: cannot cast "STRING" to "MAP<STRING, STRUCT<string_value: STRING, int_value: BIGINT>>". スキーマに置いたSTRING向けのポリシーが map 型の event_params にも解決され、関数の引数の型が合わないためです。 クエリで選んだカラムに関係なく、テーブル全体が読めなくなります。 MATCH COLUMNS に使える関数はカラムのタグを見る has_tag と has_tag_value だけで、カラムの型で振り分ける方法はありません。 そこで、ネスト型のカラムを持つテーブルを別扱いにしました。 振り分け用のgoverned tag( abac_profile )を作り、 events_nested に付ける スキーマのポリシーに WHEN NOT has_tag('abac_profile') を足し、このテーブルを除外する events_nested にはテーブル単位で、 map 型と struct 型それぞれに合う関数のポリシーをつける CREATE OR REPLACE FUNCTION poc_data_classification.synthetic.mask_params( v MAP<STRING, STRUCT<string_value: STRING, int_value: BIGINT>> ) RETURNS MAP<STRING, STRUCT<string_value: STRING, int_value: BIGINT>> RETURN transform_values( v, (k, x) -> named_struct( ' string_value ' , CASE WHEN x.string_value IS NULL THEN NULL ELSE ' *** ' END , ' int_value ' , x.int_value ) ); CREATE OR REPLACE POLICY mask_nested_params ON TABLE poc_data_classification.synthetic.events_nested COLUMN MASK poc_data_classification.synthetic.mask_params TO `account users` FOR TABLES MATCH COLUMNS has_tag( ' class.email_address ' ) AS c ON COLUMN c; この構成で events_nested が読めるようになり、 event_params の文字列値と geo の地域名がマスクされました。 event_params: {"email":{"string_value":"***","int_value":null},"page":{"string_value":"***","int_value":null},"count":{"string_value":null,"int_value":"3"}} geo: {"country":"日本","region":"***","city":"***"} ただし、 items ( array<struct> )のカラムは守れませんでした。 このカラムにも class.email_address が付いていましたが、 map 型の event_params と同じクラスなので、テーブル単位のポリシーでも1本の関数では型が合いません。 同じクラスのタグが型の違うカラムに付いたテーブルでは、そのクラスのポリシーを書けないことになります。 今回は items のタグを外しましたが、本番でこの形のテーブルを扱うなら、ネストを展開したテーブルを別に用意するか、テーブルごとアクセスを絞る必要がありそうです。 なお、スキーマのポリシーとテーブルのポリシーが同じカラムに重なった状態では以下のエラーになります。 どのポリシーがどのカラムで衝突したかがすべて書かれているため、切り分けは容易でした。 [COLUMN_MASKS_FEATURE_NOT_SUPPORTED.MULTIPLE_MASKS] ... resulting in multiple column masks mask_params(event_params), mask_string(event_params), mask_geo(geo), mask_string(geo) applying to the same column(s) event_params, geo. 現時点での使用感 日本語の氏名や住所、電話番号などは問題なく検出され、ネストした型の中身まで見てカラムにタグが付きました。 大きなテーブルではサンプルで判定するため、混入がごく少ないカラムは見落とされ、スキャンのたびに結果が変わることもありますが、それ以外で検出の精度に困る場面はありませんでした。 しかし、テーブルやカラム、レコード追加後からスキャンされるまでの間に一定の時間がかかるため、機密データが一定期間露出する可能性があります。 そのため任意タイミングでの再スキャンを行いたいですが、現時点ではUI経由でしか実行できないため、運用上の不便な点となります。 コストに関してはサーバレスジョブコンピュートのDBUがかかります。 Data Classification固有のコストはありませんが、スキャン対象のテーブル数やレコード量によってコストが増加すると思います。 まとめ Data Classificationで検出したカラムにABACのポリシーが適用されるところまで、人手でタグを付けることなく処理できました。 日本語の値やネストした型の中身も検出され、大きなテーブルでのサンプリングによる見落としを除けば、検出の精度で困ることはなさそうだと感じました。 設計で考えることが残るのはポリシーの側で、カラムの型ごとにポリシーを分けることと、ネスト型のカラムを持つテーブルの扱いが必要になります。
「あ、こっちのリポジトリにスキル移植してなかったや。コピペしとこ」 Claude Code を使っていると、一度はやりますよね。 新規リポジトリに新しくハーネスを移植する際など、あまり良くないとわかりつつ、やりがちだと思います。 そこで今回は、株式会社エブリー Android チームでの、ハーネスの一元管理方法について紹介します。 3 つのリポジトリでコピペしていたハーネスを 1 か所にまとめ、合計 5,254 行の設定を消せました。 「本当は一元管理した方が良いのだろうな」と思っているあなたの一助になれば幸いです。 1. まとめ コピーした設定はズレていくため、一元管理した方が良い。 リポジトリごとの技術スタックの違いにより、単純な共通化は難しい リポジトリごとの大きな技術スタックの違いは、読み込む plugin を変えることで対応する リポジトリごとの小さな技術スタックの違いは、読み込むファイルを変えることで対応する 2. 背景 エブリーには、ネイティブで開発されている Android アプリが 4 つあります。 私がメインで触っているのは、そのうち 2 つと、Kotlin で書いた社内ツールの計 3 つのリポジトリです。 delishkitchen.tv www.docomo.ne.jp Issue を拾って Claude Code に PR まで作らせる、JVM で動く社内ツール 同じ Kotlin ですが、前提条件は違います。 デリッシュキッチン dバリューパス版 社内 JVM ツール 動く場所 Android Android JVM(Android SDK なし) UI XML View + Compose Android View なし DI Hilt + Koin 古い版の Koin のみ なし ビルド定義 Kotlin DSL + 共通ビルドロジック Groovy Kotlin DSL(単一モジュール) version catalog あり なし なし ktlint CI と hook で実行 なし CI で実行 この 3 つに、とりあえずハーネスをコピペしました。しばらく経ったころ、資産がいちばん多いデリッシュキッチンと社内ツールで、同じ名前のファイル 5 本を比べてみました。 同名のファイル デリッシュキッチン 社内ツール 差分(追加 + 削除) 実装手順のコマンド 591 行 530 行 977 行 セキュリティのレビュー用エージェント 151 行 72 行 202 行 Kotlin のレビュー用エージェント 113 行 77 行 174 行 コメントの点検スキル 81 行 75 行 141 行 レビューのコマンド 66 行 77 行 138 行 合計 1,632 行 行数が近いファイルでも差分は大きくて、両側でそれぞれ勝手に書き換わっていました。 ただ重複しているだけなら一方を消すだけで良いのですが、実際は リポジトリごとに中身が分岐 していました。 従って修正のたびに、どちらが正しいのか・どこまで反映すべきなのかを判断する必要が出てきました。 3. 原因 ではコピーした設定は、なぜ書き換わっていくのでしょう。 結論から述べると、 各々がよりハーネスを効かせようとした結果、コード分岐が進む ためです。 あるリポジトリに対して一番よくハーネスを効かせようとすると、具体的な記述が必要になります。 例) Manifest の exported を見る Compose の再コンポジションを見る 実際、Kotlin のレビュー用エージェントを比べると、デリッシュキッチン版には Compose の観点が、社内ツール版には「依存を増やさない」「JVM 17 で動く」など固有の観点が足されていました。 これらのファイルは当然、 技術スタックが違うので流用ができません 。 従って、各々がよりハーネスを効かせようとした結果コード分岐が進んでいくのです。 4. 解決策 DRY 原則に従い 1 つにまとめて配れば良い と、解決策として真っ先に思いつきます。これは正しいと思います。 ただ原因に記載した通り、技術スタックが違うので流用できないという問題を解決する必要があります。下記は私が考えた解決策です。 案 結果 共通部分のみ一元管理し、それ以外は各リポジトリ固有で持つ [不採用] 管理が簡単だが、共有できる箇所が少なく根本的な解決にならないため 技術の組み合わせごとに配布物を分ける [不採用] Compose 用のスキル、Hilt 用のスキル……と組み合わせの数だけ作成するため、配布物が膨大になる 共通の部分を層にして配り、技術ごとの知識はリポジトリの設定を見て読み分ける [採用] 具体的な記述は技術ごとに分けて持ち、どれを読むかをリポジトリごとに選ぶ 配布に関しては Claude Code の plugin を前提としました。 採用案は、大きな関心ごとは plugin を多層で分けます。その層の中で、詳細に関しては capability gate という仕組み (後述) を用いて選択させます。 切り替え 何で決めるか 例 層 どのリポジトリが使うか Kotlin 共通 / Android 共通 / ドメイン固有 capability gate そのリポジトリがどの技術を使うか Compose / Android View / ktlint の有無 4.1 層 どのリポジトリが使うかで、層を分けます。今回の場合、層は Kotlin 共通・Android 共通・ドメイン固有の 3 つで分けました。依存の向きは「ドメイン → Android 共通 → Kotlin 共通」です。 層の境界は、次の基準だけで決めました。 そのハーネスは、 その層を使うリポジトリのうち、使っている技術がいちばん少ないところ でも成り立つか。 層 前提にしてよい 前提にしてはいけない Kotlin 共通 Kotlin、Gradle、git / GitHub など Android SDK、宣言的 UI、DI コンテナ、ビルド定義の書き方、特定の lint ツール Android 共通 Android、Activity / Fragment / ViewModel など 宣言的 UI、特定の DI、ビルド定義の書き方、version catalog ドメイン固有 自社ドメインの業務知識 あらゆる技術スタック これで Android SDK の採用など、大きな技術スタックの違いによる分岐に対応できます。 例えば Android SDK 非採用の JVM ツールでは、Kotlin 共通の plugin を入れることで、Android 関連のコンテキストを無視できます。 それだけでなく、JVM ツール開発時に行ったハーネス修正の知見が、より深い層の plugin を使用するリポジトリにも適用されるのです。 しかしながら、これだけではまだ分岐に対応しきれていません。 例えば Android の UI に関してのレビュースキルを考えます。リポジトリ A では Compose を、リポジトリ B では XML View を使用している場合、それぞれに同じスキルを使用すると、お互いにノイズとなるコンテキストが含まれてしまいます。 ここで 4.1 と同じやり方で Compose 層、XML View 層と分けてしまうと、層の数が膨大になり、依存関係が複雑になることで、管理が難しくなります。 この層の中の違いを吸収するのが、次の capability gate です。 4.2 Capability gate capability gate は、層の中の違いを吸収し、分岐に対応する仕組みです。 使用ライブラリなどの細かい技術スタック (capabilities) を、リポジトリ内に設定ファイルとして持ちます。 実行時にこの設定ファイルを読み、AI コンテキストを切り替えます。 ディレクトリ構成は、下記を想定しています。 技術ごとの観点を SKILL.md 本文から丸ごと抜き出し、 reference/ 配下へ保存します。 SKILL.md の description に分岐条件を記載することで、コンテキストの分岐に対応します。 ui-layer-review/ ├── SKILL.md # 手順と、技術に依らない観点だけ └── reference/ ├── compose.md # Compose の観点 └── xml-view.md # XML View の観点 設定は独自の json ファイルとして保持しています。 { " capabilities ": { " platform ": " android ", " ui ": [ " xml " ] , " di ": [ " koin " ] , " versionCatalog ": false , " lint ": [] } } capabilities.ui の値と、読む reference の対応は次のとおりです。 capabilities.ui 読む reference 振る舞い "ui": ["compose"] compose.md Compose の観点でレビューする "ui": ["xml"] xml-view.md XML View の観点でレビューする "ui": ["compose", "xml"] 両方 差分のファイル種別で当てる観点を決める "ui": [] なし UI レイヤーを持たないリポジトリとして指摘せずに終わる 未設定 なし 未適用としてレビューする 5. 結果 リポジトリ + - デリッシュキッチン 183 2,129 dバリューパス版 446 2,032 社内 JVM ツール 255 1,093 合計 884 5,254 足した行に関しては、主に下記です。 CLAUDE.md の修正 技術スタックの設定ファイル追加 リポジトリ固有ハーネスの書き直し そのリポジトリ固有の情報を持つファイル以外は、一元管理できるようになりました。 これで Claude Code のハーネスを別リポジトリからコピペすることは、ほぼ起きなくなったと思います。 6. おわりに エブリーの Android チームでは、今後 Loop Engineering を導入し、真剣にプロダクト開発速度 10 倍を目指しています。 このハーネスの一元管理化も、その一環として行いました。ハーネス自身の開発速度も向上したため、一歩前進です。 道のりはまだまだ、大変厳しいと思いますが、高い目標に向けて駆け抜けたいと思います。 この記事は、株式会社エブリーで Android アプリ開発を担当している岡田が書きました。 エブリーでは、ともに働く仲間を募集しています。 corp.every.tv
はじめに デリッシュキッチン開発部でデジタルサイネージの開発を行っている鈴木( @0muji4_eng )です。 昨今、Cursor や Claude Code などの自律型 LLM Agent を活用した開発が一般的になりつつあります。「Agent に気持ちよくコードを書かせるためには、アーキテクチャを Agent 向けに整える必要がある」という言説も、見かけるようになりました。 私も、広告配信の案件管理を行うための巨大な React アプリを AI 駆動開発に最適化すべく、全面的なリアーキテクチャを実施しました。 しかし、同一タスクを LLM Agent に実行させて前後で定量的に計測したところ、 Agent のパフォーマンスが上がるという当初の仮説は外れていました。 本記事では、計測の結果わかったことをお話しします。 1. 課題とリアーキテクチャの決意 対象となったのは、React + Vite + TanStack Router で構築されたアプリケーションです。 移行前のコードベースには、大きく 2 つの課題がありました。 関心事の分散(レイヤーベースの弊害) 施策・マスタ・認証といった異なるビジネスドメインのコードが、種類別( components/ , lib/ , constants/ )に同じ階層に配置され、混在していました。 ルートファイルの肥大化(Fat Routes) 例えば、複雑なデータ入力を行う入稿画面は 1,404 行、各種設定が並ぶ広告詳細画面は 1,546 行に達しており、1 つのルートファイルの中に zod スキーマ、変換関数、テーブル定義、複数のセクションコンポーネントが同居していました。 私は、「この散らばった構造と巨大なファイルは、きっと Agent のコンテキストを圧迫し、パフォーマンスを低下させているはずだ」という仮説を立て、抜本的なリアーキテクチャを決意しました。 2. LLM Agent を意識したアーキテクチャへの刷新 Feature-Based(機能別)と Vertical Slice(垂直スライス)の考え方をベースにしつつ、LLM Agent のために、以下の 3 つの厳格なルールを設けたアーキテクチャへ移行しました。 機能別のコロケーション: src/features/ 配下に、UI からロジック、定数までをすべて集約する。 単方向依存とバレルファイル禁止: routes → features → 共有層という依存方向を徹底する。 index.ts (バレルファイル)による再エクスポートは禁止し、import は常に具体的なファイルまでのパスで書く。 ルートの薄化: src/routes/ のファイルには Route 定義とページの骨格だけを残し、中身は機能層へ移す。 実際に構築した、リアーキテクチャ後のディレクトリツリー(抜粋)がこちらです。 src/ ├── api/ # orval生成物(不可侵領域) ├── components/ # 全体共有UI(shadcn 等・特定のドメインを持たない) ├── features/ # ビジネスドメインの隔離領域 │ ├── auth/ │ ├── campaigns/ # 巨大ドメインも 1 つにまとめる │ │ ├── components/ │ │ │ ├── ad-detail/ # 画面(関心事)ごとにサブフォルダ化 │ │ │ ├── campaign-detail/ │ │ │ ├── delivery/ │ │ │ └── entry/ │ │ ├── constants/ │ │ ├── hooks/ │ │ └── lib/ │ └── masters/ └── routes/ # URLの物理位置(中身は骨格のみ) ├── _auth/ │ ├── campaigns.$campaignId.tsx │ ├── campaigns.entry.tsx │ └── ... └── __root.tsx ルーティングから重いロジックが消え、Agent はキャンペーンの機能を修正したいとき、 features/campaigns/ の中だけを探索すればよい状態になりました。 では、このアーキテクチャ刷新によって何が起きたのか。定量的な計測結果を見ていきます。 3. 実測結果 リアーキテクチャの前と後のスナップショットを用意し、同一機能のまま定量的な計測を行いました。人間のための指標と LLM Agent のための指標という 2 つの観点から結果を見ていきます。 3.1 改善した人間のための指標 まずは、アーキテクチャの局所性を示す指標です。 ルートファイルの行数: −70〜92% 施策詳細画面: 894 行 → 89 行(−90%) 広告詳細画面: 1,546 行 → 125 行(−92%) 配信設定画面: 1,128 行 → 132 行(−88%) 入稿画面: 1,404 行 → 417 行(−70%) タスクあたりの読み込み対象(変更の局所性): −62% 「入稿フォームの必須チェックを変える」などの 5 タスクを実行する際に、参照すべき対象シンボルを含むファイル量を計測。 5 タスク合計で 271.6KB → 104.1KB(−62%)。 これにより、 人間がコードを読み、レビューするときに見る範囲は大きく減りました 。変更の影響範囲が機能ごとのディレクトリに収まり、レビューする人は該当するファイルだけを見ればよくなったからです。 3.2 仮説が外れた LLM Agent のための指標 ここからが本題です。「Agent が迷わないアーキテクチャにしたのだから、さぞ Agent の一発成功率も上がっただろう」と期待して計測を行いました。 しかし、計測結果は仮説どおりにはなりませんでした。 LLM Agent の一発成功率: 【差なし】 まず、同じタスク 3 つを、経緯を知らない新しい Agent(Claude Code / Claude Sonnet 5.5)に、前後のスナップショットそれぞれで 1 回ずつ実行させました。その中には、表に列を追加するタスクも入れました。見出しだけを追加して各行のセルを追加し忘れると表が崩れる、間違えやすいタスクです。手順は毎回「実装 → ビルド 1 回 → 報告して終了、追加の修正は禁止」に固定し、頼んだ範囲だけを変更してビルドが通れば合格としました。 結果はリアーキテクチャ前が 2/3、後が 3/3 で、前の巨大ファイル側で 1 回だけ不合格になりました。入稿画面の入力項目の 1 つを必須チェックから外すよう頼んだところ、頼んでいない送信処理まで書き換え、型の必須項目を as CaseBody のキャストで回避していました。ビルドは通りますが、レビューでは差し戻しになる変更です。 1 回ずつの試行では、これがリアーキテクチャ前のコードのせいなのか、たまたまなのかを区別できません。そこで、このタスクだけを Claude Sonnet 5.5・Claude Opus 5.5・Claude Fable 5 の 3 モデルで、前後それぞれ 5 回ずつ実行し直しました(計 30 回)。 モデル リアーキテクチャ前 リアーキテクチャ後 Sonnet 5.5 2/5 3/5 Opus 5.5 5/5 4/5 Fable 5 5/5 5/5 合計 12/15 12/15 合計は前後とも 12/15 で、リアーキテクチャによる差は見られませんでした。消費トークンも、どのモデルでも前後で目立った差はありません。 不合格は 6 回あり、どれも頼んでいない送信処理( toCaseBody )を同じように書き換えていました。リアーキテクチャ前の 1,404 行のルートファイルでも、リアーキテクチャで切り出した 182 行のファイルでも、同じ書き換えが起きていました。不合格の数が分かれたのはリアーキテクチャの前後ではなくモデルで、Sonnet 5.5 が 10 回中 5 回、Opus 5.5 が 1 回、Fable 5 が 0 回です。 Agent は、ファイルを頭から読むのではなく、関数名や変数名で検索して該当箇所を見つけてから読みます。そのため、1,546 行の巨大ファイルでも、間違えやすいタスクでも、 毎回、目的の箇所にたどり着けていました 。 4. 結論:今回のリアーキテクチャの効果 アーキテクチャを整えても、Agent の一発成功率は上がりませんでした。今回の計測では、LLM Agent は 1,500 行規模の巨大ファイルでも、検索で目的の箇所にたどり着けていました。 では、今回のリアーキテクチャは無駄だったのでしょうか。決してそうではありません。 結論として、今回のリアーキテクチャに LLM Agent の成功率を上げる効果はありませんでした。今回の結果から、機能別のアーキテクチャの価値は、 人間のレビューコストを下げ、複数 Agent の並行作業(コンフリクト回避)を可能にし、依存のルール違反を自動で見つけられるようにするための基盤 にあると考えています。 人間のレビューを成立させる: 実測データ(参照ファイル量 −62%)が示す通り、Agent が短時間で書いた大量のコードを、人間が無理なく読める範囲に収めます。 複数 Agent の並行作業を成立させる: 1,546 行の Fat Route のままでは、複数の LLM Agent(あるいは人間)に同時に別々のタスクを依頼すると、同じファイルを編集することになり、Git のコンフリクトが起きやすくなります。機能ごと、画面ごとにファイルを分けたことで、Agent に並行して実装させやすくなりました。 依存関係のルール違反を自動で見つけられるようにする: routes → features → 共有層という依存関係をルールとして決めたことで、feature 同士の import のようなルール違反を grep で自動的に見つけられるようになりました。ルールが守られているかを、レビューする人の目に頼らずに確かめられます。 LLM Agent が短時間で大量のコードを書く時代だからこそ、人間が把握できる 局所性の高いアーキテクチャ が、これまで以上に求められている。それが、今回の実測結果から得られた最大の学びです。
はじめに こんにちは、リテールハブ開発部の杉森です。 社内向けのリモート MCP サーバーを Amazon Bedrock AgentCore Gateway 上に構築する検証を進めており、認証には Amazon Cognito を使っています。Claude のカスタムコネクタや Claude Code から OAuth で接続する構成を調べる中で、MCP のクライアント登録の方式と、それに対する Cognito の対応状況が気になったので調べてみました。 本記事では、調べた内容の整理と、検証環境で実際に接続を試した結果を紹介します。内容は 2026 年 9 月時点の情報に基づいています。 MCP の認可の基本 登場する3つの役割 MCP の認可仕様は OAuth 2.1 をもとにしており、以下の3つの役割が登場します。 役割 仕様での説明 本記事の構成での例 MCP クライアント ユーザーに代わって MCP サーバーにリクエストを送る。OAuth 2.1 のクライアントにあたる Claude のカスタムコネクタ、Claude Code MCP サーバー アクセストークンを受け取ってリクエストを処理する。OAuth 2.1 のリソースサーバーにあたる AgentCore Gateway 認可サーバー 必要に応じてユーザーとやり取りし、MCP サーバーで使うアクセストークンを発行する Amazon Cognito ※ MCP のアーキテクチャの用語では、Claude Code のようなアプリ自体は MCP ホストと呼ばれ、MCP クライアントはホストの中で MCP サーバー1つとの接続を担当する部品を指します(ホストは接続する MCP サーバーごとに MCP クライアントを作ります)。本記事では OAuth のやり取りに注目するため、両者を区別せずに MCP クライアントと呼びます。 参考: Authorization(MCP 仕様 2026-07-28) Architecture overview(MCP 公式ドキュメント) 接続の流れ MCP の認可仕様によると、MCP クライアントから認可が必要な MCP サーバーへの接続は、おおまかに以下の流れで進みます。 MCP サーバーにトークンなしでリクエストし、401 と Protected Resource Metadata の場所を受け取る Protected Resource Metadata を取得し、どの認可サーバーを使うかを知る 認可サーバーメタデータ(認可サーバーが公開している、エンドポイントの URL や対応している機能を記載した JSON)を取得する クライアント登録を行い、client_id を得る ブラウザでユーザーが認可し、アクセストークンを取得する アクセストークンを付けて MCP サーバーにリクエストする 本記事で扱うのは、「4.」のクライアント登録と、その判断材料になる「3.」の認可サーバーメタデータです。 MCP のクライアント登録の仕組み client_id を得る3つの方式 MCP の認可仕様では、MCP クライアントは認可フローを始める前に client_id を取得しなければならないとされており、その方法として以下の表の3つが定められています。 MCP クライアントは、接続の流れの「3.」で取得した認可サーバーメタデータを見て、どの方式が使えるかを判断します。CIMD と DCR は、認可サーバーがメタデータに表の3列目のフィールドを含めることで、使えることを示します。事前登録はクライアント側に client_id を設定するだけで使えるため、対応を示すフィールドはありません。 方式 仕組み 対応を示すメタデータのフィールド Client ID Metadata Documents(CIMD) クライアントがメタデータを記載した JSON を HTTPS の URL で公開し、その URL を client_id として使う。認可サーバーは URL から JSON を取得して検証する client_id_metadata_document_supported: true 事前登録(Pre-registration) 事前に登録して得た client_id(と必要ならクレデンシャル)を、クライアントに組み込むか、ユーザーが入力する なし Dynamic Client Registration(DCR) クライアントが実行時に登録エンドポイントへ自分の情報を送り、client_id を発行してもらう registration_endpoint クライアントが方式を選ぶ順番 MCP 認可仕様の Client Registration によると、すべての方式に対応するクライアントは、以下の優先順位で登録方式を選ぶことが推奨されています。 事前登録: そのサーバー向けに事前登録されたクライアント情報があれば、それを使う CIMD: メタデータに client_id_metadata_document_supported があれば、CIMD を使う DCR: メタデータに registration_endpoint があれば、フォールバックとして DCR を使う いずれも使えない場合: ユーザーにクライアント情報の入力を求める 参考: Client Registration(MCP 仕様 2026-07-28) バージョンごとの仕様の変化 3つの方式の扱いは、仕様のバージョンごとに変わってきています。 バージョン CIMD 事前登録 DCR 2025-06-18 記載なし 規定なし(DCR に対応しない認可サーバー向けの代替手段として説明のみ) SHOULD 2025-11-25 SHOULD SHOULD(クライアントのみ) MAY(後方互換のため) 2026-07-28 SHOULD SHOULD(クライアントのみ) MAY(Deprecated) ※ CIMD と DCR は認可サーバーとクライアントの両方に対する要件ですが、事前登録はクライアントが静的なクライアント情報を扱えるようにすることを求める要件で、認可サーバー側への要件はありません。 ※ 2026-07-28 版で非推奨になった DCR は後方互換のために残されていますが、非推奨機能の一覧では、2027-07-28 以降にリリースされる版で削除される可能性があるとされています。 参考: Deprecated Features(MCP 仕様 2026-07-28) Cognito の対応状況 クライアント登録の方式への対応 2026 年 9 月時点で、Cognito は DCR にも CIMD にも対応していません。AgentCore 開発者ガイドの Runtime の OAuth のページには、Cognito は DCR(RFC 7591)に対応していないため、事前に Cognito でクライアントを登録して client_id を取得する必要があると明記されています。CIMD についても、AWS のサンプルリポジトリで Cognito のユーザープールは URL 形式の client_id を受け付けないと説明されています。 参考: Runtime の OAuth 認証(Amazon Bedrock AgentCore 開発者ガイド) sample-serverless-architecture-cimd-with-cognito(GitHub) 認可サーバーメタデータの置き場所 認可サーバーメタデータの置き場所は、以下の2つの仕様でそれぞれ定められています。 仕様 置き場所 RFC 8414(OAuth 2.0 Authorization Server Metadata) /.well-known/oauth-authorization-server OpenID Connect Discovery 1.0 /.well-known/openid-configuration ただし、Cognito の issuer(https ://cognito-idp.<リージョン>.amazonaws.com/<ユーザープールID>)のようにパスを含む場合は、URL の組み立て方が仕様によって異なります。RFC 8414 は .well-known をホストとパスの間に挿入し、OpenID Connect Discovery はパスの末尾に追加します。さらに RFC 8414 の Section 5 では、openid-configuration を RFC 8414 の組み立て方で置く形も示されています。そのため、置き場所の候補は以下の3つになります。 候補 URL 組み立て方 ① /.well-known/oauth-authorization-server/<ユーザープールID> RFC 8414(ホストとパスの間に挿入) ② /.well-known/openid-configuration/<ユーザープールID> RFC 8414 の組み立て方で openid-configuration を置く形(RFC 8414 Section 5) ③ /<ユーザープールID>/.well-known/openid-configuration OpenID Connect Discovery(パスの末尾に追加) Cognito が公開しているのは ③ の OpenID Connect Discovery のメタデータのみで、RFC 8414 のメタデータは公開されていません。Cognito のユーザープールは OpenID Connect のプロバイダーとして動作するため、アプリ側で接続先のエンドポイントをあらかじめ設定しておく通常の連携では、この点は問題になりません。AgentCore Gateway 自身も、インバウンド認可の設定では OpenID Connect Discovery の URL を指定します。 MCP クライアントは接続先の MCP サーバーがどの認可サーバーを使うかを事前に知らないため、Protected Resource Metadata から認可サーバーメタデータまでを自動でたどります。現行の MCP 認可仕様では、認可サーバーは RFC 8414 と OpenID Connect Discovery のどちらか一方を提供すればよく、クライアントは両方の置き場所を探すことが必須とされているため、Cognito のメタデータも見つけられます。 参考: Gateway のインバウンド認可(Amazon Bedrock AgentCore 開発者ガイド) Authorization Server Discovery(MCP 仕様 2026-07-28) MCP クライアントから AgentCore Gateway に接続する方法 前述のとおり、Cognito は DCR にも CIMD にも対応していないため、そのままでは MCP クライアントが client_id を得られません。これに対応する方法として、Cognito のまま client_id を事前に登録する、Cognito の前段で DCR / CIMD を受ける、認可サーバー自体を変える、の3つが考えられます。 1. 事前登録したアプリクライアントを使う Cognito にアプリクライアントを作成し、その client_id(と必要なら client_secret)を MCP クライアント側に設定する方法です。Claude のカスタムコネクタや Claude Code では、接続の設定で client_id と client_secret を指定できます。 あわせて、Cognito のアプリクライアントに MCP クライアントのコールバック URL を許可し、AgentCore Gateway の Allowed clients にも同じ client_id を登録しておく必要があります。 構成は最もシンプルですが、接続する MCP クライアントごとに、こうした登録や設定が必要です。 参考: Get started with custom connectors using remote MCP(Claude ヘルプセンター) MCP(Claude Code ドキュメント) Amazon Cognito(Amazon Bedrock AgentCore 開発者ガイド) 2. Cognito の前段に DCR / CIMD を受けるプロキシを置く Cognito はそのまま ID 基盤として使い、MCP クライアントとの間に登録を肩代わりするプロキシを置く方法です。プロキシは registration_endpoint や client_id_metadata_document_supported を含む独自のメタデータを公開して MCP クライアントからの登録を受け付け、認可やトークンの発行は裏側の Cognito に転送します。トークンの発行元は Cognito のままです。 MCP クライアント側の設定は不要になりますが、プロキシを自前で実装・運用する必要があります。MCP クライアントに Cognito ではなくプロキシを認可サーバーとして見つけさせる仕組みや、登録エンドポイントの保護など、考慮することが多くなります。 3. DCR / CIMD に対応した認可サーバーに変える DCR や CIMD に対応した認可基盤を使う方法です。たとえば Auth0 は DCR に対応しており、CIMD についても、管理者が CIMD の JSON を取り込んでクライアントを登録する形で対応しています。 ただし、既存の Cognito ユーザープールのユーザーや、Cognito に接続している既存のアプリの移行が必要になります。 参考: Register your MCP Client Application(Auth0 ドキュメント) 実際に試してみた 検証用の AgentCore Gateway に、MCP 公式の TypeScript SDK(@modelcontextprotocol/sdk)で接続しました。リクエストとレスポンスを HTTP のレベルで記録するため、Claude のアプリではなく SDK を使っています。以下の2つのパターンを試しています。 client_id を設定せずに接続する client_id を事前に設定して接続する client_id を設定せずに接続する SDK のリクエストとレスポンスは以下のとおりです(ID とドメインは伏せています)。 POST https://<Gateway>/mcp -> 401 www-authenticate: Bearer error="invalid_token", resource_metadata="https://<Gateway>/.well-known/oauth-protected-resource" GET https://<Gateway>/.well-known/oauth-protected-resource -> 200 GET https://cognito-idp.ap-northeast-1.amazonaws.com/.well-known/oauth-authorization-server/<ユーザープールID> -> 400 GET https://cognito-idp.ap-northeast-1.amazonaws.com/.well-known/openid-configuration/<ユーザープールID> -> 400 GET https://cognito-idp.ap-northeast-1.amazonaws.com/<ユーザープールID>/.well-known/openid-configuration -> 200 Error: Incompatible auth server: does not support dynamic client registration まず、トークンなしのリクエストに対して Gateway が 401 を返し、WWW-Authenticate ヘッダーで Protected Resource Metadata の場所を伝えています。Protected Resource Metadata の authorization_servers には、Cognito の issuer が入っていました。 { " authorization_servers ": [ " https://cognito-idp.ap-northeast-1.amazonaws.com/<ユーザープールID> " ] , " resource ": " https://<Gateway>/mcp ", " scopes_supported ": [] } 次に、SDK は Cognito の認可サーバーメタデータを前述の ① → ② → ③ の順で探し、③ で取得しています。これは MCP 認可仕様の Authorization Server Metadata Discovery が定める順序と同じです。③ で取得できたメタデータは以下のとおりです。 { " authorization_endpoint ": " https://<ドメイン>.auth.ap-northeast-1.amazoncognito.com/oauth2/authorize ", " end_session_endpoint ": " https://<ドメイン>.auth.ap-northeast-1.amazoncognito.com/logout ", " id_token_signing_alg_values_supported ": [ " RS256 " ] , " issuer ": " https://cognito-idp.ap-northeast-1.amazonaws.com/<ユーザープールID> ", " jwks_uri ": " https://cognito-idp.ap-northeast-1.amazonaws.com/<ユーザープールID>/.well-known/jwks.json ", " response_types_supported ": [ " code ", " token " ] , " revocation_endpoint ": " https://<ドメイン>.auth.ap-northeast-1.amazoncognito.com/oauth2/revoke ", " scopes_supported ": [ " openid ", " email ", " phone ", " profile " ] , " subject_types_supported ": [ " public " ] , " token_endpoint ": " https://<ドメイン>.auth.ap-northeast-1.amazoncognito.com/oauth2/token ", " token_endpoint_auth_methods_supported ": [ " client_secret_basic ", " client_secret_post " ] , " userinfo_endpoint ": " https://<ドメイン>.auth.ap-northeast-1.amazoncognito.com/oauth2/userInfo " } メタデータに client_id_metadata_document_supported がないため、SDK は CIMD を使わずに DCR に進みました。しかし registration_endpoint もないため、「Incompatible auth server: does not support dynamic client registration」というエラーで停止しました。 client_id を事前に設定して接続する 事前登録の方法で、Gateway の Allowed clients に登録している Cognito のアプリクライアントの client_id を SDK に設定し、同じように接続しました。 (③ の openid-configuration の取得までは同じ) リダイレクト先: https://<ドメイン>.auth.ap-northeast-1.amazoncognito.com/oauth2/authorize パラメーター: response_type, client_id, code_challenge, code_challenge_method, redirect_uri, resource メタデータを取得した後、登録の処理を飛ばして Cognito の認可エンドポイントへのリダイレクトまで進みました。この後はブラウザでユーザーがログインする手順になります。 まとめ 今回の検証では、事前登録したアプリクライアントの client_id を MCP クライアントに設定すれば、Cognito のままでも認可の手順に進めることを確認できました。 一方で、現行の MCP 仕様では CIMD が推奨され、DCR は非推奨になっています。接続する MCP クライアントが増えていくなら、Cognito の前段にプロキシを置くことや、CIMD に対応した認可サーバーを使うことも検討が必要になりそうです。 MCP の認可仕様はバージョンごとの変化が大きいため、構成を検討する際は仕様の最新版と、利用する認可サーバー・MCP クライアントの対応状況をあわせて確認するのがおすすめです。
AI AgentのタイムアウトからGoのイテレータを俯瞰する こんにちは、岩﨑 ( @uho-wq ) です。 デリッシュキッチン開発部でプレミアム機能を開発しています。 先日、アプリ版デリッシュキッチンでGenerative UIを搭載したデリッシュAIをリリースしました。 この記事ではAI Agentが引き起こしたタイムアウトのバグから、Go 1.23で入ったイテレータについて俯瞰してみたいと思います。 デリッシュAI デリッシュキッチンでは、AIアシスタントと対話しながら作りたいレシピを探すことができます。 Generative UIによる選択肢生成によって選ぶだけで会話が続き、作りたいレシピにたどり着けるようになっています。 デリッシュAI ツールコールや応答をストリーミングで表示し、無料ユーザーも上限付きで使えます。 AI Agent はすべて Go で実装しました。使用したエージェント開発フレームワークは Agent Development Kit (ADK) for Go (v2.1.0) です。 LLM の応答は SSE で配信しており、イベントの形式は AG-UI (The Agent-User Interaction Protocol) の イベントタイプ に合わせて実装しています。 異常時のイベント RUN_ERROR は今後も出てくるため、ここで紹介しておきます。 1 回の実行には context.WithTimeout で 60 秒のタイムアウトを設けています。超えた場合も RUN_ERROR を返します。 以降のコード引用と行番号は、断りがない限り adk-go v2.1.0 のものです。 ツール呼び出しがタイムアウトまで止まらなかった ある日、Sentry に次のようなエラーが届きました。 run <run_id>: context deadline exceeded これは、1 回の実行(run)が 60 秒のタイムアウトに達したことを示すエラーです。普段は数秒で終わる処理なので、明らかに異常です。 ログを追ってみると、Agent が Generative UI( A2UI )の UI 生成ツールを、1 回の実行の中で約 50 回も呼び続けていました。 そしてツール呼び出しが終わらないまま 60 秒が経過し、ユーザーには RUN_ERROR が返ってしまっていた、というわけです。 ログを深掘りすると、そもそも Agent がツールに渡す引数が間違っていたことが分かりました。さらにツールは毎回エラーを返していたにもかかわらず、Agent はそれを受けて引数を直すことなく、同じ呼び出しを繰り返していました。 つまり「間違った引数で呼ぶ → エラーが返る → また同じ引数で呼ぶ」というループに陥っていたのです。 ツール呼び出しのリトライ上限は実装済みだった 実は、実装が漏れていたわけではなく、Agent の暴走を懸念してリリース前からリトライ上限は実装済みでした。 それを説明するために、まず ADK for Go でのツールの作り方を見てみます。 ADK for Go では、 functiontool.New の第 2 引数(handler)にツールの処理を書くことで、Go の関数を Agent から呼べるようになります。 以下は2つのintを加算するツールの実装例です。 type SumArgs struct { A int `json:"a"` B int `json:"b"` } type SumResult struct { Sum int `json:"sum"` } func sumFunc(ctx agent.Context, in SumArgs) (SumResult, error ) { return SumResult{Sum: in.A + in.B}, nil } t, err := functiontool.New(functiontool.Config{ Name: "sum" , Description: "sums two integers" , }, sumFunc) 私は呼び出し回数のカウンタをこの handler の中に実装していたのですが、これが原因でした。 実際にツールが呼ばれるまでの流れは次のようになっています。 [起動時] [リクエスト時] functiontool.New Runner.Run └ resolvedSchema └ Flow.Run (for {}) └ jsonschema.For ── 推論 ──▶ f.inputSchema ──▶ functionTool.Run struct → additionalProperties:false └ ConvertToWithJSONSchema └ Validate 起動時に、handler の引数の struct から JSON Schema が推論されます。 そしてリクエスト時には、 functionTool.Run が handler を呼ぶ前に、 ConvertToWithJSONSchema (google/jsonschema による検証)で map[string]any の引数を型変換します。 input, err := typeutil.ConvertToWithJSONSchema[ map [ string ] any , TArgs](m, f.inputSchema) if err != nil { return nil , err } https://github.com/google/adk-go/blob/v2.1.0/tool/functiontool/function.go#L197-L200 引数がスキーマに合わないとここで error を返して終了してしまうため、handler まで処理が届かず、カウントされなかったのです。 この問題の対処法 この問題は、カウンタを handler ではなく BeforeToolCallback に実装すれば解決できます。 BeforeToolCallback は、ツールを実行する直前に呼ばれる関数です。 functionTool.Run がスキーマを検証するより前に呼ばれるので、引数が間違っていても回数を数えられます。 上限に達したら、 SkipSummarization を true にして結果を返します。 func limitToolCalls(limit int32 ) llmagent.BeforeToolCallback { var n atomic.Int32 return func (ctx agent.Context, t tool.Tool, args map [ string ] any ) ( map [ string ] any , error ) { if n.Add( 1 ) <= limit { return nil , nil // nil ならツールをそのまま実行する } ctx.Actions().SkipSummarization = true return map [ string ] any { "error" : "tool call limit reached" }, nil } } SkipSummarization が true のイベントは、 IsFinalResponse() の先頭の条件で true になります。これで Agent ループを終了させることができます。 func (e *Event) IsFinalResponse() bool { if (e.Actions.SkipSummarization) || len (e.LongRunningToolIDs) > 0 { return true } return !hasFunctionCalls(&e.LLMResponse) && !hasFunctionResponses(&e.LLMResponse) && !e.LLMResponse.Partial && !hasTrailingCodeExecutionResult(&e.LLMResponse) } https://github.com/google/adk-go/blob/v2.1.0/session/session.go#L212-L218 ここまでで、カウンタが動かなかった理由は分かりました。 ではなぜ、ツールは毎回エラーを返していたのに、Agent のループは終わらなかったのでしょう。 実装では、ADK が返す iter.Seq2[*session.Event, error] を range で回し、error を受け取ったら SSE で RUN_ERROR を返して終了する設計になっていました。ツールのエラーがここまで届いていれば、ループはその場で止まっていたはずです。 Go のイテレータ(range-over-func) 本題に入る前に、Go のイテレータについておさらいします。 Go 1.23 で for range の対象に関数が加わりました。対象になる関数は次の 3 形式です。 func(func() bool) func(func(V) bool) func(func(K, V) bool) iter パッケージは後ろ 2 つに名前を付けています。 package iter type Seq[V any ] func (yield func (V) bool ) type Seq2[K, V any ] func (yield func (K, V) bool ) イテレータ関数は、引数で受け取った関数(慣習的に yield と呼びます)に値を渡して呼ぶことで、 for range のループ本体に値を 1 つずつ渡します。 func Count(n int ) iter.Seq[ int ] { return func (yield func ( int ) bool ) { for i := range n { if !yield(i) { // ループ側が break したら false が返る return } } } } for i := range Count( 3 ) { fmt.Println(i) // 0, 1, 2 } yield は、ループを break や return などで途中で抜けない限り true を返し、抜けた場合は false を返します。 イテレータはデータが尽きれば自分から return して終われますが、途中で止めたいときはループ側が break や return で抜けて false を伝えてあげる必要があります。 イテレータはそれに従って yield を呼ぶのをやめます。 つまり途中で止めるかどうかを決めるのはループ側で、イテレータはその合図に従うことで終了できます。 ADK for Go の iter.Seq2[*session.Event, error] ADK for Go は iter.Seq2[K, V] の V に error を入れています。ここからは、ツールの error が yield されていたのかを確認するために ADK for Go の実装を見てみます。 ADK for Go の実行は 3 層になっています。呼び出し側が使う Runner.Run が、セッションを解決して LLMAgent を動かします。 LLMAgent は agent 本体で、モデルとツールを持ちます。そして LLMAgent は「モデル呼び出し → ツール呼び出し」のループを、内部の Flow に任せています。 それぞれのメソッドの戻り値は iter.Seq2[*session.Event, error] で、内側の yield を外側がそのまま通すような実装になっています。 LLMAgent.run を見ると、 Flow.Run を range して受け取ったイベントを逐次 yield しているだけです。 func (a *llmAgent) run(ctx agent.InvocationContext) iter.Seq2[*session.Event, error ] { // ... return func (yield func (*session.Event, error ) bool ) { for ev, err := range f.Run(ctx) { a.maybeSaveOutputToState(ev) if !yield(ev, err) { return } } https://github.com/google/adk-go/blob/v2.1.0/agent/llmagent/llmagent.go#L454-L460 Flow.Run の中身が、最終応答が出るまでモデル呼び出しを繰り返す for ループです。 func (f *Flow) Run(ctx agent.InvocationContext) iter.Seq2[*session.Event, error ] { return func (yield func (*session.Event, error ) bool ) { for { var lastEvent *session.Event for ev, err := range f.runOneStep(ctx) { if err != nil { yield( nil , err) return } // forward the event first. if !yield(ev, nil ) { return } lastEvent = ev } // A thought-only ("thinking") turn reports as final but has no // answer; don't stop on it — call the model again. if lastEvent == nil || (lastEvent.IsFinalResponse() && !isThoughtOnlyTurn(lastEvent)) { return } if lastEvent.LLMResponse.Partial { // We may have reached max token limit during streaming mode. // TODO : handle Partial response in model level. CL 781377328 yield( nil , fmt.Errorf( "TODO: last event is not final" )) return } } } } https://github.com/google/adk-go/blob/v2.1.0/internal/llminternal/base_flow.go#L103-L130 runOneStep は、モデルを 1 回呼び、その応答に含まれるツールを実行します。つまりツール呼び出しも runOneStep の中で起こります。 外側の for {} は、最終応答のイベント (IsFinalResponse) が出るまで runOneStep を繰り返します。 Flow.Run の終了条件 この Flow.Run が終わるのは、次の 5 つの場合です。 正常終了 モデル呼び出し・コールバックの error イベントが 1 つも出なかった場合 ストリームが途中で切れた場合 ループ側が range を抜けた場合 ツールのエラーはこの 5 つの終了条件に含まれません。 実際のツール呼び出しは以下のような処理になっています。 func (f *Flow) callTool(toolCtx agent.Context, tool toolinternal.FunctionTool, fArgs map [ string ] any ) map [ string ] any { var response map [ string ] any // ... if err != nil { return map [ string ] any { "error" : err.Error()} } return response https://github.com/google/adk-go/blob/v2.1.0/internal/llminternal/base_flow.go#L1233-L1278 ツールが返した Go の error をそのまま返すのではなく、 {"error": ...} という map[string]any 型に変換して返していることが分かります。 ADK for Go はこの map を Event として yield(ev, nil) します 。つまり Seq2 の error 側には乗らず、正常なイベントとして流れてくるのです。 そして、ツールの応答(FunctionResponse)を含むイベントは、先ほどの IsFinalResponse() で false になります。そのためループは終わらず、次のモデル呼び出しへ進みます。 私は「error が返ってきたらループは終わる」と思っていました。しかし実際には、 error には Seq2 の error と、イベントの一部に含まれる error の 2 通りがあった のです。 ここまでの話をまとめると以下のようになります。 モデルがスキーマに合わない引数で function call を出す functiontool が引数変換で error を返す callTool が {"error": ...} に変えてイベントとして流す イベントは function response を含むので IsFinalResponse() は false でループ継続 モデルへ戻ると、モデルは同じ引数でもう一度呼ぶ 1 に戻る。60 秒のタイムアウトまで繰り返す サーバー側の err != nil は一度も true にならないので、 RUN_ERROR を返す分岐にも入りません。これが、タイムアウトまで止まらなかった理由でした。 イベントにエラーを詰めることに対する考察 ツールのエラーをイベントとして流す設計には、それなりの理由があると思っています。 イベントで返せば range ループを抜けることがないため、エラーがそのままモデルに渡ります。これによって Agent 自身がリトライ(自己修正)できるようになります。今回のように直せないこともありますが、多くの場合はこの方が都合がよいはずです。 一方で、ツールが失敗したことを知る手段は、FunctionResponse の中の "error" キーの有無だけになります。 iter.Seq2[*session.Event, error] の設計について ADK for Go はイベントを逐次 yield してストリーム処理を実現しています。 モデル呼び出しやツール呼び出しのエラーは、イベントを複数流したあとに起こります。 (iter.Seq[Event], error) の戻り値では途中のエラーを表現できないので、現状の形式になったのではないかと理解しています。 そもそも iter.Seq2[T, error] の形式は適切なのか Go はエラーを値として返すため、 Seq2[K, V] の V に error を入れることは言語仕様上可能です。ただし、規約としては定まっていません。 実際に標準ライブラリを調べてみましたが、 公開 API で Seq2[T, error] を返すものは 1 つもなく、 Seq2 を返す API はすべて key-value か index-value の形式を取っていました。 では、 iter パッケージが提案された時点ではどうだったのでしょう。 提案 golang/go#61897 の本文には、当初 value-error の形式が書かれていました。 Seq2 represents a sequence of paired values, conventionally key-value, index-value, or value-error pairs. If iteration can fail, it is conventional to iterate value, error pairs: // Lines iterates through the lines of the named file. // Each line in the sequence is paired with a nil error. // If an error is encountered, the final element of the // sequence is an empty string paired with the error. func Lines(file string ) iter.Seq2[ string , error ] ところが、 Russ Cox 氏は提案文を godoc に取り込むとき、value-error の記述を意図的に省いています。 2024-06-07 の commit fe36ce669c ("iter: add doc comment from proposal")のメッセージにこうあります。 The edits are to drop mention of value-error Seq2 usage and to adjust for the bool result changes. 現行の iter パッケージの Seq2 の説明にも、error への言及はありません。 省いた理由をまとめて書いた箇所は、私が追った範囲では見つかりませんでした。 つまり Seq2[T, error] の振る舞いは言語も標準ライブラリも規定していないため、ループ側で責務を持ち、制御するしかありません。 ADK for Go における制御 実は ADK for Go もエラーに対する振る舞いについて決めかねています。 TODO: check if we should stop iterator on the first error from stream or continue yielding next results. https://github.com/google/adk-go/blob/v2.1.0/internal/llminternal/base_flow.go#L805 https://github.com/google/adk-go/blob/v2.1.0/internal/llminternal/base_flow.go#L822 実際に、adk-go の中だけで 3 パターンが混在しています。 パターン 場所 挙動 yield(nil, err); return model/gemini/gemini.go L146-L148 error で打ち切る yield(nil, err) して continue runner/run_node.go L148-L153 error を流して継続する yield(event, err) して continue runner/runner.go L316-L320 event と error を両方流して継続する error の後にイテレーションが続くかどうかは、イテレータ側で自由に決定できるため、ループ側では error を受け取っても次の要素が来る前提で備える必要があります。 止めたい場合は、ループ側で break や return でループから抜ける処理を実装する必要があります。 従来の関数の戻り値は 1 回きりなのでこの問題は起きず、何度でも yield できる Seq2[T, error] で初めて生じる問題といえます。 まとめ 今回のタイムアウトは、ツールのエラーが iter.Seq2 の error 側ではなく、イベントの一部として流れていたことが主な原因でした。イベント側へ流れたことにより、ループ側の err != nil は一度も true にならず、Agent がタイムアウトするまでツールを呼び続けていました。 Seq2[T, error] の error は「失敗したら必ず返される」とは限りません。そもそも error を yield するか、yield した後も要素を流し続けるかは、イテレータの実装が決定できます。言語も標準ライブラリもこの形に規約を設けておらず、ADK for Go の中でも 3 つのパターンが混在していました。 一方でループを続けるかどうかはイテレータ側がどう実装されていてもループ側によって決定できます。 ただし、ループ側が正しく判断するには、イテレータがエラーをどう扱うかを知る必要があります。ADK for Go がツールのエラーをイベントとして流してループを続けるのは、Agent に自己修正させるためでした。 もし iter.Seq2[T, error] の実装に出会ったら、error がどこに流れ、その後もイテレーションを続けることを実装が意図しているかを確認すると安全だと思います。逆に実装する側になったら、error の後も続けるのか、それはなぜなのかをドキュメントに書き残しておくと、ループ側が止める条件を決めやすくなりそうです。
はじめに 株式会社エブリーは、2026年9月11日(金)〜13日(日)に有明セントラルタワーホール&カンファレンスで開催された iOSDC Japan 2026 に、ゴールドスポンサーとして参加しました! 開発本部から6名のエンジニアが現地参加したので、ブースの様子や印象に残ったセッションをご紹介します。 会場の様子 3日間で70本以上のトークや LT が行われ、現地には約1,000名規模の参加者が集まる大盛況でした。 セッションやブース以外の企画も盛りだくさんで、朝9:30からドーナツサービスに始まり(おいしかった!!!)、声優さんによる館内アナウンス、スポンサーブースを回るスタンプカードビンゴ、ペンライトが揺れる LT 大会など、会場全体がお祭りムードでした。 エブリーブース エブリーは今回、ゴールドスポンサーとしてブースを出展させていただきました。ブースでは、参加型のアンケートボードとデリッシュAI の実機デモの2つを展示しました。 当日は多くの方々にブースへお立ち寄りいただき、iOS や AI 開発についてさまざまなお話を伺うことができました。足を運んでいただいた皆様、本当にありがとうございました! アンケートボード「iOSアプリ開発、AIにどこまで任せていますか?」 「iOSアプリ開発、AIにどこまで任せていますか?」をテーマに、「開発工程のどの段階まで AI に任せているか」と「iOS エンジニアの経験年数」の2軸をシールで答えてもらう参加型のアンケートを実施しました。 3日間で皆様に、ボードいっぱいシールを貼っていただきました。 ざっくりまとめると 完全自律させている人はまだ少数 実装は AI に任せている方が大多数 最終的な確認・判断は人間が行っている エンジニア歴との相関関係は特になし という結果でした。 シールを貼っていただいた流れでリアルな開発事情を共有し合うこともできました。 特に盛り上がったのが「iOS の UI 確認や E2E テストをどう行っているか?」というテーマです。「実装は AI に任せられるけれど、動作確認はスピードやトークン消費の兼ね合いでまだ難しい…」という声が多く、現場のリアルな工夫を聞くことができました。 ドメインの複雑さや求められる品質レベル、個人開発か業務かによって AI 活用のアプローチは異なり、各社・各人が置かれた環境の中で、ベストな開発方法を模索している印象でした。ちなみに数少ない「完全自律させている」方にお話を聞くと、最後は「勇気と度胸」という声もありました。 AI 以外の話題では、iOS エンジニア歴15年以上の歴戦の猛者から、Objective-C / iPhone 3G / Xcode 3 時代の古き良きお話を聞けたのもリアルイベントならではの醍醐味でした。 デリッシュAI の実機デモ もう一つの展示は、iOS 版で新しくなった「デリッシュAI」の実機デモです。デリッシュAI は、チャットで相談するとレシピを提案してくれる『デリッシュキッチン』の AI アシスタントで、提示された選択肢をタップしていくだけで会話が進み、作りたいレシピにたどり着けます。無料プランでも1日5回までお試しいただけるので、ぜひ触ってみてください! 今回デリッシュAI のアップデートに伴い、Generative UI というアプローチを取り入れる技術的挑戦を行いました。 仕組みとしては、サーバー側の AI エージェントがおすすめレシピといったデータだけでなく「画面構成」までを返し、アプリ側がそれを SwiftUI で動的に描画しています。AI にはあらかじめ定義した数種類のコンポーネントのみを渡し、「どれをどの順で配置するか」の判断に絞らせることで、デザインの品質を保ちながらも、対話や選択肢のバリエーションを柔軟に拡張できるようにしました。 ブースでは、Generative UI という考え方そのものはもちろん、それを実際のプロダクトへ落とし込んでいる点に対して「面白い!」という声を多数いただき、非常に嬉しかったです。 他社のスポンサーブース 3日間で他社のブースも回らせていただきました。どこも工夫を凝らした企画で、ノベルティ含め個性豊かな展示でした。全部は回りきれなかったので、立ち寄れた中から一部を紹介します。 ピクシブ さん :デジタルシールを作成・交換できるアプリ「ぴくしーる」をデモ展示されていました。今年の iOSDC のために開発されたそうで、イベント専用にアプリを作り込まれる熱量に驚きました。 ZOZO さん :ZOZO検定というクイズ企画でした。社内で実施されたクイズの出張版とのことで、全10問に挑戦し、正解数に応じてノベルティがもらえました。 LINEヤフー さん :Yahoo!知恵袋を題材にした「#リアル知恵袋」。来場者が付箋で質問や回答を寄せ合う企画で、技術ネタから人生相談まで多様な交流が付箋上で生まれていました。 転職ドラフト さん :AI 時代に評価されるエンジニアとは何か、をテーマに、来場者が付箋で意見を貼っていくアンケートボードを実施されていました。AI 時代に iOS エンジニアとして、エンジニアとして生き残っていくために何が必要か、考えさせられる内容でした。 セッション紹介 ブース運営の合間に聴講したセッションから、印象に残ったものを紹介します。 柔軟性のあるUIで、多くの環境でアプリを活躍させる 発表者: noppe さん iOSDC 開幕前日の9月10日(日本時間)に折りたたみ iPhone「iPhone Duo」が発表されたこともあり、レスポンシブな UI 設計というタイムリーなテーマに会場は超満員でした。多様な画面サイズにどう適応していくかは、これから多くの iOS エンジニアが向き合うことになる課題であり、そこにどう向き合うかのヒントとなるようなセッションでした。 noppe さんは「UI の柔軟性」を、状況を問わずに価値を提供できること、と定義していました。UI を「目的(ユーザーが実現したいこと)」「コンテキスト(ユーザーが置かれた状況)」「インターフェース(目的を実現する手段)」という3つの要素の組み合わせとして捉え直すと、コンテキストが変わったときに目的を守るにはインターフェースの側が変わるしかない。マルチプラットフォーム対応や新しい画面サイズへの対応を「利用者が少ない割にコストが高いもの」として後回しにするのではなく、同じ目的に対して複数のインターフェースを持てる状態をつくろう、というのがセッションの主張です。 では、インターフェースはコンテキストの変化にどう応じるのか。セッションではそれを「連続的な適応」と「段階的な適応」の2つに整理していました。前者は枠のサイズに合わせて伸縮させる方法、後者は境界を越えたら情報量やレイアウトの構造そのものを切り替える方法で、その境界は「同じインターフェースのまま目的を十分に達成できるか」で決める、という基準もあわせて示されていました。 単に「デザインの崩れを修正する」のではなく、「目的とコンテキストに基づいて UI を設計する」という本質ベースなアプローチの重要性を再認識させるセッションでした。連続性と段階性という捉え方も、これまで感覚的に対応していた部分を言語化していただいた印象です。iPhone Duo へのリサイズ対応に限らず、UI の設計指針として参考になる内容でした。 世界の中心でAI(App Intents)を叫ぶ - App Intents中心設計の実践ガイド 発表者: touyou(藤井 陽介)さん(株式会社グッドパッチ) App Intents はアプリの機能をアプリの外部から呼び出すための仕組みであり、2022年の登場以来、Siri、ウィジェット、Spotlight、Apple Intelligence、そして今年の Siri AI と、年々呼び出し口を広げてきました。Apple はあらゆるアプリが Siri や Apple Intelligence と統合されたエコシステムを目指しており、その手段となる App Intents の重要性は年々高まっています。これほど重要になっているなら、後から対応するのではなく最初から App Intents を設計の中心に据えてしまおう、というのが本セッションの主題「App Intents 中心設計」でした。 原則として次の3つが挙げられていました。 App Intents から設計する なるべく DRY でいること 色んな動線で使ってもらうこと 実装としては、UI も Siri も Spotlight もウィジェットも同じ Intent を通して Service を呼ぶ構成にする、アプリ内のボタンも Button(intent:) で Intent 経由にする、などというものでした。処理が一箇所にまとまり、どの内部 / 外部システムからでも同じ機能が使えるようになります。 さらに AppIntent をアプリの「動詞」、AppEntity をアプリの「名詞」と捉えれば、App Intents から設計するとは、そのアプリで「誰が何をできるのか」を動詞と名詞で棚卸しすることに他ならない、つまり App Intents 中心設計はユースケース中心設計と同じ発想に立っている、という話でした。 これまで私のなかで App Intents は「ショートカットやウィジェットの実装に必要なもの」という位置づけで、そこに iOS 27 で Siri AI が登場したためAppIntent や AppEntity を用いて音声入力によるアプリ操作をできるようにしたいな、くらいに考えていました。が、Apple が想像以上に App Intents に注力していることを知り、であれば App Intents を起点に設計するという考え方(App Intents Driven Development)も面白そうだと感じました。とはいえ、既存アプリを全面的に App Intents 中心へ移行するのは相応の工数がかかりそうなので、やるとすれば主要なユースケースをいくつか Intent として切り出すところから始めるのが、現実的なアプローチかなと思っています。 アフターイベントのお知らせ ゆめみ、フェンリル、ヤプリ、ウェルスナビ、セーフィー、エブリーの6社合同で「DroidKaigi & iOSDC After Talks Night 2026」を開催します。DroidKaigi 2026 と iOSDC Japan 2026 の合同アフターパーティーで、iOS と Android のエンジニアが同じ場に集まり、各社の LT や懇親会でプラットフォームを越えた交流ができる会です。 項目 詳細情報 開催日時 2026年10月2日(金)19:00〜21:00 開催場所 住友不動産麻布十番ビル 9F アクセンチュア・イノベーション・ハブ 東京(東京都港区三田一丁目4番1号) 開催形態 オフライン / オンライン コンテンツ 各社の Android & iOS に関するセッション / 懇親会 現地参加の申込はすでに締め切られていますが、オンライン(配信)での参加は引き続きお申し込みいただけます。当日はエブリーのメンバーも LT で登壇予定ですので、ぜひご覧ください! 詳細やオンライン参加のお申し込みは以下からどうぞ。 yumemi.connpass.com 最後に 3日間、多くの方にブースに足を運んでいただきました。エブリーのことを知っていただくだけでなく、iOS 開発や AI 活用の話を情報交換できて、とても良い機会になりました。 これからもデリッシュキッチン、そしてエブリーをよろしくお願いします! 最後までお読みいただき、ありがとうございました!
目次 はじめに 前提 プロジェクト概観 AI ネイティブな開発環境を実現するために 具体的な作業は AI に任せる skills agents hooks PreToolUse hook Stop hook ドキュメントも AI-Friendly な構成にする 実際の開発フロー 要件定義 タスク分割 各タスクの実装サイクル レビューの構造 所感 skill / agent 化の効果 コンテキストが大きくなりがち 完了までの時間がかかる トークン消費が激しい 良くも悪くもスクラップ&ビルドで進めやすい まとめ おわりに はじめに こんにちは。 開発本部開発3部トモニテ開発部所属の庄司( @ktanonymous )です。 直近で新規開発に携わる機会があり、「具体的な中身を作っていくのは AI」という前提のもとで開発環境全体を設計してみました。 本記事では、どのような環境を構築したのか、実際の開発フローはどうなっているのか、実際の開発を通じて感じている課題について紹介したいと思います。 なお、本記事の内容は 2026 年 9 月 14 日時点の情報に基づいています。 前提 プロジェクト概観 今回のプロジェクトは、既存のコードやインフラを持たない状態から立ち上げた Web アプリケーションの開発でした。 完全な新規開発ということで、技術スタックもゼロから選定していて、主な構成は以下のようになっています。 なお、エブリーでは全社員に Claude が配布されているため Claude を前提として設計していますが、設計の考え方自体は Claude であることに依存しないと思います。 モノレポ: pnpm + Turborepo ランタイム: Cloudflare Workers(API と SPA を単一の Worker に統合) バックエンド: Hono + Hono RPC フロントエンド: React + TanStack Router + Vite データベース: PlanetScale for Postgres(ORM は Drizzle) 認証: Better Auth UI: Tailwind CSS v4 + shadcn/ui ツールチェーン: oxlint / oxfmt、TypeScript 7、Vitest また、過去にチーム内で実装サイクルにおける AI 活用を設計していたこともあり、今回の設計のベースとして活用しています。 下記の記事でチームでの実装サイクルの AI 化の取り組みについて取り上げていますのでぜひご覧ください。 tech.every.tv AI ネイティブな開発環境を実現するために 具体的な作業は AI に任せる 意思決定の記録や要件の実現などの具体的な作業を AI に安心して任せられるように、まず初めに AI が判断に使うためのドキュメント群を整備することに時間をかけました。 結果的に、skills 13 個、agents 8 個、hooks 2 個という構成に着地しました。 ドキュメントや AI 設計の詳細については後述します。 skills 開発フロー全体を役割ベースで分解し、大きく 3 つの系統に分離した skills を定義しています。 要件定義: write-requirements (統括)、 draft-requirements (執筆)、 evaluate-requirements (採点)、 draft-design (外部設計) 実装・レビュー: write-tasks (タスク分割)、 implement-task (統括)、 review-implementation (統括)、 audit-architecture-approach (規約の監査)、 review-protocol (レビュー系エージェント共有の規約) ドキュメント: write-adr 、 write-ubiquitous-language 、 write-okf-document 、 write-architecture-approach 必要に応じてワークフロー全体をコントロールするオーケストレータのスキルと、実際に作業を行うスキルやエージェントを分離しています。 たとえば write-requirements では要件定義書の実体を自分で書かせることはせず、 執筆は draft-requirements に、採点は evaluate-requirements に委譲し、返ってきた結果を集約してループを判定します。 implement-task も同様に、自分ではコードを書かず、実装は feature-implementer エージェントに、レビューは review-implementation スキルに委譲します。 オーケストレータのスキルはいずれも「統括 → 執筆・評価への委譲 → 集約」という同様のフローで動かします。 もう 1 つ意識したこととして、採点基準や合否条件を執筆側・実装側に渡さないことがあります。 draft-requirements と evaluate-requirements は context: fork で独立したコンテキストとして実行され、執筆側にはスコアの基準となる情報を渡しません。 implement-task も、ループの終了基準(合否条件・上限回数)を feature-implementer への委譲メッセージに含めない設計にしています。 これは、作業者側が採点基準そのものに最適化してしまうことを防ぐためであり、指標そのものを目標にして指標が機能しなくならないようにしています。 出力の形式と品質基準がブレないようにするため、出力の期待結果を書いた evals/ や参照資料 ( references/ ) 、出力例 ( assets/examples/ ) も作成しています。 また、frontmatter で model と effort を明示するようにしています。 agents agents は 8 個あり、役割に応じて model と effort を frontmatter で指定しています。 エージェント model effort 役割 feature-implementer sonnet high 実装(1 起動 1 タスク) backend-reviewer opus high バックエンド層のレビュー frontend-reviewer opus high フロントエンド層のレビュー test-reviewer opus high テスト戦略のレビュー contract-and-types-reviewer opus xhigh 層をまたぐ型安全性のレビュー convention-auditor opus xhigh 規約文書自体の監査 finding-verifier sonnet medium 指摘の独立再検証 okf-document-manager haiku 指定なし ドキュメント一式の作成・更新(形式は後述) 作業と評価を同じモデルにすることで出力に対するバイアスがかかってしまうことを考慮し、作業と評価の間では必ずモデルを変えるようにしています。 hooks hooks では、以下のように機械的に判定できる制約に関する役割を持たせています。 PreToolUse hook ツールの実行直前に呼ばれます。 レビュー系のエージェントについては、ファイルを書き換える操作を拒否し、コマンドの実行も lint・型チェック・テストに限って許可します。 これにより、レビュアーが「read-only である」ことを保証します。 Stop hook セッションを完了しようとしたときに呼ばれます。 レビューを経ていない実装の変更が残っている場合には完了をブロックし、レビューを飛ばして作業を終えられないようにしています。 ただし、ブロックが一定回数続くと Claude Code 側が完了を許可する仕様のため 1 、絶対の保証ではありません。 ドキュメントも AI-Friendly な構成にする ドキュメントの基本フォーマットとして、Google が提唱している Open Knowledge Format(OKF) 2 を採用しました。 OKF は、YAML frontmatter 付きの Markdown ファイルのディレクトリとして知識を表現する仕様で、 index.md (一覧)と log.md (変更履歴)を併設した「バンドル」という単位で管理します。 ただし、すべてのドキュメントを OKF にしているわけではありません。 OKF を適用しているのは 1 件ずつ独立した知識単位であり、分類とクロスリンクが必要なドキュメントである ADR(Architecture Decision Record)とユビキタス言語としています。 例えば、ユビキタス言語は、業務領域ごとに複数のコンテキストへ分けて管理していて、実装規約は「原則層 + 具体ルール層(バックエンド・フロントエンド・型契約・テスト戦略)」の 2 層で構成しています。 一方で、フォーマットを揃えること自体が目的ではなく、ドキュメント管理の効率化が目的のため、散文主体の文書や時系列の文書は通常の Markdown のままにしています。 これらのドキュメントの作成・更新も skills と agents で行います。 ADR とユビキタス言語では、それぞれ専用のスキルが人間へのヒアリングとドキュメント固有のテンプレート・検証を担当し、OKF 作成の基本作業は共通のエージェント( okf-document-manager )に委譲します。 実装規約は執筆専用のスキルが作成・更新し、規約自体の監査は後述するレビューの構成の中で行っています。 ドキュメント作成に関わる skills / agents 実際の開発フロー 開発フローを構成する skills と agents の呼び出しの関係は以下のようになっています。 上にオーケストレータのスキルがいて、その下に委譲先となる skills / agents が示されています。 矢印には呼び出しに使うツールと受け渡すファイルを書いています。 開発フローを構成する skills と agents メインのセッションはオーケストレーションをメインタスクとし、そこからタスクに応じてサブエージェントを起動させます。 要件定義 要件定義は write-requirements スキルが統括します。 流れは「ヒアリング → 執筆 → 採点」のループです。 write-requirements が人間にヒアリングを行い、回答を inputs.md に記録する(ヒアリングをスキップして、あらかじめ用意した情報から自動で進めることも可能) draft-requirements が EARS(Easy Approach to Requirements Syntax)記法で要件定義書を執筆する。要件ごとに信頼度マーク(確認済み / 推測 / 業務上の未確認)を付与する evaluate-requirements が 8 観点 100 点満点で採点する 完成条件を満たすまで、採点結果のフィードバックを付けて執筆に戻す(採点は最大 3 回、執筆は最大 5 回) EARS 記法は、「〜のとき、システムは〜しなければならない」のように条件と振る舞いを定型文で書く記法です。 完成条件は「総合 85 点以上、かつすべての観点で B 評価(配点の 70%)、かつ業務上の未確認が 0 件」としています。 ここでは以下のような観点と配点でスコアリングしています。 業務要件の網羅性: 20 点 要件の具体性・非曖昧性: 20 点 受入基準の検証可能性: 20 点 異常シナリオの業務列挙: 10 点 業務由来の非機能目標: 5 点 トレーサビリティと名称体系: 10 点 根拠と信頼度の識別: 10 点 スコープの明確さ: 5 点 タスク分割 要件定義が完成したら、 write-tasks スキルで 1 日以内に終わる粒度を目安とした小さいタスクに分割していきます。 分割の原則として以下の 2 観点を取り入れています。 レイヤーごとの横割り(Repository 層・Service 層・Handler 層で別タスク)ではなく、ユースケース単位で入力からロジック、永続化、出力、テストまでを 1 タスクとする カバレッジゲート: すべての要件・受入基準・横断要件について、それを主に担当するタスクが 1 件以上あることを生成時に検証する。欠けている場合はタスクファイルを出力しない 各タスクの実装サイクル 各タスクは implement-task スキルが統括します。 このスキル自身はコードを書かず、以下のサイクルを回します。 要件・タスクを確認し、対象タスク 1 件だけを feature-implementer に渡す。実装規約は全文を渡さず、そのタスクに関係するルールだけを要約して渡す feature-implementer が実装し、フォーマット・lint・型チェックまで通す review-implementation スキルでレビューする(実装とレビューは別モデル) 実装を止めるべき指摘(後述する規約準拠の指摘)のうち、根拠まで確認できたものが残っていれば、修正して 3 に戻る(最大 3 回) エージェントで判断できない部分だけ人間が確認する 実装を止めるべき指摘が残っておらず、オーケストレータが実行する lint・型チェック・テストがすべて通れば、そのタスクは完了です。 前述の Stop hook がレビューを経ていない変更での完了をブロックするため、レビューのサイクルを飛ばして終わることがないようになっています。 レビューの構造 レビューに関わる skills / agents / hooks の構成は以下のようになっています。 review-implementation が実装のレビューを、 audit-architecture-approach が規約ドキュメント自体の監査を統括し、どちらの指摘も finding-verifier が再検証します。 レビューに関わる skills / agents / hooks の構成 review-implementation の処理は以下の順で進みます。 変更されたファイルのパスで対象レビュアーを決める 該当するレビュアーを並列に起動する。全員 read-only で、lint・型チェック・テストの結果で指摘を裏付ける すべての指摘を finding-verifier に一括で渡し、独立に再検証する。指摘ごとに信頼度(根拠まで確認できたか、可能性が高いにとどまるか)を付け直し、偽陽性を棄却し、意味的に重複する指摘を統合する。(ここでは新しい指摘は出さない) 検証済みの指摘を 2 系統に分けて出力する 1. のレビュアーの選び方は以下の通りです。 バックエンド層の変更があれば backend-reviewer 、フロントエンド層の変更があれば frontend-reviewer を起動する テストの変更または欠落があれば test-reviewer を起動する 共有パッケージの変更があれば contract-and-types-reviewer を必ず起動する。バックエンドの変更で API の公開面(ルートやレスポンスの型)が変わる場合や、フロントエンドの変更でクライアント側の型の使い方が変わる場合にも起動する 無関係なレビュアーは起動しない方針だが、判断が曖昧な場合は広めに起動する 4. の 2 系統というのは「規約準拠の指摘」と、本プロジェクトで「規約疑義」と呼んでいる指摘です。 規約準拠の指摘は「実装が規約に従っていない」という指摘で、実装のブロッキング要因になります。 規約疑義は、レビュアーが「実装ではなく規約側に誤りや陳腐化がある可能性」を検出した指摘です。 規約疑義は実装をブロックせず、専用の置き場所に 1 件 1 ファイルで記録します。 蓄積された規約疑義は、 audit-architecture-approach スキルが convention-auditor を起動して規約文書自体を監査する際の入力になります。 規約の対象方針の最終判断は人間が行います。 「実装が規約に従っているか」と「規約自体が正しいか」は、この 2 系統に分けて扱っています。 この 2 つを別の指摘として扱うことで、規約が間違っているときに実装側を直してしまったり、規約を疑って実装が進まなくなったりしないようにしています。 所感 ここからは、実際に運用してみて感じていることについて書いていきます。 skill / agent 化の効果 ドキュメント作成から実装・レビューまでのすべてを skill / agent 化したことで、自分で手を動かして作業する時間はほぼなくなりました。 実際に自分で手を動かすのは、要件を固める部分などドキュメントをより精密なものにしていく部分がほとんどです。 開発開始時点でまとまった時間をドキュメント整備に費やし、いわゆるハーネスを整備したことでより根幹の部分に集中することができていると思います。 コンテキストが大きくなりがち 一方で、AI に渡すコンテキストは大きくなっているような気がしています。 整備したドキュメントもありますが、実装から修正までのループを繰り返す中でコンテキストが徐々に大きくなっていく影響もあると考えています。 まだ着手できていませんが、ドキュメントやコードの関係性をグラフ化して効率的にコンテキストをコントロールできるようにしていきたいと思っています。 完了までの時間がかかる 1 タスクあたりの所要時間は、体感でだいたい 1 時間前後になっています。 依存関係を持つタスクを進める場合にはかなりの時間を要してしまいます(それでも AI がなかった頃に比べれば圧倒的に早いですが)。 トークン消費が激しい トークンが足りないと感じることも増えてきました。 トークンリミットで作業が止まってしまうと中途半端な状態で止まってしまい、セッションの文脈も考慮した時に手戻りもしづらい状態になってしまいます。 そのため、サイクルの途中で止まると、最初からやり直すか、リセットまで待つかしかなくなります。 良くも悪くもスクラップ&ビルドで進めやすい 作り直しの判断が軽くなるのは、利点でもあり欠点でもあります。 プロジェクト進行という観点では、実際に動くものを早く見られるので、改善ループが回しやすくなりました。 一方で、既存のものを大きく壊す変更は、AI にとっても推論の負荷が高くなりやすいと思います。 各セッション・各プロンプト(場合によってはプロジェクトレベル)で「何を変えてよくて、何を変えてはいけないか」を丁寧に設計して意図しない変更が行われないように意識する必要があります。 まとめ トークン効率化の手法は色々あると思いますが、どの程度効率化できるのか、手持ちの資源に対する充足度はどうなのか、今後どこまで AI に任せるのか、考えないといけないことは多いと感じています。 また、AI プラットフォーム側の一時的な障害で作業が止まることも実際にあり、AI 前提の仕組みにすればするほど、AI サービス側の可用性がそのまま開発にも影響するようになることを改めて実感しました。 昨今コンテキストの効率化がホットトピックではありますが、そこを目指す以前の指標として効率化を意識するくらい使い切れているのかという視点もあるかと思います。 そういう意味では、AI を前提とした開発環境を整備したことで「使い倒す」という観点において一定の成果を出せたのではないかと感じていますが、ここからさらに踏み込んで効率的な AI 開発環境を目指すことが重要になっていくと考えています。 おわりに 今回は、新規開発のプロジェクトで構築した AI ネイティブな開発環境と、実際の運用の中で感じている課題について整理してみました。 まだまだ試行錯誤の部分も多いですが、キャッチアップや検証を繰り返してより良いものにしていきたいと思っています。 この記事が、AI を活用した開発環境を整備している方の参考になれば幸いです。 最後まで読んでいただき、ありがとうございました。 Best practices for Claude Code (2026年9月14日閲覧) 。「Claude Code overrides the hook and ends the turn after 8 consecutive blocks.」と記載されています。 ↩ GoogleCloudPlatform/open-knowledge-format (2026年9月14日閲覧) 。採用時点の仕様は v0.1 で、現行の v0.2 は v0.1 を置き換えるマイナーバージョンアップとして公開されています。 ↩
Go Conference 2026 目次 はじめに エブリーのスポンサーブース アンケートボード 食クイズ・くじ引き ノベルティ セッション紹介 Go における FFI のこれまでとこれから cgo とその課題 WebAssembly を使った FFI wasmify と wasm2go 感想 ワークショップ紹介 go.devの歩き方、その先へ 〜Go公式リソースの旅。明日からの調べ方を手に入れるワークショップ〜 参加した理由 ワークショップの流れ 設問2 の調査 ワークショップで得たこと Go × SIMDで高速化するベクトル検索 ~ ルーフラインモデルでSIMDが効く境界を探れ! SIMDとは GoからSIMDを使う ベンチマークで効果を確認する int8量子化でデータ量を減らす ワークショップを体験して まとめ 非公式アフターイベント Go BASH Vol.3 のお知らせ 最後に はじめに 2026年9月11日(金)に中野セントラルパークで開催された「Go Conference 2026」に今年も参加させていただきました! 今年も参加レポートとして、会場の様子やセッションの感想についてお届けします! gocon.jp 今年のテーマは「 Go Far, Go Together 」でした。 Go Far, Go Together このテーマの通り、人との繋がりを重要視していることが強く感じられるカンファレンスだったと思います。 sanposhiho さんのKeynoteセッション「Open Source, Open World」に始まり、 speakerdeck.com コミュニケーションスペースのGo Context Wall、伝Go板、スポンサーブース、難易度別セッション・ワークショップ、そして最後は懇親会と、Goを始めたての人でも、熟練の方でも最初から最後まで楽しめたカンファレンスだったのではないかなと思います。 エブリーのスポンサーブース エブリーは今年は Silver スポンサーとしてブースを出展させていただきました! 弊社サービスであるデリッシュキッチンをイメージした黄色基調のブースとなっています。 エブリーブース ブースに足を運んでいただいた皆様、本当にありがとうございました! アンケートボード 昨年に引き続きアンケートボードを用意しました! 「あなたのGo興味教えてください」というテーマで、Go歴 (Goの利用歴)と気になるトピックが交わる場所にシールを貼っていただきました。 最終結果はこちらです! アンケート結果 Go歴0〜15年以上の方まで幅広いGopherに回答いただきましたが、Go歴0〜1年の方は設計思想、ある程度経験のある方は最新のGo1.27のトピックに関心が若干寄っているところが面白い部分でした。 またどのGo歴の方もGoとAIに対して関心があり、どんなAI Agentが社内で使われているのか、またどのようにHarnessを作っているのかといった話題についてブースではたくさんお話しすることができました! 食クイズ・くじ引き 今年から導入した新しいブース企画である、クイズも非常に盛り上がりました! クイズは食に関する問題2問、Goに関する問題2問の計4問で構成されています。 最終問題のGoの実行結果を答えるクイズで、言語仕様を理解していないと解けなかったり、考えたことがなかったユースケースに対して回答する必要があったりと、難しめに設定しただけあって苦戦している参加者の方も多く見られました。 クイズ ノベルティ 景品は軽量スプーン、まな板、菜箸、しゃもじなど普段の料理で使えるキッチングッズです。 デリッシュキッチングッズ ハズレを引いた方にも、弊社CTOが自らテイスティングして選んだ「CTOブレンド」のコーヒーとステッカーをお渡ししました。 CTOブレンド CTOブレンドの制作秘話については下記を参照ください。 人々へ明るい変化を提供する、オリジナルブレンドコーヒー「every CTO Blend」を制作 キッチングッズが当たった方からは、「最近自炊を始めたのでこれを使って頑張ります」といった感想や、「まさに今欲しかったものなので嬉しいです」といった感想をいただけて運営としても嬉しかったです! また、このくじ引きで当選したまな板を今でも使っているという方もいたりと、何度もイベントに出展しているとこういうこともあるのかと嬉しくなりました。 セッション紹介 Go における FFI のこれまでとこれから 発表者: goccy さん(株式会社LayerX) レポート: あかがわまさとも スライド: speakerdeck.com 食事管理アプリ ヘルシカ を担当している あかがわまさとも です。私からは、goccy さんの発表「Go における FFI のこれまでとこれから」について紹介させていただきます。 FFI(Foreign Function Interface)は、同一プロセス内で、ある言語で書かれたプログラムから他の言語で実装された機能を呼び出すための仕組みです。本セッションでは、従来の cgo を使う方法とそれに対する課題から、goccy さんによる WebAssembly を活用した新しい方法までを紹介していただきました。 cgo とその課題 スライド 3〜15 ページ FFI での呼び出しは、ABI(Application Binary Interface)という「バイナリ間で関数を呼び出すための約束」に合わせて行う必要があります。Go では、この ABI に合わせた呼び出しを cgo が引き受けています。 発表の序盤では、cgo が抱える課題が3つの観点から整理されていました。 メモリ管理 : Go と C ではメモリの管理が分かれているためメモリリークが起きやすく、C 側でセグメンテーション違反(SIGSEGV)が起きると Go のプロセスごと落ちます。さらに両者はアドレス空間を共有しているので、C 側の不具合で Go 側のメモリを読み書きできてしまいます。 型変換 : 構造体へのポインタや、コールバックのための関数ポインタの受け渡しが複雑になります。相手が C++ だとさらに難しくなります。 開発・デプロイ : C コンパイラが必要になるため Go のクロスコンパイルの手軽さが失われ、Go のプロファイラやデバッガも C 側のコードには使えません。 そのうえで、ポインタのライフサイクル管理、コールバックの実装、静的リンクによるシングルバイナリ化という3つのテクニックが紹介されました。ただしこれらは対症療法で、メモリの管理が分かれていることや C コンパイラが必要になることは、cgo を使う限り変わりません。 普段は Go だけで完結する開発をしているので cgo を書く機会は全くないのですが、課題を詳しく紹介していただき、あまり活用されていない現状や難しさにかなり納得しました。 WebAssembly を使った FFI スライド 16〜23 ページ ここまでの課題を踏まえて、「メモリを安全に扱いたい」「Go のクロスコンパイルの恩恵を受けたい」という動機から WebAssembly(WASM)を使う方法が紹介されました。 WASM モジュールは、自分専用に割り当てられた連続したメモリ領域(リニアメモリ)しか読み書きできないため、ホストとアドレス空間が完全に分離されます。範囲外アクセスはプロセスのクラッシュではなく Go のエラーになります。システムコールも、ホストが許可したものだけが WASI 経由で実行されます。WASI は WebAssembly System Interface の略で、WASM からファイルやネットワークといったホスト側の機能を使うための標準インターフェースです。Go 側は WASM を embed で埋め込み、Pure Go のランタイムである wazero で実行するので、先ほどの課題の多くはここで解消されます。 ただし、C/C++ プロジェクトから WASM を作ること自体が難しく、アドレス空間の異なるホストとのブリッジを書くのも簡単ではありません。cgo なら文字列のアドレスと長さを渡すだけで済むところが、WASM ではリニアメモリ上に領域を確保し、そこにホスト側から書き込む必要があります。安全のために境界を引いた分、その境界をまたぐコードは自分で書くことになります。 wasmify と wasm2go スライド 24〜50 ページ 発表の後半は、この境界を越える手間を減らすために goccy さんが開発したツールの話でした。 wasmify は、C/C++ ライブラリから FFI 用途の WASM とブリッジコードを生成する手順を抽象化した、AI エージェントのハーネスとして利用できるツールです。プロジェクトごとに大きく異なるビルド手順の部分は AI に吸収してもらい、正規化した設定をファイルに残すことで、CI などでは AI なしで同じ結果を再現できるようにしています。非決定的な部分だけを AI に任せ、その結果を固定して冪等性を担保するという設計は、FFI に限らず AI を開発に組み込むときの考え方として参考になりました。 ところが、wasmify で作り直した Pure Go 版の bigquery-emulator(BigQuery のローカルエミュレータ)については、リリースの翌日からパフォーマンス低下の報告が相次いだそうです。あるケースでは 0.6 秒だった処理が 17.3 秒と、約30分の1の速度になっていたとのことでした。この問題への対処として開発されたのが wasm2go で、WASM を Go と Plan9 asm に変換してしまうというアプローチです。メモリが分離されているという WASM の性質は保ったまま、変換後は WASM 由来の実行時の制限を受けないため、最適化の余地が生まれます。 しかし、wasm2go にも課題はありました。たとえば、変換後の Go コードが巨大になるため、そのままではメモリ不足でコンパイルが通らなくなってしまいます。これに対しては、コンパイルの単位を分けて依存関係を直列にし、そこで生じる循環参照を go:linkname で回避するという解決策が取られていました。 go:linkname は、呼びたい関数が定義されている package を import せずにその関数を参照できるコンパイラディレクティブです。ほかの課題への対処も含め、どれも Go の処理系やツールチェーンの挙動を踏まえた解き方で、ここは聞いていて一番面白かったです。 最後に、wasm2go の成果物として go-googlesql や go-python、go-llama といった Pure Go 実装が公開されており、これらを Go から組み合わせて呼び出す Cross Language Binding や、Go のための Agent Sandbox といった応用先も紹介されました。 感想 goccy さんはほぼ毎回 go:linkname の話をされているそうです。発表の本筋ではないですが、今回も後半で循環参照の解消の文脈で登場しました。初めて goccy さんの発表を聞く機会を頂いたのは、今年2月の Go Conference mini in Sendai 2026 でしたが、当時は何のことか分からず困惑した状態で聞いたのを覚えています。 speakerdeck.com 今回はこの go:linkname が何かを知った状態で臨むことができたので、前回よりも楽しく拝聴させていただきました。人の話を聞いて知るきっかけをもらい、前よりも技術を楽しめるようになって、またそこで知らないことを知る。この繰り返しが起きるのが、カンファレンスという場所の素敵なところだなと思います。 goccy さん、興味深い発表をありがとうございました!そして、エブリーブースにも来ていただきありがとうございました!直接感想を伝えられて嬉しかったです! ワークショップ紹介 go.devの歩き方、その先へ 〜Go公式リソースの旅。明日からの調べ方を手に入れるワークショップ〜 講師: Koki Narumi さん(ANDPAD) レポート: 野村 こんにちは。開発1部でデリッシュキッチンのプレミアム機能を開発している新卒エンジニアの 野村 です。 私はワークショップ「go.devの歩き方、その先へ」に参加しました。 参加した理由 Go 歴は 4 ヶ月ほどです。 Go でわからないことがあったとき、AI に聞いて済ませることもできますが、自分の手で調べた方が理解は深まると感じていました。 AI の回答が間違っている可能性を頭に置きながら進めたり、その回答が正しいかを確かめたりするためにも、公式ドキュメントを調べる力があった方がよいと考えて参加しました。 ベテランの方々が公式ドキュメントをどのように読んでいるのかにも興味がありました。 ワークショップの流れ 当日は andpad-dev/gotan リポジトリの教材に沿って、次の流れで進みました。 リポジトリの説明 初級、中級、上級のレベルごとに分かれて 4 人の班を組む 班ごとに GitHub の Issue を 1 つ持ち、そこに自己紹介を書き込む 班でカテゴリとテーマを選ぶ 設問ごとに 2 人に分かれて調査し、調査結果を Issue に書き込んでいく 答え合わせ 私は Go 歴が浅いので初級を選びました。 カテゴリは次の 4 つから選びます。 Go の標準パッケージ Go の機能や文法構造 Go のコマンド Go の言語仕様、標準ライブラリの実装、設計の背景 私たちの班は「Go のコマンド」を選びました。 初級には 3 つのテーマがあり、その中から go run のテーマ を選びました。 テーマには設問が 3 つあり、私は設問2「go run でビルドされた実行ファイルや $WORK ディレクトリは、実行後どうなるか」を担当しました。 各設問にはヒントと答えが用意されていて、必要になったときに読める形になっています。 設問2 の調査 ここでは、設問2 をどう調べていったかを紹介します。 設問1と設問2はつながっているので、まず設問1にも目を通しました。 最初は手がかりがなかったため、答えに近づきすぎないようにヒントをざっと眺め、そこに書かれていたコマンドをまず実行してみることにしました。 go run -a -x main.go 2 > first.log この -a と -x が何をするフラグなのかを調べました。 調べ方は 03-cmd-tools の README にガイドがあり、それを見ながら進めました。 go help build でも読めますが、今回は pkg.go.dev/cmd/go をページ内検索して確かめました。 -a : すでに最新状態にあるパッケージも強制的に再ビルドする -x : 実行するコマンドを表示する first.log を開くと、1 行目に $WORK ディレクトリのパスが書かれていました。 WORK=/var/folders/bj/1bpczlgx0nxd7gyh6k1gxnhm0000gp/T/go-build2456729570 ここで $WORK に移動しようとしましたが、このディレクトリはすでに存在しませんでした。 cd: no such file or directory: /var/folders/bj/1bpczlgx0nxd7gyh6k1gxnhm0000gp/T/go-build2456729570 first.log の末尾を見ると、実行ファイルを cp している行があったので、そのコピー先に移動してみました( 班の調査ログ )。 cp $WORK/b001/exe/main /Users/yuto.nomura/Library/Caches/go-build/96/9677dc4b2f735a2ab8ac9be91e1f1c1ce061dcd1e81e39106ac0b016771e252e-d/main # internal $WORK/b001/exe/main コピー先には main の実行ファイルが残っていました。 つまり $WORK ディレクトリは削除されているが、実行ファイルはキャッシュとして残っている、ということになります。 次に、手詰まりだったのでもう一度ヒントを読みました。 次のようなヒントに注目しました。 go help build のフラグ一覧を眺め、一時ディレクトリに言及しているフラグを探してみましょう。見つけたフラグの説明文を読み、それが「デフォルトでは行われないことを追加で行う」フラグなのか、「デフォルトの動作を止める」フラグなのかを見分けてみましょう。 そこで、再び pkg.go.dev/cmd/go でフラグ一覧を調べました。 すると -work というフラグがあり、「一時作業ディレクトリの名前を表示し、終了時にそれを削除しない」と説明されていました。 この一時作業ディレクトリが、先ほどから調べていた $WORK ディレクトリです。 つまり、 -work を付けないと $WORK は実行後に削除されます。 そこで -work を付けて実行しました。 go run -a -work main.go 今度は $WORK ディレクトリに移動でき、中に実行ファイルがありました。 ここで時間がなくなり、答え合わせの時間になりました。 答え合わせの中で、 -a とキャッシュの関係を理解しました。 次の 2 つのコマンドを実行して比べます。 go run -a -work main.go go run -work main.go -a ありでは $WORK の中に中間ファイルと実行ファイルがありましたが、 -a なしでは $WORK の中は空でした。 答えの中で参照されていた Go 1.24 のリリースノート を読みました。 次のように書かれています。 Executables created by go run and the new behavior of go tool are now cached in the Go build cache. This makes repeated executions faster at the expense of making the cache larger. See #69290. つまり、Go 1.24 から go run でビルドした実行ファイルもビルドキャッシュに保存されるようになった、ということです。 -a を付けると $WORK の中でビルドが実行され、できた実行ファイルがキャッシュに残ります。 -a を付けない場合、ソースに変更がなければビルドを省略してキャッシュ済みの実行ファイルをそのまま実行するため、 $WORK は作られるものの中身は空になります。 以上から、設問2の答えは次の 3 点です。 何もフラグを付けない場合、 $WORK ディレクトリは一時的に作成され、実行が終わると削除される $WORK の中にあった実行ファイルは、ビルドキャッシュのディレクトリに保存される -work を付けると、 $WORK ディレクトリが削除されずに残る ワークショップで得たこと 今回のワークショップでは、 go help と pkg.go.dev を頼りに公式ドキュメントを読み進めることができました。 公式ドキュメントの読み進め方という点では、次のことを意識しました。 go help コマンドを初めて触り、その中身を理解するというステップを踏んだことで、公式ドキュメントを読むことへのハードルが少し下がりました。 公式ドキュメントは翻訳して読んでよいと知ったことでもハードルが下がり、調べやすくなりました。 そのうえで、ページ内検索で公式ドキュメントを検索しながら地道に読んでいくことを意識して取り組みました。 今回のテーマがコマンドツールだったので、コマンドツールの調べ方として意識したこともあります。 go run を実際に動かして観察し、フォルダやファイルが存在するかを確かめたり、フラグを付けて挙動がどう変わるかを見たりしました。 加えて、普段使っているコマンドツールはどのように動いているのだろうと自分なりの仮定を持ってみると、疑問が浮かび、より良い調査ができると感じました。 一次情報を押さえながら進めたので、理解が積み上がっていく感覚がありました。 一次情報にあたる経験に加えて、 go run の仕組みへの理解も深まり、他のコマンドも同じやり方で調べてみたくなりました。 今後 Go を学ぶときにも、この調べ方を使っていきたいです! Go × SIMDで高速化するベクトル検索 ~ ルーフラインモデルでSIMDが効く境界を探れ! 講師: Hiromu Nakamura さん レポート: 黒髙 こんにちは、開発本部の 黒髙 です。普段は デリッシュキッチン の開発に携わっています。 私は、 Hiromu Nakamura さんによるワークショップ「 Go × SIMDで高速化するベクトル検索 ~ ルーフラインモデルでSIMDが効く境界を探れ! ~ 」に参加しました。 このワークショップでは、GoでSIMDを使う方法に加えて、計測結果からボトルネックを見極め、次の高速化手法を選ぶ進め方を学びます。教材本編は、次の流れで構成されています。 ベクトル検索とSIMDの仕組み、Goでの使い方を知る 「ルーフラインモデル」で計算能力とメモリ帯域から性能の上限を考え、マシンの上限を測る スカラ版の全探索を基準として測る(Stage 0) 内積の計算をSIMDで高速化する(Stage 1) int8量子化でデータ量を減らす(Stage 2) 1bit量子化でさらにデータ量を減らし、速度と検索精度の変化を見る(Stage 3) 絞り込んだ候補を元のfloat32で再採点し、検索精度を回復する(Stage 4) 私はStage 2まで取り組みました。ここでは、SIMDの機能と、実際に確認できた高速化の効果を中心に紹介します。 資料と教材は以下で公開されています。 speakerdeck.com github.com 題材は、384次元のベクトル10万件から、クエリとの内積が大きい上位10件を探す処理です。ベクトル検索では、文章などを数値の並びで表し、その近さを使って検索します。 SIMDとは SIMD (Single Instruction, Multiple Data)は、1つの命令で複数のデータに同じ演算を適用するCPUの機能です。その働きを、今回のワークショップで扱う内積計算を例に見てみます。 内積は、2つのベクトルの同じ位置にある要素を掛け合わせ、その結果をすべて足す計算です。8要素のベクトルなら、次のようになります。 a = [1, 2, 3, 4, 5, 6, 7, 8] b = [2, 3, 4, 5, 6, 7, 8, 9] 内積 = 1×2 + 2×3 + 3×4 + 4×5 + 5×6 + 6×7 + 7×8 + 8×9 = 2 + 6 + 12 + 20 + 30 + 42 + 56 + 72 = 240 Goで素直に書くと、次のようになります。 var sum float32 for i := range a { sum += a[i] * b[i] } このループでは、 a[i] と b[i] を1組ずつ掛け、その結果を1つの変数 sum に足していきます。8要素なら、この処理を8回繰り返します。このように、演算で値を1要素ずつ扱うのが スカラ処理 です。 一方、8要素を扱えるSIMD命令なら、 1×2 から 8×9 までの掛け算をまとめて実行できます。掛け算の部分に注目すると、スカラ命令では8回かかるところを、SIMD命令なら1回で処理できます。掛け算命令の実行回数は1/8です。 SIMDで8組の掛け算を1命令で実行し、結果を合計して内積を求める模式図 図1:SIMDで8組の掛け算をまとめて実行し、その結果を合計して内積を求めます。 どちらも8組の掛け算を行いますが、SIMDではそれを1つの命令にまとめられます。SIMDでまとめて掛けた結果は [2, 6, 12, 20, 30, 42, 56, 72] という8個の値なので、内積を得るには最後にこれらを合計する処理が必要です。 このように、同じ演算を大量の要素に繰り返す処理で、1命令あたりに処理できる要素を増やせることがSIMDの便利な点です。今回の検索では、384要素の内積を10万件のベクトルに対して繰り返すため、SIMDによる高速化が期待できます。SIMDは1つのCPUコアの中でも利用できる仕組みです。 GoからSIMDを使う Go 1.27では、実験的なパッケージ simd/archsimd を使い、Goの型やメソッドでCPU固有のSIMD演算を扱えます。 archsimd 自体はGo 1.26で導入され、1.27ではAPIの改訂やarm64・WebAssemblyへの対応が追加されています。まだAPIは安定しておらず、ビルド時に GOEXPERIMENT=simd を指定して有効化します( Go 1.27リリースノート )。 CPUには、計算中の値を置く レジスタ という小さな記憶領域があります。今回のamd64向け実装では、256bit幅のSIMDレジスタを利用します。 float32 は1個32bitなので、1つのレジスタに8個の値が入ります。この1要素ぶんの区画を レーン と呼びます。 Goでは、この「32bitの値を8レーン」という形を archsimd.Float32x8 という型で表します。先ほどの図と同じく、8要素をまとめて扱えます。次のコードは、384要素ある a と b のうち、先頭8要素を処理する部分を示したものです。 var acc archsimd.Float32x8 // 8レーンとも初期値は0 va := archsimd.LoadFloat32x8(a) // aの先頭8要素を読む vb := archsimd.LoadFloat32x8(b) // bの先頭8要素を読む acc = va.MulAdd(vb, acc) // 各レーンでva×vbをaccへ足す LoadFloat32x8 は、スライスから8要素を読み込む関数です。 va と vb にはそれぞれ8個の値が入り、 acc にも途中結果をためる場所が8個あります。 va.MulAdd(vb, acc) では、各レーンで「 va の値 × vb の値 + acc の値」を計算します。 MulAdd は、掛け算と足し算をまとめて行うFMA(Fused Multiply-Add)に対応します。 Float32x8 では、この積和演算を8レーンまとめて1つの命令で行います( MulAddのドキュメント )。 a と b の読み込む位置を進めながら同じ処理を繰り返すと、各レーンに積和の途中結果をためていけます。最後にレーンごとの値を合計すると、内積が求まります。 このようなSIMD命令を、アセンブリやcgoを自分で書かずに、Goの型やメソッドを通して利用できるのが archsimd の特徴です。 ベンチマークで効果を確認する ここからは、冒頭で紹介したStage 0(スカラ版の計測)→Stage 1(SIMD化)の流れに沿って、実際の効果を見ていきます。まず make bench0 でスカラ版の全探索の速度を測り、続く make bench1 でスカラ版とSIMD版を比較しました。 計測には、ワークショップで用意された教材のGitHub Codespaces環境を使いました。実行環境はLinux / amd64で、CPUはAMD EPYC 7763でした。 教材には、内積単体を測るベンチマークと、10万件すべてとの内積計算から上位10件の選択までを含む、検索全体のベンチマークがあります。SIMD版も全件を走査する流れは同じで、内積の計算をSIMDに置き換えています。実装では途中結果をためる変数を acc0 と acc1 の2本に分け、互いの結果を待たずに計算できるようにしています( 教材の内積の実装 )。 8要素をまとめて計算できても、処理全体がそのまま8倍速くなるとは限りません。以下は、 make bench1 で内積単体と検索全体を計測した結果です。 計測対象 スカラ版 SIMD版 高速化の倍率 float32の内積 393.8 ns 58.71 ns 約6.7倍 10万件の検索 36.10 ms 9.76 ms 約3.7倍 内積単体では約6.7倍、検索全体でも約3.7倍の高速化を確認できました。 内積単体の改善が、そのまま検索全体の倍率になるわけではないことが分かります。検索ではベクトルの読み込みも必要で、計算だけを速めても読み込みが追いつかなければ、CPUはデータを待つことになります。こうした性能の上限を考えるために、スライドではルーフラインモデルが紹介されていました。 Go × SIMDで高速化するベクトル検索 ~ルーフラインモデルでSIMDが効く境界を探れ! ~ - Speaker Deck この図は、横軸が算術強度、縦軸が1秒あたりの演算回数で、屋根の形をした線が性能の上限を表しています。 算術強度は「演算回数/バイト」、メモリ帯域は「バイト/秒」なので、両者を掛けると「演算回数/秒」になります。これが、メモリからのデータ供給によって決まる性能の上限です。 線が折れ曲がる点(リッジ)より左側では、算術強度 × メモリ帯域 < 演算ピークとなります。CPUの計算能力より、データを供給する速さが低い上限を作るため、この領域の上限はメモリ帯域によって決まります。算術強度を上げると、同じ転送量でできる計算が増えて上限も上がるため、グラフは右上がりの斜線になります。 リッジより右側では、算術強度 × メモリ帯域 > 演算ピークとなるため、CPUの計算能力が性能の上限を決めます。算術強度をさらに上げてもこの上限は変わらないので、グラフは水平になります。実際の計測点がこの線より下にある場合は、SIMDなどで計算を効率化し、上限に近づけられる可能性があります。 int8量子化でデータ量を減らす メモリから読み込めるデータ量には1秒あたりの上限があるため、同じ件数のベクトルでもデータ量が少なければ読み込みにかかる時間を短くできます。この読み込み時間を減らして検索を速めるのが、Stage 2(int8量子化)です。 この段階では、 make bench-int8 でint8量子化を使った実装を計測しました。量子化は、値を少ないビット数で近似して表す方法です。float32をint8に変換すると、走査するベクトル本体は153.6 MBから38.4 MBになります。 検索全体の時間は、Stage 1のfloat32 SIMD版の約9.76 msから、int8 SIMD版の約4.00 msへ短縮され、さらに約2.4倍速くなりました。量子化によるデータ量の削減と、int8向けのSIMD計算を組み合わせた効果です。 int8の内積単体でも、スカラ版の412.8 nsからSIMD版の32.17 nsへ、約12.8倍の高速化を確認できました。 ただし、量子化は値そのものを変えるので、全探索をしても、検索結果が正解とずれる可能性があります。そのため、教材ではRecall@10(正解の上位10件と何件一致したか)という指標を用いて、検索結果の精度も検証していました。この評価では、元のfloat32版で得られた上位10件を正解としています。 ワークショップを体験して 今回のワークショップでは、Goの実験的なSIMDパッケージを使い、ベクトル検索を段階的に高速化する方法を学びました。ルーフラインモデルで計算能力とメモリ帯域の制約を考えながら、SIMDによる内積の高速化から、量子化によるデータ量の削減へと進む流れでした。資料では、さらに1bit量子化と再採点を組み合わせて、速度と検索精度の両立を目指すところまで紹介されていました。 Goのコードを動かして内積や検索が速くなる様子を確かめ、SIMDによる並列化の恩恵を感じることができました。さらに、量子化でデータ量を減らすといった高速化の手法にも、手を動かしながら触れられてよかったです。 まとめ Go Conference運営の皆さん、今年もカンファレンスを開催していただきありがとうございました! 昨年との違いとして気づいたのは、今回はGo Conferenceだけでなく「DroidKaigiで弊社を見かけた」、「iOSDCにも参加予定」といったエンジニアの方が多数みられたことです。 DroidKaigi のアンケートでもある通り、どの企業様のエンジニアも越境する意識があるのだなと感じさせられました。 今年もたくさんのGopherとお話したり、セッションを拝聴したりと充実した1日を送ることができました! Go Conference 2027もぜひ参加したいです! 非公式アフターイベント Go BASH Vol.3 のお知らせ ANDPAD、OPTiM、Resilire、エブリーの4社合同で、非公式アフターイベント Go BASH Vol.3 を開催します! 2026年9月30日(水) 19:30〜、会場はエブリー本社です。Go Conference 2026 の感想戦や各社のセッションを用意していますので、ぜひご参加ください! connpass.com 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv 最後までお読みいただき、ありがとうございました!
はじめに この度、株式会社エブリーは、2026年9月11日(金)〜13日(日)に開催される「iOSDC Japan 2026」に、ゴールドスポンサーとして協賛することになりました! 弊社はこれまで Go Conference や TSKaigi などに協賛してきましたが、今年は9月頭の DroidKaigi 2026 に続いて、iOSDC Japan にも初めて協賛します。初めての参加ということで、ブースでどんな方々とお話しできるのか、今から楽しみにしています。 本記事では、開催概要と弊社のブース企画、iOSDCチャレンジのトークン紹介、そしてアフターパーティーのご案内をお届けします。 iOSDC Japan 2026 とは? iosdc.jp iOSDC Japan は、iOS 関連技術をコアテーマとしたソフトウェア技術者のためのカンファレンスです。会場でのトークセッションやスポンサーブースに加えてオンライン配信もあり、iOS エンジニアが年に一度集まる大規模なイベントです。 今年の開催概要は以下のとおりです。 開催日時 Day 0: 2026年9月11日(金) Day 1: 2026年9月12日(土) Day 2: 2026年9月13日(日) 開催場所 有明セントラルタワーホール&カンファレンス 開催形態 オフライン / オンライン(ニコニコ生放送) ブース出展日程 2026年9月11日(金)〜9月13日(日)の3日間 ブース企画 弊社のブースでは、次の2つの企画を用意しています。開発本部のメンバーが現地に参加しますので、ぜひお気軽にお立ち寄りください! アンケートボード「iOSアプリ開発、AIにどこまで任せていますか?」 「iOSアプリ開発、AIにどこまで任せていますか?」をテーマにした参加型のアンケートボードを設置します。 実装からレビュー、テストまで、日々の iOS 開発のどこまでを AI に任せているのか、エンジニア歴とあわせて皆さんのリアルな声をお聞かせください。 集計結果は、イベント後の事後レポート記事で公開する予定です。シールを1枚貼るだけで参加できますので、セッションの合間にぜひ立ち寄ってみてください! 新しくなったデリッシュAI をブースで触れます! また、iOS版でリリースしたばかりのデリッシュキッチンの新しい「デリッシュAI」を、実機でデモ展示します! 新しいデリッシュAI は、チャットで相談するとレシピを提案してくれる機能です。たとえば「子供に人気のレシピ教えて」と聞くと、まず「ハンバーグ / カレー / から揚げ …」と選択肢で聞き返してくれて、「ハンバーグ」を選ぶと、子供向けのハンバーグレシピをすぐに提案してくれます。その下には「もっと簡単」「野菜入り」のような次の絞り込みや、「こんな質問もできます」の候補が並ぶので、文字を打たなくてもタップだけで会話が進みます! 技術的には、サーバー側の AI エージェントが「どの UI をどう並べるか」まで生成し、iOS アプリがそれを SwiftUI で描画する、いわゆる Generative UI の作りになっています。 「LLM に UI を任せてデザインは崩れないの?」「サーバーが返した UI を SwiftUI でどう描画してるの?」と気になった方は、ぜひブースへ! 実機を触りながら、iOS エンジニアが設計の中身までお話しします。 デリッシュAI は最新版のアプリでご利用いただけます。iOSDC までにぜひ一度、「今日の夕飯、何がいいかな」と聞いてみてください! iOSDCチャレンジのトークン紹介 iOSDC Japan では、会場をはじめ様々なところに散りばめられた「iOSDCトークン」を探し、見つけた数に応じて抽選券を獲得できる「iOSDCチャレンジ」が開催されます。集めた抽選券は、会場の抽選カウンターでノベルティが当たる抽選に使えます。 弊社もこの企画に参加しており、トークンは全部で3つ用意しました。トークンは「#」から始まるスペースを含まない文字列です。この記事ではそのうち2つを見出しとして掲載し、残りの1つはブースで公開します。 #AIファースト・カンパニー 弊社は「AI ファースト・カンパニー」を掲げ、AI を前提に組織や仕事の進め方を組み替えている最中です。 Claude の全社導入で個人の AI 活用は一気に進みました。ここからは、そこで生まれたツールやプロンプト、スキルを個人に閉じたままにせず共有資産へ育てる「個人から組織へ」を進めきることと、AI に任せられる範囲が広がるなかで人間の役割を意思決定と設計へ寄せていくことが、次のテーマです。 #誰でも簡単においしく作れる デリッシュキッチンが大切にしているのは、「誰でも簡単においしく作れる」レシピ動画を届けることです。 料理が得意な人だけでなく、料理に苦手意識のある人、忙しい日の夕飯に悩んでいる人、料理を始めたばかりの人にも「今日はこれ作ってみよう」と思ってもらえるように、レシピの選び方から作り方の見せ方まで、サービスの体験全体をこの言葉を軸に作り込んでいます。 残りの1つはブースで 3つ目のトークンは、弊社のブースに掲示しています。 アンケートボードやデリッシュAI の展示を見に来ていただいたついでに、ぜひ探してみてください。 アフターパーティーのご案内 DroidKaigi 2026 の協賛記事でもご案内しましたが、ゆめみ、フェンリル、Yappli、WealthNavi、セーフィー、エブリーの6社合同で「DroidKaigi & iOSDC After Talks Night 2026」を開催します! DroidKaigi 2026 と iOSDC Japan 2026 の合同アフターパーティーですので、iOS エンジニアと Android エンジニアが同じ場に集まり、モバイルエンジニア全体で技術交流や LT などを楽しめる会になる予定です。 開催日時 2026年10月2日(金)19:00〜21:00 開催場所 住友不動産麻布十番ビル アクセンチュア・イノベーション・ハブ 東京 開催形態 オフライン / オンライン コンテンツ ・各社の Android & iOS に関するセッション ・懇親会 詳細・お申し込みは以下からご確認ください。皆さんのご参加をお待ちしています! yumemi.connpass.com おわりに 初めての iOSDC Japan への協賛ということで、チーム一同、当日をとても楽しみにしています。 ブースでは、デリッシュキッチンの iOS 開発の裏側や技術スタック、AI 時代のアプリ開発についてのご質問や雑談も大歓迎です。「トークンを探しに来た」「新しいデリッシュAI の技術的な中身を聞いてみたい」「ちょっとエンジニアと話してみたい」くらいの軽い気持ちで構いませんので、ぜひ弊社のブースに足をお運びください。 有明の会場、そして10月のアフターパーティーで、皆さんとお会いできることを楽しみにしています! 最後までお読みいただき、ありがとうございました!
Go Conference 2026 に 今年はSilver スポンサーとして協賛いたします! はじめに この度、株式会社エブリーは、2026 年 9 月 11 日(金)に開催される「Go Conference 2026」に、Silver スポンサーとして今年も協賛することになりました! Go Conferenceとは? gocon.jp プログラミング言語 ”Go”ユーザーのためのカンファレンスです。今年はハイブリッド開催で、会場でのセッションやワークショップに加え、セッションのオンライン配信も予定されています! 今年の開催概要は以下のとおりです。 開催日時 2026年9月11日(金) 開催場所 東京都中野区中野4丁目10番2号 中野セントラルパーク サウス 1F / B1F 開催形態 オフライン / オンライン(セッションのみ配信) コンテンツ ・基調講演 ・セッション ・ワークショップ ・Official Party(懇親会) 昨年は Go 1.24 / 1.25 の新機能や言語内部を深掘りする発表が中心でしたが、今年はすでにGoコミュニティに参加している方、はじめてGoコミュニティに参加する方、みんなの学びや好きがより遠く深く広がるようなカンファレンスになるよう「 Go Far, Go Together 」というテーマが掲げられています。 また、プロポーザルの審査基準にも「自身の経験、知見に基づく、独自性のある内容であるか」とあるように今年の タイムテーブル を見ると独自性に富んだテーマが多く見られます。 例えば、海上で動くGoサーバー、Go におけるコンソールゲーム開発、9年のOSS保守で見た標準ライブラリとtestingの進化などです。 筆者個人としては、convto さんの「標準パッケージに uuid が追加された背景から見る Go らしい意思決定」が気になっています! イベント当日について エブリーのブースでは、料理に関するクイズや、ノベルティ・キッチングッズなどが当たるくじ引き、アンケートボードを設置しています! デリッシュキッチン 食クイズ ノベルティは弊社CTO慣習のオリジナルドリップコーヒー「CTO Blend」とステッカーをご用意しています! ノベルティ 当日は弊社の Go エンジニアも現地に参加しますので、会場でお見かけの際はぜひお気軽にお声がけください! 非公式アフターイベントのご案内 Go BASH Vol.3 ANDPAD、OPTiM、Resilire、エブリーの4社合同で、非公式アフターイベント Go BASH Vol.3 を開催します! 今回の会場はエブリー本社です! Go Conference 2026 の感想戦や各社のセッションなどのコンテンツを用意していますので、みんなで盛り上がりましょう! 開催日時 2026年9月30日(水) 19:30〜21:00(19:15 開場) 開催場所 東京都港区六本木3-2-1 住友不動産六本木グランドタワー38F 株式会社エブリー本社 開催形態 オフライン コンテンツ ・各社のGoに関するセッション ・Go Conference 2026 感想戦 ・交流会 お申し込みは以下で行っております。みなさんのご参加をお待ちしています! connpass.com 最後までお読みいただき、ありがとうございました!
Codespaces で開発環境を配布する はじめに エブリーでデリッシュキッチンの開発をしている本丸です。 毎年、エブリーでは夏季にインターンシップを行っているのですが、今年は今まで行っていた長期のほかに短期でのインターンシップも行っています。 そこで問題になったのが、参加者の開発環境をどうするかというものでした。 本記事では、GitHub が提供している Codespaces を利用してどのように開発環境を用意するのか、組織で Codespaces を利用する上で設定した項目についてお伝えできればと思います。 GitHub Codespaces とは GitHub Codespaces は、GitHub のリポジトリに対してクラウド上の開発環境を立ち上げ、ブラウザや手元の VS Code から接続して開発できるサービスです。Codespaces は Dev Container の仕組みの上で動いており、リポジトリに devcontainer.json を置いておくと、Codespaces はそれを読んで環境を組み立てます。 devcontainer.json とは何を決めるファイルか devcontainer.json は、「この開発環境はこう作る」という手順をリポジトリに置いておくための設定ファイルです。Codespaces 専用の形式ではなく、同じ定義をローカルの VS Code(Dev Containers 拡張)などで利用することができます。 { "name": "...", "build": { "dockerfile": "Dockerfile" }, "remoteUser": "node", "features": { "ghcr.io/devcontainers/features/github-cli:1": {} }, "postCreateCommand": "pnpm install", "forwardPorts": [3000] } build でベースになるイメージを決め、 features でそこに入っていないツール(ここでは GitHub CLI)を後乗せし、 postCreateCommand で作成後に流すコマンドを書き、 forwardPorts で見せるポートを指定する、という構成です。ほかにも docker compose を丸ごと指定したり、VS Code の拡張機能やエディタ設定を一緒に配ったりと、開発環境まわりのことは一通り設定できます。 起動方法や使い方のポイントなど 使い方 Codespace の作成は、 https://github.com/codespaces を開き、 New codespace から対象のリポジトリを選んで起動するのが基本の流れです。 codespace からの起動 Create codespace のボタンを押した後、数秒から数分待つと Codespace がブラウザで立ち上がり、そのまま開発を始められます。 devcontainer.json さえ置いてあれば、ほとんど設定不要で開発環境を作成できます。 Codespace の停止は、 https://github.com/codespaces から対象の「…」メニュー → Stop で行えます。 ポイント ポート ポートは forwardPorts と portAttributes を使って、ラベル付きで転送しています。 転送されたポート一覧 転送されたポートには、Codespace ごとの URL( https://<codespace 名>-3000.app.github.dev のような形式)が発行されます。たとえば Next.js の開発サーバーを 3000 番で動かしておけば、この URL をブラウザで開くだけで動作確認ができます。 転送されたポートには GitHub の認証がかかっているため、URL を知られただけでは開けません。 また、必要であればポートをローカルに転送することもできます。たとえばローカルの GUI クライアントから DB を見たい場合は、次のコマンドで 3306 番をローカルに持ってこられます。 gh codespace ports forward 3306:3306 Prebuild Codespace は何も設定しないと作成のたびにイメージのビルドから始まります。ビルドに時間のかかるリポジトリでは立ち上がりが遅くなるため、そうした場合は Prebuild を設定しておくと初回起動が速くなります。ビルド済みのイメージをあらかじめ GitHub 側に用意しておき、Codespace の作成時にはそれを取ってくるだけで済ませる仕組みです。 組織アカウントでの運用 最初の設定 組織アカウントに対して、次の設定が必要になります。 まず budget の設定です。 budget の設定 ここで設定した budget を上限として Codespaces を利用していくことになります。 次にアクセス権の設定です。 アクセス権の設定 どのメンバーが Codespaces を利用できるかの設定です。 加えて owner の設定と、必要に応じて policy の設定を行います。 所有権の設定 所有者の設定 ここでは組織所有かユーザー所有かを切り替え、課金対象のユーザーを指定します。 注意しなければならないのは、支出上限に達すると新規作成も起動もできなくなり、稼働中のものも停止されることです。イベント中に全員が同時に止まる事故になり得るため、上限の設定は慎重に行う必要があります。 コストの構造 課金される軸は3つだけですが、止まっていても課金され続けるものがあるのがポイントです。 項目 課金単位 課金されるタイミング compute コア時間(コア数 × 稼働時間) 稼働中のみ (Stop すれば止まる) storage GB-month 停止中も継続 (削除するまで止まらない) prebuild 生成は Actions 分数 / 保管は Codespaces storage 常時 (無効化するまで) 組織ポリシー 組織所有の Codespace に対しては、マシンタイプの制限、アイドルタイムアウトの最大値、保持期間、1ユーザーあたりの上限といった制約を設定できます。 リポジトリ単位のポリシー compute に関しては稼働中のみコストがかかる仕組みになっているので、アイドルタイムアウトを設定することで無駄なコストがかかることを防げます。 なお、ポリシーの項目にユーザー単位の上限を含めると、適用するリポジトリの選択ができなくなります。 ユーザー単位のポリシー 一定以上のスペックが必要な場合やコストを抑える目的などで設定をする場合が多いかと思います。 他メンバーの Codespace 利用の確認 ブラウザ上で見えるのは自分の Codespace だけです。組織オーナーであっても、他メンバーの Codespace を一覧する導線がありません。棚卸しや停止・削除は gh コマンドなどから行うことになります(admin 権限が必要で、対象は組織所有のものだけです)。 # 組織の Codespace を一覧(owner / 状態 / ブランチ / 作成日時) gh codespace list --org ORGANIZATION # 停止(compute 課金を止める。作業内容は保持される) gh codespace stop --org ORGANIZATION --user USER --codespace CODESPACE_NAME state が Available であれば稼働中(compute 課金中)、 Shutdown であれば停止中(storage のみ)です。 CLI から確認した Codespace の状態 その他 Prebuild の設定は組織自体の設定ではなく、個別のリポジトリごとの設定になっているので注意が必要です。 まとめ 組織で利用する場合は、課金の仕組みや所有権まわりで注意しなければならない点があります。とはいえ、 devcontainer.json を置いておくだけで、GitHub アカウントさえあれば誰でも同じ開発環境を作れるのは非常に便利で、環境構築に時間を取られることがありません。 実際、インターンシップ当日も概ね問題なく動きました。こうしたイベントに限らず、普段の開発でも使い道はありそうだと感じました。 参考文献 GitHub Codespaces とは GitHub Codespaces の課金について 組織内の Codespace を一覧する Codespace のマシンタイプ タイムアウト期間を設定する
はじめに 2026年に開催された DroidKaigi 2026 に、弊社の開発本部から 3 名のエンジニアが参加してきましたので、イベントの様子や印象に残ったセッションをご紹介します。 イベントの様子 スポンサーブース エブリーは今回、ゴールドスポンサーとしてブースを出展させていただきました! 足を運んでいただいた皆様、本当にありがとうございました! ブース企画 アンケートボード ブースでは、「AI時代!どこまで越境したいですか?」をテーマにした参加型のアンケートボードを実施しました! Android開発をベースにしつつ、バックエンドやPdM、データサイエンスといった他の領域へどのようにスキルを広げていきたいか、皆様のリアルな声を聞かせていただきました! 回答いただいた多くの皆様、ありがとうございました!最終結果はこちらです……! 2日間でいただいたシールは、合計およそ 400 枚。領域ごとの内訳は次のようになりました。 最も票が集まったのは「Android」でしたが、Backend と iOS がほぼ同数で並び、この3つが上位を分け合う形になりました。一方で全体を見ると、Android 以外の領域に貼られたシールは全体の 8 割弱。「Android を軸に据えつつ、その外側にも手を伸ばしていきたい」という方が多数派でした。 また、シールを貼っていただきながら、こんな声も聞かせていただきました。 モバイル領域が好きなので、クロスプラットフォームでやっていきたい コードは AI が書いてくれるので、プロダクトをどうグロースさせるかを考えられるようになりたい 「AI 時代にどう越境するか」という問いに対して、技術の横方向に広げていく方向と、プロダクトづくりそのものへ踏み込んでいく方向、その両方のリアルな声を伺うことができました。 ※シール数は写真からの集計のため、概算値です。 Xフォロー&くじ引き エブリー開発部の X アカウントをフォローいただくと、くじを1回引けるという企画も実施しました。 景品は、レンジ調理鍋・まな板・計量スプーン・しゃもじ・お箸など、普段の料理で使えるキッチングッズです。 ハズレの方にも、CTO 自らがテイスティングして選んだ「CTO ブレンド」のコーヒーをお渡ししていたので、くじを引いてくださった方には全員何かしらお持ち帰りいただけるようにしていました。 キッチングッズが当たった方に喜んでいただけて、こちらも嬉しかったです! またXをフォローいただいた皆様、本当にありがとうございました!X ではテックブログの更新情報も発信しているので、ぜひチェックしていただけると幸いです! ネイル体験会 会場ではプロのネイリストによるネイル体験会が開催されており体験してきました! 流れとしては、ネイルをする指を2本選び、それぞれのデザインを決めていくというもの。ベースカラーはネイリストの方と相談しながら決められるので、ネイルに詳しくなくても安心して選ぶことができました。 デザインは、DroidKaigi のキャラクター3種類とロゴの中から好きなものをチョイスできました。指先に DroidKaigi のキャラクターがいてくれるので、ふとした拍子に目に入るたび嬉しくなります。 他社のスポンサーブース REALITY さん REALITY さんは、AEP 対応に関するアンケートを行っていました! AEP (Apps Experience Program) は、Google が指定した要件を満たすと認定を受けられ、Google Play の新しい料金表の適用などの特典が得られるプログラムです。 Material3 は対応済み (80% 以上) の回答が多く、予想以上でした。一方でフルコンポーズ化は、まだ対応中・検討中という回答の方が多いようでした。 アーキテクチャも公開されていました。3D アバター以外の箇所はネイティブで作成されているとのことで、Unity を使っていると思っていたので驚きました。 BIZREACH さん BIZREACH さんは、AI が進化して楽になったことについてアンケートを行っていました! テストコードの生成やエラーの原因調査のような、コーディング業務の補佐的な立ち位置に留まらず、相談相手として活用している方が多く面白かったです。 晩ご飯の献立については、他と比べると少ないようでした。 デリッシュキッチン の出番のようです! エムスリー さん エムスリーさんは、毎年恒例の、プログラムのコードが印刷されたクリアファイルを配布されていました。 なんと去年よりコードが短くなっているとのことでした! クリアファイルの詳細については、昨年版のものになりますが エムスリーさんのテックブログ で公開されていますので、ぜひご覧ください! セッション紹介 なんとかする力 〜Androidエンジニアからマネージャー、さらにその先へ?〜 発表者: m.coder さん(フラー株式会社) レポート: 岡田 m.coder さんに、「目の前の課題を『なんとかする』の積み重ねが今の自分を作ってきた」という考えをもとに、キャリアとの向き合い方を語っていただいたセッションでした。 仕事のやりやすさは「何を・いつまでに・どこまでやるか」が決まっているかで大きく変わり、曖昧な箇所を明確にして不確実性を下げること自体が価値ある仕事だという話から始まりました。 印象的だったのは、役職が上がっていくにつれ、皆等しく抽象度の高い課題の解決を求められるというお話です。「なんとなくチームの雰囲気が悪い」「なんかプロジェクトの品質が悪い気がする」といった、課題かどうかすら曖昧なものを扱う必要があるという具体例に痛く納得しました。こういった漠然とした課題については、どうしても目を瞑りがちなので、自身のマインドセットを見直す必要があるなと痛感しました。 またテックリードとマネージャーは向き合い方が違うだけで、どちらも「自分以外の領域(チームや組織)をなんとかする」役割だという整理も面白かったです。 自分のキャリアを考えるうえで、抽象度の高い問題に立ち向かうべきという方針や、それを実現させる方法について非常に学びになりました。ご自身の経験から語られている箇所も多く、熱いメッセージをいただいた気持ちになりました。 また冒頭で『エンジニアリング組織論への招待』を紹介していただきました。1 章だけでも読む価値があるとのことなので、ネクストアクションとしてはこちらを読もうと思います。 あなたのANRはどこから? — 発生する仕組みを診断し、症状別に処方する 発表者: chomi さん(NRIネットコム株式会社) レポート: 岡田 会場が皆うなずいていたセッションだったように思えます。 メインスレッドについての解説を経て、まずは誰しもが経験したことがあるであろう、メインスレッドでの I/O についてのお話から始まりました。その後起動時の重い初期化、ロック競合と進みました。 起動時の重い処理については、特にレガシーコードを触ったことがある人なら対応したことがあるのではないかと思います。本当に Application で初期化すべきかを考えるというのは ANR 以外にも、パフォーマンスの観点から非常に重要です。例として FirebaseSDK の初期化について出ましたが、こちら誰しもがなんとかならないかなと調べたことがあると勝手に思っているので面白かったです。また固有端末依存や Binder 経由の呼び出し先での ANR などについても話があり、やはり皆さん困っているのだなと共感しました。 何より構成と見せ方が完璧だったと思います。スライドは要点だけが目に入る作りで、定義や例などもとても丁寧でしたので、スッと内容が入ってきました。終章の ANR 診断フローチャートについても綺麗にまとめられており、参考になりました。 発表での再現には、公開されているサンプルアプリ DorodoroTimer を用いたそうです。デモモードをONにすると上記の ANR が実際に発生し、コード内の [ANR-xx] マーカーから問題箇所と修正版を見比べられます。 AndroidにおけるServer-Sent Events: 工場の現場を生き抜くリアルタイムストリーム 発表者: Mr. Jasveen Sandral (Industrial Android, Toyota Group Japan) レポート: 鈴木 ( @0muji4_eng ) 本講演は、AndroidにおけるServer-Sent Events(SSE)を用いたリアルタイムストリーミング実装の課題と、その具体的な解決策について論じています。 Webブラウザとは異なり、Androidの標準的なライブラリ(OkHttpなど)にはSSEの自動再接続や状態管理の機能が不足しており、通信障害時にエラーを検知できず画面のデータがフリーズしてしまうエラーケースが存在します。講演者はこの事象を "The Trap of Silence" (沈黙の罠) と呼んでいました。この問題を克服するためには、サーバーに依存するのではなく、クライアント側(Android側)で堅牢な自己回復機能を持つ独自の仕組みを設計する必要性が生じます。 具体的には、サーバーからの定期的な通信(ハートビート)を監視してタイムアウトなどの切断を検知する仕組みや、厳密な状態管理(ステートマシン)の実装が不可欠です。あわせて、再接続時には最後に受信したID(Last-Event-ID)をサーバーへ送信することで、通信断絶中のデータ欠落を補完し、安全にストリーミングを再開する必要があります。 また、頻繁な双方向通信に適したWebSocketとの技術的な比較や、端末がオフラインになった際の適切なUI制御にも触れられています。最終的に、一方向のデータ監視システムにおいてSSEを有効に活用するには、サーバー側でのバッファリングといった設計だけでなく、クライアント側がいかにして通信の切断と復帰に耐えうるアーキテクチャを構築できるかが重要であると結論付けています。 WebAssembly in Android Apps 〜 WASMはJNIの夢を見るか 発表者: keiji_ariyama さん (C-LIS CO., LTD.) レポート: 鈴木 ( @0muji4_eng ) 本講演は、Androidアプリ開発において、C++などで書かれた既存のネイティブライブラリ(OpenJPEG など)を、WebAssembly(Wasm)を用いて安全に再利用するためのアーキテクチャ設計について論じています。 背景として、運転免許証やパスポート、マイナンバーカードなどに格納されている顔写真データ(JPEG 2000形式など)を読み込む際、従来のJNI(Java Native Interface)経由の直接実行では、悪意のある細工された画像データによって深刻な脆弱性を突かれ、アプリ全体が危険にさらされるリスクがありました。 この課題に対する実践的な解決策として、講演者は Wasm と Jetpack JavaScript Engine を組み合わせた多層防御(Defense-in-Depth)を提案しています。Wasmによってシステムコールを持たないメモリ隔離環境(第一層)を構築し、さらにJS Engineによってネットワークやローカルファイルへのアクセス権限を持たない別プロセス(第二層)として実行します。これにより、万が一デコーダーの脆弱性を突かれても、被害をサンドボックス内に完全に封じ込め、アプリ本体への影響を防ぐことが可能になります。 また、実装上の大きな障壁となる プロセス間のデータ転送コスト についても詳細な検証が行われています。文字列変換によるデータ受け渡しでは、プラットフォーム側にネイティブ実装が存在する Base64 を使用するのが最もパフォーマンスが高いことが実証されました。しかし現在では、JS Engine バージョン1.1.0で導入された MessagePort API を活用することで、バイナリデータの双方向通信が可能となり、エンコードのオーバーヘッドが劇的に解消されることが解説されています。あわせて、プロセス間通信の1MB容量制限も、RAM 上のファイルディスクリプターを介することで安全に回避できる点が示されています。 結論として、Wasm はメモリコピーが発生する点(ゼロコピー不可)やコードの隠蔽化に向かない点においてJNIとトレードオフの関係にあります。しかし、外部からの信頼できないデータを処理する要件においては、過去の優れたネイティブ資産を極めて安全にモバイル環境へ持ち込むための、非常に有効なベストプラクティスであると位置づけています。 まとめ 今年は例年と違い、 AI 関連のトピックが増加した印象です! ブースでは AI を用いた開発に関してのアンケートが多数見受けられました! セッションでは デバイス操作はAIエージェントの時代へ。mobile-mcpを活用したAndroid UI/E2Eテストの挑戦 や AI に Inclusive UI を書かせよう — Design Rules Skill で Compose UI を作り直す のような AI を開発効率化に用いる内容から、 Google のオープンモデル Gemma を活用した最新の AI 開発のトレンド のような AI 開発そのものについてまで幅広く講演されており、時代の変化を感じました! またブースには本当に多くの方に足を運んでいただき、たくさんの人にエブリーを知っていただけて、とても良い機会でした! これからもデリッシュキッチン、エブリーのことをよろしくお願いいたします! 最後に エブリーでは、ともに働く仲間を募集しています。 テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください! corp.every.tv さらに、 DroidKaigi & iOSDC After Talks Night 2026 を、ゆめみ、フェンリル、Yappli、WealthNavi、セーフィー、エブリーの6社合同で開催いたします!なお、今回はiOSDC Japan 2026のアフターパーティーも兼ねているので、AndroidエンジニアだけでなくiOSエンジニアの方も交えて、プラットフォームの垣根を越えた活発な技術交流や情報交換をお楽しみいただけます。両カンファレンスの熱気をそのままに、各社によるLTセッションや懇親会をご用意しております。 項目 詳細情報 開催日時 2026年10月2日(金) 19:00 ~ 21:00 開催場所 東京都港区三田一丁目4番1号 住友不動産麻布十番ビル 開催形態 オフライン / オンライン コンテンツ 各社のAndroid & iOSに関するセッション / 懇親会 詳細や参加登録につきましては、以下のリンクよりご確認ください。 yumemi.connpass.com 最後までお読みいただき、ありがとうございました!
はじめに 株式会社エブリーは、2026年9月に開催される DroidKaigi 2026 にゴールドスポンサーとして協賛いたします。「エンジニアが主役のAndroidカンファレンス」である本イベントは、2026年9月1日(火)から3日(木)にかけて ベルサール渋谷ガーデン で開催されます。 項目 詳細情報 イベント名称 DroidKaigi 2026 開催日程 2026年9月1日(火)〜 9月3日(木) 会場 ベルサール渋谷ガーデン(東京都渋谷区南平台町) スポンサーランク ゴールドスポンサー ブース出展日程 2026年9月2日(水)〜 9月3日(木)の2日間 エブリーはこれまでも、 Go Conference 2025におけるPlatinum "Go"ld スポンサー や、 TSKaigi 2026におけるゴールドスポンサー としての参加など、技術コミュニティを積極的に応援してきました。今回のDroidKaigiへの協賛も、自社の開発現場で得られた知見をコミュニティに還元し、エンジニアの皆様と共に成長していくための大切な取り組みの一環です。 tech.every.tv tech.every.tv ブース出展:「AI時代!どこまで越境したいですか?」 9月2日および3日に出展するエブリーのブースでは、「AI時代!どこまで越境したいですか?」をテーマにした参加型のアンケートボードを設置します。Android開発をベースにしつつ、バックエンドやPdM、データサイエンスといった他の領域へどのようにスキルを広げていきたいか、皆様のリアルな声をお聞かせください。 また、 エブリーの公式X(旧Twitter) をフォローしていただいた方には、ハズレなしのくじ引きをご用意しています。お鍋や計量スプーン、まな板など、デリッシュキッチンならではの実用的なキッチングッズをプレゼントしますので、ぜひお立ち寄りください。 TSkaigiでもお配りした景品例 事後レポートを公開予定です! イベント終了後の9月3日(木)には、 every Tech Blog にて最速事後レポートを公開する予定です。開発部メンバーによるセッションの感想や、アンケートボード「AI時代の越境」の集計結果など、現場のリアルな熱量をお届けします。過去のイベント協賛時と同様に、オフラインで得られた知見をいち早くコミュニティに共有していきます。 アフターパーティーも開催します! さらに、 DroidKaigi & iOSDC After Talks Night 2026 を、ゆめみ、フェンリル、Yappli、WealthNavi、セーフィー、エブリーの6社合同で開催いたします!なお、今回はiOSDC Japan 2026のアフターパーティーも兼ねているので、AndroidエンジニアだけでなくiOSエンジニアの方も交えて、プラットフォームの垣根を越えた活発な技術交流や情報交換をお楽しみいただけます。両カンファレンスの熱気をそのままに、各社によるLTセッションや懇親会をご用意しております。 項目 詳細情報 開催日時 2026年10月2日(金) 19:00 ~ 21:00 開催場所 東京都港区三田一丁目4番1号 住友不動産麻布十番ビル 開催形態 オフライン / オンライン コンテンツ 各社のAndroid & iOSに関するセッション / 懇親会 詳細や参加登録につきましては、以下のリンクよりご確認ください。 yumemi.connpass.com おわりに 株式会社エブリーでは、技術コミュニティの発展を応援するとともに、開発現場で得た知見や知恵を共有し合う文化を大切にしています。今回の DroidKaigi 2026 への協賛を通じて、多くのエンジニアの皆様と技術やキャリアに関するお話ができることを楽しみにしています。 当日のブースでは、デリッシュキッチンをはじめとするプロダクト開発のリアルな話や技術スタック、AI時代におけるエンジニアの挑戦に関する雑談・ご質問も大歓迎です。「ちょっとノベルティのくじ引きをしてみたい」「エンジニアと軽く話してみたい」といった軽い気持ちで構いませんので、ぜひ気軽にエブリーのブースへ足をお運びください。 DroidKaigi 2026 の会場、そして10月のアフターパーティーで、皆様とお会いできることをチーム一同、心より楽しみにお待ちしております!
はじめに 前提:エージェントの構成 課題:画面を離れると回答が消える 設計方針:実行の作成と購読を分離する DB スキーマ:残す履歴と実行中の状態を分ける 保存単位は「AG-UI の Message 1 件 = 1 行」 DynamoDB ではなく Aurora MySQL を選んだ理由 会話履歴はサーバーが組み立てる 書き込み設計:イベントは保存せず「生成途中の回答」を上書きする 再合流:会話全体のスナップショットで追いつく 同時実行制御:実行中ロックを NULL 可のユニーク列で作る ロックの解放漏れに備える 停止:切断とキャンセルを区別する Redis は要るか まとめ はじめに 開発本部 開発1部の いくまる です。 私たちのチームでは、Web アプリの新機能として、チャット形式でデータを分析できる AI エージェントを開発中です。開発を進める中で、「回答の生成中に画面を離れると、その回答を受け取れなくなり、会話も残らない」という課題に向き合うことになりました。 本記事では、この課題を解決するために行った「会話履歴の永続化」と「バックグラウンド実行」の設計と実装を紹介します。DB スキーマ・実装コード・検討して捨てた案まで含めて書きます。 前提:エージェントの構成 このエージェントは次の構成で動いています。 ブラウザ(チャット UI) │ AG-UI イベント(SSE) ▼ Next.js(API Route ── ブラウザと AgentCore の中継役) │ InvokeAgentRuntime(SSE) ▼ Amazon Bedrock AgentCore Runtime(Strands Agents 製エージェント) │ MCP ▼ MCP サーバー(自社データの検索・集計ツール群) Amazon Bedrock AgentCore : AI エージェントの実行基盤となる AWS のサービスです。セッションごとに microVM 単位で実行環境が分離されます。 Strands Agents : AWS が公開しているオープンソースの AI エージェント SDK です。 AG-UI : エージェントとフロントエンド間のイベントストリーミングのプロトコルです。 RUN_STARTED ・ TEXT_MESSAGE_CONTENT ・ TOOL_CALL_* ・ RUN_FINISHED などのイベント型を定めています。転送方法は SSE に限定されませんが、このアプリでは SSE で流しています。 ユーザーが質問を送ると、エージェントが MCP ツールでデータを取得・分析し、回答をストリーミングで返します。ツールを繰り返し呼ぶため、1 回の回答に数十秒かかることがあります。 課題:画面を離れると回答が消える AI チャットで広く使われるのは「POST + ストリーミング応答」の構成です。ブラウザが質問を POST し、サーバーが生成イベントを流し、画面に逐次表示します。画面離脱を想定しなければ、これで十分に機能します。私たちの初期実装もこの構成でした。 私たちの場合は回答に数十秒かかるため、「生成中に画面を離れても実行は完走してほしい」という要件が加わりました。この要件を満たそうとすると、3 箇所が問題になります。 改修前の構造。画面遷移した瞬間に、以降のイベントを受け取る手段がなくなる 1 つ目はフロントエンドです。 この構成では、チャット画面の hook が unmount 時に実行を中断( abortRun() )する作りになりがちです。画面遷移がそのまま実行中断になります。 // チャット画面の hook(unmount 時に実行を中断する作り) useEffect( () => () => { stopRequestedRef. current = true ; agentRef. current ?.abortRun(); } , [] , ); 2 つ目は中継役の API Route です。 ブラウザと AgentCore の間で SSE を中継する Next.js の API Route は、クライアントの切断を上流の AgentCore への読み取りキャンセルとして伝播します。 ReadableStream の cancel() は「クライアントがもう読まない」ときに呼ばれるコールバックで、そこで上流の読み取りも止めると、切断とキャンセルの区別がなくなります。 // 中継処理(クライアントが切れると上流の読み取りも止まる作り) const readable = new ReadableStream( { async start ( controller ) { // 上流(AgentCore)の SSE を読み、そのままクライアントへ中継する } , cancel () { reader.cancel(); } , } ); 3 つ目は保存先です。 イベントは中継されるだけで、どこにも保存されません。仮に 1 つ目と 2 つ目を直して実行が完走するようにしても、戻ってきた画面に表示するデータがありません。 「実行状態を React のグローバルストアに持てば、画面遷移に耐えられるのでは」という案も検討しました。しかしこのアプリでは、チャット画面から他の画面への遷移が window.location.href によるページ全体の再読み込みで実装されています。再読み込み後のページは JavaScript の実行環境ごと新しく作られるため、React の state やグローバルストアに入れた値は引き継がれません。 設計方針:実行の作成と購読を分離する 大きく変えたのは次の 3 つです。 作成と購読の分離 : POST /runs は実行を開始して 202 { runId } を即座に返します。表示は GET /runs/{runId}/events の SSE で受け取ります。この「SSE を受信し続けること」を、本記事では「購読」と呼びます。購読はいつ切れてもよく、何度でも再開できます。 実行ワーカーの独立 : AgentCore の SSE を最後まで読み切って記録する処理(実行ワーカー)を、HTTP レスポンスから独立した非同期タスクにしました。ブラウザが切断しても実行は完走します。 二層の保存 : 実行中は「生成途中の回答」を DB に上書き保存し続け、完了したら完成したメッセージを DB の履歴テーブルに保存します。テーブル構成は次の節で説明します。 なお、実行ワーカーは Next.js と同じプロセス内で動かしているため、リクエストごとに実行環境が終了するサーバーレス環境ではこの形は取れません。現在は検証段階のためこの構成にしていますが、Next.js のデプロイに走行中の実行が巻き込まれないようにするため、本来は実行ワーカーを独立したプロセスに切り出す方が望ましいと考えています。現状、プロセスがデプロイなどで止まる場合の後始末は、同時実行制御の節で説明する回収の仕組みが担います。 改修後の構造。実行は接続と無関係に完走し、購読は何度でも再入場できる ブラウザとサーバーの間の API は次の 5 本です。 API 役割 POST /runs 実行を作成して 202 { runId, conversationId } を即返す GET /runs/{id}/events SSE 購読。切断・再入場が自由 POST /runs/{id}/cancel 明示的なキャンセル GET /conversations 会話一覧(履歴サイドバー用) GET /conversations/{id} 会話の全メッセージ + 実行中の run(あれば) 最後の API がポイントです。リロードや別タブで会話を開いた直後、クライアントは会話 ID しか知らず、実行中の run があるかどうかも分かりません。そこで GET /conversations/{id} は、会話のメッセージに加えて「実行中の run の ID」を返します。クライアントはその ID で GET /runs/{id}/events を購読し、生成途中から表示を再開します。 DB スキーマ:残す履歴と実行中の状態を分ける このアプリでは以前から、本体機能のデータを Aurora MySQL 8.0 + Prisma で管理しています。エージェントの履歴も同じ DB に、3 つのテーブルで持つことにしました。ずっと残す「履歴」と、実行中だけ使う「実行状態」でテーブルを分けています。 区分 テーブル 役割 行の扱い 履歴 conversations 会話スレッド 1 件のメタ情報 ずっと残す 履歴 conversation_messages メッセージ 1 件 = 1 行。完成した発話を保存 ずっと残す 実行状態 runs 実行 1 回の状態 + 生成途中の回答 + ロック 行は実行 1 回ごとに増え、終了後も記録として残る。生成途中の回答やロックは実行中だけ使う ER 図 Prisma スキーマは次の通りです。実際に採用したものから、タイムスタンプ列・リレーション定義・enum 定義(RunStatus / MessageStatus)を省いています。 model Conversation { id String @id @default(cuid()) companyId Int @map("company_id") userId String @map("user_id") threadId String @map("thread_id") @db.Char(36) title String @db.VarChar(255) deletedAt DateTime? @map("deleted_at") @@unique([companyId, userId, threadId]) @@map("conversations") } model ConversationMessage { id String @id @default(cuid()) conversationId String @map("conversation_id") runId String? @map("run_id") sequence Int role String @db.VarChar(16) parts Json status MessageStatus @default(complete) @@unique([conversationId, sequence]) @@map("conversation_messages") } model Run { id String @id @default(cuid()) conversationId String @map("conversation_id") clientTurnId String @map("client_turn_id") @db.VarChar(64) status RunStatus @default(queued) lockKey String? @unique @map("lock_key") ownerInstanceId String? @map("owner_instance_id") @db.VarChar(64) errorCode String? @map("error_code") @db.VarChar(64) partialState Json? @map("partial_state") heartbeatAt DateTime @default(now()) @map("heartbeat_at") @@unique([conversationId, clientTurnId]) @@index([status, heartbeatAt]) @@map("runs") } lockKey の UNIQUE、 clientTurnId の複合ユニーク、 [status, heartbeatAt] のインデックスがそれぞれ何のためにあるかは、後の節で順に説明します。 また、本記事には 4 種類の ID が登場します。ここで整理しておきます。 ID 何を指すか conversationId DB 上の会話。API で会話を指すときに使う threadId AG-UI 上の会話 ID。DB 上の会話( conversationId )と 1 対 1 で対応 runId 質問 1 回ぶんの実行 runtimeSessionId AgentCore の実行環境を束ねる ID。 t{companyId}-u{userId}-{threadId} の形式で、会話ごとに固定 保存単位は「AG-UI の Message 1 件 = 1 行」 conversation_messages は追記専用で、AG-UI の Message をそのまま parts (JSON)に格納します。テキストだけのターンは user / assistant の 2 行、ツールを使うターンは assistant(ツール呼び出し)と tool(結果)の行が挟まって 4 行以上になります。 1 会話のメッセージ行の例。ツールを使うターンは user・assistant(ツール呼び出し)・tool・assistant の 4 行、使わないターンは 2 行になる この保存単位を選んだ理由は、フロントの表示ロジックの作りにあります。ライブ表示は「AG-UI の Message 配列を受け取り、ターンの区切りやツールの実行ステップ表示を組み立てる純粋関数」として自前で実装しています。保存した Message 列をそのままこの関数に渡せば、画面を離れなかった場合と同一の表示が再現されます。保存時に表示用の形へ加工してしまうと、同じ表示を再現できなくなります。 DynamoDB ではなく Aurora MySQL を選んだ理由 会話履歴のアクセスパターン(会話 ID + 連番で順に全件取得、追記専用、JSON 主体)は DynamoDB の得意領域で、実際に移行案も検討しました。それでも Aurora MySQL 一本にしています。 まず、トランザクション要件が構成の選択肢を絞ります。質問の受付時には「会話 + user メッセージ + 実行(ロック)の INSERT」を、完了時には「assistant メッセージの INSERT + 実行の完了 + ロック解放」を、それぞれ単一トランザクションで行う必要があります。「履歴は DynamoDB、実行状態は Aurora」のように 2 つのストアに分けると、この原子性を保証できません。原子性が無いと、たとえば次のような壊れ方をします。 回答は残ったのに次の質問ができない : 完了処理の「回答を保存」と「ロック解放」の間でプロセスが落ちると、画面には回答が出ているのに DB はロックを握ったままになり、次の質問が「実行中です」と拒否され続けます。 答えのない質問が履歴に残る : 送信の二度押しで 2 本目が「質問を保存 → ロックで弾かれる」の順に進むと、誰も回答しない質問だけが履歴に残ります。1 トランザクションならロック取得の失敗と同時に質問の保存も取り消され、エラー応答だけを返せます。 したがって選択肢は「全部 Aurora」か「全部 DynamoDB」に絞られます。後者も技術的には成立します(DynamoDB でも TransactWriteItems で複数の項目をまとめて原子的に書けます)。 それでも Aurora にしたのは、既存の運用との一貫性のためです。このアプリの他のデータはすべて Aurora + Prisma で管理していて、マイグレーションの手順やレビューの観点といったチームの運用もそこで揃っています。データストアを 2 つにすると、この運用も 2 系統になります。規模の面でも、DB への書き込みはピークでも毎秒数十回の見積もりで、Aurora で十分に受けられます。DynamoDB のスケール性能が必要になる水準ではありません。 会話履歴はサーバーが組み立てる エージェントは毎回の呼び出しで会話の全履歴を受け取り、状態をゼロから組み立て直す作りにしています。この全履歴を誰が用意するかには 2 つの形があります。クライアントが手元の Message 配列を毎回送るか、 サーバーが DB から組み立てる かです。私たちは後者にしました。クライアントが送るのは新しいメッセージ 1 件だけです。 前者を避けた理由は、バックグラウンド実行と相性が悪いからです。実行を放置して別のタブで完走させると、元のタブが持っている履歴は古いままになります。その古いタブから次の質問を履歴ごと送ると、完走したはずの回答がモデルへの入力から抜け落ち、会話のつじつまが合わなくなります。後述するロックは実行中しか効かないため、この事故は防げません。最新の会話を常に持っているのは DB だけです。 なお、AgentCore 側に会話の状態を持たせる案も 2 つ検討し、見送りました。 実行環境(microVM)のメモリに持つ : セッション ID は会話ごとに固定なので、同じ会話の呼び出しは同じ実行環境に届き、メモリに状態を残すこと自体はできます。ただしこの環境は無操作 15 分(デフォルト)などで終了し、メモリごと消えます。時間を空けて続く会話の置き場にはできません Memory サービスに持つ : AgentCore には会話を保存する Memory というサービスもあります。ただし履歴はどのみち表示のために自前の DB へ保存するので、足すと同じ役割の保存先が 2 つになります POST /runs のボディは { conversationId, message, clientTurnId } だけです。モデルへ渡す履歴は、実行ワーカーが Aurora から組み立てます。 // 実行ワーカーの一部:DB から会話履歴を読み出し、モデル入力用に整える export async function buildModelMessages ( conversationId : string ): Promise < ModelMessagesResult > { const rows = await prisma.conversationMessage.findMany( { where : { conversationId , status : "complete" } , orderBy : { sequence : "asc" } , select : { parts : true } , } ); return normalizeHistory(rows); } // 実行ワーカーの一部:組み立てた履歴をエージェントに渡して生成を開始する const history = await buildModelMessages(claimed.conversationId); const { companyId , userId , threadId } = claimed.conversation; const agent = new AgentCoreAgent( { threadId , initialMessages : history .messages, agentArn , runtimeUserId : `c ${ companyId } :u ${ userId } ` , runtimeSessionId : `t ${ companyId } -u ${ userId } - ${ threadId } ` , } ); こうすると、会話の内容はサーバー(DB)だけが持つ構造になります。AgentCore の microVM がタイムアウトで終了しても、クライアントがリロードで状態を失っても、会話は Aurora から再構成できます。 書き込み設計:イベントは保存せず「生成途中の回答」を上書きする 1 回の回答で AG-UI イベントは数十〜数百個流れます。本文の断片(デルタ)1 つ 1 つやツール呼び出しがそれぞれイベントになるため、回答が長いほど増えます。これを 1 行ずつ INSERT すると、1 回答ごとに大量の行が永久に積み上がります。採用したのは次の形です。 時点 DB 操作 内容 質問送信 INSERT conversations に会話(新規会話のときのみ)、 conversation_messages に質問、 runs に実行レコード(ロック取得を兼ねる)の最大 3 行 生成中 runs.partial_state を上書き UPDATE AG-UI のイベントでは本文が細切れ(デルタ)で届く。実行ワーカーはそれをつなぎ合わせた「その時点のメッセージ配列」を保持しており、これを数秒ごとに同じ 1 行へ上書き保存。行は増えない 完了 conversation_messages に INSERT そのターンで生まれたメッセージ(回答、ツールを使った場合はその呼び出しと結果も)を保存。 runs.partial_state を空にし、ロックを解放 質問送信時のトランザクションは以下のようになっています。ロック( lockKey )の取得と質問の保存が、同時に成立するか同時に失敗するかのどちらかになります。 // API Route の一部:質問受付時の書き込み return prisma.$transaction( async ( tx ) => { const conversationId = existingId ?? ( await tx.conversation.create( { /* 省略 */ } )).id; // ロック取得に失敗したら、下で保存する質問ごと取り消される(同一トランザクションのため) const run = await tx.run.create( { data : { conversationId , clientTurnId , lockKey : conversationId } , select : { id : true } , } ); // aggregate(集計クエリ)で会話内の最大 sequence を取り、次の連番を振る const highest = await tx.conversationMessage.aggregate( { where : { conversationId } , _max : { sequence : true } , } ); await tx.conversationMessage.create( { data : { conversationId , runId : run. id , sequence : (highest._max.sequence ?? 0 ) + 1 , role : "user" , parts : { id : randomUUID(), role : "user" , content : message } , } , select : { id : true } , } ); return { runId : run. id , conversationId } ; } ); 生成中の partial_state は 3 秒間隔で間引いて書きます。間引きに加えて「前の UPDATE が完了するまで、次の UPDATE を発行しない」という制御も入れています。UPDATE を発行した順と DB に反映される順は一致するとは限らないため、古い内容の UPDATE が新しい内容の後に適用されると、保存済みの「生成途中の回答」が巻き戻ってしまうからです。 // 実行ワーカーの一部:生成途中の回答を数秒ごとに DB へ上書き保存する const PARTIAL_STATE_INTERVAL_MS = 3_000 ; return { schedule() { // 前の書き込みが完了するまで次をスケジュールしない(古い内容への巻き戻りを防ぐ) if (timer || writing || truncated) return ; timer = setTimeout (() => { timer = null ; writing = true ; void writePartialState(runId, ownerInstanceId, produced()) . then (( state ) => { truncated = state.truncated; } ) . catch (( error : unknown ) => console .error( "partial_state の更新に失敗しました" , { runId , error } ), ) . finally (() => { writing = false ; } ); } , PARTIAL_STATE_INTERVAL_MS); } , } ; 数秒おきに UPDATE を発行し続けて DB の負荷は大丈夫なのか、という点は検討しました。結論としては、同時に走る生成が多くても数十本という規模では問題になりません。更新は各実行が自分の 1 行だけを主キー指定で行い、実行間のロック競合はありません。さらに、接続中のユーザーの画面へは実行ワーカーがメモリ上のイベントを直接流すため、 partial_state の用途は後述する再合流だけです。毎秒書く必要も、イベントを 1 個ずつ書く必要もありません。 完了時は「回答の確定保存」「実行ステータスの完了への更新」「ロック解放」「生成途中の回答( partial_state )の削除」を 1 トランザクションで行います。 // 実行ワーカーの一部:完了時の書き込み。lock_key を外し損ねるとその会話が永久に 409 になる return prisma.$transaction( async ( tx ) => { const claimed = await tx.run.updateMany( { where : terminableWhere(runId, ownerInstanceId), data : { status , errorCode , finishedAt : new Date (), lockKey : null , partialState : Prisma.DbNull, } , } ); // 別の経路(キャンセルや、後述する異常終了時の回収処理)が先にこの実行を // 終わらせていたら、生成物は保存しない if (claimed. count === 0 ) return false ; await tx.conversationMessage.createMany( { data : messages. map (( message , index ) => ( { conversationId : targetId, runId , sequence : base + index + 1 , role : message.role, parts : message as Prisma.InputJsonValue , status : messageStatusAt( status , message. id , openMessageIds), } )), } ); return true ; } ); メッセージの status は通常 complete で保存します。キャンセルやエラーで実行が正常に終わらなかった場合は、そこまでに生成できていた分を partial(部分的、の意味)として保存し、画面に残せるようにしています。 再合流:会話全体のスナップショットで追いつく 実行中の会話に購読者が入ってくると、サーバーはまず RUN_STARTED (「実行が進行中です」の合図)と MESSAGES_SNAPSHOT を送ります。どちらも上流から届いたイベントの中継ではなく、この購読のためにサーバーが新しく作って送るものです。 MESSAGES_SNAPSHOT を受け取ったクライアントは、手元のメッセージ一覧を捨てて、スナップショットの内容で丸ごと置き換えます。そのため、スナップショットに生成途中の 1 件だけを入れると、過去のメッセージが画面からすべて消えてしまいます。必ず会話の全メッセージを入れて送ります。 その後の配信は 2 つのモードに分かれます。分かれ目は「購読がいつ始まったか」です。 モード いつ使われるか 配信内容 live 質問の送信直後から購読している場合(生成イベントがまだ 1 件も流れていないうちに購読が始まったとき) 実行ワーカーが受け取る生成イベント( TEXT_MESSAGE_CONTENT など)を、メモリからそのまま逐次中継 poll それ以外すべて(リロード・別タブ・離脱して戻ってきた場合) 1 秒間隔で DB を読み、確定済み履歴と partial_state の生成途中回答をマージした会話全体の MESSAGES_SNAPSHOT を、内容が変わったときだけ送り直す。実行が終わったら RUN_FINISHED (または RUN_ERROR )で締める つまり、途中から戻ってきた購読者が受け取るのは live 配信のイベント列ではなく、「会話全体のスナップショットの送り直し」です。 // 配信モードの選択。イベントが 1 件でも流れた後に始まった購読は poll に回す export function attach ( runId : string , signal : AbortSignal ): LiveSubscription | null { const fanout = fanouts. get (runId); if (!fanout) return pollInstead(runId, "fanout_absent" ); if (fanout. closed ) return pollInstead(runId, "fanout_closed" ); // 1 件でも中継済みなら列の途中からになるので、履歴を出せる poll に任せる if (fanout.relayed > 0 ) return pollInstead(runId, "already_relayed" ); // 省略(購読者を登録し、生成イベントを流す AsyncGenerator を返す) } // poll 配信のループ。会話全体のスナップショットを、内容が変わったときだけ送り直す while ( true ) { const event = snapshot(stored, readPartialMessages(progress.partialState)); const serialized = JSON . stringify (event); if (serialized !== previous) { previous = serialized; yield event; } if (isTerminal(progress. status )) break ; await sleep(POLL_INTERVAL_MS, signal); progress = await readProgress(run. id ); if (isTerminal(progress. status )) stored = await loadConversationMessages(run.conversationId); } yield terminalEvent(run, progress); 途中合流の購読者を live のイベント列に合流させず poll に回すのは、正しさを優先したためです。デルタの続きから流すには、「スナップショットに含めた分」と「これから流すデルタ」の境界を厳密に合わせる必要があります。境界がずれると、 content += delta の積み上げで本文が二重に連結されます。会話全体のスナップショットを送り直す形なら、毎回が丸ごとの置き換えなので、この事故が原理的に起きません。その代わり、poll 配信の画面は live 配信のようなストリーミング表示にはならず、数秒おきに文章がまとまって進む表示になります。途中合流でもストリーミング表示にすることは、後続の課題にしています。 同時実行制御:実行中ロックを NULL 可のユニーク列で作る 「同一会話に実行中の run は 1 本だけ」を DB で強制します。PostgreSQL なら 部分インデックス (partial index。 CREATE UNIQUE INDEX ... WHERE status IN ('queued','running') のように、条件を満たす行だけへ一意制約をかけられます)で書けますが、MySQL 8.0 には相当する構文が用意されていません。 代わりに runs.lock_key (NULL 可・UNIQUE)を使いました。実行中は lock_key = conversationId 、終了時に NULL へ戻します。MySQL のユニークインデックスは NULL を重複として扱わない ため、終了済みの run は何本でも共存でき、実行中は会話ごとに 1 本に絞られます。 同一会話への 2 本目の POST /runs は、INSERT 時にユニーク制約違反のエラーとして原子的に弾かれます。重複には 2 種類あります。1 つは「同じ送信の二度押し」です。クライアントは送信 1 回ごとに ID( client_turn_id )を発行し、リトライでも同じ ID を送るため、この列の重複で検出できます。送信ボタンの連打はフロントでも抑止できますが、ネットワーク不調時の自動再送などフロントの制御では防げない経路が残るため、DB でも守ります。もう 1 つは「別の質問の並行送信」( lock_key の重複)で、同じ会話を複数のタブで開いているときに起きます。どちらだったかを引き直して、応答を分岐します。 // API Route の一部:重複キーエラーの解釈 } catch (error) { if (!isUniqueViolation(error)) throw error; // 二度押しなら、先行の run をそのまま返す(同じ送信は 1 回として扱う) const raced = await findRunByClientTurnId(params); if (raced) return { ok : true , runId : raced. id , conversationId : raced.conversationId } ; // 並行送信なら、実行中の run を添えて拒否へ const activeRunId = await findActiveRunIn(conversationId); if (activeRunId) return { ok : false , reason : "active_run" , activeRunId } ; } // API Route の一部:並行送信への応答 if (result.reason === "active_run" ) { return Response .json( { errors : "この会話はいま実行中です" , activeRunId : result.activeRunId } , { status : 409 } , ); } 409 のレスポンスに activeRunId を含めているのは、UI がそれを使って「拒否」ではなく「実行中の run への購読切り替え」に変換できるようにするためです。 ロックの解放漏れに備える ロックには解放漏れへの備えも必要です。サーバーのプロセスが突然落ちると、running のままロックを握った run が残ります。そうなると、誰も実行していないのにその会話への質問が「実行中です」と拒否され続けます。備えは 2 つの仕組みの組み合わせです。 実行ワーカーは、実行中の run の heartbeat_at を定期的に現在時刻へ更新します(処理が続いていることの記録です) それとは別の掃除処理が、 heartbeat_at の更新が一定時間止まっている queued / running の run を「担当プロセスが異常終了した」とみなして failed にし、ロックを解放します。 heartbeat_at は INSERT 時に現在時刻が入るため、202 を返した直後・実行が始まる前にプロセスが落ちて queued のまま残った run も、この経路で回収されます(スキーマの @@index([status, heartbeatAt]) はこの検索用です) 「サーバー起動時に、残っている running を全部 failed にする」というより単純な方法は採れませんでした。デプロイ中は新旧のサーバーがしばらく同時に動いており、旧サーバーがまだ実行している最中の run を、新サーバーの起動処理が誤って failed にしてしまうためです。run に owner_instance_id (どのサーバーがその実行を担当しているか)を持たせているのも同じ理由です。掃除処理は、自分のサーバーがいま実行している run を誤って回収しないよう、この ID とメモリ上の実行一覧を突き合わせて判定します。 この回収の仕組みは、デプロイやスケールインでプロセスごと止められた場合の後始末も兼ねています。止まったプロセスが抱えていた実行は途中から再開できませんが、heartbeat が途絶えるため数分以内に failed になり、そこまでの生成分は partial として履歴に残り、会話のロックも解放されます。ユーザーは失敗を確認して、すぐ次の質問に進めます。 停止:切断とキャンセルを区別する この設計では、タブを閉じる・画面を遷移するのは「切断」であり、実行は継続します。明示的に止めたいときは POST /runs/{id}/cancel を呼びます。実行ワーカーにキャンセル要求の印を立てて上流への購読を切り離し、実行を「キャンセル」として記録します。記録後に遅れて届いた生成物は、前述の完了時トランザクションの「別の経路が先に終わらせていたら保存しない」分岐で破棄されます。 // API Route の一部:キャンセル処理 if (getActiveRun(runId) || run.ownerInstanceId === OWNER_INSTANCE_ID) { // 終了の記録は実行ワーカーに任せる。ここで書くと、ワーカーが「先に終了済み」と判定して生成物を捨てる const active = registerRun(runId); active.cancelRequested = true ; await active.agent?.detachActiveRun(); return { ok : true } ; } // 別のサーバーが担当している run。会話のロックを解放するために記録だけ書く await finalizeRun( { runId , status : "cancelled" , finalizedBy : `cancel: ${ OWNER_INSTANCE_ID } ` } ); 実行中の会話への追加送信は、前述のロックにより 409 で拒否されます。ただし 409 を返すだけだと、「戻ってきたら画面が止まって見える → もう一度送る → エラー」という流れになりやすいため、途中経過の可視化(再合流)を初回リリースの範囲に含めています。実行中であることが見えていれば追加送信は起きにくく、方向を変えたい場合も「停止してから送る」導線に誘導できます。 Redis は要るか 同種の設計では、実行中イベントの共有に Redis(Redis Streams)を使う構成がよく知られています。ただし Redis が必要になるのは「タスクを 2 つ以上に増やし、かつストリーミング表示を保ちたい」場合です。今回はどちらにも当てはまらないため、入れていません。 現在は ECS 1 タスクで動かしており、通常時は再接続のリクエストが実行ワーカーと同じプロセスに届きます。イベントはプロセス内のメモリで手渡せるため、Redis なしで live 配信が成立します。 タスクを 2 つ以上に増やすと、購読のリクエストが実行ワーカーのいない方のタスクへ届くことがあります。live 配信はワーカーと同じプロセスのメモリを介して成り立っているため、別のタスクに届いた購読では使えません。ただし DB はどのタスクからも読めるので、poll 配信はそのまま動きます。つまり live 配信できたはずの購読が poll 配信になり、ストリーミング表示が数秒おきの更新になるだけで、履歴も途中経過も見られます。設計方針の節で触れた「実行ワーカーを独立したプロセスに切り出す」場合も、ワーカーと購読者が必ず別プロセスになるため、同じく中継が必要になります。当面は 1 タスクで足りる規模のため、現時点ではこの構成にしています。 まとめ 実行の作成と購読を分離し、実行ワーカーを HTTP 接続から独立させることで、画面を離れても実行が完走する構造にしました。 履歴は「メッセージ 1 件 = 1 行」でずっと残し、生成途中の回答は上書き更新の 1 行に分けました。イベントの逐次保存はせず、モデルへ渡す履歴もサーバーが DB から組み立てます。 同時実行制御は MySQL の「NULL 可ユニーク列」によるロックで実現しました。キャンセルは購読の切り離しと、実行を「キャンセル」として記録することで実現し、切断とは明確に区別しています。 AI チャットの「履歴」と「バックグラウンド実行」は別々の機能に見えますが、作ってみると、どちらも「会話の状態はサーバー側で持つ」という同じ設計に行き着きました。AI チャットの実行基盤を作る際に共通して現れる論点だと思うので、同じものを作る方の参考になれば幸いです。
目次 はじめに Partial Prefetchingは何を解決するのか すべてを先読みする方法と、何も先読みしない方法の中間を作る OISHYでPartial Prefetchingを試す 検証条件 計測方法 実験1:クリック前の取得を減らすと、通信と遷移はどう変わるか 実験2:URL固有のデータを待つ間に何が見えるか 補足:適用範囲によって先読みの形が異なった まとめ 参考文献 はじめに こんにちは、開発本部の 黒髙 です。最近はアメリカ向けレシピサービスである OISHY の開発に関わっています。 Next.js 16.3 では、ページ遷移の応答性を改善する仕組みとして Instant Navigations が追加されました。クリック前に再利用できるUIやデータを準備し、クリック後に必要な部分を取得することで、遷移直後から次のページを描画しやすくする仕組みです。 Instant Navigationsを構成する機能のうち、今回取り上げるのは Partial Prefetching です。本記事では、Partial Prefetchingを紹介したうえで、OISHYの既存ページでは通信と画面遷移にどのように現れるのかを検証します。 Partial Prefetchingは何を解決するのか すべてを先読みする方法と、何も先読みしない方法の中間を作る Next.jsの <Link> によるprefetch(先読み) では、リンクが画面内に入ると、ユーザーがクリックする前に遷移先のデータを取得します。クリック時にデータが揃っていれば、遷移先をすぐに表示できます。 一方、リンクが多い画面では、実際にはクリックされないリンクのデータも取得します。先読みを無効にすれば事前通信はなくなりますが、その場合はクリックしてから遷移先のデータを取得することになります。 Next.js 16.3のPartial Prefetchingは、この二つの中間を作る機能です。Cache Componentsを使うページへの既定の <Link> では、URLごとの完成形をすべて先読みする代わりに、同じ種類のページで再利用できるApp Shellを先読みします。クリックされたURLに固有のデータは、必要になった時点で取得します。 App Shell とは、URL固有のデータを待たずに表示できるページの共通部分です。サイト共通のレイアウトや、データの読み込み中に表示するUIなどが含まれます。 レシピ詳細ページを例にすると、レシピ名や材料はURLごとに変わりますが、それらを待っている間の枠組みは複数のレシピで共有できます。 同じルートを指す複数のリンクで一つのApp Shellを再利用できる ため、リンクごとに同じ共通部分を取得し直す必要もありません。 この仕組みは、次のようなページで効果が現れやすいと考えられます。 商品、記事、レシピなど、同じ種類の詳細ページへのリンクが多数並ぶ 表示されたリンクのうち、実際にクリックされるのは一部だけである 詳細ページに、共通レイアウトやデータ取得中の表示など、URLをまたいで再利用できる部分がある OISHYのトップページにも、同じ /recipes/[id] へ遷移するレシピリンクが多数並びます。そこで今回は、レシピ詳細ページをPartial Prefetchingへ切り替えたとき、クリック前の通信とクリック後の表示がどう変わるかを確かめました。 OISHYでPartial Prefetchingを試す OISHYでは、すでに Cache Components を有効にしています。まず、レシピ詳細ページへの遷移にPartial Prefetchingを適用するため、次の設定を追加しました。 // app/recipes/[id]/page.tsx export const prefetch = 'partial' ; 公式リファレンスでは、 prefetch = 'partial' はリンクではなく遷移先に設定する ものと説明されています。この設定により、レシピ詳細ページをPartial Prefetchingへ段階的に切り替えます。 検証条件 主な検証では、次の3条件を比較しました。 条件 Next.js Partial Prefetchingを適用する場所 データ取得中の表示 条件A 16.2.11 適用しない なし 条件B 16.3.1 レシピ詳細ページ なし 条件C 16.3.1 レシピ詳細ページ スケルトン表示 実験1では条件Aと条件Bを比較し、クリック前の取得を減らしたときに通信と画面遷移がどう変わるかを確認しました。実験2では条件Bと条件Cを比較し、URL固有のデータを待つ間にApp Shellが画面にどう現れるかを確認しました。 以下で扱う条件B・Cと補足検証の先読み通信は、Next.js 16.3.1で観測したものです。内部的な通信の形は、今後のバージョンで変わる可能性があります。 OISHYの既存構成では、Next.jsの standalone server をECSで動かし、その前段にCloudFrontとALBを置いています。今回の計測もこの配信経路で行いました。 計測方法 対象 :同じ10件のレシピを、それぞれ3回ずつ計測 操作 :トップページを新しいタブで開き、対象リンクが画面内に入る位置までスクロール。5秒待ってからクリックし、クリック前の先読み通信とレシピタイトルが表示されるまでの通信・時間を記録 ブラウザ条件 :画面サイズは1280×900、回線速度の制限はなし。ブラウザ側のHTTPキャッシュは計測のたびに無効化し、スクロール位置とクリックまでの待ち時間を統一 集計 :各レシピの3回の中央値を求めたあと、10件の中央値を代表値として使用。通信量にはChrome DevTools Protocolの Network.loadingFinished.encodedDataLength を使い、ブラウザが受信したデータ量に近い値を比較 実験1:クリック前の取得を減らすと、通信と遷移はどう変わるか 最初に、Next.js 16.2.11の条件Aと、16.3.1へ更新してレシピ詳細ページをPartial Prefetchingへ切り替えた条件Bを比較しました。この比較にはNext.js自体の更新も含まれるため、Partial Prefetchingだけの効果ではなく、OISHYで16.3.1への更新と機能導入を行った前後差として扱います。 ここでいう RSC(React Server Components)データ は、Next.jsがクライアント側の画面を更新するために送るデータです。 指標(中央値) 条件A(変更前) 条件B(レシピ詳細ページに適用) クリック前のRSCリクエスト 75 6 クリック前の転送量 約319KB 約20KB クリック後のRSCリクエスト 0 1 クリック後の転送量 0KB 約20KB クリック前後の合計リクエスト 75 7 クリック前後の合計転送量 約319KB 約38KB クリック前のリクエスト数は約92%、転送量は約94%減りました。クリック後には、選択したレシピのRSCリクエストが1件発生しています。それを含めた合計転送量も約88%減っており、クリック前の通信がそのまますべてクリック後へ移ったわけではありませんでした。 変更前は、画面内に並ぶ多数のレシピについてURL固有のRSCデータを取得していました。変更後は、共通部分を先読みし、選択したレシピのデータをクリック後に取得しています。今回の画面では、クリックされなかったレシピへの先読みが減ったことが、通信量の差として大きく現れました。 次の画像は1回の計測例で、表の数値は反復計測から求めた代表値です。 条件Bでのクリック前のNetwork記録の例 一方、クリックからレシピタイトルが表示されるまでの代表値は、変更前が約44ms、変更後が約82msで、どちらも100ms未満でした。URL固有のデータをクリック後に取得するようになったことと整合しますが、今回の条件では目視できるほど長い待ち時間にはなりませんでした。大きく変わったのは、完成画面が出るまでの見た目よりも、クリック前に取得するデータの量でした。 なお、この結果は回線速度を制限しない環境でのものです。Partial Prefetchingは、クリック前の転送量を減らす代わりに、URL固有データの取得をクリック後へ移す仕組みです。低速な回線では、クリック後の待ち時間が今回より長くなる可能性がある一方、クリックされないリンクへの先読みを抑える効果は大きくなるため、有効に働くかどうかは通信環境によってトレードオフになり得ます。 実験2:URL固有のデータを待つ間に何が見えるか Partial Prefetchingでは、URL固有のデータがクリック時点で揃っていなければ、クリック後にデータの取得を待つ時間が生じます。その間にApp Shellがどのように現れるかを見るため、条件Cではレシピデータの取得中に表示するスケルトンを loading.tsx として定義しました。 loading.tsx 自体はNext.js 16.3の新機能ではありません。同じルート階層のページを Suspense 境界で囲み、定義したUIをデータの準備中に表示する仕組みです。Partial Prefetchingでは、 この代替表示を含むApp Shellが先読みの対象になります 。 // app/recipes/[id]/loading.tsx export default function RecipeDetailLoading () { return < RecipeDetailSkeleton /> ; } スケルトンとは、完成後のレイアウトに近い枠を先に表示し、データを待っている場所を示すUIです。今回のレシピ詳細ページでは、次のスケルトンがApp Shellとして先に表示される部分になります。 レシピ詳細ページのスケルトン表示例 30回中25回はスケルトンを経由せず、完成したレシピ詳細が直接表示されました。スケルトンが先に現れたのは5回で、クリックから14〜22msで表示され、レシピタイトルより約68〜856ms先行しました。 この結果から、スケルトンは毎回挟まる中間画面ではなく、レシピ固有のデータがクリックまでに揃わなかった場合だけ、遷移先の枠組みとして先に表示されることが分かりました。 また、条件Cの通信を追加で確認すると、条件Bとは異なる挙動が見つかりました。条件Bではレシピ固有のデータをクリック後に取得していた一方、条件Cの補完計測では、10回中4回で複数のレシピ固有データをクリック前に取得するRSC通信が並びました。 条件Cでクリック前に観測したレシピ固有データの先読み この違いがPartial Prefetchingの適用範囲と関係するのかを確認するため、追加計測しました。 補足:適用範囲によって先読みの形が異なった 条件Cでは、レシピ詳細ページだけに prefetch = 'partial' を設定していました。 prefetch = 'partial' は、設定したページより上位のルート階層まで同じ設定にするものではありません。 Next.js 16.3.1の実装 では、それぞれのルート階層が、その階層で指定された設定かアプリ全体の既定値を参照します。 この挙動を調べる中で、 段階導入時の先読みを扱うNext.js 16.3.1の公式テスト を見つけました。このテストにも、動的なページだけをPartialにし、未設定の上位階層を従来の方法で扱う構成で、リンク先固有のデータまで先読みする例があります。 そこで、条件Cの loading.tsx とページ側の設定を残したまま、 アプリ全体の既定値をPartialにする partialPrefetching: true だけを追加しました。 // next.config.ts const nextConfig = { cacheComponents : true , partialPrefetching : true , } ; 同じ導線を10回計測した結果は次のとおりです。 Partial Prefetchingの適用範囲 URL固有データの先読み 共有部分の先読み レシピ詳細ページのみ 10回中4回で観測 あり アプリ全体 10回中0回 あり 同じ loading.tsx を残したまま適用範囲を変えると、今回の導線ではURL固有データの先読みが10回中4回から0回になりました。一方、App Shellを構成する共有部分の先読みは続いていました。Next.js 16.3.1の実装と公式テストを合わせると、今回観測した通信にはPartial Prefetchingの適用範囲が関係していたと考えられます。 まとめ Partial PrefetchingをOISHYのレシピ一覧で試したところ、16.3.1への更新と機能導入後、クリック前のRSC転送量が約94%減り、クリック後を含む合計でも約88%減りました。 一方、変更前からレシピタイトルまでの表示は約44msと十分に速く、回線速度を制限しない今回の環境では、通信量ほど大きな見た目の差はありませんでした。スケルトンを含むApp Shellは、URL固有のデータがクリックまでに揃わなかった場合だけ、完成したページより先に表示されました。 また、レシピ詳細ページだけに適用した場合と、アプリ全体に適用した場合では、クリック前の先読みも異なりました。ページ単位で試せる機能ではありますが、今回の検証では適用範囲も実際の通信に関係していました。 Partial Prefetchingは、商品・記事・レシピの一覧のように、同じ種類の詳細ページへのリンクが多く、その一部だけがクリックされる画面を想定しています。クリックされないURL固有データの先読みを抑えながら、データが間に合わない場合にはApp Shellを先に表示する仕組みです。今回のOISHYでは、先読み通信量には大きな差が出た一方、見た目の差は小さいという結果でした。 参考文献 Next.js 16.3 Next.js 16.3: Instant Navigations Instant Navigationガイド Adopting Partial Prefetching <Link> のprefetch(先読み) Cache Components Server and Client Components output: 'standalone' prefetch のルート設定(Next.js 16.3.1) 同じルートでApp Shellを再利用する説明(Next.js 16.3.1) partialPrefetching の全体設定(Next.js 16.3.1) loading.tsx とSuspense境界(Next.js 16.3.1) loading.tsx の代替表示を先読みする説明(Next.js 16.3.1) ルート階層ごとの先読み設定を扱う実装(Next.js 16.3.1) 段階導入時の先読みを確認する公式テスト(Next.js 16.3.1) Chrome DevTools Protocol: Network.loadingFinished
title はじめに こんにちは、株式会社エブリーでデリッシュキッチンのiOSアプリの開発をしている成田です。 現在は、プレミアムユーザーの登録数の向上やプレミアムユーザーの体験をより良くすることを目的としたチームで開発をしています。 サブスクリプションの解約は、これまで開発者にとってブラックボックスでした。ユーザーが App Store の管理画面で「サブスクリプションをキャンセルする」を押すとき、アプリ側にできることは何もありません。引き止めのメッセージも、オファーの提示も、そもそも解約されようとしていることを知ることさえ、その瞬間にはできませんでした。 WWDC26 で発表された Retention Messaging は、ここに初めて介入手段を与える機能です( セッション309 )。解約確認画面に、アプリからのメッセージやオファーを差し込めるようになります。 Appleによれば、この機能を導入したサブスクリプションでは、解約を取りやめて利用を継続するユーザーの割合が改善されているとのことです。解約抑止にも取り組む立場としては、無視できない機能です。 まだ一般提供はされていませんが、具体的に何ができるのか、どう始めればよいのか、現時点で分かっていることを整理します。 何ができるのか ユーザーが App Store のサブスクリプション管理から解約しようとすると、確認画面が出ます。Retention Messaging を設定しておくと、この画面にアプリからのコンテンツが表示されます。 表示できる形式は3つです。 形式 内容 メッセージのみ ローカライズ済みの引き止め文言 メッセージ + 画像 テキストメッセージに Asset Library 等から設定した画像を添えて訴求 メッセージ + オファー 割引や無料期間付きなどのオファーを提示する ユーザーがオファーの対象である場合、オファーの表示が画像を置き換えます。「解約する前に、3ヶ月無料で続けられるオファーがあります」のような画面を、Apple の解約フローの中に出せるわけです。この画面は App Store 側が描画するもので、公式ドキュメントによれば iOS 15.1 以上で利用できます。アプリの最低対応バージョンと関係なく届くのは、地味に嬉しいところです。 オファーが引き換えられたかどうかはサーバー側で確認できます。署名付きトランザクションに新しいオファー種別が入ります。 { " offerType ": 5 , " offerIdentifier ": " Yoga_2026_cancel_free_3m ", " offerDiscountType ": " FREE_TRIAL ", " offerPeriod ": " P3M " } offerType はトランザクションに「どの種類のオファーが絡んだか」を刻むフィールドで、これまで4種類あったものに追加で Retention Offer が加わった形です。 offerType 種類 1 お試し 2 プロモーション 3 オファーコード 4 再獲得 5 Retention Offer メッセージを用意する方法は2つある ここまでが主に「解約確認画面に何が出るか」の話です。次は、その表示をアプリ側がどう用意するかです。 方法は2つあって、手軽さが大きく違います。App Store Connect で設定するだけの方法と、自前のサーバーを立てて顧客ごとにリアルタイムで出し分ける方法です。順に見ていきます。 方法1: App Store Connect 側での設定 App Store Connect 上でメッセージ・画像・オファーを設定し、対象のサブスクリプションにマッピングするだけです。自前のサーバー実装は不要で、Apple 側が表示を担います。 流れはこうです。 ローカライズ済みのメッセージ文言を作る 任意で Asset Library の画像、Retention Offer を添える 1つ以上のサブスクリプションにマップする Sandbox 環境でテストして公開 方法2: リアルタイム API 型(ユーザーごとの出し分け) 方法1の弱点は、全員に同じものしか出せないことです。 新しく発表された Retention Messaging API を使うと、「誰に・何を出すか」を自社のデータで決められるようになります。 誤解しやすい点を先に書いておくと、 解約の理由そのものが Apple から届くわけではありません 。リクエストに入っているのは「誰の契約か( originalTransactionId )」までです。ただ、この ID で自社のユーザーデータを引けば、手持ちの情報が使えます。例えば、購読してからどれぐらいか、最後にアプリを開いたのはいつか、月額プランか年額プランか、過去にオファーで引き止めたことがあるかなどがあるでしょう。 理由そのものは分からなくても、こうしたデータから仮説は立てられます。 出し分けのロジックが自前のサーバーにあることで A/B テストでの検証も可能になるはずなので、その仮説を検証することもできそうです。 次に、仕組みを見ていきます。 Retention Messaging API を使うと、解約操作が起きたまさにその瞬間に、App Store から自前のサーバーへ問い合わせが来ます。 // App Store からのリクエスト { " originalTransactionId ": " 123456789 ", " appAppleId ": 6745974591 , " productId ": " Yoga_summer_2026 ", " userLocale ": " en-US ", " requestIdentifier ": " c03248af-dd76-4e9b-9c1e-4489cd19a768 ", " environment ": " Production ", " signedDate ": 1780920000000 } これに対して、 このユーザーに何を出すか を返します。返せる応答は3種類です。 ① メッセージ { " message ": { " messageIdentifier ": " 551ee7c0-... " } } ② プラン切替の提案(alternateProduct) { " alternateProduct ": { " messageIdentifier ": " ed7f25fc-... ", " productId ": " Yoga_summer_2026_annual " } } 同じサブスクリプショングループ内の別プランへの乗り換えを提案できます。「月額×12ヶ月コミット」の新プランタイプとも連動していて、たとえば年額プランを解約しかけた人に「月額の12ヶ月コミットなら続けやすいですよ」という導線が作れたりしそうです。 ③ プロモーショナルオファー { " promotionalOffer ": { " messageIdentifier ": " 80135e2b-... ", " promotionalOfferSignatureV2 ": " eyJhbGciOiJFUzI… " } } プロモーショナルオファーは、開発者が特定のユーザーだけに提供できる限定オファーです。 対象ユーザー以外には利用されないように、開発者のサーバーが秘密鍵を使って「このユーザーに、このオファーを適用してよい」という署名を発行します。アプリはこの署名を使って、オファーが正しく発行されたものかを確認します。 promotionalOfferSignatureV2 は、このデジタルな許可証を標準的な形式である JWS(JSON Web Signature) で表現する新しい仕様です。 ところで、ここまでの応答例が messageIdentifier という ID しか返していないことに気づいたでしょうか。メッセージの文言や画像の実体は、あらかじめ Apple に登録しておく設計になっています。解約フローの真っ最中に文言ごと送るのではなく、実体は事前登録しておいて、その場では「どれを出すか」を ID で選ぶだけです。 この事前登録を担うのが、管理用のエンドポイント群です。メッセージや画像の登録のほか、リアルタイム問い合わせの受け口 URL の設定、性能テストの実行もここで行います。 POST /messages // メッセージ登録 GET /messages // 登録済み一覧 DELETE /messages/{messageId} // 削除 POST /images // 画像登録 GET /images DELETE /images/{imageId} POST /defaultMessages // デフォルトメッセージ設定 GET /defaultMessages/{productId}/{locale} DELETE /defaultMessages/{productId}/{locale} POST /realtimeUrl // 受け口URLの設定 GET /realtimeUrl DELETE /realtimeUrl POST /performanceTests // 性能テスト GET /performanceTests/{testId} フォールバックは段階的 リアルタイム応答が使えない・不正な場合は App Store Connect で設定されたものに、それも無ければ API で設定したデフォルトメッセージにフォールバックされます。 また、Sandbox には自社サーバーの応答性能を測るためのテスト用エンドポイントが用意されています。解約フローの中で同期的に呼ばれる API なので、応答が遅ければ体験を壊します。本番前にここで確認しておく、という建て付けだと理解しています。 まとめ Retention Messaging は、解約確認画面という最後の接点に初めて介入できる機能 用意する方法は2つ。サーバー不要の App Store Connect 設定型と、ユーザーごとに出し分けるリアルタイム API。フォールバックも整理されている 返せるのはメッセージ、プラン切替提案、プロモオファーの3種 メッセージや画像の実体は事前登録しておき、リアルタイム応答では ID で選ぶだけ。Sandbox には応答性能のテスト用エンドポイントも用意されている 解約はこれまで、起きてから初めて知るものでしたが、今回からは解約を思いとどまってもらうための施策を、解約のタイミングに合わせて出せるようになります。サブスクリプションを運営しているチームは、サービス提供が始まる前に、どんなメッセージを出すか、どんなオファーを用意するかを検討しておくとよさそうです。