2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

あの手この手を試してもダメで、結局「読ませる形式」が問題だった話――CSVをYAMLに変えたら処理時間60%・使用量70%削減できた

2
Last updated at Posted at 2026-05-26

はじめに

最近、システム連携の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はインデントがそのまま階層なので、customerNameorderInfo.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コネクトではサービス開発支援や技術支援をはじめ、
幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。

お問合せ:https://gmo-connect.jp/contactus/

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?