1

LangGraphとは何か

LangChain学習済みの方向けに解説

🤔

なぜLangGraphを学ぶのか?

AIエージェントを作るとき、こんな経験はありませんか?

「ユーザーの質問によって、違う処理をしたい...」

「複数のAIを連携させて、もっと賢いシステムを作りたい...」

「エラーが起きたら、リトライしたい...」

LangGraphは、これらすべてをシンプルなコードで実現できるフレームワークです。

⚠️

LangChainだけでは限界がある

LangChainのLCELは直線的な処理に強いです:

Python - LangChainの基本(LCEL)
123456
# LCEL: パイプライン(直線的な流れ)
# 入力 → プロンプト → LLM → パーサー → 出力
chain = prompt | llm | output_parser

# 実行(シンプル!)
result = chain.invoke({"question": "今日の天気は?"})

この「パイプライン」は簡単で分かりやすいですが...

😰 LCELだけでは難しいこと
  • 条件分岐:「天気の質問」と「ニュースの質問」で違う処理をしたい
  • ループ:ユーザーが満足するまで繰り返したい
  • 並列処理:複数のAPIを同時に呼びたい
  • 状態管理:会話履歴を持ち回りたい

→ これらを解決するのがLangGraphです!

🎯

LangGraphの3要素:State・Node・Edge

Stateを持ち運びながらNodeを順番に実行する
ワークフローの流れ State(状態) データを持ち運ぶ箱 { message, result, ... } Node A 要約する Node B 分類する Node C 整形する Edge Edge それぞれの役割 { } State(状態) = 全Nodeで共有するデータの箱 Node(ノード) = 処理を行う関数(要約、分類など) Edge(エッジ) = Node間の接続(次にどこへ行くか) Stateを持って、Edgeに沿って、Nodeを順番に実行
2

基礎概念の深堀り

State, Node, Edgeを詳しく理解する

📦

State(状態)とは?

Stateは全頂点(Node)で共有されるデータ構造です。

Python
1234567
from typing import TypedDict

class ChatState(TypedDict):
    """全頂点で共有される状態"""
    user_message: str      # ユーザーの質問
    intent: str            # 意図分類結果
    response: str          # 最終回答
💡 ポイント

Stateは「会社を回覧するクリップボード」のようなもの。
各社員(Node)が順番に自分の担当欄を埋めて、次の人に回していきます。
最後には全欄が埋まった完成版になります。

⚙️

Node(頂点)とは?

NodeはStateを受け取り、更新内容を返す関数です。

Python
123456789101112
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は頂点間の接続を定義します。

🔀 2種類の辺(Edge)
通常の辺(add_edge) A B 常に A → B へ 条件分岐の辺(add_conditional_edges) A B C D 条件1 条件2 その他 条件で行き先が変わる
種類用途コード
通常の辺常に同じ頂点へadd_edge("A", "B")
条件分岐の辺条件で分岐add_conditional_edges(...)
3

最小構成で理解する

2頂点だけの最小構成

🚀

最もシンプルなLangGraph

Python
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
# ==========================================
# 🏢 最小構成の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(全部大文字)
🔄 実行フロー(グラフ構造)- クリックでステップを進む
START process _node output _node END 📍 Step 0: 初期状態 Stateが初期化されます {message: "こんにちは", result: ""} ⚙️ Step 1: process_node 実行中 messageを処理してresultを更新 return {"result": "処理済み: こんにちは"} ※ messageは変更なし → 自動で引き継ぎ 📤 Step 2: output_node 実行中 更新されたresultを参照して出力 print("最終結果: 処理済み: こんにちは") return {} → 更新なしでもOK! ✅ Step 3: 完了! 最終State: {message: "こんにちは", result: "処理済み: こんにちは"}
Step 0 / 3
💡 部分更新のポイント
Python
123456
# OK: 更新したいフィールドだけ返す
return {"result": processed}

# NG: 全部返す必要はない
# return {"message": state["message"], "result": processed}
# messageは自動的に引き継がれる!
4

条件分岐を追加する

条件によって処理を分ける

🔀

条件分岐の実装

