🏠 总目录📚 本教程 04 · 调用层
📑 本页目录(点开跳转)

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 握手,请求还没发出去 短,几秒。连不上就是连不上
读取超时 发出请求后,多久没收到新字节 ⚠️ 这个最容易设错,见下

⚠️ 流式和非流式下,读取超时的含义完全不同

判据:读取超时要大于「一次合法的静默能有多长」,这个长度由你最长的一次工具调用决定,不是由手感决定。 ⚠️ 拿不准就往大了设(🗓️ 几十秒量级),因为设小的代价是砍掉正常请求,而设大的代价只是坏连接多挂一会儿。 想更早识别「真卡住」,靠的不是把超时调小,而是让静默期里有字节在流动——上游若在静默期发保活帧,那些字节同样会刷新读取超时;你自己往前端发的 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/httpClient
连接超时 vs 读取超时 httpx.Timeout(connect=, read=) ⚠️ fetch 只有一个整体超时,要自己拆 DialContextResponseHeaderTimeout 分开设
取消传播 asyncioCancelledError AbortController context.WithTimeout 一路传下去

重点在第二行:不是每个栈都替你把「连不上」和「连上了不说话」分开。 分不开的那些栈,你必须自己在外面补——否则你以为设了超时,其实只设了一半。


🔗 这一章连到哪里

去哪 为什么
03 · 后端骨架 那一章最后「api/ 里不许出现 SDK 名字」的规矩,本章第一节就是它的展开
05 · 流式输出 本章 complete() 返回一整段;改成边看边等之后,超时、重试、缓存三件事的含义全都变了
12 · 限流、配额与成本护栏 ⭐ 第四节那张记账表就是它的输入。先有记录才谈得上限制,顺序不能反
智能体工程 16c · 接进真实产品 输出校验失败时带着错误重试——那是「内容重试」,本章是「故障重试」,上限和退避策略都不同
全景导论 07 · 本地部署与开源生态 自建模型时多数推理服务提供 OpenAI 兼容接口——这正是包一层的回报:换上游几乎不用改业务
AI基础设施 20 · 推理服务化 你重试的那个 429,在对面是排队与批处理策略的结果

✅ 检查点

  1. 包一层的五个理由是什么?它们的共同形状是什么?
  2. 连接超时和读取超时分别卡什么?流式下读取超时的含义变成了什么?完全不设超时的症状又长什么样?
  3. 为什么重试要写成白名单?except Exception: retry 会造成什么后果?
  4. 退避为什么要加抖动?不加会发生什么?
  5. 为什么成本记账必须在调用层做?账单层和业务层各缺什么?记账表的三个归因维度是哪三个?
  6. cost_of 对不认识的模型为什么返回 None 而不是 0
  7. 缓存 key 漏掉文档 id 会发生什么?为什么这个 bug 特别危险?
  8. 假客户端的三个用途里,哪一个是别的办法替代不了的?
👀 答案
  1. 换模型、加日志、算成本、做缓存、测试替换。共同形状:都是「每次调用都要做、却和业务无关」的事——和依赖注入同类,只是那层管进来的边界,这层管出去的边界。
  2. 连接超时卡 TCP + TLS 握手(请求还没发出去);读取超时卡「发出之后多久没收到新字节」,非流式下它必须大于最长的一次生成;流式下变成 ⭐「两次字节之间的最大间隔」,可以比非流式小,但 ⚠️ 不能按普通 API 的直觉设成几秒——长上下文、工具调用中几十秒没有字节是正常的,设 5 秒砍掉的是自己正常的请求。判据是「一次合法的静默能有多长」。不设超时的症状是「跑几天越来越慢,重启就好」,难注意是因为它太温和——不报错、不告警。
  3. 黑名单会漏。except Exception: retry 会把 400 这类确定性错误也重试三遍,把本该 0.2 秒的错拖成 8 秒,而且日志里看起来像上游在抖
  4. 上游抖一下,所有客户端同一瞬间失败、同一瞬间重试,第二波更集中,把刚缓过来的上游又打回去(重试风暴)。抖动把大家回来的时间摊开。
  5. 因为只有这一层同时知道两边:账单层只有一个总数(不知道谁、哪个功能),业务层不知道 token 数(它拿到的是一段文本)。三个归因维度是 ⭐ user_id(谁)/ feature(哪个功能)/ model(哪个模型)——前两个只有业务层知道,得由调用方传进来,⚠️ feature 最容易漏,而漏了事后补不回去
  6. 为了让「新模型上线时忘了配单价」在报表里显形,而不是安静地变成一笔零元开销。
  7. B 公司会拿到 A 公司的合同摘要——同一个问题、不同的检索文档,key 却一样。危险在于没有任何报错,而且命中率还会变得很好看。
  8. 故障演练:故意抛 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

打卡记录保存在你的浏览器里,首页能看到总进度