Z4 / コマ1SEMINAR MATERIALS

座学 ② / 10:25 – 10:55

AIエージェントの基礎

座学①で分解した「観測 → 仮説 → 選択 → 検証」を、そのまま機械に扱わせる型を扱う。 エージェントとは、LLM を「1回答えるもの」ではなく「ループの中で判断し続けるもの」として使う構成のこと。

ここでの主題は4つ。ループの構造・ツール利用の仕組み・状態とコンテキストの管理・失敗からの回復。 最後に Pydantic AI での具体的な書き方として整理する。

1計画・実行・観測・評価のループ

LLM 単体は「入力に対して1回テキストを返す関数」でしかない。 エージェントは、その周りにツール実行と結果の再入力を置いてループさせたもの。 ループを回しているのは LLM ではなく、あなたが書いたプログラムである点が最初の勘所。

エージェントループ — 標的 10.10.10.5 の偵察を例に
STEP 0
再生すると、1周=1ツール呼び出しでループが回る様子が出る。
重要 / 誰がループを回すか

LLM は毎回「まっさら」から考える

LLM は状態を持たない。毎ターン、これまでの全メッセージを丸ごと渡し直している。 「さっき言ったよね」が通じるのは、さっきの発言が入力に含まれているから。この事実が、次章のコンテキスト管理の全ての出発点になる。

重要 / 停止条件

止め方を書かないと止まらない

ループの終了は①目的達成の判定 ②予算切れ ③打ち切り判断のいずれか。 ②だけに頼ると、無意味な試行で予算を使い切ってから止まる。座学①の TYPE C(撤退判断)を、 ここで実装として書く必要がある。

2ツール利用(function calling)の実際

「LLM がツールを実行する」は正確ではない。実際には LLM は「この関数をこの引数で呼んでほしい」という構造化データを返すだけで、 実行するのはあなたのコード。結果を次のメッセージとして戻す。ここを誤解していると、失敗の切り分けができない。

1回のツール呼び出しで、何がやり取りされているか 0 / 7
MESSAGE HISTORY(LLMに毎回渡される全文)
「次へ」を押すと1メッセージずつ進む。右のリストが LLM への入力そのもの。
ここが分かると切り分けができる

エージェントが失敗したとき、原因は必ず次のどれか。
(a) ツールを呼ばなかった(説明文が悪い/選択肢が多すぎる) → (b) 呼んだが引数が間違っていた(スキーマが曖昧) → (c) 実行は成功したが出力が読めなかった(生ログが長すぎる/整形されていない) → (d) 出力は読めたが解釈を誤った(知識・プロンプトの問題)。
ログを見て (a)〜(d) のどれかを言えるようにログを設計する — これが座学④のログ設計の話につながる。

ツールの説明文は、プロンプトの一部である

ツール定義の docstring と引数名・型は、システムプロンプトと同じ重みで LLM の判断に効く。 「なんとなく動く」ツールを増やすと、ツール選択の精度が落ちる(→ 座学③で定量的に扱う)。

BAD

曖昧なツール

@agent.tool
async def scan(ctx, target: str, opt: str) -> str:
    """スキャンする。"""

何をスキャンするのか、opt に何を入れてよいのかが分からない。LLM は雰囲気で埋め、失敗する。

GOOD

判断材料を含むツール

@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 の生出力、ディレクトリ列挙の結果、大量のエラー。素直に全部を履歴に積むと、すぐに必要な情報を保持できなくなる。 ここがマルチエージェントを導入する最大の動機になるので、目で見て納得しておきたい。

3つの戦略で、コンテキストがどう伸びるか ターン数 1
戦略 1

全文を履歴に積む

実装は最も単純。短いタスクなら最良。ペンテストでは10〜20ターンで限界に達する。 さらに、限界前から「中盤の重要情報が薄まる」劣化が始まる。

戦略 2

古い履歴を要約して差し替える

伸びは抑えられる。ただし要約は不可逆な情報損失。 「あのとき見た 8009 番ポート」が要約で消えると、後から取り返せない。何を落とさないかの設計が要る。

戦略 3

構造化された状態に外部化する

見つけた情報を BaseModel に格納し、履歴には「何をしたか」の要点だけを残す。 LLM は必要なときに状態を読むツールを呼ぶ。これがテンプレート実装の採る道。

戦略3:見つけた情報は履歴ではなく型付きの状態に置く
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 は動かないのが普通で、 「失敗をどう扱うか」がエージェントの性能そのものになる。失敗の層を分けて考える。

flowchart TD E["ツール実行の結果"] --> L1{"層1:呼び出しは
成立したか"} 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
図2-3 層1・層2は LLM に見せないのが原則。スキーマ違反や接続断をいちいち LLM に読ませると、コンテキストがノイズで埋まり、判断精度が落ちる。層3のエラー本文だけが LLM の判断材料として価値がある。
失敗の例正しい扱い
層1 呼び出し 引数の型が違う、必須引数がない、存在しないツール名 フレームワークが自動で LLM に差し戻す。Pydantic AI では検証エラーがそのまま再試行になる。回数上限を必ず設ける
層2 実行 VPN 断、タイムアウト、レート制限 コード側で指数バックオフ再試行。LLM に見せない。見せると「別の手を試そう」と誤った学習をする
層3 目的 exploit が Connection refused404Permission denied エラー本文をそのまま LLM に渡す。ここが判断材料の本体。要約・整形しすぎると原因特定に必要な情報が消える
層4 検証 「シェルが取れました」と言うが id を実行していない 出力バリデータで弾く。証拠がない成功報告はそもそも受け付けない設計にする(座学①
層1と層4を Pydantic AI で書く
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.tooldocstring と型注釈から自動でスキーマ生成。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体ではコンテキストが持たず、責務が混ざり、ツールが多すぎて選べなくなる。 次の 座学③ で、なぜ分けるのか・どう分けるのかを扱う。このコマの中心はここから。