Python
1234567891011121314151617181920
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",
    }
)
🔀 条件分岐のグラフ構造
START classifier _node 分岐 "weather" "time" "general" weather _node time _node general _node END
5

State管理の応用

データの受け渡しをマスターしよう

📋

まず復習:Stateって何だっけ?

Stateは「社員間で回す回覧ファイル」でしたね。各Node(社員)がこのファイルを読み書きして情報を共有します。

✅ 会社のたとえ

  • 回覧ファイル = State
  • ファイルの項目 = フィールド
  • 社員が記入 = Nodeの戻り値

💻 コードのたとえ

  • State = TypedDict
  • フィールド = key: 型
  • 戻り値 = return {"key": 値}
Python - 基本のState(復習)
1234567891011121314
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は書かない → 自動で引き継がれる
⚠️

困った!会話履歴が消えてしまう問題

チャットボットを作るとき、会話履歴を保持したいですよね。でも普通に書くと...

Python - ❌ 失敗例:履歴が消える
123456789101112131415161718192021
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[型, どう処理するか]
「この項目は○○して処理してね」という指示書きをつけるイメージ

Python - ✅ 成功例:履歴が追加される
123456789101112131415161718192021222324
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(上書き)

既存: [A, B]
↓ return {"history": [C]}
結果: [C] ← A,Bが消えた

✅ Annotated[list, add](追加)

既存: [A, B]
↓ return {"history": [C]}
結果: [A, B, C] ← 追加された
📚

よく使うStateパターン一覧(コピペ用)

実務でよく使うパターンをまとめました。必要に応じてコピペしてください。

Python - パターン集(そのまま使えます)
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
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件だけ残したい場合は、自分で処理を書きます。

Python - 履歴を15件に制限
12345678910111213141516
# 自分で「どう処理するか」を関数で定義
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というライブラリで定義します。

Python - 欲しい形式を定義
12345678910111213141516
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に形式を伝えて呼び出す

Python - 構造化出力の使い方
123456789101112131415161718192021
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で使う場合

実際のグラフ内で使う場合はこうなります:

Python - Nodeでの使い方
12345678910111213141516171819202122
# 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など最新モデルが必要
  • 複雑すぎる形式は精度が下がることがある
6

実践チュートリアル

カスタマーサポートAIを一から作る

📍 現在の進行状況 Step 1 / 6
📁
環境準備
プロジェクト作成とライブラリインストール
🎯

このチュートリアルで作るもの

実際に動く議事録要約AIを作ります。会議テキストから構造化された議事録を自動生成!

✅ 完成後にできること
  • 会議テキストを自動要約(300文字程度)
  • 主要な議題を抽出(リスト形式)
  • アクションアイテムを自動抽出(担当者・期限付き)
  • 長い会議は詳細モード、短い会議は簡易モードで自動切り替え
完成イメージ - 議事録要約AIのフロー
START summarize _node 要約生成 check _length 長さ判定 is_long? 条件分岐 長い extract _topics 議題抽出 extract _actions アクション抽出 短い format _doc 議事録整形 END 💡 長い会議(2000文字以上)→ 詳細モード(議題・アクション抽出) 💡 短い会議 → 簡易モード(要約のみで整形へ直行)
📁

Step 1: 環境準備

議事録要約AI」を作ります。会議テキスト → 要約・議題・アクションを自動抽出します。

📓
Google Colab で実行します
環境構築不要!ブラウザだけで始められます
🚀 Google Colab を開く
Python (Colab) - セル1: ライブラリインストール
1234567
# ============================================
# 必要なライブラリをインストール
#   langgraph: グラフ構築の本体
#   どちらか1つを選んでください(両方入れてもOK)
# ============================================
!pip install -q langgraph langchain-google-genai      # ← Gemini API版
# !pip install -q langgraph langchain-google-vertexai  # ← Vertex AI版

🔧 LLMの設定(どちらか1つを選択)

