AIエージェントに全部読ませない。YAMLでSkill Routerを実装する

AIエージェントへ大量のルールを常時読み込ませる代わりに、タスクに応じて必要なSkillだけを選ぶRouterをYAMLで実装する方法を、最小構成のコード付きで整理します。

AIAILLMAIエージェントYAMLPython

AIエージェントに全部読ませない。YAMLでSkill Routerを実装する

以前、AIエージェントは「ルールを増やしすぎても」壊れるという記事を書きました。

そこで書いた結論の一つが、

すべてのルールを常時読む

のではなく、

今回必要なSkillへRoutingする

という構成です。

ただ、これだけでは設計論で終わります。

そこで今回は、YAMLでSkill Registryを定義し、Pythonで必要なSkillだけ選ぶ最小実装まで落とします。

作るもの

全体像は次のようにします。

ユーザー依頼
   ↓
Task分類
   ↓
Skill Router
   ↓
skills/index.yaml
   ↓
必要なSkillだけ取得
   ↓
LLMへ渡す

Repository構成はこれくらいで十分です。

agent/
├─ router.py
└─ skills/
   ├─ index.yaml
   ├─ github.md
   ├─ article-writing.md
   └─ image-generation.md

重要なのは、Skill本文を全部promptへ入れないことです。

index.yamlはSkill本文への索引として使います。

まずSkill Registryを書く

例えば次のようなYAMLを用意します。

skills:
  github:
    path: github.md
    triggers:
      - GitHub
      - repository
      - commit
      - pull request
    provides:
      - repository inspection
      - file changes
      - commit verification

  article-writing:
    path: article-writing.md
    triggers:
      - article
      - blog
      - 記事
      - ブログ
    provides:
      - outline
      - drafting
      - editing

  image-generation:
    path: image-generation.md
    triggers:
      - image
      - illustration
      - 画像
      - イラスト
    provides:
      - prompt design
      - visual verification

このYAMLには、Skillの詳細手順を書きません。

詳細は各Markdownへ置きます。

index.yaml = 何があるか
Skill本文 = どう実行するか

と責務を分けます。

最小RouterをPythonで書く

まずは単純なキーワード一致でも動きます。

from pathlib import Path
import yaml

ROOT = Path(__file__).parent
SKILL_DIR = ROOT / 'skills'


def load_registry():
    with open(SKILL_DIR / 'index.yaml', encoding='utf-8') as f:
        return yaml.safe_load(f)['skills']


def route(message: str):
    registry = load_registry()
    text = message.lower()
    selected = []

    for skill_id, meta in registry.items():
        triggers = [str(x).lower() for x in meta.get('triggers', [])]
        if any(trigger in text for trigger in triggers):
            selected.append(skill_id)

    return selected

実行すると、

print(route('GitHubのRepositoryにブログ記事を追加して'))

例えば次の結果になります。

['github', 'article-writing']

ここで初めて、該当Skill本文を読みます。

選ばれたSkillだけロードする

def load_skills(skill_ids):
    registry = load_registry()
    loaded = []

    for skill_id in skill_ids:
        meta = registry[skill_id]
        path = SKILL_DIR / meta['path']
        loaded.append({
            'id': skill_id,
            'content': path.read_text(encoding='utf-8'),
        })

    return loaded

これで、

ids = route('GitHubのRepositoryにブログ記事を追加して')
skills = load_skills(ids)

とすれば、画像生成Skillなどは読み込まれません。

なぜindexと本文を分けるのか

全部一つの巨大YAMLにしても動きます。

ただし、それではRouter自身が巨大化します。

例えばSkill本文がそれぞれ数千文字あり、Skillが50個あったとします。

Routerが判断するだけなのに、毎回50個分の詳細手順まで渡すのは本末転倒です。

そこで、Routing時点では高信号な情報だけ残します。

triggers:
provides:
path:

必要になってから本文を読む構造にします。

単純なキーワード一致だけでは足りない

ここまでのコードには弱点があります。

例えば、

GitHubに置いてある画像を記事に使いたい

という依頼では、

GitHub
画像
記事

の三つが引っかかる可能性があります。

しかし、「画像を生成する」のではなく「既存画像を使う」だけなら、画像生成Skillは不要かもしれません。

つまりRouterには、単語ではなくタスクの意味を見る段階が必要です。

Task Shapeを先に作る

そこで、Routingの前に依頼を小さな構造へ変換します。

objective: ブログ記事を追加する
artifact: article
source: GitHub repository
operations:
  - inspect repository
  - write article
  - commit file
image_generation: false

この構造をTask Shapeとして扱います。

するとRouter側は、

GitHubという単語があるからgithub Skill

ではなく、

repository inspectionが必要だからgithub Skill

と判断できます。

triggersよりcapabilitiesを見る

YAMLも少し進化させます。

skills:
  github:
    path: github.md
    capabilities:
      - inspect_repository
      - modify_repository
      - verify_commit

  article-writing:
    path: article-writing.md
    capabilities:
      - design_article
      - draft_article
      - edit_article

  image-generation:
    path: image-generation.md
    capabilities:
      - generate_image
      - edit_image

