付録B: 最小エージェントを Python で書く
ここまで 10 章をかけて、「LLM + 道具 + ハーネス」の三角形と、それを回す think → act → observe のリズムを言葉にしてきた。
しかし、本当に腑に落ちるのは 自分で書いたとき だ。 この付録では、Anthropic の Claude API を使って、70〜100 行程度の Python で最小のエージェントを組み上げる。
仕組みは本書のとおりだ。LLM に道具のカタログを見せる。LLM が道具を呼ぶ命令を返す。ハーネスが本当に道具を動かす。結果を続きとして LLM に渡す。これを繰り返す ── それだけ。
書き終わったとき、あなたの手元には 本書の骨格そのもの が動いている、はずだ。
B.1 何を作るか
作るのは、こういうエージェントだ。
- ユーザーから自然言語のタスクを受け取る
- 「ファイルを読む / ファイルを書く / シェルを実行する」の 3 つの道具 を持つ
- LLM が判断して道具を呼び、結果を見て次を考える
- LLM が「もう道具を呼ばない」と決めた瞬間、あるいは規定回数に達した瞬間に止まる
第1〜4章で見たループの 最小実装 だ。これを段階的に組み上げる。
補足本書のスコープに合わせ、ここでは Anthropic の Claude API(tool use) を使う。OpenAI / Gemini も API 形は似ているので、終わったあとで好きな API に書き換えられるはずだ。コードは Python 3.10+ を想定する。
B.2 段階 0 ── 準備
依存はひとつだけだ。
pip install anthropic
export ANTHROPIC_API_KEY=sk-ant-...
最低限のインポートと、モデル名を決めておく。本書では Claude Opus 4.5 を基準にする(API 識別子は claude-opus-4-5)。
import os
import subprocess
import anthropic
MODEL = "claude-opus-4-5" # Claude Opus 4.5。適宜書き換え
client = anthropic.Anthropic()
ここから 5 段階で組み上げる。
B.3 段階 1 ── 補完だけのチャット(道具なし)
まずは「LLM に話しかけて、返事をもらう」だけのコードを書く。 これは本書でいう チャット に相当し、まだ エージェントではない。
def chat(user_text: str) -> str:
resp = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[{"role": "user", "content": user_text}],
)
# 返答は content の中のテキストブロックを連結
return "".join(b.text for b in resp.content if b.type == "text")
if __name__ == "__main__":
print(chat("Python で fibonacci(10) を計算する関数を書いて"))
これを動かすと、Claude が コードのテキストを返してくる だけだ。 ファイルは書かれない。シェルも動かない。本書の言葉でいえば、これが ガラスの向こうの専門家 ── まだ世界に手を出していない。
B.4 段階 2 ── 道具を 1 つ定義する(read_file)
ここから、ハーネスを組み始める。 最初の道具は ファイルを読む だけのものにする。
LLM に道具を見せるには、JSON Schema で道具のカタログを定義する (第5章で扱った function calling の話だ)。
TOOLS = [
{
"name": "read_file",
"description": "指定したパスのテキストファイルを読み、中身を返す。",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "読みたいファイルのパス"},
},
"required": ["path"],
},
}
]
道具の 本体(実装) は普通の Python 関数だ。
def run_tool(name: str, args: dict) -> str:
if name == "read_file":
with open(args["path"], "r", encoding="utf-8") as f:
return f.read()
return f"unknown tool: {name}"
LLM 呼び出しのときに tools=TOOLS を渡せば、LLM はカタログを見て「read_file を呼んでね」とテキストで返してくれるようになる。
resp = client.messages.create(
model=MODEL,
max_tokens=1024,
tools=TOOLS,
messages=[{"role": "user", "content": "README.md の冒頭3行を要約して"}],
)
print(resp.stop_reason) # "tool_use" になるはず
print(resp.content) # tool_use ブロックが入っている
stop_reason が tool_use のとき、LLM は 道具を呼ぼうとしている。
だが、まだ私たちは呼んでいない。次の段階で 本当に呼ぶ ループを書く。
B.5 段階 3 ── tool_use を処理してループを回す
ここが本書の核 ── think → act → observe を回す本体 だ。
def run_agent(user_text: str, max_turns: int = 10) -> str:
messages = [{"role": "user", "content": user_text}]
for turn in range(max_turns):
resp = client.messages.create(
model=MODEL,
max_tokens=2048,
tools=TOOLS,
messages=messages,
)
# アシスタントの応答を履歴に積む(次ターンで参照される)
messages.append({"role": "assistant", "content": resp.content})
# ① 道具を呼ばずに終わった → ループ脱出("自然停止")
if resp.stop_reason != "tool_use":
return "".join(b.text for b in resp.content if b.type == "text")
# ② tool_use ブロックを取り出して、ハーネス側で実行
tool_results = []
for block in resp.content:
if block.type == "tool_use":
print(f" -> {block.name}({block.input})")
output = run_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
# ③ tool_result をユーザーメッセージとして返す("観測の続き"を LLM に渡す)
messages.append({"role": "user", "content": tool_results})
return "[max_turns 到達] ループを打ち切りました"
このたった 1 つの関数に、本書で扱ったループの すべての要素 が入っている。
- think:
client.messages.create(...)が LLM の思考 - act:
run_tool(...)が道具の実行 - observe:
tool_resultsをmessagesに積み直すのが観測の継ぎ足し - 自然停止:
stop_reason != "tool_use"のときにreturn - 無限ループの抑制:
max_turnsという上限(第4章の「終わりを決める」三本柱のうちの一本)
たった 20 行ほどのループに、本書の第1〜4章でずっと扱ってきた骨格が そのまま 詰まっている。
ハーネス = この for ループ + tools リスト + run_tool の合計、と言ってしまってもよい。
B.6 段階 4 ── 道具を 3 つに増やす
道具は 1 つでは不便なので、write_file と run_command を足す。 ここで重要なのは、道具を増やすために LLM のコードはまったく触らない ことだ。 追加するのは「カタログ(JSON Schema)」と「本体(Python 関数)」だけ。
TOOLS = [
{
"name": "read_file",
"description": "指定したパスのテキストファイルを読む。",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
{
"name": "write_file",
"description": "指定したパスにテキストを書き込む(上書き)。",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
{
"name": "run_command",
"description": "シェルコマンドを実行し、標準出力と終了コードを返す。",
"input_schema": {
"type": "object",
"properties": {"cmd": {"type": "string"}},
"required": ["cmd"],
},
},
]
def run_tool(name: str, args: dict) -> str:
if name == "read_file":
with open(args["path"], "r", encoding="utf-8") as f:
return f.read()
if name == "write_file":
with open(args["path"], "w", encoding="utf-8") as f:
f.write(args["content"])
return f"wrote {len(args['content'])} chars to {args['path']}"
if name == "run_command":
r = subprocess.run(args["cmd"], shell=True, capture_output=True, text=True, timeout=60)
return f"exit={r.returncode}\nSTDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"
return f"unknown tool: {name}"
これで、第6章で扱った「良い道具のカタログ」のミニ版ができた。 description は丁寧に書く ── これは本書で繰り返した 道具の品質はカタログの言葉で決まる という原則の、実演でもある。
落とし穴ここで
run_commandはshell=Trueでなんでも実行できてしまう。本物のエージェントなら、本書の第10章で扱った HITL(実行前のユーザー承認) や コマンドのホワイトリスト をここに挟む。学習用と割り切るとしても、自分の本番マシンで動かす前に一度立ち止まってほしい。
B.7 段階 5 ── つなげて動かす
最終形を main で呼ぶ。
if __name__ == "__main__":
task = (
"カレントディレクトリの README.md を読んで、その内容を1段落に要約し、"
"summary.txt というファイルに書き出してください。"
)
print(run_agent(task, max_turns=8))
動かすと、こんな順序で動く(標準出力に -> tool(...) が出るはず)。
-> read_file({'path': 'README.md'})
-> write_file({'path': 'summary.txt', 'content': '...'})
要約を summary.txt に書き出しました。
LLM は自分で「まず README を読み、内容を吟味し、summary.txt に書く」と 計画して 動いた。 私たちは「何をするか」を一行で渡しただけだ。 あとは LLM + 道具 + ハーネス が、本書で見てきたとおりにループを回して、止まった。
B.8 完成コードの全体像
ここまでをひと続きにすると、こうなる(80 行弱)。
import subprocess
import anthropic
MODEL = "claude-opus-4-5"
client = anthropic.Anthropic()
TOOLS = [
{
"name": "read_file",
"description": "指定したパスのテキストファイルを読む。",
"input_schema": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
{
"name": "write_file",
"description": "指定したパスにテキストを書き込む(上書き)。",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
{
"name": "run_command",
"description": "シェルコマンドを実行し、標準出力と終了コードを返す。",
"input_schema": {
"type": "object",
"properties": {"cmd": {"type": "string"}},
"required": ["cmd"],
},
},
]
def run_tool(name, args):
if name == "read_file":
with open(args["path"], "r", encoding="utf-8") as f:
return f.read()
if name == "write_file":
with open(args["path"], "w", encoding="utf-8") as f:
f.write(args["content"])
return f"wrote {len(args['content'])} chars to {args['path']}"
if name == "run_command":
r = subprocess.run(args["cmd"], shell=True, capture_output=True, text=True, timeout=60)
return f"exit={r.returncode}\nSTDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"
return f"unknown tool: {name}"
def run_agent(user_text, max_turns=10):
messages = [{"role": "user", "content": user_text}]
for _ in range(max_turns):
resp = client.messages.create(
model=MODEL, max_tokens=2048, tools=TOOLS, messages=messages,
)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
return "".join(b.text for b in resp.content if b.type == "text")
results = []
for block in resp.content:
if block.type == "tool_use":
print(f" -> {block.name}({block.input})")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": run_tool(block.name, block.input),
})
messages.append({"role": "user", "content": results})
return "[max_turns 到達]"
if __name__ == "__main__":
print(run_agent(
"README.md を読んで、内容を1段落に要約して summary.txt に保存して",
max_turns=8,
))
80 行弱。これが、Claude Code や Cursor が内側でやっていることの 核 だ。
B.9 この最小実装と、本物の Claude Code との距離
「これでもう Claude Code が書けたのでは?」と一瞬思うかもしれないが、当然ながら本物はもっと複雑だ。 ただ、その複雑さは 本書で扱った観点 にすべて分類できる。
| 不足している要素 | 本書のどの章で扱ったか |
|---|---|
| 道具の 承認 UI、危険コマンドの ホワイトリスト | 第10章(HITL) |
| 大きなファイル をどうコンテキストに収めるか | 第7章(コンテキストウィンドウ) |
| CLAUDE.md 相当の永続メモリ・プロジェクト設定 | 第8章(メモリ階層と圧縮) |
| Plan mode / TodoWrite / サブエージェント | 第9章(計画とサブエージェント) |
| MCP で外部ツールを動的に追加 | 終章(軸③ プロトコル) |
| 三大病(早すぎる終了 / 無限ループ / 幻のツール呼び)対策 | 第10章 |
逆に言えば、この 80 行に上記の各章で扱った設計判断を肉付けしていけば、Claude Code 級のエージェントの輪郭は組み上がる。 (実際に組み上げきれるかは別問題だが、骨格は同じだ)
80 行のコードを目の前に置くと、「LLM + 道具 + ハーネス」も「think → act → observe」も「停止条件」も、もう抽象論ではなくなる。
for turn in range(max_turns): の中身に、本書の全章がコメントとして書き込める。これが、自分で書くという行為の効能だ。
B.10 拡張のヒント
完成形に対して、本書の各章を踏みながら段階的に拡張できる。 手を動かして腑に落とす ために、いくつか宿題を置いておく。
- 第5章:
read_fileの description を 1 行から 5 行に増やしてみて、エージェントの呼び方の質が変わるか観察する - 第6章:
write_fileの前に diff を表示してユーザー承認 を取るバージョンを書く。これが Cursor の Accept ボタンの原型 - 第7章:
read_fileが巨大ファイルを読んだとき、先頭 N 行に切り詰める バージョンを書く - 第8章: スクリプトと同じディレクトリの
AGENTS.mdをシステムプロンプトに自動で混ぜるようにする - 第9章:
run_agentを 「計画専用エージェント」と「実行専用エージェント」 に分け、計画ステップでは write/run を呼べないようにする - 第10章: 同じ
tool_useが 連続 3 回呼ばれたら警告して止める ガードを足す(無限ループ対策)
これを順に試すと、本書のどの章が どの行に対応する か、コードの中で見える化されるはずだ。
B.11 仕上げ ── 「動く」が、もう不思議ではない
このコードを書く前と書いた後で、Claude Code や Cursor を起動したときの見え方は きっと変わっている。
以前は「画面の中で何かよく分からない動き方をしている奇妙な存在」だったものが、今は「for ループの中で tools.create() を呼んで tool_use を捌いている、見慣れた構造」として見える。
私は学生のころ、ネットワークの教科書を読んでも TCP/IP が何ひとつ腑に落ちなかった。
腑に落ちたのは、socket() bind() listen() を生で叩いて、Python で数十行のサーバーを書いた瞬間だ。
エージェントも同じだと思う。
本書の 10 章をいくら読んでも、抽象的な「ループ」が体に入ってくる感じはしないかもしれない。
けれど、80 行のループを自分で書いて、自分の手で tool_use をディスパッチした瞬間、エージェントは急に身近な存在に変わる。
これが、この付録を最後に置いた理由だ。 腑に落とすための最後の一押しを、ぜひ自分の指先でやってほしい。
この最小エージェントを起点に、本書の全章を 改めて読み返してみる とよい。 読書時間は前回と同じはずなのに、各章の言っていることが コードのどこに刺さるか で見えてくる ── 一冊が二重に読める、最後のおまけ体験になるはずだ。