iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0

昨天我們安裝了 Ollama,也成功在終端機執行 qwen3.5:4b

不過,目前還是由人手動輸入 ollama run 與模型對話。Discord Bot 如果要使用 Qwen,就必須讓 Python 程式可以主動送出問題並取得回答。

今天要使用 httpx.AsyncClient 呼叫 Ollama 的 /api/chat,並把這段邏輯整理到 services/llm_serviceapi.py。除了取得模型回答,也會加入逾時、錯誤處理與推論統計。

這次先把範圍放在一條完整流程:使用 httpx.AsyncClient 呼叫 Ollama API,將 stream 設為 false,再從回應中取出 message.content。程式會放進 services/llm_serviceapi.py,並一起處理逾時、連線錯誤、請求時間與 Token 統計。


Python 如何與 Ollama 溝通?

Ollama 啟動後,會在本機提供 HTTP API。Python 不需要直接操作模型檔案,只需要把問題送到 API:

Python 程式
    ↓ HTTP POST
http://localhost:11434/api/chat
    ↓
Ollama
    ↓
qwen3.5:4b
    ↓ JSON
Python 取得回答與統計

今天使用 /api/chat,因為它以對話訊息的形式接收內容。每一則訊息包含:

  • role:訊息角色,例如 userassistant
  • content:實際的訊息內容

這個格式之後也能擴充成多輪對話。


為什麼使用 httpx.AsyncClient?

Discord Bot 與 Ollama 都需要等待回應。如果這段等待阻塞了整個程式,Bot 就可能來不及處理其他事件。

httpx.AsyncClient 提供非同步 HTTP 請求,可以搭配 asyncawait 使用:

async with httpx.AsyncClient() as client:
    response = await client.post(url, json=payload)

等待 Ollama 回覆時,Python 還能處理其他非同步工作,也比較容易接回 discord.py


安裝 httpx

先啟用 Python 虛擬環境,再安裝 httpx

python -m pip install httpx

接著更新 requirements.txt

python -m pip freeze > requirements.txt

今天會用到以下檔案:

Ai-Company/
├── services/
│   ├── __init__.py
│   └── llm_serviceapi.py
├── tests/
│   └── test_llmserviceapi.py
├── bot.py
├── manual_testapi.py
└── requirements.txt
  • llm_serviceapi.py:使用 HTTPX 呼叫 Ollama API
  • manual_testapi.py:真的連線到本機 Ollama,確認整體流程
  • test_llmserviceapi.py:使用 Fake Response 測試程式邏輯,不會真的啟動模型

專案中的 llm_service.py 是使用 Ollama 官方 Python 套件的版本;今天聚焦在直接呼叫 HTTP API 的 llm_serviceapi.py


建立 llm_serviceapi.py

我用 dataclass 把回答與統計資料整理在一起:

from dataclasses import dataclass


@dataclass(frozen=True)
class LLMUsageAPI:
    request_duration_seconds: float
    total_duration_ns: int | None
    load_duration_ns: int | None
    prompt_tokens: int | None
    completion_tokens: int | None

    @property
    def total_tokens(self) -> int | None:
        if self.prompt_tokens is None or self.completion_tokens is None:
            return None
        return self.prompt_tokens + self.completion_tokens


@dataclass(frozen=True)
class LLMResponseAPI:
    content: str
    usage: LLMUsageAPI

呼叫服務後,回答放在 result.content,時間與 Token 則放在 result.usage

接著建立服務類別:

import os
import time

import httpx


DEFAULT_OLLAMA_HOST = "http://localhost:11434"
DEFAULT_OLLAMA_MODEL = "qwen3.5:4b"


class LLMServiceAPIError(RuntimeError):
    pass


class LLMServiceAPI:
    def __init__(
        self,
        host: str | None = None,
        model: str | None = None,
        timeout: float = 120.0,
        transport: httpx.AsyncBaseTransport | None = None,
    ) -> None:
        self.host = (
            host or os.getenv("OLLAMA_HOST", DEFAULT_OLLAMA_HOST)
        ).rstrip("/")
        self.model = model or os.getenv(
            "OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL
        )
        self.timeout = timeout
        self.transport = transport

程式會優先使用傳入的參數,其次讀取環境變數,最後才使用預設值。

transport 在正式執行時保持為 None。單元測試則能傳入 httpx.MockTransport,用 Fake Response 驗證請求與解析邏輯。


呼叫 /api/chat

chat() 會先準備傳給 Ollama 的 JSON:

async def chat(self, message: str) -> LLMResponseAPI:
    payload = {
        "model": self.model,
        "messages": [
            {
                "role": "user",
                "content": message,
            }
        ],
        "stream": False,
    }

其中:

  • model 指定 qwen3.5:4b
  • messages 放入使用者訊息
  • stream: false 要求 Ollama 等回答完成後,一次回傳完整 JSON

Ollama 的 /api/chat 預設使用串流回覆。今天先將它設為 false,讓回應解析保持單純。


加入逾時與錯誤處理

語言模型產生回答需要時間,因此服務預設等待 120 秒:

