LangGraphとは何か
LangChain学習済みの方向けに解説
なぜLangGraphを学ぶのか?
AIエージェントを作るとき、こんな経験はありませんか?
「ユーザーの質問によって、違う処理をしたい...」
「複数のAIを連携させて、もっと賢いシステムを作りたい...」
「エラーが起きたら、リトライしたい...」
LangGraphは、これらすべてをシンプルなコードで実現できるフレームワークです。
LangChainだけでは限界がある
LangChainのLCELは直線的な処理に強いです:
# LCEL: パイプライン(直線的な流れ) # 入力 → プロンプト → LLM → パーサー → 出力 chain = prompt | llm | output_parser # 実行(シンプル!) result = chain.invoke({"question": "今日の天気は?"})
この「パイプライン」は簡単で分かりやすいですが...
- 条件分岐:「天気の質問」と「ニュースの質問」で違う処理をしたい
- ループ:ユーザーが満足するまで繰り返したい
- 並列処理:複数のAPIを同時に呼びたい
- 状態管理:会話履歴を持ち回りたい
→ これらを解決するのがLangGraphです!
LangGraphの3要素:State・Node・Edge
基礎概念の深堀り
State, Node, Edgeを詳しく理解する
State(状態)とは?
Stateは全頂点(Node)で共有されるデータ構造です。
from typing import TypedDict class ChatState(TypedDict): """全頂点で共有される状態""" user_message: str # ユーザーの質問 intent: str # 意図分類結果 response: str # 最終回答
Stateは「会社を回覧するクリップボード」のようなもの。
各社員(Node)が順番に自分の担当欄を埋めて、次の人に回していきます。
最後には全欄が埋まった完成版になります。
Node(頂点)とは?
NodeはStateを受け取り、更新内容を返す関数です。
def router_node(state: ChatState) -> dict: """ユーザーの意図を分類する頂点""" msg = state["user_message"] # 意図を判定 if "データ" in msg: intent = "data_query" else: intent = "general" # intentだけを返す(部分更新) return {"intent": intent}
戻り値は「部分更新」。更新したいフィールドだけ返せばOK!
Edge(辺)とは?
Edgeは頂点間の接続を定義します。
| 種類 | 用途 | コード |
|---|---|---|
| 通常の辺 | 常に同じ頂点へ | add_edge("A", "B") |
| 条件分岐の辺 | 条件で分岐 | add_conditional_edges(...) |
最小構成で理解する
2頂点だけの最小構成
最もシンプルなLangGraph
# ========================================== # 🏢 最小構成のLangGraph(2人の社員がいる会社) # ========================================== from typing import TypedDict from langgraph.graph import StateGraph, START, END # ========================================== # 📋 Step 1: 回覧ファイル(State)を作る # → 社員間で共有する「クリップボード」の項目を定義 # ========================================== class SimpleState(TypedDict): message: str # 📥 最初に書いてある情報(入力) result: str # 📝 社員が記入する欄(出力) # ========================================== # 👤 Step 2: 社員(Node)を定義する # → 回覧ファイルを受け取り、自分の担当欄を埋めて返す # ========================================== def process_node(state: SimpleState) -> dict: """ 👤 1人目の社員:加工担当の田中さん 回覧ファイルの「message」を読んで、「result」欄に記入する """ msg = state["message"] # 📋 回覧ファイルから読む processed = f"処理済み: {msg}" # 🔧 自分の仕事をする return {"result": processed} # 📝 result欄に記入して返す def output_node(state: SimpleState) -> dict: """ 👤 2人目の社員:報告担当の鈴木さん 回覧ファイルの「result」を見て、最終報告する """ print(f"最終結果: {state['result']}") # 📢 報告する return {} # 記入する欄がないので空で返す # ========================================== # 🏢 Step 3: 会社(Graph)を組み立てる # → 社員を登録し、回覧ルートを決める # ========================================== workflow = StateGraph(SimpleState) # 🏢 会社を作る(回覧ファイルの形式を指定) # 👤 社員を登録 workflow.add_node("process", process_node) # 田中さんを「process」という名前で登録 workflow.add_node("output", output_node) # 鈴木さんを「output」という名前で登録 # 🔀 回覧ルートを設定(業務フロー) workflow.add_edge(START, "process") # 開始 → 田中さん workflow.add_edge("process", "output") # 田中さん → 鈴木さん workflow.add_edge("output", END) # 鈴木さん → 終了 graph = workflow.compile() # 🚀 会社を稼働開始!
Python命名規則(PEP8)- チームで統一しよう
上記コードで使われている命名規則は、Pythonの標準スタイル「PEP8」に従っています。チーム開発では必ず統一しましょう。
| 種類 | 命名規則 | 例 |
|---|---|---|
| 関数(def) | snake_case小文字 + アンダースコア |
process_node, get_user_input |
| クラス(class) | PascalCase各単語の先頭を大文字 |
SimpleState, UserProfile |
| 変数 | snake_case関数と同じ |
user_input, workflow |
| 定数 | UPPER_SNAKE_CASE全て大文字 + アンダースコア |
MAX_RETRIES, API_KEY |
| プライベート | _prefix先頭にアンダースコア1つ |
_internal_func, _private_var |
💡 覚え方のコツ:
- 関数・変数は「動詞っぽい」→
snake_case(小文字でつなぐ) - クラスは「名詞っぽい」→
PascalCase(頭文字大文字) - 定数は「変わらない=強調」→
UPPER_CASE(全部大文字)
# OK: 更新したいフィールドだけ返す return {"result": processed} # NG: 全部返す必要はない # return {"message": state["message"], "result": processed} # messageは自動的に引き継がれる!
条件分岐を追加する
条件によって処理を分ける
条件分岐の実装
from typing import Literal # --- 条件分岐の関数 --- def route_by_intent(state: ChatState) -> Literal["weather", "time", "general"]: """intentに基づいて次の頂点を決定""" intent = state["intent"] if intent == "weather": return "weather" elif intent == "time": return "time" else: return "general" # --- 条件分岐の辺を追加 --- workflow.add_conditional_edges( "classifier", # 分岐元の頂点 route_by_intent, # 条件関数 { # 戻り値 → 行き先の頂点 "weather": "weather", "time": "time", "general": "general", } )
State管理の応用
データの受け渡しをマスターしよう
まず復習:Stateって何だっけ?
Stateは「社員間で回す回覧ファイル」でしたね。各Node(社員)がこのファイルを読み書きして情報を共有します。
✅ 会社のたとえ
- 回覧ファイル = State
- ファイルの項目 = フィールド
- 社員が記入 = Nodeの戻り値
💻 コードのたとえ
- State = TypedDict
- フィールド = key: 型
- 戻り値 = return {"key": 値}
from typing import TypedDict # 📋 回覧ファイルの項目を定義 class SimpleState(TypedDict): user_input: str # ユーザーからの質問 response: str # AIの回答 # 👤 社員(Node)が回覧ファイルに記入 def answer_node(state: SimpleState) -> dict: question = state["user_input"] # 読む answer = f"{question}への回答です" return {"response": answer} # 書く(responseを更新) # ↑ user_inputは書かない → 自動で引き継がれる
困った!会話履歴が消えてしまう問題
チャットボットを作るとき、会話履歴を保持したいですよね。でも普通に書くと...
class ChatState(TypedDict): user_input: str history: list # 会話履歴 response: str def chat_node(state: ChatState) -> dict: new_message = {"role": "user", "content": state["user_input"]} # ❌ これだと古い履歴が消える! return { "history": [new_message], # 新しいメッセージだけになる "response": "回答..." } # 実行するとこうなる... # 1回目: history = [{"role": "user", "content": "こんにちは"}] ← OK # 2回目: history = [{"role": "user", "content": "元気?"}] ← 1回目が消えた! # 3回目: history = [{"role": "user", "content": "天気は?"}] ← 2回目も消えた! # 本当は欲しい結果: # history = [1回目, 2回目, 3回目] ← 全部残したい!
😱 なぜ消える?
LangGraphのデフォルト動作は「上書き」です。return で返した値がそのままStateに入り、古い値は消えます。
解決策:Annotated + reducer で「追加モード」にする
「上書き」ではなく「追加」したい場合は、Annotatedを使います。
💡 覚え方
Annotated[型, どう処理するか]
「この項目は○○して処理してね」という指示書きをつけるイメージ
from typing import TypedDict, Annotated from operator import add # リストを結合する関数 class ChatState(TypedDict): user_input: str # ↓ ここがポイント!「list型で、addで処理してね」 history: Annotated[list, add] response: str def chat_node(state: ChatState) -> dict: new_message = {"role": "user", "content": state["user_input"]} # ✅ これで既存履歴 + 新メッセージになる! return { "history": [new_message], # 追加したいものだけ返せばOK "response": "回答..." } # 実行結果: # 1回目: history = [1回目のメッセージ] # 2回目: history = [1回目, 2回目] ← 追加された! # 3回目: history = [1回目, 2回目, 3回目] ← 全部残ってる!
📊 動作の違いを図解
❌ 普通の list(上書き)
↓ return {"history": [C]}
結果: [C] ← A,Bが消えた
✅ Annotated[list, add](追加)
↓ return {"history": [C]}
結果: [A, B, C] ← 追加された
よく使うStateパターン一覧(コピペ用)
実務でよく使うパターンをまとめました。必要に応じてコピペしてください。
from typing import TypedDict, Annotated, Literal from operator import add # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # パターン①:シンプルなQ&A(履歴なし) # → 1回の質問に1回答えるだけのシンプルなもの # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ class SimpleQAState(TypedDict): question: str # ユーザーの質問 answer: str # AIの回答 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # パターン②:チャットボット(履歴あり) # → 会話の流れを覚えておきたい場合 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ class ChatbotState(TypedDict): user_input: str # 今回の入力 messages: Annotated[list, add] # 会話履歴(追加される) response: str # 今回の回答 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # パターン③:分類・振り分け # → 質問の種類によって処理を変えたい場合 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ class RouterState(TypedDict): user_input: str # Literalで選択肢を限定(タイポ防止) category: Literal["質問", "雑談", "クレーム"] response: str # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # パターン④:RAG(検索拡張生成) # → ドキュメント検索してから回答する場合 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ class RAGState(TypedDict): query: str # ユーザーの質問 retrieved_docs: list # 検索で見つけた文書 context: str # 文書を整形したもの answer: str # 最終回答 sources: list # 参照元(出典) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # パターン⑤:エージェント(ツール使用) # → 必要に応じてツールを呼び出す場合 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ class AgentState(TypedDict): user_input: str messages: Annotated[list, add] # 会話履歴 tool_calls: list # 呼び出すツール tool_results: Annotated[list, add] # ツールの結果(追加) final_response: str # 最終回答 iteration_count: int # ループ回数 max_iterations: int # 最大ループ回数
応用:カスタムreducer(履歴を制限する)
addだと履歴が無限に増えてしまいます。直近15件だけ残したい場合は、自分で処理を書きます。
# 自分で「どう処理するか」を関数で定義 def keep_recent_15(existing: list, new: list) -> list: """既存 + 新規を結合し、直近15件だけ残す""" combined = (existing or []) + (new or []) return combined[-15:] # 後ろから15件 # 使い方:addの代わりに自分の関数を指定 class ChatState(TypedDict): user_input: str messages: Annotated[list, keep_recent_15] # ←ここ response: str # 動作: # [1,2,3...14,15] + [16] → [2,3,4...15,16](1が消える) # 常に15件以内をキープ!
💡 なぜ履歴を制限する?
- LLMにはトークン上限がある(履歴が長すぎるとエラー)
- コスト削減(トークン数で課金される)
- レスポンス速度の向上
応用:構造化出力(LLMの回答を整理整頓)
LLM(AI)の回答は通常「文字列」で返ってきます。でも実務では「決まった形式」で欲しいことが多いですよね。
😱 困る例:LLMの回答がバラバラ
質問: 「会議の議題を抽出して」
回答1: 「議題は予算と人事です」
回答2: 「1. 予算 2. 人事」
回答3: 「予算, 人事」
→ 毎回違う形式...プログラムで処理しづらい!
✅ 解決策:構造化出力
「この形式で回答してね」とLLMに伝えると、必ずその形式で返してくれる機能です。
形式を指定すると...
回答: {"topics": ["予算", "人事"], "main_topic": "予算"}
→ 毎回同じ形式!プログラムで扱いやすい!
Step 1: 欲しい形式を定義する(Pydantic)
まず「こういう形式で欲しい」をPydanticというライブラリで定義します。
from pydantic import BaseModel, Field from typing import List # 「こういう形式で返してね」という設計図 class MeetingTopics(BaseModel): """会議の議題抽出結果""" # topics: 議題のリスト(文字列の配列) topics: List[str] = Field( description="抽出された議題のリスト" # LLMへのヒント ) # main_topic: 最重要議題(文字列1つ) main_topic: str = Field( description="最も重要な議題" )
💡 Pydanticとは?
「データの形式を定義する」Pythonライブラリです。TypedDictに似ていますが、より多機能(バリデーション等)です。
Step 2: LLMに形式を伝えて呼び出す
from langchain_google_genai import ChatGoogleGenerativeAI # 1. LLMを用意 llm = ChatGoogleGenerativeAI(model="gemini-1.5-flash") # 2. 「この形式で返してね」とLLMに伝える structured_llm = llm.with_structured_output(MeetingTopics) # ↑ さっき定義した形式 # 3. 普通に呼び出す result = structured_llm.invoke("今日の会議では予算と人事について話し合いました。予算が最重要です。") # 4. 結果は指定した形式で返ってくる! print(result.topics) # ['予算', '人事'] ← リスト形式 print(result.main_topic) # '予算' ← 文字列 # ドット記法でアクセスできる! # result["topics"] ではなく result.topics # ↑ これがPydanticの便利なところ
LangGraphのNodeで使う場合
実際のグラフ内で使う場合はこうなります:
# Stateの定義 class MeetingState(TypedDict): meeting_text: str # 入力:会議テキスト topics: list # 出力:議題リスト main_topic: str # 出力:最重要議題 # 議題抽出Node def extract_topics_node(state: MeetingState) -> dict: """会議テキストから議題を抽出""" # 構造化出力を設定したLLM structured_llm = llm.with_structured_output(MeetingTopics) # LLMを呼び出し result = structured_llm.invoke(state["meeting_text"]) # Stateを更新(resultから必要な値を取り出す) return { "topics": result.topics, # リストがそのまま入る "main_topic": result.main_topic # 文字列がそのまま入る }
✅ 構造化出力を使うメリット
- 安定した出力: 毎回同じ形式で返ってくる
- パース不要: JSON文字列を自分で解析しなくていい
- タイポ防止:
result.topicsと書けば補完が効く - エラー防止: 形式が違うとエラーになるので気づける
⚠️ 注意点(最初は気にしなくてOK)
- 対応モデル: Gemini 1.5、GPT-4、Claude 3など最新モデルが必要
- 複雑すぎる形式は精度が下がることがある
実践チュートリアル
カスタマーサポートAIを一から作る
このチュートリアルで作るもの
実際に動く議事録要約AIを作ります。会議テキストから構造化された議事録を自動生成!
- 会議テキストを自動要約(300文字程度)
- 主要な議題を抽出(リスト形式)
- アクションアイテムを自動抽出(担当者・期限付き)
- 長い会議は詳細モード、短い会議は簡易モードで自動切り替え
Step 1: 環境準備
「議事録要約AI」を作ります。会議テキスト → 要約・議題・アクションを自動抽出します。
# ============================================ # 必要なライブラリをインストール # langgraph: グラフ構築の本体 # どちらか1つを選んでください(両方入れてもOK) # ============================================ !pip install -q langgraph langchain-google-genai # ← Gemini API版 # !pip install -q langgraph langchain-google-vertexai # ← Vertex AI版
🔧 LLMの設定(どちらか1つを選択)
取得方法: Google AI Studio で無料発行(1分で完了)
# ============================================ # 方式A: Gemini API(APIキーで認証) # ============================================ from google.colab import userdata import os # Colabのシークレットから取得(左サイドバーの🔑アイコン) os.environ["GOOGLE_API_KEY"] = userdata.get("GOOGLE_API_KEY") # LLMを初期化 from langchain_google_genai import ChatGoogleGenerativeAI llm = ChatGoogleGenerativeAI(model="gemini-1.5-flash") print("✅ Gemini API 設定完了")
必要なもの: GCPプロジェクト + Vertex AI APIの有効化
# ============================================ # 方式B: Vertex AI(GCP認証) # ============================================ from google.colab import auth auth.authenticate_user() # ★★★ あなたのプロジェクトIDに書き換え ★★★ PROJECT_ID = "your-project-id" LOCATION = "asia-northeast1" # LLMを初期化 from langchain_google_vertexai import ChatVertexAI llm = ChatVertexAI( model="gemini-1.5-flash", project=PROJECT_ID, location=LOCATION ) print(f"✅ Vertex AI 設定完了: {PROJECT_ID}")
どちらの方式でも llm 変数にLLMが入るので、Step 2以降のコードはそのまま動きます。
迷ったら方式A(Gemini API)がおすすめ。1分で始められます。
このチュートリアルは1つのノートブック内でセルを分けて進めます。
Step 2〜6のコードを順番にセルに貼り付けて実行するだけでOK!
Colabで動作確認した後、Cloud Runにデプロイする際はファイルを分割します。
詳しくは「デプロイ」タブを参照してください。
Step 2: Stateを定義する
Stateはグラフ全体で共有するデータの入れ物です。各Nodeがこれを読み書きします。
""" State定義ファイル 【このファイルの役割】 LangGraphで処理するデータの「型」を定義します。 TypedDictを使うと、IDEの補完が効いて開発が楽になります。 """ from typing import TypedDict, Literal, Annotated, List from operator import add # リストを「追加」するためのreducer class MeetingState(TypedDict): """ 議事録要約AIの状態(State) 【設計のポイント】 1. 入力: 最初に与えるデータ(raw_text) 2. 中間: 処理途中で埋まるデータ(summary, is_long) 3. 出力: 最終的に取り出すデータ(topics, action_items, final_doc) """ # ============================ # 入力データ(ユーザーが与える) # ============================ raw_text: str # 会議の生テキスト(Zoom文字起こし等) # ============================ # 中間データ(Nodeが埋めていく) # ============================ summary: str # 要約結果(300文字程度) is_long: bool # 長い会議か?(条件分岐に使用) topics: List[str] # 議題リスト(例: ["予算", "スケジュール"]) action_items: List[str] # アクションアイテム(例: ["田中: 見積作成"]) # ============================ # 出力データ(最終成果物) # ============================ final_doc: str # 整形された議事録(Markdown形式) # ============================ # 処理ログ(デバッグ用) # Annotated[List, add] = 新しい値が「追加」される # → 各Nodeが ["[要約完了]", "[議題抽出完了]"] と追加していく # ============================ logs: Annotated[List[str], add]
| フィールド | 役割 | 誰が設定? |
|---|---|---|
raw_text |
生の会議テキスト | ユーザー(最初に渡す) |
summary |
要約テキスト | summarize_node |
is_long |
長い会議フラグ | check_length_node |
topics |
議題リスト | extract_topics_node |
action_items |
アクション一覧 | extract_actions_node |
final_doc |
最終議事録 | format_doc_node |
- State設計は最初にしっかり考える! 後から追加は簡単だが、削除は既存Nodeに影響
logsのようなAnnotated[List, add]は、各Nodeがログを「追記」できて便利- 実務では
error_messageやretry_countも入れておくとエラーハンドリングしやすい
LangGraphでは TypedDict(上記の例)と BaseModel(Pydantic)の両方でStateを定義できます。
用途に応じて使い分けましょう。
| 方式 | メリット | デメリット |
|---|---|---|
TypedDict |
シンプル、軽量、公式ドキュメントで多用 | バリデーションなし |
BaseModel |
Field()で説明・デフォルト値・バリデーション可能 | やや冗長 |
from typing import TypedDict class MeetingState(TypedDict): raw_text: str summary: str is_long: bool
from pydantic import BaseModel, Field class MeetingState(BaseModel): raw_text: str = Field( default="", description="会議の生テキスト" ) summary: str = Field( default="", description="要約結果" ) is_long: bool = Field( default=False, description="長い会議フラグ" )
- 学習・プロトタイプ → TypedDict(シンプルで始めやすい)
- 本番・チーム開発 → BaseModel(descriptionでドキュメント化、バリデーションで堅牢)
- Annotated[..., add] はどちらでも使用可能
Step 3: Nodeを定義する
NodeはStateを受け取り、更新内容を返す関数です。議事録AIでは「要約」「議題抽出」「アクション抽出」の3つのNodeを作ります。
3-1. 要約ノード
""" Node定義ファイル 【このファイルの役割】 各処理(要約、議題抽出など)を個別の関数として定義します。 関数は必ず「Stateを受け取り、更新する部分だけをdictで返す」形式です。 """ from langchain_google_vertexai import ChatVertexAI from langchain_core.prompts import ChatPromptTemplate from state import MeetingState # Step 2で作ったStateをimport # ============================================ # LLMの初期化(全Nodeで共有) # temperature=0: 出力を安定させる(毎回同じ結果) # ============================================ llm = ChatVertexAI( model="gemini-1.5-flash", # 高速・低コスト temperature=0 # 創造性を抑えて安定した出力 ) def summarize_node(state: MeetingState) -> dict: """ 【要約ノード】 会議テキスト → 300文字程度の要約 なぜ要約から始める? → 長い会議テキストをそのまま後続処理に渡すと、 トークン数が増えてコストが上がる&精度が下がる """ # プロンプトを定義(systemとhumanの2つのメッセージ) prompt = ChatPromptTemplate.from_messages([ ("system", """あなたは議事録作成のプロです。 以下の会議内容を300文字程度で要約してください。 - 重要な決定事項を優先 - 具体的な数字や期日は必ず含める - 箇条書きではなく文章で"""), ("human", "{text}") # {text}に実際のテキストが入る ]) # LangChain式: prompt | llm で「チェーン」を作成 chain = prompt | llm # Stateから生テキストを取り出してLLMに渡す result = chain.invoke({"text": state["raw_text"]}) # 更新したいフィールドだけをdictで返す # → LangGraphが自動でStateにマージしてくれる return { "summary": result.content, "logs": ["[summarize] 要約完了"] }
3-2. 長さチェックノード(check_length_node)
LLMを使わない処理もNodeにできます。条件分岐に使うフラグを設定します。
def check_length_node(state: MeetingState) -> dict: """ 【長さチェックノード】 会議テキストの長さで「詳細モード or 簡易モード」を判定 なぜ分岐が必要? → 長い会議: 議題・アクション抽出をしっかり → 短い会議: シンプルに要約だけでOK """ # 2000文字以上なら「長い会議」と判定 text_length = len(state["raw_text"]) is_long = text_length > 2000 print(f"[check_length] テキスト長: {text_length}文字 → {'詳細モード' if is_long else '簡易モード'}") return { "is_long": is_long, "logs": [f"[check_length] {text_length}文字"] }
3-3. 議題抽出ノード / アクション抽出ノード
同じパターンで複数のNodeを作ります。抽出結果はリストで返します。
💡 補足: 本番環境ではJSON出力が推奨
今回は学習用にシンプルな「改行区切り」を使用していますが、最新のGeminiではresponse_mime_type="application/json"でJSON出力を強制したり、with_structured_output()でPydanticモデルに直接マッピングする方法が推奨されます。これにより型安全性とパース処理の省略が可能です。
def extract_topics_node(state: MeetingState) -> dict: """ 【議題抽出ノード】 要約から主要な議題を抽出(リスト形式) ポイント: LLMの出力を「改行区切り」にしてsplit()で分割 ※今回は学習用にシンプルな実装を採用 """ prompt = ChatPromptTemplate.from_messages([ ("system", """以下の会議要約から、議題を抽出してください。 - 1行に1議題 - 議題のみを出力(番号や記号なし) - 最大5個まで"""), ("human", "{summary}") ]) chain = prompt | llm result = chain.invoke({"summary": state["summary"]}) # 改行で分割してリスト化 topics = [ line.strip() for line in result.content.split("\n") if line.strip() # 空行を除外 ] return { "topics": topics, "logs": [f"[topics] {len(topics)}件抽出"] } def extract_actions_node(state: MeetingState) -> dict: """ 【アクションアイテム抽出ノード】 「誰が」「何を」「いつまでに」を抽出 """ prompt = ChatPromptTemplate.from_messages([ ("system", """以下の会議要約から、アクションアイテムを抽出してください。 形式: 担当者: タスク内容(期限があれば記載) - 1行に1アクション - 具体的なタスクのみ抽出"""), ("human", "{summary}") ]) chain = prompt | llm result = chain.invoke({"summary": state["summary"]}) actions = [line.strip() for line in result.content.split("\n") if line.strip()] return { "action_items": actions, "logs": [f"[actions] {len(actions)}件抽出"] }
3-4. 整形ノード(format_doc_node)
最後に、すべての情報をMarkdown形式に整形します。
from datetime import datetime def format_doc_node(state: MeetingState) -> dict: """ 【整形ノード】 全ての情報をMarkdown形式の議事録に整形 LLMを使わない処理: テンプレートに値を埋め込むだけ → コスト削減&高速化 """ # 議題をMarkdownリストに変換 topics_md = "\n".join([f"- {t}" for t in state.get("topics", [])]) # アクションアイテムをMarkdownリストに変換 actions_md = "\n".join([f"- [ ] {a}" for a in state.get("action_items", [])]) # Markdown形式で議事録を生成 doc = f"""# 議事録 **作成日時**: {datetime.now().strftime('%Y-%m-%d %H:%M')} ## 要約 {state.get('summary', '(要約なし)')} ## 議題 {topics_md or '(議題なし)'} ## アクションアイテム {actions_md or '(アクションなし)'} """ return { "final_doc": doc, "logs": ["[format] 議事録生成完了"] }
Step 4: 条件分岐を定義する
長い会議は詳細モード(議題・アクション抽出)、短い会議は簡易モード(要約のみ)に分岐させます。
""" ルーティング関数 【このファイルの役割】 条件分岐のロジックを定義します。 Stateの値を見て「次にどのNodeに進むか」を返します。 重要: ルーティング関数はStateを「読むだけ」で「更新しない」 → 戻り値は文字列(次のNodeの名前) """ from typing import Literal from state import MeetingState def route_by_length(state: MeetingState) -> Literal["detailed", "simple"]: """ 【長さによるルーティング】 Stateの`is_long`フラグを見て、次の処理を決定する 戻り値の型ヒント Literal["detailed", "simple"] は 「この2つの文字列しか返さない」ことを示す → タイポ防止&IDEの補完が効く """ # check_length_nodeで設定されたフラグを参照 is_long = state.get("is_long", False) if is_long: # 長い会議 → 詳細な議題抽出へ print("[routing] 詳細モードへ分岐") return "detailed" else: # 短い会議 → シンプルな整形へ print("[routing] 簡易モードへ分岐") return "simple"
Step 5: グラフを組み立てる
Nodeを登録し、Edgeで接続して、最後にcompile()で実行可能にします。
""" メインファイル - グラフの組み立てと実行 【このファイルの役割】 1. 各パーツ(State, Node, Routing)をimport 2. グラフを組み立てる 3. 実行する """ from langgraph.graph import StateGraph, START, END # 自作モジュールをimport from state import MeetingState from nodes import ( summarize_node, check_length_node, extract_topics_node, extract_actions_node, format_doc_node, ) from routing import route_by_length def create_meeting_graph(): """ 議事録要約グラフを構築する 【グラフの流れ】 START → summarize → check_length → [分岐] → (長い) extract_topics → extract_actions → format_doc → END → (短い) format_doc → END """ # ============================================ # 1. グラフを作成(Stateの型を指定) # → この型に従ってデータが流れる # ============================================ workflow = StateGraph(MeetingState) # ============================================ # 2. ノードを追加 # add_node("表示名", 関数) # 表示名はエッジ接続時に使う # ============================================ workflow.add_node("summarize", summarize_node) workflow.add_node("check_length", check_length_node) workflow.add_node("extract_topics", extract_topics_node) workflow.add_node("extract_actions", extract_actions_node) workflow.add_node("format_doc", format_doc_node) # ============================================ # 3. エッジを追加(固定の接続) # START: 処理の開始点(特殊なNode) # END: 処理の終了点(特殊なNode) # ============================================ workflow.add_edge(START, "summarize") # 開始 → 要約 workflow.add_edge("summarize", "check_length") # 要約 → 長さチェック # ============================================ # 4. 条件分岐を追加 # add_conditional_edges(分岐元, 条件関数, マッピング) # ============================================ workflow.add_conditional_edges( "check_length", # どのNodeから分岐するか route_by_length, # 条件を判定する関数 { # 条件関数の戻り値 → 次に進むNode "detailed": "extract_topics", # 長い → 議題抽出へ "simple": "format_doc", # 短い → 整形へ直行 } ) # 詳細モードの続き: 議題 → アクション → 整形 workflow.add_edge("extract_topics", "extract_actions") workflow.add_edge("extract_actions", "format_doc") # 最後は全てENDへ workflow.add_edge("format_doc", END) # ============================================ # 5. compile()で実行可能なグラフに変換 # ============================================ return workflow.compile()
- add_nodeは順番不問: 登録順は関係なく、エッジで接続順が決まる
- compile()は最後に1回: 変更するたびに再compile必要
- グラフの可視化:
graph.get_graph().draw_mermaid()で図を出力可能
Step 6: 実行してみる
最後のセルで実行テストを行います。サンプルの会議テキストを入力として議事録を生成します。
# ============================================ # サンプル会議テキスト(長いバージョン) # 実際にはZoomの文字起こしや議事メモを入れる # ============================================ sample_meeting = """ 【プロジェクト進捗会議】2024年1月15日 田中: 今週の進捗を報告します。フロントエンドのUI改修は80%完了。 残りはモバイル対応です。来週水曜までに完了予定。 鈴木: バックエンドのAPI改修は予定より遅れています。 認証周りで問題が発生し、解決に2日かかりました。 来週金曜までには完了できる見込みです。 山田: QAテストの準備は完了しています。 来週月曜からテスト開始できます。 テスト項目は150件、3日間で実施予定です。 田中: 予算について確認です。追加で10万円必要になりそうです。 理由はサーバー増強のため。 鈴木: 承認します。経理に連絡しておきます。 次回の会議は来週月曜10時から。 """ # ============================================ # グラフを作成して実行 # ============================================ graph = create_meeting_graph() # invoke()で実行(入力はdictで渡す) result = graph.invoke({ "raw_text": sample_meeting, # 必須: 会議テキスト "logs": [] # 必須: 空リストで初期化 }) # ============================================ # 結果を表示 # ============================================ print("=" * 50) print("【生成された議事録】") print("=" * 50) print(result["final_doc"]) print("\n【処理ログ】") for log in result["logs"]: print(f" {log}")
# 議事録 **作成日時**: 2024-01-15 15:30 ## 要約 プロジェクト進捗会議では、フロントエンドUI改修が80%完了し 来週水曜完了予定。バックエンドは認証問題で遅延、来週金曜完了見込み。 QAテストは来週月曜から3日間で150件実施予定。 サーバー増強のため追加予算10万円が承認された。 ## 議題 - フロントエンドUI改修の進捗 - バックエンドAPI改修の遅延 - QAテストの準備状況 - 追加予算の承認 ## アクションアイテム - [ ] 田中: モバイル対応を来週水曜までに完了 - [ ] 鈴木: API改修を来週金曜までに完了 - [ ] 鈴木: 経理に追加予算10万円を連絡 - [ ] 山田: 来週月曜からQAテスト開始
Cloud Runへのデプロイ方法は「デプロイ」タブを参照してください。
LangGraphの基本(State・Node・Edge・条件分岐)を学びました。
全Node間で共有。TypedDictで型定義。Annotatedで自動マージ。
Stateを受け取り、部分更新を返す関数。
Stateの値を見て、次のNodeを決定。
add_node → add_edge → add_conditional_edges → compile
Cloud Run Functions へデプロイ
実践で作った議事録AIを本番APIにする
このセクションでやること
実践チュートリアル(タブ6)で作った「議事録要約AI」をクラウドにアップして、
誰でもアクセスできるAPIにします。
💡 上から順番にコピペすれば動きます
実践セクションで作った state.py、nodes.py、routing.py はそのまま使います。
追加するのは config.py と、main.py の改造だけです。
30秒でわかる通信の基礎(たとえ話)
専門用語が出てくるので、先に「たとえ話」で理解しておきましょう。
🌐 CORS = 「誰からの電話を受けるか」
会社の電話に誰でも出られたら困る → 「この番号からだけ受ける」と設定する
🔑 APIキー = 「合言葉」
合言葉を知っている人だけが入れる → お客さんごとに別の合言葉を発行
📝 JWT = 「本人確認の会員証」
会員証に名前が書いてある → サーバーが「この人は誰か」を確認できる
✒️ 署名 = 「偽造できない実印」
会員証に実印を押す → 偽の会員証を作っても実印がないのでバレる
※ 詳しい仕組みは下で説明します。まずは「こういうものがある」と覚えてください。
通信の流れを完全理解する
HTMLからAPIを呼ぶとき、ブラウザが間に入ります。この流れを理解しましょう。
👥 登場人物
📋 通信の8ステップ
📝 CORSヘッダーの意味
| ヘッダー | 誰が誰に? | 意味 |
|---|---|---|
Access-Control-Allow-Origin |
API→ブラウザ | このドメインからのアクセスを許可するよ |
Access-Control-Allow-Methods |
API→ブラウザ | このHTTPメソッド(POST等)で送っていいよ |
Access-Control-Allow-Headers |
API→ブラウザ | このラベル(Content-Type等)を付けていいよ |
Access-Control-Max-Age |
API→ブラウザ | この許可を○秒間キャッシュして |
📦 Content-Typeとは?
「送るデータの形式が何か」をサーバーに教えるラベル
宅配便の「割れ物注意」「冷蔵」ラベルのようなもの
application/json → 「これはJSONデータです」text/html → 「これはHTMLです」image/png → 「これは画像です」
Access-Control-Allow-Origin: "*" は「誰でもAPIを使える」設定です。
本番では "https://your-app.web.app" など特定ドメインのみに制限しましょう。
Step 1: フォルダ構成(実践セクションから引き継ぎ)
実践で作ったファイル + デプロイ用の設定ファイルを追加します。
meeting_summarizer/ │ │ ── 実践セクションで作成済み ── ├── state.py # State定義(そのまま使う) ├── nodes.py # Node関数(そのまま使う) ├── routing.py # ルーティング(そのまま使う) │ │ ── このセクションで追加 ── ├── config.py # 🆕 設定・環境変数・認証 ├── main.py # 🔧 API用に改造 ├── requirements.txt # 🔧 ライブラリ追加 │ │ ── 認証方式に応じて追加 ── └── auth.py # Firebase認証を使う場合のみ
Step 2: 各ファイルをコピペ(上から順番に)
以下のファイルを上から順番にコピペしてください。
① requirements.txt(ライブラリ追加)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # Cloud Run Functions に必要なライブラリ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ functions-framework==3.* # Cloud Run Functions用 langgraph>=0.2.0 # 実践で使用 langchain-google-vertexai>=1.0.0 # Gemini API google-cloud-aiplatform>=1.38.0 # Vertex AI
② config.py(🆕 新規作成)
💡 このファイルの役割
設定を1箇所にまとめる「司令塔」
→ コードを変えずにCloud Consoleで設定変更できる
⚠️ 2種類の「APIキー」を混同しないで!
発行元: Google AI Studio または Vertex AI
誰が使う: あなたのサーバー(Cloud Run)がGoogleに対して使う
今回の設定: Vertex AIを使うため不要(GCPの認証で代用)
発行元: あなた自身が自由に決める
誰が使う: ユーザー(HTMLサイト)があなたのAPIに対して使う
今回の設定:
API_KEYS 環境変数で設定
┌──────────────┐ X-API-Key: "abc123" ┌──────────────┐ GCP認証(自動) ┌──────────────┐ │ HTMLサイト │ ──────────────────────→ │ Cloud Run │ ─────────────────→ │ Gemini │ │ (ユーザー) │ あなたのAPIキー │ (あなた) │ Vertex AI認証 │ (Google) │ └──────────────┘ └──────────────┘ └──────────────┘
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # config.py - 環境変数管理 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ★★★ Cloud Consoleの環境変数で設定を切り替える ★★★ # # 【たとえ話】 # このファイルは「受付マニュアル」のようなもの # → 「合言葉はこれ」「この番号からだけ電話を受けて」など # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ import os # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # GCP基本設定 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ PROJECT_ID = os.getenv("PROJECT_ID") LOCATION = os.getenv("LOCATION", "asia-northeast1") # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # APIキー認証(合言葉方式)★★★ 重要 ★★★ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 許可するAPIキーをカンマ区切りで設定 # 例: "key-abc123,key-xyz789"(お客さんごとに発行) API_KEYS = os.getenv("API_KEYS", "").split(",") API_KEYS = [k.strip() for k in API_KEYS if k.strip()] # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # CORS設定(誰からの電話を受けるか) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 開発時: "*"(誰でもOK) # 本番時: "https://your-app.web.app"(特定サイトのみ) CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",") CORS_ORIGINS = [o.strip() for o in CORS_ORIGINS if o.strip()] # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 認証チェック関数(合言葉が合ってるか確認) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def is_allowed_user(api_key: str) -> bool: """APIキーが許可リストにあるか確認""" # APIキーが未設定なら全員許可(開発用) if not API_KEYS: return True # 合言葉が合ってるかチェック return api_key in API_KEYS
⚠️ Cloud Consoleでの設定例
| 環境変数名 | 設定例 | 説明 |
|---|---|---|
PROJECT_ID |
my-gcp-project | GCPプロジェクトID |
API_KEYS |
key-abc123,key-xyz789 | 許可する合言葉(カンマ区切り) |
CORS_ORIGINS |
*(開発)/ https://myapp.web.app(本番) | 許可する呼び出し元 |
③ state.py、nodes.py、routing.py(✅ 実践で作成済み)
✅ そのまま使えます!
実践チュートリアル(タブ6)で作成した3つのファイルはそのまま使用します。
コピーして同じフォルダに配置してください。
state.py- MeetingStateの定義nodes.py- summarize_node, extract_topics_node などrouting.py- route_by_length
④ main.py(🔧 API用に改造)
💡 このファイルの役割(たとえ話)
実践で作った「議事録AI」を「受付窓口」として公開します。
→ CORS確認(電話番号)→ APIキー確認(合言葉)→ グラフ実行
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # main.py - 議事録要約AI(Cloud Run Functions版) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ★★★ 実践セクションで作ったグラフをAPI化 ★★★ # # 会議テキスト(raw_text) # ↓ # ┌─────────────────┐ # │ 1. CORS処理 │ ← 誰からの電話を受けるか # └─────────────────┘ # ↓ # ┌─────────────────┐ # │ 2. 認証チェック │ ← 合言葉は合ってる? # └─────────────────┘ # ↓ # ┌─────────────────┐ # │ 3. グラフ実行 │ ← 実践で作ったやつ! # └─────────────────┘ # ↓ # 議事録(final_doc) # # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ import functions_framework from flask import jsonify from graph import create_graph # 実践で作ったグラフ from config import CORS_ORIGINS, is_allowed_user # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # グラフを初期化(コールドスタート時に1回だけ) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ graph = create_graph() # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # CORS設定(誰からの電話を受けるか) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def get_cors_headers(): origin = CORS_ORIGINS[0] if CORS_ORIGINS else "*" return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, X-API-Key", } # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # エントリーポイント(★★★ 削除禁止 ★★★) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @functions_framework.http def main(request): """Cloud Run Functions のエントリーポイント""" headers = get_cors_headers() # ━━━ 1. CORSプリフライト ━━━ # ブラウザが「この電話番号に電話していい?」と確認してくる if request.method == "OPTIONS": return ("", 204, headers) # ━━━ 2. 認証チェック(合言葉の確認) ━━━ api_key = request.headers.get("X-API-Key", "") if not is_allowed_user(api_key): return (jsonify({"error": "APIキーが無効です"}), 403, headers) # ━━━ 3. 議事録要約を実行 ━━━ return handle_summarize(request, headers) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 議事録要約API(実践セクションのグラフを呼び出す) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ def handle_summarize(request, headers): try: # リクエストから会議テキストを取得 request_json = request.get_json(silent=True) meeting_text = request_json.get("meeting_text", "") if request_json else "" if not meeting_text: return (jsonify({"error": "meeting_textが必要です"}), 400, headers) # ★★★ 実践で作ったグラフを実行 ★★★ # MeetingStateのraw_textに会議テキストを渡す result = graph.invoke({ "raw_text": meeting_text # ← 実践で定義したState }) # 結果を返す(final_docが整形された議事録) return (jsonify({ "summary": result.get("summary", ""), "topics": result.get("topics", []), "action_items": result.get("action_items", []), "final_doc": result.get("final_doc", "") }), 200, headers) except Exception as e: return (jsonify({"error": str(e)}), 500, headers)
✅ ポイント(実践セクションと連動)
- 実践で作ったグラフをそのまま使用: graph.pyのcreate_graph()を呼び出し
- MeetingStateを使用: raw_textを入力、final_docを出力
- たとえ話コメント: CORS=電話、認証=合言葉
- APIキー認証: X-API-Keyヘッダーで認証
❄️ コールドスタートと遅延初期化
graph = create_graph() をファイルのトップレベルで書いているのはなぜ?
→
graph = create_graph() が1回だけ実行→ 少し時間がかかる(数秒)
→ 関数だけ実行
→ 高速!(数百ミリ秒)
メリット: 重い処理(グラフ構築)を1回だけにして、2回目以降を高速化
📨 レスポンスの返し方を理解する
Cloud Run APIからブラウザへの「手紙」の書き方です。
return ("", 204, headers)
""本文は空っぽ(確認だけなので)
204「OK、でも中身はないよ」
headers許可証(CORSヘッダー)
return (jsonify({...}), 200, headers)
jsonify({...})本文(JSON形式の結果)
200「OK、中身あるよ」
headers許可証(これも必要!)
ブラウザは結果を受け取るときも「この結果をHTMLに渡していいか」をチェックします。
許可証なし → ブラウザがブロック → 画面に表示されない
Step 3: GUIでデプロイ(Cloud Console)
コマンドを使わず、GCP Cloud Consoleからデプロイする手順
上部の青い「+ 関数を作成」ボタンをクリック
- 環境: 第2世代(Cloud Run ベース)
- 関数名: meeting-summarizer(議事録要約AI)
- リージョン: asia-northeast1(東京)
- トリガー: HTTPS
- テスト時: 「未認証の呼び出しを許可」にチェック
- 本番時: チェックを外し、後でIAMでアクセス権を設定
- ランタイム: Python 3.11
- エントリポイント: chat(main.py内の関数名)
- メモリ: 512 MB以上推奨
- タイムアウト: 300秒
「ランタイム環境変数」セクションで以下を追加:
PROJECT_ID: your-project-idLOCATION: asia-northeast1
- 「ZIP アップロード」を選択
- 作成したフォルダをZIP化してアップロード
- または「インラインエディタ」で直接コードを貼り付け
「デプロイ」ボタンをクリックして完了を待つ(2-5分)
Step 4: 認証設定(本番用)
GUIで特定のユーザー・ドメインにのみアクセス権を付与する
Cloud Functions一覧から、作成した関数をクリック
上部のタブから「権限」をクリック
アクセスを許可するユーザー/グループを追加
- 特定ユーザー: user:someone@example.com
- ドメイン全体: domain:yourcompany.com
- グループ: group:dev-team@yourcompany.com
- ロール: Cloud Functions 起動元(roles/cloudfunctions.invoker)
認証が必要な関数を呼び出すには、リクエストにIDトークンを含める必要があります。
Pythonからの呼び出し方法は「設計相談」セクションを参照してください。
Step 5: 動作確認
関数の詳細画面の「トリガー」タブにURLが表示されます
「テスト」タブで直接テストするか、ブラウザの開発者ツールやPostmanでテスト
curl -X POST https://your-function-url \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{"meeting_text": "本日の会議では..."}'
{
"summary": "新機能の開発方針と期限について議論...",
"topics": ["新機能開発", "スケジュール調整"],
"action_items": ["田中: 設計書作成(1/20まで)"],
"final_doc": "# 議事録\\n\\n## 要約\\n..."
}
デプロイ前チェックリスト
認証方式の選択(★★★ 重要 ★★★)
用途に応じて認証方式を選択してください。
| 方式 | 用途 | 実装難易度 | セキュリティ |
|---|---|---|---|
| APIキー認証 | お客さんにAPI提供 | ⭐ 簡単 | ○ 十分 |
| Firebase Auth | 自社フロントエンド経由 | ⭐⭐ やや複雑 | ◎ 高い |
| IAM認証のみ | 社内GCPユーザー限定 | ⭐ 簡単(コード不要) | ◎ 高い |
🤔 どれを選ぶ?
お客さんにAPIだけ提供する?
├─ Yes → APIキー認証
└─ No → 自社でフロントエンドも作る?
├─ Yes → Firebase Auth
└─ No → 社内GCPユーザーだけ?
└─ Yes → IAM認証のみ(Step 4で設定済み)
方式A: APIキー認証(お客さんにAPI提供)
シンプルで導入しやすい方式。お客さんごとにAPIキーを発行し、リクエストに含めてもらいます。
📊 仕組み
[お客さんのシステム]
↓ リクエスト + APIキー
↓ X-API-Key: sk-abc123...
[Cloud Run Functions]
↓ APIキーを検証
↓ 一致したら処理実行
[レスポンス]
config.py に追加
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # セクション5: APIキー認証(お客さん向け) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 有効なAPIキーのリスト(カンマ区切り) # 例: "sk-customer1-abc123,sk-customer2-def456" # ★★★ 本番では Secret Manager の使用を推奨 ★★★ VALID_API_KEYS = os.getenv("VALID_API_KEYS", "").split(",") VALID_API_KEYS = [k.strip() for k in VALID_API_KEYS if k.strip()] def is_valid_api_key(api_key: str) -> bool: """APIキーが有効かチェック""" # APIキー認証が無効(キーが設定されていない)場合はスキップ if not VALID_API_KEYS: return True return api_key in VALID_API_KEYS def get_customer_from_key(api_key: str) -> str: """APIキーからお客さん名を取得(ログ用)""" # 例: "sk-customer1-abc123" → "customer1" parts = api_key.split("-") return parts[1] if len(parts) >= 2 else "unknown"
main.py の認証部分を修正
from config import is_valid_api_key, get_customer_from_key # ━━━ 認証チェック(APIキー方式)━━━ def check_api_key_auth(request) -> tuple[bool, str]: """APIキー認証をチェック""" # ヘッダーからAPIキーを取得 api_key = request.headers.get("X-API-Key", "") if not is_valid_api_key(api_key): return False, "" customer = get_customer_from_key(api_key) return True, customer # main関数内で使用 @functions_framework.http def main(request): # ... CORS処理 ... # ━━━ 2. 認証チェック(APIキー)━━━ is_valid, customer = check_api_key_auth(request) if not is_valid: return (jsonify({"error": "Invalid API Key"}), 401, headers)
お客さんへの案内(呼び出し方)
import requests # 御社から発行されたAPIキー API_KEY = "sk-customer1-abc123" ENDPOINT = "https://your-function-url.run.app/api/chat" response = requests.post( ENDPOINT, headers={ "X-API-Key": API_KEY, "Content-Type": "application/json" }, json={"message": "こんにちは"} ) print(response.json())
⚠️ APIキー管理の注意点
- 本番ではSecret Managerでキーを管理(環境変数に直接書かない)
- お客さんごとに別のキーを発行(誰のリクエストか追跡可能に)
- 定期的にキーをローテーションする仕組みを検討
方式B: Firebase Auth(自社フロント経由)
Googleログインなどを使い、署名付きJWTでユーザーを安全に認証します。
📊 仕組み(署名検証あり)
[ユーザー] → Googleでログイン
↓
[Firebase Auth] → JWT発行(秘密鍵で署名)
↓ {"email": "user@example.com", 署名}
[フロントエンド] → JWTをヘッダーに付けてAPI呼び出し
↓
[Cloud Run] → firebase-adminで署名を検証
↓ 本物のJWTか確認(偽造不可能)
[処理実行]
💡 なぜ署名が重要?
署名 = Firebaseだけが持つ秘密鍵で作成
→ 悪意ある人が「俺はadminだ」と偽JWTを作っても、署名が違うので拒否できる
Step 1: requirements.txt に追加
firebase-admin>=6.0.0
Step 2: auth.py を作成
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # auth.py - Firebase認証ヘルパー # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ★★★ このファイルでJWTの署名検証を行う ★★★ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ import firebase_admin from firebase_admin import auth, credentials from config import is_allowed_user # Firebase Admin SDK 初期化(1回だけ) # Cloud Run では Application Default Credentials が自動で使われる if not firebase_admin._apps: firebase_admin.initialize_app() def verify_firebase_token(request) -> tuple[bool, str, str]: """ FirebaseのJWTを検証し、ユーザー情報を取得 Returns: (成功したか, メールアドレス, エラーメッセージ) """ auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): return False, "", "Authorizationヘッダーがありません" token = auth_header.split(" ")[1] try: # ★★★ ここで署名検証が行われる ★★★ # 偽のJWTは auth.verify_id_token で弾かれる decoded_token = auth.verify_id_token(token) email = decoded_token.get("email", "") # ドメイン/メール制限チェック if not is_allowed_user(email): return False, email, "アクセス権がありません" return True, email, "" except auth.InvalidIdTokenError: return False, "", "無効なトークンです" except auth.ExpiredIdTokenError: return False, "", "トークンの有効期限が切れています"
Step 3: main.py で使用
from auth import verify_firebase_token @functions_framework.http def main(request): headers = get_cors_headers() if request.method == "OPTIONS": return ("", 204, headers) # ━━━ 2. Firebase認証チェック ━━━ is_valid, email, error_msg = verify_firebase_token(request) if not is_valid: return (jsonify({"error": error_msg}), 401, headers) # 以降、emailを使ってログなどに活用可能
Step 4: フロントエンド側(参考)
// Firebase Auth でログイン済みの場合 import { getAuth } from "firebase/auth"; async function callChatAPI(message) { const auth = getAuth(); const user = auth.currentUser; // ★★★ Firebase から署名付きJWTを取得 ★★★ const token = await user.getIdToken(); const response = await fetch("https://your-function.run.app/api/chat", { method: "POST", headers: { "Authorization": `Bearer ${token}`, // JWTを付ける "Content-Type": "application/json" }, body: JSON.stringify({ message }) }); }
✅ Firebase Auth のメリット
- 署名検証: 偽造されたJWTを確実に弾ける
- ユーザー管理: Firebase Console でユーザー一覧を確認可能
- 多様なログイン: Google, Microsoft, メール/パスワード等に対応
- config.pyと連携: ドメイン制限も併用可能
設計相談ガイド
AIと一緒にLangGraphアプリを設計する
なぜ設計相談が必要?
LangGraphの構文を学んでも、実際のプロジェクトでは:
- 「どこから手をつければいい?」
- 「Nodeの分け方がわからない」
- 「Stateに何を入れるべき?」
という壁にぶつかります。
いきなりコードを書くのではなく、まず設計を整理することが成功の鍵です。
💡 このセクションではClaudeと対話しながら
ステップバイステップで設計を進めるためのテンプレートを提供します
設計の5ステップ
Claudeへの相談テンプレート
以下のテンプレートをコピーして、Claudeに送ってください。
対話しながら設計を進められます。
# LangGraph設計相談 ## 作りたいもの (例:会議の議事録を自動で要約・整理するツール) ## 入力 - 形式:(例:テキスト、音声ファイル、API経由など) - 内容:(例:会議の文字起こしテキスト) ## 出力 - 形式:(例:JSON、Markdown、画面表示など) - 内容:(例:要約、議題リスト、アクションアイテム) ## 処理の流れ(ざっくり) 1. (例:テキストを受け取る) 2. (例:要約する) 3. (例:議題を抽出する) 4. (例:結果を整形する) ## 条件分岐があれば - (例:テキストが長い場合は詳細分析、短い場合は簡易処理) ## 不明点・相談したいこと - (例:Nodeの分け方が適切かわからない) - (例:エラーハンドリングをどこでやるべき?) --- 上記をもとに、LangGraphの設計を一緒に考えてください。 State、Node、Edgeの構成を提案してもらえると助かります。
💡 使い方のコツ
- 全部埋めなくてOK: わからない部分は空欄のまま送って相談
- 具体例を書く: 抽象的より具体的な方が良いアドバイスが得られる
- 追加質問OK: 対話しながら詰めていく
対話の例
実際にどんな対話になるかの例です。
カスタマーサポートのチャットボットを作りたい。
ユーザーの質問を分類して、FAQで答えられるものはFAQから返答、
それ以外は人間のオペレーターに転送したい。
良いですね!いくつか確認させてください:
- 質問の分類は何種類くらい想定していますか?
- FAQデータはどこに保存されていますか?(DB、ファイル、API?)
- 「人間に転送」は具体的にどうしますか?(Slack通知、メール?)
分類は「FAQ対応可能」「要オペレーター」「不明」の3つ。
FAQはJSONファイル。転送はとりあえずログ出力でOK。
では以下の構成を提案します:
【Node構成】
classify_node- 質問を3分類faq_search_node- FAQから回答検索generate_response_node- 回答生成escalate_node- オペレーター転送(ログ)
【State】
question: strcategory: strfaq_answer: str | Noneresponse: str
🎯 ポイント
このように対話を重ねることで、曖昧だった要件が具体化し、
適切なNode/State/Edgeの設計に落とし込めます。
設計チェックリスト
コードを書き始める前に確認しましょう。
困ったときのヒント
「Nodeの分け方がわからない」
→ 動詞で考える。「要約する」「分類する」「検索する」など、
1つの動詞 = 1つのNodeが基本。
「Stateに何を入れるべき?」
→ 後のNodeが必要とするものをすべて入れる。
「このNodeの結果を、次のNodeで使うか?」で判断。
「条件分岐が複雑になりそう」
→ まずシンプルな直線フローで動くものを作る。
その後、分岐を1つずつ追加していく。
「LLMを使う箇所が多すぎる」
→ 本当にLLMが必要か確認。単純なルールベースで
できる処理は通常の関数で実装(コスト削減)。
生成AIのコード、ここに気をつけて!
Claude含む生成AIが書くコードにはよくある問題パターンがあります。
これを知っておくと「おかしいな?」と気づいて相談できます。
💬 こんな風にClaudeに聞いてみよう
いきなりコードを書かない。これがLangGraph開発成功の秘訣です。
- テンプレートを使ってClaudeに相談
- 対話しながら要件を具体化
- チェックリストで確認してからコーディング
- 困ったらいつでも追加で相談OK
さあ、上のテンプレートをコピーして、あなたのプロジェクトを相談してみましょう!