TECH PLAY

プログラミング

イベント

マガジン

技術ブログ

Google Workspace MCPサーバーの概要 そもそもMCPとは何? MCPとは、Model Context Protocol(モデル・コンテキスト・プロトコル)の略です。 端的に説明すると、AIエージェントやAIアプリが、外部のデータやツールに安全・標準的につながるための共通規格です。公式ドキュメントでは、AIアプリと外部システムを接続するためのオープンソース標準と説明されています。
こんにちは、モバファクエンジニアの id:knj-mf です。 今回は TypeScript の型レベルプログラミングでちょっと面白いものを作ったので紹介したいと思います。 何を作ったの? TypeScript の型レベルプログラミングは、予想に反して様々なものが実装できてしまうことで有名だったりします。 type-challenges のように、「これは普通のプログラミングで実装するものでは?」と思ってしまうようなものまで実装できてしまいます。そこで、作ってみたものが下記になります。 早速、動作を紹介します。このような Brainf**k プログラムの文字列型が… このように、型計算上で解釈されてしまう!というものです。 ある程度の形になるものはできたので、この記事では、型レベルプログラミングと書き味の近い (個人差があります) Haskell 実装と照らしながら、どのように考えてこの「型」を実装していったのかを紹介します。 cwd-k2/bf-in-type のリポジトリ に実装があるので、気になる方は手元で動作や実装を見てみてください。 Brainf**k? たった 8 つの命令からなる難読プログラミング言語です。言語の仕様としてかなり単純明快ではありますが、チューリング完全として知られています。(ちょっと企業の公式ブログには載せづらい表記を含むので、今回は ** という風に伏せさせていただきます…) 要素として、次の 4 つのものを持ちます。 要素 内容 プログラムテープ 実行するプログラム列 メモリテープ 値を記録するセルの列 プログラムポインタ 現在参照しているプログラム命令列上の位置 メモリポインタ 現在参照しているメモリテープの位置 8 つの命令は次のような単純なものです。 命令 内容 > メモリポインタをインクリメント(次のセルへ) < メモリポインタをデクリメント(前のセルへ) + 現在セルの値をインクリメント - 現在セルの値をデクリメント . 現在セルの値を ASCII 文字として出力 , 1 バイト読み込み、現在セルへ格納 [ 現在セルが 0 なら、対応する ] の直後へジャンプ ] 現在セルが 0 でなければ、対応する [ の直後へジャンプ ざっくり、プログラムテープ上に記載された 8 つの命令の列を順次実行しながらメモリテープの値を書き換えつつ、適宜 I/O していく形のプログラミング言語になります。 実際の Brainf**k プログラムそのものはまったく実用性がないのですが、この簡単な命令セットからなる言語処理系の実装には教育的価値があります。結構書いてみたことがあるというエンジニアの方も多いのではないでしょうか。 TypeScript の型レベルプログラミング ところで、TypeScript には (TypeScript に限りませんが) 型レベルプログラミングがあります。本当に単純な例だと、下記のようなものです。 type ExtendsObject < T > = T extends object ? true : false これが何をしているのかというと、型チェックの際に実施される型計算を実装しているということです。上記のような条件分岐などのロジックが型レベルで解決されてしまうということですね。 この型レベルプログラミングなのですが、表現力はさておき、チューリング完全な系になってしまっているとのもっぱらの評判です。 型から型を新たに計算できてしまうということは… 楽しいプログラミングの時間の始まりですね。 実装方針 長くなってしまうので、以降では Brainf**k を BF と記載することにします。 BF 処理系を型レベルに落とし込むにあたって、次の 4 つの要素に分けて考えます。 テープ構造体 ( Tape ) — メモリ・プログラムを共通して表現するデータ構造 現在位置を持ちつつ、前後に移動する能力を持つ 評価器 ( Runner ) — メモリテープとプログラムテープを束ねた実行状態 メモリを変化させつつプログラムポインタを移動するため、同時に扱う アクション ( Action ) — 1 ステップ実行の結果として外界に要求する効果 (なにもしない / 入力 / 出力 / 終了) 評価ループ ( Exec ) — アクションを解釈して評価器を回し、入力を消費しつつ出力を蓄積するメインループ 型レベルプログラミングでは副作用を素直に書けないため、入出力を「アクション型」としてデータに落としておき、外側のループでそれを解釈する形にしたのがポイントです。以降、この順で各要素の実装を見ていきます。 また、適宜参考実装として Haskell の実装も合わせて示しています。 TypeScript 実装は v5.4 以降で動作確認しています。 実装上の制約 制約として、実装レベルに効いてくるものもあります。数値での演算や数値⇔文字の変換が基本的にできない、というものです。不可能ではないですが、タプル (型レベル配列) の length を取るような実装になりがちなのでまわりくどくなります。 今回は ASCII 範囲でインクリメント・デクリメントを考えるだけなので、気合いで誤魔化すことができます。 NumToCharMap[65] のように参照すると 'A' という型に解決される、というマップを定義しました。 数値文字変換、インクリメント・デクリメントマップの実装 export type NumToCharMap = [ '\x00' , '\x01' , '\x02' , '\x03' , '\x04' , '\x05' , '\x06' , '\x07' , '\x08' , '\x09' , '\x0A' , '\x0B' , '\x0C' , '\x0D' , '\x0E' , '\x0F' , '\x10' , '\x11' , '\x12' , '\x13' , '\x14' , '\x15' , '\x16' , '\x17' , '\x18' , '\x19' , '\x1A' , '\x1B' , '\x1C' , '\x1D' , '\x1E' , '\x1F' , '\x20' , '\x21' , '\x22' , '\x23' , '\x24' , '\x25' , '\x26' , '\x27' , '\x28' , '\x29' , '\x2A' , '\x2B' , '\x2C' , '\x2D' , '\x2E' , '\x2F' , '\x30' , '\x31' , '\x32' , '\x33' , '\x34' , '\x35' , '\x36' , '\x37' , '\x38' , '\x39' , '\x3A' , '\x3B' , '\x3C' , '\x3D' , '\x3E' , '\x3F' , '\x40' , '\x41' , '\x42' , '\x43' , '\x44' , '\x45' , '\x46' , '\x47' , '\x48' , '\x49' , '\x4A' , '\x4B' , '\x4C' , '\x4D' , '\x4E' , '\x4F' , '\x50' , '\x51' , '\x52' , '\x53' , '\x54' , '\x55' , '\x56' , '\x57' , '\x58' , '\x59' , '\x5A' , '\x5B' , '\x5C' , '\x5D' , '\x5E' , '\x5F' , '\x60' , '\x61' , '\x62' , '\x63' , '\x64' , '\x65' , '\x66' , '\x67' , '\x68' , '\x69' , '\x6A' , '\x6B' , '\x6C' , '\x6D' , '\x6E' , '\x6F' , '\x70' , '\x71' , '\x72' , '\x73' , '\x74' , '\x75' , '\x76' , '\x77' , '\x78' , '\x79' , '\x7A' , '\x7B' , '\x7C' , '\x7D' , '\x7E' , '\x7F' , ] & { [ i: number ] : ' \x00 ' } ; export type CharToNumMap = { '\x00' : 0 x00 , '\x01' : 0 x01 , '\x02' : 0 x02 , '\x03' : 0 x03 , '\x04' : 0 x04 , '\x05' : 0 x05 , '\x06' : 0 x06 , '\x07' : 0 x07 , '\x08' : 0 x08 , '\x09' : 0 x09 , '\x0A' : 0 x0A , '\x0B' : 0 x0B , '\x0C' : 0 x0C , '\x0D' : 0 x0D , '\x0E' : 0 x0E , '\x0F' : 0 x0F , '\x10' : 0 x10 , '\x11' : 0 x11 , '\x12' : 0 x12 , '\x13' : 0 x13 , '\x14' : 0 x14 , '\x15' : 0 x15 , '\x16' : 0 x16 , '\x17' : 0 x17 , '\x18' : 0 x18 , '\x19' : 0 x19 , '\x1A' : 0 x1A , '\x1B' : 0 x1B , '\x1C' : 0 x1C , '\x1D' : 0 x1D , '\x1E' : 0 x1E , '\x1F' : 0 x1F , '\x20' : 0 x20 , '\x21' : 0 x21 , '\x22' : 0 x22 , '\x23' : 0 x23 , '\x24' : 0 x24 , '\x25' : 0 x25 , '\x26' : 0 x26 , '\x27' : 0 x27 , '\x28' : 0 x28 , '\x29' : 0 x29 , '\x2A' : 0 x2A , '\x2B' : 0 x2B , '\x2C' : 0 x2C , '\x2D' : 0 x2D , '\x2E' : 0 x2E , '\x2F' : 0 x2F , '\x30' : 0 x30 , '\x31' : 0 x31 , '\x32' : 0 x32 , '\x33' : 0 x33 , '\x34' : 0 x34 , '\x35' : 0 x35 , '\x36' : 0 x36 , '\x37' : 0 x37 , '\x38' : 0 x38 , '\x39' : 0 x39 , '\x3A' : 0 x3A , '\x3B' : 0 x3B , '\x3C' : 0 x3C , '\x3D' : 0 x3D , '\x3E' : 0 x3E , '\x3F' : 0 x3F , '\x40' : 0 x40 , '\x41' : 0 x41 , '\x42' : 0 x42 , '\x43' : 0 x43 , '\x44' : 0 x44 , '\x45' : 0 x45 , '\x46' : 0 x46 , '\x47' : 0 x47 , '\x48' : 0 x48 , '\x49' : 0 x49 , '\x4A' : 0 x4A , '\x4B' : 0 x4B , '\x4C' : 0 x4C , '\x4D' : 0 x4D , '\x4E' : 0 x4E , '\x4F' : 0 x4F , '\x50' : 0 x50 , '\x51' : 0 x51 , '\x52' : 0 x52 , '\x53' : 0 x53 , '\x54' : 0 x54 , '\x55' : 0 x55 , '\x56' : 0 x56 , '\x57' : 0 x57 , '\x58' : 0 x58 , '\x59' : 0 x59 , '\x5A' : 0 x5A , '\x5B' : 0 x5B , '\x5C' : 0 x5C , '\x5D' : 0 x5D , '\x5E' : 0 x5E , '\x5F' : 0 x5F , '\x60' : 0 x60 , '\x61' : 0 x61 , '\x62' : 0 x62 , '\x63' : 0 x63 , '\x64' : 0 x64 , '\x65' : 0 x65 , '\x66' : 0 x66 , '\x67' : 0 x67 , '\x68' : 0 x68 , '\x69' : 0 x69 , '\x6A' : 0 x6A , '\x6B' : 0 x6B , '\x6C' : 0 x6C , '\x6D' : 0 x6D , '\x6E' : 0 x6E , '\x6F' : 0 x6F , '\x70' : 0 x70 , '\x71' : 0 x71 , '\x72' : 0 x72 , '\x73' : 0 x73 , '\x74' : 0 x74 , '\x75' : 0 x75 , '\x76' : 0 x76 , '\x77' : 0 x77 , '\x78' : 0 x78 , '\x79' : 0 x79 , '\x7A' : 0 x7A , '\x7B' : 0 x7B , '\x7C' : 0 x7C , '\x7D' : 0 x7D , '\x7E' : 0 x7E , '\x7F' : 0 x7F , } & { [ k : string ]: 0 x00 ; } ; export type DecrementMap = [ 0 x7F , 0 x00 , 0 x01 , 0 x02 , 0 x03 , 0 x04 , 0 x05 , 0 x06 , 0 x07 , 0 x08 , 0 x09 , 0 x0A , 0 x0B , 0 x0C , 0 x0D , 0 x0E , 0 x0F , 0 x10 , 0 x11 , 0 x12 , 0 x13 , 0 x14 , 0 x15 , 0 x16 , 0 x17 , 0 x18 , 0 x19 , 0 x1A , 0 x1B , 0 x1C , 0 x1D , 0 x1E , 0 x1F , 0 x20 , 0 x21 , 0 x22 , 0 x23 , 0 x24 , 0 x25 , 0 x26 , 0 x27 , 0 x28 , 0 x29 , 0 x2A , 0 x2B , 0 x2C , 0 x2D , 0 x2E , 0 x2F , 0 x30 , 0 x31 , 0 x32 , 0 x33 , 0 x34 , 0 x35 , 0 x36 , 0 x37 , 0 x38 , 0 x39 , 0 x3A , 0 x3B , 0 x3C , 0 x3D , 0 x3E , 0 x3F , 0 x40 , 0 x41 , 0 x42 , 0 x43 , 0 x44 , 0 x45 , 0 x46 , 0 x47 , 0 x48 , 0 x49 , 0 x4A , 0 x4B , 0 x4C , 0 x4D , 0 x4E , 0 x4F , 0 x50 , 0 x51 , 0 x52 , 0 x53 , 0 x54 , 0 x55 , 0 x56 , 0 x57 , 0 x58 , 0 x59 , 0 x5A , 0 x5B , 0 x5C , 0 x5D , 0 x5E , 0 x5F , 0 x60 , 0 x61 , 0 x62 , 0 x63 , 0 x64 , 0 x65 , 0 x66 , 0 x67 , 0 x68 , 0 x69 , 0 x6A , 0 x6B , 0 x6C , 0 x6D , 0 x6E , 0 x6F , 0 x70 , 0 x71 , 0 x72 , 0 x73 , 0 x74 , 0 x75 , 0 x76 , 0 x77 , 0 x78 , 0 x79 , 0 x7A , 0 x7B , 0 x7C , 0 x7D , 0 x7E , ] & { [ i: number ] : 0x7F ; } ; export type IncrementMap = [ 0 x01 , 0 x02 , 0 x03 , 0 x04 , 0 x05 , 0 x06 , 0 x07 , 0 x08 , 0 x09 , 0 x0A , 0 x0B , 0 x0C , 0 x0D , 0 x0E , 0 x0F , 0 x10 , 0 x11 , 0 x12 , 0 x13 , 0 x14 , 0 x15 , 0 x16 , 0 x17 , 0 x18 , 0 x19 , 0 x1A , 0 x1B , 0 x1C , 0 x1D , 0 x1E , 0 x1F , 0 x20 , 0 x21 , 0 x22 , 0 x23 , 0 x24 , 0 x25 , 0 x26 , 0 x27 , 0 x28 , 0 x29 , 0 x2A , 0 x2B , 0 x2C , 0 x2D , 0 x2E , 0 x2F , 0 x30 , 0 x31 , 0 x32 , 0 x33 , 0 x34 , 0 x35 , 0 x36 , 0 x37 , 0 x38 , 0 x39 , 0 x3A , 0 x3B , 0 x3C , 0 x3D , 0 x3E , 0 x3F , 0 x40 , 0 x41 , 0 x42 , 0 x43 , 0 x44 , 0 x45 , 0 x46 , 0 x47 , 0 x48 , 0 x49 , 0 x4A , 0 x4B , 0 x4C , 0 x4D , 0 x4E , 0 x4F , 0 x50 , 0 x51 , 0 x52 , 0 x53 , 0 x54 , 0 x55 , 0 x56 , 0 x57 , 0 x58 , 0 x59 , 0 x5A , 0 x5B , 0 x5C , 0 x5D , 0 x5E , 0 x5F , 0 x60 , 0 x61 , 0 x62 , 0 x63 , 0 x64 , 0 x65 , 0 x66 , 0 x67 , 0 x68 , 0 x69 , 0 x6A , 0 x6B , 0 x6C , 0 x6D , 0 x6E , 0 x6F , 0 x70 , 0 x71 , 0 x72 , 0 x73 , 0 x74 , 0 x75 , 0 x76 , 0 x77 , 0 x78 , 0 x79 , 0 x7A , 0 x7B , 0 x7C , 0 x7D , 0 x7E , 0 x7F , 0 x00 , ] & { [ i: number ] : 0x00 ; } ; テープ構造体 BF では、メモリを用意してポインタ操作・ポインタを介した操作が前提になっています。 もちろん型レベルプログラミングで副作用は記述しにくいため、ポインタ前提となっている部分を再考し、同じ表現力の別の形に置き換える必要があります。 メモリ、プログラムを同じテープ構造で捉えます。今着目している値、その左右に列が続いている様子を考えたのが下記のような構造になります。 テープ構造体の実装 このような構造体は、Haskell での data 宣言と同じような形で、TypeScript の型ではオブジェクト型による宣言ができます。 data Tape a = Tape { prevs :: [a] , curr :: a , nexts :: [a] } extends unknown[] によって単なる配列型ではなく、各要素が独立した 型レベル配列としてのタプル を利用できます。 export type Tape < Hs extends unknown [], C , Ts extends unknown []> = { h : Hs c : C t : Ts } ここでいくつかの基本的な操作も定義してしまいましょう。 現在の値に対する操作 インクリメント・デクリメント 読み出し、書き込み テープ上の移動 着目するヘッドを左右に移動する操作 対応する [ , ] へのジャンプは繰り返しによって実現する 基本操作の実装 Tape a から新しい Tape a を作る ( Tape a -> Tape a ) という形の実装となります。 -- | 次の要素に移動 next :: Tape a -> Tape a next (Tape prevs curr (n : nexts)) = Tape (curr : prevs) n nexts -- | 前の要素に移動 prev :: Tape a -> Tape a prev (Tape (p : prevs) curr nexts) = Tape prevs p (curr : nexts) -- | 現在の要素をインクリメント incr :: Enum a => Tape a -> Tape a incr (Tape prevs curr nexts) = Tape prevs (succ curr) nexts -- | 現在の要素をデクリメント decr :: Enum a => Tape a -> Tape a decr (Tape prevs curr nexts) = Tape prevs (pred curr) nexts -- | 現在の要素を取得 get :: Tape a -> a get (Tape _ curr _) = curr -- | 現在の要素を設定 put :: a -> Tape a -> Tape a put a (Tape prevs _ nexts) = Tape prevs a nexts TypeScript の型でも同様に、 Tape を受け取って新しい Tape を作成するという方針で実装できます。 [infer H, ...infer Hs] のパターンマッチングにより、型レベル配列の要素 (head, rest) を扱うことができてしまいます。 export type Prev < M > = M extends Tape < [infer H , ... infer Hs] , infer C , infer Ts > ? Tape< Hs , H , [C , ... Ts] > : never export type Next < M > = M extends Tape < infer Hs , infer C , [infer T , ... infer Ts] > ? Tape< [C , ... Hs] , T , Ts > : never export type Incr < M > = M extends Tape < infer Hs , infer C extends number , infer Ts > ? Tape< Hs , IncrementMap [C], Ts > : never export type Decr < M > = M extends Tape < infer Hs , infer C extends number , infer Ts > ? Tape< Hs , DecrementMap [C], Ts > : never export type PutC < M , C > = M extends Tape < infer Hs , unknown , infer Ts > ? Tape < Hs , C , Ts > : never プログラム実行 基本的な構造、操作は定義してしまったので、次はインタプリタとして重要な実行について考えます。 評価器としての実行系内部 (メモリ・プログラムポインタ) と外界とのやりとりを含む効果の管理の部分を、次のような形で切り分けます。 型レベルプログラミングでは入出力をそのまま扱うことはできないので、入力待ちや出力があるということは特別な状態として表現することにします。 評価器の内部状態 こちらは至ってシンプルです。 状態はメモリ、プログラムのテープ (現在位置を保持する) から成る これを評価に通すことによって、次の実行に関する状態が出てくる data Machine = Machine { memory :: DT.Tape Int , program :: DT.Tape Char } type Runner < M , P > = { mem : M prg : P } 外部とのやりとりを含むアクション 今のメモリ・プログラムを含む、先程の構造を評価して得られるアクションです。 -- | 何もしない、入力要求、出力要求、終了の 4 つのアクションを持つ data WithAction a = ActionN { hold :: a } -- ^ 外部には何もしない | ActionI { hold :: a } -- ^ 入力要求 | ActionO { hold :: a, out :: Int } -- ^ 出力要求 | ActionE -- ^ 終了 これを型レベルプログラミングで再現すると、ADT よりは個別の型として定義してあげて、後で extends などの条件分岐してあげる方が素直になります。 type ActionN < R > = { action : "N" ; runner : R } type ActionI < R > = { action : "I" ; runner : R } type ActionO < R , O > = { action : "O" ; runner : R ; output : O } type ActionE = { action : "E" } 8 つの命令に対する操作の整理 評価器の状態とアクションを型として定義できたので、次はプログラムの示す命令を処理していく実装も考えていきます。 これは最初に確認した BF の 8 つの命令に対して、次の評価器の状態と計算の効果を含む全体を返す形で定義していけば良いです。 インクリメント デクリメント 次を参照 (ポインタインクリメント) 前を参照 (ポインタデクリメント) while (ジャンプ) while end (ジャンプバック) getchar putchar 命令→次の状態・アクション さて、図で整理できたので、実装にそのまま落としていきます。 現在の命令ポインタが指す命令に応じて、次の Action と状態を返します。 -- | 次のステップを実行し、状態とアクションを返す step :: Machine -> WithAction Machine step machine = case pc of '+' -> ActionN $ machine { memory = DT.incr (memory machine), program = DT.next (program machine) } '-' -> ActionN $ machine { memory = DT.decr (memory machine), program = DT.next (program machine) } '>' -> ActionN $ machine { memory = DT.next (memory machine), program = DT.next (program machine) } '<' -> ActionN $ machine { memory = DT.prev (memory machine), program = DT.next (program machine) } '[' -> ActionN $ machine { program = if mc == 0 then skip (program machine) else DT.next (program machine) } ']' -> ActionN $ machine { program = if mc /= 0 then back (program machine) else DT.next (program machine) } ',' -> ActionI { hold = machine { program = DT.next (program machine) } } '.' -> ActionO { hold = machine { program = DT.next (program machine) }, out = DT.get (memory machine) } _ -> ActionE where (pc, mc) = (,) <$> DT.get . program <*> DT.get . memory $ machine TypeScript で書いても、ほとんど同じ対応があります。 type Step < R > = R extends Runner < infer M extends TapeMm , infer P extends TapePg > ? P[ 'c' ] extends '+' ? ActionN< Runner < Incr < M >, Next < P >>> : P[ 'c' ] extends '-' ? ActionN< Runner < Decr < M >, Next < P >>> : P[ 'c' ] extends '>' ? ActionN< Runner < Next < M >, Next < P >>> : P[ 'c' ] extends '<' ? ActionN< Runner < Prev < M >, Next < P >>> : P[ 'c' ] extends '[' ? ActionN< Runner < M , M [ 'c' ] extends 0 ? Skip < P > : Next < P >>> : P[ 'c' ] extends ']' ? ActionN< Runner < M , M [ 'c' ] extends 0 ? Next < P > : Back < P >>> : P[ 'c' ] extends ',' ? ActionI< Runner < M , Next < P >>> : P[ 'c' ] extends '.' ? ActionO< Runner < M , Next < P >>, M [ 'c' ]> : ActionE : never; 状態・アクション→継続 次は状態、アクションを受けて、次のステップに継続していくループを実装していきます。 上記の step を実行し、その Action に応じた操作を実行していきます。 -- | 入力を消費・出力を収集しながら step を繰り返す loop :: (Machine -> WithAction Machine) -> (String, Machine) -> String loop step (input, machine) = go (step machine) where -- アクションに対応した動作を実行し、再帰に進む go (ActionN machine') = loop step (input, machine') -- そのまま次へ go (ActionI machine') = loop step (iTail, machine'') where -- 入力を消費してメモリに書き込み、次に進む (iHead : iTail) = input machine'' = machine' { memory = DT.put (fromEnum iHead) (memory machine') } go (ActionO machine' out) = toEnum out : loop step (input, machine') -- 出力を収集し、次に進む go ActionE = [] -- 終端 TypeScript の型の方では、今回は文字列の累積を保持する形で実装しています。ちょっと命名が異なってしまっていますが、やっていることは同じです。 type Exec < R , I extends string , O extends string = '' > = Step < R > extends infer WithAction ? WithAction extends ActionN< infer Q > ? Exec< Q , I , O > : WithAction extends ActionI< infer Q > ? I extends ` ${ infer F }${ infer S } ` ? Exec< Read < Q , CharToNumMap [F]>, S , O > : Exec< Read < Q , 0>, I , O > : WithAction extends ActionO< infer Q , infer N extends number > ? Exec< Q , I , ` ${ O }${ NumToCharMap [N] } ` > : WithAction extends ActionE ? O : never : never; まとめ TypeScript で Brainf**k 処理系の型レベルプログラムの実装について見ていきました。 補足として、TypeScript の型レベルプログラミング実行系には次のような制約があります。 型の再帰評価回数、つまり実行できるステップ数が制限されている Tape 構造体の保持する要素列の長さに制限がある (どちらも大体 1,000 程度のイメージ) 一方、このような制限がある中でも、冒頭に示した例のように簡単な Hello World の例までは実装できてしまいます。 みなさんもぜひ自分の型レベルプログラミングに挑戦してみてください。 私が今回示した実装も最善ではないと思います。「もっと良いものを書いてみよう」など、楽しんでみてください。 付録 Haskell のコード全文を掲載しておきます。 cwd-k2/bf-in-type のリポジトリ と比較する、または手元でテスト実行するなどしてください。 ディレクトリ構成 . ├── Data │   └── Tape.hs ├── Interpreter.hs └── Main.hs Data/Tape.hs module Data.Tape ( Tape( .. ), zeros, fromList, next, prev, incr, decr, get, put, ) where -- | テープ様構造体 -- * 前後に無限に要素があり、現在要素 (針の先にあるもの) を中心に配置している -- -- > <-prev- ... 4 5 6 <<7>> 8 9 10 ... -next-> data Tape a = Tape { prevs :: [a] , curr :: a , nexts :: [a] } deriving Show -- | ゼロ初期化された無限長のテープ zeros :: Enum a => Tape a zeros = Tape (repeat $ toEnum 0 ) (toEnum 0 ) (repeat $ toEnum 0 ) -- | リストからテープを作成 fromList :: [a] -> Tape a fromList (x : xs) = Tape [] x xs fromList [] = undefined -- 今回は特に考えずに未定義とする -- | 次の要素に移動 next :: Tape a -> Tape a next (Tape prevs curr (n : nexts)) = Tape (curr : prevs) n nexts -- | 前の要素に移動 prev :: Tape a -> Tape a prev (Tape (p : prevs) curr nexts) = Tape prevs p (curr : nexts) -- | 現在の要素をインクリメント incr :: Enum a => Tape a -> Tape a incr (Tape prevs curr nexts) = Tape prevs (succ curr) nexts -- | 現在の要素をデクリメント decr :: Enum a => Tape a -> Tape a decr (Tape prevs curr nexts) = Tape prevs (pred curr) nexts -- | 現在の要素を取得 get :: Tape a -> a get (Tape _ curr _) = curr -- | 現在の要素を設定 put :: a -> Tape a -> Tape a put a (Tape prevs _ nexts) = Tape prevs a nexts Interpreter.hs module Interpreter ( bf ) where import qualified Data.Tape as DT import Data.List (unfoldr) -- | メモリとプログラムを持つ data Machine = Machine { memory :: DT.Tape Int , program :: DT.Tape Char } deriving Show -- | 何もしない、入力要求、出力要求、終了の 4 つのアクションを持つ data WithAction a = ActionN { hold :: a } -- ^ 外部には何もしない | ActionI { hold :: a } -- ^ 入力要求 | ActionO { hold :: a, out :: Int } -- ^ 出力要求 | ActionE -- ^ 終了 deriving Show -- | 対応する @']'@ までプログラムをスキップする skip :: DT.Tape Char -> DT.Tape Char skip = skipInner 0 where skipInner n program = let program' = DT.next program in case DT.get program' of '[' -> skipInner (n + 1 ) program' ']' -> if n == 0 then program' else skipInner (n - 1 ) program' _ -> skipInner n program' -- | 対応する @'['@ までプログラムを戻す back :: DT.Tape Char -> DT.Tape Char back = backInner 0 where backInner n program = let program' = DT.prev program in case DT.get program' of ']' -> backInner (n + 1 ) program' '[' -> if n == 0 then program' else backInner (n - 1 ) program' _ -> backInner n program' -- | 次のステップを実行し、状態とアクションを返す step :: Machine -> WithAction Machine step machine = case pc of '+' -> ActionN $ machine { memory = DT.incr (memory machine), program = DT.next (program machine) } '-' -> ActionN $ machine { memory = DT.decr (memory machine), program = DT.next (program machine) } '>' -> ActionN $ machine { memory = DT.next (memory machine), program = DT.next (program machine) } '<' -> ActionN $ machine { memory = DT.prev (memory machine), program = DT.next (program machine) } '[' -> ActionN $ machine { program = if mc == 0 then skip (program machine) else DT.next (program machine) } ']' -> ActionN $ machine { program = if mc /= 0 then back (program machine) else DT.next (program machine) } ',' -> ActionI { hold = machine { program = DT.next (program machine) } } '.' -> ActionO { hold = machine { program = DT.next (program machine) }, out = DT.get (memory machine) } _ -> ActionE where (pc, mc) = (,) <$> DT.get . program <*> DT.get . memory $ machine -- | 入力を消費・出力を収集しながら step を繰り返す loop :: (Machine -> WithAction Machine) -> (String, Machine) -> String loop step (input, machine) = go (step machine) where -- アクションに対応した動作を実行し、再帰に進む go (ActionN machine') = loop step (input, machine') -- そのまま次へ go (ActionI machine') = loop step (iTail, machine'') where -- 入力を消費してメモリに書き込み、次に進む (iHead : iTail) = input machine'' = machine' { memory = DT.put (fromEnum iHead) (memory machine') } go (ActionO machine' out) = toEnum out : loop step (input, machine') -- 出力を収集し、次に進む go ActionE = [] -- 終端 -- | Bf プログラムから、入力を受け取って出力を返す関数を作る bf :: String -> [Char] -> String bf program input = loop step (input', machine) where input' = input ++ repeat ' \0 ' machine = Machine { memory = DT.zeros , program = DT.next $ DT.fromList ( "#" ++ program ++ "#" ) } Main.hs module Main where import Interpreter -- | ハローワールドする Bf プログラム helloWorld :: String helloWorld = "++++++++++[>+++++++>++++++++++>+++++++++++>+++>+++++++++>+<<<<<<-]>++.>+.>--..+++.>++.>---.<<.+++.------.<-.>>+.>>." -- | エコーする Bf プログラム echo :: String echo = "+[,.]" main :: IO () main = do let getOutputBf = bf helloWorld putStr $ getOutputBf "こんにちは \n "
G-gen の佐々木です。当記事では、Agent Development Kit(ADK)で開発した AI エージェントで Agent Runtime(旧称 : Vertex AI Agent Engine)の Memory Bank 機能を使用することで、セッション間で情報を保持できるエージェントを構築していきます。 構成 当記事で使用するもの Agent Development Kit(ADK) Agent Runtime Memory Bank Cloud Run Memory Bank を使用するエージェントの開発 エージェントの概要 ディレクトリ構成 プロジェクトの準備 エージェントのソースコード(agent.py) Agent Runtime にエージェントをデプロイ Google Cloud の認証と設定 .env ファイルの作成 Agent Runtime へのデプロイ 動作確認(ADK Web) フロントエンドの構築 フロントエンドの概要 チャットボットの開発 ディレクトリ構成 プロジェクトの準備 app.py Dockerfile OAuth 同意画面の構成 Cloud Run へのデプロイ サービスアカウントの作成 デプロイ 動作確認 Memory Bank のカスタマイズ カスタマイズの概要 カスタマイズ用スクリプト(update.py)の作成 カスタマイズの適用 カスタマイズ後の動作確認 構成 当記事では、 Agent Development Kit (ADK)で定義した AI エージェントを Agent Runtime にデプロイし、また、フロントエンドとしてエージェントとやり取りを行うチャットボットを Cloud Run に構築していきます。 エージェントは、Agent Platform の Memory Bank 機能を使用するように構築します。これにより、一度チャットボットとの会話を終了した後でも、前の会話内容の一部を長期記憶として Memory Bank に保存しておくことができます。 また、Cloud Run では Identity-Aware Proxy (IAP)を有効化することで、Google アカウントで認証されたユーザーのみがチャットボットを利用できるようにします。その上で、IAP が付与する認証済みユーザーのメールアドレスを user_id として Memory Bank に送信することで、ユーザーごとの記憶を Memory Bank に蓄積できるようにします。 Memory Bank 機能を使用する Agent Runtime とチャットボットの構成 当記事で使用するもの Agent Development Kit(ADK) Agent Development Kit (ADK)は、Google Cloud が提供する AI エージェント構築のためのオープンソース フレームワークであり、単純なタスクをこなすエージェントから複数のエージェントが協働する複雑なワークフローまで容易に実装できます。 ADK には、Memory Bank と連携するための PreloadMemoryTool や、セッションイベントを Memory Bank に送信するための add_events_to_memory() といったユーティリティが標準で組み込まれており、長期記憶を扱うエージェントを少ないコードで実装できます。 参考 : Agent Development Kit 参考 : Agent Development Kit の概要 Agent Runtime Agent Runtime は Gemini Enterprise Agent Platform (旧称 : Vertex AI、以下 Agent Platform と記載)で提供されるサービスの1つであり、AI エージェントの実行基盤を提供するフルマネージドサービスです。 Agent Runtime では、エージェントとのマルチターン会話を実現するための組み込みの セッション機能 を利用することができるほか、Memory Bank のように、エージェントの機能拡張に必要な様々な機能が提供されています。 Agent Runtime の詳細については、以下の記事をご一読ください。 blog.g-gen.co.jp Memory Bank Memory Bank は Agent Platform が提供する 長期記憶 を実現するための機能です。Agent Runtime のセッション機能が一連の会話の中での短期的な記憶を扱うのに対し、Memory Bank はセッションをまたいで利用できる記憶を蓄積します。 Memory Bank は内部的に LLM を用いて、セッションのイベント(会話履歴)からユーザーの嗜好や事実を抽出・要約し、エージェントが認識しているユーザー ID( user_id )単位で保存します。これにより、エージェントとの会話(セッション)を一度中断しても、後の会話でユーザーごとにパーソナライズされた応答を返すことができるエージェントを構築できます。 エージェント側から以下の2つの操作を行うことで Memory Bank を利用できます。 記憶の生成・保存 : セッション中のイベントを Memory Bank に送信し、長期記憶として抽出・保存させる 記憶の参照 : 新しいセッションの開始時や会話中に、Memory Bank から関連する記憶を取得し、プロンプトに含めて LLM に渡す ADK ではこれらの操作を簡単に行うための add_events_to_memory() や PreloadMemoryTool といったユーティリティが提供されており、エージェントのコールバックやツールとして組み込むだけで Memory Bank を利用できます。 参考 : Agent Platform メモリバンク Cloud Run Cloud Run は Google Cloud のマネージドなコンテナ実行環境でアプリケーションを実行できる、サーバーレス コンテナコンピューティング サービスです。 当記事ではユーザーがエージェントとやり取りするためのフロントエンドとして使用します。 Cloud Run の詳細については、以下の記事をご一読ください。 blog.g-gen.co.jp Memory Bank を使用するエージェントの開発 エージェントの概要 当記事では、コーヒーに関する質問に回答する「コーヒーエージェント」を ADK で構築していきます。エージェントを Memory Bank と連携できるように実装することで、ユーザーごとの嗜好や過去のやり取りを長期記憶として蓄積できるようにします。 Agent Runtime を使用してエージェントを構築する ディレクトリ構成 最終的なディレクトリ構成は以下の通りになります。 coffee_agent ディレクトリで AI エージェントを実装していきます。 . ├── coffee_agent │ ├── agent.py │ ├── .env │ └── __init__.py ├── pyproject.toml # 自動で作成 └── uv.lock # 自動で作成 ADK ではエージェントのパッケージ(ここでは coffee_agent ディレクトリ)内に agent.py を配置し、そこにツール関数とエージェント定義を実装します。 プロジェクトの準備 エージェント開発用のディレクトリでプロジェクトを初期化します。 # uv プロジェクト初期化 $ uv init --no-readme # パッケージの追加 $ uv add " google-adk>=1.29.0 " 次に、エージェント用のパッケージディレクトリ( coffee_agent )を作成し、ADK がパッケージとして認識できるように __init__.py を配置します。 __init__.py では agent モジュールをインポートしておくことで、 adk コマンドからエージェントを参照できるようになります。 # エージェントのパッケージディレクトリを作成 $ mkdir coffee_agent # __init__.py を作成 $ cat <<EOF > coffee_agent/__init__.py from . import agent EOF エージェントのソースコード(agent.py) 当記事では、Google 検索でコーヒーに関する情報を調べるサブエージェントをツールとして内包するエージェントを構築し、Memory Bank との連携機能を組み込みます。 Memory Bank と連携するために、以下の2つの仕組みを利用しています。 PreloadMemoryTool : ADK が標準提供するツールで、エージェントの実行開始時に Memory Bank から user_id に紐付く記憶を取得し、プロンプトに自動で挿入します。エージェントは過去の会話の文脈を踏まえた応答が可能になります。 after_agent_callback : エージェントの応答が完了した直後に呼び出されるコールバックです。 callback_context.add_events_to_memory() を通じて、直近のセッションイベントを Memory Bank に送信し、記憶として抽出・保存させています。 generate_memories_callback では callback_context.session.events[-5:-1] のように直近のイベントをスライスして送信しています。これにより、応答完了時の最終イベントを除外しつつ、ユーザーの発話とエージェントの応答を含む直近のやり取りのみを Memory Bank に送信します。 from google.adk.agents import Agent from google.adk.tools import google_search from google.adk.tools.agent_tool import AgentTool from google.adk.agents.callback_context import CallbackContext from google.adk.tools.preload_memory_tool import PreloadMemoryTool # 記憶を Memory Bank に保存するコールバック関数 async def generate_memories_callback (callback_context: CallbackContext): # イベント単位で Memory Bank に送信する await callback_context.add_events_to_memory( events=callback_context.session.events[- 5 :- 1 ]) return None # Web 検索用エージェント search_agent = Agent( name= "search_agent" , model= "gemini-2.5-flash" , description= "Google検索でコーヒーに関する情報を調べるエージェント" , instruction= "ユーザーの質問に対してGoogle検索を使って情報を収集し、日本語で回答してください。" , tools=[google_search] ) # ルートエージェント root_agent = Agent( name= "coffee_agent" , model= "gemini-2.5-flash" , description= "コーヒーに関する情報を収集するエージェント" , instruction= """あなたはコーヒーの専門家アシスタントです。 ユーザーからの質問に対して、search_agentを活用しながらコーヒーに関する正確で有益な情報を提供してください。 対応できるトピックの例: - コーヒー豆の産地・品種・特徴 - 抽出方法(ドリップ、エスプレッソ、フレンチプレスなど) - 焙煎度合いと味わいの違い - カフェやコーヒーショップの情報 - コーヒーの健康効果や歴史 - ラテアートやバリスタの技術 回答は日本語で、わかりやすく丁寧に行ってください。""" , tools=[ AgentTool(agent=search_agent), PreloadMemoryTool() ], after_agent_callback=generate_memories_callback ) 参考 : Agent Development Kit によるクイックスタート Agent Runtime にエージェントをデプロイ Google Cloud の認証と設定 デプロイの前に、Google Cloud CLI での認証を行っておきます。 # プロジェクト ID を環境変数にセット $ export PROJECT_ID = < プロジェクトID > # 認証 $ gcloud auth login $ gcloud auth application-default login # プロジェクトの設定 $ gcloud config set project $PROJECT_ID .env ファイルの作成 エージェントの実行時に Gemini Enterprise Agent Platform (旧称 : Vertex AI、以下 Agent Platform と記載)を利用するための環境変数を、 coffee_agent ディレクトリ配下の .env ファイルに設定します。ADK は実行時にこのファイルを自動で読み込みます。 # coffee_agent/.env を作成 $ cat <<EOF > coffee_agent/.env GOOGLE_GENAI_USE_VERTEXAI=1 GOOGLE_CLOUD_PROJECT= $PROJECT_ID GOOGLE_CLOUD_LOCATION=asia-northeast1 EOF GOOGLE_GENAI_USE_VERTEXAI : 1 を指定することで、Gemini API ではなく Agent Platform 経由で Gemini モデルを利用します GOOGLE_CLOUD_PROJECT : エージェントをデプロイする Google Cloud プロジェクト ID GOOGLE_CLOUD_LOCATION : Agent Runtime およびモデルを利用するリージョン Agent Runtime へのデプロイ adk deploy コマンドを使用して、Agent Runtime にエージェントをデプロイします。 # Agent Runtime にエージェントをデプロイ $ uv run adk deploy agent_engine \ --project = $PROJECT_ID \ --region = asia-northeast1 \ --display_name =" Coffee Agent " \ coffee_agent デプロイが成功すると、以下のように Agent Runtime のリソース名が出力されます。 ✅ Created agent engine: projects/ < プロジェクト番号 > /locations/asia-northeast1/reasoningEngines/ < エージェント固有の数字 > このリソース名は、後続の動作確認やチャットボットのデプロイ時に使用するため、シェル変数にセットしておきます。 # 環境変数にリソース名をセット $ export RESOURCE_NAME =projects/ < プロジェクト番号 > /locations/asia-northeast1/reasoningEngines/ < エージェント固有の数字 > なお、すでにデプロイ済みの Agent Runtime を更新する場合は、 --agent_engine_id オプションでリソース名を指定して同じ adk deploy コマンドを実行します。 # 既存の Agent Runtime を更新する場合 $ uv run adk deploy agent_engine \ --project = $PROJECT_ID \ --region = asia-northeast1 \ --agent_engine_id = $RESOURCE_NAME \ --display_name =" Coffee Agent " \ coffee_agent 参考 : ADK CLI documentation - deploy 動作確認(ADK Web) ローカルで ADK Web UI を起動し、Memory Bank と連携した状態でエージェントの動作を確認します。 --memory_service_uri オプションに Agent Runtime のリソース名を指定することで、ローカルの Web UI から Agent Runtime の Memory Bank をエージェントの長期記憶ストアとして利用できます(エージェントはローカルで実行し、Memory Bank 用途でのみ Agent Runtime にアクセスしている状態)。 # Memory Bank を指定して ADK Web UI を起動 $ uv run adk web --memory_service_uri = agentengine:// $RESOURCE_NAME ブラウザで http://localhost:8000 を開き、チャットでエージェントと会話します。 adk web から起動した場合、Memory Bank には user という固定のユーザー ID で記憶が保存されていきます。 まず最初のセッションでは、コーヒーの好み(例: 「私は酸味の強いコーヒーが好きです。おすすめのコーヒー豆はありますか」)をエージェントに伝えます。 最初のセッションでエージェントに好みを伝える Agent Runtime のコンソールから Memory Bank の中身を確認することができます。ADK Web のユーザー( user )に関する記憶として、「私は酸味の強いコーヒーが好きです。」という情報が記録されています。 Memory Bank に好みに関する情報が記録されている その後、ADK Web UI 上で新しいセッションを開始し、「私の好みに合う抽出方法を教えて」などと質問することで、Memory Bank に保存されている個人的な好みに関する情報を踏まえた応答が返ってくることを確認できます。 最初のセッションで伝えた好みに関する情報が新しいセッションに引き継がれている 参考 : ADK CLI documentation - web フロントエンドの構築 フロントエンドの概要 機械学習モデルのデモ用 Web UI を容易に作成できる Gradio という Python ライブラリを使用してチャットボットを実装します。 チャットボット Cloud Run にデプロイし、Web サービスとして公開できるようにします。Cloud Run では Identity-Aware Proxy(IAP)を有効化し、Google アカウントで認証されたユーザーのみがアクセスできるようにします。 IAP による認証つきのチャットボットを構築する 参考 : Gradio チャットボットの開発 ディレクトリ構成 フロントエンドはエージェントとは別のディレクトリで構築します。最終的なディレクトリ構成は以下の通りになります。 app.py にチャットボットを実装していきます。 . ├── app.py ├── Dockerfile ├── pyproject.toml # 自動で作成 └── uv.lock # 自動で作成 プロジェクトの準備 エージェントとは別のディレクトリで uv プロジェクトを初期化します。 # uv プロジェクト初期化 $ uv init --no-readme # パッケージの追加 $ uv add " google-cloud-aiplatform[agent-engines]>=1.142.0 " " gradio>=5.29 " app.py app.py では以下の処理を実装しています。 vertexai.init() で Agent Platform に接続し、 agent_engines.get() で Agent Runtime にデプロイしたエージェントを取得 IAP が付与するリクエストヘッダ( x-goog-authenticated-user-email )からユーザーのメールアドレスを取り出し、Agent Runtime のセッションおよび Memory Bank の user_id として使用 Agent Runtime のセッション機能( create_session )を使い、ユーザーごとにマルチターンの会話を管理 agent.stream_query() でエージェントにメッセージを送信し、ストリーミングで応答を受信 gr.Blocks で Gradio のチャット UI を構築 Memory Bank の記憶は user_id ごとに分離して保存されるため、 user_id の決め方がそのままユーザーごとのパーソナライズの単位となります。ここでは IAP が付与する認証済みメールアドレスをそのまま user_id として用いることで、ブラウザやデバイスをまたいでも同一ユーザーであれば一貫した記憶を参照できる構成にしています。 import os import gradio as gr import vertexai from vertexai import agent_engines AGENT_ENGINE_ID = os.environ[ "AGENT_ENGINE_ID" ] # Identity-Aware Proxy (IAP) が認証済みユーザーのメールアドレスを付与するヘッダ IAP_EMAIL_HEADER = "x-goog-authenticated-user-email" def get_agent (): vertexai.init( project=os.environ.get( "GOOGLE_CLOUD_PROJECT" ), location=os.environ.get( "GOOGLE_CLOUD_LOCATION" ), ) return agent_engines.get(AGENT_ENGINE_ID) agent = get_agent() # IAP から渡されるリクエストヘッダを元にユーザーを一意に特定する def get_user_id (request: gr.Request) -> str : # IAP は "accounts.google.com:user@example.com" の形式で付与するため、 # プレフィックスを除去してメールアドレス部分のみを取り出す raw = request.headers.get(IAP_EMAIL_HEADER, "" ) return raw.split( ":" , 1 )[ 1 ] if ":" in raw else raw def chat (message: str , history: list , session_state: dict , request: gr.Request) -> tuple [ str , dict ]: # IAP で認証されたユーザーのメールアドレスをそのまま Agent の user_id として利用する user_id = get_user_id(request) if not user_id: raise gr.Error( "IAPからユーザー情報を取得できませんでした。" ) session_state[ "user_id" ] = user_id session_id = session_state.get( "session_id" ) if not session_id: session = agent.create_session(user_id=user_id) session_id = session[ "id" ] session_state[ "session_id" ] = session_id response_text = "" for event in agent.stream_query( message=message, user_id=user_id, session_id=session_id, ): if event.get( "content" ) and event[ "content" ].get( "parts" ): for part in event[ "content" ][ "parts" ]: if part.get( "text" ): response_text += part[ "text" ] yield response_text, session_state with gr.Blocks( title= "コーヒーエージェント" , fill_height= True , css= """ .title-row { text-align: center; margin-bottom: 0; } .caption-row { text-align: center; margin-top: 0; color: #666; font-size: 0.9em; } .input-row { position: sticky; bottom: 0; background: var(--background-fill-primary); padding: 10px 0; } """ , ) as demo: gr.Markdown( "<h1 class='title-row'>☕ コーヒーエージェント</h1>" "<p class='caption-row'>コーヒーに関するあれこれをお答えします</p>" ) session_state = gr.State(value={}) chatbot = gr.Chatbot( show_label= False , scale= 1 , avatar_images=( None , "https://em-content.zobj.net/source/google/412/hot-beverage_2615.png" ), placeholder= "質問を入力すると、ここに会話が表示されます" , ) with gr.Row(elem_classes= "input-row" ): textbox = gr.Textbox( placeholder= "コーヒーについて質問してください(例: エスプレッソとドリップの違いは?)" , show_label= False , container= False , scale= 7 , ) def respond (message, history, session_state, request: gr.Request): history = history + [ { "role" : "user" , "content" : message}, ] yield history, session_state, gr.update(value= "" , interactive= False ) assistant_text = "" for text, updated_state in chat(message, history, session_state, request): assistant_text = text session_state = updated_state yield ( history + [{ "role" : "assistant" , "content" : assistant_text}], session_state, gr.update(interactive= False ), ) yield ( history + [{ "role" : "assistant" , "content" : assistant_text}], session_state, gr.update(interactive= True ), ) textbox.submit( respond, inputs=[textbox, chatbot, session_state], outputs=[chatbot, session_state, textbox], ) if __name__ == "__main__" : port = int (os.environ.get( "PORT" , 8080 )) demo.launch(server_name= "0.0.0.0" , server_port=port) Dockerfile Cloud Run にデプロイするためのコンテナイメージを定義します。uv の公式イメージからバイナリをコピーし、依存パッケージのインストールとアプリケーションの起動を行います。 FROM python:3.14-slim COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ WORKDIR /app COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-dev COPY app.py . EXPOSE 8080 CMD [ " uv ", " run ", " python ", " app.py " ] OAuth 同意画面の構成 Cloud Run で IAP を有効化すると、Cloud Run 上のサービスへのアクセス時に Google アカウントでのログインが求められるようになり、許可されたユーザーのみがチャットボットを利用できます。 プロジェクトで OAuth 同意画面の構成をまだ行っていない場合、以下のドキュメントを参照して実施してください。 参考 : OAuth 同意画面を設定し、スコープを選択する Cloud Run へのデプロイ サービスアカウントの作成 Cloud Run 用のカスタムサービスアカウントを作成し、Agent Runtime へのアクセスに必要な Agent Platform ユーザー ( roles/aiplatform.user )ロールを付与します。 # サービスアカウントの作成 $ gcloud iam service-accounts create coffee-agent-frontend \ --display-name =" Coffee Agent Frontend " # Agent Platform User ロールの付与 $ gcloud projects add-iam-policy-binding $PROJECT_ID \ --member =" serviceAccount:coffee-agent-frontend@ ${PROJECT_ID} .iam.gserviceaccount.com " \ --role =" roles/aiplatform.user " デプロイ gcloud run deploy コマンドで Cloud Run にデプロイします。 --source オプションを指定すると、Cloud Build によるコンテナイメージのビルドとデプロイが自動で行われます。 --no-allow-unauthenticated と --iap を指定することで、IAP で認証されたユーザーのみがアクセスできるようにしています。 --service-account で先ほど作成したカスタムサービスアカウントを指定します。また、 --set-env-vars でデプロイ済みの Agent Runtime のリソース名を環境変数として渡します。 $ gcloud run deploy coffee-agent-frontend \ --source . \ --region asia-northeast1 \ --set-env-vars " GOOGLE_CLOUD_PROJECT= $PROJECT_ID ,GOOGLE_CLOUD_LOCATION=asia-northeast1,AGENT_ENGINE_ID= $RESOURCE_NAME " \ --service-account coffee-agent-frontend@ ${PROJECT_ID} .iam.gserviceaccount.com \ --cpu 1 \ --memory 1Gi \ --no-allow-unauthenticated \ --iap 動作確認 デプロイが完了したら、Cloud Run サービスの URL にブラウザでアクセスします。 # デプロイ後に出力されるサービス URL Service URL: https://lawapi-frontend- < プロジェクト番号 > .asia-northeast1.run.app IAP が有効化されているため、Google アカウントでのログインが求められます。 IAP で保護されたウェブアプリ ユーザー ( roles/iap.httpsResourceAccessor )ロールが付与されたユーザーでログインします。 Google アカウントでログインする まず最初のセッションでは、普段飲むコーヒーについての情報(例: 「私は中浅煎りのコスタリカをよく飲みます。おすすめの抽出方法を教えてください」)をエージェントに伝えます。 最初のセッションで普段飲んでいるコーヒー豆についての情報をエージェントに伝える Memory Bank の中身を確認すると、IAP でログインしたユーザーのメールアドレスを user_id として、「私は中浅煎りのコスタリカをよく飲みます。」という情報を保持する記憶が作成されていることがわかります。 IAP でログインしたユーザーのメールアドレスを user_id として記憶が作成される 再度、別ブラウザから同じユーザーを使用してチャットボットにログインして、「私の好みに合うコーヒー豆を探してください」と伝えてみます。前回伝えたコーヒーの好みが Memory Bank に記憶されているため、その情報を元にした回答が返ってきます。 ユーザーごとに保存された好みに関する情報が別のセッションに引き継がれている Memory Bank のカスタマイズ カスタマイズの概要 Memory Bank はデフォルト設定でもユーザーの嗜好などを自動的に抽出して長期記憶として保存してくれますが、実際にエージェントとやり取りを繰り返してみると、思うように抽出されない場合もあります。 Memory Bank では、カスタマイズ設定を適用することで、抽出する情報の種類や挙動をユースケースに合わせて調整することができます。 Memory Bank では主に以下の項目をカスタマイズできます。 項目 説明 トピック Memory Bank が保存すべきと判断する情報の種類を定義します。Google Cloud が提供する managed トピック ( USER_PERSONAL_INFO 、 USER_PREFERENCES 、 KEY_CONVERSATION_DETAILS 、 EXPLICIT_INSTRUCTIONS )と、ラベルと抽出指示を自分で定義できる custom トピック の2種類があります。 生成モデル 記憶の生成に使用する LLM を指定できます(デフォルトは gemini-2.5-flash )。 埋め込みモデル 記憶の検索や統合の判定に使用する埋め込みモデル(embedding model)を指定できます(デフォルトは text-embedding-005 )。日本語など英語以外の会話を扱う場合は、 gemini-embedding-001 や text-multilingual-embedding-002 といった多言語対応モデルを指定することで検索品質を向上できます。 有効期限(TTL) 生成・更新された記憶の有効期限を自動設定するルールを定義できます。 Few-shot Examples 抽出してほしい記憶の例をいくつか与えることで、Memory Bank の抽出挙動を調整できます。 カスタマイズ項目の詳細については、以下のドキュメントを参照してください。 参考 : メモリバンク 用に Agent Platform インスタンスを構成する カスタマイズ用スクリプト(update.py)の作成 adk コマンドからは Memory Bank のカスタマイズを直接適用することはできないため、Agent Platform SDK を使用するスクリプトで、デプロイ済みの Agent Runtime インスタンスを更新します。 ここではコーヒーエージェントのユースケースに合わせて、コーヒーに関する情報を重点的に抽出するための custom トピックを定義します。具体的には、以下の5つのトピックを Memory Bank に登録します。 トピック 種類 抽出対象 USER_PREFERENCES managed ユーザーの一般的な嗜好 coffee_taste_preferences custom 好む / 苦手な味わいの傾向(酸味・苦味・フレーバーノート・焙煎度合いなど) brewing_methods custom 普段使用している抽出方法と器具 favorite_beans_and_origins custom 好みの豆の産地・品種・銘柄・ロースター coffee_habits_and_restrictions custom 飲用習慣やカフェイン制限などの制約 また、日本語での会話における検索の品質を高めるため、 similarity_search_config で多言語対応の埋め込みモデル gemini-embedding-001 を指定します。 スクリプトの内容は以下のようになります。 client.agent_engines.update() に context_spec.memory_bank_config.customization_configs を渡すことで、デプロイ済みの Agent Runtime インスタンスに対してカスタマイズ設定のみを反映できます。 import os import vertexai from vertexai.types import ( MemoryBankCustomizationConfig as CustomizationConfig, MemoryBankCustomizationConfigMemoryTopic as MemoryTopic, MemoryBankCustomizationConfigMemoryTopicCustomMemoryTopic as CustomMemoryTopic, MemoryBankCustomizationConfigMemoryTopicManagedMemoryTopic as ManagedMemoryTopic, ManagedTopicEnum, ) client = vertexai.Client( project=os.getenv( "PROJECT_ID" ), location=os.getenv( "LOCATION" , "asia-northeast1" ), ) # Memory Bank に保存する記憶のトピック定義 memory_topics = [ # ユーザーの好み(managed トピック) MemoryTopic( managed_memory_topic=ManagedMemoryTopic( managed_topic_enum=ManagedTopicEnum.USER_PREFERENCES ) ), # 味わいの好み MemoryTopic( custom_memory_topic=CustomMemoryTopic( label= "coffee_taste_preferences" , description=( "ユーザーが好む / 苦手なコーヒーの味わいの傾向。" "酸味・苦味・甘み・ボディ感、フレーバーノート(フルーティ、" "ナッティ、チョコレートなど)、焙煎度合い(浅煎り / 中煎り / 深煎り)。" ), ) ), # 抽出方法・器具 MemoryTopic( custom_memory_topic=CustomMemoryTopic( label= "brewing_methods" , description=( "ユーザーが普段使っている、または興味のあるコーヒーの抽出方法と器具。" "ハンドドリップ、エスプレッソ、フレンチプレス、エアロプレス、サイフォンなど、" "および使用しているグラインダーやドリッパーなどの器具情報。" ), ) ), # 好みの豆・産地 MemoryTopic( custom_memory_topic=CustomMemoryTopic( label= "favorite_beans_and_origins" , description=( "ユーザーが好む / 過去に飲んだコーヒー豆の産地・品種・銘柄・ロースター。" "例: エチオピア イルガチェフェ、ゲイシャ、ブルーマウンテン、特定のロースター名など。" ), ) ), # 飲用習慣・カフェイン制限 MemoryTopic( custom_memory_topic=CustomMemoryTopic( label= "coffee_habits_and_restrictions" , description=( "ユーザーのコーヒーの飲用習慣(1日の杯数、飲む時間帯)、" "カフェイン制限の有無、デカフェ志向、乳製品アレルギーや代替ミルクの好みなど。" ), ) ), ] customization_config = CustomizationConfig(memory_topics=memory_topics) # 類似性検索に使用する埋め込みモデル(多言語対応の gemini-embedding-001 を指定) project = os.getenv( "PROJECT_ID" ) location = os.getenv( "LOCATION" , "asia-northeast1" ) embedding_model = ( f "projects/{project}/locations/{location}/publishers/google/models/gemini-embedding-001" ) # 既存の Agent Runtime を Memory Bank カスタマイズ付きで更新 resource_name = os.environ[ "RESOURCE_NAME" ] agent_engine = client.agent_engines.update( name=resource_name, config={ "context_spec" : { "memory_bank_config" : { "customization_configs" : [customization_config], "similarity_search_config" : { "embedding_model" : embedding_model, }, }, }, }, ) print ( "Memory Bank customization applied." ) print (f "Resource Name: {agent_engine.api_resource.name}" ) カスタマイズの適用 スクリプトを実行する際は、デプロイ済みの Agent Runtime のリソース名( projects/<プロジェクトID>/locations/<ロケーション>/reasoningEngines/<エージェントID> )を環境変数 RESOURCE_NAME にセットしておきます。 # 環境変数のセット $ export PROJECT_ID = < プロジェクトID > $ export LOCATION =asia-northeast1 $ export RESOURCE_NAME = < エージェントのリソース名 > # カスタマイズの適用 $ uv run python update.py スクリプトの実行が成功すると、Agent Runtime インスタンスに Memory Bank のカスタマイズ設定が反映されます。 コンソールからは、類似性検索に使用する埋め込みモデルが gemini-embedding-001 に更新されていることが確認できます。 類似性検索に使用するモデルが変更されている カスタマイズ後の動作確認 Cloud Run にデプロイしたチャットボットにログインし、「私は酸味が美味しいコーヒーが好きで、コスタリカ、パナマ、ケニア、エチオピアが特に好みです。ハンドドリップで1日に4杯ほど飲みます。好みに近いおすすめの豆を教えてください」のような内容でメッセージを送信してみます。 Memory Bank を確認すると、設定したトピックごとに記憶が作成されていることがわかります。 設定したトピックごとの記憶が Memory Bank に記録される 佐々木 駿太 (記事一覧) G-gen 最北端、北海道在住のクラウドソリューション部エンジニア 2022年6月に G-gen にジョイン。Google Cloud Partner Top Engineer に選出(2024 / 2025 Fellow / 2026)。好きな Google Cloud プロダクトは Cloud Run。 趣味はコーヒー、小説(SF、ミステリ)、カラオケなど。 Follow @sasashun0805

動画

書籍