专栏:AI 全栈开发|16
上一篇 15《同步、异步、并发到底有什么区别?》已经把 FastAPI 后端的运行模型讲清楚了:外部模型调用通常是长 I/O,async 的价值是等待期间让出执行权,而不是让单次推理变快。这一篇终于把这套运行模型接到真实 LLM 服务上:API Key 放哪里、请求体长什么样、响应怎么读、timeout 和错误怎么处理,先完成第一次“能用、不会裸奔”的模型调用。 |
一、先把问题缩小:后端到底要完成哪一步
假设页面上有一个“AI 总结”按钮。用户把文本发给 FastAPI,后端最后只需要完成这样一件事:
reply = llm_client.chat( model="your-model", messages=[ {"role": "user", "content": "请总结下面这段文本..."} ], )真正麻烦的不是这一行,而是它背后还有密钥、URL、HTTP 请求、超时、状态码、响应解析和日志。第一次调用 LLM API 的目标不是把所有 Provider 参数背完,而是先把这条最小链路走通。
图 1 浏览器不直接拿 API Key;FastAPI 通过统一 LLM Client 调用模型服务
二、一次最小 LLM 调用到底需要哪些东西
信息 | 作用 |
api_key | 证明你的后端有权限调用模型服务 |
base_url | 模型 API 的服务地址 |
model | 选择具体模型 |
messages | 这一次要发给模型的对话内容 |
timeout | 网络或模型长期无响应时,什么时候放弃等待 |
这一篇先只讲单次、非流式文本调用。多轮历史、SSE 流式输出、Tool Calling 和完整采样参数都先放到后面的章节,否则第一次请求会被太多概念淹没。
三、API Key 先放对地方
API Key 的第一条规则很简单:它属于后端配置,不属于业务代码,更不属于浏览器。
# shell |
.env 可以用于本地开发,但必须进入 .gitignore。
• 前端不要拿长期密钥直接调用模型服务。
• 日志不要打印 Authorization Header,也不要把 Key 拼进异常文本。
• 一旦怀疑泄露,正确动作是吊销并轮换,而不是只删 Git 提交。
四、先不看 SDK:最小 HTTP 请求长什么样
先看 HTTP 能帮助你理解所有兼容 SDK 在替你做什么。下面仍然使用上一篇原稿里的 OpenAI-compatible Chat Completions 形状:
import requests url = base_url.rstrip("/") + "/chat/completions" resp = requests.post( url, headers={ "Authorization": "Bearer " + api_key, "Content-Type": "application/json", }, json={ "model": "your-model", "messages": [ {"role": "user", "content": "用一句话解释 HTTP"} ], }, timeout=(5, 60), )这里最重要的不是 requests 这个库,而是请求的四个组成:URL、鉴权 Header、JSON Body、Timeout。以后换成官方 SDK 或异步 httpx,这四件事仍然存在。
五、messages 才是模型真正读到的上下文
最小文本对话里,messages 可以先理解成按顺序排列的消息数组。
messages = [ |
system 用来放应用级规则,user 是当前用户输入,assistant 是模型过去的回复。多轮对话本质上就是继续把历史消息放进这个数组;历史该保留多少、什么时候裁剪,是后面的会话管理问题。
六、收到响应以后,至少看这 3 类信息
字段 | 你为什么要关心 |
choices[0].message.content | 真正要展示或继续处理的模型文本 |
finish_reason | 模型是正常结束,还是因为长度等原因停止 |
usage | 这一轮消耗了多少 token,用于成本与监控 |
解析时不要直接把整个响应结构当成永远不变的常量。最起码先检查 choices 是否为空,再读取第一条结果;否则外部 API 一旦返回异常结构,你得到的会是一个和业务完全无关的 IndexError。
data = resp.json() choices = data.get("choices") or [] if not choices: raise RuntimeError("LLM response has no choices") message = choices[0].get("message") or {} content = message.get("content") or ""七、Timeout 和错误分类为什么一定要在第一版就加
LLM 调用比普通 CRUD 更慢,也更容易碰到限流和上游故障。如果不设 timeout,一条坏请求可能长期占着线程、连接和并发槽。
现场 | 先怎么处理 |
401 / 鉴权失败 | 检查 Key / 权限,不要机械重试 |
400 / 422 参数问题 | 检查请求体,不要机械重试 |
404 模型或路径不存在 | 检查 model / base_url |
429 限流 | 读取服务端提示,退避后再考虑重试 |
5xx / 临时服务故障 | 可在有限次数内退避重试 |
连接失败 / 读取超时 | 按网络故障处理,并限制重试次数 |
八、Retry 不是“失败就再发一次”
重试只对“下一次可能恢复”的错误有意义。429、部分 5xx、连接失败和超时可以考虑重试;密钥错、模型名错、参数结构错,重试只会重复浪费时间和成本。
退避也不能写成固定 sleep(1)。更常见的做法是指数退避并加一点随机抖动;如果服务端给了 Retry-After,就优先尊重服务端的等待时间。更重要的是给重试设上限——“会重试”不等于“无限重试”。
delay = min(base * (2 ** attempt), max_delay) |
九、把第一次调用封装成一个最小 LLMClient
import requests class LLMClient: def __init__(self, api_key, base_url, timeout=(5, 60)): self.api_key = api_key self.base_url = base_url.rstrip("/") self.timeout = timeout self.session = requests.Session() def chat(self, model, messages): resp = self.session.post( self.base_url + "/chat/completions", headers={"Authorization": "Bearer " + self.api_key}, json={"model": model, "messages": messages}, timeout=self.timeout, ) resp.raise_for_status() data = resp.json() choices = data.get("choices") or [] if not choices: raise RuntimeError("LLM response has no choices") return { "content": choices[0]["message"].get("content") or "", "finish_reason": choices[0].get("finish_reason"), "usage": data.get("usage") or {}, }这还不是“万能 SDK 封装”,但它已经把密钥、连接复用、timeout 和响应解析从业务路由里拿走。下一步要加错误分类、重试或流式输出,也应该继续加在 Client / Transport 层,而不是散在每个接口里。
十、FastAPI 里同步还是异步,接回上一篇的判断
你的路由 | 更自然的客户端方式 |
def 路由 / 同步服务 | requests.Session 或同步 SDK |
async def 路由 | 原生异步 SDK / httpx.AsyncClient |
async 路由但只有同步 SDK | 短期用 to_thread 桥接,并控制并发 |
CPU 重计算 | 不要因为用了 async 就塞进事件循环 |
最需要避免的现场仍然是:在 async def 里直接 requests.post()。那不是“调用慢一点”,而是整个事件循环在这段同步 I/O 期间都不能去推进别的请求。
图 3 Route 只管业务,LLM Client 统一管理调用细节;同步/异步只是 Transport 的实现选择
十一、生产环境至少记哪些调用日志
第一次能调用成功以后,最容易被忽略的是“以后怎么排查”。一条 LLM 调用至少值得留下:
• model / provider。
• HTTP 状态或业务错误类型。
• 总耗时。
• finish_reason。
• usage 里的输入、输出与总 token(如果 Provider 返回)。
不要默认把完整 Prompt、Authorization Header 或用户敏感数据直接写日志。可观测性是为了排障,不是给密钥和隐私做备份。
十二、几个最容易背错的结论
•误区 1:第一次调用先学某个 SDK 就够了。先理解 URL、Header、Body、Timeout,换 SDK 才不会重新学一遍。
•误区 2:API Key 放前端方便。长期密钥应该留在后端。
•误区 3:请求失败就重试。先分类;很多 4xx 重试没有意义。
•误区 4:requests 不写 timeout 也有默认值。网络调用应该显式设置连接与读取等待上限。
•误区 5:收到 200 就一定有可用文本。仍然要防御性检查 choices / message。
•误区 6:async def 里可以直接 requests.post。同步阻塞 I/O 会卡住事件循环。
十三、下一篇
下一篇 17《LLM 请求完整参数》会继续拆请求体:本文只先用 model、messages 和最基本的调用配置,下一篇再看 temperature、输出长度、stop、response_format 等参数分别在控制什么,以及哪些参数会直接影响延迟、成本和结果稳定性。