iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
AI 自動化

AI 公司模擬器:Discord x Multi-Agent 架構實作系列 第 7

DAY 7|整理設定,替 Discord Bot 加上測試

  • 分享至 

  • xImage
  •  

DAY 6 已經把 Discord 的 !ask 接到本機 Qwen。功能可以執行後,我回頭看了一次專案,發現設定、Bot 指令與模型服務之間還黏得太近。

如果直接繼續增加指令,之後每次測試都可能需要登入 Discord 或啟動 Ollama。今天先不加新功能,而是整理程式的責任,讓 Discord Bot 可以在不連線的情況下測試。

這次主要處理三件事:

  • 使用 config.py 集中讀取環境變數
  • 將設定與 LLM Service 傳入 Bot
  • 使用 Fake Service 測試 !hello!ask

調整後的專案結構

目前和這次修改有關的檔案如下:

Ai-Company/
├── services/
│   ├── __init__.py
│   ├── llm_service.py
│   └── llm_serviceapi.py
├── tests/
│   ├── __init__.py
│   ├── test_bot.py
│   ├── test_llm_service.py
│   └── test_llmserviceapi.py
├── .env
├── .env.example
├── .gitignore
├── bot.py
├── config.py
├── manual_test.py
├── manual_testapi.py
└── requirements.txt

和前一版相比,這次多了 config.py.env.exampletest_bot.py。原本散落在不同檔案的設定,也改由 config.py 統一處理。

使用 config.py 集中管理設定

先前 LLMService 會自行讀取 OLLAMA_HOSTOLLAMA_MODEL,Bot 也在自己的檔案內設定逾時和字數限制。程式不大時還看得懂,但設定一多,就不容易確認每個值是從哪裡來的。

現在使用 Settings 保存需要的設定:

@dataclass(frozen=True)
class Settings:
    discord_token: str
    ollama_host: str = "http://localhost:11434"
    ollama_model: str = "qwen3.5:4b"
    ollama_timeout_seconds: float = 300.0
    max_ask_input_length: int = 500
    max_ask_output_length: int = 1900
    log_level: str = "INFO"

frozen=True 代表物件建立後不能直接修改欄位,可以避免程式執行途中不小心改掉設定。

真正讀取 .env 的工作放在 load_settings()

def load_settings() -> Settings:
    load_dotenv()

    discord_token = os.getenv("DISCORD_TOKEN", "").strip()
    if not discord_token:
        raise RuntimeError("找不到 DISCORD_TOKEN,請檢查 .env 設定。")

    return Settings(
        discord_token=discord_token,
        ollama_host=os.getenv(
            "OLLAMA_HOST",
            "http://localhost:11434",
        ).rstrip("/"),
        ollama_model=os.getenv("OLLAMA_MODEL", "qwen3.5:4b"),
        ollama_timeout_seconds=_read_positive_float(
            "OLLAMA_TIMEOUT_SECONDS",
            300.0,
        ),
        max_ask_input_length=_read_positive_int(
            "MAX_ASK_INPUT_LENGTH",
            500,
        ),
        max_ask_output_length=_read_positive_int(
            "MAX_ASK_OUTPUT_LENGTH",
            1900,
        ),
        log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
    )

除了確認 Discord Token 是否存在,數字設定也會先轉換型別並檢查必須大於 0。這樣設定有問題時,Bot 會在啟動階段直接說明原因,不必等到收到指令才發現。

加入 .env.example

.env 包含真正的 Discord Token,不能提交到 Git。不過,如果儲存庫裡完全沒有設定範例,之後重新建立環境時,很容易漏掉必要欄位。

因此專案加入 .env.example

DISCORD_TOKEN=請填入你的_Discord_Bot_Token
OLLAMA_HOST=http://localhost:11434
OLLAMA_MODEL=qwen3.5:4b
OLLAMA_TIMEOUT_SECONDS=300
MAX_ASK_INPUT_LENGTH=500
MAX_ASK_OUTPUT_LENGTH=1900
LOG_LEVEL=INFO

這個檔案只放欄位名稱、範例值與預設設定,不放真實 Token。下載專案後,可以複製一份改名為 .env,再填入自己的資料。

.env 繼續放在 .gitignore;截圖或文章中如果出現 Token,也要先遮蔽。

讓 LLMService 不再自己找設定

整理前,LLMService 會在內部呼叫 os.getenv()。現在環境變數只由 config.py 讀取,Service 改成接受外部傳入的值:

