トピック

Radeon AI PRO R9700で作った自社AIを営業・在庫・調査で実用化する実践マニュアル

docker-compose.yml

 「MySQL」「mcpo」「Open WebUI」の3コンテナをまとめて起動する構成。実際の社内MySQLに接続する場合は、mysqlサービスの部分を外し、mcpo/config.jsonのMYSQL_HOST(必要に応じてMYSQL_USER/MYSQL_PASS/MYSQL_DB)を実サーバーに向ければ、あとの構成はそのまま使い回せる。

yaml
services:
  mysql:
    image: mysql:8.4
    container_name: demo-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpw_demo}
    volumes:
      - ./db:/docker-entrypoint-initdb.d:ro
      - mysql-data:/var/lib/mysql
    ports:
      - "3306:3306"
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-rootpw_demo}"]
      interval: 5s
      timeout: 5s
      retries: 20

  mcpo:
    image: ghcr.io/open-webui/mcpo:main
    container_name: demo-mcpo
    restart: unless-stopped
    depends_on:
      mysql:
        condition: service_healthy
    command: ["--port", "8000", "--api-key", "${MCPO_API_KEY:-mcpo_demo_key}", "--config", "/config/config.json"]
    volumes:
      - ./mcpo/config.json:/config/config.json:ro
    ports:
      - "8000:8000"

  open-webui:
    image: ghcr.io/open-webui/open-webui:v0.11.0
    container_name: demo-open-webui
    restart: unless-stopped
    depends_on:
      - mcpo
    environment:
      OPENAI_API_BASE_URL: ${LLM_API_BASE_URL}
      OPENAI_API_KEY: ${LLM_API_KEY}
      WEBUI_AUTH: "true"
    volumes:
      - open-webui-data:/app/backend/data
    ports:
      - "3000:8080"

volumes:
  mysql-data:
  open-webui-data:

MySQL 読み取り専用ユーザー作成SQL(GRANT文)

sql
-- MCPサーバー用の読み取り専用ユーザー(在庫管理データのみ参照可能)
-- デモ用の固定パスワード。実運用ではシークレット管理の仕組みを使うこと。
CREATE USER IF NOT EXISTS 'mcp_reader'@'%' IDENTIFIED BY 'mcp_reader_pw_demo';
GRANT SELECT ON salesdb.products TO 'mcp_reader'@'%';
GRANT SELECT ON salesdb.inventory TO 'mcp_reader'@'%';
FLUSH PRIVILEGES;

ダミーDBのスキーマ定義(CREATE TABLE)

sql
-- デモ用ダミーDB: 在庫管理スキーマ
-- MySQL docker-entrypoint-initdb.d で自動実行される
-- 初期化スクリプトはmysqlクライアントのデフォルト文字コード(latin1)で実行されるため、
-- 日本語リテラルを含むファイルの先頭には必ず SET NAMES utf8mb4; が必要
SET NAMES utf8mb4;

CREATE DATABASE IF NOT EXISTS salesdb CHARACTER SET utf8mb4 COLLATE utf8mb4_ja_0900_as_cs;
USE salesdb;

