LayerX エンジニアブログ

LayerX の エンジニアブログです。

AI明細仕訳機能におけるCodeAgentを用いたデータセット作成

こんにちは。 LayerXのバクラク事業部のAI・機械学習部でテックリードを務めております機械学習エンジニアの島越(@nt_4o54)です。
こちらはLayerX AI Agentブログリレー10日目の記事です。前回の記事はponさんによる最速でAI Agent機能をPoCするChrome Extensionsの威力でした。

今回の記事では、先日リリースを行ったAI明細仕訳の問題を題材に、データセット作成時にCodeAgentを用いた検証についてお話しします。

AI明細仕訳とは

AI明細仕訳とは、請求書に記載されている表から、勘定科目などのマスタデータや過去の編集履歴を元に仕訳を自動で作成する機能です。
企業の経理担当者様の中には、100行近い表から手打ちでエクセルに転記してマクロを叩いていたり、一つずつコピーしていたり。。と行数の多い請求書の仕訳作業に多大な工数をかけています。

AI明細仕訳はそのような負荷の高い仕訳作業を効率化するために開発した機能になります。
実際には、プロジェクトの進め方や体験面、データの貯め方、出力の工夫、マスタデータとのマッピング方法など多くの工夫があるのですが、今回はデータセット作成に焦点を当ててお話しします。

データセット作成の難しさ

AI明細仕訳は現状生成AIのAPIなどを用いて提供しているのですが、コストやレイテンシ、精度の観点から内製モデルとして提供したいというモチベーションがあります。 AI明細仕訳のような機能を持つ機械学習モデルを開発するためには「どの表の」「どのような行の内容から」「どのような仕訳を作成すればいいか」をAIに学習させる必要があります。
しかし、AI明細仕訳を開発する以前は、表抽出パイプラインはもちろん動いておらず、ログとして残っている仕訳データも、必ずしも請求書に記載されている表1行に対して1行仕訳が作成されている訳ではありませんでした。
例えば、以下のような請求書があった場合に、単純に合計金額だけで1行の仕訳を切るお客様もいれば、「営業本部」などの部門ごとに仕訳を切るお客様、サーバーレンタル料金に掛かる「通信費」のような勘定科目単位で仕訳を切るお客様、はたまた1行ずつ丁寧に仕訳を切るお客様もいたりします。
更に表には税抜金額しか書かれておらず、そこから仕訳を切る際には税込金額に変換されていたりもします。

つまり現時点では、我々が関知していないお客様による何かしら合算のような処理が行われた結果の仕訳データのみが手元にはある状態です。

今回はCodeAgentを用いて、このような状態からAI明細仕訳モデルを開発するのに必要なデータセット作成に挑戦しました。
これが実現できれば、手元にある大量の仕訳データから機械的に大量のアノテーションデータを作成でき、モデル開発に必要なデータを高速に貯めることができます。

何故CodeAgentか

一般的なAgentがJSONオブジェクトや構造化テキストなどの事前定義されたアクション形式でToolを実行することで環境と情報をやり取りするのに対し、CodeAgentは基本的にコードを生成して実行することで環境と情報をやり取りします。
これにより以下のようなメリットがあります。

  • 1つのアクション内で複数のツールを呼び出すことが可能
  • ループや条件分岐を使用して複雑な制御フローの実装が可能
  • 変数を使用して中間結果を保存および操作可能
  • 既存ライブラリの広大なPythonエコシステムを活用可能

今回扱うデータは二つのテーブル形式のデータの突合を行うような問題形式に置き換えられます。
実際に人間がこれらの突合を行う場合、以下のような手順が考えられます。

  1. 勘定科目などの仕訳の内容とそれぞれの表を突き合わせて、明細として使っていそうな表の内容を絞り込む。
  2. 仕訳が切られている表の内容から、どのような単位 (部門、勘定科目、品目など)で仕訳を切っているかを明らかにして、組み合わせの候補を絞る。
  3. 仕訳の金額を見て、表に記載されている金額を手計算で組み合わせ、再現できたら確定する。

このようなテーブルデータの操作と自然言語的な内容の理解、複雑なロジックを一つ一つpromptを書いたり、toolを実装することも可能です。
しかし、テーブル操作などはpolarsやpandasを使うことのできるPythonだと柔軟に表現でき、金額の組み合わせの総当たりや計算、バリデーションなど一つ一つのロジックは、コードで表現した方がシンプルだと考えられます。 学術的にも、複雑なToolを複数使うようなAgentを用いるより、PythonのCodeを実行して環境と相互作用するようなAgentの方がベンチマークで20%程度性能が良く、平均で2.1ターン少なく問題を解くことができると報告されていたりします。*1