推奨 方式A: Gemini API(簡単!)
メリット: GCPプロジェクト不要、APIキーを取得するだけ
取得方法: Google AI Studio で無料発行(1分で完了)
Python (Colab) - セル2A: Gemini API設定
# ============================================
# 方式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 設定完了")
本番向け 方式B: Vertex AI(GCPプロジェクト)
メリット: Cloud Runデプロイ時にコード変更不要、GCPの他サービスと連携しやすい
必要なもの: GCPプロジェクト + Vertex AI APIの有効化
Python (Colab) - セル2B: Vertex AI設定
# ============================================
# 方式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分で始められます。

📓 Colabでの進め方

このチュートリアルは1つのノートブック内でセルを分けて進めます。
Step 2〜6のコードを順番にセルに貼り付けて実行するだけでOK!

📋 これから作るセル構成
完了 セル1-2: 環境準備 & LLM設定
Step 2 セル3: State定義(MeetingState)
Step 3 セル4: Node関数(summarize_node等)
Step 4 セル5: ルーティング関数(route_by_length)
Step 5 セル6: グラフ組み立て
Step 6 セル7: 実行テスト
💡 本番デプロイ時のファイル分割

Colabで動作確認した後、Cloud Runにデプロイする際はファイルを分割します。
詳しくは「デプロイ」タブを参照してください。

📦

Step 2: Stateを定義する

Stateはグラフ全体で共有するデータの入れ物です。各Nodeがこれを読み書きします。

Python (Colab) - セル3: State定義
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
"""
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
🧑‍💻 AIエンジニア視点
  • State設計は最初にしっかり考える! 後から追加は簡単だが、削除は既存Nodeに影響
  • logsのようなAnnotated[List, add]は、各Nodeがログを「追記」できて便利
  • 実務ではerror_messageretry_countも入れておくとエラーハンドリングしやすい
📚 State定義の2つの方法:TypedDict vs BaseModel

LangGraphでは TypedDict(上記の例)と BaseModel(Pydantic)の両方でStateを定義できます。
用途に応じて使い分けましょう。

方式 メリット デメリット
TypedDict シンプル、軽量、公式ドキュメントで多用 バリデーションなし
BaseModel Field()で説明・デフォルト値・バリデーション可能 やや冗長
TypedDict版(シンプル)
from typing import TypedDict

class MeetingState(TypedDict):
    raw_text: str
    summary: str
    is_long: bool
BaseModel版(Pydantic)
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. 要約ノード

Python (Colab) - セル4: Node関数(その1: 要約)
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
"""
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にできます。条件分岐に使うフラグを設定します。

Python (Colab) - セル4に追加: 長さチェック
123456789101112131415161718192021
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モデルに直接マッピングする方法が推奨されます。これにより型安全性とパース処理の省略が可能です。

Python (Colab) - セル4に追加: 抽出系
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
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形式に整形します。

Python (Colab) - セル4に追加: 整形
1234567891011121314151617181920212223242526272829303132333435
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: 条件分岐を定義する

長い会議は詳細モード(議題・アクション抽出)、短い会議は簡易モード(要約のみ)に分岐させます。

Python (Colab) - セル5: ルーティング関数
123456789101112131415161718192021222324252627282930313233343536373839
"""
ルーティング関数

【このファイルの役割】
条件分岐のロジックを定義します。
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"
条件分岐のイメージ
check_length is_long? extract_topics True format_doc False
🔗

Step 5: グラフを組み立てる

Nodeを登録し、Edgeで接続して、最後にcompile()で実行可能にします。

Python (Colab) - セル6: グラフ組み立て
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
"""
メインファイル - グラフの組み立てと実行

【このファイルの役割】
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()
🧑‍💻 AIエンジニア視点
  • add_nodeは順番不問: 登録順は関係なく、エッジで接続順が決まる
  • compile()は最後に1回: 変更するたびに再compile必要
  • グラフの可視化: graph.get_graph().draw_mermaid()で図を出力可能
▶️

Step 6: 実行してみる

最後のセルで実行テストを行います。サンプルの会議テキストを入力として議事録を生成します。

Python (Colab) - セル7: 実行テスト
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
# ============================================
# サンプル会議テキスト(長いバージョン)
# 実際には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・条件分岐)を学びました。

