座学 ② / 10:25 – 10:55
AIエージェントの基礎
座学①で分解した「観測 → 仮説 → 選択 → 検証」を、そのまま機械に扱わせる型を扱う。 エージェントとは、LLM を「1回答えるもの」ではなく「ループの中で判断し続けるもの」として使う構成のこと。
ここでの主題は4つ。ループの構造・ツール利用の仕組み・状態とコンテキストの管理・失敗からの回復。 最後に Pydantic AI での具体的な書き方として整理する。
1計画・実行・観測・評価のループ
LLM 単体は「入力に対して1回テキストを返す関数」でしかない。 エージェントは、その周りにツール実行と結果の再入力を置いてループさせたもの。 ループを回しているのは LLM ではなく、あなたが書いたプログラムである点が最初の勘所。
LLM は毎回「まっさら」から考える
LLM は状態を持たない。毎ターン、これまでの全メッセージを丸ごと渡し直している。 「さっき言ったよね」が通じるのは、さっきの発言が入力に含まれているから。この事実が、次章のコンテキスト管理の全ての出発点になる。
止め方を書かないと止まらない
ループの終了は①目的達成の判定 ②予算切れ ③打ち切り判断のいずれか。 ②だけに頼ると、無意味な試行で予算を使い切ってから止まる。座学①の TYPE C(撤退判断)を、 ここで実装として書く必要がある。
2ツール利用(function calling)の実際
「LLM がツールを実行する」は正確ではない。実際には LLM は「この関数をこの引数で呼んでほしい」という構造化データを返すだけで、 実行するのはあなたのコード。結果を次のメッセージとして戻す。ここを誤解していると、失敗の切り分けができない。
エージェントが失敗したとき、原因は必ず次のどれか。
(a) ツールを呼ばなかった(説明文が悪い/選択肢が多すぎる) →
(b) 呼んだが引数が間違っていた(スキーマが曖昧) →
(c) 実行は成功したが出力が読めなかった(生ログが長すぎる/整形されていない) →
(d) 出力は読めたが解釈を誤った(知識・プロンプトの問題)。
ログを見て (a)〜(d) のどれかを言えるようにログを設計する — これが座学④のログ設計の話につながる。
ツールの説明文は、プロンプトの一部である
ツール定義の docstring と引数名・型は、システムプロンプトと同じ重みで LLM の判断に効く。 「なんとなく動く」ツールを増やすと、ツール選択の精度が落ちる(→ 座学③で定量的に扱う)。
曖昧なツール
@agent.tool
async def scan(ctx, target: str, opt: str) -> str:
"""スキャンする。"""
何をスキャンするのか、opt に何を入れてよいのかが分からない。LLM は雰囲気で埋め、失敗する。
判断材料を含むツール
@agent.tool
async def port_scan(
ctx: RunContext[Deps],
ports: str = "1-1000",
udp: bool = False,
) -> str:
"""TCP/UDPポートを列挙し版を推定する。
ports は nmap 記法("1-1000" / "-p-")。
全ポートは数分かかる。まず既定範囲で試す。"""
コストと使い分けまで書く。LLM の判断材料は、あなたが書いた文字列しかない。
3状態の持ち方とコンテキスト管理
ペンテストは長い。nmap の生出力、ディレクトリ列挙の結果、大量のエラー。素直に全部を履歴に積むと、すぐに必要な情報を保持できなくなる。 ここがマルチエージェントを導入する最大の動機になるので、目で見て納得しておきたい。
全文を履歴に積む
実装は最も単純。短いタスクなら最良。ペンテストでは10〜20ターンで限界に達する。 さらに、限界前から「中盤の重要情報が薄まる」劣化が始まる。
古い履歴を要約して差し替える
伸びは抑えられる。ただし要約は不可逆な情報損失。 「あのとき見た 8009 番ポート」が要約で消えると、後から取り返せない。何を落とさないかの設計が要る。
構造化された状態に外部化する
見つけた情報を BaseModel に格納し、履歴には「何をしたか」の要点だけを残す。
LLM は必要なときに状態を読むツールを呼ぶ。これがテンプレート実装の採る道。
from dataclasses import dataclass, field
from pydantic import BaseModel, Field
class Service(BaseModel):
port: int
proto: Literal["tcp", "udp"]
product: str | None = None
version: str | None = None
evidence: str = Field(description="この判断の根拠になった生出力の抜粋")
class EngagementState(BaseModel):
"""標的1台ぶんの共有状態。全エージェントがここを見る。"""
target: str
services: list[Service] = []
credentials: list[Credential] = []
shells: list[ShellHandle] = []
tried: list[Attempt] = [] # 何を試して何が起きたか=ループ検知の材料
artifacts_dir: Path # 生ログはファイルへ。履歴には入れない
「生の出力」と「そこから抽出した事実」を分ける。 生出力はファイルに保存して証拠として残し(→ 座学①「証拠と再現性」)、履歴には抽出済みの構造化データだけを入れる。 この分離ができていると、コンテキストが伸びないうえに、自動で機械的に成功判定できるようになる。一石二鳥。
4失敗回復
ペンテストにおいて失敗は例外ではなく常態。PoC は動かないのが普通で、 「失敗をどう扱うか」がエージェントの性能そのものになる。失敗の層を分けて考える。
成立したか"} L1 -->|"引数がスキーマ違反"| R1["ModelRetry
LLMに訂正させる"] L1 -->|"成立"| L2{"層2:実行は
成功したか"} L2 -->|"接続断・タイムアウト"| R2["コード側でリトライ
LLMに投げない"] L2 -->|"実行できた"| L3{"層3:目的は
達成されたか"} L3 -->|"エラー出力あり"| R3["エラー本文をLLMへ
解釈させて次の行動を決める"] L3 -->|"成功らしい"| L4{"層4:証拠は
取れたか"} L4 -->|"取れていない"| R4["成功と認めない
検証ツールを呼ばせる"] L4 -->|"取れた"| OK["前進。状態を更新"] R1 --> E R2 --> E R3 --> NEXT["別ベクタ or 別パラメータ"] R4 --> E classDef q fill:#f6fbfc,stroke:#0e8f9e,color:#10262e classDef r fill:#fdf8f1,stroke:#f2b544,color:#10262e classDef ok fill:#f0faf6,stroke:#2ec4a0,color:#10262e class L1,L2,L3,L4 q class R1,R2,R3,R4 r class OK ok
| 層 | 失敗の例 | 正しい扱い |
|---|---|---|
| 層1 呼び出し | 引数の型が違う、必須引数がない、存在しないツール名 | フレームワークが自動で LLM に差し戻す。Pydantic AI では検証エラーがそのまま再試行になる。回数上限を必ず設ける |
| 層2 実行 | VPN 断、タイムアウト、レート制限 | コード側で指数バックオフ再試行。LLM に見せない。見せると「別の手を試そう」と誤った学習をする |
| 層3 目的 | exploit が Connection refused、404、Permission denied |
エラー本文をそのまま LLM に渡す。ここが判断材料の本体。要約・整形しすぎると原因特定に必要な情報が消える |
| 層4 検証 | 「シェルが取れました」と言うが id を実行していない |
出力バリデータで弾く。証拠がない成功報告はそもそも受け付けない設計にする(座学①) |
from pydantic_ai import Agent, RunContext, ModelRetry
@exploit_agent.tool
async def run_on_shell(ctx: RunContext[Deps], shell_id: str, cmd: str) -> str:
"""取得済みシェル上でコマンドを実行する。shell_id は list_shells() の値。"""
if shell_id not in ctx.deps.state.shell_ids():
# 層1:LLMの誤りなので、訂正のヒント付きで差し戻す
raise ModelRetry(f"shell_id={shell_id} は存在しない。"
f"有効なID: {ctx.deps.state.shell_ids()}")
return await ctx.deps.shells.exec(shell_id, cmd)
@exploit_agent.output_validator
async def require_evidence(ctx: RunContext[Deps], out: ExploitResult) -> ExploitResult:
# 層4:証拠なき成功報告を受け付けない
if out.shell_obtained and not out.id_output:
raise ModelRetry(
"シェル取得を主張するなら run_on_shell で `id` を実行し、"
"その生出力を id_output に入れること。未実行なら shell_obtained=False。"
)
return out
層3のエラーを渡し続けると、LLM はわずかに引数を変えた同じ試行を延々繰り返すことがある。
予算が尽きるまで止まらない。対策は「試したことを状態に記録し、それを毎ターン見せる」こと(state.tried)。
ループ検知の設計は座学③で本格的に扱う。
5Pydantic AI での書き方
ここまでの概念が、コード上でどの部品に対応するかを押さえる。 テンプレート実装を読む前に、この対応表を頭に入れておくと座学④が速い。
| 概念 | Pydantic AI の部品 | 役割 |
|---|---|---|
| エージェント本体 | Agent(model, ...) | モデル・システムプロンプト・ツール群・出力型をまとめた単位。使い回す前提で1回だけ生成する |
| 依存の注入 | deps_type= / ctx.deps | シェル接続、標的情報、共有状態などをツールに渡す口。グローバル変数を使わないための仕組み |
| 出力の型 | output_type= | 返り値を BaseModel で縛る。「文字列で報告」をやめるとハルシネーションが測れるようになる |
| ツール | @agent.tool | docstring と型注釈から自動でスキーマ生成。RunContext 経由で deps にアクセス |
| ctx 不要のツール | @agent.tool_plain | 依存を使わない純粋な関数用 |
| 動的プロンプト | @agent.system_prompt | 実行時の状態をプロンプトに差し込む。「現在の見つけた情報」を毎回入れるのに使う |
| 訂正の差し戻し | ModelRetry | 層1・層4の失敗を、ヒント付きで LLM に返す |
| 出力の検証 | @agent.output_validator | 証拠チェックの置き場所 |
| 予算の上限 | UsageLimits | リクエスト数・トークン数の上限。暴走を止める最後の砦 |
| 会話の継続 | message_history= | 前回の実行の続きから走らせる。ここに何を渡すかがコンテキスト設計そのもの |
from dataclasses import dataclass
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
@dataclass
class Deps:
"""ツールが必要とする外界。テスト時は差し替えられる。"""
target: str
shell: ShellRunner
state: EngagementState
class ReconResult(BaseModel):
services: list[Service]
next_targets: list[str]
stopped_because: str # 打ち切り理由を必ず書かせる(TYPE C の可視化)
recon_agent = Agent(
"anthropic:claude-sonnet-5",
deps_type=Deps,
output_type=ReconResult,
system_prompt=(
"あなたは偵察担当。攻撃面の確定が目的で、侵入は行わない。"
"列挙は網羅より優先順位。打ち切ったら理由を stopped_because に書く。"
),
)
@recon_agent.tool
async def port_scan(ctx: RunContext[Deps], ports: str = "1-1000") -> str:
"""TCPポートを列挙し版を推定する。ports は nmap 記法。
"-p-" は全65535ポートで数分かかる。まず既定範囲から。"""
raw = await ctx.deps.shell.run(f"nmap -sV -p {ports} {ctx.deps.target}")
ctx.deps.state.save_artifact("nmap", raw) # 生出力はファイルへ=証拠
return summarize_nmap(raw) # 履歴には要点だけ返す
# 現在の状態をプロンプトに差し込む。
# これがないと LLM は「今どこまで進んだか」を履歴から読み取るしかなく、
# ターンが伸びるほど取りこぼす。
@recon_agent.system_prompt
async def current_findings(ctx: RunContext[Deps]) -> str:
s = ctx.deps.state
return (
f"## 標的\n{s.target}\n\n"
f"## 確認済みサービス\n{s.render_services()}\n\n"
f"## 既に試して失敗した手\n{s.render_tried()}\n"
"上記と同じ試行を繰り返さないこと。"
)
「既に試して失敗した手」を毎ターン差し込むだけで、同じ手の反復がかなり減る。 コストの割に効果が大きい改善なので、コマ3で最初に試す価値がある。
from pydantic_ai.usage import UsageLimits
result = await recon_agent.run(
"標的の攻撃面を確定させてください。",
deps=deps,
usage_limits=UsageLimits(request_limit=25), # 暴走の最終防波堤
)
result.output # -> ReconResult(型が保証されている)
result.usage() # -> 要求数・トークン数。評価指標そのもの
result.all_messages() # -> 全メッセージ。次の実行に渡せばコンテキストを引き継げる
# 続きから走らせる場合
follow = await recon_agent.run(
"UDPの主要ポートも確認してください。",
deps=deps,
message_history=result.all_messages(), # ← ここが伸びる。何を渡すかが設計
)
result.usage() が、そのまま評価指標の 「LLM要求数」になる。
コマ2以降で組む自動実行の仕組みは、ここを標的ごとに集計する。最初から記録する作りにしておくと後で楽。
*この座学の要点
1. ループはあなたが回す
LLM は状態を持たない。毎回全部渡し直している。
2. ツールは提案、実行は自分
だから失敗を4層に切り分けられる。
3. 生ログは履歴に載せない
ファイル=証拠、型付き状態=判断材料。
4. 失敗は常態
層ごとに扱いを変える。層3だけが LLM の仕事。
ここまではエージェント1体の話。しかし実際にペンテストをやらせると、 1体ではコンテキストが持たず、責務が混ざり、ツールが多すぎて選べなくなる。 次の 座学③ で、なぜ分けるのか・どう分けるのかを扱う。このコマの中心はここから。