*1より引用

そのため、今回はCodeAgentの性能が本当に高いのか、というのを確かめる意味でもCodeAgentを用いて検証を行います。

smolagents

smolagents*2とはHuggingFace製のCodeAgentに特化したAgent Frameworkです。
お手軽にCodeAgentを試すことができ、LocalPythonExecutorによるlocalでのコード実行だけでなく、DockerExecutorなどを用いてコンテナ分離してコードを実行することができます。
今回は、手元での検証ということでLocalPythonExecutorを用いて実行しました。

以下のように、簡単にCodeAgentを実行することができます。

import os

from smolagents import AzureOpenAIServerModel, CodeAgent

model = AzureOpenAIServerModel(
    model_id=os.environ.get("AZURE_OPENAI_MODEL"),
    azure_endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"),
    api_key=os.environ.get("AZURE_OPENAI_API_KEY"),
    api_version=os.environ.get("OPENAI_API_VERSION"),
)

agent = CodeAgent(
    tools=[],
    model=model,
    stream_outputs=True,
    additional_authorized_imports=["pulp"], # pulpで数理最適化を扱えるように
    executor_type="local", # LocalPythonExecutorを使う指定
)

agent.run("""
    ## 多次元ナップサック問題

    30個のアイテムから、重量制限100kg、体積制限80L以内で価値の合計が最大になる組み合わせを求めてください。

    items = [
        {'name': 'A1', 'weight': 12, 'volume': 8, 'value': 15000},
        {'name': 'A2', 'weight': 8, 'volume': 11, 'value': 12000},
        {'name': 'A3', 'weight': 15, 'volume': 7, 'value': 18000},
        {'name': 'A4', 'weight': 6, 'volume': 9, 'value': 8000},
        {'name': 'A5', 'weight': 10, 'volume': 12, 'value': 14000},
        {'name': 'B1', 'weight': 9, 'volume': 6, 'value': 11000},
        {'name': 'B2', 'weight': 14, 'volume': 10, 'value': 17000},
        {'name': 'B3', 'weight': 7, 'volume': 8, 'value': 9500},
        {'name': 'B4', 'weight': 11, 'volume': 5, 'value': 13000},
        {'name': 'B5', 'weight': 5, 'volume': 11, 'value': 7000},
        {'name': 'C1', 'weight': 13, 'volume': 9, 'value': 16000},
        {'name': 'C2', 'weight': 8, 'volume': 7, 'value': 10500},
        {'name': 'C3', 'weight': 6, 'volume': 10, 'value': 8500},
        {'name': 'C4', 'weight': 12, 'volume': 6, 'value': 14500},
        {'name': 'C5', 'weight': 9, 'volume': 8, 'value': 11500},
        {'name': 'D1', 'weight': 10, 'volume': 9, 'value': 13500},
        {'name': 'D2', 'weight': 7, 'volume': 12, 'value': 10000},
        {'name': 'D3', 'weight': 15, 'volume': 5, 'value': 17500},
        {'name': 'D4', 'weight': 8, 'volume': 8, 'value': 10800},
        {'name': 'D5', 'weight': 11, 'volume': 7, 'value': 14200},
        {'name': 'E1', 'weight': 6, 'volume': 6, 'value': 7800},
        {'name': 'E2', 'weight': 13, 'volume': 11, 'value': 16500},
        {'name': 'E3', 'weight': 9, 'volume': 10, 'value': 12200},
        {'name': 'E4', 'weight': 14, 'volume': 8, 'value': 17200},
        {'name': 'E5', 'weight': 5, 'volume': 7, 'value': 6500},
        {'name': 'F1', 'weight': 10, 'volume': 5, 'value': 12500},
        {'name': 'F2', 'weight': 12, 'volume': 10, 'value': 15500},
        {'name': 'F3', 'weight': 8, 'volume': 9, 'value': 10200},
        {'name': 'F4', 'weight': 7, 'volume': 6, 'value': 8800},
        {'name': 'F5', 'weight': 11, 'volume': 8, 'value': 13800}
    ]

    制約:
    - 重量の合計 ≤ 100kg
    - 体積の合計 ≤ 80L
    - 各アイテムは0個または1個のみ選択可能

    最適な組み合わせと合計価値を教えてください。
""")

CodeAgentの中身として基本的に以下のような内容をReActで行うようにpromptで制御されています。

  1. Thought: 推論と使用ツールの説明
  2. Code: Pythonコードを記述
  3. Observation: 実行結果の観察

