フレームワークなしでNode.jsにマルチステップAIエージェントを構築する方法

Dev.to / 2026/3/24

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical Usage

要点

  • 記事は、LangChain、AutoGen、CrewAIといった人気のAIエージェント用フレームワークは重要な挙動を隠してしまい、エージェントが予期せぬ動作をした際のデバッグが難しくなると主張している。
  • そこで、ClaudeのAPIを直接利用し、「フレームワークの魔法」を避けつつ、Node.jsからスクラッチで本番投入に耐えるマルチステップエージェントを約200行で構築することを提案する。
  • AIエージェントは、LLMがタスク、ツール、履歴を受け取り、回答するかツールを呼び出すかを判断し、ツールを実行し、結果をメモリ/履歴に追記して、最終的な応答が生成されるまで繰り返すループとして定義される。
  • 提案するアーキテクチャには、Memory Manager(メモリ管理)、Claude LLMレイヤー、Tool Router(ツールルータ)、およびツールの選択と実行を管理するTool Store/Executor(ツール格納/実行)が含まれる。
  • チュートリアルは、利用可能なツールをLLMが一貫して理解し呼び出せるようにするため、まずツールのインターフェース(名前と説明)を定義することから始まる。

Node.jsでマルチステップAIエージェントを作る方法(フレームワークなし)

「エージェント」のチュートリアルの多くは、すぐにLangChainやAutoGPTに手を伸ばします。なぜそれをすべきでないのか、そしてNode.jsの200行で堅牢で本番投入可能なエージェントをゼロから作る方法を説明します。

LangChain、AutoGen、CrewAIのようなフレームワークは、人気が爆発的に高まりました。これらは、AIエージェントを簡単に作れると約束します。そして実際、簡単です——ただし、エージェントが想定外の動作をした瞬間を除いては。そこからは、フレームワークのソースコード10,000行を読むのに3日かけて「なぜそうなったのか」を理解することになります。

この記事は別のアプローチを取ります。ツールを使えること、メモリを保持できること、複雑なタスクを完了できること——これらを備えた完全に機能するマルチステップAIエージェントを、ゼロから構築します。フレームワークなし。魔法なし。ClaudeのAPIと、きれいなNode.jsだけです。

最後には、あなたはまさにエージェントがどのように動くのかを理解しており、他人の抽象化に立ち向かうことなく拡張できる土台が手に入っているはずです。

AIエージェントを「エージェント」にするものは何か?

単純なLLM呼び出しは次のような流れです:ユーザーがメッセージを送る → LLMが応答する → そこで終了。

エージェントとはループです:

  1. LLMがタスク+ツール+履歴を受け取る
  2. LLMが応答する(答えを返す)か、ツールを呼び出す
  3. ツール呼び出しの場合:ツールを実行し、結果を履歴に追加する
  4. ステップ1へ戻る
  5. LLMが最終回答を出すまで繰り返す

以上です。「知能」は、LLMがどのツールを、どの順番で呼び出すかを判断することにあります。

アーキテクチャ概要

このエージェントには4つのコンポーネントがあります:

┌─────────────────────────────────────────────────────┐
│                    Agent Loop                         │
│                                                       │
│  ┌──────────┐    ┌──────────┐    ┌──────────────┐   │
│  │  Memory  │───▶│   LLM    │───▶│  Tool Router │   │
│  │   Manager  │    │ (Claude) │    │              │   │
│  └──────────┘    └──────────┘    └──────┬───────┘   │
│       ▲                                  │           │
│       │          ┌──────────────────────┘           │
│       │          ▼                                   │
│       │   ┌─────────────┐                           │
│       └───│  Tool Store │                           │
│           │  (Executor) │                           │
│           └─────────────┘                           │
└─────────────────────────────────────────────────────┘

ステップ1:ツールインターフェースを定義する

ツールは、エージェントが呼び出せる関数です。次のような分かりやすいインターフェースを定義します:

// agent/types.js

/**
 * @typedef {Object} Tool
 * @property {string} name - ツールの一意な識別子
 * @property {string} description - このツールが何をするか(LLMが読み取る)
 * @property {Object} inputSchema - ツールのパラメータ用のJSONスキーマ
 * @property {Function} execute - 実際の実装
 */