Task Shape側で必要能力を出します。

requires:
  - inspect_repository
  - design_article
  - draft_article
  - modify_repository
  - verify_commit

Routerはrequiresを満たすSkillを選びます。

capabilityベースのRouter

def route_by_capabilities(required):
    registry = load_registry()
    required = set(required)
    selected = []

    for skill_id, meta in registry.items():
        capabilities = set(meta.get('capabilities', []))
        if capabilities & required:
            selected.append(skill_id)

    return selected

これなら表面的な単語への依存を減らせます。

Compositionも持たせる

実際のタスクでは、複数Skillがセットで必要になることがあります。

例えばRepository変更なら、

現状確認
↓
変更
↓
検証

までが一まとまりです。

そこでCompositionをYAMLへ持たせます。

compositions:
  repository_article_change:
    when:
      artifact: article
      operation: repository_change
    skills:
      - github
      - article-writing
      - verification

Compositionは便利ですが、増やしすぎると今度はRouter自体がルール地獄になります。

そのため、

に絞る方が扱いやすいです。

requiresで依存関係を閉じる

Skill同士に依存がある場合もあります。

skills:
  article-writing:
    path: article-writing.md
    requires:
      - source-check

この場合、article-writingが選ばれたらsource-checkも追加します。

def resolve_dependencies(skill_ids, registry):
    resolved = set(skill_ids)
    pending = list(skill_ids)

    while pending:
        skill_id = pending.pop()
        for required in registry[skill_id].get('requires', []):
            if required not in resolved:
                resolved.add(required)
                pending.append(required)

    return list(resolved)

ここで大事なのは、件数で切らないことです。

最大3Skillまで

のような制限を入れると、4個目が本当に必要な場合に壊れます。

制限すべきなのは件数ではなく、今回の成功条件に必要かどうかです。

RootルールはRouterの外に置く

Routingできるからといって、すべてをSkillへ落とすべきではありません。

例えば、

実行していないことを完了と報告しない
権限を越えた操作をしない
課金が発生する操作を勝手にしない

のようなものは、画像生成でも記事作成でもGitHub操作でも必要です。

これはSkillではなくRootです。

Root
  ↓
Task Shape
  ↓
Router
  ↓
Skill
  ↓
Execution
  ↓
Verification

この層を混ぜない方が、後から整理しやすくなります。

RouterをLLMだけに任せるか

選択肢は大きく三つあります。

1. 完全にルールベース

キーワードやcapabilityで決めます。

利点は再現性です。

欠点は曖昧な自然言語に弱いことです。

2. LLMに分類させる

LLMへTask Shapeを作らせます。

{
  "artifact": "article",
  "operation": "repository_change",
  "requires": [
    "inspect_repository",
    "draft_article",
    "verify_commit"
  ]
}

その結果を決定的なRouterへ渡します。

自然言語への対応力が上がります。

3. Hybrid

実運用ではこれが扱いやすいです。

LLM
  ↓
Task Shape生成
  ↓
決定的なRegistry照合
  ↓
Skill選択

LLMに「存在しないSkill名」を自由生成させず、候補の確定はRegistry側で行います。

Skillを増やす前に見ること

Skill Routerを作ると、何でもSkill化したくなります。

しかし、これもルール追加と同じ罠があります。

新しいSkillを作る前に、

既存Skillで表現できないか
既存Skillのcapability追加で済まないか
単なるRepository固有ルールではないか
機械的なvalidationにすべきではないか

を確認します。

例えば、

このブログの記事はsrc/content/articlesへ置く

は汎用Skillではありません。

Repository側のAGENTS.mdやREADMEに置く情報です。

Routingした後の検証まで設計する

Routerが正しくても、成果物が正しいとは限りません。

正しいSkillを選んだ
↓
実装に失敗した

ということは普通にあります。

そのため最後にVerificationを置きます。

completion:
  - required files exist
  - tests pass
  - generated artifact opens
  - external state matches requested state

Routerは「何を使うか」を決めるだけです。

完了判定までRouterへ背負わせない方が責務が明確になります。

最小構成から始める

最初から巨大なAgent Harnessを作る必要はありません。

まずは、

skills/index.yaml
skills/*.md
router.py

だけでも十分です。

さらに必要になったら、

Task Shape
Composition
Dependency resolution
Verification

を追加します。

最初から100個のSkillと複雑な優先順位表を作ると、結局「大量のルールを全部読む問題」をRouterの中に再発させます。

まとめ

Skill Routerの目的は、AIを賢く見せることではありません。

今回のタスクに不要な判断面を減らすことです。

最小構成なら、

1. YAMLでSkill Registryを作る
2. Taskを分類する
3. 必要Skillだけ選ぶ
4. 選ばれた本文だけロードする
5. 最後に成果物を検証する

で成立します。

大量のルールを持つこと自体は悪くありません。

問題は、

大量のルールを、毎回すべて判断対象にしてしまうこと。

Skill Routerは、その問題を「プロンプトを削る」だけではなく、必要な能力へ到達する仕組みとして解決するための一つの実装方法です。

Keep reading

記事を検索 →