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の後半に、
- 禁止事項
- 安全上の制約
- 完了条件
- 問題が起きたときの戻り先
- 検証手順
がある場合、前半だけ読んで実行すると重要条件を落とします。
そのため、最初の導入段階では、
- Skillを選ぶところだけ軽量化する
- そのタスクに必須と判断したSkill本文は全文読む
と分離するのがおすすめです。
部分読みを導入するなら、それ自体を別の回帰テスト対象として扱います。
まとめ
実装の核心はJSONを小さくすることではありません。
次の4点です。
- 正規Registryを唯一の正しい情報源として残す
- 生成したIndexは候補探しだけに使う
- 生成元のhashを使って、Indexが古くないことを確認する
- 判断できない場合は正本へ戻る
この境界を守れば、Indexは削除しても再生成できます。
AIエージェントのコンテキストを軽くしたいとき、いきなりVector DBやSemantic Searchを追加する前に、まず既存Registryから、 同じ入力なら同じ結果になる小さな索引 を作れるか試す余地があります。
参考資料
- AIエージェントのSkill Routing設計
https://zenn.dev/c_a_p_engineer/articles/ai-agent-skill-routing - Obsidian Vault向けReasoning Index
https://zenn.dev/c_a_p_engineer/articles/obsidian-reasoning-index - Anthropic — Effective context engineering for AI agents
https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents - Anthropic — Equipping agents for the real world with Agent Skills
https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills - yaml.v3 package documentation
https://pkg.go.dev/gopkg.in/yaml.v3 - Qiita Markdown記法 チートシート
https://qiita.com/Qiita/items/c686397e4a0f4f11683d