0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

YAML/MarkdownからAIエージェント用の軽量Routing IndexをGoで自動生成する

0
Posted at

YAML/MarkdownからAIエージェント用の軽量Routing IndexをGoで自動生成する

AIエージェントへSkillを追加していくと、いつか「Skill本文」だけでなく Skillを選ぶための一覧表そのもの が大きくなります。

この記事では、YAMLを正規Registryとして残したまま、

  • 実行時は、まず軽量なIndexだけを見る
  • 候補が決まったら正規YAMLへ戻る
  • Indexが元データより古ければ使わない
  • 生成したJSONを正本にしない

という構成をGoで作ります。

最小構成は次です。

作るもの

ディレクトリはこれだけで試せます。

agent-routing-demo/
├─ skills.yaml
├─ cmd/
│  └─ build-index/
│     └─ main.go
├─ generated/
│  └─ routing.min.json
├─ go.mod
└─ .github/
   └─ workflows/
      └─ verify-index.yml

1. 正規RegistryをYAMLで持つ

skills.yaml を唯一の正しい情報源、つまり正本(Source of Truth)にします。

schema_version: 1

skills:
  repository-change:
    path: skills/repository-change.md
    priority: P0
    triggers:
      - repository change
      - 実装修正
      - 設定変更

  grounded-research:
    path: skills/grounded-research.md
    priority: P0
    triggers:
      - 調査
      - 最新情報
      - 出典
      - 根拠

  document-production:
    path: skills/document-production.md
    priority: P1
    triggers:
      - 記事
      - 仕様書
      - 文書作成

composition:
  software-change:
    - repository-change

  article-writing:
    - grounded-research
    - document-production

このYAMLを人間とAgentが管理する正本にします。

生成したJSONは直接編集しません。

2. 実行時用Indexには最小限の情報だけ入れる

出力は次の形にします。

{
  "v": 1,
  "authority": "skills.yaml",
  "source_sha256": "9d...",
  "skill_fields": ["id", "source_start", "source_end", "triggers"],
  "skills": [
    ["repository-change", 4, 10, ["repository change", "実装修正", "設定変更"]],
    ["grounded-research", 12, 19, ["調査", "最新情報", "出典", "根拠"]]
  ],
  "composition_fields": ["id", "source_start", "source_end"],
  "compositions": [
    ["software-change", 28, 29],
    ["article-writing", 31, 33]
  ]
}

pathやpriorityを落としているのは意図的です。

候補が決まった後は、source_start / source_end を使って正規YAMLの該当範囲を読み直します。

つまり実行時用Indexは、 候補を見つけるところまで を担当します。

3. Go moduleを作る

go mod init example.com/agent-routing-demo
go get gopkg.in/yaml.v3

yaml.v3 のNodeには行番号があるので、正規YAML内の各項目の位置を取得できます。

4. generatorを書く

cmd/build-index/main.go を作ります。

package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os"
	"sort"

	"gopkg.in/yaml.v3"
)

type SkillTuple struct {
	ID       string
	Start    int
	End      int
	Triggers []string
}

type RangeTuple struct {
	ID    string
	Start int
	End   int
}

func sha256Hex(b []byte) string {
	sum := sha256.Sum256(b)
	return hex.EncodeToString(sum[:])
}

func mappingValue(node *yaml.Node, key string) *yaml.Node {
	if node == nil || node.Kind != yaml.MappingNode {
		return nil
	}
	for i := 0; i+1 < len(node.Content); i += 2 {
		if node.Content[i].Value == key {
			return node.Content[i+1]
		}
	}
	return nil
}

func scalarList(node *yaml.Node) []string {
	if node == nil || node.Kind != yaml.SequenceNode {
		return nil
	}
	out := make([]string, 0, len(node.Content))
	for _, child := range node.Content {
		out = append(out, child.Value)
	}
	return out
}