📍 現在の位置
📁 環境準備 📦 MeetingState (raw_text, summary, ...) ⚙️ Nodes(処理関数) summarize_node check_length_node extract_topics_node extract_actions_node format_doc_node 🔀 条件分岐 is_long? True/False 🔧 グラフ組み立て add_node → add_edge → compile() ▶ graph.invoke()
Step 1: 環境準備
🎓 このチュートリアルで学んだこと
1
State = 共有データ

全Node間で共有。TypedDictで型定義。Annotatedで自動マージ。

2
Node = 処理単位

Stateを受け取り、部分更新を返す関数。

3
条件分岐 = ルーティング

Stateの値を見て、次のNodeを決定。

4
グラフ構築 = 組み立て

add_node → add_edge → add_conditional_edges → compile

7

Cloud Run Functions へデプロイ

実践で作った議事録AIを本番APIにする

🎯

このセクションでやること

実践チュートリアル(タブ6)で作った「議事録要約AI」をクラウドにアップして、
誰でもアクセスできるAPIにします。

実践で完成
議事録AI
📁
+config.py
設定を追加
🔧
main.py改造
API化
☁️
デプロイ
本番API完成

💡 上から順番にコピペすれば動きます

実践セクションで作った state.pynodes.pyrouting.pyそのまま使います
追加するのは config.py と、main.py の改造だけです。

📞

30秒でわかる通信の基礎(たとえ話)

専門用語が出てくるので、先に「たとえ話」で理解しておきましょう。

🌐 CORS = 「誰からの電話を受けるか」

会社の電話に誰でも出られたら困る → 「この番号からだけ受ける」と設定する

🔑 APIキー = 「合言葉」

合言葉を知っている人だけが入れる → お客さんごとに別の合言葉を発行

📝 JWT = 「本人確認の会員証」

会員証に名前が書いてある → サーバーが「この人は誰か」を確認できる

✒️ 署名 = 「偽造できない実印」

会員証に実印を押す → 偽の会員証を作っても実印がないのでバレる

※ 詳しい仕組みは下で説明します。まずは「こういうものがある」と覚えてください。

🔄

通信の流れを完全理解する

HTMLからAPIを呼ぶとき、ブラウザが間に入ります。この流れを理解しましょう。

👥 登場人物

HTML あなたのWebサイト 🌐 ブラウザ Chrome, Safari等 ★ここが重要! ☁️ Cloud Run API あなたのサーバー

📋 通信の8ステップ

1
ユーザーがボタンを押す
HTMLの「要約する」ボタンをクリック
2
HTMLがブラウザに頼む
「Cloud Run APIにこのデータを送ってきて」
3
⚡ 事前確認(OPTIONSリクエスト)
ブラウザ→API「POSTメソッドでデータ送っていい?Content-Typeラベル付けていい?」
※まだデータは送っていない!許可を求めてるだけ
4
✅ 許可を返す(CORSヘッダー)
API→ブラウザ「いいよ!Allow-Origin: * で許可するね」
5
📤 本番データを送る(POSTリクエスト)
ブラウザ→API「許可もらえた!会議テキストを送るよ」
6
⚙️ LangGraphを実行
Cloud Run APIが議事録要約AIを動かす
7
📥 結果を返す
API→ブラウザ「処理結果だよ」+ Allow-Originヘッダー
8
🎉 画面に表示
ブラウザがHTMLに結果を渡す → 画面に表示!

📝 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(ライブラリ追加)

requirements.txt - Cloud Run用に追加
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 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キー」を混同しないで!

