Yyatmita

ルールブックをWorkflowに食わせて、決定論的なlintを生成する——LLM校正の確率性を2レイヤーで畳む

あるドメインのルールブックをdynamic workflowに食わせて構造化し、それをregex lint + LLM verifierの2レイヤーに落とす。LLM一発校正で200万トークン・毎回違う指摘という確率的な状態から、56倍トークン削減・10倍高速・候補集合は完全決定論まで持っていった途中経過。ground truth pytestで正当性を客観保証するメカニズムも含めて、設計の判断と失敗を時系列で記録する。

自分のエージェント基盤を組む#claude-code#agent#workflow#lint#architecture#determinism

ルールブックがある領域は多い。法令、業界規範、社内手順、コーディング規約、用字用語。これを Claude Code の dynamic workflow に食わせて構造化し、そのまま実行可能な lint に落とす——という設計を1セッションで通した。

最初に LLM だけで校正する素朴な実装をして、決定論性に欠けることに気付き、regex lint + LLM verifier の2レイヤーに組み直した。トークンは約56倍削減、時間は10倍速くなり、検出候補集合は完全に決定論的になった。各検出の正当性は、ルールブック自身の例文を ground truth とした pytest で客観的に保証されている。

題材としたドメインや書名は伏せる(手元の蔵書を私的な学習用途で扱った話で、組織所属の場合は出版元・所属の許可が要る話だ)。同型のパイプラインは法務・医療・金融・製造・出版・公務、どこのドメインのルールブックでも当てはまる。ショーケースとして抽象化したまま記録する。

技術評論社『Claude Codeで学ぶ Agent Skills入門』が出る同じ週の作業だ。あの本が「スキルを使う・作る」の地図なら、この記事はスキルを LLM に作らせて、その正当性を客観検証する応用編の途中経過にあたる。

1. ルールブックを構造化する——著作権と社内文書の取り扱い

最初の工程は、書籍 PDF 約480ページから個別ルールを構造化された YAML に落とすこと。目次から各ルールの開始ページを取り、page スライスをエージェントに渡して、項目タイトル・規則の要約・OK 例・NG 例・例外・関連 ID を構造化スキーマで返してもらう。

ここで Workflow を使う判断が要った。1ルール=1エージェントで並列に走らせる。160ルール並列、cap で queue されるが10〜15分で完了する。書き起こしのプロンプトは「rule 番号 N の解説をこのページ範囲から読み取って、決まったスキーマで返せ」だけ。

技術的な注記としてもう1つ。出版物や社内規程をエージェントに食わせるのは、個人の学習用途と組織の業務利用で扱いがまったく違う。前者は私的複製の範囲に収まる場合があるが、後者は権利者の許可がほぼ必要になる。本稿はあくまで前者の話で、Workflow の組み方そのものを記録するために抽象化している。自分の手元の本ならやってよいことが、組織の本では一気にやってはいけない——この境界は最初に確認すべきだ。

ルールブックを構造化した結果として、約160ルール+付録6本+約914パターンの YAML が手元に残った。これがこの後のすべての出発点になる。

2. ショーケースとして社内フローを1つ通す

ルールブックがあるだけでは何にもならない。「このルールブックに照らして、入力文書を校正する」社内フローを1本通すのが次の工程だ。題材は適当な業務文書を1本選んで、docx → 平文 → 文単位 ID 付与 → チャンク分割 → 並列校正 → 集約 → 提示、を通す。

エージェント設計の起点として明確にしたのは、「実行までさせない、人間承認を残す」こと。校正は提示で止める。自動修正はしない。pre-commit や CI に乗せても検出のみ・置換しない。これは agent システムの導入初期の規範として、被害ゼロから始めるための線引きだ。失敗しても誰も困らない位置に置くから、安心して実証を積める。

最初の実装は、素朴に LLM だけで校正した。チャンク × レンズ並列で、6カテゴリのレンズエージェントが本書の該当ルール群を Read して、入力文書から違反を抽出する——という設計。動くは動いた。が、これに大きな問題が3つあった。

3. スキルとWorkflowの責務分離——並列はWorkflowの内側

エージェント設計の責務をどう切るか、最初の論点はここだった。CLI ラッパは何を持ち、スキルは何を持ち、Workflow は何を持つか。

