别把代码全塞一个文件:接口分层怎么分

19 阅读5分钟

「从零到 AI 应用工程师」专栏 · 第 3 篇


上一篇你已经能 curl/chat 了。庆祝完,问题马上出现:

所有逻辑都在一个 main.py 里——鉴权、校验、调模型、拼返回值。现在还能看懂;等你加「存历史」「做缓存」「失败重试」,这个文件就会变成没人敢动的怪物。

今天只做一件事:把职责拆开。不加新功能,只让结构变干净。


一、为什么分层,而不是「多建几个文件夹」

分层不是为了好看,是为了三件事:

  1. 改一处,别牵全身:换模型厂商,不该动路由代码;
  2. 能单独测:业务逻辑可以不启服务器就测;
  3. 后来人(包括三个月后的你)能读懂:打开文件,知道该找谁。

常见的最小三层:

职责不该做的事
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)

五、怎么判断你分对了

用三个问题自测:

  1. 换模型厂商:是否只改 clients/llm_client.py
  2. 加一条业务规则(比如敏感词):是否只改 services/
  3. 换一种返回包装:是否只改 routers/ 或统一响应工具?

三个都是「是」,分层就基本对了。

如果答案是「到处都要改」——说明职责又搅在一起了。


六、常见误区

误区 1:为了分层而分层。
一个只有一行 return service.xxx() 的中间层,没有边界价值,删掉。

误区 2:Router 里继续写业务。
「顺手 if 一下」最危险。今天顺手,明天就变成第二套规则,且容易被新接口绕过。

误区 3:Service 返回 HTTP 响应对象。
业务层一旦依赖 FastAPI 类型,单元测试就得造 Request,痛。

误区 4:把 Schema 和数据库 Model 混成一个。
对外字段和表字段会分叉。今天混着写,明天改接口就动表,或改表就动接口。


七、验收

启动后,请求多带上 user_idsession_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": "什么是服务层"
  }'

预期:仍然能正常返回答案;项目结构已变成多文件,但行为与上一篇一致。

行为不变、结构变干净——这就是今天的成功标准。


八、带走这三条

  1. 分层的目标是职责边界和可测试性,不是文件数量。
  2. Router 薄、Service 厚、Client/Repository 专一——换依赖时才有地方下手。
  3. 业务规则只放一处:放 Service,别在 Router 和 Service 各写一份。

下一篇专门解决「接口说话方式不统一」:成功一套 JSON、失败又一套默认格式——前端(以及未来的你)会疯。我们做 统一响应 + 统一异常

这是专栏第 3 篇。结构拆开了,后面加功能才不会炸。两天一更,下篇见。