🤖 Gemini APIキー(LLM用)
用途: GoogleのLLM(Gemini)を呼び出すためのキー
発行元: Google AI Studio または Vertex AI
誰が使う: あなたのサーバー(Cloud Run)がGoogleに対して使う
今回の設定: Vertex AIを使うため不要(GCPの認証で代用)
🔑 あなたのAPIキー(アクセス制限用)
用途: あなたのAPIを使う人を制限する「合言葉」
発行元: あなた自身が自由に決める
誰が使う: ユーザー(HTMLサイト)があなたのAPIに対して使う
今回の設定: API_KEYS 環境変数で設定
┌──────────────┐   X-API-Key: "abc123"    ┌──────────────┐   GCP認証(自動)   ┌──────────────┐
│  HTMLサイト   │ ──────────────────────→ │  Cloud Run   │ ─────────────────→ │   Gemini     │
│  (ユーザー)   │   あなたのAPIキー        │   (あなた)   │   Vertex AI認証    │   (Google)   │
└──────────────┘                          └──────────────┘                     └──────────────┘
config.py - APIキー認証 + 環境変数管理
123456789101112131415161718192021222324252627282930313233343536373839404142434445
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 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用(実践セクションのグラフを使用)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 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() をファイルのトップレベルで書いているのはなぜ?

🚀 コールドスタート(初回起動)
main.py 全体を読み込む
graph = create_graph() が1回だけ実行
→ 少し時間がかかる(数秒)
⚡ ウォームスタート(2回目以降)
graphは既に作成済み
→ 関数だけ実行
→ 高速!(数百ミリ秒)

メリット: 重い処理(グラフ構築)を1回だけにして、2回目以降を高速化

📨 レスポンスの返し方を理解する

Cloud Run APIからブラウザへの「手紙」の書き方です。

OPTIONSリクエストへの返事(許可証だけ)
return ("", 204, headers)
""本文は空っぽ(確認だけなので) 204「OK、でも中身はないよ」 headers許可証(CORSヘッダー)
POSTリクエストへの返事(結果 + 許可証)
return (jsonify({...}), 200, headers)
jsonify({...})本文(JSON形式の結果) 200「OK、中身あるよ」 headers許可証(これも必要!)
⚠️ なぜPOSTの返事にもheadersが必要?

ブラウザは結果を受け取るときも「この結果をHTMLに渡していいか」をチェックします。
許可証なし → ブラウザがブロック → 画面に表示されない

🖥️

Step 3: GUIでデプロイ(Cloud Console)

コマンドを使わず、GCP Cloud Consoleからデプロイする手順

1
Cloud Console にアクセス

console.cloud.google.com/functions を開く

2
「関数を作成」をクリック

上部の青い「+ 関数を作成」ボタンをクリック

3
基本設定を入力
  • 環境: 第2世代(Cloud Run ベース)
  • 関数名: meeting-summarizer(議事録要約AI)
  • リージョン: asia-northeast1(東京)
  • トリガー: HTTPS
4
認証設定
  • テスト時: 「未認証の呼び出しを許可」にチェック
  • 本番時: チェックを外し、後でIAMでアクセス権を設定
5
ランタイム設定
  • ランタイム: Python 3.11
  • エントリポイント: chat(main.py内の関数名)
  • メモリ: 512 MB以上推奨
  • タイムアウト: 300秒
6
環境変数を設定

「ランタイム環境変数」セクションで以下を追加:

  • PROJECT_ID: your-project-id
  • LOCATION: asia-northeast1
7
ソースコードをアップロード
  • 「ZIP アップロード」を選択
  • 作成したフォルダをZIP化してアップロード
  • または「インラインエディタ」で直接コードを貼り付け
8
デプロイ

「デプロイ」ボタンをクリックして完了を待つ(2-5分)

🔒

Step 4: 認証設定(本番用)

GUIで特定のユーザー・ドメインにのみアクセス権を付与する

1
関数の詳細画面を開く

Cloud Functions一覧から、作成した関数をクリック

2
「権限」タブを選択

上部のタブから「権限」をクリック

3
「プリンシパルを追加」をクリック

アクセスを許可するユーザー/グループを追加

4
プリンシパルとロールを設定
  • 特定ユーザー: user:someone@example.com
  • ドメイン全体: domain:yourcompany.com
  • グループ: group:dev-team@yourcompany.com
  • ロール: Cloud Functions 起動元(roles/cloudfunctions.invoker)
⚠️ 認証ありの場合の呼び出し方

認証が必要な関数を呼び出すには、リクエストにIDトークンを含める必要があります。
Pythonからの呼び出し方法は「設計相談」セクションを参照してください。

