Add deployment tuning and chat load test

This commit is contained in:
2026-07-08 17:45:18 +08:00
parent 6464c61396
commit efd03ff9df
8 changed files with 209 additions and 2 deletions

View File

@@ -5,6 +5,10 @@ API_PREFIX=/api
DATABASE_URL=mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4 DATABASE_URL=mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
REDIS_URL= REDIS_URL=
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
DB_POOL_TIMEOUT=30
DB_POOL_RECYCLE=1800
AUTO_CREATE_TABLES=false AUTO_CREATE_TABLES=false
JWT_SECRET_KEY=change-me-in-production JWT_SECRET_KEY=change-me-in-production

View File

@@ -12,4 +12,4 @@ COPY . .
EXPOSE 8100 EXPOSE 8100
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8100"] CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8100 --workers ${BACKEND_WORKERS:-1}"]

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@@ -31,6 +32,7 @@ from app.services.chat_queue_service import load_chat_queue_config
from app.services.chat_stream_service import ChatStreamService from app.services.chat_stream_service import ChatStreamService
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/session") @router.post("/session")
@@ -155,6 +157,7 @@ async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception: except Exception:
logger.exception("Chat stream failed")
yield _sse_event("error", message="AI 回复生成失败,请稍后再试。") yield _sse_event("error", message="AI 回复生成失败,请稍后再试。")
finally: finally:
if acquired: if acquired:

View File

@@ -26,6 +26,10 @@ class Settings(BaseSettings):
database_url: str = "mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4" database_url: str = "mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4"
redis_url: str = "" redis_url: str = ""
db_pool_size: int = 10
db_max_overflow: int = 20
db_pool_timeout: int = 30
db_pool_recycle: int = 1800
auto_create_tables: bool = False auto_create_tables: bool = False
jwt_secret_key: str = "change-me-in-production" jwt_secret_key: str = "change-me-in-production"

View File

@@ -10,7 +10,15 @@ from app.models import Base
settings = get_settings() settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True) engine = create_engine(
settings.database_url,
pool_pre_ping=True,
pool_size=settings.db_pool_size,
max_overflow=settings.db_max_overflow,
pool_timeout=settings.db_pool_timeout,
pool_recycle=settings.db_pool_recycle,
future=True,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)

View File

@@ -474,3 +474,41 @@
1. dev 环境默认仍使用 1 个 worker避免本地调试时多 worker 日志和热更新混乱。 1. dev 环境默认仍使用 1 个 worker避免本地调试时多 worker 日志和热更新混乱。
2. 生产可以通过环境变量调高 worker不需要改代码。 2. 生产可以通过环境变量调高 worker不需要改代码。
3. 压测脚本只作为研发/验收工具,不进入应用启动链路。 3. 压测脚本只作为研发/验收工具,不进入应用启动链路。
## 阶段 5 实施记录
2026-07-08 完成 worker、连接池和压测工具第一版。
已完成:
1. 后端启动支持 `BACKEND_WORKERS` 环境变量。
2. Dockerfile 启动命令支持 `uvicorn --workers ${BACKEND_WORKERS:-1}`
3. SQLAlchemy 连接池支持环境变量配置:
- `DB_POOL_SIZE`
- `DB_MAX_OVERFLOW`
- `DB_POOL_TIMEOUT`
- `DB_POOL_RECYCLE`
4. `.env.example` 和 dev compose 已补齐对应配置。
5. 新增 `scripts/chat_load_test.py`,支持并发请求 `/chat/completions` 并输出:
- 单请求结果。
- `sessionId`
- 首事件耗时。
- 总耗时。
- 成功数和失败数汇总。
6. `/chat/completions` 增加后端异常日志,用户端仍保持通用错误提示,便于压测时查堆栈。
验证记录:
1. 本机 `compileall` 检查通过。
2. 压测脚本 `--help` 检查通过。
3. `git diff --check` 通过。
4. 后端镜像重建通过。
5. 后端健康检查通过。
6. 单请求压测通过1 个请求成功,首事件约 20ms总耗时约 11.66s。
7. 2 并发、2 请求压测暴露当前模型链路风险:两次复测均出现 1 成功、1 失败,失败请求在约 60s 返回“AI 回复生成失败”。该现象更像当前模型配置或上游模型并发/超时限制,需要后续结合后端异常日志和模型管理配置继续调参。
阶段限制:
1. 当前没有做大并发压测;阶段 5 只补齐调参能力和轻量验证脚本。
2. 生产 worker 数不能只看 CPU需要结合模型接口限流、飞书频控、DB 连接数和 Redis 队列等待时间一起定。
3. 当前建议先把 `chat_max_active_requests` 调低到当前模型真实可承受范围,再逐步放大压测。

View File

