はじめに
※この記事は「エムティーアイ Blog Summer 2026」の 6/19 の記事です。
こんにちは。テクノロジー本部の田中です。
以前からエージェントアプリ自作に興味があってやっていたことを改めてまとめました。
GPUなし、CPU推論だけで、LLMエージェントはどこまで実用的にできるのか。
この問いに答えようとして、Ollama + LangChain + MCP でローカルエージェントをフルスクラッチで作り、実験しました。
最初の結果は「14bは全タスクタイムアウト」という壊滅的なものでしたが、試行錯誤を重ねてある程度動くようになりました。
本記事では構成・実験設計・実験で分かったことを共有します。
「実験管理を Claude Code に任せる」という構造が意外と機能したので、その話も書きます。
環境・構成
| 項目 | 値 |
|---|---|
| CPU | AMD Ryzen 9 6900HX |
| GPU | なし(CPU推論のみ) |
| OS | WSL2 + Docker |
| 推論速度 | prefill 29 tok/s / generation: 7b=10.9 tok/s, 14b=6.0 tok/s |
| モデル | qwen2.5:7b, qwen2.5:14b |
Ollama + LangChain でローカルLLMをラップして tool calling を扱います。 ツール群は MCP サーバー(filesystem / shell / websearch / SQLite)として別コンテナで動かし、エージェントのコードと完全に分離しています。
エージェントモードは2種類実装しました。
plan_exec モード(LLMが計画→逐次実行)
- メリット: ステップが明確で進捗を追いやすい。失敗したときに Replan で部分的な修正ができる
- デメリット: 計画と実行履歴が両方コンテキストに積み上がるため、長いタスクほどコンテキストが膨らむ

ReAct モード(計画フェーズなし・1ループで推論+ツール呼び出し)
- メリット: 計画生成フェーズがない分、コンテキストを短く保てる。柔軟にループを抜けられる
- デメリット: 「もう終わり」という終了判断を LLM 自身が行うため、小モデルでは自己終了に失敗しやすい