promptに書かれている内容を一部抜粋すると以下のような内容です。

  • まずThought:で推論を説明
  • コードブロック:{{code_block_opening_tag}}{{code_block_closing_tag}}で囲む
  • 中間出力: print()で重要な情報を保存。(これにより意図を明確にしたコード生成を可能に)
  • 最終回答: final_answer()ツールで最終出力を返却して終了

実際に出力を見るとそのような内容が観察できます。

出力例

Thought:                                                                                                                                                                          
多次元(重量・体積)0-1ナップサック問題なので、整数計画(混合整数最適化)で解きます。pulpを使い、各アイテムに対して二値変数を作り、価値の総和を最大化、重量≤100、体積≤80の制約を課
します。解の選択アイテムと合計(価値・重量・体積)を出力し、その結果を final_answer に渡します。                                                                                  



┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                                                                                    問題定義                                                                                    ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
Thought:                                                                                                                                                                          
多次元(重量・体積)0-1ナップサック問題なので、整数計画(混合整数最適化)で解きます。pulpを使い、各アイテムに対して二値変数を作り、価値の総和を最大化、重量≤100、体積≤80の制約を課
します。解の選択アイテムと合計(価値・重量・体積)を出力し、その結果を final_answer に渡します。                                                                                  


items = [ {'name': 'A1', 'weight': 12, 'volume': 8, 'value': 15000}, {'name': 'A2', 'weight': 8, 'volume': 11, 'value': 12000}, {'name': 'A3', 'weight': 15, 'volume': 7, 'value':
18000}, {'name': 'A4', 'weight': 6, 'volume': 9, 'value': 8000}, {'name': 'A5', 'weight': 10, 'volume': 12, 'value': 14000}, {'name': 'B1', 'weight': 9, 'volume': 6, 'value':    
11000}, {'name': 'B2', 'weight': 14, 'volume': 10, 'value': 17000}, {'name': 'B3', 'weight': 7, 'volume': 8, 'value': 9500}, {'name': 'B4', 'weight': 11, 'volume': 5, 'value':   
13000}, {'name': 'B5', 'weight': 5, 'volume': 11, 'value': 7000}, {'name': 'C1', 'weight': 13, 'volume': 9, 'value': 16000}, {'name': 'C2', 'weight': 8, 'volume': 7, 'value':    
10500}, {'name': 'C3', 'weight': 6, 'volume': 10, 'value': 8500}, {'name': 'C4', 'weight': 12, 'volume': 6, 'value': 14500}, {'name': 'C5', 'weight': 9, 'volume': 8, 'value':    
11500}, {'name': 'D1', 'weight': 10, 'volume': 9, 'value': 13500}, {'name': 'D2', 'weight': 7, 'volume': 12, 'value': 10000}, {'name': 'D3', 'weight': 15, 'volume': 5, 'value':  
17500}, {'name': 'D4', 'weight': 8, 'volume': 8, 'value': 10800}, {'name': 'D5', 'weight': 11, 'volume': 7, 'value': 14200}, {'name': 'E1', 'weight': 6, 'volume': 6, 'value':    
7800}, {'name': 'E2', 'weight': 13, 'volume': 11, 'value': 16500}, {'name': 'E3', 'weight': 9, 'volume': 10, 'value': 12200}, {'name': 'E4', 'weight': 14, 'volume': 8, 'value':  
17200}, {'name': 'E5', 'weight': 5, 'volume': 7, 'value': 6500}, {'name': 'F1', 'weight': 10, 'volume': 5, 'value': 12500}, {'name': 'F2', 'weight': 12, 'volume': 10, 'value':   
15500}, {'name': 'F3', 'weight': 8, 'volume': 9, 'value': 10200}, {'name': 'F4', 'weight': 7, 'volume': 6, 'value': 8800}, {'name': 'F5', 'weight': 11, 'volume': 8, 'value':     
13800} ]                                                                                                                                                                          

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                                                                                    問題定義                                                                                    ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

prob = pulp.LpProblem("MultiDim_Knapsack", pulp.LpMaximize)                                                                                                                       

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                                                                                      変数                                                                                      ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

names = [it['name'] for it in items] x = pulp.LpVariable.dicts('x', names, lowBound=0, upBound=1, cat='Binary')                                                                   

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                                                                             目的関数:価値の最大化                                                                             ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

prob += pulp.lpSum([it['value'] * x[it['name']] for it in items])                                                                                                                 

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                                                                                制約:重量、体積                                                                                ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

