12
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

0から低コストで社内AIエージェントを開発しよう

12
Last updated at Posted at 2025-12-15

はじめに

現在、弊社のSlackではこのようなBOTが運用されています。

名前がかっこいいですね。
弊社はエンジニアの自主性を重んじる組織であるので、勝手に作って導入したところ、なんかいい感じに普及したのでこの記事を書くに至りました。

なぜ作ったのか

弊社ではShopifyや楽天市場、Yahooショッピング等の外部ECと連携するシステムを作っているので、10種類程度ある各ECの仕様を常に記憶しておくことは困難です。
また、お問い合わせも毎日飛んでくるので、CSの方で解決できない場合はエンジニア側が回答を行う必要があります。
お問い合わせに対して、適切な回答をするためには以下のような行動を行います。

1. 該当するECのヘルプセンターを確認
2. 過去のRedmineチケットを検索
3. 関連する社内Wikiのドキュメントを参照
4. 過去のSlackでのやり取りを検索
5. 必要に応じてGitHubのコードを確認

普段はシステムの開発を行なっているので、お問合せの対応に時間がかかると開発スケジュールが逼迫することになります。

もう開発以外でMPを消費したくないんだ

ということでAIにぶん投げるという方法は誰もが思いつくところですが、関連データを網羅的に検索するためにはアクセストークン等の情報をサードパーティ製のMCPサーバーに渡す必要があり、社外秘の情報が漏洩してしまう恐れがあります。
そのためサードパーティ製のMCPサーバーの使用は弊社では禁止されています。

じゃあ自分で作ればいいねという安易な気持ちで作ることにしました。

MCP(Model Context Protocol)とは

MCPはLLMが外部リソース(API、DB、検索サービスなど)に安全かつ一貫した手順でアクセスするための標準プロトコルです。

MCPはLLMが次のことを判断できるように設計します。

  • どのツールを使うべきか
  • どの引数を渡せばよいか
  • 実行結果をどう解釈すべきか

なぜならLLMには以下の制約があるためです。

  • URLの先を直接見ることができない
  • 外部APIを直接叩けない

そのため、RedmineやRedashなどの外部の情報を取得するためには、LLMの代わりにAPIを実行できるエージェント(=MCPサーバー) が必要となってきます。

回答を得るまでの全体像

大きく分けるとこのような流れで回答を実行しています。
構成を考えるにあたり、社内で既に使用しているサービスで、尚且つお金をかけないという点を意識した結果このようになりました。

特にGitHub Actionsが実行環境として優秀で、webhook経由でworkflowを起動できるので自前でサーバーを用意する必要がありません。

詳細な流れ

① Slackでhelpスタンプを押す

ユーザーがSlackのメッセージにhelpスタンプを付けます。

② Zapierがメッセージを検知

ZapierがSlackのメッセージを監視し、helpスタンプが付けられたメッセージを検知すると、GitHub Actionsのworkflowをwebhookで起動します。

③ GitHub Actionsのworkflowが起動

GitHub Actionsのworkflowが起動し、sample-ai-agentリポジトリの処理が実行されます。

④ 外部データの取得方法の定義

外部データの取得方法をLLMへ伝えるために、sample-mcpを使ってまとめます。
対応している外部サービスは以下の通り(sample-mcpではSlack/Redmine/Claudeのみ):

  • GitHub(コード検索、Issue確認など)
  • GoogleDocs / GoogleSpreadSheet(ドキュメント参照)
  • GoogleCalender(スケジュール確認)
  • Growi(Wiki参照)
  • Redash(データ分析)
  • Redmine(チケット検索、過去の対応履歴確認)
  • Slack(過去のやり取り検索)
  • FAQ(よくある質問参照)
  • Claude Code(コード生成支援)
  • Amazon Titan(追加のLLM)

⑤ Claudeに質問を送信

質問の内容と、先ほどまとめた外部データの取得方法をClaudeに送信します。
ここはAmazonBedrockのAPI経由でClaude Sonnet 4.5を使用しています。

Claudeは質問の内容に基づいて、必要な情報を収集するために適切な外部データの取得方法を選択し、実行要求を返します。

例えば「Shopifyの注文同期エラーについて」という質問なら

  • Redmineで過去のShopify関連のチケットを検索
  • GrowiでShopifyのドキュメントを参照
  • GitHubでShopify関連のコードを確認

