LLM搭載エージェントを当社のNode.js/Express APIに統合して学んだこと

Dev.to / 2026/4/8

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

要点

  • この記事では、既存のNode.js/ExpressバックエンドにLLM搭載エージェントを統合することの実践的な価値を説明しており、内部APIを通じて「パース、要約、ワークフローのオーケストレーション」といった“つなぎ作業”を自動化できる点を強調しています。
  • エージェント的アプローチ(例:関数呼び出しやツール統合)は、壊れやすい定型ロジックよりも優れた結果を出せる一方で、信頼性の高い挙動を実現し、バックエンドのパフォーマンス問題を防ぐためには慎重な実験が必要だと論じています。
  • 中核となるウォークスルーでは、OpenAIのNode.js SDKを使ってExpressのルートからLLMエージェントを呼び出す方法を示しており、テキスト要約のための最小限の例も紹介されています。
  • 著者は、全体の体験を「ブレークスルーの可能性」と「プロダクション品質の課題」が混ざり合ったものとして捉え、その学びやコード断片を他の開発者が再利用できる形で共有しています。

こう想像してみてください。バックエンドには、退屈な自動化のチケットが山積みです。メールを解析したり、顧客チャットを要約したり、サービス間でワークフローをオーケストレーションしたり。TwitterでLLMが魔法のようなことをしているのは聞いたことがある。でも、それをガタガタのNode.js/Express APIに統合する?それはまた別の話です。私はそこにいました。そして、LLMによるエージェントを実際のバックエンドで扱おうとしたことがあるなら、それが「ブレークスルー」でもあり「髪の毛を抜きたくなる」ことでもあるのを知っているはずです。ここでは、エージェント型AIを本番投入したときに実際に起きたこと、もっと早く知っておきたかったこと、そして拝借できるいくつかのコードスニペットを紹介します。

ExpressでLLMエージェントにわざわざ手を出す理由は?

バックエンドの仕事の多くは、システム間でデータをやり取りし、フォーマットを変換し、退屈な業務ロジックを埋めることです。LLMによるエージェント(OpenAIのfunction callingやLangChainのTool連携のようなもの)は、通常のスクリプトよりも文脈への理解や柔軟性を持って、こうした「つなぎ」の作業を自動化できます。

たとえば、顧客メールから意味を抽出するために50個もの脆い正規表現を書く代わりに、エージェントに何をすべきか、そしてどうやるかを判断させ、必要に応じて社内APIを呼び出すことができます。

ただし落とし穴は細部にあります。APIを苦しめることなく、LLMエージェントに確実に有用な仕事をさせるには、多くの試行錯誤が必要でした。

例1:Expressルートからエージェントを呼び出す

まずはシンプルにいきましょう。Expressのエンドポイントで、LLMエージェントを使って文章の一部を要約する必要があるとします。

以下は、OpenAIのGPT-4(Node.js SDK経由)を使い、シンプルな「summarize」ツールを用いる最小限の例です。

// routes/summarize.js
const express = require('express');
const { OpenAI } = require('openai'); // 公式OpenAI SDK

const router = express.Router();
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

router.post('/summarize', async (req, res) => {
  try {
    const { text } = req.body;
    if (!text) return res.status(400).json({ error: 'Missing text' });

    // 要約プロンプトでGPT-4モデルを呼び出す
    const completion = await openai.chat.completions.create({
      messages: [{ role: 'user', content: `Summarize this: ${text}` }],
      model: 'gpt-4',
      max_tokens: 200,
      temperature: 0.5,
    });

    // 要約をクライアントへ返す
    res.json({ summary: completion.choices[0].message.content });
  } catch (err) {
    // デバッグのためにエラーをログ出力する
    console.error(err);
    res.status(500).json({ error: 'Summarization failed' });
  }
});

module.exports = router;

ここで重要なのは何?

  • 必ず入力を検証する。LLMはゴミのような入力で混乱したり、空のプロンプトでトークンを無駄に消費したりします。
  • 例外を捕捉してログに残す。これらのログが必要になります。LLM APIは思っているよりも頻繁に失敗します。
  • max_tokenstemperature はコストと品質に本当に影響します。最初は控えめに。