CREATE TABLE products (
    product_id    INT AUTO_INCREMENT PRIMARY KEY,
    sku           VARCHAR(20)  NOT NULL UNIQUE,
    product_name  VARCHAR(100) NOT NULL,
    category      VARCHAR(50)  NOT NULL,
    unit_price    DECIMAL(10,2) NOT NULL,
    created_at    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

CREATE TABLE inventory (
    product_id    INT PRIMARY KEY,
    warehouse     VARCHAR(20) NOT NULL,
    stock_qty     INT NOT NULL,
    reorder_point INT NOT NULL,
    updated_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (product_id) REFERENCES products(product_id)
) ENGINE=InnoDB;

ダミーデータの生成スクリプト

 「5カテゴリ×10品目=計50品目、うち8品目が発注点割れ」というデモ用データは、以下のスクリプトで再現できる(外部依存なし、random.seed(42)で固定しているため毎回同じ結果になる)。使い方はpython3 generate_seed.py > 02_seed.sqlで、出力したSQLをdb/ディレクトリに置けばMySQL起動時に自動投入される。

python
#!/usr/bin/env python3
"""ダミー在庫管理データのSQLを生成するスクリプト(外部依存なし、再現性のため固定シード)。
使い方: python3 generate_seed.py > 02_seed.sql
"""
import random

random.seed(42)

CATEGORIES = {
    "OA機器": ["レーザープリンター", "複合機", "シュレッダー", "ラミネーター", "モニターアーム"],
    "IT機器": ["ノートPC", "外付けSSD", "USBハブ", "Webカメラ", "無線LANルーター"],
    "事務用品": ["コピー用紙", "ファイルボックス", "ホワイトボード", "付箋セット", "ボールペン12本セット"],
    "家具": ["オフィスチェア", "スタンディングデスク", "ロッカー", "会議用テーブル", "パーテーション"],
    "消耗品": ["トナーカートリッジ", "電池パック", "清掃用クロス", "梱包テープ", "段ボール箱(L)"],
}

def esc(s):
    return s.replace("'", "''")

lines = ["SET NAMES utf8mb4;", "USE salesdb;", ""]

# --- products ---
lines.append("INSERT INTO products (sku, product_name, category, unit_price) VALUES")
rows = []
product_id = 1
for category, items in CATEGORIES.items():
    for item in items:
        for variant in range(1, 3):  # 各カテゴリ x 5品目 x 2バリエーション = 10品目/カテゴリ
            sku = f"SKU-{product_id:04d}"
            name = f"{item} {'Type' + str(variant) if variant > 1 else ''}".strip()
            price = round(random.uniform(500, 45000), -1)
            rows.append(f"('{sku}', '{esc(name)}', '{category}', {price:.2f})")
            product_id += 1
lines.append(",\n".join(rows) + ";")
lines.append("")

n_products = product_id - 1

# --- inventory (一部を意図的に少在庫にして「在庫が少ない商品」デモに使う) ---
lines.append("INSERT INTO inventory (product_id, warehouse, stock_qty, reorder_point) VALUES")
inv_rows = []
low_stock_ids = set(random.sample(range(1, n_products + 1), k=8))
for pid in range(1, n_products + 1):
    warehouse = random.choice(["東京DC", "大阪DC", "福岡DC"])
    reorder_point = random.randint(10, 30)
    if pid in low_stock_ids:
        stock_qty = random.randint(0, reorder_point - 1)  # 発注点割れ
    else:
        stock_qty = random.randint(reorder_point, reorder_point + 200)
    inv_rows.append(f"({pid}, '{warehouse}', {stock_qty}, {reorder_point})")
lines.append(",\n".join(inv_rows) + ";")

print("\n".join(lines))

mcpoの起動設定・MCPサーバー登録設定

 docker-compose.ymlのmcpoサービスが--config /config/config.jsonで読み込む設定ファイル。@benborla29/mcp-server-mysqlへの接続情報と、書き込み系オペレーションの無効化(二重防御の2つ目)をここで指定する。

json
{
  "mcpServers": {
    "mysql": {
      "command": "npx",
      "args": ["-y", "@benborla29/mcp-server-mysql"],
      "env": {
        "MYSQL_HOST": "mysql",
        "MYSQL_PORT": "3306",
        "MYSQL_USER": "mcp_reader",
        "MYSQL_PASS": "mcp_reader_pw_demo",
        "MYSQL_DB": "salesdb",
        "ALLOW_INSERT_OPERATION": "false",
        "ALLOW_UPDATE_OPERATION": "false",
        "ALLOW_DELETE_OPERATION": "false"
      }
    }
  }
}

curlの例(完全版)

 本文中で省略した部分を含めた、在庫アラート確認クエリの完全形。

bash
curl -X POST -H "Authorization: Bearer (APIキー)" -H "Content-Type: application/json" \
  -d '{"sql":"SELECT p.product_name, i.stock_qty, i.reorder_point FROM inventory i JOIN products p ON i.product_id = p.product_id WHERE i.stock_qty < i.reorder_point"}' \
  http://localhost:8000/mysql/mysql_query
# => [[{"product_name":"ラミネーター","stock_qty":14,"reorder_point":23}, ...]]

vLLM起動コマンド(全文)

 第1回のコマンドに--enable-auto-tool-choiceと--tool-call-parser gemma4を追加したもの。小冊子版では第1回を参照できないため、ここに全文を再掲する。

bash
docker run -d \
  --name vllm-gemma4 \
  --network=host \
  --group-add=video \
  --group-add=render \
  --ipc=host \
  --cap-add=SYS_PTRACE \
  --security-opt seccomp=unconfined \
  --device=/dev/kfd \
  --device=/dev/dri \
  --shm-size=16g \
  -v ./gemma-4-26B-A4B-it-AWQ-4bit:/model \
  -e VLLM_ROCM_USE_AITER=0 \
  vllm/vllm-openai-rocm:v0.20.1 \
  /model \
  --served-model-name gemma-4-26B-A4B-it-AWQ-INT4 \
  --dtype float16 \
  --gpu-memory-utilization 0.90 \
  --max-model-len 131072 \
  --limit-mm-per-prompt '{"image":1,"audio":0}' \
  --enable-auto-tool-choice \
  --tool-call-parser gemma4 \
  --host 0.0.0.0 \
  --port 8888

ソフトウェア・機材バージョン一覧

 実機で確認した値(2026年8月時点)。

ソフトウェア/機材バージョン
Open WebUIv0.11.0
mcpo0.0.20(イメージタグは:mainのため今後変動し得る)
@benborla29/mcp-server-mysql2.0.9
MySQL8.4.11
Docker(ホスト)29.7.2
Node.js / npm(mcpoコンテナ内)v22.22.0 / 10.9.4
vLLMv0.20.1(第1回から変更なし)
ROCm7.2.1(第1回から変更なし)
OSUbuntu 24.04.4 LTS
メモリ64GB(第1回の32GBから増設)
ストレージ1TB SSD追加(第1回からの変更点)

提案書・Skill生成スクリプト(完全版)

 本文で紹介した簡易生成スクリプトの全体。議事録・稟議書・メール文体・提案書、計35件のSkillをまとめて出力する。

python
#!/usr/bin/env python3
"""ダミーSkillデータ(文書フォーマット・トーン集)を生成するスクリプト。
Open WebUIの「Import Skills」がそのまま読み込める形式(id / name / description / content / meta / is_active)でJSONを出力する。
使い方: python3 generate_skills.py > skills_seed.json
"""
import json

skills = []

# ============================================================
# カテゴリ1: 議事録テンプレート(10種)
# ============================================================
# (表示名, スラッグ, 説明, フォーカス)
meeting_types = [
    ("定例会議", "regular", "週次・月次などの定例ミーティング", "進捗確認・課題共有が中心"),
    ("プロジェクトキックオフ", "kickoff", "新規プロジェクト開始時のキックオフミーティング", "目的・体制・スケジュールの合意形成が中心"),
    ("顧客打ち合わせ", "customer", "社外の顧客・取引先との打ち合わせ", "先方要望の正確な記録と、社内共有のしやすさを重視"),
    ("品質会議", "quality", "品質管理・不具合対応に関する会議", "発生事象・原因・対策・再発防止の4点セットを重視"),
    ("経営会議", "exec", "経営層が参加する意思決定会議", "決定事項と保留事項を明確に分けて記録"),
    ("採用面接", "interview", "候補者との面接記録", "評価観点ごとの所見を記録し、選考の公正性を担保"),
    ("障害対応会議", "incident", "システム障害発生時の緊急対応会議", "時系列での事実整理と、暫定対応・恒久対応の切り分けが中心"),
    ("予算検討会議", "budget", "部門予算・投資案件の検討会議", "金額・根拠・承認ステータスを記録"),
    ("ベンダー打ち合わせ", "vendor", "外部ベンダー・委託先との定例打ち合わせ", "契約範囲内外の区別を意識して記録"),
    ("1on1面談", "one-on-one", "上長と部下の1対1面談記録", "本人の発言を中心に記録し、評価目的では使わない"),
]

MEETING_TEMPLATE = """# {title}議事録フォーマット

以下のフォーマットに沿って議事録を作成してください。{focus}。

## 記載項目
- 日時・場所(またはオンライン/オフラインの別)
- 出席者・欠席者
- 議題
- 決定事項(誰が・何を・いつまでに)
- 未決事項・持ち越し課題
- 次回予定

## トーン
簡潔な体言止めを基本とし、憶測や個人の感想は「所感」欄に分けて記載する。
{note}
"""

for name, slug, desc, focus in meeting_types:
    skills.append({
        "id": f"minutes-{slug}",
        "name": f"議事録テンプレート({name})",
        "description": f"{desc}向けの議事録フォーマット。{focus}。",
        "content": MEETING_TEMPLATE.format(
            title=name, focus=focus,
            note="関係者以外が読んでも経緯が分かるよう、専門用語には簡単な補足を添える。"
        ),
        "meta": {"tags": ["議事録", name]},
        "is_active": True,
    })

# ============================================================
# カテゴリ2: 稟議書テンプレート(10種)
# ============================================================
ringi_types = [
    ("備品購入", "supplies", "10万円未満のPC周辺機器・什器等の購入", "見積書の添付を必須とする"),
    ("出張申請", "travel", "国内・海外出張の事前申請", "訪問先・目的・概算費用を明記する"),
    ("システム導入", "system", "業務システム・SaaSの新規導入", "情報システム部門の事前レビューを経ていることを明記する"),
    ("外部委託契約", "outsourcing", "業務の外部委託・業務委託契約の締結", "委託範囲と成果物の定義を明確にする"),
    ("採用稟議", "hiring", "正社員・契約社員の採用決定", "採用背景と配属予定部署を明記する"),
    ("交際費", "entertainment", "接待・贈答等の交際費支出", "相手先と金額の妥当性を説明する"),
    ("研修費用", "training", "社員研修・資格取得支援の費用申請", "研修の目的と期待効果を記載する"),
    ("設備投資", "capex", "10万円以上の設備・機器投資", "投資回収見込み(ROI)を記載する"),
    ("契約更新", "renewal", "既存契約の更新・条件変更", "変更点を旧条件との対比で明記する"),
    ("広告出稿", "advertising", "広告・販促費用の支出申請", "想定効果(リーチ・コンバージョン等)の見込みを記載する"),
]

RINGI_TEMPLATE = """# 稟議書フォーマット({title})

{desc}に関する稟議書は、以下の項目で作成してください。

## 記載項目
- 件名
- 起案者・起案日
- 稟議内容の概要(3行程度で要約)
- 金額・費用(税込/税抜を明記)
- 決裁ルート(担当 → 部門長 → 経理 → 役員 等、金額に応じたルート)
- 稟議理由(背景・目的)
- 想定リスクと対応

## 留意点
{note}

## トーン
主観的な表現を避け、事実と根拠に基づいて簡潔に記載する。決裁者が数十秒で判断できる分量を心がける。
"""

for name, slug, desc, note in ringi_types:
    skills.append({
        "id": f"ringi-{slug}",
        "name": f"稟議書テンプレート({name})",
        "description": f"{desc}の稟議書フォーマット。",
        "content": RINGI_TEMPLATE.format(title=name, desc=desc, note=note),
        "meta": {"tags": ["稟議書", name]},
        "is_active": True,
    })

# ============================================================
# カテゴリ3: メール文体ガイド(10種)
# ============================================================
email_types = [
    ("社外・初回問い合わせ返信", "inquiry", "初めて連絡をいただいた社外の方への返信", "丁寧語を基本とし、自己紹介を簡潔に添える"),
    ("社外・謝罪", "apology", "ミスやトラブルに対する謝罪連絡", "言い訳を先に書かず、まず謝罪と事実確認の意思を明確に伝える"),
    ("社外・見積もり送付", "quotation", "見積書を添付して送付する際の本文", "有効期限・前提条件を本文中に明記する"),
    ("社内・進捗報告", "progress", "上長やチームへの進捗共有", "結論(進んでいる/遅れている)を最初の1文で示す"),
    ("社内・依頼", "request", "他部署・他メンバーへの作業依頼", "期限と背景(なぜ必要か)をセットで伝える"),
    ("督促", "reminder", "回答や支払いが遅れている相手への催促", "強い表現を避けつつ、期限を明確に再提示する"),
    ("お礼", "thanks", "打ち合わせや対応へのお礼連絡", "具体的にどの点に感謝しているかを一言添える"),
    ("アポイント調整", "appointment", "面談・打ち合わせの日程調整", "候補日を複数提示し、先方の負担を減らす"),
    ("クレーム対応", "complaint", "顧客からのクレームへの一次返信", "まず状況把握への感謝と共感を示し、次のアクションを明記する"),
    ("契約後フォロー", "followup", "契約締結後の初回フォローアップ", "今後の流れ・担当窓口を明確に伝える"),
]

EMAIL_TEMPLATE = """# メール文体ガイド({title})

{desc}の際は、以下のトーン・構成を参考にしてください。

## 構成
1. 宛名・挨拶
2. 用件(結論を先に)
3. 詳細説明
4. 依頼事項・次のアクション(あれば)
5. 結びの挨拶・署名

## トーン
{note}。過度にへりくだらず、対等な立場での丁寧語を基本とする。

## 避けるべき表現
- 曖昧な期限表現(「なるべく早めに」等)→ 具体的な日付を明記
- 二重否定・持って回った言い回し
"""

for name, slug, desc, note in email_types:
    skills.append({
        "id": f"email-{slug}",
        "name": f"メール文体ガイド({name})",
        "description": f"{desc}の際に使う文体・構成のガイド。",
        "content": EMAIL_TEMPLATE.format(title=name, desc=desc, note=note),
        "meta": {"tags": ["メール文体", name]},
        "is_active": True,
    })

# ============================================================
# カテゴリ4: 提案書テンプレート(5種)
# ============================================================
proposal_types = [
    ("新規提案", "new", "新規顧客への提案書", "課題の整理を提案の前段に置き、自社ソリューションとの対応関係を明確にする"),
    ("価格・見積提示", "pricing", "見積書を伴う価格提示の提案書", "価格の妥当性を示す根拠(工数・原価等)を簡潔に添える"),
    ("競合比較訴求", "comparison", "他社製品・サービスとの比較を含む提案書", "他社の悪口ではなく、自社の強みが顧客の課題にどう効くかで語る"),
    ("導入事例引用", "case-study", "類似業種の導入事例を引用する提案書", "守秘義務のある事例は社名を伏せ、業種・規模感のみ記載する"),
    ("フォローアップ提案", "followup", "初回提案後の追加提案・再提案", "前回提案からの変更点・追加提案理由を冒頭で明確にする"),
]

PROPOSAL_TEMPLATE = """# 提案書テンプレート({title})

{desc}は、以下の構成で作成してください。

## 記載項目
- 表紙(顧客名・提案日・提出者)
- 課題整理(顧客が抱える課題の要約)
- 提案内容(課題への対応策)
- 価格・スケジュール
- 導入後の効果・期待値

## 留意点
{note}

## トーン
売り込み口調を避け、顧客の課題解決を軸に据えた説明を基本とする。
"""

for name, slug, desc, note in proposal_types:
    skills.append({
        "id": f"proposal-{slug}",
        "name": f"提案書テンプレート({name})",
        "description": f"{desc}のフォーマット。",
        "content": PROPOSAL_TEMPLATE.format(title=name, desc=desc, note=note),
        "meta": {"tags": ["提案書", name]},
        "is_active": True,
    })

print(json.dumps(skills, ensure_ascii=False, indent=2))

TavilyのAPIキー取得手順

  1. https://tavily.comにアクセスし、メールアドレスまたはGoogle/GitHubアカウントでサインアップする(クレジットカード登録は不要)
  2. ダッシュボードにログインすると、APIキー(tvly-で始まる文字列)が発行済みの状態で表示される
  3. Open WebUIの管理者パネル > 設定 > Web検索で、検索エンジンに「Tavily」を選び、取得したAPIキーを貼り付けて保存する
  4. 無料枠は月1,000クレジット。1回のWeb検索リクエストで消費するクレジット数は検索深度(basic/advanced)によって変わる

実測値(各PARTの応答時間)

 大がかりなベンチマークではなく、各PART1回ずつの応答時間。実機(Radeon AI PRO R9700)で計測。

PART質問応答時間備考
営業編(Skill)「出張申請の稟議書を書きたい。来月大阪の展示会に2日間出張します」約12.5秒view_skillでテンプレートを読み込んでから生成
在庫管理編(MCP)「在庫が発注点を下回っている商品を教えて」約7秒初回問い合わせのためテーブル構造を確認する問い合わせ(SHOW TABLES/DESCRIBE)を挟んでから本クエリを実行。会話が続いている状態(スキーマを把握済み)ならもっと短くなる
リサーチ編(Web検索)「2026年、30B未満のローカルLLMの動向についてWebで検索してまとめて」約20秒Tavily APIへの検索リクエスト待ちを含む

 MCPで往復が増える体感について。テーブル定義を自分で確認しに行く(SHOW TABLES/DESCRIBEを挟む)分だけ余計な往復が発生しているにもかかわらず、それでも単純なSkillの回答より速いという逆転現象が起きている。Skill側が12.5秒かかっているのは、view_skillでテンプレートを読み込んだうえで、稟議書という長文の下書きを生成しているため。実際の運用では、モデルに事前にテーブル定義を一言伝えておく、またはSkillとして「salesdbのテーブル構成」を登録しておくといった工夫で、MCPの往復をさらに減らせる。