はじめに
最近、システム連携のIF定義書(Excel)をLLMで自動生成する仕組みを作って使っています。
(TIPS:AIを検索ツールで終わらせない:Javaコード解析スラッシュコマンド化で学んだ5つのTIPS)
かなり便利なんですが、1回の実行で5時間分の使用量を20〜35%も消費してしまっていて、「これは絶対どこかに無駄があるよね😇」とずっと気になっていました。
ネットで調べると「絞り込んで読め」「サブエージェントで分割しろ」と出てくるので試しました。全部うまくいきませんでした。
最終的に効いたのは、LLMに読ませる定義ファイルの形式を変えることでした。初心者が一般論を試してことごとく空振りして、本質的な問題にたどりついた記録です。
先にまとめ
最終的な変更内容と結果はこちらです。
やったこと
- 定義ファイル(CSV)読み込み → 解析結果出力用Pythonスクリプト生成 → IF定義書(Excel)の流れを廃止
- 定義ファイルをYAML に変更(階層構造を明示)
- 解析結果をJSONで出力
- 解析結果をIF定義書の形式に変換する固定ジェネレーターを用意(LLMが毎回生成していたPythonスクリプトを廃止)
結果
| シンプルな商品 | 複雑な商品(商品情報が複数) | |
|---|---|---|
| 変更前 | 処理時間 10分 / 使用量 10〜15% | 処理時間 25分 / 使用量 30〜35% |
| 変更後 | 処理時間 7分 / 使用量 5% | 処理時間 10分 / 使用量 7〜10% |
複雑な商品では処理時間が半分以下、使用量は70%以上削減できました。
やっていたこと
サービス申込処理のコードを読んで、外部システムへのリクエスト定義書(IF定義)をExcelで出力する、というものです。処理の流れは3ステップです。
STEP 1: Javaのソースコードを辿って呼び出し連鎖を把握する
STEP 2: 定義ファイル(汎用IF項目の一覧)を参照しながら、
コードから設定値を読み取る
STEP 3: 解析結果をもとにExcelファイルを生成する
STEP 1では5〜9個のJavaクラスを連鎖的に読み、STEP 2では350行超の定義ファイルをLLMに読ませていました。処理が重いのは当然といえば当然なんですが、どう軽くするかが問題でした。
失敗談① Grepで絞り込んでから読めばいいじゃん
何をしたか
ファイルを全行読み込んでいるのが問題だと考え、「Grepで対象メソッドの行番号を特定してから、その周辺だけ読む」という改善をプロンプトに追加しました。これ、LLMのコスト最適化の文脈でよく見かける手法です。
【変更前の挙動】
Read(Resource.java) ← ファイル全体(数百行)
→ 該当メソッドを探す
→ 次の呼び出し先を見つける
Read(次のService.java) ← ファイル全体(数百行)
→ ...
【変更後の挙動(狙い)】
Grep("methodName", "Resource.java")
→ 行番号: 145行目
Read(Resource.java, offset=140, limit=60) ← 必要な部分だけ
狙い通りに動けばファイルあたりの読み込み量が減るはずでした。
結果
変更前: 処理時間 10分 / 使用量 11%
変更後: 処理時間 30分 / 使用量 35%
逆効果でした。
なぜ逆効果だったか
読み込み量を減らすつもりが、ツール呼び出しの回数が爆増していました。
今回のJavaソースは1ファイルあたり300行前後という「LLMが一度に読むのにちょうどいいサイズ」でした。全部読んでしまう方がツール呼び出し1回で済む。なのに「絞り込んで読む」と判断を挟むたびに呼び出しが増え、その出力がコンテキストに積み重なって、むしろ重くなりました。
該当メソッドの読み込みから始め、呼び出しメソッド、参照定数の定義箇所、など同一ファイル内の複数読み込みが頻発するイメージです。
全体を一度に読んでいれば手元にあった情報を、絞り込んだことで「後から取りに行かなければならない情報」に変えてしまっていたわけです。
「Grep → 絞り込みRead」は大規模コードベース向けの手法で、今回の規模には合っていませんでした。 一般論として正しくても、コードベースのサイズや処理の性質によっては逆に働きます。
失敗談② サブエージェントで処理を分割しよう
何をしたか
「STEPごとに独立したサブエージェントに分けて、引き継ぎ情報だけを親に渡せば、ステップをまたいだコンテキストの蓄積が減る」という考えでプロンプトを書き換えました。
【変更前】
1つの会話で STEP 1 → STEP 2 → STEP 3 を実行
→ 各STEPの読み込み結果が全部蓄積される
【変更後(狙い)】
親Agent
└─ サブAgent STEP1 → 引き継ぎ情報だけ返す
└─ サブAgent STEP2 → 解析結果だけ返す
└─ サブAgent STEP3 → ファイル生成
コンテキスト分離はLLMの長い処理では有効とされています。
結果
サブエージェントへの分割をプロンプトに明示的に書いても、LLMが「この処理はサブエージェントに分けない方が効率的」と判断してキャンセルする場面がありました。処理の前後で情報を受け渡す必要がある構成のため、分割するメリットよりオーバーヘッドが大きくなると判断されたようです。
これも「サブエージェント分割」という手法自体は正しい。ただ今回の処理の構成がそれに向いていませんでした。
転機: 「そもそも何を読ませているか」を疑う
2つの施策が空振りした後、「読み方」ではなく「読ませるもの」を見直すことにしました。
問題のSTEP 2では、LLMが350行超のCSVファイルを読んでコードのフィールドと照合していました。このCSVの構造がどうなっていたかというと、こういうものです。
データ項目名_階層1,データ項目名_階層2,データ項目名_階層3,データ項目名_階層4,データ項目名_階層5,データ項目名_階層6,レベル,フィールド名,設定値
(情報域)申込情報,,,,,,01,orderInfo,
,(情報域)申込者情報,,,,,02,customerInfo,
,,申込者名,,,,03,customerName,
,,生年月日,,,,03,birth,
,(情報域)申込内容,,,,,02,orderContent,
,,商品ID,,,,03,productId,
,,申込種別,,,,03,orderType,
「customerName というフィールドは申込情報 > 申込者情報 の配下にある」という情報を、6列の空カラムで表現しています。
人間が階層を直感的に表現したExcelをcsvに変換したものです。
しかしそれをLLMが読むと、構造を把握するだけで余計なトークンを使います。
解決策① 定義ファイルをYAMLに変える
CSVをYAMLに書き換えました。同じ内容をYAMLで書くとこうなります。
orderInfo:
_label: (情報域)申込情報
customerInfo:
_label: (情報域)申込者情報
customerName: 申込者名
birth: 生年月日
階層構造がネストで明示されています。LLMにとっての変化は2点あります。
1. フィールドの「居場所」が一目でわかる
CSVでは「どの列が空か」で階層を読み取る必要がありました。YAMLはインデントがそのまま階層なので、customerName が orderInfo.customerInfo 配下にあることが即座にわかります。
2. フィールドの特定にキーパスが使える
このYAMLのキーパス(orderInfo.customerInfo.customerName)が、次に説明するJSONのキーとそのまま一致します。LLMが「このフィールドはどのキーで書けばいいか」を考える必要がなくなります。
解決策② 固定ジェネレーター+JSON出力
変更前のSTEP 3では、LLMがExcel生成スクリプト(Python)を毎回丸ごと書いていました。
# LLMが毎回生成していたコード(抜粋)
import csv, copy, os
def is_container(row):
return any('(情報域)' in (c or '') for c in row[:6])
def make_row(row, val=''):
row = list(row)
while len(row) <= 8:
row.append('')
if val == '' and not is_container(row):
val = '-'
row[8] = val
return row
# 商品ごとの設定値(ここだけ毎回変わる)
FIELD_MAP = {
10: '"XX-XXXXXX"',
25: '"2"(メール)',
...(数十行)
}
# 以下、CSV読み書きの定型処理がさらに続く
スクリプト1本が8,000〜19,000文字。LLMの出力トークンは入力より高コストなので、毎回書き直させるのは効率が悪い施策でした。
変更後は、LLMが出力するのは設定値のJSONだけにして、スクリプト本体は固定ファイルに分離しました。
{
"_meta": {
"product": "機能名",
"entry_class": "Resource",
"entry_method": "methodName"
},
"values": {
"orderInfo.customerInfo": [
{
"customerName": "契約者名",
"birth": "生年月日"
}
]
}
}
固定の generate_from_values.py がこのJSONを読んでCSV→Excelに変換します。LLMは解析結果を構造化して渡すだけ。スクリプトのロジックは何度実行しても同じ結果が返るので、再現性も100%保証されます。
2つの変更がセットで効いた理由
YAMLへの変更とJSON出力への変更は、ちょうどかみ合っています。
YAML のキーパス
orderInfo.customerInfo.customerName
↕ そのまま一致
values.json のキー
orderInfo.customerInfo.customerName
LLMがコードから設定値を読み取ったとき、「このフィールドをJSONのどのキーで書けばよいか」を考える必要がありません。YAMLのキーパスをそのまま使えばよい。照合コストがゼロになりました。
まとめ
試した3つのアプローチを振り返るとこうなります。
| アプローチ | 結果 | 理由 |
|---|---|---|
| Grep → 絞り込みRead | 悪化(時間3倍、使用量3倍) | ファイルサイズが小さく、ツール呼び出しが連鎖して逆効果 |
| サブエージェント分割 | 効果なし | 処理の構成上、分割のメリットよりオーバーヘッドが大きい |
| YAML化 + 固定ジェネレーター | 大幅改善 | 入力の構造を変えることでLLMの照合コストを根本から削減 |
失敗した2つは、どちらも手法として間違っていません。今回の処理構成に合っていなかっただけです。
振り返ると、「LLMがどう動くか」の最適化ばかり考えていました。見直すべきだったのは「LLMに何を読ませるか」の設計でした。
LLMに定義ファイルやスキーマを読ませる場面があるなら、その構造が「誤読しにくいか」「余計な推論をしなくて済むか」を先に確認してみてください。プロンプトより先にそこを疑う価値があります。
最後に、GMOコネクトではサービス開発支援や技術支援をはじめ、
幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。