1. はじめに:完全無料のローカルAIエージェントを作りたい!
クラウドのAI API(GeminiやClaude、OpenAI)を使って自律型AIエージェントを作ると、ツールの呼び出しを何往復も繰り返すため、あっという間に従量課金が跳ね上がるか、無料枠のレートリミット(15RPM等)で停止します。
「それなら、最近賢くなったローカルLLM(Ollama)を使って、完全無料・オフライン・無制限でPCのアプリを操作するデスクトップAIエージェントを作ればいいのでは?」
そう考えて、Qwen2.5 (14B) や Gemma などを組み込んだWindows自律エージェント基盤をC# (WinUI 3) で開発し始めました。
しかし、いざ動かしてみると……
- 開始1秒で勝手に「完了しました!」と嘘をつく
- セルに何も書かれていない真っ白なExcelファイル(7KB)を保存して作業を終了する
- APIから返ってきた通信用IDをドキュメントIDと勘違いして自爆ループに陥る
クラウドの巨大モデルでは見られなかった**「小型モデル特有のサボりや勘違い」**のオンパレードで大惨事になりました。
本記事では、16B以下のローカルLLMを自律エージェントとして本気で実用化するために、「なぜサボるのか」を徹底解剖し、アプリ(フレームワーク)側のコードでどうねじ伏せたかの実戦Tipsをまとめました。
2. 罠①:Ollamaの「コンテキスト長2Kの壁」
発生した現象
エージェントに「見積書を作って保存して」と頼むと、新規ブックを開いた直後に突然「作業が終わりました」と報告してタスクを放棄しました。
原因
Ollamaはモデル自体のカタログスペックが32Kや128Kコンテキスト対応であっても、API経由の既定コンテキスト長(num_ctx)がわずか「2,048トークン(2K)」に絞られています。
エージェントはツール一覧の定義(JSON Schema)やシステム指示だけで、1ターン目から簡単に3,000トークン近く消費します。
つまり、指示を出した瞬間にユーザーのプロンプトが記憶領域から押し出されて消滅し、モデルは何を作ればいいか分からなくなって適当に終了していたのです。
解決策
APIリクエストの options で明示的に作業領域を16K以上に拡張し、ハルシネーション(妄想)を抑えるために temperature を低く固定します。
var payload = new JsonObject
{
["model"] = "qwen2.5:14b",
["messages"] = JsonSerializer.SerializeToNode(conversationHistory),
["stream"] = false,
["options"] = new JsonObject
{
// 既定の2048を突破し、16K〜32Kの作業領域を確保する
["num_ctx"] = 16384,
// エージェントタスクではブレを排除して決定論的に振る舞わせる
["temperature"] = 0.1
}
};
これだけで、「指示自体を忘れる」問題は一発で解消しました。
3. 罠②:通信用IDを「自分のファイル番号」と勘違いして自爆する
発生した現象
新規ワークブックを作成(new_workbook)した後、セルにデータを書き込もうとして以下のエラーで延々と失敗しました。
[ツール呼出] write_range: {"doc": 3, "range": "A1", ...}
[実行結果] write_range: no open workbook has id 3 (isError: true)
「開いているブックはID=1なのに、なぜか存在しないID=3を指定し続ける」という謎の挙動です。
原因:プロトコルノイズの誤認
MCP(Model Context Protocol)サーバーが返したJSON-RPCレスポンスの生の文字列をそのままLLMに読ませていたことが原因でした。
{"id": 3, "jsonrpc": "2.0", "result": {"content": [{"text": "{\"index\": 1}"}]}}
クラウドの超巨大モデル(GPT-4o等)は賢いので外枠の通信用 "id": 3 を無視できますが、ローカルLLMは愚直です。視界に入った "id": 3 を見て「このドキュメントのIDは3番だ!」と思い込み、以降のすべてのコマンドに doc: 3 を入れ続けて自爆していました。
解決策:プロトコルヘッダ剥ぎ取り層(UnpackToolResult)
通信の外枠(id や jsonrpc)をC#側で完全に剥ぎ取り、純粋な result.content[0].text だけを抽出してLLMにフィードバックします。
private static string UnpackToolResult(string rawResult)
{
if (string.IsNullOrWhiteSpace(rawResult)) return "(出力なし)";
try
{
using var doc = JsonDocument.Parse(rawResult);
var root = doc.RootElement;
// エラーの抽出
if (root.TryGetProperty("error", out var err))
{
string msg = err.TryGetProperty("message", out var m) ? m.GetString() ?? "" : err.GetRawText();
return $"[ツール実行エラー]: {msg}";
}
// 通信用ヘッダを除去し、純粋な text だけを抽出
if (root.TryGetProperty("result", out var res) && res.TryGetProperty("content", out var contentArray)
&& contentArray.ValueKind == JsonValueKind.Array && contentArray.GetArrayLength() > 0)
{
var sb = new StringBuilder();
foreach (var item in contentArray.EnumerateArray())
{
if (item.TryGetProperty("text", out var t)) sb.AppendLine(t.GetString());
}
return sb.ToString().Trim();
}
}
catch { }
return rawResult.Trim();
}
この剥ぎ取り層を入れたことで、通信用IDに惑わされる事故が100%消えました。
4. 罠③:中身が空のファイル(7KB)を保存して終わったフリをする
ローカルLLMに自律エージェントをやらせる上で、最も手強いのが「サボり」 です。
発生した現象
新規ワークブックを開いた後、セルへの書き込みツールで引数エラーが発生。すると、モデルはエラーを修正して再実行することを諦め、「とりあえず保存コマンドだけ呼んで、何食わぬ顔でタスク完了報告を出す」という暴挙に出ました。結果として、保存フォルダーには中身が真っ白な空のxlsxファイルが残されました。
なぜ「0バイトチェック」では防げないのか?
「ファイルサイズが0バイトなら未完成とみなす」という単純なチェックを入れようとしましたが、これは通用しませんでした。
Excel(.xlsx)やWord(.docx)などのOfficeファイルは、実態がZIP圧縮されたXMLコンテナ構造(OPC規格)です。そのため、セルや本文が完全に白紙であっても、メタデータだけで初期状態で約5KB〜8KBの容量を持ちます。ファイルサイズの絶対値で中身の有無を判定するのは不可能なのです。
解決策:「編集ライフサイクル(時系列因果関係)」によるねじ伏せ
特定アプリのコマンド名やファイルサイズに頼るのではなく、「データ作成・編集系ツールが正常成功した【後】に、保存ツールが正常成功したか」という時系列の因果関係を状態マシンとして追跡するようにしました。
実装コード
開始時点のファイル更新時刻スナップショットと、ライフサイクルフラグを掛け合わせて判定します。
// 1. ツール名のセマンティクス(意味)判定
private static bool IsMutationTool(string funcName)
{
string lower = funcName.ToLowerInvariant();
return lower.Contains("write") || lower.Contains("insert") || lower.Contains("set")
|| lower.Contains("add") || lower.Contains("draw") || lower.Contains("format");
}
private static bool IsSaveTool(string funcName) =>
funcName.Contains("save", StringComparison.OrdinalIgnoreCase) ||
funcName.Contains("export", StringComparison.OrdinalIgnoreCase);
// 2. 実行ループ内でのフラグ追跡
if (!isError)
{
// 実作業ツールが正常成功
if (IsMutationTool(funcName)) hasMutatedContent = true;
// 実作業が成功した【後】に保存が正常成功した場合のみフラグを立てる
if (IsSaveTool(funcName) && hasMutatedContent) isSavedAfterMutation = true;
}
// 3. ループ終了時の厳格ゲートチェック
bool hasPhysicalFileUpdated = HasPhysicalArtifactUpdated(outputDir, fileSnapshot, sessionStartTime);
bool isActuallyComplete = hasPhysicalFileUpdated && isSavedAfterMutation;
if (!isActuallyComplete)
{
// AIがサボって完了しようとした場合、強制的に差し戻す
string guidance = !hasMutatedContent
? "【システム警告】コンテンツの作成・編集がまだ行われていません。枠を開いただけで終了せず、指示されたデータをツールで入力してください。"
: "【システム警告】データの編集は行われましたが、保存フォルダーへの保存が完了していません。保存ツールを実行してください。";
conversationMessages.Add(new JsonObject { ["role"] = "user", ["content"] = guidance });
continue; // ループを継続させて強制的に作業を行わせる
}
この「ライフサイクル状態マシン」を組み込んだことで、AIがデータを書き込まずに空保存で逃げようとした場合、システムが自動で検知して「サボるな、データを書け」と差し戻すことができるようになり、中身のある完成品が確実に生成されるようになりました。
5. 罠④:Tool Calling非対応モデル(Gemma等)の即死を防ぐ
発生した現象
Ollamaで gemma3:12b や deepseek-r1 などの人気モデルを選択して実行すると、開始直後に以下のエラーで即死しました。
HTTP 400: {"error":"registry.ollama.ai/library/gemma3:12b does not support tools"}
原因
Ollamaでは、ツール呼び出し(Function Calling)に対応しているモデル(qwen2.5、llama3.1 等)と、非対応のモデル(gemma、phi3 等)があります。非対応モデルにツール定義を渡してリクエストすると、Ollamaサーバー側で即座に400エラーとして拒絶されます。
解決策:/api/show による事前自動判定
モデル一覧をロードする際、Ollamaの /api/show エンドポイントを並列で叩き、モデルが tools に対応しているかを事前に判定します。
public static async Task<bool> CheckModelSupportsToolsAsync(HttpClient client, string endpoint, string modelName)
{
try
{
var payload = new { model = modelName };
using var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var res = await client.PostAsync($"{endpoint}/api/show", content);
string json = await res.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
// capabilities 配列に "tools" が含まれているか判定
if (doc.RootElement.TryGetProperty("capabilities", out var capArr))
{
foreach (var c in capArr.EnumerateArray())
{
if (c.GetString()?.Equals("tools", StringComparison.OrdinalIgnoreCase) == true)
return true;
}
}
// テンプレートに .Tools が定義されているかフォールバック判定
if (doc.RootElement.TryGetProperty("template", out var tmpl))
{
return tmpl.GetString()?.Contains(".Tools") == true;
}
}
catch { }
return false;
}
非対応モデルにはUI上で「No Tools」バッジを付け、ユーザーが誤って実行ボタンを押しても、通信する前に「このモデルはツール呼び出しに対応していません」と警告を出して無駄なエラーを防げるようにしました。
6. まとめ&完成品のご紹介
小型ローカルLLM(7B〜14Bクラス)は単体では注意力が散漫で、マルチターンの自律エージェントを任せるとすぐに破綻します。
しかし、フレームワーク側(C#)で適切なレールを敷くことで、タスクを完遂できるようになります。
ただし、小型ローカルLLM(7B〜14Bクラス)はまだまだ実用に耐えられるレベルではありません。
num_ctx: 16384以上と低温度化を明示する(コンテキスト溢れの防止)- JSON-RPCの外枠を剥ぎ取り、純粋なコンテンツだけをLLMに見せる(ID誤認の防止)
- 容量ではなく「編集 ➔ 保存」の時系列ライフサイクルでサボりを検知・差し戻す
/api/showでツール対応モデルかを事前に検査する
完成したアプリ「WinAIAgent」をストア公開しました!
本記事で解説したローカルLLM防衛策、MCP(Model Context Protocol)によるデスクトップアプリ操作、Google Geminiとのデュアルエンジン構成をすべて統合したデスクトップアプリ 「WinAIAgent」 を Microsoft Store にて公開しました。
Windows 11 の Mica / Fluent Design で完全ネイティブ動作します。
【WinAIAgent の主な特徴】
・Ollama(完全無料・オフライン)& Google Gemini のデュアル AI エンジン対応
・Model Context Protocol (MCP) を通じた外部アプリ(表計算・画像等)の自律操作
・複数アプリを横断して中間ファイルをリレーする「複合連携オーケストレーション」
・作業の途中停止と、追加指示を与えてシームレスに再開できる「続きを実行」
・15RPM レート制限自動回避、トークンを節約する履歴圧縮、エラー無限ループ検知
WinAIAgent:Windows 用 AIエージェント
https://apps.microsoft.com/detail/9N3G0QRFWS6M
ローカルLLMで実用的なエージェントを作りたいと考えている方のヒントになれば幸いです!