阶段3(上):Python/asyncio/FastAPI 专题的课程、企业场景、面试题和可运行示例

This commit is contained in:
Shikui Wang committed 2026-10-23 15:44:13 +08:00
1 parent b8010a74f3
commit 7f52513ffc
24 files changed
+2817 -33

No files matched your search

@@ -0,0 +1,152 @@
# requires: fastapi, httpx
"""流式对话 API:鉴权、参数校验、请求 ID、统一错误格式、SSE 流式输出、依赖注入替换模型客户端。
运行:uvicorn app:app --reload(或 python app.py 打印一次演示)
测试:python -m pytest -q test_app.py
模型客户端是假的(逐字返回固定文本),换成真实 SDK 时只需要实现同样的 stream 方法。
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from collections.abc import AsyncIterator
from typing import Annotated, Literal, Protocol
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
log = logging.getLogger("chat-api")
# ---------- 模型 ----------
class Message(BaseModel):
role: Literal["system", "user", "assistant"]
content: str = Field(min_length=1, max_length=4000)
class ChatRequest(BaseModel):
messages: list[Message] = Field(min_length=1, max_length=50)
temperature: float = Field(default=0.3, ge=0, le=2)
stream: bool = True
class ChatResponse(BaseModel):
request_id: str
content: str
# ---------- 依赖 ----------
class LLMClient(Protocol):
def stream(self, messages: list[Message], temperature: float) -> AsyncIterator[str]: ...
class FakeLLM:
async def stream(self, messages: list[Message], temperature: float) -> AsyncIterator[str]:
answer = f"你问的是:{messages[-1].content}。这是一个演示回答。"
for ch in answer:
await asyncio.sleep(0.005)
yield ch
def get_llm() -> LLMClient:
return FakeLLM()
API_KEYS = {"demo-key": "tenant-a"} # 演示用;真实项目从数据库或密钥服务读取,并做哈希比对
def current_tenant(x_api_key: Annotated[str | None, Header()] = None) -> str:
tenant = API_KEYS.get(x_api_key or "")
if tenant is None:
raise HTTPException(status_code=401, detail="API Key 无效")
return tenant
# ---------- 应用 ----------
app = FastAPI(title="chat-api")
@app.middleware("http")
async def request_id_and_timing(request: Request, call_next):
rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:16]
request.state.request_id = rid
start = time.perf_counter()
response = await call_next(request)
response.headers["X-Request-ID"] = rid
log.info("%s %s %s %.1fms", request.method, request.url.path, response.status_code,
(time.perf_counter() - start) * 1000)
return response
def error_body(request: Request, code: str, message: str, details=None) -> dict:
body = {"code": code, "message": message, "request_id": getattr(request.state, "request_id", "")}
if details is not None:
body["details"] = details
return body
@app.exception_handler(HTTPException)
async def http_error(request: Request, exc: HTTPException):
return JSONResponse(error_body(request, f"HTTP_{exc.status_code}", str(exc.detail)), status_code=exc.status_code)
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError):
details = [{"field": ".".join(str(p) for p in e["loc"][1:]), "message": e["msg"]} for e in exc.errors()]
return JSONResponse(error_body(request, "INVALID_PARAMS", "参数校验失败", details), status_code=422)
@app.exception_handler(Exception)
async def unhandled_error(request: Request, exc: Exception):
log.exception("未处理的异常") # 详细信息只进日志,不返回给调用方
return JSONResponse(error_body(request, "INTERNAL", "服务器内部错误"), status_code=500)
@app.get("/healthz")
async def healthz() -> dict:
return {"status": "ok"}
@app.post("/v1/chat", response_model=None)
async def chat(req: ChatRequest, request: Request,
tenant: Annotated[str, Depends(current_tenant)],
llm: Annotated[LLMClient, Depends(get_llm)]):
rid = request.state.request_id
if not req.stream:
content = "".join([t async for t in llm.stream(req.messages, req.temperature)])
return ChatResponse(request_id=rid, content=content)
async def events() -> AsyncIterator[str]:
try:
async for token in llm.stream(req.messages, req.temperature):
if await request.is_disconnected(): # 客户端断开就停止生成,节省模型费用
log.info("客户端已断开,停止生成 request_id=%s", rid)
return
yield f"data: {json.dumps({'delta': token}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
except Exception:
log.exception("生成失败 request_id=%s", rid)
# 响应头已经发出,不能再改状态码,只能在流里发送错误事件
yield f"event: error\ndata: {json.dumps({'message': '生成失败', 'request_id': rid}, ensure_ascii=False)}\n\n"
return StreamingResponse(events(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@app.get("/boom")
async def boom():
raise RuntimeError("数据库连接串 postgres://secret@...") # 演示:内部细节不会泄露给调用方
if __name__ == "__main__":
from fastapi.testclient import TestClient
with TestClient(app) as c:
r = c.post("/v1/chat", headers={"X-API-Key": "demo-key"},
json={"messages": [{"role": "user", "content": "年假怎么算"}], "stream": False})
print(r.status_code, r.json())
@@ -0,0 +1,90 @@
# requires: fastapi, httpx
import json
from collections.abc import AsyncIterator
from fastapi.testclient import TestClient
from app import Message, app, get_llm
AUTH = {"X-API-Key": "demo-key"}
BODY = {"messages": [{"role": "user", "content": "你好"}]}
class ScriptedLLM:
"""测试替身:按脚本返回 token,可以模拟中途失败。"""
def __init__(self, tokens: list[str], fail_at: int | None = None) -> None:
self.tokens, self.fail_at = tokens, fail_at
self.calls: list[list[Message]] = []
async def stream(self, messages: list[Message], temperature: float) -> AsyncIterator[str]:
self.calls.append(messages)
for i, t in enumerate(self.tokens):
if i == self.fail_at:
raise RuntimeError("上游模型 500")
yield t
def client_with(llm) -> TestClient:
app.dependency_overrides[get_llm] = lambda: llm
return TestClient(app, raise_server_exceptions=False)
def parse_sse(text: str) -> list[tuple[str, str]]:
events = []
for block in text.strip().split("\n\n"):
name, data = "message", ""
for line in block.splitlines():
if line.startswith("event: "):
name = line[7:]
elif line.startswith("data: "):
data = line[6:]
events.append((name, data))
return events
def teardown_function():
app.dependency_overrides.clear()
def test_auth_required():
r = client_with(ScriptedLLM(["x"])).post("/v1/chat", json=BODY)
assert r.status_code == 401 and r.json()["code"] == "HTTP_401" and r.json()["request_id"]
def test_validation_error_format():
r = client_with(ScriptedLLM(["x"])).post("/v1/chat", headers=AUTH,
json={"messages": [{"role": "robot", "content": ""}], "temperature": 5})
assert r.status_code == 422
fields = {d["field"] for d in r.json()["details"]}
assert {"messages.0.role", "messages.0.content", "temperature"} <= fields
def test_non_stream():
llm = ScriptedLLM(["你", "好"])
r = client_with(llm).post("/v1/chat", headers={**AUTH, "X-Request-ID": "rid-1"}, json={**BODY, "stream": False})
assert r.status_code == 200
assert r.json() == {"request_id": "rid-1", "content": "你好"}
assert r.headers["X-Request-ID"] == "rid-1"
assert llm.calls[0][0].content == "你好"
def test_stream_sse():
r = client_with(ScriptedLLM(["A", "B", "C"])).post("/v1/chat", headers=AUTH, json=BODY)
assert r.status_code == 200 and r.headers["content-type"].startswith("text/event-stream")
events = parse_sse(r.text)
assert [json.loads(d)["delta"] for n, d in events[:-1]] == ["A", "B", "C"]
assert events[-1] == ("message", "[DONE]")
def test_stream_error_event():
r = client_with(ScriptedLLM(["A", "B"], fail_at=1)).post("/v1/chat", headers=AUTH, json=BODY)
events = parse_sse(r.text)
assert events[0][0] == "message" and events[-1][0] == "error"
assert json.loads(events[-1][1])["message"] == "生成失败"
def test_internal_error_hides_details():
r = client_with(ScriptedLLM([])).get("/boom")
assert r.status_code == 500
assert r.json()["message"] == "服务器内部错误" and "postgres" not in r.text