を実行要求として返してきます。

⑥ Claudeからの回答を取得

Claudeの回答に含まれる実行要求に基づいてアプリ側で外部データを取得し、その結果を再度Claude渡します。

⑦ 最大10回まで繰り返し

Claudeは必要に応じて追加の情報を収集するため、最大10回までこのやり取りを繰り返します。

⑧ 最終回答を生成

下記の条件に該当した場合、Claudeに総括を生成してもらいます。

  • 十分な情報が集まったとClaudeが判断した場合
  • やり取りが10回に達成した場合

⑨ Slackに投稿

最終的な回答と、参照した情報源のURLを付与した状態でSlackに投稿します。

成果物

コードも貼らずにスレ立ては麻呂なので、今回の記事の説明用に一部機能だけ抜粋したものを成果物として置いておきます。(なのでそのまま動くかは不明です)

つまり、AIエージェントの中身はこの2つのリポジトリの内容で構成されており、GitHub Actionsのworkflow内でこれらを実行しています。

sample-mcpについて

sample-mcpはMCP部分の実装だけを切り出したTypeScript製のライブラリです。
外部サービスのデータをCLIから取得する手段」と、「AIアプリケーション作成時に使用するライブラリ」という2つの役割を提供しています。

また、sample-mcpでは、Claudeに利用可能なMCPを伝えるためのToolUseスキーマへの変換も担当しています。

ToolUseとは

Claudeにメソッドの情報を教えると、Claudeが実行要求を返してくれる機能です。

// Claudeからの実行要求例
{
  "id": "toolu_01X23Y4Z5A6B7C8D9E0F1G2H",
  "name": "redmine_getIssue",
  "input": {
    "issueId": "57442"
  }
}

sample-mcpは全ての外部サービスに対して、
getMcp('サービス名').mcpFunctions['メソッド名']('引数')
の形式で実行できるように作られているので、実行要求を分解してそのまま実行できます。

import { getMcp } from 'sample-mcp';

// Redmineのチケットを取得
const issue = getMcp('redmine').mcpFunctions['getIssue']('57442');

// GitHubのIssueを検索
const issues = getMcp('github').mcpFunctions['searchIssues']('Shopify エラー');

MCPを定義することで、LLMと実装コード間の連携をスムーズに行う事ができるというわけです。

ToolUseに関するあれこれ

なぜメッセージでCLIコマンドを渡すのではなくToolUseなのか

  • メッセージにコマンド内容を含めなくて良い
    • 推論時のノイズを抑えることができる。
    • LLMが自然言語で「このAPIを叩いて」と指示してくるのではなく、構造化された実行要求を返してくれる。
  • 揺れを減らす
    • ツール名と型付き引数をJSONでやり取りするので、自然文やCLI文字列をパースするより解釈が安定する。
  • 安全・監査しやすい
    • 許可した関数だけ実行するサンドボックスとして動き、実行結果やエラーを構造化ログに残せる。
  • 環境依存を吸収
    • OS/シェル/パス差分に引きずられにくく、バックエンド実装を差し替えても同じI/Fで呼べる。
  • オーバーヘッド対策
    • CLIよりプロセス起動回数を減らしやすい。重い処理はアプリ側でバッチ化するなど工夫がしやすい。ライブラリ呼び出しで済ませれば、毎回のプロセス生成(fork/exec)より軽くできる。

CLIを返す方が人間は再現しやすい一方で、ToolUseは「LLMと実行環境の間の共通RPCインターフェース」という位置づけで、再現性・安全性・移植性を優先する思想になっています。

(余談)ToolUse的なものが無いLLMの場合はどうするのか

ToolUse的なものが無いLLMの場合は、JSONで回答を返すように指示しておけば、ToolUseを使った時のような返答を返すことができるので、実装部分との連携が可能になります。

## 返答形式例
以下のJSON形式で返答してください:
{
  "answer": "分析結果",
  "additional_links": ["https://example.com/doc"],
  "additional_commands": ["npm run cli growi:getPage 123456"],
  "additional_infos": ["補足情報"]
}

重要な注意事項:
- additional_links: 参考になるURLがあれば具体的なURLを記載してください
- additional_commands: コマンドを提示する場合は、[ID]などのプレースホルダーではなく、コンテキストから取得した実際のIDを使用してください
- additional_infos: 追加の補足情報があれば記載してください
- 情報がない場合は空配列 [] を返してください