/**
 * @typedef {Object} ToolResult
 * @property {boolean} success - ツールが正常に実行されたかどうか
 * @property {any} output - ツールの出力(文脈のために文字列へシリアライズされる)
 * @property {string} [error] - successがfalseの場合のエラーメッセージ
 */

/**
 * @typedef {Object} Message
 * @property {'user'|'assistant'|'tool_result'} role
 * @property {string|Array} content
 */

ステップ2:ツールレジストリを作る

// agent/tools.js
import { execSync } from 'child_process';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { resolve } from 'path';

class ToolRegistry {
  constructor() {
    this.tools = new Map();
  }

  register(tool) {
    if (this.tools.has(tool.name)) {
      throw new Error(`Tool '${tool.name}' is already registered`);
    }
    // Validate tool has required fields
    if (!tool.name || !tool.description || !tool.inputSchema || !tool.execute) {
      throw new Error(`Tool '${tool.name}' is missing required fields`);
    }
    this.tools.set(tool.name, tool);
    return this; // Chainable
  }

  get(name) {
    return this.tools.get(name);
  }

返却形式: {"translated": "翻訳されたHTML"}// ClaudeのAPI用のフォーマットツール
  toAnthropicFormat() {
    return Array.from(this.tools.values()).map(tool => ({
      name: tool.name,
      description: "tool.description,"
      input_schema: tool.inputSchema,
    }));
  }

  async execute(name, input) {
    const tool = this.tools.get(name);
    if (!tool) {
      return { success: false, error: `不明なツール: ${name}` };
    }

    try {
      const output = await tool.execute(input);
      return {
        success: true,
        output: typeof output === 'string' ? output : JSON.stringify(output, null, 2),
      };
    } catch (error) {
      return {
        success: false,
        error: error.message,
      };
    }
  }
}

// ビルトインツール付きのデフォルトレジストリを作成してエクスポートする
export const registry = new ToolRegistry();

// ツール: シェルコマンドを実行(サンドボックス)
registry.register({
  name: 'shell',
  description: "'シェルコマンドを実行して、その出力を返します。ファイル操作、gitコマンド、npmなどに使用してください。コマンドは現在のディレクトリで実行されます。',"
  inputSchema: {
    type: 'object',
    properties: {
      command: {
        type: 'string',
        description: "'実行するシェルコマンド',"
      },
      cwd: {
        type: 'string',
        description: "'コマンドの作業ディレクトリ(任意)',"
      },
    },
    required: ['command'],
  },
  execute: async ({ command, cwd }) => {
    // 基本的な安全性: 明らかに危険なコマンドをブロックする
    const BLOCKED = ['rm -rf /', 'mkfs', 'dd if=', ':(){ :|& };:'];
    if (BLOCKED.some(b => command.includes(b))) {
      throw new Error(`安全のためブロックされたコマンド: ${command}`);
    }

    const output = execSync(command, {
      cwd: cwd || process.cwd(),
      timeout: 30_000,
      maxBuffer: 1024 * 1024, // 1MB
      encoding: 'utf8',
    });
    return output.trim() || '(no output)';
  },
});

