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

TECH PLAY

株式会社エブリー

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

全491件

はじめに こんにちは、エブリーでECの開発をしている山舗です。 この度、弊社で運営している EC サイトにて、既存の SaaS のカートに代わる自社実装を新規に開発し、全面リプレイスを行いました。扱うデータには、会員の個人情報や、定期購入の課金情報などがあります。期間は 3 か月弱、コードは約 61 万行、コミットは約 7,500 件に及ぶ開発となりました。この規模の開発を短期間で行うには、Claude Code を中心に、半自律的に開発を進めさせる必要がありました。 一方で、世の中では個人情報の漏えいのニュースが数多く流れています。2025 年に上場企業とその子会社が公表した分だけで、3,063 万人分に及びます( 東京商工リサーチの調査 )。 この記事では、こうした状況の中で、Claude に書かせる前提で開発しながら個人情報を守るために、どんな技術を選び、どう使ったかをご紹介します。 1. Claude に書かせる前提の技術選定 新規開発にあたって、まず技術選定から始めました。開発の中心を Claude に任せるので、Claude が書いたコードに読み違いや書き忘れがあっても、実行する前に機械で見つけられることを基準にしました。選んだ構成は次のとおりです。 役割 選んだ技術 言語 TypeScript(フロント / バックエンド) API Hono DB と ORM PostgreSQL、Drizzle フロント(storefront) Next.js、Tailwind フロント(admin) Vite、Tailwind まず、言語は TypeScript に統一しました。バックエンドを Go、フロントを TypeScript のように分けると、2 つの言語の間で型は自動ではつながりません。API の形を OpenAPI などの中間の定義に書き、そこからフロントの型とクライアントを生成する工程が要ります。例えば、「定期購入の配送サイクルは、決まった日数のどれか」のような値の決まりも、バックエンドの検証と画面の検証の 2 か所に書くことになります。片方だけ直すと、もう片方は古いまま動き、ずれに気付くのは実行したときです。 AI は、API だけを直して画面を直し忘れるといった、片方だけの変更をよく書きます。それが実行時まで見つからない作りは避けたかったので、API から画面までを 1 つの言語にして、型をつなげることにしました。フロントとバックエンドでドメインのロジックを共有でき、lint とコーディングの決まりを 1 言語に絞れるのも理由です。 その型を API から画面へ届けるために、API のフレームワークには Hono を選びました。Hono RPC の型推論を使うと、API の定義からフロントの呼び出しまで、型がそのままつながります。Express などより軽く、Web 以外から使う API も今は無いので、これで足りました。NestJS は DI の構造が複雑で、問題に起動時にしか気付けません。構造を静的に追えるほうが AI に向くと考えました。 DB の側も同じ考え方で、ORM には Drizzle を選びました。スキーマを TypeScript で定義し、型をそこから導くので、独自の言語やコード生成の工程がありません。Prisma は独自の言語でスキーマを書き、クライアントの生成が要ります。行ロックと入れ子のトランザクションを API で書けることも理由で、これは後で例を挙げます。管理画面の見た目には Tailwindを使っています。Tailwind は任意の値を text-[#e91e63] のように角括弧で書くので、AI が決まったスタイルから外れたことを機械で検知しやすくなります。 // 決めた色のトークンを使った書き方 <button className= "bg-primary" >申し込む</ button > // 決まりから外れた書き方。角括弧があるので、検索で見つかる <button className= "bg-[#0b794d]" >申し込む</ button > 色は決めたトークンだけを使う決まりにしていて、 -[# で検索すれば、外れた箇所を一覧にできます。 型を一貫させたときに起きること この EC サイトでは、配送サイクルの決まりは 1 か所にしかありません。 // packages/domain-values/src/subscription.ts(抜粋) export const cycleDaysSchema = z.union( [ z.literal( 7 ), z.literal( 14 ), z.literal( 21 ), z.literal( 28 ) ] ); export type CycleDays = z.infer < typeof cycleDaysSchema >; この 1 つの定義を、次の全部が参照します。 DB から読んだ値の検査(repository) API の要求と応答の契約(contracts) 画面のコード(ストアフロントと管理画面) API のルートの型は、Hono RPC でフロントのクライアント( hc<AppType> )にそのまま届きます。そのため、API の応答の項目を変えると、それを使う画面のコードが型の検査で落ちます。直し忘れは、実行する前に CI で止まります。実際にあった例を 2 つ紹介します。 例 1: 型を変えた後の直し忘れが、8 つのファイルで見つかった 顧客のメールアドレスは、最初はただの string でした。これを、前後の空白を取り、形式を確かめ、小文字にそろえた値だけが入る型に変えました。 // packages/domain-values/src/email.ts(抜粋) export const emailAddressSchema = z .string() . trim () .pipe(z.email()) .transform(( value ) => value. toLowerCase ()) .brand< "EmailAddress" >(); export type EmailAddress = z.infer < typeof emailAddressSchema >; .brand を付けた型には、このスキーマを通した値しか入りません。ただの文字列を渡すと、型の検査で落ちます。 この変更を進めている間に、main ではメールアドレスを文字列のまま渡すテストが足されていました。main を取り込んで型の検査を流すと、API・画面・ドメインの 3 つのパッケージに散った 7 つのテストが落ちました。直し方はどれも同じです。 // 直す前: 型の検査で落ちる customerContact= { { email : "member@example.com" , phone : null } } // 直した後 customerContact= { { email : emailAddressSchema.parse( "member@example.com" ), phone : null } } 型が無ければ、検証を通していない値が渡っていることに気付く手がかりがありません。型の検査は、直す場所をファイルと行で全部挙げます。 例 2: 画面に届く応答の型が広がっていたのを直し、型だけのテストで固定した 管理画面の API には、URL の番号の形式が合わないときに 404 を返す共通の部品があります。この部品が応答を { error: string } で受けていたため、エラーのコードの型が string に広がっていました。Hono RPC は応答の型をそのまま画面に届けるので、画面の側では、どのコードが返るのかが型から分からなくなっていました。 // apps/admin-api/src/middleware/validation.ts(抜粋) // 直す前 export function codeParamValidator < Schema extends ZodType >( schema : Schema , notFound : { error : string } , ) { /* ... */ } // 直した後: 渡されたコードをリテラル型のまま保つ export function codeParamValidator < Schema extends ZodType , const NotFound extends { error : string } , >( schema : Schema , notFound : NotFound ) { /* ... */ } 同じことがまた起きたら型の検査で落ちるように、画面の側に、型だけを確かめるテストを足しました。実行時には何もしないテストです。 // apps/admin/src/lib/api-client.test.ts(抜粋) expectTypeOf< InferResponseType <( typeof apiClient.customers )[ ":code" ][ "withdrawal" ][ "$post" ], 404> >().toEqualTypeOf< { readonly error : "customer_not_found" } >(); API の側でこの応答の型が変わると、画面の側のこのテストが型の検査で落ちます。 行ロックと入れ子のトランザクションを使う場面 ORM に Drizzle を選んだ理由の 1 つは、行ロックと入れ子のトランザクション(savepoint)を API で書けることです。EC では、同じデータに 2 つの操作が重なる場面が多くあります。 たとえば、会員がクーポンコードを入れた瞬間に、管理者がそのクーポンを停止することがあります。「使えるか」を確かめてから会員の保有を作るまでの間に停止が入ると、停止されたクーポンが会員の手元に残ります。そこで、クーポンの行をロックして読み、判定と保有の作成を同じトランザクションで済ませます。 // packages/repositories/src/coupons/coupon-code-entry.ts(抜粋) const query = executor .select( { id : coupons. id , grantOnly : coupons.grantOnly } ) .from(coupons) .where(sql ` ${ coupons.code } COLLATE "C" = ${ code } ` ) .limit( 1 ); const [ row ] = await (options?.forUpdate === true ? query.for( "share" ) : query); ロックはクエリの最後に .for("share") を付けるだけで、 row の型は select に書いた列から決まります。一方、Prisma のクエリの API には行ロックの指定がありません( 要望の issue は 2021 年から開いたままです)。同じことをするには、生の SQL を書くことになります。 // Prisma で同じ読み取りを書く場合の例(実際のコードではありません) const rows = await tx.$queryRaw< CouponRow []> ` SELECT id, grant_only FROM coupons WHERE code = ${ code } FOR SHARE ` ; CouponRow は自分で書いた型で、SQL が返す列と合っているかは検査されません。列の名前を変えても型の検査では落ちず、実行して初めて失敗します。行ロックを取る読み取りはリポジトリの中に 50 か所ほどあるので、そこで型が途切れないことを重視しました。 この上に、機械で確かめる決まりを重ねています。規約には「ルールは機械で守れるものから機械化する。散文の規約は機械化できないものに限る」と書いてあります。 パッケージの依存の向きは、Turborepo の boundaries が CI で確かめる。顧客向けの API と社内向けの API は、互いを import できない id は、どの表の id かを型で区別する(Zod の brand)。注文の id を顧客の id として渡すと型エラーになる 外部に触れる部品には、手書きのフェイクと本物の両方に同じテスト(契約テスト)を流し、挙動のずれを見つける 2. その型で個人情報を守る 個人情報も、「気を付ける」ではなく「書き忘れたら通らない」形に寄せました。 列を足したら、分析基盤に出すかを決めないと通らない 全部の表の全部の列を、全社のデータ基盤(Databricks)に「出す」か「出さない」かに分けた定義を、コードに置いています。分類の型を Drizzle の表の定義から導いているので、列を足して分類を書き忘れると型の検査で落ちます。表を足して書き忘れるとテストで落ちます。 // packages/db/src/analytics-exposure.ts(抜粋) export type Exposure = "analytics" | "restricted"; type ColumnExposure<T extends PgTable> = { [K in keyof T["_"]["columns"]]: Exposure }; デプロイのたびに、マイグレーションの後で、データ基盤用の DB のユーザーの権限を全部外し、「出す」列だけに列単位で付け直します。分類と実際の権限が食い違ったまま残りません。個人情報は DB の権限で守られるので、取り込みのコードを通さずに DB を読んでも取れません。 この仕組みは、入れた PR の中でさっそく働きました。main を取り込んだところ、別の PR が足した 3 つの列の分類が無く、型の検査が落ちました。一方で、型では止まらない誤りもレビューで見つかっています。サポート担当が自由記述する列(返金の理由など)が「出す」側に分類されていました。自由記述には顧客の名前や電話でのやり取りが書かれうるので、書く人が誰でも「出さない」にする決まりを足しました。 監査ログに、個人情報の写しを増やさない 管理画面の変更履歴の元になる監査ログも、同じ作りです。全部の列を「前後の値を書く」「書かない」「値を記録せず、変わったことだけ書く」などに分類し、漏れは型とテストで止まります。カードの下 4 桁と有効期限は、値を {"unrecorded": true} に差し替えて記録します。アプリの DB ユーザーは、監査ログに追記しかできません。書き換えと削除の権限を、デプロイのたびに外しています。 エラーの監視には、送る前に止める Sentry の SDK は、何も指定しないと、利用者の情報・Cookie・要求の本文・DB の引数・ローカル変数を集めます。これを止める設定を 1 か所に置き、どのアプリの初期化もそれを使います。届いてから Sentry の側で消す案は、個人情報が一度届いてしまうので採りませんでした。 ログでも同じ考え方を取っています。倉庫のシステムの API が JSON として読めない応答を返したときに、本文の先頭 500 文字を例外のメッセージに入れていたことがありました。受注や出荷の伝票の応答が壊れた形で返ると、宛先の氏名や住所がログに出ます。Claude に差分を分析させたレビューで見つかり、例外に入れるのを状態コード・種類・本文の長さだけに直しました。 id から、他人のデータを順にたどれないようにする 表の id は連番でなく UUIDv7 にしています。連番の id が URL や API に出ると、番号を 1 つずつ変えて他人の注文やお届け先を順に試せます。番号の大きさから、会員や注文の件数も読めます。UUIDv7 なら、この列挙と件数の推測が難しくなります。 ただし、id を認可の代わりにはしていません。誰のデータかの確認は別に必ず行い、他の会員のデータを指す要求には 403 でなく 404 を返して、その id があるかどうかも分からないようにしています。Claude に読ませる規約にも「外に出る識別子に、連番を新しく使わない」と書いてあります。UUIDv7 は作った時刻を含むので、時刻を知られて困るデータには別の形式を検討する決まりです。 そもそも持たないもの カード番号とセキュリティコードの入力欄は、Stripe の Payment Element をそのまま使っています。入力された値は Stripe に直接送られるので、自社のコード・ログ・DB を通りません。会員のパスワードはCognito に渡し、自社の DB には持ちません。この考え方は「 個人情報を自社のDBに持たない設計は、どこまでできるのか - every Tech Blog 」に詳しく書かれています。 3. そのほかにやったこと 型とテストで確かめられるのは、形にできた決まりだけです。そのほかに、次のようなこともしています。 Claude Code が読む規約(AGENTS.md)には、個人情報の扱いで守ることを書いています。個人情報をログや監視に出さない、渡す相手は一覧にある相手だけにする、他の会員のデータを指す要求には 403 でなく 404 を返す、外に出る識別子に連番を使わない、といった内容です。Claude Code のセキュリティレビューのプラグインも入れていて、「境界は会員個人にある」といった、このサービス固有の観点を渡しています。認証・認可・決済の境界に触る変更は、人のレビューを必ず通します。 規約そのものが実態とずれていたこともあります。以前の規約は「個人情報をログ・監視・外部 SaaS に平文で出さない」でした。しかし実際には、倉庫には宛先の氏名・住所・電話番号を、決済代行には請求先の氏名を渡しています。そこで「外部に渡すのは業務に要る相手だけ」として渡してよい相手の一覧を書き、相手を足すときはその行を直してレビューを受ける形にしました。 まとめ 今回は、EC サイトの新規開発で、Claude を中心に開発を進めるために選んだ技術と、その上で個人情報を守るためにしたことを紹介しました。 言語を TypeScript に統一し、Hono と Drizzle で DB から画面まで型をつなげたことで、Claude が書いたコードの直し忘れを、動かす前に型の検査で見つけられるようになりました。個人情報についても、分析基盤に出す列や監査ログに残す値を型とテストで確かめる形にして、書き忘れたら通らないようにしています。型で確かめられない部分は、Claude に読ませる規約とセキュリティレビュー、人のレビューで補っています。 私自身、新卒エンジニアとして入社半年でここまでの仕事を任せていただけるとは夢にも思っていませんでしたが、手でコードを書いていたときとは違った角度で技術そのものの強みや使いどころについて理解を深められ、良い機会になりました。型やテスト、DB の権限のように、AI に書かせる今だからこそ効いてくる仕組みのありがたさを、実際に作りながら感じました。 AI を中心に開発を進めようとしている方や、個人情報を扱うサービスを作っている方の参考になれば幸いです。 最後までお読みいただきありがとうございました!
開発2部の内原です。 自分の観測範囲では、最近Claude CodeやCodex CLI、Gemini CLIといったAIツールのCLIを使うケースが増え、以前に比べてターミナルソフトを使う機会が多くなったように感じます。 ただ、日常的に使っているとしても、このソフトウェアが何をしているかを正しく理解しているとは言えない状態でした。ターミナル、シェル、ttyの区別が曖昧だったり、Ctrl-Cでプログラムが停止するのはなぜか、文字の色付けは誰が担当しているのか、といったことを人に説明できるだけの知識は持てていないという自覚がありました。 そこでこの記事では、ターミナルソフトの挙動について改めて学んでみます。 なお、ターミナルソフトは本来MacやWindows上で動作するGUIアプリケーションとして実装されるものですが、今回の目的はあくまで内部の挙動の把握であるため、描画やフォントといったGUI部分は扱わず、その裏側の機構のみを対象とします。POSIX環境を前提に、C言語で動作を確かめる方針です。 用語の整理 まず、ターミナルを使っているときに関わってくる要素を整理します。 ターミナルエミュレータ 普段ターミナルソフトと呼んでいるGUIアプリケーション キー入力の受付と画面の描画を担当 pty(擬似端末、pseudo tty) カーネルが提供する、双方向なバイト列のやり取りに用いられる仕組み masterとslaveの2つの終端を持ち、master側にターミナルエミュレータ、slave側にシェルがそれぞれ接続される カーネル内の端末処理層(line discipline) ptyのmasterとslaveの間に位置する加工層 行編集、エコー、シグナル生成などを担当 シェルや各プログラム ptyのslaveを標準入出力として読み書きする データの流れを図にすると以下のようになります。 ターミナルのデータの流れ 両端のターミナルエミュレータとシェルはユーザー空間のプロセスで、その間のptyとline disciplineはカーネル内に存在します。 ttyという名前はteletypewriterに由来します。かつてはコンピュータにシリアル回線で物理的な端末(VT100など)を接続しており、カーネルはその回線の先にいる端末とのやりとりをttyとして扱っていました。物理端末の役割をソフトウェアで再現したものがターミナルエミュレータで、シリアル回線の代わりを務めるのがptyです。 自分のシェルがどの端末につながっているかは tty コマンドで確認できます。 $ tty /dev/ttys003 ps でTTY列を表示すると、プロセスと端末の対応が見えます。 $ ps -o pid,tty,comm PID TTY COMM 1029 ttys000 -zsh 14861 ttys000 tmux 1041 ttys001 -zsh 43561 ttys008 vim ターミナルのウィンドウやタブ、tmuxのペインごとに、別々のptyが割り当てられていることがわかります。 ptyを開いてシェルを実行 ターミナルエミュレータの中核は、ptyを確保してシェルを起動し、そのあとはバイト列を中継し続けることです。 以下の minipty.c は、 script(1) コマンドとほぼ同じ動作をする、画面描画を持たない簡易的なターミナルの実装です。GUI部分こそ存在しませんが、ターミナルソフトとやっていること自体は同じと言えます。 #define _GNU_SOURCE #include <fcntl.h> #include <poll.h> #include <stdio.h> #include <stdlib.h> #include <sys/ioctl.h> #include <termios.h> #include <unistd.h> static struct termios orig; static void restore ( void ) { tcsetattr (STDIN_FILENO, TCSANOW, &orig); } int main ( void ) { // 1. ptyのmasterを確保し、対応するslaveのパスを取得 int master = posix_openpt (O_RDWR | O_NOCTTY); grantpt (master); unlockpt (master); char *slave_name = ptsname (master); pid_t pid = fork (); if (pid == 0 ) { // 2. 子:新しいセッションを生成し、slaveを制御端末にしてfd 0,1,2に接続 setsid (); int slave = open (slave_name, O_RDWR); ioctl (slave, TIOCSCTTY, 0 ); dup2 (slave, 0 ); dup2 (slave, 1 ); dup2 (slave, 2 ); close (slave); close (master); execl ( "/bin/sh" , "sh" , NULL ); _exit ( 1 ); } // 3. 親:自分の端末をrawモードに変更し、キー入力を加工なしで素通しする if ( isatty (STDIN_FILENO)) { tcgetattr (STDIN_FILENO, &orig); atexit (restore); // 終了時に元の設定へ戻す struct termios raw = orig; cfmakeraw (&raw); tcsetattr (STDIN_FILENO, TCSANOW, &raw); } // 4. 親:「キーボード → master」「master → 画面」の中継をくり返すのみ struct pollfd fds[] = {{STDIN_FILENO, POLLIN, 0 }, {master, POLLIN, 0 }}; char buf[ 4096 ]; for (;;) { poll (fds, 2 , - 1 ); if (fds[ 0 ].revents & POLLIN) { ssize_t n = read (STDIN_FILENO, buf, sizeof buf); if (n <= 0 ) fds[ 0 ].fd = - 1 ; // 入力が尽きたら以降は監視しない else write (master, buf, n); } if (fds[ 1 ].revents & (POLLIN | POLLHUP)) { ssize_t n = read (master, buf, sizeof buf); if (n <= 0 ) break ; // シェルが終了した write (STDOUT_FILENO, buf, n); } } return 0 ; } 説明のため、エラー処理は省略しています。処理は大きく4つに分かれます。 ptyの確保 posix_openpt(3) でmasterを開く grantpt(3) と unlockpt(3) で、対応するslaveを使用可能にする ptsname(3) でslaveのデバイスパス( /dev/ttys005 など)を取得 子プロセスの準備 setsid(2) で新しいセッションを作成し、元の端末から切り離す slaveを開いて制御端末にする dup2(2) でslaveをfd 0, 1, 2に複製してから、 execl(3) でシェルを起動 親プロセスの端末設定 自分自身の端末をrawモードに設定 中継 poll(2) で2つのfdを監視し、キーボードから来たバイト列はmasterへ、masterから来たバイト列は画面へ書き出す dup2(2) による複製は、 以前の記事 で扱ったリダイレクトと同じ仕組みです。 準備が終わった時点で、fdは以下のような状態になっています。 minipty実行時のfdの接続 実行して、子のシェルで tty を打ってみます。 $ tty /dev/ttys003 $ cc minipty.c -o minipty && ./minipty sh-3.2$ tty /dev/ttys005 sh-3.2$ exit exit $ 子のシェルは、元の端末( /dev/ttys003 )とは別のpty( /dev/ttys005 )につながっています。 親プロセスの処理は、2つのfdの間でバイト列を転送しているだけです。プロンプトの表示も、打った文字が画面に出るのも、シェルや次の章で扱うline disciplineの仕事であるため、このプログラム自体はデータの中身を一切解釈していません。 GUIのターミナルソフトは、この中継部分の画面側を自前の描画に置き換えたものだと考えることができます。 line disciplineとtermios ptyのmasterとslaveの間には、line disciplineという加工層が存在しています。普段は意識しませんが、ターミナルの基本的な挙動の多くはここで実現されています。 line disciplineの処理 現在の設定は stty -a で確認できます。以下はmacOSでの出力の抜粋です。 $ stty -a lflags: icanon isig iexten echo echoe -echok echoke -echonl echoctl iflags: -istrip icrnl -inlcr -igncr ixon -ixoff ixany imaxbel -iutf8 oflags: opost onlcr -oxtabs -onocr -onlret cchars: discard = ^O; dsusp = ^Y; eof = ^D; eol = <undef>; eol2 = <undef>; erase = ^?; intr = ^C; kill = ^U; lnext = ^V; ... 主な項目の意味は以下のとおりです。 icanon canonicalモード 入力を行単位で蓄積し、Enterが押された時点でプログラムに渡す Backspaceによる削除もこの段階で処理される echo 受け取った文字をmaster側へ送り返して画面に表示する isig intr = ^C などの特殊文字を受け取ったら、シグナルを生成する Ctrl-Cを押したとき、ターミナルソフトは0x03の1バイトを送るだけ line disciplineがそれをSIGINTに変換し、フォアグラウンドのプロセスに送ることでプログラムが停止する onlcr 出力の改行 \n を \r\n に変換する rawモード vimやless、AIツールのCLIのようなTUIアプリケーションは、line disciplineの加工をほとんど無効にしたrawモードで動作しています。1キーごとに反応し、カーソル移動や補完、エコーの表示方法を自前で制御する必要があるためです。 line disciplineの設定はtermios構造体で表され、 tcgetattr(3) と tcsetattr(3) で読み書きします。 cfmakeraw(3) を使うと、icanon、echo、isigなどの加工をまとめて無効にできます。 前章のプログラムで親プロセスが自分の端末をrawモードにしていたのも同じ理由です。このプログラムではline disciplineが2つ直列に並んでいます。 親プロセスと本物の端末の間のline discipline masterとslaveの間のline discipline(子のシェル側) 親側をrawモードにしないと、Ctrl-Cは親側のline disciplineでSIGINTに変換され、子のシェルではなく親自身が終了してしまいますし、エコーも二重になります。親側をrawにして素通しすることで、加工は子のシェル側のline disciplineだけが担当するようになります。 エスケープシーケンス line disciplineを通過した出力は、最終的にターミナルソフトに単なるバイト列として届きます。 文字の色やカーソルの移動は、バイト列の中に埋め込まれた制御用の文字列、エスケープシーケンスによって実現されています。 以下のコマンドでは赤文字で red という文字が出力されます。 $ printf '\e[31mred\e[0m\n' ESC( \e )、 [ 、数字、終端文字、で命令を表します。 上の例では ESC [31m で文字色を赤にし、 ESC [0m で装飾を元に戻しています。 以下はよく使われるエスケープシーケンスです。 シーケンス 意味 ESC [31m 文字色を赤に設定 ESC [0m 装飾をリセット ESC [2J 画面全体を消去 ESC [5;10H カーソルを5行10列目に移動 ESC [A カーソルを1行上に移動 ターミナルソフト側の処理 ターミナルソフトは、受け取ったバイト列を状態機械によってパースし、画面バッファを更新しています。 ターミナルソフトの出力処理 パーサ 通常の文字を受け取ったら、カーソル位置のセルに書き込んでカーソルを進める ESCを受け取ったら、エスケープシーケンスの読み取り状態に遷移 [ に続く数字をパラメータとして蓄積し、終端文字( m 、 H 、 J など)を受け取った時点で命令を実行 画面バッファ 行数 × 列数のセルの配列 各セルは文字と属性(文字色、背景色、太字など)を保持 描画 画面バッファの内容をフォントで描画する(今回は対象外) TERMとterminfo エスケープシーケンスは端末の種類によって異なる場合があります。そこでvimやless、AIツールのCLIのようにシーケンスを出力する側のプログラムは、環境変数 TERM で出力先の端末の種類を知ります。vimやlessは、さらにterminfoというデータベースから対応するシーケンスを引いています。 TERM 自体は、ターミナルソフトがシェルを起動する際に自身の端末種類を伝えるために設定しておくものです。 $ echo $TERM xterm-256color $ infocmp xterm-256color ... clear=\E[H\E[2J, cup=\E[%i%p1%d;%p2%dH, ... clear は画面消去、 cup はカーソル移動、などを表しています。 なおncursesなどの端末用ライブラリを利用すればこの定義を自動的に参照してくれるため、アプリケーション側でシーケンスを直接記述することを避けられます。 isattyとパイプ ここまでは、プログラムの標準入出力が端末につながっている場合の話でした。しかし標準出力の行き先はパイプやファイルの場合もあり、多くのプログラムは出力先が端末かどうかで挙動を変えています。 判定には isatty(3) を使います。 #include <stdio.h> #include <unistd.h> int main ( void ) { if ( isatty (STDOUT_FILENO)) { printf ( " \033 [32mstdout is a terminal \033 [0m \n " ); } else { printf ( "stdout is NOT a terminal \n " ); } printf ( "line 1 \n " ); sleep ( 2 ); // 端末なら1行ずつ、パイプならまとめて出力されることを確認するための待機 printf ( "line 2 \n " ); return 0 ; } 同じプログラムを3通りの方法で実行してみます。 $ ./isatty_demo stdout is a terminal line 1 line 2 $ ./isatty_demo | cat stdout is NOT a terminal line 1 line 2 $ ./minipty sh-3.2$ ./isatty_demo | cat stdout is NOT a terminal ... sh-3.2$ ./isatty_demo stdout is a terminal ... 端末に直接出力すると緑色で表示され、 | cat を付与すると色付けはなくなります。前述のminiptyの中で実行した場合も、子のシェルの標準出力はpty slaveなので端末として判定されます。 ls --color=auto や git も同じように判定しています。 ls --color=auto 端末に出力するときだけ色を付ける git log など 端末に出力するときはページャ(less等)を起動し、パイプ先では起動しない AIエージェントがコマンドを実行するとき この挙動は、AIエージェントにコマンドを実行させるときにも関わってきます。 試しにClaude Codeに tty と、シェル自身の標準入出力を確認するコマンドを実行させてみました。 $ tty not a tty $ lsof -p $$ -a -d 0-2 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME zsh 549 uchihara 0r CHR 3,2 0t0 336 /dev/null zsh 549 uchihara 1w REG 1,14 18 8392275 /private/tmp/claude-502/.../tasks/bryiflukh.output zsh 549 uchihara 2w REG 1,14 18 8392275 /private/tmp/claude-502/.../tasks/bryiflukh.output 自分の環境では、標準入力は /dev/null 、標準出力と標準エラー出力はファイルに接続されており、ptyは使われていませんでした。このため、端末上で人間が実行した場合とは以下のような違いが生じます。 色付けが行われない isatty(3) が偽になるため、 ls や git はカラー無効とする 出力が一括で行われる 行単位ではなくブロック単位でバッファリングされる 時間のかかるスクリプトの途中経過が見えないことがある 対話的なコマンドは利用不可能 標準入力が /dev/null に接続されているので、入力を求めるコマンドはすぐにEOFを受け取って終了する パスワード入力のように /dev/tty を直接開くコマンドは、制御端末がないため失敗する 標準入力がパイプのまま開いている実装では、入力を待ち続けて固まることがある まとめ ターミナルソフトが担当しているのは、以下の処理でした。 pty masterの読み書き キー入力をバイト列に変換してmasterに書き込む masterから届いたバイト列のエスケープシーケンスを解釈して描画する それ以外の、行編集やエコー、Ctrl-Cによるプロセスの停止はカーネルのline disciplineが、コマンドの解釈はシェルが担当しています。日常的に使っているターミナルソフトも、中身は数十行のCで再現できる程度の単純な機構の組み合わせであることがわかりました。
App Intents を使った機能の組み込み 目次 App Intents を使った機能の組み込み はじめに 前提知識 何ができるかの見極め 機能調査:そもそも何ができるのか 実装調査:どう作ればいいのか ベータ版での壁 App Schema の壁と落とし所 実装①:IndexedEntity で Spotlight に索引登録 RecipeEntity を IndexedEntity に準拠させる 索引に載せるレシピを絞る 書き込みを 1 本の列に並べる 起動時に索引を入れ替える 実装②:searchInApp で Siri AI からアプリに遷移 searchInApp スキーマに準拠した Intent お気に入り・閲覧履歴画面への遷移 まとめ はじめに こんにちは。株式会社エブリーの、デリッシュキッチンのiOSチームで1ヶ月の間インターンをしていました、鶴見です。 デリッシュキッチンは、「作りたい!が見つかる」をコンセプトにした、国内最大級のレシピ動画メディアです。 料理のレシピを動画で分かりやすく紹介していて、アプリではレシピの検索や、気に入ったレシピの保存(お気に入り)などの機能を提供しています。 インターンでは、WWDC26でアップデートされたApp IntentsやSiri AI関連の技術をプロダクトに組み込むことに取り組みました。 まずは、デリッシュキッチンのコア機能であるレシピ検索をSiri経由で利用できるようにすることをゴールに設定しました。具体的には、ユーザーがSiriに作りたい料理やレシピについて曖昧なリクエストを伝えるだけで、デリッシュキッチンのレシピが提案される体験を目指して検証を進めました。しかし、後述する技術的な制約により、当初想定していた体験を実現することはできませんでした。 代わりに、次の 2 つの入口を作りました。 Spotlight(iPhone の検索)から、レシピのタイトルでレシピを検索できる Siri AI に「デリッシュキッチンで〇〇を検索して」と頼むと、アプリの検索結果画面が開く 本記事では、上記の実装について紹介をします。 なお、本記事の内容は次の環境で検証したものです。ベータ版で検証しているため、正式版では挙動が変わっている可能性があります。 iOS 27.2 beta 3 Xcode 27 Siri AI と端末の言語:英語(ベータ版では、日本語の設定で Siri AI を使えなかったため) 前提知識 App Intents は、アプリの機能やデータを Siri、Spotlight、ショートカットアプリなどに公開するためのフレームワークです。 アプリのデータ(レシピなど)は AppEntity 、アプリの操作(レシピを検索するなど)は AppIntent として定義します。 iOS 27 では Siri が刷新されて Siri AI となり、自然な文章で頼みごとができるようになりました。 Siri AI がアプリのデータや操作を理解して使うには、Entity や Intent を App Schema に準拠させる(Apple が決めた型の定義に合わせる)必要があります。 App Schema は、メール・写真・カレンダー・メモといった用途(ドメイン)ごとに Apple が用意している型の定義です。 用意されているドメインの一覧は、Apple のドキュメント「 アプリスキーマドメイン 」で確認できます。 以降のコードに出てくる RecipeEntity は、デリッシュキッチンのレシピを Siri やショートカットアプリで使えるようにするために定義したものです。レシピのタイトルやサムネイル、材料名を連結したテキストデータなどを持っています。 何ができるかの見極め 新しい技術なので、まず「そもそも何ができるのか」を調べてから、「どう作ればいいのか」を調べるという順番で進めました。 機能調査:そもそも何ができるのか 最初は、新しい Siri AI で何ができるようになるのかが分かりませんでした。そこで次の順番で調べました。 WWDC26(Apple が毎年開催している開発者向けのカンファレンス)の発表を見て、イメージを掴む Apple のデモアプリを入れて、実際に Siri AI に話しかけてみる 「はじめに」で書いたとおり、Siri AI と端末の言語は英語に設定して試しています。 ただ、言語を英語に設定したままでも、日本語で話しかければ応答は返ってきました。 実装調査:どう作ればいいのか 何ができるのかのイメージを掴んでから、作り方を調べました。 デモアプリの解説ページを読み、実装を理解する Siri AI 対応に必要な実装の公式ドキュメントを読む ベータ版での壁 調査と実装の途中では、次のような壁がありました。 最新技術なので資料が少なく、何ができて何ができないのかが分からない 動作確認は iOS のベータ版で行うため、うまく動かないときに、原因が自分の実装なのか OS なのかの判断が難しい 動かして確かめるだけでは、原因を切り分けきれませんでした。 そこで、App Schema のドキュメントなどを読み込み、「この仕様なら、こういう要件は実現できるはずだ」というイメージを先に固めるようにしました。 できる・できないの判断は、実際の挙動よりも、ドキュメントに書かれた仕様からの推論を中心にしました。 App Schema の壁と落とし所 当初の目標は、「Siri AI に話しかけるだけで、アプリ名を言わなくてもデリッシュキッチンのレシピが表示される」ことでした。 しかし調査を進めるうちに、この目標は実現できないことが分かりました。 Siri AI がレシピを認識するには、 RecipeEntity を App Schema に準拠させる必要があります。 ところが、App Schema のドメインは Apple が用意したものに限られていて、「レシピ」に当てはまるものがありませんでした。 そこで、今の App Schema でも実現できる範囲に絞り、次の 2 つを実装することにしました。 入口 使う仕組み できること Spotlight IndexedEntity アプリを開かなくても、レシピのタイトルで検索した結果にレシピが表示される Siri AI .system.searchInApp スキーマ キーワード(料理名)付きでアプリの検索結果画面を開く searchInApp は「システムとアプリ内検索」ドメイン(コード上は .system )に含まれる、「アプリの中で検索する」ためのスキーマです。 扱うデータの種類を問わないので、レシピのドメインがなくても、これなら Siri AI からアプリを呼び出せます。 実装①:IndexedEntity で Spotlight に索引登録 IndexedEntity は、Siri AI が登場する前の iOS 18 から使える仕組みで、アプリのデータを Spotlight で検索できるようにするためのものです。 Siri AI 向けの新しい API ではありませんが、これを使って Spotlight からレシピへの入口を作りました。 次の画像は、Spotlight で「基本のプ」と入力したときの様子です。索引に載せたデリッシュキッチンのレシピ「基本のプリン」が、検索結果に表示されています。 なお、本記事の画像はすべて開発用のビルドで撮影しているため、アプリ名が「開発」と表示されています。 ただし、検証では、入力が 3 文字以下のときは、索引に載せたレシピが検索結果に表示されませんでした。画像のように 4 文字入力すると表示されます。 Apple Developer Forums にも、iOS 17 以降、アプリが索引に載せた内容は 4 文字以上入力しないと Spotlight に表示されない、という同様の報告があります。ただ、2026 年 10 月時点で Apple からの回答はなく、仕様なのか不具合なのかは分かっていません。 iOS 17+: Spotlight only shows app-indexed keywords after 4+ characters — intended behavior or bug? RecipeEntity を IndexedEntity に準拠させる IndexedEntity に準拠させると、Spotlight の索引(検索に使うためのデータ)に Entity をそのまま登録できるようになります。 索引への登録や削除は、 CSSearchableIndex というクラスを通して行います。 RecipeEntity に summary (リード文と説明文)と keywords (カテゴリとタグ名)のプロパティを足し、材料と合わせて索引に載せています。 @available ( iOS 18.0 , * ) extension RecipeEntity : IndexedEntity { var attributeSet : CSSearchableItemAttributeSet { let attributeSet = defaultAttributeSet attributeSet.contentDescription = [summary, ingredientsText] .filter { !$0 .isEmpty } .joined(separator : "\n" ) attributeSet.keywords = keywords return attributeSet } } そのため、 RecipeEntity 自体は iOS 17 でも使えるようにしたまま、 IndexedEntity への準拠だけを extension で iOS 18 以降に足しています。 また、 @Property(indexingKey:) (プロパティごとに索引の項目を指定する書き方)は iOS 18.4 以降の API です。プロパティは iOS 17 でも使う RecipeEntity の本体で宣言しているため、これを付けることができません。代わりに、索引に載せる値は attributeSet で渡しています。 索引に載せるレシピを絞る 索引への書き込みは、 RecipeIndexer というクラスにまとめています。登録・削除のタイミングは次のとおりです。 タイミング 処理 起動時・ログイン時 索引を全件消して、お気に入りと閲覧履歴から入れ直す レシピを閲覧したとき そのレシピを索引に追加する お気に入りに追加したとき そのレシピを索引に追加する お気に入りから外したとき 閲覧履歴にもなければ、索引から削除する 書き込みを 1 本の列に並べる お気に入りに追加したときは、API でレシピの詳細を取得してから索引に書き込むため、少し時間がかかります。 このとき、呼び出しごとに別々に書き込むと、お気に入りを付けてすぐ外したときに「外す」処理が先に終わり、あとから「追加」が書き込まれて、外したはずのレシピが索引に残ってしまいます。 そこで、索引への書き込みの依頼はすべて AsyncStream (非同期に値を順番に流すための仕組み)に並べ、1 つのタスクが届いた順に 1 件ずつ処理するようにしました。 次のコードは、説明のために簡略化したものです。 @available ( iOS 18.0 , * ) @MainActor final class RecipeIndexer { static let shared = RecipeIndexer() /// 索引への書き込みの依頼 private enum Event { /// 索引を全件入れ替える case rebuild /// 閲覧したレシピを載せる case index([RecipeEntity]) /// お気に入りに追加したレシピを載せる case add(RecipeEntity.ID) /// お気に入りから外したレシピを消す case removeFavorite(RecipeEntity.ID) } private let index = CSSearchableIndex(name : "delish_recipes" ) private let eventContinuation : AsyncStream < Event > .Continuation private init () { let (events, eventContinuation) = AsyncStream.makeStream(of : Event.self ) self .eventContinuation = eventContinuation // 1 つのタスクが、依頼を届いた順に 1 件ずつ処理する Task { for await event in events { await handle(event) } } } /// 起動時・ログイン時に呼ぶ func rebuild () { eventContinuation.yield(.rebuild) } /// レシピを閲覧したときに呼ぶ func addWatched (_ recipe : Recipe ) { eventContinuation.yield(.index(RecipeEntity.entities( for : [ recipe ] ))) } // ... } アプリの各所からは rebuild() や addWatched(_:) を呼ぶだけで、索引への実際の書き込みは列を処理するタスクだけが行います。 お気に入りの追加・削除も、同じように Event として列に並べています。 起動時に索引を入れ替える 閲覧履歴から押し出された古いレシピや、ログインし直す前のアカウントのレシピなど、列を通らずに対象から外れたレシピが索引に残り続けないよう、起動時とログイン時には索引を全件入れ替えています。 次の rebuildIndex() は、列から .rebuild が届いたときに呼ばれる処理です。 private func rebuildIndex () async { // お気に入りと閲覧履歴のレシピを並行して取得する async let watched = Self .fetchWatched() async let favorites = Self .fetchFavorites() // オフラインなどで片方でも取れなかったときは、今の索引を消さずに残す。次の起動・ログインでやり直す guard let watchedEntities = await watched, let favoriteEntities = await favorites else { return } // 両方に入っているレシピを 1 つにまとめる var entities : [ RecipeEntity.ID : RecipeEntity ] = [ : ] for entity in favoriteEntities + watchedEntities { entities[entity.id] = entity } // 索引の失敗はアプリの操作に影響させない try ? await index.deleteAllSearchableItems() try ? await index.indexAppEntities(Array(entities.values)) } ポイントは次の 3 つです。 お気に入りと閲覧履歴のレシピを async let で並行して取得する どちらかの取得に失敗したときは、今の索引を消さずに残す(オフラインで起動したときに索引が空になるのを防ぐ) 両方に入っているレシピは、ID をキーにした辞書で 1 つにまとめてから索引に載せる 実装②:searchInApp で Siri AI からアプリに遷移 searchInApp スキーマに準拠した Intent Siri AI からの呼び出しには、WWDC26 で新しく発表された App Schema の 1 つである searchInApp を使いました。 Intent に @AppIntent(schema: .system.searchInApp) を付けると、呼び出しのフレーズ(Siri に話しかける決まった言い回し)を AppShortcutsProvider に登録しなくても、「デリッシュキッチンで〇〇を検索して」のような自然な文章から Intent が呼ばれます。 受け取ったキーワードを使って、アプリの検索結果画面へディープリンク(URL でアプリ内の特定の画面を開く仕組み)で遷移させています(参考: System and in-app search )。 次の GIF は、Siri AI に「開発でカレーを検索して」と頼み、アプリの「カレー」の検索結果画面が開くまでの様子です。 前述のとおり開発用のビルドのため、「デリッシュキッチンで」の代わりに「開発で」と頼んでいます。 @available ( iOS 27.0 , * ) @AppIntent ( schema: .system.searchInApp ) struct SearchRecipeBySiriAI { static let searchScopes : [ StringSearchScope ] = [.general] var criteria : StringSearchCriteria @MainActor func perform() async throws -> some IntentResult { let keyword = criteria.term.trimmingCharacters( in : .whitespacesAndNewlines) // お気に入りや閲覧履歴の一覧を指す語なら、検索せずにその画面を開く if let screen = MyRecipeSchemeType(searchTerm : keyword ) { screen.openInApp() return .result() } var allowed = CharacterSet.urlPathAllowed allowed.remove( "/" ) guard ! keyword.isEmpty, let encoded = keyword.addingPercentEncoding(withAllowedCharacters : allowed ), let url = URL(string : " \( SchemeType.search.urlString ) / \( encoded ) " ) else { return .result() } // アプリが起動していない状態から開いたときは、起動画面が終わるまで遷移を待つため、ディープリンクの遷移キューに載せる PopupMediator.shared.handleLink(url : url , scheme : SchemeType.search.rawValue ) return .result() } } ポイントは次の 3 つです。 criteria.term に、Siri AI が聞き取った検索キーワードが入る( searchScopes と criteria は、スキーマで決められているプロパティ) アプリ側は URL のパスの 1 つ目の区切りだけを検索語として読むため、「ご飯/パン」のような語に含まれる / もエンコードして、1 つの検索語にまとめる PopupMediator と SchemeType は、ウィジェットや通知からの遷移でも使っている、アプリ既存のディープリンクの仕組み。これに載せることで、アプリが起動していない状態から開いたときも、起動画面が終わってから検索結果画面に遷移する お気に入り・閲覧履歴画面への遷移 「デリッシュキッチンのお気に入り」のように頼んだときも、 searchInApp には「お気に入り」という検索キーワードとして届きます。 そこで、キーワードが一覧画面そのものを指しているかを MyRecipeSchemeType で判定し、指していればその画面へ遷移させています。 次の GIF は、Siri AI に頼んでお気に入り画面と閲覧履歴画面を開いている様子です。 extension MyRecipeSchemeType { /// 一覧画面そのものを指す言い方。完全一致で判定する private var searchTerms : Set < String > { switch self { case .favorites : [ "お気に入り", "お気に入りレシピ", "お気に入りのレシピ", /* ... */ ] case .histories : [ "閲覧履歴", "履歴", "最近見たレシピ", /* ... */ ] } } /// Siri AI から渡された検索キーワードが一覧画面を指していれば、その画面を返す init ?(searchTerm : String ) { // 空白や、「画面」「一覧」のように画面を指す接尾辞を取り除いてから判定する let normalized = Self .normalize(searchTerm) guard let screen = Self .allCases.first( where : { $0 .searchTerms.contains(normalized) }) else { return nil } self = screen } /// マイレシピのタブから、この一覧画面へ遷移する @MainActor func openInApp() { guard let url = URL(string : urlString ) else { return } // 検索結果画面と同じく、ディープリンクの遷移キューに載せる PopupMediator.shared.handleLink(url : url , scheme : SchemeType.myrecipe.rawValue ) } } まとめ iOS 27 の Siri AI にレシピを直接表示させるという当初の目標は、App Schema にレシピのドメインがないため実現できませんでした。 それでも、Spotlight と Siri AI という、アプリの外からレシピへの入口を 2 つ作ることができました。 今回一番学んだのは、 最新技術はまず「できること・できないこと」の見極めから始める ということです。 資料が少なく、ベータ版では不具合の原因が自分の実装なのか OS なのかも分かりにくいため、いきなり作り始めると、できないことに時間を使ってしまいます。 WWDC の発表やデモアプリで何ができるのかを先に確かめ、ドキュメントの仕様から実現できる範囲を見極めたことで、早い段階で落とし所を決められました。 資料の少ない最新技術を学び、実装まで落とし込むのは今回が初めての経験でした。iOS エンジニアを目指す自分にとって、良い予習になったと思います。 今後 App Schema にレシピに当てはまるドメインが追加されれば、Siri AI に直接レシピを表示させるという当初の目標にも再挑戦できるはずです。 最後まで読んでいただき、ありがとうございました。
はじめに こんにちは、トモニテ開発部の吉田です。 現在、トモニテではデータ基盤(Databricks)の整備に取り組んでいます。これまでの分析は個人が作成したスプレッドシートに頼る部分が多く、人やシートによって指標の定義が違ったり、誰が何を見られるのかを把握しにくかったりしたため、データを一元化することにしました。 その一環として、アプリの本番 DB のデータを Databricks に連携します。本番 DB には扱いに気をつけるべき情報が多く入っているので、開発部では「どのテーブル・カラムを Databricks に渡すか」をアプリのリポジトリで定義し、デプロイのたびにその定義を DB の権限と view に反映する仕組みをつくりました。 この記事は、本番 DB をデータ基盤につなごうとしていて、個人情報の扱いに悩んでいるエンジニアに向けて、その設計で考えたことを紹介します。伝えたいのは次の2点です。 連携の分類をマイグレーションの手順に組み込み、人が気づくことに頼らない 本番 DB を書き換えるツールは、失敗したら見える範囲を減らす側に倒す 全体の流れは次のとおりです。 図は本番環境の流れです。開発環境でも同じ流れで同期します。 連携に必要なこと Databricks が本番 DB(Aurora MySQL 3.10.4、MySQL 8.0 互換)のデータを取り込むには、次の2つが必要です。 Databricks の環境から本番 DB のリードレプリカに届く接続経路 Databricks が参照に使う、読み取り専用の DB ユーザー 経路はインフラ側で用意すれば済みますが、もう1つの「読み取り用のユーザーに何を見せるか」は、そう簡単には決められません。この記事の本題はここです。 何をどこで絞るか トモニテは妊娠・育児期の家族向けのアプリで、DB には扱いに気をつけるべき情報が多く入っています。一方で、分析に必要な情報もあり、テーブル・カラムの単位で「渡す/粗くして渡す/渡さない」を決める必要があります。 DB 側で絞る 絞り方には、次の2つがあります。 データ基盤にはすべて取り込み、分析向けにはデータ基盤側でマスクする 読み取り用ユーザーの権限で、問題のないものだけを見せる 一度 Databricks に取り込まれたデータは、コピー先まで含めて消すのが難しくなります。データの発生元である DB 側で絞るのが一番漏れのない形なので、2を選びました。 カラム単位の GRANT ではなく view で渡す 最初は、 GRANT SELECT (id, created_at, ...) ON users のように機密カラム以外のカラムだけに GRANT する方法(カラム単位の GRANT)を試しましたが、そのテーブルを Databricks から読めなくなりました。Databricks からは、Lakehouse Federation(外部の DB を Unity Catalog のカタログとして見せる機能)の MySQL 接続で読んでいます。 手元の MySQL 8.0 で、カラム単位の GRANT だけを持つユーザーをつくって確かめると、次のようになりました。 クエリ 結果 SHOW TABLES 、 information_schema.COLUMNS テーブルは見え、カラムは許可したものだけが見える SELECT id, created_at FROM users (許可したカラムだけを指定) 読める SELECT * FROM users WHERE 1=0 ERROR 1142 (42000): SELECT command denied to user ... for table 'users' MySQL では、許可していないカラムを含む SELECT * は、行を1件も返さないクエリでも拒否されます。しかもエラーはカラムではなくテーブル単位の拒否(1142)として返るので、テーブルの権限そのものが無いように見えます。 一方、Spark の JDBC は、テーブルのカラムと型を調べるときに既定で SELECT * FROM <テーブル> WHERE 1=0 を送ります。この既定は JdbcDialect.getSchemaQuery で、MySQL 用の MySQLDialect はこれを上書きしていません。 github.com github.com Databricks も同じクエリを送っているのかを、Aurora の performance_schema で確かめました。 events_statements_summary_by_digest は、実行されたクエリの値を ? に置き換えた形ごとに集計した表で、実行回数・返した行数・エラーの回数に加えて、置き換える前のクエリの例( QUERY_SAMPLE_TEXT )も持っています。users について、元のテーブルと、後述する機密カラムを除いた view( <masked> のデータベースに置いたもの)を、次のクエリで比べました。 SELECT QUERY_SAMPLE_TEXT, COUNT_STAR, SUM_ROWS_SENT, SUM_ERRORS FROM performance_schema.events_statements_summary_by_digest WHERE DIGEST_TEXT IN ( ' SELECT * FROM `<元のデータベース>` . `users` WHERE ? = ? ' , ' SELECT * FROM `<masked>` . `users` WHERE ? = ? ' ); QUERY_SAMPLE_TEXT COUNT_STAR SUM_ROWS_SENT SUM_ERRORS SELECT * FROM `<元のデータベース>`.`users` WHERE 1=0 41 0 7 SELECT * FROM `<masked>`.`users` WHERE 1=0 40 0 0 WHERE 1=0 を付けた SELECT * が届いていて、1行も返していませんでした。Lakehouse Federation も、Spark と同じ方法でカラムを調べていると考えられます。カラム単位の GRANT を試した元のテーブルにはこのクエリのエラーが記録されていて、view では 0 回でした。カラムを調べる段階で拒否され、テーブルごと読めなくなったと考えています。 許可したカラムを明示したクエリ( SELECT * FROM (SELECT id, created_at FROM users) t )なら通るので、読む側でテーブルごとにカラムを指定すれば回避はできます。ただ、それでは「何を渡すか」の定義が Databricks 側にも分散します。そこで、機密カラムを除いた view を別のデータベース(以下、masked)につくって渡すことにしました。view なら SELECT * で読んでも、出てくるのは view が出力するカラムだけです。連携が不要なテーブルには、権限を付けません。 加工も Databricks の ETL ではなく view で行います。ETL で加工すると、加工前の値が、取り込んだデータをそのまま置く層(Bronze)に残ってしまうためです。 view と Unity Catalog の二段構え view で落とす・粗くするのが一段目、Databricks 側の Unity Catalog で見られる人を絞るのが二段目です。view で粗くしきれないものは、二段目で閲覧者を限定します。 分類の方針 どのカラムを渡すかは、厳しめにルールを整備しました。 認証情報や、それ単独で個人情報になるものは渡さない 単独では個人を特定できなくても、ほかの情報と容易に照合することで個人を識別できるもの(容易照合性)も考慮し、渡さないか、分析に必要な粒度まで粗くして渡す 判断に迷ったら「渡さない」側に倒す 迷ったら渡さない側に倒したのは、あとから必要になれば view を直して追加できますが、一度渡したものを消すのは難しいからです。厳しめにした分、運用を始めてメンバーから「分析しづらい」という声が出れば、見直すつもりです。 連携するテーブル・カラムを tables.yml で宣言する この方針では、テーブルの追加・変更のたびに権限を整理する必要があります。手作業で整理する運用は避けたいので、定義をリポジトリで管理し、CI で検査、デプロイで反映する仕組みにしました。 定義は、アプリ(API サーバー)のリポジトリに置いています。中心になるのは tables.yml で、全テーブルを次の3つに分類します。 grant :そのまま渡す view :機密カラムを除いた view で渡す exclude :渡さない view のテーブルは、さらに全カラムを include (そのまま渡す)/ transform (加工して渡す)/ exclude (渡さない)に分け、 transform には加工内容と理由を、 exclude には理由を書きます。 現在は全 201 テーブルを分類していて、内訳は grant が 165、 view が 30、 exclude が 6 です。 # テーブル名・カラム名は説明用の例です tables : table_a : { policy : grant } table_b : policy : view columns : include : [ id, status, created_at ] # そのまま渡すカラム transform : # 加工して渡すカラム: 加工内容と理由 column_x : 粒度を粗くする。元の値はほかの情報と照合すると個人を特定できる exclude : # 渡さないカラム: 理由 column_y : 個人を特定できる情報 table_c : policy : exclude reason : 連携が不要 この定義と、view の SQL( views/*.sql )、Databricks の読み取り用ユーザーへの GRANT( grants.sql )を1つのディレクトリで管理しています。 なぜインフラのリポジトリではなく、アプリのリポジトリに置いたか トモニテのインフラは Terraform で別のリポジトリが管理していますが、定義はアプリのリポジトリに置きました。 見直しのきっかけになるテーブル・カラムの追加はアプリのマイグレーションで行われるので、同じ PR で分類と view も更新・レビューできる 分類漏れの検出にはマイグレーション済みの DB が必要で、アプリの CI ではすでに MySQL を立ててマイグレーションを流している Databricks からの経路や DB ユーザー・IAM などは、引き続きインフラのリポジトリで管理しています。 分類を書くまで CI が通らないようにする CI では、マイグレーション後のスキーマと定義を突き合わせ、次のものがあれば落とします。 分類されていないテーブル、 view のテーブルの分類されていないカラム 元のテーブルの変更で壊れた view exclude ・ transform にしたカラムを、そのまま出力している view grants.sql と tables.yml の食い違い これで、マイグレーションでテーブルやカラムを足すと、分類を書くまで CI が通らなくなります。 「追加に人が気づく」ことに頼らず、分類を書くことがマイグレーションの手順に組み込まれる 形です。 開発メンバーにとっては、マイグレーションのたびに分類を書く手間が増えます。この手間をどう感じるかは、これから運用しながら確かめていきます。 開発環境の DB でも実際に止まった 同じ検査は、後述の同期コマンドもデプロイのたびに実際の DB に対して行います。開発環境で動作確認したところ、定義に分類の無いカラムがある状態で、同期が想定どおり止まりました。別の機能の開発で開発環境の DB にはすでにカラムが追加されていましたが、連携定義にはそのカラムの分類がなかったためです。止まったのは定義の検証の段階(Apply の前)なので、DB には何も変えずに失敗が通知されました。 マイグレーションと定義のずれを、CI だけでなく実際の DB に対しても検知できることを確かめられました。 同期コマンド:差分(Plan)を計算して適用する 定義を DB に反映するのは、Go で書いた同期コマンドです。デプロイのたびに ECS のタスクとして起動し、定義を検証して排他をとったうえで、今の権限と view を読んで定義との差分(Plan)を計算し、適用します。適用後は状態を読み直して確かめ、変更か失敗があれば Slack に通知します。 宣言的で冪等に動くので、差分がなければ何もせず、通知もしません。定義に無い権限や view は外れます。手作業で付けた GRANT や、手でつくった view も、同期用ユーザーが外せる範囲で次の同期で外れます。 権限の管理には Terraform の MySQL provider などの既存ツールもありますが、定義をアプリのリポジトリに置いたので、同期コマンドもアプリのリポジトリに置き、アプリと同じ Go で書きました。 適用の順番を固定する Plan は、必ず次の順番で適用します。 ① view の作成・更新 ② GRANT ③ REVOKE と、定義に無い view の DROP この順番には、次のルールを決めています。 ①が1本でも失敗したら、②は流さない。 新しい view ができていないのに、GRANT だけが増える状態を避けるためです ①の中、②の中では、1本が失敗しても残りは止めない。 関係のない文まで巻き込んで止めないためです ③は①②の結果にかかわらず必ず全部流し、1本が失敗しても残りを続ける。 見える範囲を減らす操作は、止める理由がないためです 適用後に確かめ、だめなら段階的に安全側へ倒す 適用が終わったら、状態(権限と view の一覧)と、masked の各 view の出力カラムを読み直します。そのうえで、機密カラム( exclude ・ transform にした元のカラム)を出している view があれば、次のように段階的に対応します。 その view を DROP する。取り込みは止まるが、漏れは止まる DROP できなければ(ほかのユーザーが作った view など)、masked 全体の SELECT を REVOKE する。全 view の取り込みが止まるが、機密カラムが見え続けるよりは安全 それもできなければ、機密カラムが見え続けていることを至急の通知で知らせる 同期コマンド自体を信用しすぎない 同期は本番 DB の権限と view を書き換えるので、誤って別のユーザーや別のデータベースを触らないよう、コマンド自体にも制限をかけています。 発行できる SQL を4種類に固定する masked の view をつくる・更新する CREATE OR REPLACE VIEW 読み取り用ユーザーに権限を付ける GRANT SELECT 読み取り用ユーザーの権限を外す REVOKE masked の view を消す DROP VIEW 制限は二段構えです。 生成時 :SQL は専用の型( Statement )のコンストラクタからしか作れず、この4種類以外を組み立てる手段がない 実行直前 :実行する側が、許可リスト(正規表現)で SQL の文字列を再検査し、一致しないものは実行しない 同期用ユーザーの権限を絞る 同期用の DB ユーザーには、必要な権限だけを付けています。同期で外せる権限も、同期用ユーザーが GRANT OPTION 付きで持っている範囲に限り、それ以外の権限が付いていた場合は、同期では直せないものとして毎回失敗で通知します。 この最小権限で同期が通ること、2回目の同期では SQL を1本も送らないこと(冪等)、手作業で足した権限が次の同期で外れることは、CI の MySQL で本物の定義を使った結合テストで確かめています。 デプロイとの連動 同期は、開発環境・本番へのデプロイの後に、別のジョブとして起動します。定義やマイグレーションなど同期に関係するものが変わったときだけ動き、変わったかを判定できないときは、見落とさないよう同期する側に倒しています。失敗した実行は成功に数えないので、その変更は次のデプロイで同期し直されます。 割り切ったこと 最後に、この仕組みでは検出できないことも書いておきます。 view の検査は、 exclude ・ transform にした元のカラム名がそのまま出力に無いかだけを見ている。別名を付ける、式で包むといった書き方で機密カラムを出しても検出しないので、view の SQL の変更は PR のレビューで確かめる grant のテーブルに機密カラムが足された場合は、CI では検出できない。マイグレーションのレビューで view に切り替える 読み取り用ユーザーのグローバル権限やロール経由の権限は、同期用ユーザーが読めないので検出できない すべてを機械で防げるわけではないので、機械で防げる範囲と、レビューで確かめる範囲を README に明記しています。 まとめ 本番 DB のデータを Databricks に安全に渡すために、「どのテーブル・カラムを渡すか」をアプリのリポジトリで宣言し、デプロイのたびに DB に反映する仕組みをつくりました。冒頭の2点をあらためて挙げます。 連携の分類をマイグレーションの手順に組み込み、人が気づくことに頼らない。 分類を書くまで CI が通らず、分類はマイグレーションと同じ PR でレビューする 本番 DB を書き換えるツールは、失敗したら見える範囲を減らす側に倒す。 適用の順番を固定し、機密カラムが見えていれば view の DROP、masked 全体の REVOKE と段階的に安全側へ倒す データ基盤に何を渡すかを、シートの共有範囲ではなく、リポジトリの定義で管理できるようになりました。これからは、一元化したデータの上で分析を進めていきます。
RAGとJevで「ベクトルDBに根拠となる答えがない」ことを見分ける仕組みを作った話 はじめに こんにちは。1か月間のインターンで、サーバーサイド(Go)を担当した橋本です。 インターンでは、AIエージェントの機能追加の設計・実装に携わりました。この記事では、その中でもRAG(Retrieval-Augmented Generation)の検索の部分に焦点を当てます。RAGは、質問に関係する文書を探してLLMに渡し、その文書をもとに回答を作成する仕組みです。ここではデリッシュキッチンの情報を検索し、AIの回答の根拠として使えるかどうかを検討しました。 作ってみて一番難しかったのは、「ベクトルDBに根拠となる答えがないことをどのように見分けるか」でした。関係の薄い情報をLLMに渡してしまうと、LLMはそれをもとにもっともらしいが正確ではない回答を生成してしまう可能性があります。よってベクトルDBに根拠となる情報が無い場合は、LLMに回答させないようにする必要があります。 この記事では、次の2つのトピックに絞ります。 HYBRIDとANNという2つの検索を組み合わせて、候補を絞る仕組み 絞った候補が質問に答えているかを、TypeSafeのAIモデルJevで文書ごとに判定する仕組み HYBRIDとANNはベクトルDBに対する検索方式で、具体的なアルゴリズムは後述します。 最後に、これらの手法についてローカルで計測した結果も記述しています。なお、以下に出てくる質問や文書の例は、説明のために作ったものです。 全体の流れ 前提として、ベクトルDBとインデックスはDatabricksのAI Searchというサービスにてホストされており、バックエンドサーバーからベクトルDBに対して直接リクエストするような形式を取っています。 またインデックスは、文書を段落レベルの単位(チャンク)に分けた上で埋め込みベクトルを生成しています。 処理の全体像は以下の通りです。 検索方式を決めるまで インターンで一番やりがいを感じたのは、この検索方式を決めるところでした。自分の考えを特に多く入れられた部分でもあるので、どう考えて今の形に落ち着いたのかを書きたいと思います。検索方式は何度か変わり、HYBRIDから始めてANNに移り、最後に両方を組み合わせる形になりました。 欲しかった候補 例えば「ハンバーグの焼き時間は?」と聞かれたとします。候補に来てほしいのは、「ハンバーグの作り方」のようなハンバーグについての文書です。「鶏むね肉のソテーの焼き時間」のような、別の料理の焼き時間の文書ではありません。 ベクトルの近さだけで探すと、別の料理の文書でも質問と同じく「焼き時間」について書かれていれば上位に来るおそれがありました。そこで最初は、HYBRIDを使うことを考えました。HYBRIDはベクトルの近さとキーワードの一致を合わせて並べるので、「ハンバーグ」という言葉が入った文書が上位に来やすく、聞かれている対象を外しにくくなります。 HYBRID単体では根拠となる回答があるかを判断できない HYBRIDを使う場合、scoreが閾値より低ければ「答えが無い」と判定するつもりでした。ところが、設計を詰める中で、HYBRIDではこの判定が難しいことに気づきました。 HYBRIDは、ベクトル検索とキーワード検索の結果を、Reciprocal Rank Fusion(RRF)で1つにまとめる検索です。DatabricksのAI Searchでは、チャンクxについて次の値を計算します(rankは、それぞれの検索でのxの順位で、1位を1と数えます。60はRRFの定数kで、AI Searchでは60です)。 AI Searchが返すscoreは、この値を取りうる最大値で割り、最大が1になるようにしたものです。一定の値で割るだけなので、scoreが順位で決まることは変わりません。 このように、HYBRIDのscoreは2つの検索の 順位 から計算するので、検索語とチャンクがどれだけ近いかの目安にはなりません。インデックスの文書がどれも質問と関係なくても、上位の文書には、順位に応じた高いscoreが付きます。そのためscoreを見ても、答えのある文書があるのか、関係のない文書しか無いのかを区別できません。 ANNと、見出しを足した埋め込み 次に、ANN(ベクトルの近さだけで探す検索)に切り替えました。AI SearchのANNは、検索語のベクトルqとチャンクのベクトルxの距離(ユークリッド距離)から、次の式でscoreを計算します。 ‖q − x‖ はqとxのユークリッド距離です。距離が0ならscoreは1になり、遠いほど小さくなります。使っているOpenAIの埋め込みは長さ1に正規化されているので、距離は最大でも2になり、実際のscoreは0.2〜1の間に収まります。 HYBRIDでは、どの文書も関係なくても上位に高いscoreが付きますが、ANNでは、どのチャンクも遠ければscoreも低いままです。ANNのscoreは検索語とチャンクの近さそのものなので、閾値を付けられます。残った心配は、聞かれている対象を外すことです。この点をインデックスの側で補うために、埋め込む文を工夫しました。 RAGでは、文書を段落のような小さな単位(チャンク)に分け、チャンクごとにベクトルにするのが一般的です。文書全体を1つのベクトルにすると、そのベクトルはいろいろな関心事を含み、細かい質問に対応しくくなってしまいます。 ただ、チャンクに分けると、今度は文脈が抜け落ちます。文書の中ではタイトルや見出しが何について書かれているかを示していますが、段落だけを切り出すと、その手がかりが無くなります。例えば、次の段落があるとします。 中火で 5 分ほど焼き、裏返してふたをします。 この段落だけでは、何を焼くのかが分かりません。同じ文書のタイトルが「ハンバーグの基本の作り方」で、段落が「焼き方」という見出しの下にあれば、「ハンバーグは中火で5分ほど焼く」という内容だと分かります。もし段落だけをベクトルにすると、「ハンバーグ」や「焼き方」で聞かれてもこの段落はヒットしないでしょう。 そこで、埋め込む文には、チャンクの本文の前に、文書のタイトルと、そのチャンクが属する見出しをつなげて入れました。 文書のタイトル / 見出し / チャンクの本文 見出しが何階層かある文書では、チャンクのすぐ上にある見出しだけでなく、それより上の階層の見出しまでつなげます。すぐ上の見出しだけでは、上の階層が示す文脈(「作り方」なのか「アレンジ」なのか)が抜け落ちるからです。 HYBRIDとANNの並行 ただ、見出しを足しても、ANNが見ているのはあくまでベクトルの近さです。固有の料理名や略語のように、言葉がそのまま一致するかどうかが決め手になる質問では、近さだけだと、似た話題の別の文書が上位に来るおそれが残りました。一方で、「答えが無い」を見分けるための閾値には、近さそのものを表すANNのscoreが要ります。 そこで、自分が出した案をもとにチームで相談して、2つを組み合わせる形に決めました。並べ替えはキーワードの一致も使えるHYBRIDに、明らかに遠い候補を閾値で落とすのは近さそのものをscoreで返すANNに任せます。インデックスに入れる見出しの工夫は、そのまま残しています。 具体的には、2つを並行に問い合わせ、HYBRIDの結果のうち、ANNの閾値を通ったチャンクを持つ文書のものだけを残します。次のコードは、説明のために簡略化したものです。 // HYBRID と ANN(閾値つき)を並行に問い合わせる。片方が失敗したら、もう片方も止める var annHits, hybridHits []*Hit g, gctx := errgroup.WithContext(ctx) g.Go( func () (err error ) { annHits, err = index.Query(gctx, annQuery); return }) g.Go( func () (err error ) { hybridHits, err = index.Query(gctx, hybridQuery); return }) if err := g.Wait(); err != nil { return nil , err } // annQuery に閾値を付けてあるので、annHits には閾値を通ったチャンクだけが入っている。 // そのチャンクを持つ文書を「近い」とみなす near := map [ int64 ] struct {}{} for _, h := range annHits { near[h.DocID] = struct {}{} } // HYBRID の結果は、近い文書のものだけを残す(並び順は HYBRID のまま) hits := make ([]*Hit, 0 , len (hybridHits)) for _, h := range hybridHits { if _, ok := near[h.DocID]; ok { hits = append (hits, h) } } 文書の単位で照らし合わせているのは、ANNとHYBRIDでは、同じ文書でも別のチャンクが返ってくることがあるからです。 自分で作ったベクトルを入れるインデックスでもHYBRIDを使えるかは、Databricksのドキュメントからは分からなかったので、ローカルで試して動くことを確かめました。ただ、少数の質問でANNだけの場合と比べたところ、最終の結果にはほとんど差が出ませんでした(後の「ローカルで試した結果」で詳しく記述しています)。HYBRIDを足した効果はまだ言い切れないので、設定でANNだけの形にも切り替えられるようにしました。 文書ごとのまとめ インデックスから返ってくるのはチャンクの単位です。そこで、同じ文書から来たチャンクを1つの候補に束ね、文書のscoreは、その文書のチャンクの(HYBRIDの)scoreの最大値にします。このscoreは候補を並べる順番にだけ使い、上位10文書を判定にかけます。判定を通った文書からも、この順番のまま上位3文書を選びます。 同じ話題だが答えていない文書 ここまでで、明らかに意味が異なる候補は落とすことができます。 ただ、同じ話題だが質問の回答にはならない文書を完全に落とすことはまだできていません。 例えば「ハンバーグの焼き時間は?」と聞かれ、インデックスには「ハンバーグの由来」を紹介する文書しかないとします。この文書は焼き時間には答えていませんが、ハンバーグについて書かれているので、チャンクのベクトルは検索語のベクトルに近くなります。ANNの閾値を厳しくすれば落とせるかもしれませんが、そうすると今度は、答えている文書まで落ちてしまいます。 考えた案は次の3つでした。 案 結論 理由 ANNの閾値を厳しくする 見送り 答えている文書まで落ちる。閾値だけでは、同じ話題だが答えていない文書を分けられない 質問と文書の主題が一致するかを見る 見送り 主題が一致していても、聞かれたこと(焼き時間)が書かれているとは限らない 文書ごとにAIモデルで判定する 採用 この文書はこの質問に答えているかを、直接聞ける 最終的にチームで決めたのは、ANNの閾値で明らかに遠いものを落とし、残った上位10文書をAIモデルで判定する形です。 従来のLLMでは推論速度に少なからず影響が出るのが懸念だったのですが、Jevを使用すれば比較的影響を少なく実装できると思い、今回試してみることになりました。 Jevで文書ごとに判定する Jevとnoul型 判定にはTypeSafeのJevを使いました。Jevは従来のLLMのようなテキスト生成をするのではなく、高速かつ型安全なデータ構造を出力することに特化した新しいAIモデルです。今回はCloudflareのWorkers AIのAPIから呼びだしています。 Jevに渡す問い( questions )の型にはいくつかあり、今回は noul を選択しました。 noul は、trueとfalseの基準を渡すと、trueに当たる確率を0〜1の数で返します。答えているかどうかを確率で受け取れるので、判定の閾値を後から調整できます。 判定に渡すもの 1文書ごとに、次の形でリクエストを送ります(文言は簡略化し、一般的な言い方に直しています)。 { " model ": " typesafe/jev ", " input ": { " state ": " 質問: ハンバーグの焼き時間は? \n タイトル: …… \n\n [大見出し / 中見出し] \n 本文の段落…… \n\n [大見出し / 中見出し] \n 本文の段落…… ", " questions ": { " answers_question ": { " type ": " noul ", " instructions ": " You decide whether the blocks in the state answer the user's question. ... Use the headings only to understand where each block sits; do not count a heading alone as an answer. ", " criteria ": { " true ": " The text of the blocks alone answers the question, at least in part. ", " false ": " The blocks are about the same topic but do not answer the specific thing that was asked, or they are about something else. " } } } } } state に判定の材料(質問と、見出し付きのチャンク)を、 questions に何を判定させるかを入れます。 工夫したのは次の4点です。 渡すのは、その文書のチャンクのうち、検索のscoreが高い上位3つだけです。文書全体を渡すと長くなり、判定も遅くなるからです。答えが書かれた段落は質問と内容が近いので、上位3つに入っているはずだと考えました チャンクには見出しも付けて渡しますが、見出しはその段落が文書のどこにあるかを示す手がかりとしてだけ使うよう、指示に書きました。例えば見出しに「焼き時間」とあっても、本文に焼き時間が書かれていなければ、答えているとは数えません falseの基準には、「同じ話題だが、聞かれたことには答えていない」場合をはっきり書きました。ANNの閾値(ベクトルの近さ)で落とせずに残るのは、まさにこういう文書だからです 広い質問は別に扱います。「ハンバーグについて教えて」のような質問なら、ハンバーグを説明している文書は答えていると数える、と指示に書きました。これが無いと、広い質問では「具体的なことに答えていない」として落ちかねません 返ってきた確率が判定の閾値(今は仮で0.5)以上なら、答えているとします。 ローカルで試すと、同じ文書でもANNとHYBRIDでは当たるチャンクが違い、判定に渡す3チャンクが変わる文書が58件ありました。それらの文書で、ANNだけのときとHYBRID+ANNのときのJevの確率の差は、中央値で0.01でした。渡すチャンクが多少変わっても、判定はほとんど変わらないようです。ただし、差が最大で0.61開いたものもあり、0.5をまたいで、通るかどうかが逆になったものが2件ありました。 並行判定とやり直し 10文書は並行して判定します。1文書ずつ順番に呼ぶと、待ち時間が文書の数だけ積み重なるからです。どれか1つの判定でエラーが起きても、その文書を判定できなかったものとして扱うだけで、ほかの文書の判定はそのまま続けます。前処理のHYBRIDとANNの問い合わせでは、片方が失敗したら両方を止めるerrgroupを使いましたが、ここでは1つの失敗でほかを止めたくないので、sync.WaitGroupを使っています。 // 1 文書の判定結果。判定できなかったときは Err に入れ、他の文書は止めない type Verdict struct { Probability float64 // 「答えている」の確率 Err error } // 文書ごとの失敗は Verdict.Err で返すので、ここでは常に nil を返す func (j *JevJudge) Judge(ctx context.Context, query string , docs []*Candidate) ([]*Verdict, error ) { verdicts := make ([]*Verdict, len (docs)) var wg sync.WaitGroup for i, d := range docs { wg.Go( func () { // Go 1.25 の sync.WaitGroup.Go p, err := j.judgeOne(ctx, query, d) verdicts[i] = &Verdict{Probability: p, Err: err} }) } wg.Wait() return verdicts, nil } Jevの判定1回にかかる時間は中央値で247 msでした。並行にしたので、判定の段階は数百msで終わっています。 判定でエラーが起きた文書は、最後に選ぶ3文書がまだそろわず、時間も残っているときに限り、1回だけ判定をやり直します。一方、答えていないと判定された文書はやり直しません。エラーと違って、判定そのものはできているからです。 判定の結果から、返すものは次の3通りに決まります。 判定の結果 返すもの 答えている文書が1つ以上ある その文書(上位3つまで) すべての文書が「答えていない」と判定された 「答えが無い」 答えている文書が無く、判定できなかった文書が残った エラー 3つ目を「答えが無い」にしないのは、判定できなかった文書に答えが書かれているかもしれないからです。その場合に「答えが無い」と返すと、実際には答えがあるのに無いと伝えてしまいます。 閾値の決め方と、ローカルで試した結果 ANNの閾値と判定の閾値は、今は仮の値です。どちらの値にも同じトレードオフがあります。厳しくすると、答えが無い質問では正しく0件にできる代わりに、答えがある質問では正しい文書まで落としてしまいます。 そのため、評価では次の2つを分けて測ります。 指標 測るもの 検索の正しさ 正解の文書が上位3文書に入るか 答えが無い質問 答えが無い質問で、正しく0件になるか RAGの評価では、LLMが渡された文書のとおりに答えを書けているかを測ることがよくあります。ただ、これだけでは検索の失敗が見えません。 例えば「ハンバーグの焼き時間は?」に対して、検索が鶏むね肉のソテーの文書を渡してしまったとします。LLMがその文書のとおりに「片面3分ずつです」と答えれば、文書に忠実なので、この測り方では満点になります。しかし、ユーザーが聞いたのはハンバーグの焼き時間なので、答えとしては間違いです。そのため、検索が正しい文書を選べているか(上の表の「検索の正しさ」)は、別に測る必要があります。 評価に使う質問には、答えが文書に書かれている質問、どの文書にも答えが無い質問、答えが一部にしか書かれていない質問の3種類を混ぜます。なお、評価のために外部のサービスであるJevへ送るのは、ユーザーが実際に送った質問ではなく、評価用に作った質問だけです。 ローカルで試した結果 評価用に作った15問、Jev判定なしと判定ありの差を比べました。Jevで判定すると、答えが無い質問の多くで、正しく「答えが無い」と返せるようになりました。その代わり、答えがある質問12問のうち1〜2問では、文書を全部落としてしまいました。 答えが無い質問に文書を返してしまった(5問中) 答えがある質問で文書を全部落とした(12問中) 判定なし 5 0 Jevで判定(ANNだけ) 1 1 Jevで判定(HYBRID+ANN) 1 2 どちらの列も、数が少ないほど良い結果です。 今回はどの組み合わせでもANNの閾値を0にしていたので、閾値では何も落ちません。そのため判定なしの場合は、どんな質問にも上位3文書がそのまま返り、答えがある質問では12問すべてで文書が残りました。一方で、「明日の東京の天気は?」「スマホの電池を長持ちさせる方法は?」のような、インデックスのどの文書とも関係のない質問にも文書を返してしまいました。 Jevで判定すると、答えが無い質問5問のうち4問で、正しく「答えが無い」と返せました。一方、答えがある質問でも、文書が1つも残らないものが、ANNだけで1問、HYBRID+ANNで2問ありました。そのうちの1つは、「薄力粉と強力粉の違いは?」のような、2つのものの違いを聞く質問です。インデックスには薄力粉の文書と強力粉の文書が別々にあり、どちらも1つだけでは違いに答えていないので、両方とも落ちました。文書を1つずつ判定する作りの弱みで、複数の文書にまたがる質問には別の扱いが要ります。 ANNだけとHYBRID+ANNの差は、今回はほとんど出ませんでした。判定にかける上位10文書は、一部が入れ替わっているだけでした。それでも、最終の結果が違ったのは1問だけで、それも判定に渡したチャンクの違いによるものでした。 推論速度の中央値は次のとおりです。 段階 時間(中央値) 検索語の埋め込み 145 ms 検索(ANNだけ) 213 ms 検索(HYBRID+ANN) 242 ms HYBRID+ANNは2本を並行に問い合わせるので、ANNだけとの差は30 msほどです。判定の段階は、前に書いたとおり数百msです。 この結果には、次の注意点があります。 質問は評価用に作ったもので、実際のユーザーの質問ではありません 閾値は仮の値です(判定は0.5、ANNは0)。ANNの閾値で遠い候補を落とす効果は、まだ測れていません 学んだこと HYBRIDのscoreは「近さ」ではない。 順位から作った値は並べ替えには使えても、答えの有無を閾値で見分けるのには使えません。scoreが何から計算されているかを確かめてから使う必要がある。 ベクトルの近さで分かるのは「意味が近いか」まで。 「聞かれたことに答えているか」は、近さとは別の問い。 判定の結果は確率で受け取る。 はい/いいえで受け取ると、閾値を動かせません。Jevの noul のように確率で返すものを使えば、評価を見ながら調整できます 文書ごとの判定は、複数の文書にまたがる質問に弱い。 「AとBの違い」のような質問は、1つの文書だけでは答えられないので、文書が全部落ちることがあります おわりに RAGの「根拠となる答えが無い」ことをどう見分けるかは、作る前に思っていたよりずっと奥が深い問題でした。 1か月のインターンで、検索方式の選び方から判定の仕組みまで自分で案を考えて提案し、チームと相談しながら形にできました。評価では、判定を足すと、答えが無い質問の多くを排除できる傾向も見えました。 一方で、答えがある質問でも、文書を全部落とす場合があることや、複数の文書にまたがる質問に弱いことも分かりました。さらに質問を増やした評価は、これからの課題です。 お忙しい中、レビューをしてくださり、相談にも乗ってくださった 配属先のチームの皆さん、特に岩崎さん、ありがとうございました。
はじめに こんにちは、開発1部の新谷です。 最近、個人情報の漏洩のニュースが増えています。私は普段セキュリティを専門にしているわけではありませんが、漏洩への対策として、そもそも自社で個人情報を持たないように設計する方法はすぐに思いつくと思います。 ただ、それを具体的にどう実現するのかは、詳しく調べたことがありませんでした。そこで今回、認証や決済などを外部サービスに任せることで、自社のDBからどこまで個人情報をなくせるのかを調べて考えてみました。 DBに持たないことで防げる場面 DBに個人情報を持っていなければ、DBやそのコピーを抜かれても個人情報は漏れません。設定ミスでDBが外から見えてしまった場合や、分析基盤や開発環境にコピーしたデータが漏れた場合、DBにだけアクセスできる人が持ち出した場合も同じです。DBにあるのがユーザーIDや決済サービスのトークンだけなら、攻撃者はそれだけでは誰のデータかを特定しにくくなります。 一方で、保存する前の段階やDB以外の場所で盗まれる場合は、DBに持たないようにしても防ぎにくいです。 入力画面のJavaScriptが改ざんされると、ユーザーが入力した時点で情報が抜かれる XSSやセッションの乗っ取りでは、攻撃者が本人として正規の画面からデータを見る DBに保存していなくても、ログやエラー通知に入力値が残っていることがある 外部サービスに任せた場合、そのサービス側の事故は自社では制御できない アプリのサーバーを乗っ取られると、アプリの権限でIDaaSのAPIを呼ばれ、個人情報を取得される サーバーを乗っ取られた場合でも、APIサーバーに渡すIDaaSの権限を絞ったり、IDaaSのAPIの呼び出しを監視したりすれば、被害を小さくできます。 どこまでが個人情報になるのか 個人情報保護法では、氏名やメールアドレスのように、それだけで特定の個人を識別できる情報が個人情報にあたります。加えて、他の情報と容易に照合でき、それによって特定の個人を識別できる情報も個人情報に含まれます。 IDaaSを使うと、自社のDBにはユーザーを識別するsubが残ります。sub単体には名前もメールアドレスも含まれていませんが、自社はIDaaSの管理画面やAPIを使えば、そのsubが誰なのかを調べられます。そのため、subとひも付いた利用履歴は、自社にとっては個人情報として扱われる可能性が高いと考えられます。 判断の細かい基準は、個人情報保護委員会の ガイドライン(通則編) にまとめられているようです。 外部に任せきれるもの パスワードやクレジットカードの情報は、専門のサービスに任せれば、自社で持たずに済みます。 パスワードとMFAの情報 Auth0やAmazon Cognito、Firebase AuthenticationなどのIDaaSを使うと、パスワードのハッシュやMFAの情報はIDaaS側が持ちます。OIDCで連携する場合、自社のDBにはIDトークンに含まれるsubを保存し、それをキーにして自社のデータとひも付けます。 外部に任せれば、パスワードのハッシュが漏れて使い回しの攻撃に使われる心配が減り、MFAやパスキーへの対応も自分たちで実装せずに済みます。 クレジットカードの情報 決済で扱う情報のうち、持ち方に法律や業界の決まりがあるのが、クレジットカードの情報です。日本では割賦販売法という法律で、カード加盟店にカード番号の適切な管理が義務づけられているようです。 日本クレジット協会の加盟店向けの説明 によると、加盟店がとる対策は、カード番号を自社で保存・処理・通過させない非保持化か、カードブランドが定めたセキュリティ基準であるPCI DSSへの準拠のどちらかです。PCI DSSは要件が多いので、多くの加盟店は決済サービスに任せて非保持化を選んでいると思います。 StripeやPAY.JPなどの決済サービスとのつなぎ方は、カード番号がどこを通るかで、大きく3つに分けられます。 リンク型: 決済サービスの画面に移ってカード番号を入力してもらう。自社の画面にはカード番号の入力欄がない JavaScript型: 自社の画面に決済サービスのJavaScriptを読み込み、カード番号をブラウザから決済サービスに直接送ってトークンに変える 通過型: カード番号をいったん自社サーバーで受け取り、サーバーから決済サービスに送る リンク型とJavaScript型では、カード番号は自社のサーバーを通りません。通過型は、カード番号を保存していなくても自社のサーバーを通るため、ガイドラインが定める非保持化の条件を満たせません。特別な理由がなければ、リンク型かJavaScript型を選ぶことになります。ただ、JavaScript型でも、自社の画面に読み込んだスクリプトが改ざんされると、入力した時点でカード番号を盗まれます。決済を任せていても、入力画面のスクリプトは自社で守る必要があります。 決済サービスから受け取れるカードの下4桁やブランドは、カード番号そのものではないので、表示用に持つ程度ならリスクは小さいと思います。一方で、セキュリティコードは、決済の承認後に保存してはいけないとPCI DSSで決められています。 コンビニ払いやキャリア決済、QRコード決済のようなほかの決済手段も、支払いの手続きは決済サービス側で完結するものが多く、自社が持つのは取引のIDくらいです。 アプリ内課金 スマホアプリでデジタルコンテンツを販売する場合は、App StoreやGoogle Playのアプリ内課金を使うことが多いです。支払いの手続きはAppleやGoogleが行うので、自社がカード番号に触れることはありません。自社のサーバーが受け取るのは、主に購入ごとのトランザクションIDや購入トークンです。受け取ったIDは自社のユーザーとひも付けて持つので、subと同じく自社に残る情報になります。 一部だけ任せられるもの メールアドレスや氏名は、IDaaSに持たせることはできても、自社のシステムで使う場面が出てきます。 メールアドレス IDaaSはメールアドレスをログインIDとして持ってくれますが、お知らせや通知のメールを送るときは、自社のシステムが宛先を知る必要があります。ログイン中のユーザーなら、IDトークンから取り出せます。一方で、バッチでまとめて送るときはIDaaSのAPIに問い合わせることになり、件数が多いと呼び出し回数の上限にかかることがあります。IDaaSのメール機能は、確認コードやパスワード再設定のような認証まわりのメール向けで、お知らせの配信には向いていません。 自社のDBにもメールアドレスを持つと、持つ場所が一つ増え、更新をどちらに反映するかも考える必要があります。自社のDBにはsubだけを持ち、必要なときにIDaaSから取得する形にできれば、持つ場所を一つにまとめられます。 氏名や住所 画面に名前を表示するだけなら、IDトークンから取り出せるので、自社のDBに保存しなくて済みます。氏名くらいならIDaaSの標準の属性として持てますが、住所や電話番号、プロフィールのような認証に関係しない情報までIDaaSに置くのは、一般的には推奨されていません。 たとえばAuth0の ドキュメント には、メタデータに置くのは認証に関係するデータだけにし、詳しいプロフィールは外部のシステムに置くように書かれています。Amazon Cognitoの ドキュメント にも、頻繁に変わるデータは属性ではなく別のデータストアに置くように書かれています。 IDaaSは認証のためのサービスで、アプリケーションのデータベースの代わりにはなりません。IDaaSに置くのはメールアドレスや氏名のようにログインやIDトークンで使う情報にとどめて、サービスの中で使うプロフィールは自社のDBで持つ、という分け方になります。表示名(ニックネーム)のように、重複を禁止したり不適切な言葉をはじいたりする処理が絡む情報も、自社のDBにあるほうが扱いやすいです。 管理画面からの検索 IDaaSに置くのをメールアドレスや氏名にとどめても、自社のDBにそれらを持たないなら、管理画面からユーザーを探すときはIDaaSのAPIを使うことになります。Amazon Cognitoの ListUsers では、検索に使えるのは一部の標準属性だけで、条件も完全一致か前方一致に限られます。名前の一部で探すような検索は、全ユーザーを取得して手元で絞り込むことになります。 ただ、1回で返るのは最大60件で、一覧系のAPIはアカウントのリージョンごとに毎秒30リクエストまでです。1秒あたり最大1,800件なので、1万人なら6秒ほど、100万人なら9分以上かかります。前方一致で足りて、ユーザーが数万人くらいまでなら、Cognitoの検索だけでも運用できるかもしれません。それを超える場合は、検索に使う属性だけを自社のDBに持つか、メインのDBから切り離した専用のDBに置くことになります。 個人情報を預かる専用のサービス IDaaSに置かない住所や電話番号は、個人情報を預かる専用のサービスに置く方法もあります。データプライバシーボルトと呼ばれるもので、Skyflowなどが提供しています。自社のDBには預けたときに受け取るトークンだけを持ち、表示するときにだけ元の値に戻す形で、決済サービスのトークンの考え方をカード以外の個人情報に広げたものです。IDaaSと違い、項目ごとに見られる人を絞ったり、一部を伏せて返したり、誰がいつ見たかを記録したりすることに向いていて、海外では金融や医療のように重い情報を扱う分野で使われているようです。ただ、表示のたびに元の値に戻す通信が必要になり、住所で検索するような処理も自社のDBだけではできなくなります。自社はトークンから元の値を取り出せるので、トークンとひも付いた利用履歴が個人情報のままである点は、subと変わりません。 自社に残るもの 外部サービスのIDや利用履歴のように、サービスそのものに関わるデータは、外部に出しようがありません。こうしたデータは、量を減らすことと、ほかのデータと切り離して持つことで守りやすくなります。 外部サービスのID IDaaSのsubや決済サービスの顧客IDは、自社のデータとひも付けるためのキーなので、必ず自社のDBに残ります。このIDそのものはなくせないので、外部サービスのIDと自社の内部IDの対応表を別に持ち、利用履歴などのテーブルには内部IDだけを持たせる方法があります。対応表を守れていれば、ほかのテーブルが漏れても、外部サービスの情報とひも付けにくくなります。 利用履歴 利用履歴を外部に出すことはできないので、持つ量を減らすことを考えます。カラムを追加する前にこのデータがないと何が困るかを考えたり、年齢確認だけが目的なら生年月日の代わりに20歳以上かどうかのフラグだけを持ったりするように、持たない、粒度を落とす、という選択肢があります。 あわせて、持つ期間を決めて自動で消すようにしておくと、古いデータが残り続けずに済みます。行ごと消すほかに、行は残して個人を特定できる列だけを空にする方法もあり、この方法なら売上の集計などに使う列は残せます。 任せたあとも残る責任 外部サービスに任せても、預けた情報についての責任がなくなるわけではありません。 委託先の監督 個人情報保護法では、個人情報の取り扱いを外部に委託した場合、委託元が委託先を監督する必要があると定められています。IDaaSや決済サービスにユーザーの情報を預けている場合は、この委託にあたることが多いようです。 とはいえ、外部のサービスに自社が立ち入って確認するのは現実的ではありません。サービスを選ぶときに、SOC 2やISO/IEC 27001のような第三者の認証を取っているか、データをどこに保管しているか、事故が起きたときにどう連絡がくるかを確認しておくのが、実際の対応になりそうです。たとえば Stripe や AWS は、取得している認証や監査の報告書を確認できるページを用意しています。 削除の依頼への対応 退会したユーザーから情報を消してほしいと言われたとき、IDaaSからアカウントを消すだけでは終わりません。自社のDBに残った利用履歴、ログ、分析基盤のデータ、決済サービス側の顧客情報も対象になります。 このとき、削除フラグを立てるだけの論理削除では、データそのものは残ります。消したと言うためには、物理削除をするか、少なくとも個人を特定できない形に加工しておく必要があります。 バックアップには、保持期間が過ぎるまで、削除したデータも含まれたままです。DBのデータを消す期限とバックアップの保持期間をあわせて決めておくと、消したデータがいつまでどこに残るのかを把握しやすくなります。 まとめ 今回は、外部サービスを使って、個人情報を自社のDBに持たない設計がどこまでできるのかを考えてみました。 外部に任せれば個人情報は減らせますが、まったく持たない設計にするのは難しそうです。それでも、任せられるものを任せて、残ったものの量と置き場所を絞るだけで、自社で守らなければならない範囲はかなり小さくできると思いました。 新しくシステムを作るときや、既存のシステムを見直すときの参考になれば幸いです。
はじめに こんにちは。株式会社エブリーで約1ヵ月間インターンシップに参加していた山下と申します。 今回のインターンでは、AIを活用して社内の業務変革を進める「AX(AI Transformation)プロジェクト」に参加しました。主に取り組んだのは、次の2つです。 営業活動(Event)の記録を効率化するための、Slack botのバックエンド設計・実装 案件(Opportunity)の記録を効率化するための、提案資料からの情報抽出の検証 この記事では、開発の背景や技術的な取り組みに加えて、初めての技術や営業業務をどのように理解し、社員の方と相談しながら進めたかについても紹介します。 プロジェクトの背景 AXプロジェクトでは、AIを活用して営業担当者の管理業務等の負担を減らし、顧客へのアプローチに使える時間を増やすことで、最終的には売上の増加に貢献することを目指しています。その第一歩として取り組んだのが、Salesforceへの営業活動・案件情報の記録を支援する仕組みです。 Salesforceは、顧客情報や営業活動、案件の進捗などを管理するプラットフォームです。営業担当者は、会議やメールでのやり取りを振り返り、その内容を画面から手作業で記録する必要がありました。活動の内容を思い出し、関連する顧客や案件を確認しながら入力する作業には大きな労力と心理的負担がかかります。 そこで、さまざまな場所に散在する情報を集約し、AIが作成した登録候補をSlack上で確認・承認することで、入力の負担を減らす仕組みづくりを目指しています。 システムの全体像と担当範囲 営業活動の情報は、Zoomでの会議やGmailなど、複数のソースに分かれています。既存の設計では、これらの情報をDatabricksに取り込み、ETLで統合・加工します。さらにAIの判断を加え、紐づけ先となる取引先や担当者、案件の候補を含むGoldテーブルを作成します。 Goldテーブルでは、営業活動1件が1行に対応します。この情報を営業担当者に提示し、内容を確認して承認してもらった後にSalesforceへ登録する、という流れです。 情報ソースからSalesforceへの登録までのシステム全体像 ETLからアプリケーションに渡すデータまでの設計・実装は、主にメンターの方が担当しました。私はその設計を理解した上で、登録候補をSlackに提示し、営業担当者の操作を受け取ってSalesforceに登録するバックエンドを担当しました。 また、営業活動とは別に、案件情報の記録を支援するため、Slackからの情報取得パイプラインの実装と、提案資料からの情報抽出の検証にも取り組みました。 設計・実装に取り組むまで 以前からETLには興味があったものの、実際に触れるのは今回が初めてでした。まずは、段階的にデータ品質を高める メダリオンアーキテクチャ の考え方や、Databricksでのパイプラインの記述方法を学ぶところから始めました。既存の実装やドキュメントを読みながら、どの段階で何の情報が作られ、アプリケーションがどのデータを使うのかを、手探りながら確認していきました。 同時に、営業業務の理解も必要でした。営業担当者が顧客への提案に使う資料やSalesforceの入力画面を直接触って確認したり、営業担当者とのミーティングに参加させていただいたりして、実際にどのような情報を扱っているのかを学びました。 最初は分からないことばかりでしたが、メンターの方は定例会だけでなく、都度の相談にも快く対応してくださいました。実装を読み、業務の情報を確認し、分からない点を相談することを繰り返す中で、少しずつプロジェクトの全体像と、自分の担当する部分を理解できるようになりました。 営業活動の記録を支援するSlack bot 登録候補の提示からSalesforceへの書き込みまで Slack botは、Goldテーブルと同期する Lakebase を参照します。アプリケーション側からはPostgreSQLとしてアクセスできるため、バックエンドは定期的にLakebaseをポーリングし、新たに提示する対象がないかを確認します。 投稿対象があれば、営業活動の内容と、関連する取引先や案件などの候補をSlackに表示します。営業担当者が候補を選択して承認すると、バックエンドがその操作を受け取り、Salesforce APIを通じて営業活動を登録します。 実装には、Slackアプリ開発用のフレームワークである Bolt for Python を利用しました。Slackでのボタン操作などはWebSocket接続を使うSocket Modeで受け取るようにしており、インターネットにポートを公開せずにSlackとやり取りをすることができます。 デプロイ先にはDatabricks Appsを選びました。ETLと同じプラットフォームを利用することで、素早くプロトタイプを作成できると判断したためです。一方で、今後の運用コストの観点から、AWS ECSなどへの移行も検討が必要だと考えています。 営業担当者が確認しやすいフォームを考える 実装で工夫したのは、Slackに表示するフォームです。営業担当者によっては、1日に10件ほどの投稿が届く場合もあります。AIがETLで提示する候補は最大3件に抑えられていますが、それでも候補が多いとメッセージの縦幅が大きくなります。入力の負担を減らすという目的を考えると、Slackでの承認の心理的ハードルを抑えることは重要であると考えました。 候補の表示方法として、ラジオボタンとセレクトメニューを比較しました。今回は、選択肢を一覧できることと、スマートフォンとPCで見え方が大きく変わらないことを重視し、ラジオボタンを採用しました。セレクトメニューはコンパクトに収まるものの、PCでは候補のテキストの省略が発生してしまうため、好ましくありませんでした。 その上で、表示する情報を減らす工夫も行いました。取引先企業が1社に決まっている場合は、取引先企業を1行で示し、案件だけを選んでもらいます。候補が1件しかない項目も、同じように1行で表示します。決まっている項目を選択肢にしないことで、フォームの行数を減らしました。 Slackに表示される営業活動の登録フォーム 検証環境でSlackでの承認からSalesforceの書き込みまで動作することを確認し、リリース直前のところまで実装することができました。 提案資料からの案件情報抽出の検証 何を抽出したいのか Salesforceには、営業活動に加えて案件(Opportunity)という情報を記録します。案件では、どの営業担当者が、どの顧客に、どの商材を提案し、現在どの受注フェーズにあるかなどを管理します。 案件情報を取得するソースとして、Slackの投稿やGoogle Slidesの提案資料が有用でした。そこで、Slackからの情報取得パイプラインの実装と並行して、提案資料から情報を抽出する方法を検証しました。提案資料には、どの顧客に、どの商材を、いくらで提案したかといった情報が含まれます。今回は、LLMを使って資料の内容を提案メニューの候補と照合し、該当するメニューをどの程度正確に抽出できるかを確認しました。 検証方法とコスト削減の仮説 評価のために、9つの提案資料と提案メニューの候補を自分で確認し、対応する商材を紐づけた正解データを作成しました。提案の初期段階では複数のプランが提示される場合もあるため、該当する候補を取りこぼさないこと(高い再現率)を重視しました。 資料には、提案内容に直接関係しない情報(市場調査データや広告戦略など)も含まれています。そのため、LLMに渡す前に不要な情報を取り除けば、入力トークンを減らしてコスト低減につながるのではないか、と考えました。そこで、高速/低コストで判断を下すことのできる、 Typesafe AI のJevによるフィルタリングを前処理に加える場合と、加えない場合を比較しました。また、LLMの性能の違い、thinkingの有無による結果の違いも確認しました。提案資料はGoogle Slides APIを介して取得したJSONをテキストに変換した上、LLMに入力しています。 Jevには価格情報の有無、メニュー情報の有無の観点からスライドを評価させ、閾値0.5でフィルタリングしています。実行対象のスライドには、正解データをつけた9つの資料に加え、コスト測定用に6件を加えた計15件としています。 検証結果 評価には再現率とF1スコアを使いました。再現率は、正解データの商材のうちLLMが抽出できた割合です。取りこぼしが少ないほど高くなります。適合率は、LLMが抽出した商材のうち正解だった割合です。F1スコアは再現率と適合率の調和平均で、取りこぼしと誤った抽出がどちらも少ないほど高くなります。どの指標も1に近いほどよい結果です。 モデル Jev 再現率 F1 コスト/件 時間/件 gpt-6-luna thinking なし なし 0.931 0.718 ¥0.18 6.1 秒 gpt-6-luna thinking なし あり 0.797 0.721 ¥0.24 11.1 秒 gpt-6-luna thinking あり なし 0.972 0.862 ¥0.30 25.0 秒 gpt-6-luna thinking あり あり 0.794 0.733 ¥0.34 27.2 秒 gpt-5-6-terra thinking あり なし 0.954 0.788 ¥5.09 14.6 秒 gpt-5-6-terra thinking あり あり 0.757 0.731 ¥3.08 17.1 秒 今回の検証では、軽量モデル( gpt-6-luna )ではJevによる処理コストが上乗せされ、コストはむしろ増加しました。より重いモデル( gpt-5-6-terra )では、入力トークンの削減効果が現れています。一方、今回試した軽量モデル gpt-6-luna でも、検証用データに対しては十分高いマッチング精度が得られました。コストの内訳を確認すると、thinkingを含む出力トークンのコストが支配的でした。thinkingを無効にした場合はマッチング精度が大きく低下しています。 また、Jevを利用した場合の再現率の低下が確認され、コンテクストが希薄になることで取りこぼしも生じています。 この結果から、今回の検証条件では、Jevによるフィルタリングを行わず、thinkingを有効にした軽量モデルを使う構成がよいと考えています。 今後の課題 検証と営業担当者への聞き取りを進める中で、顧客に見せる資料には、提案を目的としないものも混ざっていることが分かりました。今回試した「資料内の不要な情報を減らす」用途とは別に、「処理対象となる提案資料かどうかを判別する」用途でJevを活用できないか、今後検討の余地があると考えています。 また、今回作成した検証セットでは高い精度が得られたものの、商材の名称には多義性があり、期間限定のものも存在します。営業チームの中で共有されている暗黙知がマッチングに影響する可能性があり、精度をさらに高めるためには、それぞれのメニューの定義など、判断に必要な情報を整備することも必要だと感じました。 AXプロジェクト全体としては、完成した資料から情報を抽出するだけでなく、提案資料を作る前の段階から支援し、情報を記録できる仕組みを考えることも、今後の方向性だと考えています。 インターンを通じて感じたこと データ基盤からアプリケーションまでをつなぐ経験 今回のインターンでは、以前から興味のあったETLを含め、Databricksに触れることができました。複数のソースから統合したデータを、Slack botとして利用するところまでつなげられたのは、貴重な経験でした。 今回は既存設計を理解することで精一杯な部分もありましたが、今後はETL全体の設計にも挑戦できるよう、知識と実力を身につけていきたいです。 チームで設計を共有し、担当範囲を考える難しさ これまで、複数人で1つのプロジェクトに取り組み、並行して開発した経験がありませんでした。今回は、社員の方と設計を共有し、その内容を理解した上で必要な設計について相談することや、プロジェクトの目的と責任範囲から自分のやるべきことを整理することに難しさを感じました。 自分の実装がシステム全体の中でどの役割を持つのかを考え、認識を合わせながら進める経験ができたことは、今後の開発にもつながる学びになりました。 業務・ドメインの理解をもっと大切にしようと思った 営業業務やSalesforceの仕様を理解するには、想像以上に時間がかかりました。一方で、営業担当者が実際に使う資料や画面を確認することで、必要な情報や仕組みを少しずつ理解できるようになりました。私が担った業務の中でも、SlackのUIで提示すべき情報の判断や提案資料からの情報抽出の検証において、業務理解の必要性を強く感じました。 エブリーではビジネスサイドとの距離の近さを感じる機会があり、誰のどのような業務を支援するのかを具体的に捉えながら取り組めたことが、自分のモチベーションにもつながりました。 振り返ると、もっと早い段階で営業資料や実際の入力作業といった一次情報に当たる必要性に気づければよかったと思います。これからの開発に当たっても技術の理解だけでなく、できる限り一次情報に触れ「何がなぜ必要なのか」を理解・判断できるように心がけられたらと考えています。 おわりに 今回のインターンでは、社内の業務変革に取り組むプロジェクトに参加し、Slack botの開発と案件情報抽出の検証に取り組みました。初めての技術や業務に触れながら、社員の方と相談して進める経験ができました。 また、業務外でもさまざまな社員の方とお話しする機会を用意していただき、エブリーのことだけでなく、今後のキャリアについて考えるきっかけもいただきました。 AXプロジェクトの皆様はじめ、様々な形で関わってくださった皆様、1ヵ月という短い間でしたが、本当にありがとうございました。
はじめに こんにちは、開発本部の福岡( @say_go0 )です。26卒としてエブリーに入社し、今はデリッシュキッチンのWeb側の開発に携わっています。 入社して半年が経ちました。この半年、私は全てのコードをAIに書いてもらいながら、いくつかの機能をリリースしてきました。その一方で、「自分は一人前のエンジニアになれるのだろうか」という悩みを、ずっと抱えていました。 この悩みを分解していくと、最後は「AIとどう向き合うか」という問いに行き着きました。 この記事では、文系・未経験から、AIが当たり前の環境でエンジニアを始めた私が、この半年で何に悩み、何を考え、どうアクションしたかを振り返ります。自分の中で答えが出きっているわけではありませんが、リアルな現在地としてここに記してみます。 はじめに 手でコードを書いたことがない AIを使ってリリースはできたが、積み上がっている感じがしなかった どこが分からないかも分からない 初めての大きな施策 このまま1年過ごして、1年後に一人前のエンジニアになれるのだろうか? 初めてのコードレビューや1on1を通して、理解を深める大切さに気づいた 初めてのコードレビューでの挫折 気づかなかったエンジニアという職種の特性 技術書を読み始め、コードもちゃんと読んでみることにした 一人前のエンジニアとは何か?が見えてきた気がした 理解を手放さないためのハーネスを再設計した おわりに 手でコードを書いたことがない 私は大学でマーケティングを学んでいた、いわゆる文系です。情報系の授業を少しかじった程度で、開発の経験はありません。学生時代はマーケティングのインターンをするなど、エンジニアとは無縁の生活を送っていました。 エブリーにはプロダクトマネージャー(PdM)として採用されました。ですが、将来的に技術への深い理解を持ち、本質的なプロダクト価値を生み出せるPdMになるためには、技術の土台が必要だという考えから、エンジニアとしてキャリアをスタートすることになりました。 入社したときには、設計からコーディング、そしてレビューに至るまでAIを使って進めるのが当たり前になっていました。正直に言うと、私はこの会社で手でコードを書いたことが一度もありません。 また、当時の私は、 Goとは? HTTPとは? APIとは? S3って何? そもそもエンジニアって毎日何をされているの?エンジニアの1日の流れを教えてください! というレベルでした。 AIを使ってリリースはできたが、積み上がっている感じがしなかった どこが分からないかも分からない 新卒研修を経て、5月にデリッシュキッチンアプリの買い物を便利にする機能を開発するチームへ、バックエンドエンジニアとして配属されました。何も分からないながらに、先輩からいただいた小さなタスクを、Claude Codeに頼りながら進めていました。 本当に分からないことだらけで、どこが分からないかも分からず、先輩に質問することも躊躇してしまっていました。 初めての大きな施策 5月から6月にかけて、デリッシュキッチンアプリ内で表示しているチラシを、食材ごとに切り抜いて見せる機能を担当しました。簡単に言うと、バッチ処理でチラシをOCR処理して座標を取得し、切り抜いてS3に保存する、といったバックエンドの処理を作る必要がありました。 その際、先輩から「どういう技術でチラシを切り抜くのが良いと思うか、考えて提案してみてください」と、技術選定を任せてもらいました。Lambdaで切り抜くか、APIサーバー側で切り抜くか、バッチで事前に切り抜いておくか、といった選定です。当時の私はいくら調べても技術について理解しきれず、Claude Codeをひたすら頼りにして、拙い提案をまとめました。 その提案をたたき台にチームで話し合って方式が決まり、実装に入りました。ただ、Goの文法も、使っている技術もよく分からないまま、AIを駆使してプルリクエストを作りました。レビューでいただいた指摘も、根本を理解しないまま直していたと思います。先輩方がレビューしてくださるという安心感に、頼っていた面もありました。 それでも、リリースされて実際に動いたときは本当に嬉しかったです。自分が実装を担当した機能が世に出ていると思うと感慨深く、エンジニアって楽しいなと感じたのを覚えています。 このまま1年過ごして、1年後に一人前のエンジニアになれるのだろうか? その後も、指定した食材が載ったレシートをアップロードするとポイントがもらえる新機能を設計から担当しました。PdMからもらった要件を、自分がどういった技術で、どのようなコードを書いて実現しているのか、AIに聞きながらその場その場で理解しようとはしていました。 ただ、これだけ大きな施策を担当したわりに、自分の中に残っているものが少ないと感じていました。「このまま1年過ごして、1年後に一人前のエンジニアになれるのだろうか?」という危機感が、だんだん強くなっていきました。 7月にはWebのチームに異動し、Goに加えてJavaScript(Nuxt、Vue)も触ることになりました。分からないものが増えて、余計にわけが分からなくなりました。AIを使えば、早くアウトプットを出せます。一方で、理解とのバランスも取らないといけない。そう頭では分かっていても、早く成果を出したいという焦りもあり、いろいろな先輩に相談しても、自分なりの答えが見つけられない状態が続きました。 同じころ、SNSで「AIが生成したコードをもう読まなくなった」という投稿を見かけるようになりました。答えが出ないまま、私はその言説に流され、理解をおろそかにする方向に進んでしまいました。人が読むコードの量を減らすことを目指して、個人のハーネス構築に力を入れた時期もあります。その結果、アウトプットが出るまでの速度は上がったかもしれませんが、分からないまま進めた部分は、自分の中に理解負債として溜まっていく一方でした。 初めてのコードレビューや1on1を通して、理解を深める大切さに気づいた そんな中、考え方が変わるきっかけが2つありました。 初めてのコードレビューでの挫折 1つ目は、異動してしばらくして、先輩に勧められて初めてコードレビューをしたときのことです。 文法の知識も、アーキテクチャの理解も、リポジトリの慣習の把握も追いついていなかった私は、AIに頼り切りでした。結局、AIが出した指摘をほぼそのままコメントするだけになってしまいました。 そのとき、極端な話、「これはAIだけでいいのではないか」と自分で思ってしまいました。AIの指摘をなぞったり、AIが示したメリットとデメリットをよく理解しないまま選んだりしているだけなら、自分がエンジニアとして間に入る価値は本当にあるのだろうか?このやり方を続けて、いつかこの状態から抜け出せるのだろうか? そう率直に悩みました。 AIには出せない価値を発揮するためには、自分でコードを読み、全体のアーキテクチャを見通したうえで、どのコードがよいかを判断できる力が要ると感じました。 気づかなかったエンジニアという職種の特性 もう1つは、社内の制度で月に1回させていただいていた、長くエンジニアをされてきたプロダクトマネージャーの上司との1on1です。 悩みを率直に相談したところ、「エンジニアはインプットが必要な職種で、最初の積み上げがどうしても必要」という旨の言葉をいただきました。エンジニア経験がなかった私は、エンジニアという職種のそういう特性にすら気づけていませんでした。 技術書を読み始め、コードもちゃんと読んでみることにした そこから、基礎的な技術書を読むことに力を入れました。コンピュータサイエンスの基礎、GoやJavaScriptの文法、インフラやサーバー、APIの本などです。 すると、業務で断片的に触れていた知識が、線としてつながり始めました。以前の自分と比べると、Goのコードが読めるようになり、CI/CDの仕組みや、APIのリクエストとレスポンスの流れも分かるようになってきました。「Goとは?HTTPとは?」から始まった自分にとっては、大きな変化で、とても嬉しかったことを覚えています。 さらに、ある担当施策で試しに、AIは分からないときに聞く程度にとどめ、ソースコードを全部理解できるまで自分で追う時間を取ってみることにしました。1つの施策にかかる時間は増えましたが、「このPRの範囲のコードは理解した!」と自信を持ってレビュー依頼を出すことができました。 その結果、担当した施策についてPdMから仕様の確認や追加開発の相談を受けたときに、コードを追っていたおかげで全体像や挙動を把握できていて、「これはすぐにできます」と即答できたことがありました。AIを介さずに自分の言葉で返答できたことは、今までAIに頼り切りだった私にとって、とても大きな成功体験でした。 それまでは、事業スピードを優先したいという焦りから、深く理解しきれないままプルリクエストを出してしまうこともありました。今振り返ると、自分が思っていた何倍も理解に時間を割くべきだったのだと、半年かけてようやく気づきました。 一人前のエンジニアとは何か?が見えてきた気がした 入社してからずっと、「自分は一人前のエンジニアになれるのだろうか」と悩んでいました。ただ、基礎が少し分かるようになって、以前は単に「すごい」としか思えなかった先輩たちが、どういう視点で物事を見ているのかが、少しずつ見えるようになってきた気がしました。そこから、自分が目指すべき一人前のエンジニアとは、そもそもどういうエンジニアなのかを考えるようになりました。 私なりに見えてきたのは、一人前のエンジニアとは、エンジニアとしての「感覚」を持っていることなのではないか、という仮説でした。 コードはどちらでも動くけれど、それでもこちらの方が良いと判断できる感覚 障害が起きたときに、おそらくここに問題があると瞬時に仮説をはじき出せる感覚 ミーティングで依頼を受けたときに、今こういう実装だから、こうすれば実現できそうだとすぐに分かる感覚 新しい施策で、設計や実装のイメージの仮説を立てられる感覚 どれも、今の実装や、その場の事情を自分の中で理解しているからこそ、すぐに判断や仮説が出てくる場面だと思います。AIに聞けば一般的な答えは返ってきますが、この状況ではどうするのがよいかを最終的に決めるのは人間です。そういった感覚を持っているエンジニアこそ、私が目指すべき一人前のエンジニアなのではないかと思います。 そうした感覚は、知識の土台と、理解をおろそかにしない習慣があって初めて自分の中に積み上がっていくものだと、この半年の経験から感じています。AIがコードを書いてくれる今だからこそ、この感覚がAIに代替されにくいエンジニアの価値として残っていくのではないか。まだまだひよっこですが、今の私はそういった仮説を持っています。 理解を手放さないためのハーネスを再設計した とはいえ、AIに任せれば楽ができてしまうのは今も変わりません。大事なのは、どこまでは自分で理解し、どこからはAIに任せるのか、という線引きを自分の中で持つことだと考えています。特に設計の仮説を立てる段階は、AIに頼らず自分で考える必要があると感じています。ここをAIに任せてしまうと、全体の構造が見えないまま進むことになり、理解負債が溜まっていくからです。 ただ、この線引きを意思だけで守り続けるのは難しいです。そこで、一度は速さのために整えたハーネスを、自分が理解しないと先に進めない形に作り直し、線引きそのものを仕組みにしました。 たとえば、AIにいきなり設計やコーディングをさせるのではなく、施策の大枠の設計を自分が先に仮説として書くようにしています。どのリポジトリのどの層の、どのファイルをだいたいどう触るか、どこが設計の論点になりそうかを、分からなくてもまず書いてから実装に進む流れにしています。 以前は、AIがコードを出してくれても、「そもそも何を実装すればいいんだっけ?」「なんでこのファイルを触っているんだっけ?」となることがありました。先に仮説を書くようになってからは、これから何を実装するのかをつかんだうえで進められるので、コードを読むときも「要件を満たせているか」という基準を自分の中に持って見られるようになりました。 仮説を持って入ると、実装そのものが仮説の検証になります。自分の仮説が合っていたのか間違っていたのかが分かるので、施策ごとに、その結果が自分の学びとして積み上がっていく感覚があります。 AIは自分を教育してくれるわけではありません。だからこそ、自分が理解すべきところをハーネスそのものに組み込んでおくことが、特に私のような未経験からエンジニアになった人にとっては大事なのではないかと感じています。 おわりに この半年を振り返ると、「自分は一人前のエンジニアになれるのだろうか」という悩みの正体は、エンジニアとしての感覚がまだ自分にないことでした。そして、その感覚を磨くのを邪魔していたのが、AIに任せきりにしたことで溜まった理解負債でした。だからこそ、自分の中で一番大きなテーマは「AIとどう向き合うか」でした。AIに任せれば楽はできます。楽をしたくなるところをどう自制して、自分が理解すべき範囲を把握し、AIの使い方に組み込んでいくか。今の私は、そういうスタンスに落ち着いています。 まだまだ分からないことだらけで大変な毎日ですが、最近改めて感じているのは、自分で作ったものが世に出ることへの大きな喜びです。エンジニアという仕事を、今とても楽しめています。 残りの半年で一人前のエンジニアに近づけるよう、引き続き精進していきます。 最後までお読みいただきありがとうございました。
はじめに こんにちは、株式会社エブリーのデリッシュキッチン開発部にて、9 月から約 1 か月間エンジニアとしてインターンシップに参加していました、渡邉 蒼太 @asuto です。 今回のインターンで私は、社内の Web アプリ「ストアビジョンダッシュボード」のデータを Claude から読めるようにする、社内用のリモート MCP 1 サーバーの開発に取り組みました。 認証の設定、ダッシュボードのバックエンドサーバーの改修、MCP サーバーの実装、AWS の実行環境とデプロイの仕組みの構築までを行い、dev 環境で動かせるようにしました。 ストアビジョンの営業担当者(広告主に広告枠を販売する担当者)は、提案の準備や施策の入力のたびに、ダッシュボードのデータを Web アプリから手作業で読み書きしています。 これらの作業は LLM に任せやすいものですが、Claude はダッシュボードのデータを読めないため、どの範囲のデータが必要かを人が見極め、抜き出して渡すしかありません。 必要な範囲は作業の途中で変わるので、これでは作業の負担の大半が人に残ります。 今回の MCP サーバーは、Claude が必要なデータを自分でダッシュボードから取りに行けるようにするものです。 この記事では、その設計、MCP サーバーが利用者の権限でバックエンドサーバーを呼ぶための認証の設計、それぞれの実装、そして Claude Code を使って開発を進めるうえで工夫したことを紹介します。 背景 エブリーは、スーパーなどの小売店舗の売場に設置したサイネージで、レシピ動画や広告を放映する「デリッシュキッチン ストアビジョン」(以下、ストアビジョン)というサービスを提供しています。 広告主(主にメーカー)から受注した広告は、どの本部(小売チェーン)のどの店舗のサイネージで、どの動画を、いつからいつまで放映するかを決めて配信します。 ストアビジョンダッシュボードは、この広告の受注から配信までを管理する社内の管理画面です。 施策ごとに、メーカーと商材、配信先の本部と店舗、配信期間、申込台数、申込金額、請求先などを登録し、提案状況や配信設定の進み具合を管理します。 課題 ダッシュボードを読み書きする手作業 営業担当者は、ダッシュボードのデータを手作業で読み書きしています。 提案の準備では、広告主から受け取る配荷リスト(商品を扱う店舗の一覧。本部ごとに形式が異なる)と、サイネージを設置している店舗の一覧を突合し、本部別・売場別の設置台数を数えます。 その台数をもとに、金額、再生回数、想定インプレッションを計算して、シミュレーションを作ります。 この突合は、提案資料を作る日程の中で、独立した 1 つの工程として扱われるほど手間のかかる作業です。 受注した施策を入力するときは、商談のメモや過去の施策から情報を集め、ダッシュボードの項目に合わせて入力します。 配信先の本部や店舗は、商談での呼び方のままでは入力できず、ダッシュボードの選択肢から探して選ばなければなりません。 渡すデータの範囲を人が決めなければならないこと これらの作業は、絞り込み・集計・計算の繰り返しや、過去の施策から推測できる項目の入力が中心です。 人が判断するのは突合の方針くらいなので、LLM に任せやすい作業です。 しかし、Claude はダッシュボードのデータを読めないので、人が必要なデータを抜き出して渡すしかありません。 必要な範囲は作業の途中で変わるため、範囲が変わるたびに人がデータを探し直すことになり、作業の負担の大半は人に残ったままです。 提案手法 そこで、Claude がダッシュボードのデータを自分で取りに行けるように、社内用のリモート MCP サーバーを作りました。 MCP サーバーは、データの取得を「ツール」として公開します。 Claude は、利用者の質問に応じて、どのツールをどの引数で呼ぶかを自分で決めます。 どの範囲のデータが必要かも Claude が判断し、範囲が途中で変われば、ツールを呼び直して取りに行きます。 人が範囲を前もって決めて、データを抜き出して渡す必要はなくなります。 営業担当者が自分の PC に環境を作らなくても、普段使っている Claude から使えるように、MCP サーバーは社内の共通のゲートウェイの配下に置きました。 MCP サーバーには次のことを求めました。 読めるデータの範囲を、ダッシュボードでの利用者の権限と一致させる 誰が、いつ、何を読んだかをログに残す(読んだデータそのものは残さない) 利用者を特定できない呼び出しは拒否する 明示的に許可した操作だけを提供する 結果を件数で切り詰めるときは、そのことを明示する インターンの期間は約 1 か月なので、MCP サーバーの基盤と、本部名・店舗名の突合を補助する読み取りのツール 2 つまでを作りました。 この基盤があれば、今後ツールを足していくことで Claude にできることを増やせます。 今回の実装で解決すること 課題の節で挙げた作業のうち、今回の実装で解決するのは、施策の入力で配信先の本部・店舗を特定する作業です。 これまで営業担当者は、商談での呼び方をもとに、ダッシュボードの選択肢から該当する本部・店舗を探して選んでいました。 今回の MCP サーバーを使うと、営業担当者は商談での呼び方を Claude に伝えるだけで済みます。 Claude が本部と店舗の一覧を自分で取得して突合し、DB 上の名前と ID で答えます。 本部を特定したあとで店舗の一覧が必要になっても、Claude がツールを呼んで取得するので、営業担当者がデータを探して渡す必要はありません。 候補を絞り切れないときは、Claude は推測で決めずに営業担当者に確認します。 実装した MCP サーバーと Claude Code からの使用例 今回実装した MCP サーバーには、次の 2 つのツールがあります。 list_chains : 広告を配信できる本部の一覧を返す list_chain_shops : 本部を 1 つ受け取り、その本部の店舗の一覧を返す dev 環境にデプロイした MCP サーバーを、営業担当者の使い方に近い形で Claude Code から使った例が次の画像です。 画像に写っている本部と店舗は、dev 環境にある動作確認用のデータです。 1 つ目の質問では、Claude が商談メモでの呼び方から本部と店舗を特定しています。 2 つ目の質問では、もう一方の本部の店舗の一覧を新たに取得したうえで、候補を絞り切れないため、推測で決めずに利用者へ確認を求めています。 設計 全体の構成 エブリーには、社員が Claude から社内の MCP サーバーを使うための共通のゲートウェイ(以下、MCP ゲートウェイ)がすでにあります。 MCP ゲートウェイは、社員が Google アカウントでログインして得たトークンを検証し、利用者のメールアドレスをヘッダに付けて、配下の MCP サーバーへリクエストを転送します。 今回実装した MCP サーバーはこの MCP ゲートウェイの配下に置き、ストアビジョンダッシュボードのバックエンドサーバー(DB に接続し、画面に API を提供するサーバー)を呼んでデータを取得します。 MCP サーバーから DB を直接読まず、画面と同じくバックエンドサーバーの API を経由することにしました。 こうすると、MCP サーバーに求めたことのうち、権限の一致とアクセスの記録を、画面から使ったときと同じ仕組みで満たせます。 LLM による本部名・店舗名の突合 営業担当者が日常的に使う本部名・店舗名は略称や通称のことが多く、DB 上の名前と一致しないことがあります。 さらに、DB 上の名前そのものにも表記ゆれがあります。 dev 環境で調べたところ、次のようなパターンがありました。 法人格の有無と書き方がそろっていない(「株式会社」「(株)」「(株)」) 全角と半角、前後の空白、語の区切り方(空白・中黒・なし)がそろっていない 英字表記とカタカナ表記が混在し、長音記号の代わりの全角ハイフンや英字のつづりの誤りもある 同じチェーンが名前の少し違う複数の本部として登録されていたり、店舗名がそのまま本部名として登録されていたりする 同じ本部の中で、業態名が付く店舗名と付かない店舗名が混在し、同名の店舗には地域名を括弧で補うものと補わないものがある 店舗名の読み(カナ)が空のものや、店舗名と食い違うものがある そのため、呼び方と DB 上の名前を 1 対 1 の対応表で結びつけるだけでは突合できません。 そこで、MCP サーバーには表記ゆれを吸収する処理を書かず、本部や店舗の一覧をそのまま返して、該当する本部・店舗の選択を LLM に任せることにしました。 表記ゆれのパターンをサーバー側で網羅するのは難しい一方、一覧との突合は、ルールを書かなくても LLM で行えるからです。 ツールの説明文には、呼び方と DB 上の名前が一致しないことがあることと、候補が絞れないときは推測で決めずに利用者に確かめることを書きました。 読み取りの突合だけであれば、Elasticsearch のような全文検索エンジンを導入し、表記ゆれに強い検索を作る方法もあります。 ただ、今後やりたいのは、施策の入力そのもの、つまりダッシュボードへの書き込みを補助することです。 そのため今回は、検索の仕組みを別に作るのではなく、バックエンドサーバーの API を呼ぶ MCP サーバーとして実装しました。 MCP サーバーが利用者の権限でバックエンドサーバーを呼ぶための認証 構成を決めたあと、一番時間をかけたのが認証の設計です。 バックエンドサーバーの API は、人が画面からログインして使うことだけを想定して作られていました。 画面からのログインでは、バックエンドサーバーが Cognito 2 でパスワードを確かめたうえで、自前のトークンを発行しています。 MCP サーバーから呼ぶには、バックエンドサーバーが次の 2 つを確かめられる必要があります。 呼び出し元が、許可された MCP サーバーであること MCP サーバーが、どの利用者の権限で呼んでいるか 最初に考えたのは、画面向けと同じ方式のトークンを MCP サーバーが利用者ごとに発行する案でした。 この案なら、バックエンドサーバーは今の検証のままで MCP サーバーからの呼び出しを受け付けられます。 しかし、MCP 経由の呼び出しと画面からの呼び出しを区別できないので、MCP から呼べる API を絞ることも、ログで見分けることもできません。 また、トークンに署名する鍵を 2 つのサービスで共有することになり、鍵を更新するときは両方を同時に入れ替える必要があります。 そこで、1 をアクセストークン、2 をヘッダという別々の情報で伝える案を採用しました。 1 については、OAuth 2.0 の client credentials フロー 3 を使います。 ストアビジョンの Cognito に MCP サーバー用のクライアントを登録し、MCP サーバーはその資格情報で、MCP サーバー自身を表すアクセストークン(以下、サービストークン)を受け取ります。 この方式では、MCP サーバーが利用者のパスワードを持つ必要はありません。 2 については、 X-Acting-User というヘッダを新しく定義し、MCP サーバーがどの利用者の権限で呼んでいるかを、利用者のメールアドレスで伝えます。 ストアビジョンのアカウントは社員のメールアドレスで登録されているので、MCP ゲートウェイが付けたメールアドレスで、そのままストアビジョンの利用者を特定できます。 バックエンドサーバーは、サービストークンが正しいことを確かめたうえで、このヘッダの利用者に対して、画面からログインしたときと同じ権限のチェックを行います。 ただし、この案では、サービストークンを得た者は X-Acting-User に任意の利用者のメールアドレスを入れて、その利用者として API を呼べます。 そこで、サービストークンを得られる範囲と、サービストークンでできることを次のように絞りました。 クライアントシークレットは Secrets Manager に置き、MCP サーバーには ECS のタスク定義で渡す サービストークンで呼べる API は、本部と店舗の一覧を返す読み取りの API だけにする アクセスログに、どのクライアントから、どの利用者として呼ばれたかを残す MCP サーバーが、MCP ゲートウェイから受け取った利用者のメールアドレスを信用できる理由は、実装の節で説明します。 実装 Cognito の設定 Cognito でアクセストークンを発行するには、トークンで呼べる API を「リソースサーバー」として登録し、その中に「スコープ」(そのトークンに許す操作の範囲)を定義します。 今回は、バックエンドサーバーをリソースサーバーとして登録し、利用者の権限で API を呼ぶことを表すスコープ act-as-user を 1 つだけ定義しました。 MCP サーバー用のクライアントは、許可するフローを client credentials だけにし、要求できるスコープを act-as-user だけに絞りました。 トークンの有効期間は 60 分です。 client credentials フローでトークンを受け取るにはトークンエンドポイント 4 が必要なので、ユーザープールにドメインも追加しています。 # どの AWS アカウントのプロバイダを使うかの指定(provider)は省略 resource "aws_cognito_resource_server" "storevision_v2" { identifier = "storevision-v2" name = "storevision-v2" user_pool_id = local.sv_user_pool_id scope { scope_name = "act-as-user" scope_description = "利用者の代理として storevision/v2 API を呼ぶ" } } resource "aws_cognito_user_pool_client" "mcp" { name = "storevision-mcp-remote" user_pool_id = local.sv_user_pool_id generate_secret = true allowed_oauth_flows_user_pool_client = true allowed_oauth_flows = [ "client_credentials" ] allowed_oauth_scopes = aws_cognito_resource_server.storevision_v2.scope_identifiers supported_identity_providers = [ "COGNITO" ] access_token_validity = var.access_token_validity_minutes token_validity_units { access_token = "minutes" } } Cognito と、クライアントシークレットを置く Secrets Manager は別々の AWS アカウントにあるので、取り違えを防ぐため既定のプロバイダは置かず、リソースごとに指定しています。 バックエンドサーバーのサービス用の認証 バックエンドサーバー側では、MCP サーバーからの呼び出しを次の形で受けるようにしました。 Authorization: Bearer <サービストークン> X-Acting-User: <利用者のメールアドレス> 実装では、次の 4 点を意識しました。 画面からのログインの認証を変えない。 サービストークンを検証するミドルウェアを既存の検証の手前に置き、トークンの発行者( iss 5 )がストアビジョン用の Cognito のときだけ、このミドルウェアで検証します。 署名方式は RS256 に固定し、Cognito が公開している公開鍵の一覧(JWKS)で検証します。 それ以外のトークンは、これまでどおり既存の検証に渡します。 サービスから呼べる API を許可リストで絞る。 許可リストに載せたのは本部と店舗の一覧を返す API だけで、ユーザー管理のように MCP から呼ぶ必要のない API は、サービストークンで呼ばれると 403 を返します。 X-Acting-User の利用者を既存のユーザーとして確認する。 存在しない利用者なら 401 を返し、存在すれば、画面からログインしたときと同じ権限のチェックが行われます。 アクセスログに呼び出し元を残す。 利用者のメールアドレスに加えて、それをトークンとヘッダのどちらから得たのか、どのクライアントから呼ばれたのかを記録し、MCP 経由の操作と画面からの操作を後から区別できるようにしました。 ミドルウェアの中心部分は次のとおりです。 // ServiceAuth は、iss がストアビジョン用の Cognito のトークンだけを引き受け、それ以外は既存の検証(human)に渡す func ServiceAuth(svc ServiceConfig, routes *ServiceRoutes, human echo.MiddlewareFunc) echo.MiddlewareFunc { return func (next echo.HandlerFunc) echo.HandlerFunc { toHuman := human(next) // 実際のコードでは、利用者をトークンから得たことをログ用に記録してから next を呼ぶ toService := func (c echo.Context) error { if !routes.allows(c) { return echo.NewHTTPError(http.StatusForbidden, "this API is not allowed for service tokens" ) } return next(c) } return func (c echo.Context) error { raw, ok := bearerToken(c.Request()) if !ok || !issuedBy(raw, svc.Issuer) { return toHuman(c) } // RS256 に固定して JWKS で署名を検証し、期限・client_id・scope と X-Acting-User を確かめる return authenticateService(c, toService, raw, svc) } } } ミドルウェアと既存の処理を合わせると、リクエストは次の順で判定されます。 バックエンドサーバーの店舗一覧 API 本部の一覧を返す API はすでにあったので、本部 ID を受け取って店舗の ID・名前・読みを返す API を追加しました。 権限は、既存の本部一覧の API と同じく本部の読み取り権限で確認します。 MCP サーバー MCP サーバーは Go で書き、MCP の公式の Go SDK 6 を使いました。 MCP ゲートウェイとの間は、MCP の Streamable HTTP 7 で通信します。 MCP ゲートウェイからのリクエストは MCP サーバーの複数のタスクに振り分けられることがあるので、MCP のセッションは持たない(ステートレスな)設定にしています。 ツールの定義 ツールは、入力と出力の型を Go の構造体で定義しました。 Go SDK は構造体から JSON Schema を生成するので、各フィールドの説明(「DB 上の店舗名」「店舗名の読み(カタカナ)」など)が、そのまま LLM に渡るツールの説明になります。 // ListChainShopsInput は list_chain_shops の入力 type ListChainShopsInput struct { ChainID string `json:"chain_id" jsonschema:"本部 ID(list_chains が返す id)"` } // ShopItem は list_chain_shops が返す店舗 1 件 type ShopItem struct { ID string `json:"id" jsonschema:"店舗 ID"` Name string `json:"name" jsonschema:"DB 上の店舗名"` Kana string `json:"kana" jsonschema:"店舗名の読み(カタカナ)"` } // ListChainShopsOutput は list_chain_shops の出力 type ListChainShopsOutput struct { ChainID string `json:"chain_id" jsonschema:"本部 ID"` ChainName string `json:"chain_name" jsonschema:"DB 上の本部名"` Shops []ShopItem `json:"shops" jsonschema:"本部に紐づく店舗の一覧"` } // ツールの名前・説明・ハンドラを登録する mcp.AddTool(server, tools.ListChainShopsTool, tools.ListChainShops(client)) 利用者の特定 MCP サーバーは、MCP ゲートウェイが付けたヘッダのメールアドレスを利用者とし、バックエンドサーバーへのリクエストの X-Acting-User に入れます。 ヘッダがないときは呼び出しを拒否し、共通のアカウントで代わりに呼ぶようなことはしません。 このヘッダを信用できるのは、2 つの理由からです。 1 つは、MCP ゲートウェイが、利用者が自分で送ってきた同名のヘッダを削除してから付け直すことです。 もう 1 つは、MCP サーバーのセキュリティグループが MCP ゲートウェイのタスクからの通信だけを受け付けるので、MCP ゲートウェイを通らずに MCP サーバーへ直接リクエストを送れないことです。 サービストークンの取得と保持 サービストークンは呼び出しのたびに取得せず、プロセスの中で期限まで使い回します。 期限の 60 秒前からは取得し直すことで、期限切れ間際のトークンをバックエンドサーバーに送らないようにしています。 ロックを持ったまま取得するので、同時に複数のツールが呼ばれても、Cognito へのリクエストは 1 回で済みます。 再試行の条件 バックエンドサーバーの呼び出しが失敗したときの再試行は、通信の失敗と 502・503・504 に限りました。 これらは、デプロイ中などでバックエンドサーバーに一時的に接続できないときに起きる失敗であり、時間をおけば成功する見込みがあります。 401 や 403 のように何度送っても結果が変わらないエラーは、すぐに MCP クライアントへ返します。 ツール呼び出しのログ ツールが 1 回呼ばれるごとに、ツール名、利用者、結果、かかった時間を 1 行のログに出します。 引数と応答の中身はログに出しません。 ログには、ALB がリクエストに付けるトレース ID( X-Amzn-Trace-Id )も含めているので、MCP ゲートウェイのアクセスログと照合して、1 つのリクエストを追えるようにしました。 実行環境とデプロイ MCP サーバーの実行環境は、社内のほかの MCP サーバーと同じ構成にそろえ、Terraform で構築しました。 MCP ゲートウェイと同じ VPC のプライベートサブネットと ECS クラスタに、Fargate 8 のサービスとして置く MCP ゲートウェイからは、Cloud Map 9 のサービス検出で付けた名前で呼ぶ セキュリティグループは、MCP ゲートウェイのタスクからの通信だけを受け付ける クライアントシークレットは Secrets Manager に置き、ECS のタスク定義 10 で環境変数として注入する コンテナのログは CloudWatch Logs に出す コンテナイメージは、ビルド用と実行用の 2 段階の Dockerfile でビルドします。 ベースイメージはダイジェスト 11 で固定し、コンテナは root 以外のユーザーで動かしています。 デプロイは GitHub Actions で行います。 main ブランチに push すると、 go build ・ go vet ・ go test -race 12 を実行し、通ればイメージをビルドして ECR に push し、ecspresso 13 で ECS のタスク定義を差し替えます。 AWS への認証には、長期のアクセスキーではなく OIDC 14 で一時的にロールを引き受ける方式を使っています。 イメージのタグには GitHub Actions の実行 ID を付けているので、問題があれば前のタグに戻せます。 最後に、MCP ゲートウェイの設定ファイルに、 /storevision/mcp へのリクエストを MCP サーバーへ転送する設定を 1 件追加しました。 Claude Code を使った開発の進め方 今回の開発では、Design Doc(DD) 15 の作成から実装、デプロイの設定まで、ほとんどの作業に Claude Code を使いました。 作業用のディレクトリには、関係する複数のリポジトリと、作業記録だけを置く docs/ リポジトリを並べています。 Claude Code はこのディレクトリで起動するので、ここに置いた CLAUDE.md が毎回読み込まれます。 CLAUDE.md に書いたルール CLAUDE.md はセッションのたびに読み込まれるので、作業の手順よりも、守らないと取り返しがつかなくなるルールを優先して書きました。 git push 、PR の作成、デプロイ、DB や API への書き込みなど、外部に影響する操作は、実行する前に人間の承認を得る 本番環境には、読み取りであってもアクセスしない 作業記録とタスク表 docs/ には、セッションが終わっても残しておきたいものを置きました。 主なものは、作業記録とタスク表です。 作業記録については、タスクに着手したら 1 つのファイルを作り、調べたこと、判断とその理由、コマンドの出力を節目ごとに追記する、という手順を CLAUDE.md に書きました。 これらの記録は、レビューで「なぜこうしたのか」と聞かれたときや、PR の本文を書くための材料にもなりました。 タスク表は、リポジトリをまたいで進捗を管理するためのものです。 今回の変更は 4 つのリポジトリにまたがり、Cognito の設定ができないとバックエンドサーバーの認証を試せない、実行環境ができないと MCP サーバーをデプロイできない、といった依存関係がありました。 そこで、実現したいこと(Claude から、バックエンドサーバーの API を通して本部と店舗の一覧を取得できること)をゴールとして書き、それを 1 本の PR で完結する粒度のタスクに分けて、1 つの表にまとめました。 PR の状態が変わるたびに表を更新したので、どのタスクが何を待っているのかを、セッションをまたいでも 1 か所で確かめられました。 ほかに、PR の本文やレビューへの返信は Claude Code に下書きを書かせ、内容を確認して疑問点を解消してから自分で投稿しています。 メモリに記録したルール 作業中に Claude Code の出力に対して指摘したことは、Claude Code のメモリ機能(auto memory 16 )に残しました。 メモリは、次のように 1 つのルールを 1 つのファイルとして保存されます。 $ ls ~/.claude/projects/-Users-sota-watanabe-every/memory MEMORY.md check-state-before-asking.md code-comment-rules.md daily-notes-in-docs-log.md do-not-stage-without-asking.md docs-for-user-in-every-docs.md every-workspace-doc-layout.md installed-external-skills.md keep-task-plan-current.md no-production-access.md no-progress-in-readme.md no-pseudo-command-lines.md no-task-ids-in-shared-text.md pr-body-style.md review-intent-minimal-change.md MEMORY.md は各ファイルへの索引で、セッションの開始時に読み込まれます。 たとえば do-not-stage-without-asking.md には「 git add を勝手に実行しない。ステージされると、 git diff で差分を確認できなくなるため」、 check-state-before-asking.md には「push などを頼む前に、 gh で既に済んでいないかを確認する」と書いています。 これにより、一度指摘したことを次のセッションで同じように指摘し直す手間が減りました。 使ったスキル チームでは、PRD や DD などの文書を管理しているリポジトリの規約で、文書を書くときに使うスキルが決められていました。 まず変更を PRD と DD に分解し、DD は設計判断を吟味してから書き始め、レビューを依頼する前には必ず日本語を校正する、という順序です。 suite-prd-dd-authoring :変更を、要件を定める PRD(何を・なぜ)と、技術的な論点を扱う DD(どう作るか)の組に分け、章立てを決める suite-system-design :設計判断を下す前に、制約から選択肢を洗い出して比べる suite-logical-writing :説明文書の論理に飛躍や矛盾がないかを確かめる suite-natural-wording :造語や不自然な言い回しを避け、自然な表現に直す ドメインの用語や不変条件をまとめたドキュメントを検索する MCP サーバーも、社内で用意されていたものを使いました。 これらに加えて、次のスキルを自分で導入しました。 grilling :設計案に対して質問を続け、決め切れていない点を洗い出す ponytail :コードを書くときに、頼まれていない抽象化や中間層を追加しないようにする japanese-tech-writing :日本語の技術文書の書き方の規範に沿って、文章を推敲する Claude Code の権限とセッションの分け方 git の操作は基本的に許可せず、 gh コマンドも読み取りだけを許可しました。 外部との HTTP 通信が発生しうる操作や dev 環境の操作は Claude Code には行わせず、コマンドを出力させて自分で確認したうえで手動で実行しました。 作業は細かく分け、複数のセッションで並行して進めました。 社内の MCP サーバーの事例を調べるセッション、リポジトリのコードを読むセッション、DD を書くセッション、MCP サーバーのコードを書くセッションといった具合です。 あるセッションの成果物を別のセッションでレビューすることで、設計の穴や考慮漏れが見つかったことが何度かありました。 今回のインターンで感じたこと Claude Code のようなエージェントを使って開発するにあたって、LLM が生成した文章やコードの品質を上げるようにセルフレビューする必要があるため、私が今回のインターンで一番必要だと感じたのは、ドキュメントやコードに対する審美眼と、日本語の文章力です。 これを特に感じたのが、実装の前に書く DD の作成です。 設計そのものに加えて、なぜその設計にしたのかという意図を、レビューする人に伝わるように書く必要があり、かなりの時間がかかりました。 書いている途中で早めに見せて方針を確かめれば、手戻りは減らせますが、LLM が出力した文章を自分で理解しきれていないうちに見せても、レビュワーの時間を奪うだけになります。 そこで、LLM の出力を自分で理解してから、近くにいるメンターに「ここはこういう方針で良いですか?」とこまめに確認しながら文章を整え、改めてレビューしてもらう進め方に落ち着きました。 DD のレビューが済んでから Claude Code で実装に進むと、設計を先に固めていたため、出力されるコードはおおむね自分の意図どおりになり、手戻りなく実装を進められました。 また、LLM の出力は局所最適、つまり目の前の問題を場当たり的に直すような修正になりがちです。 ドメインを理解している人間が LLM に十分なコンテキストを与え、出力をシステム全体として最適な方向へ導くことが、今のエンジニアに求められる役割なのではないかと思いました。 まとめ この記事では、営業担当者がダッシュボードのデータを探して Claude に渡す手間をなくすために、Claude がデータを自分で取りに行けるようにしたリモート MCP サーバーについて、その設計と実装を紹介しました。 MCP サーバーは、サービストークンと利用者を示すヘッダを分けてバックエンドサーバーに渡し、画面と同じ権限でデータを読みます。 また、Claude Code を使った開発では、外部に影響する操作を人間が承認するルール、セッションをまたいで残す作業記録とタスク表、指摘を残すメモリを使って、4 つのリポジトリにまたがる変更を進めました。 インターン期間中に、dev 環境で MCP ゲートウェイからバックエンドサーバーまでをつないで動かすところまで進められました。 営業担当者が実際に使う本番環境へのデプロイは、メンターに引き継いでもらおうと思います。 営業担当者へのヒアリングと、過去の施策をもとに新しい施策の情報を埋めるなどの書き込みを行うツールの追加は今後の課題です。 アプリのコードだけでなく、認証基盤やインフラ、CI まで含めて dev 環境にデプロイするところまでやり遂げられたのは、インターンにも大きな裁量があり、素早く意思決定して実務に取り組めるエブリーだからこそできた経験でした。 最後に、1 か月間サポートしてくださったメンターの鈴木さん、およびエブリーの社員の皆様、本当にありがとうございました。 LLM を使うアプリが、外部のツールやデータに接続するためのプロトコル https://modelcontextprotocol.io/ ↩ AWS の認証サービス。 ユーザープールで利用者を管理し、OAuth 2.0 のトークンを発行する https://docs.aws.amazon.com/cognito/latest/developerguide/what-is-amazon-cognito.html ↩ OAuth 2.0 で、アプリ自身の資格情報だけでトークンを得るフロー(RFC 6749 4.4 節) https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 ↩ Cognito がアクセストークンを発行するエンドポイント(/oauth2/token) https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html ↩ JWT のクレームの 1 つで、トークンを発行した者を表す ↩ MCP のサーバーとクライアントを Go で実装するための公式の SDK https://github.com/modelcontextprotocol/go-sdk ↩ MCP の通信方式の 1 つ。 HTTP の POST で要求を送り、応答を通常の HTTP レスポンスか SSE(Server-Sent Events)のストリームで受け取る https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http ↩ サーバーを管理せずにコンテナを動かせる、ECS の実行環境 ↩ ECS のタスクに DNS 名を付け、名前で呼べるようにする AWS のサービス ↩ コンテナのイメージ、環境変数、CPU やメモリなどを定めた ECS の設定 ↩ イメージの内容から計算したハッシュ。 タグと違い、同じ指定で中身が変わらない ↩ 実行中のデータ競合(goroutine 間の同時アクセス)を検出するオプション ↩ ECS のサービスとタスク定義をデプロイするためのツール https://github.com/kayac/ecspresso ↩ GitHub Actions の ID トークンを AWS が信頼し、一時的な認証情報を払い出す仕組み ↩ 実装の前に、設計とその理由を書いてレビューを受けるための文書 ↩ Claude Code が覚えておくべきことをファイルに保存し、次のセッションでも読み込む機能 ↩
はじめに こんにちは、リテールハブ開発部で小売アプリ開発を行っているホシと申します。 先日、担当しているシステムでMySQL 8.0から8.4へのバージョンアップ対応を行う機会がありました。 今回はシステム全体の仕様や実装を十分にまだ把握できていない状態からの対応だったため、事前の影響調査や動作確認をどのように進めるかが課題となりました。 その中で特に役立ったのが、 既存のAPIログとAIを活用した、新旧環境の動作比較 です。 従来であれば手間のかかっていた広範囲の動作確認を、想像以上に手軽に実施できたことが今回の大きな収穫でした。 そこで本記事では、以下の点についてお話できればと思います。 MySQLバージョンアップにおける事前調査と課題 既存のAPIログ(アクセスログなど)とAIを活用した新旧環境の比較 実際に活用して良かったこと 今回の経験から感じたことと今後への活用 MySQL 8.4の変更点や具体的な移行手順の解説というよりは、今回の対応で実際に役立った方法や、経験して感じたことを中心に記載しています。 1. MySQLバージョンアップにおける事前調査と課題 MySQL 8.0から8.4へのバージョンアップ 今回対応したのは、MySQL 8.0から8.4へのバージョンアップです。 MySQL 8.4はLTS(長期サポート)リリースですが、バージョンアップにあたっては既存のSQLやアプリケーションの動作に影響がないかを確認する必要があります。 そのため、まずは以下のような観点で調査を進めました。 MySQL 8.0から8.4で変更された仕様や機能 廃止・非推奨となった機能の利用有無 SQLやDB設定に関する互換性 アプリケーション側で影響を受ける可能性のある処理 公式ドキュメントなどから変更点を確認し、既存システムへの影響を調査することは重要な作業です。 そのため、実際にどの機能で影響が発生しそうかを判断するには、さまざまな調査が必要になります。 例えば、対象となるSQLがどこで使用されているのか、普段あまり利用されない機能にも影響がないかなど、確認すべきことは多くあります。 もちろん、時間をかけて調査すれば詳細にチェックすることはできます。 しかし、既存システムのすべての処理を把握し、影響がないことを確認するのは現実的には難しいところがあります。 そこで、 個別の仕様調査に加えて、実際の動作結果から新旧環境の差異を確認できないか と考えました。 2. 既存ログを利用した動作確認 従来の動作確認で感じていた負担 バージョンアップの動作確認では、一般的に以下のような対応が必要になると思います。 影響を受ける可能性のある機能を洗い出す 対象機能の動作確認を行う 必要に応じてテストデータやテストケースを用意する 新旧環境での動作結果を比較する エラーが発生していないかを確認する どれも重要な作業ですが、対象となる機能が多いほど確認には相応の時間が必要になります。 既存のAPIログを活用できないか 今回のシステムでは、APIのリクエストやレスポンスなどをログから確認できる状態でした。 ログには主に以下の情報が記録されています。 リクエスト内容 レスポンス内容(一部) レスポンスサイズ HTTPステータス 応答時間 普段は障害調査や動作確認などで利用しているログですが、これを新旧環境の比較にも活用しようと思いました。 例えば、同じリクエストをMySQL 8.0と8.4の環境で実行し、そのレスポンスを比較できれば、動作に差異が発生していないかを確認できます。 さらに、複数のリクエストに対して実施し、結果を集計できれば、個別に一つずつ確認するよりも効率的に全体の傾向を把握できます。 3. 既存ログとAIを活用した新旧環境の比較 新旧のローカル環境を用意する 今回は、MySQL 8.0と8.4を利用するローカル環境をそれぞれ用意しました。 DBのバージョン以外の条件をできる限りそろえ、同じリクエストに対する動作結果を比較できるようにします。 なお、ローカル環境と本番のRDS環境では構成やデータ量などが異なりますが、今回はDBのバージョン変更による動作差異を事前に把握することを目的として、ローカル環境での比較を行いました。 大まかな流れは以下の通りです。 シンプルに同じリクエストを新旧環境で実行することで、バージョンアップによって動作に差異が発生していないかを確認しました。 AIを活用して比較作業を効率化する 今回、特に便利だったのがAIの活用です。 新旧環境で同じリクエストを実行して比較すること自体は、考え方としてはシンプルです。 しかし、これを多くのリクエストに対して実施するには、ログの抽出、リクエストの実行、比較、集計など、さまざまな処理が必要になります。 今回は、これらの作業をAIに支援してもらいました。 指示内容としては、以下のようなイメージです。 MySQL 8.0と8.4のローカル環境で動作比較を行いたい。 既存のAPIログからリクエスト情報を利用し、 新旧環境で同じリクエストを実行して結果を比較する。 以下の観点で差異を確認したい。 ・レスポンス内容に違いがないか ・レスポンスサイズに変化がないか ・APIごとの応答時間に大きな変化がないか ・エラー数やエラー種別に変化がないか 比較結果は集計し、差異がある箇所を 確認しやすい形に整理する。 ※上記は実際の指示内容を簡略化したイメージです。 もちろん、ログの形式や環境に合わせた調整は必要になりますが、比較的簡単な指示から検証の仕組みを用意できたことは大きなメリットでした。 比較した内容 今回の比較では、主に以下の項目を確認しました。 比較項目 確認内容 レスポンス内容 新旧環境で返却内容に違いがないか レスポンスサイズ 返却データのサイズに変化がないか HTTPステータス 正常・異常の応答に変化がないか 応答時間 新環境で応答が遅くなっていないか エラー数・種別 エラーの発生状況に変化がないか また、日時など実行のたびに変化する項目については、比較対象から除外するようにしました。 こうした項目をそのまま比較すると、本来確認したい動作差異とは関係のない差分が大量に発生してしまうためです。 なお、APIログに記録されているレスポンスは一部のため、比較できる内容にも限りがあります。 APIごとの応答時間を比較する レスポンス内容の比較に加えて、APIごとの応答時間も集計しました。 今回は平均応答時間に加えて、最小値(MIN)、最大値(MAX)を取得し、新旧環境で比較しています。 平均値: 全体的な応答時間の傾向を確認 MIN・MAX: 極端な変化が発生していないかを確認 MIN・MAXについては、数値が一致していることを確認するのではなく、大きな差異が発生していた場合に個別調査するための判断材料として利用しました。 実際の比較結果では、新旧環境で応答時間に大きな違いは見られず、全体としてほぼ同じ傾向であることを確認できました。 もちろん、応答時間は実行環境やデータの状態などによって変動するため、この結果だけで性能に問題がないと判断できるわけではありません。 しかし、APIごとに集計して比較することで、バージョンアップによる大きな性能変化がないかを手軽に確認できたことは非常に有用でした。 4. 実際に活用して良かったこと 検証の準備から結果確認までの負担が減った 今回の方法で特に良かったのは、検証の準備から結果確認までの負担を大きく減らせたことです。 従来であれば、ログからのリクエスト抽出や新旧環境での実行、比較条件の調整、結果の集計などを行う仕組みを用意する必要があります。 個々の処理は特別に難しいものではありませんが、 これらを一通り用意し、多くのリクエストに対して実行できるようにするには、それなりの時間と労力が必要になります。 今回はAIを利用することで、こうした作業の多くを任せることができました。 特に便利だったのは、 比較処理だけでなく、チェック後の差異の整理や集計まで支援してもらえたこと です。 比較処理を実行できても、結果を一つずつ確認していては、結局多くの時間がかかってしまいます。 今回のように結果を整理できれば、全体の状況を把握し、確認が必要な箇所を見つけやすくなります。 以前であれば、検証のための仕組みを作ること自体が一つの作業になる場合もありましたが、今回はそこにかかる負担を大きく減らせたと感じています。 実際の動作結果を判断材料にできた もう一つ良かったのは、実際の動作結果を比較材料として利用できたことです。 バージョンアップの影響調査では、変更点や既存の実装から問題が発生しそうな箇所を予測して確認することも重要です。 しかし、事前調査だけでは把握しきれない部分もあります。 今回の方法では、実際に新旧環境でリクエストを実行し、その結果を比較しています。 そのため、仕様や実装からの推測だけでなく、実際のレスポンスや応答時間、エラーの発生状況を判断材料にできました。 「まず広い範囲の差異を確認し、気になる箇所があれば個別に調査する。」 この進め方が、今回の検証作業では非常に役立ちました。 5. 今回の検証で感じたことと今後への活用 ログ比較だけですべてを確認できるわけではない ここまでメリットを中心に記載しましたが、今回の方法だけですべての動作を確認できるわけではありません。 例えば、ログに記録されていない処理は比較できませんし、レスポンスが同じでもDB内部の状態まで同じとは限りません。 また、実行条件やデータの状態によって結果が変化する場合もあります。 そのため、今回の方法はあくまで大まかな動作差異を確認するための手段として利用しました。 重要な機能や気になる箇所については、個別の動作確認などと組み合わせて判断する必要があります。 ただ、最初からすべてを個別に確認するのではなく、まず広い範囲の差異を把握できることには大きな価値があると感じています。 他の変更作業の確認にも利用できる 今回の方法は、MySQLのバージョンアップの確認で使用していますが、他の変更作業の確認にも利用できると思います。 例えば、以下のようなケースです。 フレームワークやライブラリのバージョンアップ アプリケーションのリファクタリング APIの内部実装の変更 DBやインフラ構成の変更 いずれも、変更前後で基本的な動作が変わっていないかを確認したい場面です。 もちろん、すでに用意されているものがあれば一番ですが、急遽必要になった際も非常に助かるのではと思いました。 また、今回のAI活用を通じて、 すべてを自動化・高度化することの他に、まず手間のかかっている作業を手軽に実施できるようにすることの価値 を改めて感じました。 共通化、エージェント化 今回はMySQLのバージョンアップに合わせた一度限りの検証として実施しましたが、同様の確認を継続的に行うのであれば、仕組みを共通化することも考えられます。 例えば、対象のログや比較する環境を指定するだけで、リクエストの抽出から実行、結果の比較・集計までを行えるようにする方法です。 さらに、これらの処理をAIエージェントとしてまとめることで、環境や目的に応じた検証をより手軽に実施できる可能性もあります。 今回はそこまでの仕組みを用意せず柔軟に試せるレベルで良かったのですが、利用頻度や対象範囲によっては、こうした発展も考えられると思います。 今後も、こうした身近な作業の効率化にAIを活用していきたいと思っています。 6. まとめ いかがでしたでしょうか。 今回のMySQL 8.0から8.4へのバージョンアップでは、事前の仕様調査やシステムの把握に加えて、既存ログとAIを活用した新旧環境の動作比較が非常に役立ちました。 もちろん、今回の方法だけでバージョンアップの安全性を保証できるわけではありませんが、 これまで手間がかかっていた作業を少ない負担で実施できるようになったことは、非常に大きな収穫でした。 「完璧な検証の仕組みを作ることを目指すのではなく、既存の資産とAIをうまく活用して、必要な確認をより手軽に行えるようにする。」 今回の経験を通じて、こうした考え方も今後の開発や運用に活かしていきたいと思っています。 もし今後、DBやフレームワークなどのバージョンアップを行う際に、本記事が少しでも参考になれば幸いです。 最後までお読みいただきありがとうございました。
目次 はじめに 前提 結論 理由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 活用の話を情報交換できて、とても良い機会になりました。 これからもデリッシュキッチン、そしてエブリーをよろしくお願いします! 最後までお読みいただき、ありがとうございました!