@@ -44,6 +44,11 @@ services:
DEBUG: "true" DEBUG: "true"
DATABASE_URL: mysql+pymysql://ai_kb:ai_kb@mysql:3306/ai_knowledge_base_v2?charset=utf8mb4 DATABASE_URL: mysql+pymysql://ai_kb:ai_kb@mysql:3306/ai_knowledge_base_v2?charset=utf8mb4
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
BACKEND_WORKERS: "1"
DB_POOL_SIZE: "10"
DB_MAX_OVERFLOW: "20"
DB_POOL_TIMEOUT: "30"
DB_POOL_RECYCLE: "1800"
JWT_SECRET_KEY: local-dev-secret-change-before-production JWT_SECRET_KEY: local-dev-secret-change-before-production
MOCK_SMS_ENABLED: "true" MOCK_SMS_ENABLED: "true"
MOCK_SMS_CODE: "123456" MOCK_SMS_CODE: "123456"

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
import argparse
import json
import statistics
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
def main() -> None:
parser = argparse.ArgumentParser(description="Run a lightweight chat SSE load test.")
parser.add_argument("--base-url", default="http://127.0.0.1:8100/api")
parser.add_argument("--phone", required=True)
parser.add_argument("--code", default="123456")
parser.add_argument("--message", default="请用一句话回复:压测验证")
parser.add_argument("--concurrency", type=int, default=5)
parser.add_argument("--requests", type=int, default=20)
parser.add_argument("--timeout", type=int, default=90)
args = parser.parse_args()
token = login(args.base_url, args.phone, args.code, args.timeout)
lock = threading.Lock()
results: list[dict] = []
started_at = time.perf_counter()
with ThreadPoolExecutor(max_workers=args.concurrency) as executor:
futures = [
executor.submit(run_one, args.base_url, token, args.message, args.timeout)
for _ in range(args.requests)
]
for future in as_completed(futures):
result = future.result()
with lock:
results.append(result)
print(json.dumps(result, ensure_ascii=False))
elapsed = time.perf_counter() - started_at
print_summary(results, elapsed)
def login(base_url: str, phone: str, code: str, timeout: int) -> str:
body = post_json(f"{base_url}/auth/login", {"phone": phone, "code": code}, timeout=timeout)
return body["data"]["token"]
def run_one(base_url: str, token: str, message: str, timeout: int) -> dict:
started_at = time.perf_counter()
try:
session_body = post_json(
f"{base_url}/chat/session",
{"title": "压测会话"},
token=token,
timeout=timeout,
)
session_id = session_body["data"]["sessionId"]
first_event_ms = None
content_events = 0
error_message = ""
request = urllib.request.Request(
f"{base_url}/chat/completions",
data=json.dumps({"sessionId": session_id, "message": message}).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
for raw_line in response:
line = raw_line.decode("utf-8").strip()
if not line.startswith("data:"):
continue
if first_event_ms is None:
first_event_ms = elapsed_ms(started_at)
if line == "data: [DONE]":
break
payload = json.loads(line[5:].strip())
if payload.get("type") == "content":
content_events += 1
if payload.get("type") == "error":
error_message = payload.get("message", "unknown error")
return {
"ok": not error_message and content_events > 0,
"sessionId": session_id,
"firstEventMs": first_event_ms,
"totalMs": elapsed_ms(started_at),
"contentEvents": content_events,
"error": error_message,
}
except (urllib.error.URLError, TimeoutError, KeyError, ValueError) as exc:
return {
"ok": False,
"sessionId": None,
"firstEventMs": None,
"totalMs": elapsed_ms(started_at),
"contentEvents": 0,
"error": str(exc),
}
def post_json(url: str, payload: dict, *, token: str | None = None, timeout: int) -> dict:
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers)
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def print_summary(results: list[dict], elapsed: float) -> None:
ok_results = [result for result in results if result["ok"]]
failed_results = [result for result in results if not result["ok"]]
first_event_values = [result["firstEventMs"] for result in ok_results if result["firstEventMs"] is not None]
total_values = [result["totalMs"] for result in ok_results]
summary = {
"totalRequests": len(results),
"success": len(ok_results),
"failed": len(failed_results),
"elapsedSeconds": round(elapsed, 2),
"firstEventMsAvg": round(statistics.mean(first_event_values), 2) if first_event_values else None,
"firstEventMsP95": percentile(first_event_values, 95),
"totalMsAvg": round(statistics.mean(total_values), 2) if total_values else None,
"totalMsP95": percentile(total_values, 95),
}
print("SUMMARY " + json.dumps(summary, ensure_ascii=False))
def percentile(values: list[int], percentile_value: int) -> int | None:
if not values:
return None
sorted_values = sorted(values)
index = min(len(sorted_values) - 1, round((percentile_value / 100) * (len(sorted_values) - 1)))
return sorted_values[index]
def elapsed_ms(started_at: float) -> int:
return int((time.perf_counter() - started_at) * 1000)
if __name__ == "__main__":
main()