Step 5: 動作確認

1
URLを取得

関数の詳細画面の「トリガー」タブにURLが表示されます

2
テストリクエスト

「テスト」タブで直接テストするか、ブラウザの開発者ツールやPostmanでテスト

🎉 成功レスポンス例(議事録要約AI)
リクエスト(curlコマンド)
curl -X POST https://your-function-url \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-api-key" \
  -d '{"meeting_text": "本日の会議では..."}'
レスポンス(JSON)
{
    "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 に追加

config.py - APIキー設定を追加
123456789101112131415161718192021
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# セクション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 の認証部分を修正

main.py - APIキー認証を追加
1234567891011121314151617181920212223
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)

お客さんへの案内(呼び出し方)

お客さんに渡すサンプルコード(Python)
123456789101112131415
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 に追加

requirements.txt
firebase-admin>=6.0.0

Step 2: auth.py を作成

auth.py - Firebase認証ヘルパー
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 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 で使用

main.py - Firebase認証を使用
123456789101112131415
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: フロントエンド側(参考)

JavaScript - フロントエンドからの呼び出し
123456789101112131415161718
// 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と連携: ドメイン制限も併用可能
8

設計相談ガイド

AIと一緒にLangGraphアプリを設計する

🤝

なぜ設計相談が必要?

LangGraphの構文を学んでも、実際のプロジェクトでは:

  • 「どこから手をつければいい?」
  • 「Nodeの分け方がわからない」
  • 「Stateに何を入れるべき?」

という壁にぶつかります。
いきなりコードを書くのではなく、まず設計を整理することが成功の鍵です。

💡 このセクションではClaudeと対話しながら
ステップバイステップで設計を進めるためのテンプレートを提供します

🗺️

設計の5ステップ

1
ゴールを明確にする
「何を入力して、何を出力したいか」を言語化
2
処理を日本語で書き出す
コードではなく「やること」を箇条書きに
3
Nodeに分解する
「1つの処理 = 1つのNode」を意識して分割
4
Stateを設計する
Node間で受け渡すデータを洗い出す
5
Edgeと分岐を決める
フローの順序と条件分岐を整理
📋

Claudeへの相談テンプレート

以下のテンプレートをコピーして、Claudeに送ってください。
対話しながら設計を進められます。

プロンプトテンプレート(コピーして使う)
# LangGraph設計相談

## 作りたいもの
(例:会議の議事録を自動で要約・整理するツール)


## 入力
- 形式:(例:テキスト、音声ファイル、API経由など)
- 内容:(例:会議の文字起こしテキスト)

## 出力
- 形式:(例:JSON、Markdown、画面表示など)
- 内容:(例:要約、議題リスト、アクションアイテム)

## 処理の流れ(ざっくり)
1. (例:テキストを受け取る)
2. (例:要約する)
3. (例:議題を抽出する)
4. (例:結果を整形する)

## 条件分岐があれば
- (例:テキストが長い場合は詳細分析、短い場合は簡易処理)

## 不明点・相談したいこと
- (例:Nodeの分け方が適切かわからない)
- (例:エラーハンドリングをどこでやるべき?)

---
上記をもとに、LangGraphの設計を一緒に考えてください。
State、Node、Edgeの構成を提案してもらえると助かります。

💡 使い方のコツ

  • 全部埋めなくてOK: わからない部分は空欄のまま送って相談
  • 具体例を書く: 抽象的より具体的な方が良いアドバイスが得られる
  • 追加質問OK: 対話しながら詰めていく
💬

対話の例

実際にどんな対話になるかの例です。

👤

カスタマーサポートのチャットボットを作りたい。
ユーザーの質問を分類して、FAQで答えられるものはFAQから返答、
それ以外は人間のオペレーターに転送したい。

🤖

良いですね!いくつか確認させてください:

  1. 質問の分類は何種類くらい想定していますか?
  2. FAQデータはどこに保存されていますか?(DB、ファイル、API?)
  3. 「人間に転送」は具体的にどうしますか?(Slack通知、メール?)
👤

