はじめに
SaaS版のchatGPTやClaudeのサービスのようにセルフホスト版のDify上で生成AIに作ってもらったグラフ可視化コードを実行したいけど、Difyのコードブロックだとpython標準モジュールしかインポートできない...という課題点があります。
なので、生成AIが作成したコードを実行するための実行環境をDifyの真横に立てて実行結果をDifyで受け取るという構成でfastAPIをベースにコード実行環境を構築したよ。というお話です。
なぜdifyなのか?
やっぱり社内データとか機密データとかをSaaS版アプリにアップロードするのは社内規定的にNGという会社が多いのでは?と思います。
そんな中Difyなら、セルフホストで社内環境に構築でき、かつ、各企業で契約しているLLMに対してデータを投入することができる。つまり、社内規定違反にならずSaaS版chatGPTみたいなことができる!ということもあり、Difyを選定し色々試してみました。
注意事項
- 本記事では、Difyのセルフホストでの構築方法については記載しませんが、公式上でも手順書が出ているのでそちらを参照ください
- Docker及び、Docker composeについても利用できる前提で記載します
- あくまで個人利用想定なのでセキュリティ部分はあまり細かく制御かけていません
Difyの制約
- コードブロックなるものがあるが、pythonの標準モジュールしかインポートできない
- コードブロックを実行するdifyのコンテナにライブラリ等のインストールを行ってみたものの、動作が安定しなかった
Difyのymlファイル
とりあえずDify用のymlファイルの中身
これを拡張子.ymlにして、difyにインポートすればワークフローが立ち上がります。
app:
description: ''
icon: 🤖
icon_background: '#FFEAD5'
mode: advanced-chat
name: test
use_icon_as_answer_icon: false
dependencies:
- current_identifier: null
type: marketplace
value:
marketplace_plugin_unique_identifier: langgenius/gemini:0.2.1@88c1b2c816ef2ea36fc411b35298a621b3260d34bc08bd9357772092728aadde
kind: app
version: 0.3.0
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload:
allowed_file_extensions:
- .JPG
- .JPEG
- .PNG
- .GIF
- .WEBP
- .SVG
allowed_file_types:
- image
allowed_file_upload_methods:
- local_file
- remote_url
enabled: false
fileUploadConfig:
audio_file_size_limit: 50
batch_count_limit: 5
file_size_limit: 15
image_file_size_limit: 10
video_file_size_limit: 100
workflow_file_upload_limit: 10
image:
enabled: false
number_limits: 3
transfer_methods:
- local_file
- remote_url
number_limits: 3
opening_statement: ''
retriever_resource:
enabled: true
sensitive_word_avoidance:
enabled: false
speech_to_text:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
language: ''
voice: ''
graph:
edges:
- data:
sourceType: start
targetType: llm
id: 1747533164339-llm
source: '1747533164339'
sourceHandle: source
target: llm
targetHandle: target
type: custom
- data:
isInIteration: false
isInLoop: false
sourceType: llm
targetType: code
id: llm-source-1747542796003-target
source: llm
sourceHandle: source
target: '1747542796003'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: http-request
targetType: answer
id: 1747546166083-source-answer-target
source: '1747546166083'
sourceHandle: source
target: answer
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: code
targetType: if-else
id: 1747542796003-source-1747549203456-target
source: '1747542796003'
sourceHandle: source
target: '1747549203456'
targetHandle: target
type: custom
zIndex: 0
- data:
isInLoop: false
sourceType: if-else
targetType: http-request
id: 1747549203456-true-1747546166083-target
source: '1747549203456'
sourceHandle: 'true'
target: '1747546166083'
targetHandle: target
type: custom
zIndex: 0
- data:
isInIteration: false
isInLoop: false
sourceType: if-else
targetType: answer
id: 1747549203456-false-1747549268209-target
source: '1747549203456'
sourceHandle: 'false'
target: '1747549268209'
targetHandle: target
type: custom
zIndex: 0
- data:
isInLoop: false
sourceType: llm
targetType: code
id: llm-source-1747551257595-target
source: llm
sourceHandle: source
target: '1747551257595'
targetHandle: target
type: custom
zIndex: 0
- data:
isInLoop: false
sourceType: code
targetType: if-else
id: 1747551257595-source-1747549203456-target
source: '1747551257595'
sourceHandle: source
target: '1747549203456'
targetHandle: target
type: custom
zIndex: 0
nodes:
- data:
desc: ''
selected: false
title: 開始
type: start
variables: []
height: 54
id: '1747533164339'
position:
x: 80
y: 282
positionAbsolute:
x: 80
y: 282
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
context:
enabled: false
variable_selector: []
desc: ''
memory:
query_prompt_template: '{{#sys.query#}}'
role_prefix:
assistant: ''
user: ''
window:
enabled: false
size: 10
model:
completion_params: {}
mode: chat
name: gemini-1.5-flash
provider: langgenius/gemini/google
prompt_template:
- id: 1be6443f-92e6-489c-bacc-0d18c4e22102
role: system
text: "ユーザーから入力されるデータをまとめ考察を行い日本語でレポートを作成して、出力は \"report\"に出力してください。\n\n\
また、ユーザーから入力されるデータを1枚のグラフにするコードもmatplotlibで作成してください。\nPythonコードは、JSON形式の\"\
code\"に出力に含めてください。参考にするコードも参照してコードを作成してください。\n\n【参考にするコード】\nimport matplotlib.pyplot\
\ as plt\ndef create_sample_plot():\n fig, ax = plt.subplots()\n \
\ x = [1, 2, 3, 4, 5]\n y = [2, 4, 1, 8, 7]\n ax.plot(x, y, marker='o')\n\
\ ax.set_title(\\\"Sample\\\")\n ax.set_xlabel(\\\"X\\\")\n ax.set_ylabel(\\\
\"Y\\\")\n ax.grid(True)\n return fig\nfig = create_sample_plot()\n\
\n【出力ルール(重要)】\n・出力は JSON形式 にしてください。\n・JSONの code フィールドには、上記のPythonコードを\
\ 1つの文字列として格納してください。\n・Pythonコード内の改行は、文字列中で \\\\n(バックスラッシュ2つ + n)に変換してください。\n\
・Pythonコードの中で 三重クォート(\"\"\")は使用しないでください。\n・フォーマット内では、(```)や(```JSON)は使用しないでください。\n\
・コメントは不要です。\n・グラフ画像は1枚のみ描画するコードとしてください。\n・グラフ描画を行う関数create_sample_plot()とそれを実行するfig\
\ = create_sample_plot()を含めてください。\n・japanize_matplotlibをインポートするコードも含めてください。\n\
・ax.set_ylabel、ax.set_xlabel、ax.set_titleで設定する軸、グラフのタイトルは英語で記載してください。\n\
\n出力全体は以下のフォーマットに完全準拠してください:\n\n{\n \"report\": \"test\",\n \"code\"\
: \"response = \\\"test\\\"\\\\nprint(response)\"\n}\n\n\nこの形式で出力してください。"
- role: user
text: '{{#sys.query#}}'
selected: false
structured_output_enabled: true
title: LLM
type: llm
variables: []
vision:
enabled: false
height: 90
id: llm
position:
x: 380
y: 282
positionAbsolute:
x: 380
y: 282
selected: true
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
answer: '{{#1747551257595.report#}}
{{#1747546166083.files#}}'
desc: ''
selected: false
title: 回答
type: answer
variables: []
height: 122
id: answer
position:
x: 1580
y: 74.36266919532903
positionAbsolute:
x: 1580
y: 74.36266919532903
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
code: "import json\nimport re\n\ndef main(arg1: str) -> dict:\n # \"code\"\
: \"...\"\"\" の部分を正規表現で取り出す\n # match = re.search(r'\"code\"\\s*:\\s*\"\
\"\"(?P<code>.*?)\"\"\"', arg1, re.DOTALL)\n # match = re.search(r'\"\
code\"\\s*:\\s*\"(?P<code>.*?)\"', arg1, re.DOTALL)\n match = re.search(r'\"\
code\"\\s*:\\s*\"(?P<code>(?:[^\"\\\\]|\\\\.)*?)\"', arg1, re.DOTALL)\n\
\ if not match:\n return {\n \"code\": \"200\",\n \
\ }\n else:\n # そのまま取り出せばOK(unicode_escapeしない!)\n \
\ code_raw = match.group(\"code\")\n\n return {\n \"\
code\": code_raw,\n }\n\n"
code_language: python3
desc: ''
outputs:
code:
children: null
type: string
selected: false
title: コード実行
type: code
variables:
- value_selector:
- llm
- text
variable: arg1
height: 54
id: '1747542796003'
position:
x: 681.2273575077277
y: 282
positionAbsolute:
x: 681.2273575077277
y: 282
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
authorization:
config: null
type: no-auth
body:
data:
- id: key-value-350
key: ''
type: text
value: "{\n \"code\": \"{{#1747542796003.code#}}\"\n}"
type: raw-text
desc: ''
headers: 'accept:application/json
Content-Type:application/json'
method: POST
params: ''
retry_config:
max_retries: 3
retry_enabled: true
retry_interval: 100
selected: false
ssl_verify: true
timeout:
connect: 60
max_connect_timeout: 0
max_read_timeout: 0
max_write_timeout: 0
read: 60
write: 60
title: HTTPリクエスト
type: http-request
url: http://{コンテナのIPアドレス}:8000/execute
variables: []
height: 110
id: '1747546166083'
position:
x: 1276.596109331071
y: 74.36266919532903
positionAbsolute:
x: 1276.596109331071
y: 74.36266919532903
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
cases:
- case_id: 'true'
conditions:
- comparison_operator: is not
id: fe3a97af-494c-4444-bd41-eea0666125d3
value: '200'
varType: string
variable_selector:
- '1747542796003'
- code
id: 'true'
logical_operator: and
desc: ''
selected: false
title: IF/ELSE
type: if-else
height: 126
id: '1747549203456'
position:
x: 980
y: 282
positionAbsolute:
x: 980
y: 282
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
answer: '{{#llm.text#}}
{{#1747551257595.report#}}'
desc: ''
selected: false
title: 回答 2
type: answer
variables: []
height: 122
id: '1747549268209'
position:
x: 1276.596109331071
y: 253.36266919532903
positionAbsolute:
x: 1276.596109331071
y: 253.36266919532903
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
- data:
code: "import re\nimport json\n\ndef main(arg1: str) -> str:\n # arg1 はすでに文字列として定義済み\n\
\ # 1. ```json\\n...\\n``` の中身を取り出す\n match = re.search(r\"```json\\\
n(.*?)\\n```\", arg1, re.DOTALL)\n if match:\n json_str = match.group(1)\n\
\ # 2. JSONとして読み込む(エスケープされた文字列もデコードされる)\n data = json.loads(json_str)\n\
\ report = data.get(\"report\", \"\")\n\n return {\n \
\ \"report\": report,\n }\n\n else:\n return {\n\
\ \"report\": \"正常にレポートの作成を行うことができませんでした。\"\n }\n "
code_language: python3
desc: ''
outputs:
report:
children: null
type: string
selected: false
title: コード実行 2
type: code
variables:
- value_selector:
- llm
- text
variable: arg1
height: 54
id: '1747551257595'
position:
x: 651.2001439993564
y: 516.0618484150948
positionAbsolute:
x: 651.2001439993564
y: 516.0618484150948
selected: false
sourcePosition: right
targetPosition: left
type: custom
width: 244
viewport:
x: 8.420840102169336
y: 244.27199138055713
zoom: 0.4744883430657055
title: HTTPリクエスト
type: http-request
url: http://{コンテナのIPアドレス}:8000/execute
variables: []
ワークフローに関する解説
🧠 アプリ情報
項目 | 内容 |
---|---|
アプリ名 | test |
モード | advanced-chat |
使用モデル | Gemini 1.5 Flash (langgenius/gemini/google ) |
バージョン | 0.3.0 |
🔧 フロー構成概要
Difyのワークフローは以下のノードで構成されています:
開始 → LLM → [コード実行1, コード実行2] → IF/ELSE → [HTTPリクエスト or 回答2] → 回答
🧩 各ノードの詳細
1. 開始(start)
- ワークフローの起点
- 入力変数はなし
2. LLM(gemini-1.5-flash)
- モデル: Google Gemini 1.5 Flash
- 入力: ユーザーの自然言語
- システムプロンプト内容:
-
ユーザー入力をもとに日本語で考察レポート (
report
) を生成 -
matplotlibで可視化用Pythonコード (
code
) をJSONで返却 -
出力形式は以下のようなJSON形式に厳密に準拠:
{ "report": "考察内容", "code": "Pythonコード文字列" }
-
3. コード実行1 (1747542796003
)
- 処理内容:
- LLM出力テキストから
"code"
フィールドのPythonコード文字列を抽出 - 正規表現を使ってJSON内の
"code"
を切り出す
- LLM出力テキストから
4. コード実行2 (1747551257595
)
- 処理内容:
- LLM出力内にある ```json ブロックから
"report"
フィールドを抽出 - JSON文字列を正規表現で抽出して
json.loads()
で読み込む
- LLM出力内にある ```json ブロックから
5. IF/ELSE (1747549203456
)
- 条件判定:
-
コード実行1
の結果 (code
) が"200"
でない場合、HTTPリクエストに進む -
"200"
の場合は LLMとコード実行2の出力を回答に表示
-
6. HTTPリクエスト (1747546166083
)
- URL:
http://100.64.1.35:8000/execute
- メソッド:
POST
- ヘッダー:
Accept: application/json Content-Type: application/json
- ボディ:
{ "code": "{{#1747542796003.code#}}" }
- 認証: なし (
no-auth
)
7. 回答 (answer
/ 回答 2
)
-
回答
:コード実行1
の結果とHTTPリクエスト
のファイルレスポンスを表示 -
回答 2
:LLMの生出力
とコード実行2
の結果を表示
🔒 認証と依存関係
- 認証方式: 全ノードで「認証なし」(
no-auth
) - 依存プラグイン:
-
langgenius/gemini:0.2.1
(Google Geminiプロバイダ)
-
📁 ファイルアップロード設定
- ファイルアップロード機能: 無効化
- 設定値は存在するが、
enabled: false
のため無効
🧪 デバッグ・ロジックポイント
- 正規表現に基づく文字列処理が2箇所あり、LLMの出力形式に依存
- 出力JSONの仕様逸脱時には
"200"
を返しIF文岐により表示パスを変える
📌 備考
- ワークフロー全体の目的は:
- ユーザーの自然文入力をもとにGeminiでレポート + Pythonグラフコードを生成
- コードを抽出して外部実行APIへ渡す
- 結果を表示またはレポートのみ返す
LLM生成コード実行環境
-
gitクローン
git clone https://github.com/tom160313/code_executor.git
- docker composeでコンテナ立ち上げ
docker compose up
-
立ち上がったFastAPIのサーバーを確認
http://localhost:8000/docs
にアクセスし以下の画面が表示されたら成功
-
コードの中身
コード自体はいたってシンプルで、受け取ったコードを実行→画像ファイルを保存→そのファイルをレスポンスとして返却といった感じです。
from fastapi import FastAPI, Request, UploadFile, File
from pydantic import BaseModel
import uuid
import os
import traceback
import matplotlib.pyplot as plt
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from starlette.middleware.cors import CORSMiddleware
import codecs
app = FastAPI()
# CORS対応
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
# static ディレクトリ作成
STATIC_DIR = "static"
os.makedirs(STATIC_DIR, exist_ok=True)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
class CodeInput(BaseModel):
code: str
@app.post("/execute")
async def execute_code(payload: CodeInput):
code = codecs.decode(payload.code, "unicode_escape")
local_vars = {}
try:
exec(code, local_vars, local_vars)
for val in local_vars.values():
if hasattr(val, "savefig"):
filename = f"{uuid.uuid4().hex}.png"
filepath = os.path.join(STATIC_DIR, filename)
val.savefig(filepath)
plt.close("all")
# ファイルをそのままレスポンスとして返す(ダウンロード対応)
return FileResponse(
path=filepath,
filename="graph.png",
media_type="image/png",
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers": "Content-Disposition"
}
)
return {
"status_code": 200,
"body": "コードは実行されましたが、画像は生成されませんでした。",
"files": []
}
except Exception as e:
return {
"status_code": 500,
"body": f"エラーが発生しました: {str(e)}",
"traceback": traceback.format_exc(),
"files": []
}
🐳 Dify用コンテナ・アプリケーション構成概要
このドキュメントは、提供された docker-compose.yml
、Dockerfile
、main.py
、および tree.txt
に基づき、コンテナ構成、ファイル構成、プログラムの動作制御内容をまとめたものです。
1. 🐋 コンテナ構成
docker-compose.yml
-
サービス名:
api
-
ビルド元:
./docker/Dockerfile
-
ボリューム: カレントディレクトリを
/app
にマウント -
ポート公開:
8501:8501
services:
api:
build:
context: .
dockerfile: ./docker/Dockerfile
ports:
- "8501:8501"
volumes:
- .:/app
Dockerfile
- ベースイメージ:
python:3.10
- 作業ディレクトリ:
/app
- 依存パッケージ:
requirements.txt
- 公開ポート:
8501
FROM python:3.10
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
EXPOSE 8501
2. 📁 ファイル構成(tree.txtより)
.
├── docker-compose.yml
├── requirements.txt
├── app/
│ ├── main.py
│ ├── static/
│ └── __pycache__/
├── docker/
│ └── Dockerfile
├── static/
│ ├── .gitkeep
│ └── *.png
3. ⚙️ アプリケーション動作(main.py)
概要
- フレームワーク: FastAPI
- 静的ファイル:
/static
にマウント - 実行コード受け取りAPI:
POST /execute
エンドポイント /execute
入力形式:
{
"code": "(エスケープされたPythonコード文字列)"
}
処理の流れ:
- 受信コードを
unicode_escape
でデコード -
exec()
によりコードを実行 -
savefig()
を持つオブジェクトがあれば PNG 画像を/static
に保存 - 作成画像を
FileResponse
として返却
レスポンス例:
- 成功時(画像生成あり): PNGファイルをレスポンス
- 成功時(画像なし):
{
"status_code": 200,
"body": "コードは実行されましたが、画像は生成されませんでした。",
"files": []
}
- 失敗時:
{
"status_code": 500,
"body": "エラーが発生しました: ...",
"traceback": "..."
}
✅ まとめ
項目 | 内容 |
---|---|
フレームワーク | FastAPI |
機能 | Pythonコードの実行とグラフ画像生成 |
ファイル保存先 |
static/ ディレクトリ |
コンテナ構成 | Python3.10ベース、ポート8501公開、ボリュームマウント型 |
セキュリティ |
exec() 使用のため信頼された入力が前提 |
利用用途想定 | Dify等からのコード生成API実行環境として利用 |