DAY 6 已經把 Discord 的 !ask 接到本機 Qwen。功能可以執行後,我回頭看了一次專案,發現設定、Bot 指令與模型服務之間還黏得太近。
如果直接繼續增加指令,之後每次測試都可能需要登入 Discord 或啟動 Ollama。今天先不加新功能,而是整理程式的責任,讓 Discord Bot 可以在不連線的情況下測試。
這次主要處理三件事:
config.py 集中讀取環境變數!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.example 與 test_bot.py。原本散落在不同檔案的設定,也改由 config.py 統一處理。
先前 LLMService 會自行讀取 OLLAMA_HOST 與 OLLAMA_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 包含真正的 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 會在內部呼叫 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
設定從哪裡來、由誰使用,現在比較容易追蹤。
測試 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。
Bot 實際上不需要知道 Ollama Client 的細節,只需要一個具有非同步 chat() 方法的物件:
class ChatService(Protocol):
async def chat(self, message: str) -> LLMResponse:
...
正式環境傳入 LLMService,測試環境則傳入 Fake Service。只要兩者都有相同的 chat() 介面,create_bot() 就能使用。
這樣一來,create_bot() 只在意服務能不能回答問題;測試時也不需要建立真正的 Ollama Client。
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 字時是否加入截斷提示在虛擬環境中執行:
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。