分類は「FAQ対応可能」「要オペレーター」「不明」の3つ。
FAQはJSONファイル。転送はとりあえずログ出力でOK。

🤖

では以下の構成を提案します:

【Node構成】

  • classify_node - 質問を3分類
  • faq_search_node - FAQから回答検索
  • generate_response_node - 回答生成
  • escalate_node - オペレーター転送(ログ)

【State】

  • question: str
  • category: str
  • faq_answer: str | None
  • response: str

🎯 ポイント

このように対話を重ねることで、曖昧だった要件が具体化し、
適切なNode/State/Edgeの設計に落とし込めます。

設計チェックリスト

コードを書き始める前に確認しましょう。

💡

困ったときのヒント

「Nodeの分け方がわからない」

動詞で考える。「要約する」「分類する」「検索する」など、
  1つの動詞 = 1つのNodeが基本。

「Stateに何を入れるべき?」

後のNodeが必要とするものをすべて入れる。
  「このNodeの結果を、次のNodeで使うか?」で判断。

「条件分岐が複雑になりそう」

→ まずシンプルな直線フローで動くものを作る。
  その後、分岐を1つずつ追加していく。

「LLMを使う箇所が多すぎる」

→ 本当にLLMが必要か確認。単純なルールベースで
  できる処理は通常の関数で実装(コスト削減)。

⚠️

生成AIのコード、ここに気をつけて!

Claude含む生成AIが書くコードにはよくある問題パターンがあります。
これを知っておくと「おかしいな?」と気づいて相談できます。

🔴 古いAPI・非推奨の書き方

LangChainは進化が速く、半年前の書き方が既に古いことも。

❌ 古い
llm.predict(text)
LLMChain(llm, prompt)
✅ 最新
llm.invoke(text)
prompt | llm

👉 見かけたら聞こう:「この書き方、最新?」

🔴 過剰なtry-except

AIは「念のため」でエラーハンドリングを入れがち。本当に必要か確認を。

❌ 過剰
try:
    result = llm.invoke(x)
except Exception as e:
    logger.error(e)
    return None
✅ シンプル
result = llm.invoke(x)
# エラーは呼び出し元で
# まとめてハンドリング

👉 見かけたら聞こう:「このtry-except、必要?」

🔴 不要なクラス化・抽象化

「将来の拡張性のため」で複雑にしがち。YAGNI原則を忘れずに。

❌ 過剰
class SummarizerService:
    def __init__(self, llm):
        self.llm = llm
    def summarize(self, text):
        return self.llm.invoke(text)
✅ シンプル
def summarize(text):
    return llm.invoke(text)

# 関数で十分!

👉 見かけたら聞こう:「クラスにする意味ある?関数で良くない?」

🔴 冗長なコメント・docstring

コードを見れば分かることを長々と説明しがち。

❌ 冗長
# カウンターを1増やす
counter += 1
# 結果をリストに追加
results.append(x)
✅ 必要なときだけ
counter += 1
results.append(x)
# 15件に制限(メモリ対策)
results = results[-15:]

👉 見かけたら聞こう:「このコメント、本当に必要?」

🟣 LangGraph特有の注意点
  • Stateの直接変更: state["key"] = value はNG。必ず return {"key": value} で返す
  • グローバル変数の使用: Node内でグローバル変数を使うと予期せぬ挙動に
  • reducer忘れ: リストに追加したいのに毎回上書きされる → Annotated[List, add] が必要
  • END忘れ: 終了条件で END を返さないと無限ループに

💬 こんな風にClaudeに聞いてみよう

「このコード、もっとシンプルに書ける?」
「LangChain/LangGraphの最新の書き方に直して」
「この処理、LangGraphでやる意味ある?普通の関数で良い?」
「コードレビューして、改善点を教えて」
🎓 設計相談のまとめ

いきなりコードを書かない。これがLangGraph開発成功の秘訣です。

  • テンプレートを使ってClaudeに相談
  • 対話しながら要件を具体化
  • チェックリストで確認してからコーディング
  • 困ったらいつでも追加で相談OK

さあ、上のテンプレートをコピーして、あなたのプロジェクトを相談してみましょう!