このエンドポイントは動きますが、まだ「エージェント的」ではありません。単一のプロンプトです。LLMが本当のアクションを引き起こし始めると、面白さが本格化します。

例2:LLMエージェントが社内API呼び出しをトリガーする

エージェントの魔法は、どのツール(関数)を、どの順番で呼ぶかをLLMに判断させられることです。たとえば、サポート依頼を処理するLLMエージェントを作りたいとします。必要に応じて社内の /lookup-user/send-email のエンドポイントを呼び出せる、という感じです。

私が見つけた最も柔軟な方法:OpenAIのfunction callingを使い、その関数定義をバックエンドの実際のAPI呼び出しに接続します。

以下に、簡略化した(ただし動作する)パターンを示します:

// services/agent.js
const { OpenAI } = require('openai');
const axios = require('axios');

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

// エージェントが呼び出せるツール(関数)を定義する
const functions = [
  {
    name: 'lookupUser',
    description: 'メールアドレスでシステム内のユーザーを検索します',
    parameters: {
      type: 'object',
      properties: {
        email: { type: 'string', description: 'ユーザーのメールアドレス' },
      },
      required: ['email'],
    },
  },
  {
    name: 'sendEmail',
    description: 'ユーザーにメールを送信します',
    parameters: {
      type: 'object',
      properties: {
        to: { type: 'string', description: '受信者のメールアドレス' },
        subject: { type: 'string' },
        body: { type: 'string' },
      },
      required: ['to', 'subject', 'body'],
    },
  },
];

// 関数名を実際の実装にマッピングする
const tools = {
  lookupUser: async ({ email }) => {
    // 内部APIを呼び出す(データベースを直接問い合わせてもよい)
    const resp = await axios.post('http://localhost:3000/internal/lookup-user', { email });
    return resp.data;
  },
  sendEmail: async ({ to, subject, body }) => {
    // SMTPサービス、またはメールのマイクロサービスを呼び出す
    await axios.post('http://localhost:3000/internal/send-email', { to, subject, body });
    return { status: 'sent'};
  },
};async function runAgent(messages) {
  // 手順 1: LLM に、呼び出したい関数を何にするかを尋ねる
  const completion = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages,
    functions,
  });

  const functionCall = completion.choices[0].message.function_call;
  if (!functionCall) {
    // 呼び出す関数がないので、メッセージをそのまま返す
    return { result: completion.choices[0].message.content };
  }

  // 手順 2: 対応付けられた関数を呼び出す
  const fn = tools[functionCall.name];
  if (!fn) throw new Error('不明な関数: ' + functionCall.name);

  // 関数の引数は常に OpenAI からの JSON 文字列
  const args = JSON.parse(functionCall.arguments);

  const fnResult = await fn(args);

  // 手順 3: 関数結果を LLM に渡して次のステップへ進める
  messages.push({
    role: 'function',
    name: functionCall.name,
    content: JSON.stringify(fnResult),
  });

  // 手順 4: 会話を続行する(複数ステップ繰り返すことができる)
  const finalCompletion = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages,
    functions,
  });

  return { result: finalCompletion.choices[0].message.content };
}

module.exports = { runAgent };

なぜこのパターン?

  • LLM が、どのツールをどんな引数で呼び出すかを選びます。
  • あなたのコードが LLM と実際の API の間を安全に仲介します。決してモデルに直接呼ばせないようにします。
  • tools にログ出力、リトライ、権限チェックを簡単に追加できます。

ここで私は最初の壁にぶつかりました。OpenAI から返ってくる「関数呼び出し」の結果は常に JSON 文字列であり、注意深く解析して検証する必要があります。必要なプロパティが欠けていたせいで本番エージェントがクラッシュした不具合を、週末まるごとデバッグする羽目になりました。

Example 3: Express 経由で LLM のレスポンスをストリーミング

