阶段3(上):Python/asyncio/FastAPI 专题的课程、企业场景、面试题和可运行示例
This commit is contained in:
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
|
||||
Reference in new issue
Block a user