func entryRanges(mapping *yaml.Node, fileEnd int) map[string][2]int {
	out := map[string][2]int{}
	if mapping == nil || mapping.Kind != yaml.MappingNode {
		return out
	}

	type entry struct {
		id    string
		start int
	}
	entries := []entry{}

	for i := 0; i+1 < len(mapping.Content); i += 2 {
		key := mapping.Content[i]
		entries = append(entries, entry{
			id:    key.Value,
			start: key.Line,
		})
	}

	for i, e := range entries {
		end := fileEnd
		if i+1 < len(entries) {
			end = entries[i+1].start - 1
		}
		out[e.id] = [2]int{e.start, end}
	}
	return out
}

func main() {
	sourcePath := "skills.yaml"
	outPath := "generated/routing.min.json"

	raw, err := os.ReadFile(sourcePath)
	if err != nil {
		panic(err)
	}

	var root yaml.Node
	if err := yaml.Unmarshal(raw, &root); err != nil {
		panic(err)
	}

	doc := root.Content[0]
	skillsNode := mappingValue(doc, "skills")
	compositionNode := mappingValue(doc, "composition")

	lineCount := 1
	for _, b := range raw {
		if b == '\n' {
			lineCount++
		}
	}

	skillRanges := entryRanges(skillsNode, lineCount)
	compositionRanges := entryRanges(compositionNode, lineCount)

	skillIDs := make([]string, 0, len(skillRanges))
	for id := range skillRanges {
		skillIDs = append(skillIDs, id)
	}
	sort.Strings(skillIDs)

	skills := make([][]any, 0, len(skillIDs))
	for _, id := range skillIDs {
		var value *yaml.Node
		for i := 0; i+1 < len(skillsNode.Content); i += 2 {
			if skillsNode.Content[i].Value == id {
				value = skillsNode.Content[i+1]
				break
			}
		}
		r := skillRanges[id]
		triggers := scalarList(mappingValue(value, "triggers"))
		skills = append(skills, []any{id, r[0], r[1], triggers})
	}

	compositionIDs := make([]string, 0, len(compositionRanges))
	for id := range compositionRanges {
		compositionIDs = append(compositionIDs, id)
	}
	sort.Strings(compositionIDs)

	compositions := make([][]any, 0, len(compositionIDs))
	for _, id := range compositionIDs {
		r := compositionRanges[id]
		compositions = append(compositions, []any{id, r[0], r[1]})
	}

	index := map[string]any{
		"v":                  1,
		"authority":          sourcePath,
		"source_sha256":      sha256Hex(raw),
		"skill_fields":       []string{"id", "source_start", "source_end", "triggers"},
		"skills":             skills,
		"composition_fields": []string{"id", "source_start", "source_end"},
		"compositions":       compositions,
	}

	encoded, err := json.Marshal(index)
	if err != nil {
		panic(err)
	}

	if err := os.MkdirAll("generated", 0o755); err != nil {
		panic(err)
	}
	if err := os.WriteFile(outPath, append(encoded, '\n'), 0o644); err != nil {
		panic(err)
	}

	fmt.Printf("generated %s (%d bytes)\n", outPath, len(encoded)+1)
}

この例ではtupleを使っています。

JSONとしての自己説明性は少し落ちますが、skill_fields を一度だけ置くことで、フィールド名の繰り返しを減らせます。

5. 生成する

go run ./cmd/build-index

generated/routing.min.json ができます。

重要なのは、生成結果を手編集しないことです。

skills.yaml
   ↓ deterministic
routing.min.json

という一方向にします。

6. 利用時はsource hashを確認する

Indexが存在するだけでは不十分です。

生成後にskills.yamlが変更されていれば、Indexは古い可能性があります。

利用側では概念的に次を行います。

current := sha256Hex(mustRead("skills.yaml"))
index := loadIndex("generated/routing.min.json")

if current != index.SourceSHA256 {
    // 元データより古いIndexなので使わない
    useCanonicalRegistry()
}

Indexが古いときに、差分をAIへ推測させないのがポイントです。

一致しなければ正本へ戻る。

これだけにします。

7. 候補が決まったら正規YAMLへ戻る

例えばrepository-changeが候補になったとします。

実行時用Indexには、

["repository-change", 4, 10, ["repository change", "実装修正", "設定変更"]]

があります。

AIやToolは4〜10行を正規skills.yamlから取得します。

そこで初めて、

  • 正式path
  • priority
  • status
  • その他の正規属性

