iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Build on Google AI

給藥袋裝一張嘴:30 天用 Android 與 Google VLM 實作高齡語音用藥助手系列 第 19 篇

Day 19|拒絕重複花費與爆額度!實作 Redis 雜湊快取與 API 限流防護

  • 分享至 

  • xImage
  •  

✏️【本日實作紀錄:Redis 圖片雜湊快取與 Rate Limiting 限流保護】

在 Day 18 完成 X-Request-ID 全域追蹤與結構化 JSON 日誌等可觀測性建設後,PrescriptionVLM 後端服務已能掌握每一次 API 請求的細部耗時與運作狀態。

然而,在實際部署與長輩使用情境中,我們發現了兩個容易導致成本增加的問題:

  1. 重複上傳相同的藥單照片:長輩或照護者可能因為重複點擊或確認狀況,短時間內連續上傳同一張藥袋照片。如果每一次都直接呼叫 Gemini VLM 模型,會浪費 API Token 額度,還讓使用者重複等待數秒的解析時間。
  2. API 頻率限制 (Rate Limit / 429 Error):當流量瞬間增加時,容易觸發雲端 API 429 錯誤。

今天我們將為 PrescriptionVLM 系統導入 Redis 記憶體快取與限流機制。完成項目包含:

  • 圖片 SHA-256 雜湊快取 — 計算上傳圖片的雜湊值,若相同圖片已解析過,直接從 Redis 秒級回傳 JSON 成果,不重複呼叫 Gemini API。
  • Flask-Limiter 介面限流 — 針對 API 端點進行每分鐘請求次數上限保護,防止 API 被異常暴擊。
  • Docker Compose 多容器整合 — 將 Flask 應用程式與 Redis 服務進行容器化編排。

一、系統快取與限流架構的設計

在結合視覺大語言模型(VLM)的系統架構中,導入快取與限流有以下好處:

  • 節省 API 成本與回應時間:Gemini VLM 模型解析一張圖片通常需耗時 2~4 秒;若透過 Redis 讀取快取,回應時間縮短至 10ms 以內。
  • 精準辨識相同圖片:使用圖片檔案的 SHA-256 數位指紋作為 Cache Key,即使檔名不同,只要圖片內容一致就能命中快取。
  • 系統自我防護:透過 Rate Limiting 限制單一 IP 的請求頻率,防止系統遭爆破或觸發 upstream API 的上限。

二、升級專案依賴(requirements.txt)

請開啟專案根目錄的 requirements.txt,加入 Redis 與 Flask-Limiter 相關套件:

redis==5.0.3
Flask-Limiter==3.6.0

三、實作圖片雜湊計算與快取邏輯(app.py)

開啟 app.py,導入 Redis 與 Flask-Limiter,並寫入圖片快取機制。

1. 匯入必要模組與初始化 Redis / Limiter

在 app.py 頂部加入:

import hashlib
import redis
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

# 初始化 Redis 連線(由環境變數讀取 Redis Host)
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0, decode_responses=True)

# 初始化 Flask-Limiter 限流器
limiter = Limiter(
    key_func=get_remote_address,
    app=app,
    default_limits=["200 per day", "50 per hour"],
    storage_uri=f"redis://{REDIS_HOST}:{REDIS_PORT}"
)

def compute_image_hash(image_bytes: bytes) -> str:
    """計算圖片二進位內容之 SHA-256 雜湊值"""
    return hashlib.sha256(image_bytes).hexdigest()

2. 在 /analyze-prescription API 導入快取與限流裝飾器

修改 /analyze-prescription 路由,加上每分鐘限制 10 次的限流規則,並在呼叫 Gemini 前先查詢 Redis 快取:

