📑 本页目录(点开跳转)
04 · 调用层
⏱ 54 分钟 | ⭐ 把「调模型」包成你自己的一个函数 | 🔨 本章代码不联网、不花钱、可直接跑
🎯 一句话
client.chat.completions.create() 只是这一层最里面的一行;外面那圈——超时、重试、记账、缓存、可替换——才是它存在的理由。
上一章最后那条规矩「api/ 里不许出现模型 SDK 的名字」,这一章解释为什么。
🧩 一、为什么要在 SDK 外面再包一层
不包也能跑。 下面五件事会在不同时间点分别来找你,每一件都在问同一句话:「调模型」这件事,有没有一个统一的地方?
| 理由 | 没包一层时的具体场景 |
|---|---|
| 换模型 | 新模型便宜一半,你要在十几个文件里改调用写法、返回字段解析、错误类型判断 |
| 加日志 | 想回答「哪些请求慢」,得在每个调用点复制一遍计时代码,还总有人忘了加 |
| ⭐ 算成本 | 账单只有一个总数。「聊天花的还是摘要花的」「哪个用户」——问不出来 |
| 做缓存 | 「什么时候能缓存」的判据散在各处,漏判一次就是把 A 的答案发给 B |
| ⭐ 测试替换 | CI 每跑一次都真花钱、真联网,上游一抖测试就红——红了还不是你的错 |
⭐ 五个理由的形状是同一个:都是「每次调用都要做、却和业务无关」的事。 和上一章依赖注入同类,只是那层管进来的边界,这层管出去的边界。
地基是两样东西:一个你自己的返回结构,一个假客户端。
# llm.py —— 调用层的地基:统一返回结构 + 假客户端
import time
import random
from dataclasses import dataclass
@dataclass
class LLMResult: # ⭐ 业务代码只认这个形状,永远不认某个 SDK 的返回对象
text: str
model: str
in_tokens: int
out_tokens: int
latency_ms: int
cached: bool = False
class FakeLLM:
"""不联网、不花钱、结果可预测。测试、CI、本地开发共用这一个。"""
def __init__(self, reply="(假模型)收到。", latency=0.05, seed=0):
self.reply, self.latency = reply, latency
self.calls = [] # ⭐ 记下每次调用,测试里可以直接断言
self._rng = random.Random(seed)
def complete(self, prompt, model="fake-1", temperature=0.0, timeout=30):
t0 = time.perf_counter()
self.calls.append({"prompt": prompt, "temperature": temperature})
time.sleep(self.latency)
# ⭐ temperature > 0 就每次不一样 —— 让"不确定性"在测试里也是真的
text = self.reply if temperature == 0 else self.reply + str(self._rng.randint(100, 999))
return LLMResult(text, model, len(prompt) // 3 + 1, len(text) // 3 + 1,
int((time.perf_counter() - t0) * 1000))
if __name__ == "__main__":
llm = FakeLLM()
a, b = (llm.complete("同一个问题", temperature=0.7).text for _ in range(2))
print(a, b, "|两次相同吗:", a == b) # ⭐ False —— 断言 == 的测试写不出来
⭐ LLMResult 是这一层最值钱的一行:它让「换 SDK」变成「改一个文件」。⚠️ 别偷懒把 SDK 的响应对象直接往上传——那等于把整个 SDK 的形状焊进业务代码。
FakeLLM 也不是玩具:① 测试 / CI 不花钱不联网,上游抖动不再让你的测试变红;② 本地开发 前端不用等你的 Key;③ ⭐ 故障演练——让它故意抛 429、故意超时、故意返回半截 JSON,重试和降级路径只有这样才测得到。⭐ 它和 03 章那行 app.dependency_overrides[...] 是同一件事:「能被换掉」是这整套设计的目的。
⏳ 二、超时是两个数,不是一个
| 超时 | 卡的是什么 | 该设多大 |
|---|---|---|
| 连接超时 | TCP + TLS 握手,请求还没发出去 | 短,几秒。连不上就是连不上 |
| 读取超时 | 发出请求后,多久没收到新字节 | ⚠️ 这个最容易设错,见下 |
⚠️ 流式和非流式下,读取超时的含义完全不同:
- 非流式:整段生成完才有第一个字节回来。它必须大于最长的一次生成,否则正常的长回答被你自己砍断。
- 流式:字一段一段回来,它的实际含义变成 ⭐ 「两次字节之间的最大间隔」——总时长不再受它约束,所以这个数可以比非流式小。 ⚠️ 但绝不能按普通 API 的直觉设成几秒:模型在长上下文、工具调用中「想」的时候,连接上几十秒没有任何字节是正常的(05 章第四节)。设成 5 秒,砍掉的不是故障,是你自己正常的请求——而且专挑最贵、最慢的那类砍。
⭐ 判据:读取超时要大于「一次合法的静默能有多长」,这个长度由你最长的一次工具调用决定,不是由手感决定。 ⚠️ 拿不准就往大了设(🗓️ 几十秒量级),因为设小的代价是砍掉正常请求,而设大的代价只是坏连接多挂一会儿。 想更早识别「真卡住」,靠的不是把超时调小,而是让静默期里有字节在流动——上游若在静默期发保活帧,那些字节同样会刷新读取超时;你自己往前端发的
ping注释帧(05 章)就是同一招的下游版本。
💀 不设超时的下场:连接永远挂着不释放,进程里的连接数只增不减。症状是「服务跑几天越来越慢,重启一下就好」——⚠️ 这个症状太温和,通常拖很久才有人去查。
# 🗓️ 未实跑 —— 需要 httpx 和真实网络。要看的是【形状】:两个数分开设。
import httpx
# ⚠️ read 这个数按【最长的一次合法静默】定,不是拍脑袋:流式 + 工具调用时它可能是几十秒
timeout = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0) # ⭐ 分开设
client = httpx.Client(timeout=timeout, limits=httpx.Limits(max_connections=100))
⭐ 形状不过期,参数名会:🗓️ 有的库把两者塞进一个参数,那种库你得自己在外面再补一层。
🔁 三、重试:用白名单,不是黑名单
| 该重试 | 绝不重试 |
|---|---|
| 429 上游限流——等一会真的会好 | 400 / 422 参数错、超长:重试一万次还是 400 |
| 5xx 上游故障——通常是瞬时的 | 401 / 403:Key 不会因为多试一次就变对 |
| 连接超时 / 网络错误 | 内容策略拒绝:这是判断结果,不是故障 |
| ⚠️ 读取超时要小心:上游可能已在生成,重试 = 付两次钱 |
⚠️ 必须写成白名单:except Exception: retry 会把「prompt 超长」这种确定性错误也重试三遍,让本该 0.2 秒返回的 400 变成 8 秒——而且日志里看起来像上游在抖。
⚠️ 白名单漏一类,症状同样安静:比如漏了 5xx,上游一次瞬时故障就直接变成用户看见的报错,而日志里只有一次失败,看不出「它本该重试一次就好」。所以白名单要对着上面那张表逐行写,别凭记忆敲。
# retry.py —— 哪些错该重试、退避怎么算(可直接跑,看得见退避序列)
import random
import time
class RateLimited(Exception): # 上游 429
pass
class UpstreamError(Exception): # 上游 5xx:500 / 502 / 503 / 504,通常是瞬时的
pass
class BadRequest(Exception): # 400:参数错、超长、被内容策略拒绝
pass
# ⭐ 白名单,不是黑名单。⚠️ 上面那张表里"该重试"的四类,这里必须一个不少地对上:
# 429 → RateLimited|5xx → UpstreamError|超时 → TimeoutError|网络错误 → ConnectionError
RETRIABLE = (RateLimited, UpstreamError, TimeoutError, ConnectionError)
def call_with_retry(fn, tries=4, base=0.5, cap=8.0, sleep=time.sleep, rng=None):
rng = rng or random.Random(0)
for i in range(tries):
try:
return fn()
except RETRIABLE as e:
if i == tries - 1:
raise
wait = rng.uniform(0, min(cap, base * 2 ** i)) # ⭐ 全抖动,别让大家一起回来
print(" 第 %d 次失败(%s),等 %.2f 秒" % (i + 1, type(e).__name__, wait))
sleep(wait)
# ⭐ 白名单之外的异常(如 BadRequest)根本不进 except,直接往上抛
if __name__ == "__main__":
n = [0]
def flaky(): # 第一次被限流,第二次上游 5xx —— 两类都在白名单里
n[0] += 1
if n[0] == 1:
raise RateLimited("429")
if n[0] == 2:
raise UpstreamError("503 上游故障")
return "第 %d 次成功" % n[0]
def always_400():
raise BadRequest("400 prompt 超长")
print("可重试:", call_with_retry(flaky, sleep=lambda s: None)) # 演示时不真睡
try:
call_with_retry(always_400, sleep=lambda s: None)
except BadRequest as e:
print("不可重试,一次就抛出来:", e)
⭐ 为什么一定要有抖动:上游抖一下,所有客户端在同一瞬间失败,也就在同一瞬间重试——第二波比第一波更集中,把刚要缓过来的上游又打回去。这叫重试风暴,rng.uniform(0, 退避上限) 就是解药:把大家回来的时间摊开。
三条容易漏的:① 次数要有上限(三四次);② 重试烧的 token 也是钱,所以下一节记账表里有 retries;③ ⭐ 流式首字发出去之后不能再静默重试——用户已经看见字了,重来一遍就是当着他的面推翻自己(05 章第五节)。
💰 四、成本记账:不在这层做,之后就没地方做了
| 层 | 它知道什么 | 它不知道什么 |
|---|---|---|
| 云厂商账单 | 一个总数 | 谁、哪个功能、哪次请求 |
| 业务代码 | 谁、哪个功能 | ⚠️ token 数——它拿到的是一段文本 |
| ⭐ 调用层 | 两边都知道 | —— |
# cost.py —— 每次调用都记 token 和钱。⚠️ 单价是【占位数字】,用前去官方价格页抄一遍
PRICES = {"small": {"in": 2.0, "out": 8.0}, # 🗓️ 元 / 百万 token
"big": {"in": 20.0, "out": 60.0}}
def cost_of(model, in_tokens, out_tokens):
p = PRICES.get(model)
if p is None:
return None # ⭐ 不认识的模型返回 None,别默默算成 0 —— 新模型的账会凭空消失
return (in_tokens * p["in"] + out_tokens * p["out"]) / 1_000_000
def record(trace_id, user_id, feature, model, in_tok, out_tok, ms, cached=False, retries=0):
"""最小字段集。少任何一个,事后都有一类问题查不了:
user_id 少了答不出"谁把钱花光的",⭐ feature 少了答不出"哪个功能在烧钱",
model 少了答不出"换模型省了多少",
cached 少了算不出缓存省了多少,retries 少了看不见重试烧掉的那笔账。"""
return {"trace_id": trace_id, "user_id": user_id, "feature": feature, "model": model,
"in": in_tok, "out": out_tok, "ms": ms, "cached": cached, "retries": retries,
"cost": cost_of(model, in_tok, out_tok)}
if __name__ == "__main__":
for m in ("small", "big", "忘了配单价的新模型"):
print(m, "→", record("t1", "u_9", "摘要", m, 1200, 800, 940)["cost"])
⚠️ 单价必须是配置,不能是散落在代码里的字面量——🗓️ 它一年变好几次,而且新模型上线时你会忘了加。cost_of 对不认识的模型返回 None 而不是 0,就是为了让「忘了加」在报表里显形,而不是安静地变成一笔零元开销。
⭐ 三个归因维度一个都不能少:user_id(谁)、feature(哪个功能)、model(哪个模型)。 前两个只有业务层知道,所以得由调用方传进来——⚠️ feature 是最常被漏掉的那个,因为它是唯一一个「不传也能跑」的字段。漏了它,本节开头那个问题「聊天花的还是摘要花的」就永远答不出来,而且事后补不回去:历史记录里没有这一列。
⭐ 有了这张表,12 章的配额扣减、15 章的成本看板都只是拿它做聚合——15 章那三条 GROUP BY 正是按天 / 按 feature / 按 user_id。顺序不能反:先有记录,才谈得上限制。
🗄️ 五、缓存:不确定性让它变难了
普通 Web 的缓存判据是「这个资源变没变」。AI 应用多一个前置问题:同样的输入,是不是「应该」得到同样的输出?
| 能缓存 | 不能缓存 |
|---|---|
temperature=0 的调用 |
temperature > 0 的创作:用户点「换一个」你给他一模一样的,这是 bug 不是优化 |
| ⭐ embedding:同一段文本的向量永远一样,还经常被重复算,性价比最高 | 带工具调用的对话:工具结果随时在变 |
| 固定提示词的分类 / 抽取:幂等 | 流式响应:缓存的是「一整段」,和「边生成边吐」对不上 |
# cache.py —— 什么能缓存、key 里必须放什么(可直接跑)
import hashlib
import json
def cacheable(temperature, stream=False, tools=None):
"""⭐ 判据只有一条:同样的输入,是不是【应该】得到同样的输出。"""
return temperature == 0 and not stream and not tools
def cache_key(*, model, prompt, system="", temperature=0.0, doc_ids=()):
"""⚠️ 凡是会影响输出的东西都必须进 key —— 漏一个就是把别人的答案发给这个人。"""
blob = json.dumps({"model": model, "system": system, "prompt": prompt,
"temperature": temperature,
"docs": sorted(doc_ids)}, # ⭐ 检索到的文档也影响输出
sort_keys=True, ensure_ascii=False)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:32]
if __name__ == "__main__":
print("能缓存吗:t=0", cacheable(0.0), "|t=0.7", cacheable(0.7),
"|带工具", cacheable(0.0, tools=["搜索"]))
k = [cache_key(model="s", prompt="这份合同有什么风险", doc_ids=[d]) for d in "AB"]
print("同问题、不同文档,key 相同吗:", k[0] == k[1]) # ⭐ 必须 False
💀 key 漏字段是这一节唯一真正危险的错误。 假设 key 只算 prompt,而提示词模板里拼进了检索到的文档——A 公司和 B 公司都问「这份合同有什么风险」,B 会拿到 A 的合同摘要。⚠️ 这类 bug 没有任何报错,缓存命中率还会因此变得很好看。判据:凡是会影响输出的东西(模型、system、温度、文档 id 和版本、租户)都得进 key。
🔄 六、换个栈怎么对应
| 概念 | Python | Node(Express / Hono) | Go |
|---|---|---|---|
| HTTP 客户端 | httpx / requests |
fetch / undici |
net/http 的 Client |
| 连接超时 vs 读取超时 | httpx.Timeout(connect=, read=) |
⚠️ fetch 只有一个整体超时,要自己拆 |
DialContext 与 ResponseHeaderTimeout 分开设 |
| 取消传播 | asyncio 的 CancelledError |
AbortController |
⭐ context.WithTimeout 一路传下去 |
⭐ 重点在第二行:不是每个栈都替你把「连不上」和「连上了不说话」分开。 分不开的那些栈,你必须自己在外面补——否则你以为设了超时,其实只设了一半。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 03 · 后端骨架 | 那一章最后「api/ 里不许出现 SDK 名字」的规矩,本章第一节就是它的展开 |
| 05 · 流式输出 | 本章 complete() 返回一整段;改成边看边等之后,超时、重试、缓存三件事的含义全都变了 |
| 12 · 限流、配额与成本护栏 | ⭐ 第四节那张记账表就是它的输入。先有记录才谈得上限制,顺序不能反 |
| 智能体工程 16c · 接进真实产品 | 输出校验失败时带着错误重试——那是「内容重试」,本章是「故障重试」,上限和退避策略都不同 |
| 全景导论 07 · 本地部署与开源生态 | 自建模型时多数推理服务提供 OpenAI 兼容接口——这正是包一层的回报:换上游几乎不用改业务 |
| AI基础设施 20 · 推理服务化 | 你重试的那个 429,在对面是排队与批处理策略的结果 |
✅ 检查点
- 包一层的五个理由是什么?它们的共同形状是什么?
- 连接超时和读取超时分别卡什么?流式下读取超时的含义变成了什么?完全不设超时的症状又长什么样?
- 为什么重试要写成白名单?
except Exception: retry会造成什么后果? - 退避为什么要加抖动?不加会发生什么?
- 为什么成本记账必须在调用层做?账单层和业务层各缺什么?记账表的三个归因维度是哪三个?
cost_of对不认识的模型为什么返回None而不是0?- 缓存 key 漏掉文档 id 会发生什么?为什么这个 bug 特别危险?
- 假客户端的三个用途里,哪一个是别的办法替代不了的?
👀 答案
- 换模型、加日志、算成本、做缓存、测试替换。共同形状:都是「每次调用都要做、却和业务无关」的事——和依赖注入同类,只是那层管进来的边界,这层管出去的边界。
- 连接超时卡 TCP + TLS 握手(请求还没发出去);读取超时卡「发出之后多久没收到新字节」,非流式下它必须大于最长的一次生成;流式下变成 ⭐「两次字节之间的最大间隔」,可以比非流式小,但 ⚠️ 不能按普通 API 的直觉设成几秒——长上下文、工具调用中几十秒没有字节是正常的,设 5 秒砍掉的是自己正常的请求。判据是「一次合法的静默能有多长」。不设超时的症状是「跑几天越来越慢,重启就好」,难注意是因为它太温和——不报错、不告警。
- 黑名单会漏。
except Exception: retry会把 400 这类确定性错误也重试三遍,把本该 0.2 秒的错拖成 8 秒,而且日志里看起来像上游在抖。 - 上游抖一下,所有客户端同一瞬间失败、同一瞬间重试,第二波更集中,把刚缓过来的上游又打回去(重试风暴)。抖动把大家回来的时间摊开。
- 因为只有这一层同时知道两边:账单层只有一个总数(不知道谁、哪个功能),业务层不知道 token 数(它拿到的是一段文本)。三个归因维度是 ⭐
user_id(谁)/feature(哪个功能)/model(哪个模型)——前两个只有业务层知道,得由调用方传进来,⚠️feature最容易漏,而漏了事后补不回去。 - 为了让「新模型上线时忘了配单价」在报表里显形,而不是安静地变成一笔零元开销。
- B 公司会拿到 A 公司的合同摘要——同一个问题、不同的检索文档,key 却一样。危险在于没有任何报错,而且命中率还会变得很好看。
- ⭐ 故障演练:故意抛 429、故意超时、故意返回半截 JSON,重试和降级路径只有这样才测得到。
🛑 可以停在这里
⚡ 走神救援
调用层 = 把「调模型」包成你自己的一个函数。五个理由:换模型 / 加日志 / 算成本 / 做缓存 / 测试替换——共同形状是「每次调用都要做、却和业务无关」,和 03 章依赖注入同类,只是那层管进来的边界、这层管出去的边界。地基两样:自己定的
LLMResult(让换 SDK 变成改一个文件,⚠️ 别把 SDK 响应对象直接往上传)和假客户端(它唯一不可替代的用途是 ⭐ 故障演练:故意抛 429、故意超时、故意返回半截 JSON,重试和降级路径只有这样才测得到)。超时是两个数:连接超时卡 TCP/TLS 握手,读取超时卡「发出后多久没收到新字节」;非流式下它必须大于最长的一次生成;⭐ 流式下含义变成「两次字节之间的最大间隔」,可以比非流式小,但 ⚠️ 不能按普通 API 的直觉设成几秒——长上下文、工具调用中连接上几十秒没有字节是正常的(05 章第四节),设 5 秒砍掉的不是故障、是你自己最贵最慢的那些正常请求;判据是「一次合法的静默能有多长」,拿不准往大了设。不设超时的症状是「跑几天越来越慢,重启就好」——太温和,所以没人查。重试用白名单:429 / 5xx / 网络错误该重试,400、401、内容策略拒绝绝不重试;except Exception: retry会把 0.2 秒的 400 拖成 8 秒,还伪装成上游在抖。退避要指数 + 抖动:上游抖一下所有客户端同时失败、同时重试,第二波比第一波更集中,把刚缓过来的上游又打回去,rng.uniform(0, 退避上限)就是解药。另外三条:次数有上限、重试烧的 token 也是钱(所以记账表里有retries)、⭐ 首字发出去之后不能静默重试。成本记账必须在这一层,因为只有它同时知道两边:账单层只有一个总数,业务层不知道 token 数;⭐ 三个归因维度一个都不能少:user_id/feature/model,⚠️feature最容易漏(它是唯一「不传也能跑」的),漏了「聊天花的还是摘要花的」就永远答不出来、而且事后补不回去;⭐ 不认识的模型返回None不是0,好让「忘了配单价」在报表里显形。先有记录才谈得上限制,12 章的配额建在这张表上。缓存的判据是「同样输入是不是应该得到同样输出」:temperature=0、embedding(性价比最高)、固定提示词的分类能缓存;创作、带工具、流式不能。💀 key 漏字段是唯一真正危险的错:只算 prompt 不算文档 id,B 公司会拿到 A 公司的合同摘要,而且没有任何报错,命中率还很好看。
下一节 👉 05-流式输出.md