第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['output']<br/>を最終応答として取り出す]
Final --> SaveHistory[history.append<br/>HumanMessage / AIMessage を保存]
SaveHistory --> Return([is_final=True で yield<br/>→ Chainlit が cl.Message で表示])
Decide -->|AgentAction[s]<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[s]"]
assignsは dict を dict に変換するRunnableMap。3つの値を並列に計算してプロンプトに渡すスロットを埋めるagent_scratchpadに過去のツール実行履歴が並ぶことで、LLM は「もう read_file は済んだから次は write_file」と判断できるbind_tools(tools)が LLM 呼び出しにtools=[...]パラメータを毎回付けてくれる- Parser が応答の
tool_callsの有無を見てAgentFinishかAgentAction[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_function→convert_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_steps と agent_scratchpad¶
これがエージェントの記憶装置:
intermediate_steps(list):[(AgentAction, observation), ...]の配列。「LLMがツールを選び、その結果がこうだった」の積み重ねagent_scratchpad(prompt変数): 上記を OpenAI Function Calling 用のメッセージ形式に整形して、毎ターン LLM に「これまでの行動と結果」として渡す
agent_scratchpad を渡すからこそ、LLM は「もう read_file は済んだから次は write_file」と判断できる。
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= で必要なものだけ選ぶ。
AgentFinish と AgentAction¶
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_chain と fallback_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.txt 〜 paper3.txt |
エージェントが操作する論文ファイル(題材データ) |
requirements.txt |
旧バージョン依存を pin: openai==1.2.0, langchain==0.0.332, chainlit==0.7.501 |
行レベルの工夫¶
conversational_agent.py:25 — LLM に tools を bind¶
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 だけ違う:
- 通常:
OpenAIFunctionsAgentOutputParserでAgentActionかAgentFinishに変換 - フォールバック:
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_stepsとagent_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_functions→format_to_openai_tool_messagesOpenAIFunctionsAgentOutputParser→OpenAIToolsAgentOutputParserllm.bind(functions=[...])→llm.bind_tools(tools)
加えて、Tools API では1度の応答で複数ツール呼び出しが並列で返ることがあるため、parser が AgentAction の list を返すケースに対応する必要がある。process_step で actions = 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_file → write_file の 2段ループ
記事参照¶
- Software Design 2023年〜の連載 第04回 「Function Callingによるエージェント」
- 関連: 第05回(フォールバック処理の発展), 第07〜09回(LangGraph によるエージェント実装), 第11回(ReAct Agent)
作成: 2026-05-18 / 最終更新: 2026-06-10