実験ハーネスを Claude Code で作った
エージェントを改善するには「今何点か」を測る指標が必要です。 そこで、ローカルLLM(qwen2.5)が作った成果物を Claude が採点するという指標を考えました。
| 指標 | 説明 |
|---|---|
| TCA | ツールを呼んだターンの割合。低い = テキストだけ回答して終わっている |
| StepCR | 計画ステップの完了率(plan_exec 用) |
| Judge スコア | 成果物を 0〜5 点で評価した合計点 |
Judge スコアは成果物ファイルを実際に確認・実行しないと採点できません。
そこで Claude Code のカスタムスキルで自動化しました。
/plan-experiment # 仮説・設定・評価基準を書く /judge # コンテナに入って成果物確認・採点 /record-experiment # 結果を記録 /conclude-experiment # 考察まとめ
実際の流れ:
$ ./scripts/bench.sh --tier hard --models qwen2.5:14b --mode plan_exec 所要時間: 1398秒 ... $ /judge → ファイル確認・コード実行・採点 → judge_report.md に保存 $ /conclude-experiment → /plan-experiment(次の仮説へ)
ベンチマークのタスクは難易度ティアで管理しています。
主に使った 2 ティアの内容は以下のとおりです。
medium tier(3問)
| # | タスク |
|---|---|
| M1 | SQLite に売上データを 10 件 INSERT し、Python で合計・最大・最小・平均を集計したレポートを /data/sales_report.txt に保存 |
| M2 | Web で「Python asyncio tutorial」を検索し、asyncio の主要概念 5 点を日本語でまとめたノートを /data/asyncio_notes.txt に保存 |
| M3 | /data/primes.py(1〜100の素数)を作成・実行・コードレビューし、レビューコメントを /data/review.txt に保存 |
hard tier(2問)
| # | タスク |
|---|---|
| H1 | Web で「2024年人気プログラミング言語ランキング」を検索し、上位 5 言語を SQLite に保存、Python で集計レポートを /data/lang_rank.txt に作成 |
| H2 | /data/counter.py(1〜10カウント)を作成→実行→確認→1〜20に修正→再実行し、変更前後の比較レポートを /data/counter_report.txt に保存 |
medium の各タスクは「検索・DB・ファイル生成」など複数ツールを組み合わせる設計です。
hard はさらに「実行→確認→修正」という状態依存のループを含みます。
実験で分かったこと
14回の実験のうち、特に重要な知見が得られた3回(①③⑭)を紹介します。
実験① ベースライン
まず何も調整しない状態で計測しました。 条件: plan_exec / hard tier / trimming=False / window=False。
| モデル | TCA | StepCR | Judge スコア |
|---|---|---|---|
| qwen2.5:7b | 0.800 | 0.327 | 0/10 |
| qwen2.5:14b | 0.845 | 0.443 | 1/10 |
Judge は成果物ゼロが続き、合計 1/20。7b が 3 ターン・14b が 5 ターンで早期タイムアウトしており、ほぼ着手できない状態でした。
原因は 14b の「stuck ターン」でした。
1ターンで3000トークンの長文を生成してしまい、prefill だけで100秒超えが多発していました。(3000 tokens → prefill 103s + gen 60s ≈ 163s/turn)
14b は1ターンあたり平均 250s 以上を消費しており、コンテキストの肥大化が最大のボトルネックであることが数字からも明確です。
直感的な対策で「コンテキスト長を 2048 に削れば速くなるのでは?」とも試しました。
結果は最悪でした。7b は fetch_page 数回でコンテキストが溢れて無限ループに、14b は計画全体が消えて誤動作し TCA が 0.917→0.500 に激減。
削るべきはコンテキスト長ではなく、ツール結果そのものという示唆を得ました。
実験③ ツール結果のトリミング
実験①と同条件(plan_exec / hard tier)で trimming=True のみ追加して計測しました。
fetch_page の結果は無制限だと8000文字を超えます。これをツール別に上限設定して切り捨てます。
TOOL_RESULT_MAX_CHARS = {
"fetch_page": 1500, "web_search": 1000,
"read_file": 2000, "execute_command": 1500,
}
Judge スコア(hard tier / plan_exec / trimming=True):
| タスク | モデル | Judge | 理由 |
|---|---|---|---|
| H1: Web検索→SQLite→レポート | 7b | 0/5 | web_search ループを脱出できずタイムアウト |
| H2: counter.py→実行→修正→レポート | 7b | 0/5 | ツール未呼び出しでテキスト回答して終了 |
| H1: Web検索→SQLite→レポート | 14b | 0/5 | 9ターン全て web_search、SQLite 未到達 |
| H2: counter.py→実行→修正→レポート | 14b | 0/5 | /data への書き込み権限なし(環境問題) |
合計 0/20。成果物ゼロという厳しい結果です。
ただし各タスクの失敗理由を見ると、trimming の恩恵はログに刻まれています。
7b の実行時間が 994s→716s に短縮し、StepCR も 0.327→0.965 に改善しました。
コンテキストの増加ペースが落ち、より多くのターンを消費できるようになっています。
「web_search ループを脱出できずタイムアウト」のような問題は trimming だけでは解決できませんでした。
LLMはツール結果の全文を読まなくても処理を進められます。
積極的に切り捨てることでコンテキスト増加を抑えられ、StepCR の改善につながりました。
補助的な対策として、会話履歴のスライディングウィンドウも試しました(実験⑤・⑥)。
window=8(直近4ターン保持)では 7b の StepCR が 63.9%→47.5% に低下しました。
小モデルは会話履歴への依存度が高いためです。
window=12(直近6ターン保持)に広げると 7b の StepCR が 90.5% まで回復しました。
なお num_predict_limit(出力トークン上限)も実験④・⑦で試しましたが、ツール呼び出し JSON が途中で切れて TCA が大幅低下。生成長の制限は逆効果でした。
実験⑭ plan_exec vs ReAct
qwen2.5:14b / medium tier で両モードを比較しました(trim=True, window=True)。
プロンプトは中国語(zh prompt)を使用しています。
qwen2.5 は中国語コーパスで訓練されているため日本語より安定するという仮説のもとで試用したものです。
| 指標 | ReAct | plan_exec |
|---|---|---|
| タイムアウト | 0/3 | 2/3 |
| 平均実行時間 | 779s | 1314s |
| Judge スコア | 2/15 | 0/15 |
plan_exec は計画ステップと実行履歴が両方積み上がるため、コンテキストの肥大化が致命的でした。
make_plan +180s・replan +160s のオーバーヘッドで全タスクタイムアウト。
ReAct は履歴が短く保てるため、CPU推論環境では構造的に有利です。
ただし ReAct が機能したのは 14b のみ。7b は Turn 1 でテキストだけ回答して即終了するパターンが多発(TCA=0.25)。ReAct は 14b 専用と判断しました。
ReAct の改善として「toolcall_only ルールで強制的にツールを呼ばせる」を試みましたが(実験⑫)、今度はモデルが終了条件を失い無限ループに陥りました。
ReAct は「tool call不要になったらテキスト回答→終了」という自己判断が必要で、それを塞ぐと終われなくなります。
tool calling の不安定さとの戦い
小モデルは存在しないツール名(edit_file → 正しくは write_file)や引数名を間違えることが頻繁にあります。
毎回エラーにすると無駄なターンが増えてCPU推論では致命的です。
そこで fixers.py で自動修正する層を挟みました。
_TOOL_NAME_ALIAS = {
"edit_file": "write_file", "run_command": "execute_command",
"search_web": "web_search", # 他40+エントリ
}
エイリアスにないものは difflib の fuzzy マッチで補完します。引数名の誤りも同様に自動変換(q= → sql= など)。
実験⑥で「arg_fixer が MCP ツールの JSON Schema dict 形式に未対応」というバグを発見しました。
write_file に file_path が渡り続けて繰り返し失敗→タイムアウトというパターンで、StepCR の異常値(100%なのに成果物ゼロ)から発覚しました。
メトリクスの異常値がバグ発見のトリガーになったケースです。
まとめ
全14回の実験のうち、最も重要な知見が得られた3回(①③⑭)の結論を絞って示します。
コンテキスト管理がすべて: CPU推論では prefill コストが支配的で、改善の多くはコンテキスト削減に帰着しました。 ツール結果トリミング・スライディングウィンドウ・ReAct 採用、いずれも本質は同じです。
小モデルは叩き直す: ハルシネーションを自動修正する層を挟むだけで無駄なターンを大幅に削減できます。
実験基盤を先に作る: Claude Code で採点・記録を自動化したことで、14回の実験を短期間で回せました。 「改善が数値で見える状態」がなければ、試行錯誤は続けられません。
GPU環境では自明に解決できる問題が、CPU制約という縛りによって可視化されます。
あえてGPUなしでやって良かったと思っています。