整理した結果はこうなる。

  • CLI ラッパ (Bash): docx → text 変換、claude -p 呼び出し、出力整形、/tmp 掃除
  • スキル (SKILL.md): 内部手順(前処理 → Workflow 起動 → 集約 → 提示)の指示書
  • Workflow (.js): 並列実行エンジン(N チャンク × M レンズ並列、集約、dedup、sort)
  • lint パッケージ (Python): 後で出てくる regex 適用レイヤー
  • rules/*.yaml: 構造化ルール DB(凍結)

ここで一度、「Workflow をアトミックにして、スキル側で並列をオーケストレーションする」案を考えた。Workflow を「1チャンク1校正」みたいな最小単位にして、スキルがそれを N 回呼ぶ形だ。これは却下した。理由は2つ。

ひとつ、Workflow の存在意義はそもそも「複数エージェントを deterministic に並列実行する」ことにある。並列度の制御を外に出すと、Workflow を使う意味が薄まる。

ふたつ、スキル ≒ Claude のメインループは1本しかない。複数 Workflow を順次起動して待ち合わせると、本質的に直列になる。スキルは I/O アダプタ、Workflow は並列実行エンジン、と割り切るのが筋がいい。

dynamic workflow の中で、チャンクとレンズの直積を1つの parallel に展開し、その内側で集約・重複除去・ソートまでやって、最終的な findings 配列だけスキルに返す。スキルはそれを表に整形して人間に見せる。これで責務はきれいに分かれた。

4. LLM一発校正は決定論的じゃない

責務分離が決まって、6レンズ並列の素朴な実装で1セッション走らせた。検出7件。「悪くない」と思った。

別セッションで同じ文書を同じ Workflow に通したら、検出が違った。前回は出た指摘が出ず、出なかった指摘が出る。両方とも妥当な指摘だ。LLM の sampling が確率的なので、見落としの場所が run ごとに変わる。同じ文書に対して結果が再現しないツールは、実運用に出せない。

数値で言うと、この素朴な実装は1文書あたり約200万トークン、約2分、検出件数は run ごとに4〜7件で揺れる状態だった。校正観点の網羅性は出ているが、決定論性が皆無なので「同じ入力 → 同じ出力」が成立しない。pre-commit や CI に組み込むこと自体が成り立たない。

ここで2つの方向性があった。

ひとつ、multiple-runs で union を取る。3回走らせて全部マージすれば取りこぼしは減る。が、これは決定論性そのものを解決していない。再現性は依然としてない。

ふたつ、LLM を呼ぶ前に regex で候補集合を確定させる。決定論的な lint で候補を絞り、LLM は「絞られた候補が文脈で本当に違反か」を yes/no で判定するだけにする。後者を選んだ。

5. regex pre-filter + LLM verifier——2レイヤーで畳む

Layer 1 と Layer 2 に分ける。

入力テキスト
   ↓
Layer 1: Python regex lint(決定論、トークンゼロ)
   ├ 確定指摘(規範違反が文脈なしで決まるもの)
   └ verify 候補(regex で当たるが、文脈で OK/NG が分岐するもの)
   ↓
Layer 2: LLM verifier(候補のみを1回のバッチ判定)
   ├ violate(確定)
   └ ok(誤検出だった)
   ↓
集約 → 提示(人間承認)

Layer 1 は完全に決定論的だ。同じテキストに同じ regex を当てるだけなので、同じ入力なら必ず同じ候補集合が出る。トークンは消費しない。

Layer 2 は LLM を使うが、役割が「open-ended な検出」から「狭い yes/no 判定」に変わっている。候補は決まっていて、文脈で許容範囲か違反かを1bitで返すだけ。temperature 影響が大きく減って、同じ候補に対する判定はほぼ安定する。実装上は、候補リストと元文書を Workflow スクリプトに埋め込んで1エージェントでバッチ verify した。

数値はこうなった。

観点素朴な LLM 校正2レイヤー(lint + verify)
トークン約200万約36K
時間約2分約11秒(lint 0.2秒 + verify 11秒)
検出候補run ごとに揺れる完全に決定論
同じ文書での再現性なしあり(verify の判断のみ薄く揺れる)

約56倍トークン削減、10倍高速、決定論性確保。同じ品質を、確率性なしで出せる状態になった。

6. ルールはregexで書けるのか——rubricを明文化する

ここで前のめりにやらず、立ち止まる必要があった。「914パターンの regex は誰が正しいと保証したのか」という問題だ。

最初に Workflow に「各ルールに detect_patterns を生成してくれ」と頼んだとき、各エージェントが個別に「これは regex で書ける/書けない」を判断した。判定基準は明文化されていない。同じルールを別 run で評価すれば、また違う分類になりうる。生成過程が確率的なので、914パターン自体が信頼できる前提に立っていない

これに対して判定基準(rubric)を明文化した。各ルールに対して以下の9要件に boolean で yes/no を埋める。

A. lint_only(regex のみで判定可)
  A-1: 違反パターンを具体文字列で記述できる
  A-2: 例外を exceptions_regex で完全列挙可能
  A-3: 構文的に判定が確定する
  → A-1 ∧ A-2 ∧ A-3 ならば A

B. lint_with_llm_verify(regex で候補抽出、文脈で最終判定)
  B-1: 目的語・主語・前後文脈で OK/NG が分岐
  B-2: 慣用例外が多くホワイトリスト化困難
  B-3: 同一 regex が文脈で OK/NG 反転
  → A でなく、B-* のいずれかなら B

C. llm_required(regex 不適、LLM のみ)
D. not_implementable(機械化困難)

エージェントには「直感で分類しろ」ではなく「rubric の9要件を順に check して、適用順序に従って分類しろ。各 yes/no の根拠を justification に書け」と指示する。

これで個別判断のばらつきは大きく減る。結果は A=30 / B=128 / C=2 / D=0 になった。各分類に「A-1 は yes(理由)、A-2 は yes(理由)、A-3 は yes(理由)、よって A」のような justification が必ず付いてくる。

この rubric 自体も人間が決めた規約だから、誰かが他の rubric を立てれば違う分類になりうる。「LLM の直感」から「人間が明文化した規約への LLM 適用」に評価レイヤーを1つ上げた、という性質の改善だ。完全な決定論にはなっていないが、判定の根拠を辿れる状態にはなった。

7. 正当性は誰が保証するか——ground truthを本書から引く

rubric で分類しても、まだ問題がある。A 判定の各ルールが、本当に決定論的に違反を捕まえているかは別の話だ。regex が examples_ng を1件も検出していなかったり、examples_ok を誤検出したりしているかもしれない。

ここで救いになったのは、ルールブック自身が規範ごとに「OK 例」「NG 例」を提示していることだった。本書がそうしている以上、それを ground truth として pytest fixture にすればよい。

@pytest.mark.parametrize("rule_id,ng_example", A_NG_CASES)
def test_A_ng_detected(rule_id, ng_example):
    """A 判定 rule の NG 例 → lint で検出される(取りこぼしテスト)"""
    findings = lint_text(ng_example, [rule])
    assert findings, f"{rule_id}: NG 例 {ng_example!r} が検出されない"
 
@pytest.mark.parametrize("rule_id,ok_example", A_OK_CASES)
def test_A_ok_not_detected(rule_id, ok_example):
    """A 判定 rule の OK 例 → lint で誤検出されない(過剰検出テスト)"""
    findings = lint_text(ok_example, [rule])
    assert not findings, f"{rule_id}: OK 例 {ok_example!r} が誤検出"

全 A ルールに対して examples_ng と examples_ok を当てる。最初は2208ケース中6件 fail。1件ずつ regex を補強して、最終的に2208 pass / 0 failまで持っていった。

このとき副次的に大きい発見が1つあった。失敗した1件で、Workflow が生成した exceptions_regex「真の検出ターゲットを例外として除外してしまうロジックミス」が混入していた。具体的には「ターゲット語 + 直後がひらがな」を例外指定していて、ターゲット語の典型用法をことごとくスキップしていた。

これは人間が yaml を眺めても見つけにくいバグだった。examples テストで初めて気付ける性質のもの。Workflow に何かを作らせたら、その何かが本当に意図通り動いているかを、ルールブック自身の例文で機械的に検証する仕組みが要る——という教訓だ。

正当性の保証メカニズムが、初めて自己完結したループとして動くようになった。

1. ルール追加・修正
2. examples_ng / examples_ok をルールブックから記入
3. detect_patterns を作る(LLM or 人間)
4. pytest 実行
   ├ 失敗 → regex 補強 → 3 へ戻る
   └ 全 pass → 客観的に正当性 OK
5. git commit(yaml と patterns が同期)

8. 残課題と「実証はまだまだこれから」

ここまでで2レイヤーは動き、A判定30ルールは ground truth テストに完全合格した。が、実証はまったく足りていない。

ひとつ、B判定128ルールの verify 経路は本走未検証。lint 単独で false positive が出すぎていないか、verify で正しく弾けるかは、もっと多様な文書で当てて確認する必要がある。

ふたつ、pattern 単位での A 昇格。ルール全体は文脈判定要だが、特定の NG 例文字列パターンは決定論的——というケースが結構ある。今は rule 単位でしか分類していないので、pattern 単位の rubric 適用に拡張する余地がある。

みっつ、自動修正はまだ早い。仕組み上は auto_apply=true のパターンを置換適用できるが、当面はオフ固定にしている。十分なドメイン文書で「提案を出して人間が採用率を判断する」期間を経てから、置換モードを開放する。

そして、同型タスクへの展開。法務文書のドメイン規範、医療記録の記載ガイドライン、コーディング規約、社内マニュアル——どれも構造は同じだ。「ルールブック → 構造化 → rubric 分類 → 2レイヤー lint → ground truth テスト」のパイプラインは、対象ドメインを差し替えても通る。同じテンプレートで複数ドメインに展開していくのが、本稿のアーキテクチャの実証になる。

終わりに

LLM の確率性そのものを否定する必要はない。確率的な検出は強い。が、「確率的に検出する」と「確率的に判定する」は分けられる。決定論的に候補を出して、確率的に文脈判定する。それだけで運用上の決定論性は取り戻せて、トークンは桁で削れる。

Agent Skills を使う・作るのが入門だとしたら、スキルを LLM に作らせて、その正当性を客観検証するのが次の段階だと思う。スキルを生成するメタスキル、Workflow を生成するメタ Workflow、rubric を生成するメタ rubric——どこかで決定論的な保証メカニズムを差し込まないと、生成物の信頼が積み上がらない。本稿で組んだ ground truth pytest は、その最小単位だ。

実証はこれからだ。自動実行をさせない位置から始めて、人間承認のループを残しながら、徐々に置換モードを開放していく。失敗しても被害ゼロの場所から始める、という規範が、エージェント基盤を実運用に乗せる初手として一番強いと信じている。