ToolUseという存在を知るまではこのような感じで実装していました。

MCPをToolUseで渡す

sample-mcpnpm run cli all-commandsを実行すると、各外部サービス毎にマークダウンで説明が出力されます。

sample-mcp % npm run cli all-commands

> sample-mcp@1.0.0 cli
> ts-node -r tsconfig-paths/register src/presentation/cli/main.ts all-commands

# 全プラットフォームのコマンド一覧

# SLACK Platform

## 概要
Slackの情報にアクセス

## 使用場面
- チャンネル情報を確認する場合
- メッセージ履歴を確認する場合
- 指示された内容がわからない場合
- オペレーションで過去のやり取りを確認する場合

## 利用可能なコマンド
1. getChannels [queryParams]
   説明: チャンネル一覧を取得(channels:read スコープが必要)
   使用方法: npm run cli slack:getChannels

2. getConversationHistory <channel> [queryParams]
   説明: 会話履歴を取得(channels:history スコープが必要)
   使用方法: npm run cli slack:getConversationHistory xxxxx limit=10

3. getFileInfo <fileId>
   説明: ファイル情報を取得(files:read スコープが必要)
   使用方法: npm run cli slack:getFileInfo xxxxx
...

8. searchMessages <query> [queryParams]
   説明: ワークスペース全体でメッセージを検索(search:read スコープが必要)
   使用方法: npm run cli slack:searchMessages "copilot ライセンス" count=20

## セキュリティルール
以下の操作は禁止されています:
- 絶対禁止: ファイルの削除・アップロード・アーカイブ・非公開化する行為
- 絶対禁止: 個人情報(人物名/メールアドレス/電話番号)を記載する行為
- 絶対禁止: 機密情報を記載する行為
- 絶対禁止: 指示がない状態で書き込みを行う行為

---

# REDMINE Platform

## 概要
Redmine(課題・チケット管理)にアクセス

## 使用場面
- チケットの詳細を確認する場合
- 案件の全容を確認する場合
- お問い合わせの内容を確認する場合
- 過去のオペレーションの対応内容を確認する場合

## 利用可能なコマンド
1. getIssues [queryParams]
   説明: 課題一覧を取得
   使用方法: npm run cli redmine:getIssues '{"subject":"~在庫減算","description":"~在庫減算","limit":"5"}'

2. getIssue <issueId> [queryParams]
   説明: 特定の課題を取得(親・子チケット、関連、コメント含む)
   使用方法: npm run cli redmine:getIssue xxxxx

---

# 全AIモデルのコマンド一覧

=== CLAUDE ===

概要:
  Claude AIモデルを使用してメッセージを送信し、応答を取得

使用コンテキスト:
  - LLMを使った自然言語処理が必要な場合
  - テキスト生成、要約、質問応答などのタスクを実行する場合
  - AIアシスタントとの対話が必要な場合

利用可能な関数:
  claude:ask
    説明: Claudeに質問を送信して応答を取得(テキスト形式)
    使用例: npm run cli claude:ask "こんにちは"
  claude:askJson
    説明: Claudeに質問を送信して応答を取得(JSON形式)
    使用例: npm run cli claude:askJson "このメッセージを分析してください"

---

特に「使用場面」「使用方法」が重要で、LLMが回答を推論する際に次に知りたい情報についてLLM側が具体的な提示をしやすくなります。
この内容は利用者への説明を提示すると共に、ToolUseで渡すJSONデータの元にもなっています。

// ClaudeのToolUseへ渡す場合の例
{
  "name": "redmine_getIssue",
  "description": "特定の課題を取得(親・子チケット、関連、コメント含む)",
  "input_schema": {
    "type": "object",
    "properties": {
      "issueId": {
        "type": "string",
        "description": "issueId parameter"
      },
      "queryParams": {
        "type": "string",
        "description": "queryParams parameter"
      }
    },
    "required": ["issueId"]
  }
}

sample-ai-agentについて

ローカルで実行するだけなら、sample-mcpの存在をエディタ上のAIエージェント(例: Cursorなどの実行仲介まで持つタイプ)に教えるだけで充分で、実際に開発の時にもよく利用しています。