prob += pulp.lpSum([it['weight'] * x[it['name']] for it in items]) <= 100 prob += pulp.lpSum([it['volume'] * x[it['name']] for it in items]) <= 80                                

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                                                                              解く(CBCソルバー)                                                                               ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

prob.solve(pulp.PULP_CBC_CMD(msg=0))                                                                                                                                              

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                                                                                    解の収集                                                                                    ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

selected = [] total_weight = 0 total_volume = 0 total_value = 0 for it in items: if pulp.value(x[it['name']]) > 0.5: selected.append(it['name']) total_weight += it['weight']     
total_volume += it['volume'] total_value += it['value']                                                                                                                           

print("Selected items:", selected) print("Total value:", total_value) print("Total weight:", total_weight) print("Total volume:", total_volume)                                   

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                                                                                   詳細リスト                                                                                   ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

print("Details of selected items:") for it in items: if it['name'] in selected: print(it)                                                                                         

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃                                                                           最終結果を返す(日本語で)                                                                           ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛


result_text = ( f"最適な組み合わせ: {selected}\n" f"合計価値: {total_value} (単位)\n" f"合計重量: {total_weight} kg (制約: 100 kg)\n" f"合計体積: {total_volume} L (制約: 80 L)\n"
) final_answer(result_text)                                                                                                                                                       
最適な組み合わせ: ['A2', 'A3', 'A5', 'C2', 'D1', 'D4', 'D5', 'E1', 'E4', 'F1']
合計価値: 130500 (単位)
合計重量: 100 kg (制約: 100 kg)
合計体積: 80 L (制約: 80 L)

また、planning_intervalパラメータを設定すると事前にタスクプランニングを行ったり、return_full_result=Trueを渡すとprintで出力される中間出力などのログにもアクセスできるようになります。
今回は、このCodeAgentを検証に用います。

実験

データセット

今回は以下のように検証用のデータセットを作成しました。
まず正解データですが、請求書から抽出した表とその表から1:1で仕訳を切る前提で、実際に仕訳データを作成して準備しました。
ただし、既存のお客様は表と仕訳の対応関係がN:1になることもあるので、ランダムで同じ勘定科目や部門のものを合算させたりと複雑なデータにしました。
イメージとしては仕訳一行に対して以下のようなデータが作成されるイメージです。labelが複数ある場合は、表の複数の行からその仕訳が作成されたことを表しています。
(実際には、1:Nで表の内容をプロジェクト単位で按分することもあるのですが、この機能としては正確に表の転記をすることをスコープにしているので、対象外としています)

{
  "journal_idx": 0,
  "account_item_name": "広告宣伝費",
  "section_name": "営業本部",
  "amount": 6655000,
  "label": [
    { "table_idx": 0, "row_idx": 1 },
    { "table_idx": 0, "row_idx": 2 },
    { "table_idx": 0, "row_idx": 3 },
    { "table_idx": 0, "row_idx": 4 },
    { "table_idx": 0, "row_idx": 6 },
    { "table_idx": 1, "row_idx": 0 },
    { "table_idx": 1, "row_idx": 1 },
    { "table_idx": 2, "row_idx": 0 },
    { "table_idx": 2, "row_idx": 1 },
    { "table_idx": 2, "row_idx": 2 }
  ]
}

データ量としては数100書類ほどにし、大規模なデータで正確な検証を行うよりも、Agentの試行錯誤を高速化することを優先して検証を行いました。
評価としては、どれくらい予測した表と行が仕訳単位で被っていたかを計算し、全体のJaccard係数とPrecision、Recallで評価します。

結果

CodeAgentで本当に精度が良くなるのかを確かめるために比較として、単純に一度だけ推論を行うLLMと比較させます。

今回は以下のようなsystem promptを持ち、user promptに抽出した表全てのmarkdownと仕訳結果のmarkdownを与えて推論させたモデルをベースラインとしました。プ

        あなたは請求書に記載されている表から仕訳を作成する専門家です。
        以下のような手順で仕訳に利用した表とどの行が仕訳に使用されたのかを突合してください。
        <procedure>
          ユーザによって最終的に作成された仕訳と、請求書から抽出された表の一覧が与えられます。
          1. 各表から金額の記載部分がどこになるのかを特定してください。
          2. 金額の組み合わせから仕訳の金額を再現できる表を特定してください。
          3. 複数の突合候補がある場合は、勘定科目の値や部門の値を参考に一致する行を特定してください。
          4. 仕訳の金額を再現できる表とその行を出力してください。
        </procedure>
        <notification>
          表に書かれている金額から以下のような修正がされていることがあるので注意してください。
          - 表には金額が税抜き金額で書かれており、仕訳では1.1倍や1.08倍された税込金額で書かれていることがあります。
          - 表の複数行を用いて仕訳1行を作成していることがあります。
        </notification>
        <self_reflection>
        まず、自信が持てるまでどのような手順で抽出するかを考えてください。
        必ず、予測した結果から仕訳行単位で金額が再現できるかを確かめてください。
        複数の突合候補が考えられる場合は、一個のみを出力してください。
        </self_reflection>