class LLMService:
    def __init__(
        self,
        host: str | None = None,
        model: str | None = None,
        timeout: float = 300.0,
        client: OllamaClientProtocol | None = None,
    ) -> None:
        self.host = (host or DEFAULT_OLLAMA_HOST).rstrip("/")
        self.model = model or DEFAULT_OLLAMA_MODEL
        self.client = client or AsyncClient(
            host=self.host,
            timeout=timeout,
        )

正式啟動時,再由 bot.py 將設定傳入:

settings = load_settings()

llm_service = LLMService(
    host=settings.ollama_host,
    model=settings.ollama_model,
    timeout=settings.ollama_timeout_seconds,
)

執行流程變成:

.env
  ↓
config.py 讀取並驗證
  ↓
bot.py 建立 LLMService
  ↓
LLMService 呼叫 Ollama

設定從哪裡來、由誰使用,現在比較容易追蹤。

把 Bot 建立流程拆成 create_bot()

測試 Discord 指令時,不希望程式一匯入 bot.py 就直接登入 Discord。因此,Bot 的建立與正式啟動被拆開:

def create_bot(
    settings: Settings,
    llm_service: ChatService,
) -> MyBot:
    intents = discord.Intents.default()
    intents.message_content = True

    bot = MyBot(command_prefix="!", intents=intents)

    # 在這裡註冊 !hello、!ask、/help 與錯誤處理

    return bot

正式執行 python bot.py 時,main() 會載入設定、建立真正的 LLMService,最後才執行 bot.run()

測試時則可以傳入假設定與假的模型服務,不會登入 Discord,也不用啟動 Ollama。

使用 Protocol 留下最小介面

Bot 實際上不需要知道 Ollama Client 的細節,只需要一個具有非同步 chat() 方法的物件:

class ChatService(Protocol):
    async def chat(self, message: str) -> LLMResponse:
        ...

正式環境傳入 LLMService,測試環境則傳入 Fake Service。只要兩者都有相同的 chat() 介面,create_bot() 就能使用。

這樣一來,create_bot() 只在意服務能不能回答問題;測試時也不需要建立真正的 Ollama Client。

使用 Fake Service 測試 !ask

test_bot.py 使用 AsyncMock 建立假的 chat()。它不會真的呼叫模型,而是回傳預先準備好的內容:

async def asyncSetUp(self) -> None:
    self.settings = Settings(
        discord_token="fake-discord-token",
    )
    self.fake_service = SimpleNamespace(
        chat=AsyncMock(),
    )
    self.bot = create_bot(
        self.settings,
        self.fake_service,
    )

例如測試正常回答時,先指定 Fake Service 的結果:

self.fake_service.chat.return_value = SimpleNamespace(
    content="Qwen 的回答",
)

await self.bot.get_command("ask").callback(
    ctx,
    question="什麼是 Discord Bot?",
)

接著檢查三件事:Bot 是否先送出處理中訊息、問題是否交給 chat(),以及原訊息是否更新成模型回答。

目前 test_bot.py 一共測試六種情況:

  • !hello 是否標記使用者
  • !ask 是否先顯示處理中,再更新成回答
  • 問題超過 500 字時是否拒絕呼叫模型
  • 回答超過 1900 字時是否加入截斷提示
  • Ollama 未啟動時是否顯示連線錯誤
  • Ollama 回應逾時時是否顯示逾時訊息

執行全部測試

在虛擬環境中執行:

python -m unittest discover -s tests -v

這次的實際結果是:

Ran 13 tests in 0.054s

OK

13 個測試包含:

測試檔案 數量 檢查內容
test_bot.py 6 Discord 指令、長度限制與錯誤訊息
test_llm_service.py 4 Ollama 官方套件版本的回應與錯誤處理
test_llmserviceapi.py 3 HTTP API 版本的請求、解析與錯誤處理

今天整理後的差別

這次沒有增加新的 Discord 指令,但專案的分工比前一版清楚:config.py 負責設定,bot.py 負責 Discord,LLMService 負責 Ollama,測試則使用 Fake Service 取代外部連線。

之後修改 !ask 時,可以先執行單元測試確認處理中訊息、長度限制和錯誤流程。真的需要確認 Discord 與 Ollama 是否能一起運作時,再啟動 Bot 做整合測試。

下一篇會開始把常用操作改成 Discord 的 Slash Command。


上一篇
DAY 6|讓 Discord Bot 取得 Qwen 回覆
下一篇
DAY 8|建立第一個 Slash Command
系列文
AI 公司模擬器:Discord x Multi-Agent 架構實作9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言