@app.route("/analyze-prescription", methods=["POST"])
@limiter.limit("10 per minute")  # 限制單一 IP 每分鐘最多請求 10 次
def analyze_prescription():
    req_id = getattr(g, "request_id", "N/A")
    logger.info(f"收到藥單解析請求 [ReqID: {req_id}]")
    
    if "image" not in request.files:
        return jsonify({"error": "未提供圖片檔案"}), 400

    image_file = request.files["image"]
    image_bytes = image_file.read()
    image_file.seek(0) # 重設檔案指標

    # ── 階段 1: 計算圖片 SHA-256 雜湊值 ──
    img_hash = compute_image_hash(image_bytes)
    cache_key = f"prescription_cache:{img_hash}"
    
    # ── 階段 2: 檢查 Redis 快取命中 (Cache Hit) ──
    cached_data = redis_client.get(cache_key)
    if cached_data:
        logger.info(f"⚡ 命中 Redis 圖片快取![Hash: {img_hash[:10]}...]")
        result_data = json.loads(cached_data)
        
        # 快取命中時依然推播 LINE 通知(確保使用者獲得即時反饋)
        send_line_notification(
            summary=result_data.get("spoken_summary", ""),
            medicines_count=len(result_data.get("medicines", [])),
            safety_warnings=result_data.get("safety_warnings", []),
            audio_url=""
        )
        
        return jsonify({
            "status": "success",
            "cached": True,
            "request_id": req_id,
            "data": result_data
        }), 200

    # ── 階段 3: 未命中快取 (Cache Miss),呼叫 Gemini VLM 模型 ──
    logger.info(f"快取未命中,開始呼叫 Gemini VLM 模型...")
    vlm_start = time.time()
    
    # ... (執行原本 Gemini 解析與 Pydantic 驗證邏輯) ...

    vlm_latency = round((time.time() - vlm_start) * 1000, 2)

    # ── 階段 4: 將解析成果寫入 Redis 快取 (設定生存時間 TTL 為 24 小時) ──
    redis_client.setex(cache_key, 86400, json.dumps(result_data, ensure_ascii=False))
    logger.info(f"已將解析成果寫入 Redis 快取,TTL: 24 小時")

    # 推播 LINE 訊息
    send_line_notification(
        summary=result_data.get("spoken_summary", ""),
        medicines_count=len(result_data.get("medicines", [])),
        safety_warnings=result_data.get("safety_warnings", []),
        audio_url=""
    )

    return jsonify({
        "status": "success",
        "cached": False,
        "request_id": req_id,
        "metrics": {"vlm_latency_ms": vlm_latency},
        "data": result_data
    }), 200

四、多容器服務編排(docker-compose.yml)

為了確保 Flask API 與 Redis 能一鍵編排啟動,請在專案根目錄建立 docker-compose.yml:

version: '3.8'

services:
  redis:
    image: redis:7.2-alpine
    container_name: prescription_redis
    ports:
      - "6379:6379"
    restart: always

  web:
    build: .
    container_name: prescription_service
    ports:
      - "5000:5000"
    env_file:
      - .env
    environment:
      - REDIS_HOST=redis
      - REDIS_PORT=6379
    depends_on:
      - redis
    restart: always

五、測試與成果驗證

1. 透過 Docker Compose 一鍵啟動所有服務

在 Terminal 執行:

docker-compose up --build -d

2. 第一次上傳測試 (Cache Miss - 快取未命中)

在 Terminal 執行:

curl -i -X POST http://127.0.0.1:5000/analyze-prescription \
  -F "image=@test_rx.jpg"
  • 預期成果:JSON 回傳 "cached": false,耗時約 2~3 秒,Terminal 印出 快取未命中,開始呼叫 Gemini VLM。

3. 第二次上傳相同圖片 (Cache Hit - 快取命中)

再次執行完全相同的命令:

curl -i -X POST http://127.0.0.1:5000/analyze-prescription \
  -F "image=@test_rx.jpg"
  • 預期成果:JSON 回傳 "cached": true,回應時間在 10ms 內完成。Terminal 印出 ⚡ 命中 Redis 圖片快取!。

六、版本控制與提交 GitHub

測試通過後,將 Day 19 的修改提交至 GitHub:

git add .
git commit -m "保留雙引號 改填寫自己要記錄的標記 ex.鐵人賽第十九天"
git push

七、本日小結與明日預告

今天我們透過 Redis SHA-256 雜湊快取 與 Flask-Limiter 限流機制,將重複圖片的解析時間從 3 秒縮短至 10ms,同時大幅減少 Gemini API 的呼叫次數,節省成本並防止過度的流量。

明天(Day 20),我們將進入系統的架構維護重點,實作 SQLite / PostgreSQL 資料庫自動備份與備援機制,確保長輩的用藥歷史紀錄永久安全留存!


上一篇
Day 18|DeBug 不再大海撈針!導入 Request-ID 追蹤、結構化 JSON 日誌與效能監控
下一篇
Day 20|資料安全不漏接!實作 SQLite 資料庫自動定期備份與災難復原機制
系列文
給藥袋裝一張嘴:30 天用 Android 與 Google VLM 實作高齡語音用藥助手 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言