「从零到 AI 应用工程师」专栏 · 第 3 篇
上一篇你已经能 curl 通 /chat 了。庆祝完,问题马上出现:
所有逻辑都在一个 main.py 里——鉴权、校验、调模型、拼返回值。现在还能看懂;等你加「存历史」「做缓存」「失败重试」,这个文件就会变成没人敢动的怪物。
今天只做一件事:把职责拆开。不加新功能,只让结构变干净。
一、为什么分层,而不是「多建几个文件夹」
分层不是为了好看,是为了三件事:
- 改一处,别牵全身:换模型厂商,不该动路由代码;
- 能单独测:业务逻辑可以不启服务器就测;
- 后来人(包括三个月后的你)能读懂:打开文件,知道该找谁。
常见的最小三层:
| 层 | 职责 | 不该做的事 |
|---|---|---|
| Router(路由) | 路径、入参、鉴权依赖、调用 Service、返回 | 写 SQL、调模型、拼缓存 Key |
| Service(业务) | 流程编排:校验 → 缓存?→ 调模型 → 落库 | 直接操作 HTTP 细节 |
| Repository(数据) | 数据库读写 | 决定「要不要调模型」 |
再加上 Schema:请求/响应的数据结构,跟数据库表结构分开。
一句话:Router 面向 HTTP,Service 面向业务,Repository 面向持久化。
二、一次 /chat 请求怎么走
POST /chat
→ routers/chat.py # 收请求、验 Token
→ services/chat_service.py # 编排流程
├─ 业务规则校验
├─(后面会加)查缓存
├─ 调 LLM 客户端
└─(后面会加)写历史
→ 返回统一结构
今天我们先把「路由薄、业务厚」这个形状搭出来。缓存和数据库后面两篇再接。
三、推荐目录(够用就行,别过度设计)
chat-api/
├── main.py # 创建 app、挂载路由、注册中间件
├── schemas/
│ └── chat.py # ChatRequest / ChatResponse
├── routers/
│ └── chat.py # POST /chat、GET /ping
├── services/
│ └── chat_service.py # reply() 业务流程
└── clients/
└── llm_client.py # 调大模型的唯一出口
原则:能一眼看懂谁负责什么;不要为了分层而再套三层 Manager/Helper/Adapter。
四、拆开之后的代码长什么样
1. Schema:只描述「长什么样」
# schemas/chat.py
from pydantic import BaseModel, Field, field_validator
class ChatRequest(BaseModel):
user_id: str = Field(min_length=1, max_length=64)
session_id: str = Field(min_length=1, max_length=64)
message: str = Field(min_length=1, max_length=500)
@field_validator("message")
@classmethod
def normalize(cls, value: str) -> str:
value = " ".join(value.split())
if not value:
raise ValueError("message 不能为空")
return value
class ChatData(BaseModel):
answer: str
from_cache: bool = False
2. LLM 客户端:模型细节关在这里
# clients/llm_client.py
import os
import httpx
MODEL_API_KEY = os.getenv("MODEL_API_KEY", "")
MODEL_API_URL = os.getenv("MODEL_API_URL", "")
MODEL_NAME = os.getenv("MODEL_NAME", "your-model-name")
async def generate(prompt: str) -> str:
if not MODEL_API_KEY or not MODEL_API_URL:
raise RuntimeError("模型服务未配置")
payload = {
"model": MODEL_NAME,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.2,
}
headers = {"Authorization": f"Bearer {MODEL_API_KEY}"}
async with httpx.AsyncClient(timeout=20.0) as client:
resp = await client.post(MODEL_API_URL, headers=headers, json=payload)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
以后换厂商,优先改这个文件,而不是满项目搜 httpx.post。
3. Service:业务流程的唯一入口
# services/chat_service.py
from clients import llm_client
def validate_business_rules(message: str) -> None:
# 示例:禁止过短灌水(按你的场景改)
if len(message) < 2:
raise ValueError("问题过短")
async def reply(user_id: str, session_id: str, message: str) -> dict:
validate_business_rules(message)
answer = await llm_client.generate(message)
# user_id / session_id 今天先收下,下一篇存历史时用
return {
"answer": answer,
"from_cache": False,
"user_id": user_id,
"session_id": session_id,
}
注意:Service 返回 dict / 业务对象,不要在这里构造 JSONResponse。HTTP 长什么样,交给 Router。
4. Router:又薄又短
# routers/chat.py
from fastapi import APIRouter, Depends, Header, HTTPException
from schemas.chat import ChatRequest
from services import chat_service
import os
router = APIRouter()
API_TOKEN = os.getenv("API_TOKEN", "dev-token")
def verify_token(authorization: str | None = Header(default=None)) -> None:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供有效令牌")
if authorization.removeprefix("Bearer ").strip() != API_TOKEN:
raise HTTPException(status_code=401, detail="令牌无效")
@router.get("/ping")
async def ping():
return {"status": "ok"}
@router.post("/chat", dependencies=[Depends(verify_token)])
async def chat(req: ChatRequest):
result = await chat_service.reply(
user_id=req.user_id,
session_id=req.session_id,
message=req.message,
)
return {"code": 200, "message": "ok", "data": result}
5. main.py:只负责组装
# main.py
from fastapi import FastAPI
from routers import chat
app = FastAPI(title="chat-api")
app.include_router(chat.router)
五、怎么判断你分对了
用三个问题自测:
- 换模型厂商:是否只改
clients/llm_client.py? - 加一条业务规则(比如敏感词):是否只改
services/? - 换一种返回包装:是否只改
routers/或统一响应工具?
三个都是「是」,分层就基本对了。
如果答案是「到处都要改」——说明职责又搅在一起了。
六、常见误区
误区 1:为了分层而分层。
一个只有一行 return service.xxx() 的中间层,没有边界价值,删掉。
误区 2:Router 里继续写业务。
「顺手 if 一下」最危险。今天顺手,明天就变成第二套规则,且容易被新接口绕过。
误区 3:Service 返回 HTTP 响应对象。
业务层一旦依赖 FastAPI 类型,单元测试就得造 Request,痛。
误区 4:把 Schema 和数据库 Model 混成一个。
对外字段和表字段会分叉。今天混着写,明天改接口就动表,或改表就动接口。
七、验收
启动后,请求多带上 user_id、session_id(为后面存历史铺路):
curl -X POST http://127.0.0.1:8000/chat \
-H "Authorization: Bearer dev-token" \
-H "Content-Type: application/json" \
-d '{
"user_id": "layer_demo",
"session_id": "session_001",
"message": "什么是服务层"
}'
预期:仍然能正常返回答案;项目结构已变成多文件,但行为与上一篇一致。
行为不变、结构变干净——这就是今天的成功标准。
八、带走这三条
- 分层的目标是职责边界和可测试性,不是文件数量。
- Router 薄、Service 厚、Client/Repository 专一——换依赖时才有地方下手。
- 业务规则只放一处:放 Service,别在 Router 和 Service 各写一份。
下一篇专门解决「接口说话方式不统一」:成功一套 JSON、失败又一套默认格式——前端(以及未来的你)会疯。我们做 统一响应 + 统一异常。
这是专栏第 3 篇。结构拆开了,后面加功能才不会炸。两天一更,下篇见。