この記事について
「AIエージェントを作ってみたい」と思い立ち、最初はAnthropicの Claude Agent SDK を使うつもりでした。ですが調べていく過程で「ローカルLLM(Ollama)で動かしたい」という方向に転換し、結果的に自前のシンプルなTool Callingループを実装することになりました。この記事はその過程をそのまま記録したものです。
対象読者:
- ローカルLLMでAIエージェントの仕組みを理解したい人
- OllamaのTool Calling(Function Calling)を試してみたい人
- 「フレームワークを使わず自分で仕組みを書いてみたい」人
1. なぜClaude Agent SDKではなくOllamaにしたか
AIエージェントを試しに作ってみたい、と思い立った時、まず気になったのが費用でした。Anthropic公式の Claude Agent SDK(TypeScript版、@anthropic-ai/claude-agent-sdk)を使う想定で調べ始めましたが、これはAnthropic APIのClaudeモデルを呼び出す従量課金の仕組みが前提です。動かす・試行錯誤するたびに費用がかかる構成は、最初の「仕組みを理解するための検証」という目的には合わないと感じました。
そこで、以前「Ollama×SLM」の記事でも扱ったローカルLLM環境(Ollama)を流用する方針に転換しました。手元のマシンで動かす分には何度実行しても追加費用がかかりません。
ちなみに、Claude Agent SDKはClaude Codeのランタイムをベースにした仕組みで、内部的にClaude Codeのネイティブバイナリをサブプロセスとして起動し、Anthropic APIのClaudeモデルとやり取りする設計になっています。モデル部分をOllamaのようなローカルLLMに直接差し替えることは想定されていません。仮に費用を度外視したとしても、今回やりたかった「ローカルLLMでエージェントの仕組みを理解する」という目的には、そもそも噛み合わない構成だったという点も付け加えておきます。
2. OllamaのTool Calling
Ollamaは/api/chatエンドポイントにOpenAIのFunction Calling互換のtools配列を渡すことで、Tool Calling(Function Calling)に対応しています。
curl -s http://localhost:11434/api/chat -H "Content-Type: application/json" -d '{
"model": "llama3.1",
"messages": [{"role": "user", "content": "What is the temperature in New York?"}],
"tools": [...]
}'
調べていて重要だと感じたのは、OpenAI互換の/v1エンドポイント(http://localhost:11434/v1/...)ではなく、Ollamaのネイティブ/api/chatエンドポイントを使うべきという点です。/v1経由だとTool Callingの信頼性が下がり、モデルが生のツール呼び出しJSONをただのテキストとして出力してしまうことがある、という情報がありました。この記事でもネイティブAPIをそのまま使っています。
Tool Calling対応モデルは、llama3.1以降・qwen3・mistral-nemoあたりが安定しているとされています。今回はローカルに既にpull済みだったllama3.1:8bを使いました。
3. 内部で何が起きているか
「結局プロンプトを組み立ててAPIを呼び出しているだけなのでは」という疑問がありました。調べてみると、大枠ではその理解で合っていましたが、プロンプトを組み立てているのはこちらのコードではなくOllamaのサーバー側でした。
-
クライアント側が送るのは構造化JSON:
messages配列(会話履歴)とtools配列(ツール定義)を、そのままJSONとして/api/chatに送ります。 -
Ollamaがモデル専用の「テンプレート」で1本のプロンプトに変換する:各モデルには専用のチャットテンプレート(Goの
text/template形式)が紐付いており、messagesとtoolsをそのテンプレートに流し込んで、モデルが学習時に見慣れた形の特殊トークン付きテキストに変換します。例えばLlama 3.1のテンプレートは、ツール定義をシステムメッセージの中に次のような形で埋め込みます。
<|start_header_id|>system<|end_header_id|>
Cutting Knowledge Date: December 2023
When you receive a tool call response, use the output to format an answer
to the original user question.
You are a helpful assistant with tool calling capabilities.
{ツール定義のJSON Schemaがここに埋め込まれる}
<|eot_id|>
- モデル自体はただのテキスト生成をしているだけ:モデルは「ツールを呼び出す」という特別な動作をしているわけではありません。学習時に「こういうツール定義が見えたら、こういう形式で応答する」というパターンを覚え込まされているだけで、ツールを使うべきと判断したら、決まったフォーマット(JSON風のテキスト)を出力します。
-
Ollamaが生のテキスト出力を構造化JSONに戻す:モデルが出力したテキストの中から、そのフォーマット部分を検出し、
tool_callsという構造化フィールドに変換してクライアントに返します。
つまり、クライアントとOllamaの間は綺麗なJSON API(OpenAI Function Calling互換)でやり取りしていますが、Ollamaとモデル本体の間は結局「特殊トークン込みの1本のプロンプト文字列⇔生テキスト」のやり取りになっています。Ollamaがその変換を肩代わりしてくれるおかげで、こちらは意識せずにJSONベースで開発できている、という構造でした。
4. まずは最小構成で動作確認
mkdir ollama-agent
cd ollama-agent
npm init -y
npm pkg set type=module
npm install -D typescript @types/node tsx
npm pkg set type=moduleは、トップレベルawaitを使うための設定です。外部の依存ライブラリは一切使わず、Node.js標準のfetchだけでOllamaの/api/chatを叩きます。
const OLLAMA_URL = "http://localhost:11434/api/chat";
const MODEL = "llama3.1:8b";
const tools = [
{
type: "function",
function: {
name: "calculate",
description: "四則演算を計算する",
parameters: {
type: "object",
required: ["expression"],
properties: {
expression: { type: "string", description: "計算式(例: '12 * 34 + 5')" },
},
},
},
},
];
function executeTool(name: string, args: Record<string, unknown>): string {
if (name === "calculate") {
const expr = args.expression as string;
const result = eval(expr); // ローカル検証用として割り切っている
return String(result);
}
return `Unknown tool: ${name}`;
}
async function chat(messages: any[]): Promise<any> {
const res = await fetch(OLLAMA_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: MODEL, messages, tools, stream: false }),
});
return res.json();
}
async function main() {
const messages: any[] = [{ role: "user", content: "234 かける 17 は?" }];
const first = await chat(messages);
messages.push(first.message);
if (first.message.tool_calls) {
for (const call of first.message.tool_calls) {
const result = executeTool(call.function.name, call.function.arguments);
messages.push({ role: "tool", content: result });
}
const second = await chat(messages);
console.log(second.message.content);
}
}
main();
実行結果:
--- 1ターン目のレスポンス ---
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_n2skpi9d",
"function": {
"index": 0,
"name": "calculate",
"arguments": { "expression": "234 * 17" }
}
}
]
}
--- ツール実行: calculate({"expression":"234 * 17"}) => 3978 ---
--- 最終回答 ---
答えは3978です。
llama3.1:8bが「234かける17」に対して自分で計算せず、calculateツールを呼ぶべきと正しく判断できていることが確認できました。この最小構成で、Tool Callingの基本的な往復(モデル→ツール呼び出し要求→実行→結果を踏まえた最終回答)が動くことが分かりました。
5. 汎用ループ化とUI化をClaude Codeに依頼
ここから先(任意ステップ数のループ化、ブラウザで動くUI)は、要件をプロンプトとしてまとめてClaude Codeに実装してもらいました。最初に渡したプロンプトの骨子です。
現在のプロジェクト(ollama-agent/)には、Ollamaのllama3.1:8bモデルとTool Callingで
やり取りする最小限のagent.tsがある。これを拡張して、ブラウザで動かせるシンプルな
Web UIを作ってほしい。
## 要件
### 1. エージェントループの汎用化
- 「モデルがtool_callsを返す限り繰り返す」汎用ループに書き換える
- 無限ループを防ぐため、最大ステップ数を引数で指定できるようにする
- 各ステップの構造化記録(モデルの発言・ツール名・引数・結果)を返せるようにする
### 2. Web UI
- フレームワークは使わず、素のHTML/JS/CSS + Node.js標準httpモジュール
- 質問・計算内容を自由入力できるテキストエリア
- 最大ステップ数を指定する数値入力欄
- 「実行」ボタン、結果をステップごとに時系列表示するエリア
(以下略)
その後、「複合計算をステップ・ループで画面から設定したい」という要望を追加し、フロー入力機能(A→B→Cとステップを連結する形)を仕様に加えて再依頼しました。
6. 出来上がったもの
エージェントループ(agent.tsより抜粋)
export async function runAgent(
userInput: string,
maxSteps: number = 5
): Promise<AgentResult> {
const messages: Message[] = [
{
role: "system",
content:
"calculate ツールは四則演算を1つだけ計算できる。複数の演算が必要な場合は、" +
"一度に1つの式だけを渡し、その結果を使って次のツール呼び出しを行うこと。",
},
{ role: "user", content: userInput },
];
const steps: AgentStep[] = [];
for (let step = 1; step <= maxSteps; step++) {
const response = await chat(messages);
const assistantMessage: Message = response.message;
messages.push(assistantMessage);
const toolCalls = assistantMessage.tool_calls;
if (!toolCalls || toolCalls.length === 0) {
steps.push({ step, assistantContent: assistantMessage.content ?? "", toolCalls: [], isFinal: true });
return { steps, finalAnswer: assistantMessage.content ?? "", stoppedReason: "final_answer" };
}
const executedCalls: AgentStep["toolCalls"] = [];
for (const call of toolCalls) {
const result = executeTool(call.function.name, call.function.arguments);
executedCalls.push({ name: call.function.name, arguments: call.function.arguments, result });
messages.push({ role: "tool", content: result });
}
steps.push({ step, assistantContent: assistantMessage.content ?? "", toolCalls: executedCalls, isFinal: false });
}
return { steps, finalAnswer: "(最大ステップ数に達したため、最終回答は得られませんでした)", stoppedReason: "max_steps" };
}
tool_callsが返らなくなった時点で終了、maxStepsに達したら打ち切り、という素直なループです。
フロー機能(ステップ連結)の設計
「234×17に、さらに56を足して、3で割って」のような複合計算を1回の指示でモデルに渡すと、途中で計算を誤ったり、ツールを呼ばずに暗算してしまったりすることがありました。そこでClaude Codeが選んだ設計が、各ステップを独立したエージェント呼び出しとして実行し、前ステップの結果を自然文で次の指示に注入する方式です。
export async function runFlow(
steps: string[],
maxStepsPerStep: number = 3
): Promise<FlowResult> {
const flowSteps: FlowStepResult[] = [];
let prevValue: number | null = null;
for (let i = 0; i < steps.length; i++) {
const label = String.fromCharCode("A".charCodeAt(0) + i);
const instruction =
prevValue === null
? steps[i]
: `前回の計算結果は ${prevValue} です。この値について: ${steps[i]}`;
const agentResult = await runAgent(instruction, maxStepsPerStep);
const value = extractLastToolResultValue(agentResult);
flowSteps.push({ label, instruction, agentResult, value });
prevValue = value;
}
return { flowSteps, finalValue: prevValue };
}
ポイントは、モデルの自然文の最終回答(「答えは3978です」)をパースするのではなく、ツールの実行結果という構造化データから直接数値を取り出している点です。
function extractLastToolResultValue(agentResult: AgentResult): number | null {
for (let i = agentResult.steps.length - 1; i >= 0; i--) {
const calls = agentResult.steps[i].toolCalls;
if (calls.length > 0) {
const n = Number(calls[calls.length - 1].result);
if (!Number.isNaN(n)) return n;
}
}
const n = Number.parseFloat(agentResult.finalAnswer);
return Number.isNaN(n) ? null : n;
}
自然文回答は表現ゆれ(「結果は3982です」「3982になります」など)があり、パースの信頼性が落ちます。ツール呼び出しの引数・結果はJSON構造化データなので、そちらを優先して値を拾う、という設計判断は理にかなっていると感じました。
7. 動作確認
「フロー入力」タブで、以下の3ステップを設定しました。
- A:
234かける17 - B:
さらに4を足す - C:
さらに2を足す
実行結果:
| ステップ | 実際にモデルへ渡した指示 | ツール呼び出し | 結果 |
|---|---|---|---|
| A | 234かける17 |
calculate({"expression":"234 * 17"}) |
3978 |
| B | 前回の計算結果は 3978 です。この値について: さらに4を足す |
calculate({"expression":"3978 + 4"}) |
3982 |
| C | 前回の計算結果は 3982 です。この値について: さらに2を足す |
calculate({"expression":"3982 + 2"}) |
3984 |
最終結果は3984。234×17=3978、+4=3982、+2=3984と、手計算した結果とも一致しています。各ステップで、モデルが正しくcalculateツールを1回ずつ呼び出し、前ステップの結果を踏まえて次の計算を実行できていることが確認できました。
実際の画面はこちらです。
8. 注意点
-
executeTool内のeval(expr)は、任意のJavaScript式を実行できてしまう危険な実装です。今回はローカルでの検証用と割り切っていますが、外部公開するようなアプリでは電卓ライブラリ(mathjsなど)に置き換えるべきです - ローカルLLM(今回は
llama3.1:8b)はクラウドの大規模モデルに比べてTool Calling精度が不安定になることがあります。今回のような単純な四則演算では安定していましたが、複雑なツール構成では挙動を見ながら調整が必要になりそうです
まとめ
- 検証のたびに費用がかかる構成は避けたかったため、Claude Agent SDK(従量課金のAnthropic API前提)ではなくローカルLLM(Ollama)を選んだ。加えてClaude Agent SDKはClaude専用設計で、そもそもローカルLLMへの差し替えは想定されていない構成だった
- OllamaのTool Callingは、OpenAI互換の
/v1エンドポイントではなくネイティブ/api/chatを使うのが信頼性の面で無難 - 汎用ループ(
tool_callsが返る限り繰り返す)自体はシンプルに実装できた - 複合計算をモデルに一括で渡すより、ステップを分割してそれぞれ独立に実行し、結果を自然文で橋渡しする設計の方が安定した
- モデルの自然文回答をパースするより、ツール呼び出しの構造化データから直接値を取る方が堅牢
- 要件をプロンプトとして明文化してClaude Codeに実装を委任する進め方は、UI部分のような「仕組みの理解自体が目的ではない部分」との分業として機能した


![FireShot Capture 028 - Ollama Agent - [localhost].png](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F111487%2F9a98afd6-c288-4534-86d9-f88646a1bb6c.png?ixlib=rb-4.1.1&auto=format&gif-q=60&q=75&s=0361c3fe6ecde8e3485ecfc7ab62a865)