// ツール: ファイルを読み取る
registry.register({
  name: 'read_file',
  description: ""'ファイルの内容を読み取ります。ファイルの内容を文字列として返します。",
  inputSchema: {
    type: 'object',
    properties: {
      path: {
        type: 'string',
        description: "'ファイルへの絶対パスまたは相対パス',"
      },
      maxLines: {
        type: 'number',
        description: "'返す最大行数(デフォルト: 200)',"
      },
    },
    required: ['path'],
  },
  execute: async ({ path, maxLines = 200 }) => {
    const fullPath = resolve(path);
    if (!existsSync(fullPath)) {
      throw new Error(`File not found: }${fullPath}`);
    }
    const content = readFileSync(fullPath, 'utf8');
    const lines = content.split('
');
    if (lines.length > maxLines) {
      return lines.slice(0, maxLines).join('
') + `
... (${lines.length - maxLines} more lines)`;
    }
    return content;
  },
});

// ツール: ファイルを書き込む
registry.register({
  name: 'write_file',
  description: ""'存在しない場合は作成しつつ、ファイルに内容を書き込みます。'",
  inputSchema: {
    type: 'object',
    properties: {
      path: {
        type: 'string',
        description: "'書き込むパス',"
      },
      content: {
        type: 'string',
        description: "'書き込む内容',"
      },
    },
    required: ['path', 'content'],
  },
  execute: async ({ path, content }) => {
    const fullPath = resolve(path);
    writeFileSync(fullPath, content, 'utf8');
    return `Written ${content.length} bytes to ${fullPath}`;
  },
});

// ツール: HTTP fetch
registry.register({
  name: 'http_get',
  description: "'HTTP GET リクエストを行い、レスポンスボディを返します。"
  inputSchema: {
    type: 'object',
    properties: {
      url: {
        type: 'string',
        description: "'URL"
      },
      headers: {
        type: 'object',
        description: "'任意のリクエストヘッダー'"
      },
    },
    required: ['url'],
  },
  execute: async ({ url, headers = {} }) => {
    const response = await fetch(url, {
      headers: {'User-Agent': 'AI-Agent/1.0', ...headers},
      signal: AbortSignal.timeout(15_000),
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    const text = await response.text();
    // 大きなレスポンスを切り詰める
    return text.length > 5000 ? text.slice(0, 5000) + '... (truncated)' : text;
  },
});

Step 3: メモリマネージャ

コンテキスト管理は、エージェント設計において最も過小評価されている部分です。素朴なアプローチ——すべてのメッセージを1つの配列に追加する——では、コンテキストの制限にすぐにぶつかってしまいます。より賢いメモリが必要です:

// agent/memory.js

返却形式: {"translated": "翻訳されたHTML"}export class MemoryManager {
  constructor({
    maxMessages = 50,
    maxContextTokens = 80_000,
    summaryThreshold = 30,
  } = {}) {
    this.messages = [];
    this.maxMessages = maxMessages;
    this.maxContextTokens = maxContextTokens;
    this.summaryThreshold = summaryThreshold;
    this.sessionSummary = null;
  }

  add(message) {
    this.messages.push({
      ...message,
      timestamp: Date.now(),
    });
  }

  /**
   * ClaudeのAPI向けにメッセージを整形し、コンテキストの制限内に収めるためにインテリジェントにトリミングします
   */
  getContextWindow() {
    if (this.messages.length <= this.summaryThreshold) {
      return this.messages.map(({ role, content }) => ({ role, content }));
    }

    // 保持: 最初の5つのメッセージ(タスクのコンテキスト)、最後の20のメッセージ(直近のアクティビティ)
    const head = this.messages.slice(0, 5);
    const tail = this.messages.slice(-20);

    // 中間部分の要約を作成
    const middle = this.messages.slice(5, -20);
    const summarized = this.summarizeMessages(middle);

    const contextMessages = [
      ...head.map(({ role, content }) => ({ role, content })),
      {
        role: 'user',
        content: `[Context summary: ${summarized}]`,
      },
      {
        role: 'assistant',
        content: 'Understood, I have the context from earlier in our session.',
      },
      ...tail.map(({ role, content }) => ({ role, content })),
    ];

    return contextMessages;
  }summarizeMessages(messages) {
    // ツール呼び出しから主要なアクションと発見事項を抽出する
    const toolCalls = messages.filter(m =>
      Array.isArray(m.content) &&
      m.content.some(c => c.type === 'tool_use')
    );

    const toolResults = messages.filter(m =>
      Array.isArray(m.content) &&
      m.content.some(c => c.type === 'tool_result')
    );

    const actions = toolCalls.map(m => {
      const toolUse = m.content.find(c => c.type === 'tool_use');
      return `${toolUse.name}を呼び出し(${JSON.stringify(toolUse.input).slice(0, 100)})`;
    });

    return `${messages.length} 件のメッセージ、 ${toolCalls.length} 件のツール呼び出し。直近のアクション: ${actions.slice(-5).join(', ')}`;
  }

  /**
   * セッションをまたいだ継続性のため、メモリをディスクに永続化する
   */
  save(path) {
    const data = {
      messages: this.messages,
      sessionSummary: this.sessionSummary,
      savedAt: new Date().toISOString(),
    };
    writeFileSync(path, JSON.stringify(data, null, 2));
  }

  /**
   * ディスクからメモリを読み込む
   */
  load(path) {
    if (!existsSync(path)) return;
    const data = JSON.parse(readFileSync(path, 'utf8'));
    this.messages = data.messages || [];
    this.sessionSummary = data.sessionSummary;
    console.log(`メモリから ${this.messages.length} 件のメッセージを読み込みました`);
  }get messageCount() {
    return this.messages.length;
  }
}

ステップ 4: エージェントループ

ここがシステムの中核です — 最終回答に到達するまでエージェントを動かし続けるループです。

// agent/agent.js
import Anthropic from '@anthropic-ai/sdk';
import { MemoryManager } from './memory.js';
import { registry } from './tools.js';

const DEFAULT_SYSTEM_PROMPT = `あなたは、ツールにアクセスできる役立つAIアシスタントです。

タスクが与えられたら:
1. 問題を明確なステップに分解する
2. 利用可能なツールを使って情報を収集する、またはアクションを実行する
3. 次に進む前に、各ツール呼び出しの出力を慎重に考える
4. ツールが失敗した場合は、別のアプローチを試す
5. タスクが完了したら、明確で簡潔な最終回答を提示する

ガイドライン:
- いつも自分の作業を確認する(書いたファイルを読み直し、コマンドが成功したかを確認する)
- 何をしているのか、なぜそれをしているのかを明確にする
- 何かに不確かさがある場合は、推測するのではなくツールで確認する
- タスクが完了したら停止する — 不必要にツールを呼び続けない`;

export class Agent {
  constructor({
    model = 'claude-opus-4-5',
    systemPrompt = DEFAULT_SYSTEM_PROMPT,
    maxIterations = 20,
    tools = registry,
    verbose = false,
  } = {}) {
    this.model = model;
    this.systemPrompt = systemPrompt;
    this.maxIterations = maxIterations;
    this.tools = tools;
    this.verbose = verbose;
    this.memory = new MemoryManager();
    this.client = new Anthropic();
  }

  log(...args) {
    if (this.verbose) console.log('[Agent]', ...args);
  }

  /**
   * タスク上でエージェントを実行する
   * @param {string} task - ユーザーのタスク
   * @returns {Promise<string>} - エージェントの最終レスポンス
   */
  async run(task) {
    this.log(`タスクを開始: ${task}`);

    // 初期タスクをメモリに追加する
    this.memory.add({
      role: 'user',
      content: task,
    });

    let iterations = 0;

    while (iterations < this.maxIterations) {
      iterations++;
      this.log(`反復 ${iterations}/${this.maxIterations}`);

      // LLMを呼び出す
      const response = await this.callLLM();// 応答からテキストとツール使用を抽出
      const textContent = response.content.filter(c => c.type === 'text');
      const toolUseContent = response.content.filter(c => c.type === 'tool_use');

      // アシスタントの応答をメモリに追加
      this.memory.add({
        role: 'assistant',
        content: response.content,
      });

      // 停止理由が 'end_turn' か、ツール呼び出しがない場合は完了
      if (response.stop_reason === 'end_turn' || toolUseContent.length === 0) {
        const finalText = textContent.map(c => c.text).join('
').trim();
        this.log(`  `Task complete after ${iterations} iterations`);
        return finalText;
      }

      // ツール呼び出しを処理
      const toolResults = await this.executeTools(toolUseContent);

      // ツール結果をメモリに追加
      this.memory.add({
        role: 'user',
        content: toolResults,
      });
    }

    throw new Error(`Agent exceeded maximum iterations (${this.maxIterations})`);
  }

  /**
   * 現在のメモリをコンテキストとしてClaudeを呼び出す
   */
  async callLLM() {
    const messages = this.memory.getContextWindow();

    const response = await this.client.messages.create({
      model: this.model,
      max_tokens: 4096,
      system: this.systemPrompt,
      tools: this.tools.toAnthropicFormat(),
      messages,
    });

    this.log(`LLM response: stop_reason=${response.stop_reason}, tool_calls=${
      response.content.filter(c => c.type === 'tool_use').length
    }`);

    return response;
  }/**
   * 応答からすべてのツール呼び出しを実行します
   */
  async executeTools(toolUseBlocks) {
    const results = await Promise.all(
      toolUseBlocks.map(async (toolUse) => {
        this.log(`ツールを呼び出しています: ${toolUse.name}`, toolUse.input);

        const result = await this.tools.execute(toolUse.name, toolUse.input);

        this.log(`ツール結果 (${toolUse.name}): ${result.success ? 'success' : 'error'}`);

        return {
          type: 'tool_result',
          tool_use_id: toolUse.id,
          content: result.success
            ? result.output
            : `ERROR: ${result.error}`,
          is_error: !result.success,
        };
      })
    );

    return results;
  }

  /**
   * エージェントをストリーミングモードで実行します(進捗をリアルタイムで表示)
   */
  async runStreaming(task, onChunk) {
    // run() と似ていますが、ストリーミング API を使用します
    this.memory.add({ role: 'user', content: task });

    let iterations = 0;

    while (iterations < this.maxIterations) {
      iterations++;

      const stream = this.client.messages.stream({
        model: this.model,
        max_tokens: 4096,
        system: this.systemPrompt,
        tools: this.tools.toAnthropicFormat(),
        messages: this.memory.getContextWindow(),
      });

      let fullResponse = '';
      const toolUseBlocks = [];for await (const event of stream) {
        if (event.type === 'content_block_delta') {
          if (event.delta.type === 'text_delta') {
            fullResponse += event.delta.text;
            onChunk?.(event.delta.text);
          }
        }
      }

      const finalMessage = await stream.finalMessage();

      this.memory.add({
        role: 'assistant',
        content: finalMessage.content,
      });

      if (finalMessage.stop_reason === 'end_turn') {
        return fullResponse;
      }

      const toolUses = finalMessage.content.filter(c => c.type === 'tool_use');
      if (toolUses.length > 0) {
        const results = await this.executeTools(toolUses);
        this.memory.add({ role: 'user', content: results });
      }
    }

    throw new Error('Max iterations exceeded');
  }
}

Step 5: まとめて使う

それでは、エージェントを使って実際のタスクを達成してみましょう:

// examples/code-analyzer.js
import { Agent } from '../agent/agent.js';
import { registry } from '../agent/tools.js';

// このユースケース用のカスタムツールを追加する
registry.register({
  name: 'count_lines',
  description: "コードディレクトリ内の行数を、ファイル種別ごとに分解して数える。"
  inputSchema: {
    type: 'object',
    properties: {
      directory: { type: 'string', description: "分析するディレクトリのパス" },"
    },
    required: ['directory'],
  },
  execute: async ({ directory }) => {
    const { execSync } = await import('child_process');
    const output = execSync(
      `find ${directory} -type f \( -name "*.js" -o -name "*.ts" -o -name "*.py" \) | xargs wc -l 2>/dev/null | sort -n`,
      { encoding: 'utf8' }
    );
    return output;
  },
});

const agent = new Agent({
  verbose: true,
  maxIterations: 15,
});

// 複数ステップの分析タスクを実行する
const result = await agent.run(`
  現在のディレクトリ内の Node.js プロジェクトを分析し、次を提供してください:
  1. ファイル種別ごとのコード行数の合計
  2. 最大のファイル上位 5 つ
  3. 見つけられる限りの明らかなコード品質の問題
  4. 構成(構造)に基づいて、このプロジェクトが何を行うものかの簡単な要約

  利用可能なツールを使ってコードベースを調べてください。まず package.json から始めてください。
`);

console.log('
=== ANALYSIS RESULT ===
');
console.log(result);

ステップ 6: 高度なメモリパターン

セマンティックメモリ(長期の事実)

複数のセッションにまたがって動作するエージェントの場合は、永続的なメモリが必要です:

// agent/semantic-memory.js
import { readFileSync, writeFileSync, existsSync } from 'fs';

export class SemanticMemory {
  constructor(storagePath = '`./agent-memory.json') {
    this.storagePath = storagePath;
    this.facts = [];
    this.load();
  }

  load() {
    if (existsSync(this.storagePath)) {
      const data = JSON.parse(readFileSync(this.storagePath, 'utf8'));
      this.facts = data.facts || [];
    }
  }

  save() {
    writeFileSync(this.storagePath, JSON.stringify({ facts: this.facts }, null, 2));
  }

  remember(fact, category = 'general') {
    this.facts.push({
      fact,
      category,
      timestamp: new Date().toISOString(),
    });
    this.save();
  }

  recall(category = null) {
    const relevant = category
      ? this.facts.filter(f => f.category === category)
      : this.facts;
    return relevant.map(f => `[${f.category}] ${f.fact}`).join('
');
  }// システムプロンプトの先頭に注入するためのメモリプレフィックスを構築する
  buildMemoryContext() {
    if (this.facts.length === 0) return '';
    return `## あなたのメモリ
あなたは、過去のセッションから次の内容を記憶しています:
${this.recall()}
`;
  }
}

// 使用例: エージェントにメモリを注入する
const memory = new SemanticMemory('./my-agent-memory.json');

const agent = new Agent({
  systemPrompt: `${memory.buildMemoryContext()}
あなたは役に立つアシスタントです...`,
});

// 「remember」ツールを登録して、エージェントが情報を保存できるようにする
registry.register({
  name: 'remember',
  description: ""将来のセッションのために重要な事実を長期メモリに保存する。""
  inputSchema: {
    type: 'object',
    properties: {
      fact: { type: 'string', description: ""記憶しておく事実"" },
      category: { type: 'string', description: ""カテゴリ: user_preference, task_result, technical_fact""},
    },
    required: ['fact'],
  },
  execute: async ({ fact, category }) => {
    memory.remember(fact, category);
    return `記憶しました: "${fact}"`;
  },
});

ワーキングメモリ(タスク固有の状態)

// ときには、ツール呼び出し間で共有状態が必要になります
export class WorkingMemory {
  constructor() {
    this.state = {};
  }

  set(key, value) {
    this.state[key] = value;
  }

  get(key) {
    return this.state[key];
  }

  dump() {
    return JSON.stringify(this.state, null, 2);
  }
}

const workingMemory = new WorkingMemory();// ツールは共有状態を読み書きできる
registry.register({
  name: 'set_variable',
  description: "'後で使うために作業メモリへ値を保存します。',"
  inputSchema: {
    type: 'object',
    properties: {
      key: { type: 'string' },
      value: { type: 'string' },
    },
    required: ['key', 'value'],
  },
  execute: async ({ key, value }) => {
    workingMemory.set(key, value);
    return `保存しました ${key} = ${value}`;
  },
});

ステップ7:マルチエージェントのオーケストレーション(フレームワークなし)

場合によっては、1人のエージェントでは不十分です。複数のエージェントをオーケストレートする方法は次のとおりです:

// agent/orchestrator.js
import { Agent } from './agent.js';

export class Orchestrator {
  constructor() {
    this.agents = new Map();
  }

  addAgent(name, config) {
    this.agents.set(name, new Agent(config));
    return this;
  }

  /**
   * 各エージェントの結果を次へ渡すことで、一連のパイプラインを実行する
   */
  async pipeline(stages) {
    let result = null;

    for (const stage of stages) {
      const agent = this.agents.get(stage.agent);
      if (!agent) throw new Error(`不明なエージェント:${stage.agent}`);

      const task = typeof stage.task === 'function'
        ? stage.task(result) // 直前の結果に基づいて動的にタスクを作る
        : stage.task;

      console.log(`
=== 実行中 ${stage.agent} ===`);
      result = await agent.run(task);

      if (stage.transform) {
        result = stage.transform(result);
      }
    }

    return result;
  }
// 返却形式: {"translated": "翻訳されたHTML"}
/** * エージェントを並列に実行し、結果を統合する */ async parallel(tasks) { const results = await Promise.all( tasks.map(async ({ agent: agentName, task }) => { const agent = this.agents.get(agentName); const result = await agent.run(task); return { agent: agentName, result }; }) ); return results; } } // 例: 調査 + 執筆パイプライン const orchestrator = new Orchestrator(); orchestrator .addAgent('researcher', { systemPrompt: 'あなたはリサーチアシスタントです。事実を見つけ、情報を要約し、重要なポイントを特定してください。', maxIterations: 10, }) .addAgent('writer', { systemPrompt: 'あなたはテクニカルライターです。調査メモを受け取り、それを明確で構造化されたドキュメントに仕上げてください。', maxIterations: 8, }); const documentation = await orchestrator.pipeline([ { agent