📑 本页目录(点开跳转)
03 · 后端骨架
⏱ 40 分钟 | ⭐ 这一章的每个零件,都是后面几章往上挂东西的挂载点
🎯 一句话
后端不是「给模型调用套一层 HTTP」,而是立四根柱子:边界在哪、等待怎么处理、每个请求都要做的事挂在哪、出错时对外长什么样。
柱子立歪了,第 8 章的认证、第 12 章的限流、第 15 章的可观测性就没地方生根,只能一路 if 补下去。
🧩 一、为什么中间必须有个后端
「浏览器直接调模型 API」在 Demo 里能跑。上不了线的理由有四条,每条都足够致命:
| 没有后端 | 后果 |
|---|---|
| 密钥在前端 | 打开开发者工具就能看见,别人拿去烧你的账单,你连是谁烧的都不知道 |
| 没有配额边界 | 一个用户循环发一万次,你按 token 付钱 |
| 没有审计 | 出事后查不到「谁在什么时候问了什么、模型回了什么」 |
| 业务逻辑摊在前端 | 提示词、检索策略白盒暴露;改一次要等所有客户端更新 |
⭐ 模型 API 是「谁拿到密钥谁就是老板」的接口,产品需要的是「谁是谁、能做什么、做过什么」。这两者之间的翻译层就是你的后端。
🧱 二、最小骨架:两个模型和一个路由
形状是一个入口 + 一份「进来的长什么样」+ 一份「出去的长什么样」,任何框架都有这三样,只是叫法不同。
# 最小骨架:一对请求/响应模型 + 一个路由
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class ChatIn(BaseModel): # ⭐ 边界合同:不合规的请求进不到业务代码
message: str = Field(min_length=1, max_length=4000) # 卡住【输入】的花费
max_tokens: int = Field(default=512, ge=1, le=4096) # ⭐ 卡住【输出】的花费,更贵的是这头
temperature: float = Field(default=0.0, ge=0.0, le=2.0)
class ChatOut(BaseModel): # ⭐ 同时是裁剪器:没写进来的字段不会被吐出去
reply: str
tokens: int
@app.post("/chat", response_model=ChatOut)
def chat(body: ChatIn) -> ChatOut:
reply = "你说的是:" + body.message # 下一章换成真的模型调用,max_tokens 原样传给上游
return ChatOut(reply=reply, tokens=len(reply))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app) # 空 message / max_tokens=99999 / temperature=9 都被挡在 422
为什么值得单独定义这两个模型:① max_length=4000 这类限制只写一处,而成本变量在请求发出前能卡住两个:输入长度和输出上限(max_tokens);② 非法请求进业务之前就被 422 挡掉,业务函数拿到的对象一定合法;③ 响应模型是个裁剪器——内部对象上挂的 raw_prompt 只要没写进 ChatOut 就漏不出去,比「记得删掉」可靠。
⭐ 两个里更该先卡的是输出:按 04 章那张价格表,输出单价是输入的 3~4 倍(🗓️ 占位数字,量级上普遍如此)。 ⚠️ 而
max_tokens特别容易被漏掉——它常常有个很大的默认值,message再短也拦不住一次跑满上限的回答。
⚡ 三、async 到底在解什么
一次模型调用要 3 秒。这 3 秒里服务器在干什么?什么也没干——它在等网络,CPU 完全空着。
⭐⭐ 普通 Web 应用的瓶颈通常是「算不完」,AI 应用的瓶颈是「等不完」。 等待型负载正是 async 的主场,所以 AI 应用比一般 Web 应用更需要 async。
同步模型下一个工作进程被这 3 秒完全占住,8 个进程就只能同时服务 8 个人;async 下等待期间控制权交回事件循环,一个进程能同时挂着上千个「正在等模型」的请求。
⚠️ 但 async 函数里一个同步阻塞调用,会卡死整个进程
# 一段能自己跑出结论的对照:async 里写同步阻塞调用,会把整个事件循环卡死。
import asyncio
import time
N, WAIT = 8, 0.5 # 8 个并发请求,每个"等模型"0.5 秒
async def good(i):
await asyncio.sleep(WAIT) # ⭐ 等的时候把控制权交回事件循环,别人能跑
return i
async def bad(i):
time.sleep(WAIT) # 💀 同步阻塞:这 0.5 秒里整个进程什么都干不了
return i
async def main():
for name, fn in (("await asyncio.sleep", good), ("time.sleep", bad)):
t0 = time.perf_counter()
await asyncio.gather(*(fn(i) for i in range(N)))
print("%-22s %d 个请求耗时 %.2f 秒" % (name, N, time.perf_counter() - t0))
asyncio.run(main())
实跑结果:await asyncio.sleep 版 0.51 秒,time.sleep 版 4.01 秒——8 个请求被排成了一队。⚠️ bad() 长得完全像个正常协程,没有语法错误、没有任何警告,而且本地单人开发时一切正常,上线并发一起来才一起爆炸。
真实代码里的 time.sleep 伪装成:requests.post(...)(换 httpx.AsyncClient)、同步数据库 / Redis 驱动(换 async 驱动或走线程池)、只有同步版的 SDK(当阻塞处理)。另有一类是真 CPU 活——解析 50 MB JSON、本地算 embedding——async 救不了,只能扔线程池或独立进程。
⭐ FastAPI 的逃生舱:路由写成普通 def(不是 async def)时,它会自动把这个函数扔进线程池。本章示例用的都是 def。🗓️ 线程数有限且随版本变,按官方文档核对。
⭐ 判据背下来:
async def里只能写「会 await 的等待」。拿不准某个库阻不阻塞,就写成def交给线程池——慢一点,但不会全站一起躺下。
🔌 四、依赖注入不是花架子
它解决一个很具体的问题:有些事每个请求都要做,却不属于任何一个业务函数——鉴权、限流、拿连接、生成 trace_id、识别租户。写进业务函数就是十个路由十份复制粘贴,改一次漏三处。
# 依赖注入:把"每个请求都要做的事"挪出业务函数,变成可替换的挂载点
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException
app = FastAPI()
def current_user(authorization: Annotated[str, Header()] = ""):
if not authorization.startswith("Bearer "):
raise HTTPException(401, "缺少 Bearer token")
return {"uid": authorization[7:]} # 第 8 章换成真校验
def quota(user: Annotated[dict, Depends(current_user)]):
if user["uid"] == "u_banned": # ⭐ 依赖可以套依赖
raise HTTPException(429, "免费额度已用完")
return user
@app.post("/chat")
def chat(user: Annotated[dict, Depends(quota)]):
return {"uid": user["uid"], "reply": "..."} # ⭐ 业务函数只剩业务
# ⭐ 测试里一行把整条鉴权链换掉,不用起认证服务、不用造 token
# app.dependency_overrides[quota] = lambda: {"uid": "u_test"}
⭐ 最后那行注释和第 4 章「把模型客户端换成假客户端」是同一个思路——「能被换掉」是这个骨架最值钱的性质。
这两个依赖现在是空壳,但是已经焊好的挂载点:current_user 留给第 8 章(真 token 校验、多租户隔离),quota 留给第 12 章(限流与配额扣减),再加一个 trace 就是第 15 章(trace_id 串起日志与指标)的位置。没有这层,那三章只能塞进每个路由函数的开头。
🚨 五、错误处理:对外一种形状,对内保留细节
模型应用比普通 CRUD 多一大类错误:上游 429、上游超时、内容被拒、输出校验失败。每个路由自己 try/except 自己拼 JSON,前端就得认十种格式。
# 统一错误响应:一种形状、一个可报的编号、一句人话;细节只进日志
import logging, uuid
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
log, app = logging.getLogger("api"), FastAPI()
@app.exception_handler(Exception)
def on_unknown(r: Request, e: Exception):
tid = uuid.uuid4().hex[:12]
log.exception("unhandled trace=%s path=%s", tid, r.url.path) # 细节只进日志
# ⚠️ 绝不把上游报错原样吐出去:既泄露内部结构,用户也看不懂
return JSONResponse({"error": {"code": "internal_error", # ⭐ 稳定机器码
"message": "服务开小差了,稍后再试",
"trace_id": tid}}, status_code=500)
业务自己的错误同理:定义一个带「状态码 + 机器码 + 一句人话」的 AppError,再注册一个形状一样的 handler。三条原则:
| 原则 | 不这么做会怎样 |
|---|---|
code 是稳定机器码,message 是可随便改的人话 |
前端按 message 的文字做判断,你改一次文案就线上炸一次 |
每个错误带 trace_id 并同时写进日志 |
用户说「刚才报错了」,你在几百万行日志里大海捞针 |
| ⚠️ 未知异常只吐兜底文案 | 上游报错常带着密钥前缀、内网 IP、SQL 片段,如 upstream sk-xxxx at 10.0.3.7:8000 |
⚠️ 一个 AI 应用特有的取舍:上游 429 该翻译成什么? 原样透传,用户会以为是自己发太快了。诚实的做法是分开——用户超限用 429,你的上游额度不够用 503 +「服务繁忙」。两者的止损动作完全不同,混在一起监控也分不清。
📁 六、什么时候从单文件拆出来
别一上来就建八个目录。 判据只有一条:当你为了改一件事必须在同一个文件里上下反复横跳时就该拆,通常在 300~500 行、第三个路由出现时。按「变化的理由」分,不要按「技术种类」分:
app/
├─ main.py # 只做装配:建 app、挂路由、注册异常处理器
├─ api/ # HTTP 层:路由、请求/响应模型
├─ deps.py # 依赖:current_user / quota / trace
├─ services/ # 业务编排:一次对话该做哪几步
├─ llm/ # ⭐ 第 4 章的调用层:超时/重试/记账/缓存/假客户端
├─ store/ # 第 6/7 章:数据库与向量库
└─ settings.py # 配置一律从环境变量读
⭐ 唯一强制的一条:api/ 里不许直接出现模型 SDK 的名字。为什么,下一章第一节整节都在讲。
🔄 七、换个栈怎么对应
| 概念 | Python / FastAPI | Node(Express / Hono) | Go |
|---|---|---|---|
| 路由 + 请求校验 | 装饰器 + Pydantic 模型 | app.post + Zod schema |
http.HandleFunc + struct tag |
| 等待不占线程 | async def + 事件循环 |
天生单线程事件循环,IO 默认异步 | goroutine,运行时自动挪走阻塞的 |
| 对应的坑 | async def 里写同步调用 |
处理函数里跑 CPU 密集活,同样卡死 | 忘了把 context 传下去,取消传不到 |
| 每请求要做的事 | Depends(...) |
中间件 + ctx 上挂对象 |
中间件 + context.Context |
| 统一错误 | @app.exception_handler |
错误中间件(放最后) | 包一层 handler 返回 error |
⭐ 这张表真正想说的:三个栈里只有「依赖注入」是 FastAPI 的叫法,另两个栈用中间件做同一件事。 「每请求要做的事得有统一的地方挂」这个概念不会过期,
Depends这个名字会。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 04 · 调用层 | 本章 chat() 里那行假回复,下一章换成真客户端——以及为什么不能直接写 SDK |
| 05 · 流式输出 | 本章返回一整个 JSON;用户要边看边等时,返回方式整个变了 |
| ⭐ 03b · 接口的形状 | 本章给了错误响应的形状(code / message / trace_id),但没说该返哪个码。那一章讲资源怎么划、方法怎么选、状态码按「谁的错、要不要重试」怎么分 —— ⚠️ 第 11 章的死信规则(400/401 不重试、429/5xx 重试)依赖的正是它 |
| ⭐ 03c · 幂等与条件请求 | 本章的错误形状让客户端知道「能不能重试」,那一章讲重试怎么才是安全的:Idempotency-Key 的服务端协议、ETag 省流量、If-Match 防丢更新 |
| 模型上线之后 05 · 该监控什么 | 本章埋 trace_id 就是为了它。⚠️ 分界:本板块管「服务健不健康」,那边管「模型准不准」 |
| AI基础设施 20 · 推理服务化 | 你调的那个模型服务自己也是个后端。那边讲推理引擎怎么选,这里讲应用怎么调它 |
✅ 检查点
- 前端直接调模型 API 会踩到哪四类问题?
- 单独定义请求/响应模型带来的三个好处是什么?
- 为什么说 AI 应用比一般 Web 应用更需要 async?
async def里调一个同步阻塞的库会怎样?对照代码跑出了什么结果?这个 bug 为什么本地开发时发现不了?- FastAPI 的「逃生舱」是什么?什么时候不该靠它?
- 依赖注入解决的具体问题是什么?本章埋的两个依赖分别留给哪两章?
- 统一错误响应的三条原则是什么?上游 429 直接透传会造成什么误解?
- 什么时候该拆文件?
api/层唯一强制的规矩是什么?
👀 答案
- 密钥在前端可见(被烧账单且查不到是谁)、没有配额边界、没有审计记录、业务逻辑白盒暴露且改一次要等客户端更新。
- ① 限制只写一处——请求发出前能卡住的成本变量有两个:输入长度和输出上限
max_tokens,其中输出更该先卡(04 章价格表里输出单价是输入的 3~4 倍,而max_tokens的默认值往往很大);② 非法请求进业务前就 422 被挡;③ 响应模型是裁剪器,raw_prompt之类没写进去就漏不出去。 - 因为瓶颈是「等不完」不是「算不完」——一次调用等 3 秒,CPU 完全空着,等待型负载正是 async 的主场。
- 卡死整个事件循环,同进程所有请求一起排队。8 个各等 0.5 秒的任务:
await asyncio.sleep版 0.51 秒,time.sleep版 4.01 秒。本地发现不了是因为只有你一个人用,没有并发就看不出排队;语法也完全正确、不报警。 - 路由写成普通
def,FastAPI 自动扔进线程池。适合「偶尔有个同步库」,不适合「所有请求都走同步」——线程数有限,量一大照样排队。 - 「每个请求都要做、又不属于任何业务函数」的事,否则十个路由十份复制粘贴。
current_user留给第 8 章(认证与多租户),quota留给第 12 章(限流与配额)。 code稳定 /message可改、每个错误带trace_id并进日志、未知异常只给兜底文案(上游报错常带密钥前缀和内网 IP)。429 原样透传会让用户以为是自己发太快——用户超限 429,上游额度不够 503。- 当改一件事需要在同一文件里反复横跳时(约 300~500 行、第三个路由);
api/里不许出现模型 SDK 的名字。
🛑 可以停在这里
⚡ 走神救援
后端不是给模型调用套层 HTTP,是立四根柱子。① 为什么要有它:密钥在前端等于公开、没有配额边界、没有审计、业务逻辑白盒——模型 API 是「谁拿到密钥谁是老板」,产品需要「谁是谁、能做什么、做过什么」。② 边界:单独写请求/响应模型,非法请求进业务前就 422 被挡,响应模型同时是裁剪器;⭐ 请求发出前能卡死的成本变量有两个——输入长度(
max_length)和输出上限(max_tokens),而按 04 章那张价格表输出单价是输入的 3~4 倍,所以更该先卡的是输出那个,⚠️ 它还常带一个很大的默认值,光限制输入拦不住。③ ⭐⭐ async 在解什么:普通 Web 瓶颈是「算不完」,AI 应用瓶颈是「等不完」——一次调用等 3 秒 CPU 全空,所以 AI 应用更需要 async。⚠️ 最贵的坑:async def里一个同步阻塞调用会卡死整个进程。本章可跑对照:8 个各等 0.5 秒的任务,await asyncio.sleep版 0.51 秒,time.sleep版 4.01 秒;错误写法语法完全正确、不报警,本地单人开发一切正常,上线并发一起来才一起爆炸。伪装形态是requests、同步 DB/Redis 驱动、只有同步版的 SDK。⭐ 逃生舱:路由写成普通def,FastAPI 自动扔线程池——但线程数有限。④ 依赖注入不是花架子:它是「每请求都要做、又不属于任何业务函数」的统一挂载点。current_user是第 8 章认证/多租户的位置,quota是第 12 章限流的位置,再加个trace就是第 15 章可观测性的位置;没有这层,那三章只能塞进每个路由开头。dependency_overrides能在测试里把整条链换掉,和第 4 章的假客户端同源——「能被换掉」是这个骨架最值钱的性质。⑤ 错误处理:对外一种形状(code+message+trace_id),⚠️ 未知异常绝不原样透出上游报错(里面常有密钥前缀、内网 IP);上游 429 别直接透传,用户超限才 429,上游额度不够用 503。⑥ 拆文件:判据是「改一件事要在同一文件里反复横跳」,api/里不许出现模型 SDK 的名字。⑦ 换个栈:Node 用中间件、Go 用context.Context做同一件事——概念不会过期,Depends这个名字会。
下一节 👉 03b-接口的形状.md