コンテンツにスキップ

第04回 学習メモ: Function Callingによるエージェント

全体像

本リポジトリで初めてエージェントが登場する回。それまで(01〜03)は「ユーザー発話 → LLM → 応答」という一往復で完結していたが、ここからは LLM 自身が 「どのツールを呼ぶか」を決めて使い分ける ループ構造に変わる。

題材は ファイル管理エージェント で、./work/ 配下の論文ファイル(paper1〜3.txt)に対して: - ディレクトリ一覧を見る(list_directory) - ファイルを読む(read_file) - ファイルを書く(write_file

の3操作を、ユーザーの自然言語指示から LLM が選んで実行する。

エージェントループ全体

flowchart TD
    Start([ユーザー発話<br/>例: paper1.txt を要約して<br/>paper1_summary.txt に保存して]) --> Init[ConversationalAgent.run<br/>chat_history と<br/>intermediate_steps を準備]

    Init --> Invoke["agent_chain.ainvoke<br/>= assigns → prompt → llm(tools 付き) → parser"]

    Invoke -->|BadRequestError<br/>主にコンテキスト長超過| Trim[最後の observation を<br/>空文字に差し替え]
    Trim --> Fallback["fallback_chain.invoke<br/>(tools 無しの LLM で<br/>エラーを日本語化)"]
    Fallback --> Return

    Invoke --> Decide{出力の型}
    Decide -->|AgentFinish<br/>= LLM が「もう答えられる」| Final[output.return_values&#91;'output'&#93;<br/>を最終応答として取り出す]
    Final --> SaveHistory[history.append<br/>HumanMessage / AIMessage を保存]
    SaveHistory --> Return([is_final=True で yield<br/>→ Chainlit が cl.Message で表示])

    Decide -->|AgentAction&#91;s&#93;<br/>= LLM がツール呼び出しを指示| Exec[tools_by_name から<br/>該当ツールを invoke<br/>例: read_file / write_file]
    Exec --> Append["intermediate_steps に<br/>(action, observation) を append"]
    Append --> YieldStep([is_final=False で yield<br/>→ Chainlit が cl.Step で<br/>折りたたみ表示])
    YieldStep --> Invoke

要点: - 中央の Invoke ノードに戻る矢印が 「LLM が AgentFinish を返すまでループ」 の本質 - 左の BadRequestError 経路がフォールバックチェインの出番(第05回への伏線) - ユーザーから見える出力は2種類: ループ中は cl.Step(折りたたみ)、最後は cl.Message(通常)

LCEL チェイン内部のデータフロー

agent_chain の中で何が起きているかを分解すると:

flowchart LR
    Input["入力 dict<br/>{input, intermediate_steps}"] --> Assigns

    subgraph Assigns["assigns (RunnableMap)"]
        direction TB
        A1["input: lambda x: x['input']"]
        A2["agent_scratchpad:<br/>format_to_openai_tool_messages<br/>(intermediate_steps)"]
        A3["chat_history:<br/>self.history.messages"]
    end

    Assigns --> Prompt["ChatPromptTemplate<br/>system + {chat_history}<br/>+ user + {agent_scratchpad}"]
    Prompt --> LLM["ChatOpenAI(gpt-4o-mini)<br/>.bind_tools(tools)"]
    LLM --> Parser["OpenAIToolsAgentOutputParser"]
    Parser --> Out["AgentFinish<br/>または AgentAction&#91;s&#93;"]
  • assigns は dict を dict に変換する RunnableMap。3つの値を並列に計算してプロンプトに渡すスロットを埋める
  • agent_scratchpad に過去のツール実行履歴が並ぶことで、LLM は「もう read_file は済んだから次は write_file」と判断できる
  • bind_tools(tools) が LLM 呼び出しに tools=[...] パラメータを毎回付けてくれる
  • Parser が応答の tool_calls の有無を見て AgentFinishAgentAction[s] に振り分ける

第03回 RAG との対比

観点 03(RAG) 04(Agent)
ループ構造 1往復で終わる(chain) LLM が「終わり」と言うまで反復
LLM の役割 既知の情報を回答に整形 どの行動を取るか自分で決める
外部呼び出し 検索1回(retriever) 任意回数の関数呼び出し
結果の予測可能性 高い 低い(LLM が選択を間違えると変な動作)
中間状態 検索結果 1セット intermediate_steps に積み上げ

チェイン = 決められた手順を流す」のに対し「エージェント = 手順自体を LLM が決める」、というのが本質的な違い。

使用ライブラリ・原理

OpenAI Function Calling とは

OpenAI が 2023年6月に追加した、LLM が関数呼び出しを構造化された JSON で返す機能。それまでは LLM の応答テキストから regex で関数名を抽出するしかなかったが、これにより:

# LLM への入力(簡略化)
messages = [{"role": "user", "content": "paper1.txt を読んで"}]
functions = [
    {
        "name": "read_file",
        "description": "Read the contents of a file",
        "parameters": {
            "type": "object",
            "properties": {
                "file_path": {"type": "string"}
            }
        }
    }
]

# LLM の応答
{
    "role": "assistant",
    "function_call": {
        "name": "read_file",
        "arguments": '{"file_path": "paper1.txt"}'   # ← 構造化された呼び出し
    }
}

「自然言語 → 関数名と引数の JSON」の翻訳を LLM がやってくれる。これがエージェントの実装が一気に楽になった転換点

⚠️ 現状(2025年〜)この API は tools パラメータに統合・改名されており、functions=...legacy 扱い。意味は同じだが、tools=[{"type": "function", "function": {...}}] のように一段ネストが深くなった。LangChain 側もこれに合わせて format_tool_to_openai_functionconvert_to_openai_tool に変わっている。

Agent と Chain の違い

Chain Agent
制御フロー 静的(コードで決まっている) 動的(LLMが決める)
ループ 通常なし 「終わり」を LLM が判断するまで反復
失敗時の挙動 例外を投げて終わり 別のツールを試す等、自己修正の余地
プロンプト 固定 agent_scratchpad で過去のツール実行履歴を毎回詰める

Agent の本質は「LLM の応答を、最終回答かツール呼び出しかで分岐させる while ループ」。本コードでは process_step がその分岐をやっている:

if isinstance(output, AgentFinish):
    return output.return_values["output"], True   # 最終回答 → ループ終了
else:
    observation = self.tool_execute(output.tool, output.tool_input)  # ツール実行
    self.intermediate_steps.append((output, observation))
    return message, False                          # ツール実行 → 次ステップへ

intermediate_stepsagent_scratchpad

これがエージェントの記憶装置:

  • intermediate_steps (list): [(AgentAction, observation), ...] の配列。「LLMがツールを選び、その結果がこうだった」の積み重ね
  • agent_scratchpad (prompt変数): 上記を OpenAI Function Calling 用のメッセージ形式に整形して、毎ターン LLM に「これまでの行動と結果」として渡す

agent_scratchpad を渡すからこそ、LLM は「もう read_file は済んだから次は write_file」と判断できる。

# setup_chain 内
"agent_scratchpad": lambda x: format_to_openai_functions(x['intermediate_steps'])

format_to_openai_functions は intermediate_steps を [AIMessage(function_call=...), FunctionMessage(content=...)] のような並びに変換する関数。

FileManagementToolkit

LangChain 組み込みのファイル操作ツール集:

FileManagementToolkit(
    root_dir=str(self.working_directory.name),       # ← "work"
    selected_tools=["read_file", "write_file", "list_directory"]
).get_tools()

root_dir を指定すると、それより上のパスへのアクセスは拒否される(パストラバーサル対策)。例えば read_file({"file_path": "../etc/passwd"}) のような呼び出しはツール側でエラーになる。エージェントは LLM が変な引数を渡しうるので、こういう sandbox 機能は本質的に重要。

利用可能なツールは他にも copy_file, delete_file, file_search, move_file 等あり、selected_tools= で必要なものだけ選ぶ。

AgentFinishAgentAction

LangChain が定義する2つのドメインオブジェクト:

クラス 意味 主なフィールド
AgentAction LLM が「次にこのツールを使うべき」と判断 tool (str), tool_input (dict), log (str)
AgentFinish LLM が「もう答えられる」と判断 return_values (dict), log (str)

OpenAIFunctionsAgentOutputParser() が LLM の応答(function_call の有無)を見て、このどちらかに変換する。エージェントループはこの型で分岐する。

ConversationBufferMemory

会話履歴を保持。intermediate_steps(1問の中での思考の足跡)とは別物で、こちらは問答を跨いで残る。1問が終わったら finish_process で:

self.memory.save_context({"input": input_message}, {"output": output_message})
self.intermediate_steps = []   # 思考の足跡はクリア

入力と出力だけ memory に残し、ツール実行ログは捨てる、という設計。会話履歴は次回 chat_history プレースホルダから取り出されてプロンプトに展開される。

フォールバックチェインの仕掛け

agent_chainfallback_chain の2本立てになっている:

self.agent_chain = self.setup_chain(llm_with_tools, is_fallback=False)   # 通常: tools 付き
self.fallback_chain = self.setup_chain(llm, is_fallback=True)            # 失敗時: tools 無し

OpenAI BadRequestError が発生したら(典型的にはコンテキスト長超過)、tools 無しの LLM に「エラーをユーザーに分かりやすく日本語で説明して」と頼む。これが第05回のフォールバック処理の伏線

ファイル別の役割

ファイル 役割
chatbot.py Chainlit のエントリーポイント。ConversationalAgent を生成し、ユーザー発話を agent.run() に流して、ツール実行ログと最終応答を分けて表示する
conversational_agent.py エージェントの本体。ツール定義・チェイン構築・ループ実行・フォールバック処理・メモリ管理を担当
work/paper1.txtpaper3.txt エージェントが操作する論文ファイル(題材データ)
requirements.txt 旧バージョン依存を pin: openai==1.2.0, langchain==0.0.332, chainlit==0.7.501

行レベルの工夫

conversational_agent.py:25 — LLM に tools を bind

llm_with_tools = llm.bind(functions=[format_tool_to_openai_function(t) for t in self.tools])

llm.bind(...)「この LLM 呼び出しには毎回これらの引数を付ける」と固定するヘルパー。format_tool_to_openai_function は LangChain Tool → OpenAI Functions JSON スキーマへの変換。これで llm_with_tools.invoke(...) を呼ぶと、毎回内部で functions=[...] パラメータが付いた API 呼び出しになる。

連載原典では llm.bind(functions=...) だが、現行 LangChain では llm.bind_tools([...]) が標準。OpenAI 側の API 変更(functions → tools)に追従している。

conversational_agent.py:36-46 — 同じ素材で2本のチェインを作る

def setup_chain(self, llm, is_fallback):
    prompt = self.create_prompt(is_fallback)
    assigns = {
        "input": lambda x: x["input"],
        "agent_scratchpad": lambda x: format_to_openai_functions(x['intermediate_steps']),
        "chat_history": lambda x: self.memory.load_memory_variables({})["chat_history"]
    }
    if is_fallback:
        return assigns | prompt | llm | StrOutputParser()                # 文字列を返す
    else:
        return assigns | prompt | llm | OpenAIFunctionsAgentOutputParser()  # AgentAction/AgentFinish を返す

ここが LCEL の柔軟さを生かした実装:

  • 入力 dict を変換する assigns、プロンプト、LLM は共通
  • 最後の output parser だけ違う:
  • 通常: OpenAIFunctionsAgentOutputParserAgentActionAgentFinish に変換
  • フォールバック: StrOutputParser で素のテキストとして取り出す

assigns も dict だが、LCEL の | の左辺に dict を置くと RunnableMap として自動昇格 し「各キーに対応する Callable を並列実行して dict を作る」動作になる。

conversational_agent.py:61-66 — 自前 while True ループ

async def run(self, input_message):
    while True:
        message, is_final = await self.process_step(input_message)
        yield {"message": message, "is_final": is_final}
        if is_final:
            self.finish_process(input_message, message)
            break

LangChain には AgentExecutor という「エージェントループを自動でやってくれる」抽象もあるが、ここではそれを使わず自前で書いている。理由は:

  • 各ステップを yield で外に返すことで、Chainlit にリアルタイムでツール実行ログを表示できる
  • フォールバック処理を自分で挟める
  • ループの終了条件を細かく制御できる

AgentExecutor だと「最後の応答 1個」しか返ってこないので、思考過程を見せる UI には不向き。

conversational_agent.py:84-90 — フォールバック発動

def handle_error(self, error: BadRequestError):
    # コンテキスト長あふれの可能性もあるため、最後のステップのツール実行結果を空にする
    self.intermediate_steps[-1] = (self.intermediate_steps[-1][0], "")
    return self.fallback_chain.invoke({
        "input": error.response.json()["error"]["message"],
        "intermediate_steps": self.intermediate_steps
    })

注目点: - BadRequestError は OpenAI SDK の例外。最頻原因はコンテキスト長超過 - 最後のツール実行結果(observation)を空文字に差し替える ← これがコンテキストを圧迫してる犯人の可能性が高いから - フォールバックチェインには 「エラーメッセージ自体」を input として渡す - LLM はそれを受けて「コンテキスト長が超過しました。〇〇のファイルが大きすぎるかも」のような日本語説明を返す

学んだこと(要点)

  • エージェント = LLM がツール選択を担う while ループ: チェインとの決定的な違いはここ。03 の RAG は「決められた1往復」だが、エージェントは「終わるまで反復」
  • intermediate_stepsagent_scratchpad の関係: 前者は Python 側のメモ、後者はそれを LLM に渡すためのプロンプト変数。LLM は scratchpad を読んで「次の行動」を決める
  • AgentFinish がループ終了サイン: LLM が「もう答えられる」と判断したかどうかは、Function Calling の有無で決まる(function_call が無ければ AgentFinish)
  • AgentAction がツール呼び出しサイン: function_call があれば、その関数名と引数で AgentAction が組み立てられる
  • AgentExecutor を使わない自前ループの利点: 各ステップを yield して UI 側でリアルタイム表示できる
  • root_dir 指定によるパストラバーサル対策: エージェントはユーザー指示で動くため、../etc/passwd のような攻撃ベクトルがある。FileManagementToolkit(root_dir=...) でそれを防ぐ
  • OpenAI Functions → Tools への移行: 2024年以降、functions パラメータは非推奨。tools パラメータと tool_choice に統合された。LangChain 側も bind_tools / convert_to_openai_tool に追従
  • フォールバックは別チェイン: 失敗時に同じプロンプトで再試行するのではなく、「エラーを説明する」という別のタスクとして LLM を呼び直す設計。第05回でさらに発展

拡張アイデア

  • 第03回の RAG ツール化: ChromaDB 検索を @tool でツール化し、Function Calling Agent に組み込むと「ファイル操作も検索も自分で選ぶ」エージェントになる
  • ツール追加: FileManagementToolkit から file_search, move_file, copy_file も入れて、より柔軟なファイル管理エージェントに
  • 第07〜09回のLangGraphで書き直す: 自前 while True ループを LangGraph で書くと、状態遷移が見える化される。StateGraph の練習に最適
  • Streaming: 各ステップの応答を astream_events() で逐次ストリーミング。Chainlit の cl.Message().stream_token(...) と組み合わせる
  • 構造化出力: 最終応答を JSON Schema に従わせる(with_structured_output)。「要約して JSON で返して」のような指示でフォーマットを保証

連載原典からの移植で行った変更

連載原典(LangChain 0.0.332, openai 1.2.0, OpenAI Functions API legacy)から、LangChain 0.3.x + OpenAI Tools API + 1Password CLI に移植した。

箇所 原典 本フォルダ
LangChain version langchain==0.0.332 langchain>=0.3,<1.0(後述の注意点を参照)
LLM クラス import from langchain.chat_models import ChatOpenAI from langchain_openai import ChatOpenAI
Toolkit import from langchain.agents.agent_toolkits import FileManagementToolkit from langchain_community.agent_toolkits import FileManagementToolkit
Tool を JSON 化 format_tool_to_openai_function(t) を手で呼ぶ 不要(llm.bind_tools(tools) が内部でやる)
LLM に bind llm.bind(functions=[...]) llm.bind_tools(tools)
Scratchpad 整形 format_to_openai_functions(...) format_to_openai_tool_messages(...)
Output Parser OpenAIFunctionsAgentOutputParser OpenAIToolsAgentOutputParser
履歴管理 ConversationBufferMemory(memory_key="chat_history", ...) HumanMessage / AIMessage の list を直接管理(_SimpleHistory
モデル gpt-4 gpt-4o-mini(コスト・速度のバランス)
ツール解決 read_file, write_file, list_directory = self.tools の順序依存 tools_by_name 辞書ベース
root_dir str(working_directory.name)"work"(実行ディレクトリ依存) str(working_directory.resolve())(絶対パス)
エラー取り出し error.response.json()["error"]["message"] error.message(openai SDK 1.x 形式)
関連情報表示 cl.Message(author="tool", indent=1) cl.Step(type="tool")
キー取得 コメントアウト(環境変数依存) 1Password CLI 経由(01〜03 と同方針)
AgentAction の扱い 単一の AgentAction 前提 Tools API では複数並列 AgentAction が来ることがあるので actions = output if isinstance(output, list) else [output] で正規化

Tools API への移行が一番大きな変更

OpenAI が 2024年に Functions API → Tools API へ統合・改名したことで、LangChain 側のエージェント周りは全面的に新シンボルになった。具体的には:

  • format_to_openai_functionsformat_to_openai_tool_messages
  • OpenAIFunctionsAgentOutputParserOpenAIToolsAgentOutputParser
  • llm.bind(functions=[...])llm.bind_tools(tools)

加えて、Tools API では1度の応答で複数ツール呼び出しが並列で返ることがあるため、parser が AgentAction の list を返すケースに対応する必要がある。process_stepactions = output if isinstance(output, list) else [output] と正規化して、各 action を順に実行する形に書き換えた。

既知の落とし穴: langchain>=0.3,<1.0 のピン留め必須

2025年にリリースされた langchain 1.x は agent API を全面廃止し LangGraph ベースに統一されている。具体的には langchain.agents.format_scratchpad モジュール自体が消えており、本コードはそのまま動かない。初回の起動試行で ModuleNotFoundError: No module named 'langchain.agents.format_scratchpad' で落ちたのは、uv run --with "langchain>=0.3" が依存解決の結果 1.3.1 を選んだため。<1.0 の上限を明示することで 0.3.x 系に固定している。

_SimpleHistory の自前実装

連載原典の ConversationBufferMemory は LangChain 0.3 でも動くが、現行 LangChain ではソフト deprecated 扱い(公式推奨は RunnableWithMessageHistory)。チャットセッション 1 つに紐づく単純な履歴管理だけが必要なので、HumanMessage / AIMessage の list を持つだけの最小クラスに置き換えた。

既知の不具合・注意点

  • エージェントは並列ツール呼び出しを返すことがある: Tools API は1回の応答で複数の tool_calls を返せるため、本フォルダの実装では actions を for ループで順次実行している。並列性は活かせていない(必要なら asyncio.gather で並列化可能)
  • intermediate_steps[-1] = (..., "") の改変は履歴改ざん寄り: フォールバック発動時に最後のツール結果を空に差し替えているが、これは記録としては問題がある。コンテキスト長対策としては「古い observation から順に削る」方が明示的
  • chat_history のトークン量上限なし: _SimpleHistory は全履歴を貯め続けるので、長時間会話で gpt-4o-mini のコンテキスト上限 (128k) に当たる可能性
  • requirements.txt は参考のみ: ファイル自体は連載原典のまま残してあるが、実行時には使わない(uv の --with で現行版を pin する)

起動方法

詳細は README.md を参照。サマリだけ:

cd 04
uv run --no-project \
  --with "langchain>=0.3,<1.0" \
  --with "langchain-openai>=0.2,<1.0" \
  --with "langchain-community>=0.3,<1.0" \
  --with "openai>=1.50" \
  --with "chainlit" \
  chainlit run chatbot.py -w

依頼例: - 「work ディレクトリの中身を教えて」 → list_directory 1回 - 「paper1.txt のタイトルを教えて」 → read_file 1回 + 抽出 - 「paper1.txt の概要を paper1_summary.txt に保存して」 → read_filewrite_file2段ループ

記事参照

  • Software Design 2023年〜の連載 第04回 「Function Callingによるエージェント」
  • 関連: 第05回(フォールバック処理の発展), 第07〜09回(LangGraph によるエージェント実装), 第11回(ReAct Agent)

作成: 2026-05-18 / 最終更新: 2026-06-10