started_at = time.perf_counter()

try:
    async with httpx.AsyncClient(
        timeout=self.timeout,
        transport=self.transport,
    ) as client:
        response = await client.post(
            f"{self.host}/api/chat",
            json=payload,
        )
        response.raise_for_status()
except httpx.TimeoutException as error:
    raise LLMServiceAPIError(
        "Ollama 回應逾時,請稍後再試。"
    ) from error
except httpx.HTTPStatusError as error:
    raise LLMServiceAPIError(
        f"Ollama API 回傳錯誤狀態:{error.response.status_code}。"
    ) from error
except httpx.RequestError as error:
    raise LLMServiceAPIError(
        "無法連線到 Ollama,請確認服務是否已啟動。"
    ) from error

request_duration = time.perf_counter() - started_at

這裡處理三類問題:

  • TimeoutException:模型未在設定時間內完成回覆
  • HTTPStatusError:API 回傳 4xx 或 5xx 狀態碼
  • RequestError:Ollama 沒有啟動、網址錯誤或其他連線問題

response.raise_for_status() 會檢查 HTTP 狀態碼,避免程式把錯誤回應當成模型回答。


解析 message.content

stream 設為 false,Ollama 會一次回傳完整 JSON。回答位於:

{
  "message": {
    "role": "assistant",
    "content": "模型產生的回答"
  }
}

程式先將 Response Body 轉成 Python dict,再檢查 message.content

try:
    data = response.json()
except ValueError as error:
    raise LLMServiceAPIError(
        "Ollama 回傳的資料不是有效 JSON。"
    ) from error

message_data = data.get("message")
content = (
    message_data.get("content")
    if isinstance(message_data, dict)
    else None
)

if not isinstance(content, str) or not content.strip():
    raise LLMServiceAPIError(
        "Ollama 回傳內容缺少 message.content。"
    )

除了確認欄位存在,也會排除空字串,避免 Discord Bot 收到無法顯示的內容。


記錄時間與 Token 統計

這次會記錄兩種資料。

Python 實際等待時間

request_duration_seconds 是從 Python 送出請求,到完整收到回應的實際等待時間。

Ollama 回傳的統計

Ollama 的回應還包含:

  • total_duration:整次請求的處理時間
  • load_duration:載入模型所花的時間
  • prompt_eval_count:輸入 Token 數量
  • eval_count:輸出 Token 數量

Ollama 的 duration 欄位使用奈秒,因此程式以 _ns 結尾保留原始單位:

usage = LLMUsageAPI(
    request_duration_seconds=request_duration,
    total_duration_ns=data.get("total_duration"),
    load_duration_ns=data.get("load_duration"),
    prompt_tokens=data.get("prompt_eval_count"),
    completion_tokens=data.get("eval_count"),
)

return LLMResponseAPI(
    content=content.strip(),
    usage=usage,
)

輸入與輸出 Token 都存在時,total_tokens 屬性會將兩者相加。


建立 manual_testapi.py

manual_testapi.py 會真的呼叫本機 Ollama,用來確認 Python、HTTP API 與 qwen3.5:4b 可以一起運作:

import asyncio

from services.llm_serviceapi import (
    LLMServiceAPI,
    LLMServiceAPIError,
)


async def main():
    service = LLMServiceAPI(timeout=300)

    try:
        result = await service.chat(
            "請使用繁體中文,簡單說明什麼是 Discord Bot。"
        )
    except LLMServiceAPIError as error:
        print(f"測試失敗:{error}")
        return

    print(f"回答:{result.content}")
    print(f"Prompt Token:{result.usage.prompt_tokens}")
    print(f"輸出 Token:{result.usage.completion_tokens}")
    print(f"總 Token:{result.usage.total_tokens}")
    print(
        f"請求耗時:"
        f"{result.usage.request_duration_seconds:.3f} 秒"
    )


if __name__ == "__main__":
    asyncio.run(main())

確認 Ollama 已經啟動,再執行:

python manual_testapi.py

執行成功後,終端機會顯示 Qwen 的回答、輸入與輸出 Token,以及 Python 實際等待的時間。

https://ithelp.ithome.com.tw/upload/images/20260919/20183880a5WfrWiB2Z.png

test_llmserviceapi.py 的用途不同。它使用 Fake Response 快速檢查程式邏輯,不會真的呼叫 Ollama;manual_testapi.py 才是確認本機模型整合是否成功。


實際測到什麼

執行 manual_testapi.py 後,Python 已經能把 Prompt 交給本機的 qwen3.5:4b,再取得 message.content、請求時間與 Token 統計。逾時、HTTP 狀態和連線問題也統一轉成 LLMServiceAPIError

這些細節都留在 services/llm_serviceapi.py。Discord Bot 之後只要呼叫 LLMServiceAPI.chat(),不用再把 HTTP 請求和回應解析寫進 bot.py


上一篇
DAY 4|安裝 Ollama,在本機執行 Qwen
下一篇
DAY 6|讓 Discord Bot 取得 Qwen 回覆
系列文
AI 公司模擬器:Discord x Multi-Agent 架構實作9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言