AI エージェントを仕組みから理解する
はじめに
こんにちは、ダイニーの ogino です。
この記事では、AI エージェントや MCP に入門しようとしている人向けに、エージェントの内部実装について概説します。これを理解することで、現状の AI にできることが明確になり、今後の技術動向を追う上でも役に立つはずです。
本記事の要旨
MCP の表層的なプロトコルには大した意味も革新性も無いので、AI エージェントを理解するにはまずコンテキストを把握しましょう。
素の LLM の能力と、エージェントの実装を切り分ける
AI エージェントは、自律的に判断してファイル操作や Web ブラウザなどのツールを使い分けることが可能です。しかし、その基盤となっている LLM にできるのは、テキストを入力してテキストを出力することだけに限られます[1]。
以降では「LLM にできないこと」を掘り下げ、それを補うために AI エージェントがどのように実装されているのかを見てみましょう。
LLM は記憶を持たない
LLM は人間のような長期記憶を持ちません。モデルのパラメータはほぼ固定されており、個別の会話を即座に学習したり記憶したりすることはありません。
では、複数回のラリーをしても文脈を保って会話ができるのはなぜでしょうか? LLM へのリクエスト時に毎回、過去の会話履歴を「コンテキスト」として送信しているためです。
例えば、 Anthropic の API を使ってごく簡単なチャット機能を実装するには、以下のようにします。
import Anthropic from "@anthropic-ai/sdk";
import * as readline from "readline/promises";
import {stdin, stdout} from "process";
const anthropic = new Anthropic();
const rl = readline.createInterface({input: stdin, output: stdout});
/** ⚠️ 過去全ての会話履歴 */
const context: Anthropic.Messages.MessageParam[] = [];
while (true) {
const userQuery = await rl.question("You: ");
context.push({role: "user", content: userQuery});
// ⚠️ LLM へのリクエスト
const response = await anthropic.messages.create({
// ⚠️ 直近の `userQuery` だけでなく `context` を全て送る
messages: context,
model: "claude-3-7-sonnet-latest",
max_tokens: 1000,
});
console.log(response.content);
context.push({role: "assistant", content: response.content});
}
逆に、LLM はコンテキスト以外の追加情報を何も持っていません。もし特定のコードベースに関するタスクを高精度で解決したいなら、そのコードに関する情報をコンテキストに含める必要があります。
とはいえ、あらゆる情報を際限なくコンテキストに詰め込むわけにもいきません。入力するコンテキストの長さに関して次のような制約があるためです。
- 課金される額は入力の長さに比例する
- 入力できる最大のコンテキスト幅にはハードリミットがある
- たとえ制限内のコンテキスト幅に収めても、入力が長いと LLM が重要な情報を「見落とす」ことがある(e.g. 指示したコーディング規約の一部に従わない)
そのため、LLM をうまく活用するには、関連度の高い情報だけをコンテキストとして取捨選択しなければなりません。
LLM は外部環境にアクセスできない
LLM はテキストを生成するだけなので、単独でインターネットに接続したりファイルを編集したりはできません。しかし、「実行したいコマンド」を LLM に出力させてそれを自動的に処理すれば、実質的に AI を自律行動させることが可能です。
ここで言う「コマンド」とは、関数や API を呼び出す時の引数に当たるものです。 LLM は、JSON などの機械可読なテキストを書くことにも長けているので、これを利用してコマンドを出力させます。
下の画像は Claude の API を使った例です。 AI に対して「許可されているコマンド」を JSON のスキーマとして伝え、状況に適したものを選ぶように指示しています。
Anthropic Console Workbench
画像右側の Response がスキーマ通りの形式になっている点に注目してください。また、選択されたツールも妥当なものになっています。(誤字を修正するためには、まずファイルの内容を読む必要がある。)
こうして JSON が得られたら、それをパースしてツールを実行するのは簡単です。更にその結果を LLM にフィードバックして再度ツールを選択させることで、人間が介入せずにループを回すことができます。
この実装パターンは、"Tool use" または "Function calling" という呼び名で知られており、AI エージェントの基礎になっています。
MCP とは何か
AI と連携できると便利なツールは色々考えられますが、それら全ての実装を一つの AI エージェントの中にハードコードするのは非効率です。
そこで、ツールの定義・実行を他のプロセスに移譲して拡張性を高めようと考えるのが自然な流れであり、MCP はそのための通信プロトコルの一種です。
次のシーケンス図と先程の図を見比べてみてください。 Runtime の中でツール実行していた部分が、MCP server へのリクエストに置き換わります。
MCP server とは要するに「JSON Schema が付いた JSON-RPC サーバー」です。任意のツール定義を自動的に LLM のコンテキストに埋め込むためには、何かしら共通の形式のスキーマが定義されていると便利なので、JSON Schema が使われています。
MCP server を増やすだけで AI エージェントの能力を拡張でき、それを統一規格によって複数のクライアント (Cline, Cursor, etc.) で使い回せることが MCP の提供するメリットです。
Roo Code (Cline) の内部実装
ここまでで説明したことが分かれば、プロダクションレベルの AI エージェントの実装を理解するのも簡単になります。実際に Roo Code (Cline の fork) の中身を調べてみましょう。
AI エージェントの動作はほとんど LLM の返答次第で決まります。そして、LLM の返答がコンテキストだけで決まるのは既に述べた通りです。したがって、コンテキストの内容さえ把握すれば AI エージェントがどんな動きをするか予測できるようになります。
Roo Code のコアループの中で LLM にリクエストしているのは下記の 1 行だけです。ここから、コンテキストが system prompt と会話履歴で構成されていることがわかります。
コンテキストの構成要素:System Prompt
system prompt とはコンテキストの先頭に置く固定のテキストで、AI のロールやガイドラインを指示するためのものです。
実は、system prompt の内容はコードを追うまでもなく確認できます。Roo Code の設定画面に "Preview System Prompt" 機能が付いているためです。
Roo Code の Mode 設定画面の中に "Preview System Prompt" ボタンがある。
実際の system prompt 全文は長すぎるので gist に載せていますが、大枠を抜き出すと以下の通りです。上述した Tool use や MCP についての指示とスキーマ定義が含まれているのがわかるでしょうか。
You are Roo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
====
TOOL USE
You have access to a set of tools that are executed upon the user's approval. You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use.
# Tool Use Formatting
Tool use is formatted using XML-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags. Here's the structure:
<tool_name>
<parameter1_name>value1</parameter1_name>
<parameter2_name>value2</parameter2_name>
...
</tool_name>
For example:
<read_file>
<path>src/main.js</path>
</read_file>
Always adhere to this format for the tool use to ensure proper parsing and execution.
# Tools
## read_file
Description: Request to read the contents of a file at the specified path. Use this when you need to examine the contents of an existing file you do not know the contents of, for example to analyze code, review text files, or extract information from configuration files. The output includes line numbers prefixed to each line (e.g. "1 | const x = 1"), making it easier to reference specific lines when creating diffs or discussing code. By specifying start_line and end_line parameters, you can efficiently read specific portions of large files without loading the entire file into memory. Automatically extracts raw text from PDF and DOCX files. May not be suitable for other types of binary files, as it returns the raw content as a string.
Parameters:
- path: (required) The path of the file to read (relative to the current workspace directory /Users/****/libs/typescript-go)
- start_line: (optional) The starting line number to read from (1-based). If not provided, it starts from the beginning of the file.
- end_line: (optional) The ending line number to read to (1-based, inclusive). If not provided, it reads to the end of the file.
Usage:
<read_file>
<path>File path here</path>
<start_line>Starting line number (optional)</start_line>
<end_line>Ending line number (optional)</end_line>
</read_file>
Examples:
1. Reading an entire file:
<read_file>
<path>frontend-config.json</path>
</read_file>
2. Reading the first 1000 lines of a large log file:
<read_file>
<path>logs/application.log</path>
<end_line>1000</end_line>
</read_file>
# Tool Use Guidelines
# Connected MCP Servers
When a server is connected, you can use the server's tools via the `use_mcp_tool` tool, and access the server's resources via the `access_mcp_resource` tool.
====
CAPABILITIES
====
MODES
====
RULES
====
SYSTEM INFORMATION
====
OBJECTIVE
====
USER'S CUSTOM INSTRUCTIONS
コンテキストの構成要素:会話履歴
Roo Code でタスクを実行していると UI 上に会話履歴が表示されます。その内容も勿論 LLM のコンテキストに含まれます。
Roo には以下のような優れた点がありますが、これはエディタから収集した情報を会話履歴の中にうまく挿入することで実現されています。
- 静的解析により見つかった型エラーや lint エラーなどを自動で修正してくれる
- タスクに関連しそうなファイルを自動で見つけてくれる
会話履歴のコンテキストの実装を調べるには、以下の assistant message (LLM の返答メッセージ) と user content が起点になります。
後者の user content を更に掘り下げると、下記のような情報が含まれています。
- Tool use の実行結果とエラーメッセージ
- VSCode で今開いているファイル名のリスト
- Workspace ディレクトリ内のファイル名のリスト
- 直近更新されたファイル名のリスト
Tool use の中で特にファイルの編集時には、「編集によって新たに発生したエラー」が実行結果の一部としてフィードバックされます。
この実装は VSCode API の Diagnostics を利用しています。つまり、エディタ上の赤い波線で警告されるエラーの情報が LLM に渡されるということです。これによって、明らかに目に見えているエラーを、人間がわざわざ指摘するまでもなく修正してくれるようになります。また、普段から開発に使用している language server や linter にタダ乗りするので、余計な追加設定をする必要もありません。
下の画像は、Roo が書いた TypeScript コードに含まれる型エラーを、Roo 自身が修正している時の様子です。
Roo Code の会話履歴の UI
エージェントのコストを 1/10 にする Prompt Caching
AI エージェントは Tool use のために何往復もリクエストを繰り返す傾向があります。その度に全ての会話履歴をコンテキストとして送信する、というのは既に説明した通りです。
そのため、エージェント実行にかかる累積コストを考えると、「過去分の、全く同じ内容の履歴を何回も LLM に送るコスト」が支配的な項になります。しかし prompt caching を活用するとその部分のコストを 1/10 に抑えることが可能です。
エージェントの累積コストを単純化した図。青色の部分がキャッシュによって安くなる
Claude の API の場合、プロンプトの先頭部分がキャッシュされた内容と同一であれば、その部分の処理にかかる latency が短縮され、料金が 90% 割引になります。エージェントのコンテキストは基本的に append only なので、キャッシュの恩恵を受けやすくなっています。
AI の進化を振り返る
Tool use を利用する AI エージェントが PoC として成立したのは、私の知る限り 2023 年 3 月リリースの AutoGPT が初めてです。AutoGPT は確実な将来性を感じさせるものではありましたが、まるで使い物にはなりませんでした。
とにかくコストが高く、遅く、問題を解決できないままずっとループしていることもよくありました。
エージェントの UX がまだ洗練されていなかったのもありますが、何よりも LLM の性能が足りていなかったのです。当時飛び抜けて最高峰に位置していた GPT-4 と、現在のモデルを比べてみるとそれがよくわかると思います。
モデル | 価格 (1M トークン毎) | コンテキスト幅 (トークン) | 出力速度 (トークン/秒) |
---|---|---|---|
GPT-4 (2023/03) | $30 | 8k | 15 |
Claude 3.7 Sonnet | $0.3 (cache read) | 200k | 79 |
Gemini 2.5 Pro | $0.625 (cache read) | 1,000k | 198 |
わずか 2 年ほどの間に、LLM のコストは約 100 分の 1、コンテキスト幅は約 100 倍になり、より賢く、高速化しています。
この性能向上のおかげで、長大なコードもあまり気にすることなくコンテキストに放り込むことができるようになりました。2 年前の GPT-4 のコンテキスト幅には、今の Roo Code の system prompt すら収まりません。
次の 2 年後は果たしてどうなっているでしょうか?
もしコストが更に 100 分の 1、コンテキスト幅が 100 倍になったら? AI エージェントがロボットに組み込まれて、自律行動できる範囲が広がったら何が起きるでしょうか?
We're hiring!
ダイニーでは、Roo Code や Cursor などのエージェントを含む AI ツールの業務活用を積極的に進めています。ダイニーのコードベースはフルスタック TypeScript の monorepo 構成で、テストや linter やドキュメントが充実しているため、AI を自走させるためのコンテキストが揃っています。
ご興味をお持ちの方は、ぜひカジュアル面談にご応募ください。
-
Multi-modal モデルならここに画像や音声なども加わります。 ↩︎
Discussion