なぜ充分なのかというと、エディタ上のAIエージェントが「LLMに質問→返ってきた実行要求を実行→結果を再度LLM渡す」というループを裏で肩代わりしてくれるからです。

一方で、誰でも使えるBOTや独立したAIアプリとして提供する場合は、この「実行要求を受け取り、実行し、再度LLMに投げ返す」というエージェント層を自前で実装する必要があります。

sample-ai-agentはまさにこの役割を担い、LLMとの対話を再帰的に回しつつ、MCP経由で外部サービスを呼び出すハブとして動いています。

リポジトリを分けている理由は、sample-mcpは共通のライブラリとして利用しつつ、sample-ai-agentはAIアプリにあたる部分なので、「実現したい内容に応じて各自で作ってください」という思想によるものです。

sample-ai-agentへsample-mcpを導入する

package.jsonに下記のコードを追加してnpm installするだけでいいように作成しています。

{
  "dependencies": {
    "sample-mcp": "github:Kate-AC/sample-mcp"
  }
}

AIエージェント実装における3つの壁

AIエージェントを実装する際には、以下の3つの壁を考慮する必要があります。

1. API従量課金の壁

料金は実行回数より送信したトークン数の影響が大きいです。

LLMは過去のやり取りを保持しないので、毎回やり取りの履歴を渡す必要があります。

例えばLLMから提案された実行要求が失敗した場合、LLMは成功するように微調整した実行要求を提案する場合がありますが、その提案に従って何度も実行していくと過去の履歴が積み重なり、金額が増加していきます。

対策:

  • MCPを定義して実行要求が確実に成功するものに限定する
  • 数回のやり取りごとに履歴の要約処理を挟む
    • 要約は料金の安いLLMに投げるのも有効

2. トークン数上限の壁

Claude Sonnet 4.5の場合、トークン数は20万程度です。

MCPを使って全ての情報源から包括的にデータを取得して推論をしてもらうようなアプリケーションの場合、数回もやり取りすればトークン数の上限に達してしまいます。
上限に達した場合は古い情報が切り捨てられるので、その時点で回答の信頼性が低くなる上、過去の履歴の蓄積によりハルシネーションが起きやすくなる可能性もあります。

対策:

  • MCPを定義して実行要求が確実に成功するものに限定する
  • 数回のやり取りごとに履歴の要約処理を挟む
  • 推論回数の上限を定めて20万に到達する前に回答を出してもらう

3. リクエスト数制限の壁

リクエスト制限は下記の要素で複合的に発生します:

  • 1分間あたりのリクエスト数
  • 1分間あたりのトークン入力数
  • 1分間あたりのトークン出力数

リクエスト制限は頻発するので、リトライ処理はほぼ必須と言っていいです。

対策:

  • リトライ処理の実装
  • 数回のやり取りごとに履歴の要約処理を挟むことでそもそもトークン数を削減する

今後やろうと思っている改善点

1. 回答が役に立ったか評価できる仕組みを導入

継続的に回答内容を調整できるようにするため、回答の評価機能を導入予定です。

2. メッセージに対してタグ付けを行う

最初にメッセージをLLMにPOSTする際に、内容に対してタグ付けを行うように指示し、タグの内容に応じた情報を次回のPOST時に付与するようにします(タグはこちらが用意します)。

これによりドメイン知識が必要な回答の精度の向上が見込めます。

例えば「お問い合せ」「Shopify」のタグがあれば、Shopifyのヘルプセンターやwikiの内容を読ませて、尚且つRedmineで過去の対応を検索する比重を増やす等の対応を行います。

3. 適切なタイミングで過去のやり取りの要約処理を挟む

複数の情報源からデータを取得するという特性上、やり取りが増えると情報のノイズが増えていきます。
要約処理を挟むことで、本当に必要な内容に絞って推論を行うことができ、回答精度の向上が見込めます。

まとめ

この仕組みのおかげでお問い合わせの対応だけでなく、開発時の仕様調査や情報収集が効率化されました。過去の対応内容や類似事例から回答を拾ってきているので、AIの回答をそのまま流用できるケースもそれなりにあり、かなりの負担軽減になっていると感じています。

まだまだ改善の余地はありますが、引き続きこのAIエージェントを成長させていくつもりです。
もし、あなたの会社でも同じような課題を抱えているなら、ぜひ参考にしてみてください。

12
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?