これに対し、同様のpromptを入力にあたえたCodeAgentとの精度を比較します。 以下が結果になります。

手法 LLM モデル パラメータ 実行時間 (*) Jaccard Recall Precision
LLM (Responses API) GPT4.1-mini - 17.01(req / s) 0.28198 0.34994 0.59217
LLM (Responses API) GPT5-mini {"reasoning": {"effort": "low"}} 4.55 (req/s) 0.32735 0.41264 0.61298
LLM (Responses API) GPT5-mini {"reasoning": {"effort": "medium"}} 1.19 (req/s) 0.44092 0.50051 0.78738
LLM (Responses API) GPT5-mini {"reasoning": {"effort": "high"}} 0.34 (req/s) 0.44112 0.48509 0.82952
CodeAgent GPT4.1-mini - 1.48 (req / s) 0.37084 0.44707 0.68503
CodeAgent GPT5-mini {"reasoning": {"effort": "low"}} 2.31 (req/s) 0.35098 0.41212 0.70202
CodeAgent GPT5-mini {"reasoning": {"effort": "medium"}} 1.64 (req/s) 0.38990 46043 0.71680
CodeAgent GPT5-mini {"reasoning": {"effort": "high"}} 0.025 (req/s) 0.48297 0.56834 0.74983
CodeAgent GPT5-mini {"reasoning": {"effort": "high"}, "verbosity": "low"} 0.053 (req/s) 0.46480 0.55652 0.72824

(* 再大100並列でAPIを実行しており、180secでタイムアウトを設定しているため参考値。またCodeAgentはLocalRunnerを利用しているためGILの影響も受けています。)

結果としてCodeAgentを用いた結果が論文などで報告されている通り精度が高くなりましたが、意外と単純にLLMでポンするだけでも精度が高く、レイテンシも低いことが分かりました。
今回のようなタスクにおいては、GPT5レベルの推論能力で特にCodeAgentを使用しなくても、単純にreasnong_effortを大きくするだけで精度改善が見込めるようです。
実際、GPT4.1の場合に比べてCodeAgentを用いることによる性能に対する寄与が小さくなっている様子も観察できます。
今の時代だと素のGPT5でもCodeAgentに匹敵する性能が、複雑なタスクでもコスパ良く出せそうなので、実務で取り組む上ではまずGPT5を使いそうという所感です。

とはいえ、今回のユースケースでは、実用するには精度としてはまだまだ改善の余地があります。CodeAgentの場合は、実際に実行したコードもログとして取得できるので、そのログを用いてpromptを改善するといったデバッグ用途としても有用だと感じました。普通のAPI実行でも思考過程を除くことはできますが、エンジニア的には見慣れたコードで確認できるとデバッグが捗ります。

例えば、今回失敗していたケースだと「税込変換」といった処理時の四捨五入といった端数処理が上手くいっておらず、Validationが通らないため何も出力しないといった事象がありました。
このようなケースを一つ一つ拾ってpromptの指示に組み込んでいき、より精度の高いモデルを作成するのが今後の課題です。

最後に

今回はCodeAgentを使った複雑なデータセット作成方法の検証を行いました。 今回の検証では、あまり有意な精度の差は出ませんでしたが、よりコードで表現した方が有効な最適化タスクなどで今後ユースケースが出てきたら再度チャレンジしようと思います。 まだまだAgent自体は発展途上ではありますが、使えるところを見極めて新たな体験をプロダクトに導入するために、LayerXでは開発・検証を日々進めていきます! もし、少しでもLayerXでAgent開発やその基盤開発、プロダクト組み込みなどに興味がある方、全方位で採用中なので是非お声がけください!

jobs.layerx.co.jp jobs.layerx.co.jp

明日は、SREチームのtaddyさんからAgentの社内活用についての記事が公開予定です!お楽しみに!

*1:Xingyao Wang, Yangyi Chen, et al., Executable Code Actions Elicit Better LLM Agents, ICML, 2024, https://www.alphaxiv.org/ja/overview/2402.01030v4

*2:https://github.com/huggingface/smolagents