状況によっては、生成されていくエージェントの応答をストリーミングしたいことがあります。例えばチャット UI を動かすためです。OpenAI SDK はストリーミングに対応していますが、Express でそれをきれいに組み立てるには少し注意が必要です。

私がやったのはこうです:

// routes/streamAgent.js
const express = require('express');
const { OpenAI } = require('openai');
const router = express.Router();

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

返却形式: {"translated": "翻訳されたHTML"}router.post('/agent/stream', async (req, res) => {
  try {
    const { prompt } = req.body;
    if (!prompt) return res.status(400).json({ error: 'プロンプトがありません' });

    // Expressにこれがイベントストリームであることを伝える
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');

    // OpenAI SDKでストリームを開始する
    const stream = await openai.chat.completions.create({
      model: 'gpt-4o',
      messages: [{ role: 'user', content: prompt }],
      stream: true,
    });

    // 到着したトークンをそのままパイプする
    for await (const chunk of stream) {
      // それぞれのチャンクをSSEイベントとして送信する(1トークンずつ)
      res.write(`data: ${chunk.choices[0]?.delta?.content || ''}

`);
    }

    // 完了したらストリームを終了する
    res.end();
  } catch (err) {
    console.error('ストリームのエラー', err);
    // ストリーミングに失敗した場合は接続を閉じる
    res.end();
  }
});

module.exports = router;

いくつかのヒント:

  • 必ずSSE(Server-Sent Events)の正しいヘッダーを設定してください。
  • res.end() を呼び出すのを忘れないでください。これがないとクライアントが永遠に待ち続けます。
  • プロキシの背後にいる場合(例:Nginx)、必ずストリーミングを有効化し、バッファをフラッシュしてください。

これを初めて出荷したとき、Cache-Control: no-cache を設定するのを忘れてしまい、Chrome がストリームをずっとバッファし続けました。IE6と格闘していた頃を思い出すようなデバッグ体験でした。

共通のミス

最初にこれらを誰かに教えてもらえていればよかったです:

1. エージェントにやらせすぎる

LLMエージェントに すべて の社内APIを呼ばせないでください。何が公開されているかを明確にし、すべての引数を検証してください。そうしないと、意図していないことをエージェントが勝手に実行してしまいます。

2. トークンコストとレイテンシを無視する

LLMは無料ではありません。また、モデル呼び出しをループで行うエージェント型のフローは、コスト(そしてAPIの応答速度)を急速に悪化させる可能性があります。常に妥当な max_tokens を設定し、可能な場合はバッチ処理を行い、使用状況を監視してください。

3. 関数呼び出しエラーを扱わない

OpenAI(ほかのLLMも同様)は、ときどき存在しない関数名をでっち上げたり、必須の引数を忘れたりします。必ず検証し、エラーを適切に処理してください。LLMが「勝手にうまくいく」ことを前提にしないでください。

重要なポイント

  • エージェント型のLLMはExpress APIをずっと賢くできますが、ツールの境界と検証を慎重に設計する必要があります。
  • LLMのリクエストとツール呼び出しの両方を必ずログに残し、監視してください。デバッグやコストの追跡に必要になります。
  • 小さく始めましょう。重要または高コストなものを公開する前に、シンプルで安全なツールから接続します。
  • LLMのレスポンスをストリーミングすることは可能ですが、ヘッダー、プロキシ、クライアントの切断を処理する必要があります。
  • LLMから送られてくる すべて を検証してください。モデルに、APIを安全に呼び出すことをそのまま期待してはいけません。

LLMエージェントをNode.js/ExpressのAPIに持ち込む体験は、バックエンド自動化のための新しいチートコードを解放したように感じました。魔法ではありませんが、正しいガードレールを設定すれば、非常に柔軟です。もし検討しているなら、おもちゃのエージェントから始めて、どんな予想外が起きるか確かめてみてください。

役に立ったなら、私たちのブログでもっとプログラミングのチュートリアルをチェックしてください。PythonJavaScriptJavaデータサイエンス、そしてその他を扱っています。

LLM搭載エージェントを当社のNode.js/Express APIに統合して学んだこと | AI Navigate