を読みます。

さらにComposition候補が選ばれた場合も、Composition名と行範囲から正規YAMLへ戻り、現在のmember一覧を取得します。

この構造にすると、生成したIndexへSkill定義を重複して持たせずに済みます。

8. 候補を絞れない場合はRegistry全文へ戻る

軽量Indexだけで無理に決め切ろうとしない方が安全です。

例えば、

  • 複数Skillが同程度に一致する
  • 強制適用Ruleがある
  • 高リスクなタスク
  • Compositionが競合する
  • trigger語だけでは必要なSkillの組み合わせを確定できない

なら、正規Registryの全文を読みます。

疑似コードなら、

switch {
case directFastRoute(task):
	return fastRoute(task)

case compactIndexFresh() && candidateIsClear(task):
	return expandCanonicalCandidate(task)

default:
	return routeWithFullCanonicalRegistry(task)
}

です。

9. CIで生成ファイルが古くないかを検査する

生成物をcommitするなら、CIで差分を検査します。

.github/workflows/verify-index.yml の例です。

name: Verify routing index

on:
  pull_request:
    paths:
      - "skills.yaml"
      - "cmd/build-index/**"
      - "generated/routing.min.json"

jobs:
  verify:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: "1.24.x"

      - run: go mod download
      - run: go test ./...

      - name: Regenerate
        run: go run ./cmd/build-index

      - name: Ensure generated file is current
        run: git diff --exit-code -- generated/routing.min.json

正規YAMLを変更したのにIndex更新を忘れたPRは、ここで落ちます。

10. Indexが再び肥大化しないように回帰テストを置く

Indexが再び太り始めるのも検知できます。

例えばPythonやGoの小さなテストで、

runtime index < canonical registry * 0.70

を確認します。

これは「70%が普遍的な正解」という意味ではありません。

今回の目的は 最初に読む情報量を小さくすること なので、その目的を壊す変更を検出する構造テストです。

別のRepositoryなら50%でも80%でも構いません。

大事なのは、「軽量Index」と名付けただけで満足せず、実際のサイズを測ることです。

実運用では一度失敗した

私の実例では、最初に生成した詳細Catalogは、正規Registry 38,533文字に対して27,103文字ありました。

約30%削減です。

そこで、実行時用と確認・デバッグ用を分離しました。

  • 実行時用: id / 検索用trigger / 正規Registry内の行範囲
  • 確認・デバッグ用: path / priority / status / metadataなども含む詳細JSON

最終的な実行時用Indexは10,657文字。

文字数ベースで 72.3%削減 になりました。

Memory向けIndexも33,301文字から6,678文字へ 79.9%削減 でした。

これは、Token数やLatencyも同じ割合で改善したという意味ではありません。

ただし、「Routing時に最初に読む情報量が本当に小さいか」は、文字数として静的に検証できるようになりました。

Skill本文の部分読みは別問題として扱う

このIndexを作ると、次に「Skill本文も見出し単位で部分読みできるのでは」と考えたくなります。

できます。

ただし、すぐにはやらない方が安全です。

Skillの後半に、

  • 禁止事項
  • 安全上の制約
  • 完了条件
  • 問題が起きたときの戻り先
  • 検証手順

がある場合、前半だけ読んで実行すると重要条件を落とします。

そのため、最初の導入段階では、

  1. Skillを選ぶところだけ軽量化する
  2. そのタスクに必須と判断したSkill本文は全文読む

と分離するのがおすすめです。

部分読みを導入するなら、それ自体を別の回帰テスト対象として扱います。

まとめ

実装の核心はJSONを小さくすることではありません。

次の4点です。

  1. 正規Registryを唯一の正しい情報源として残す
  2. 生成したIndexは候補探しだけに使う
  3. 生成元のhashを使って、Indexが古くないことを確認する
  4. 判断できない場合は正本へ戻る

この境界を守れば、Indexは削除しても再生成できます。

AIエージェントのコンテキストを軽くしたいとき、いきなりVector DBやSemantic Searchを追加する前に、まず既存Registryから、 同じ入力なら同じ結果になる小さな索引 を作れるか試す余地があります。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?