大模型 API 开发入门

接口是无状态的——模型不记得上一轮,多轮记忆全靠你每次把完整 messages 重新发过去。

30″30 秒看懂大模型 API

调用大模型这件事,像给一位住在国外、没有记忆的枪手写信

他文笔极好、什么都懂,但有一个古怪的毛病:每封信读完就彻底忘光。所以你想让他接着上次的话题往下写,就必须把之前所有的往来信件按顺序重新誊一遍,连同新问题一起寄过去。他读完整叠信,只回你一段新文字。

信封上写的地址是 base_url,信封里的邮票是 api_key,你要找哪位枪手是 model,那叠按顺序摞好的信纸就是 messages。至于他是把整篇写完一次性寄回,还是写一句念一句用电话读给你听——这就是非流式与流式的区别。

图① 30 秒看懂:给一位没有记忆的枪手写信
图① 30 秒看懂:给一位没有记忆的枪手写信
寄信里的角色对应的技术概念它到底是什么
收信地址base_url服务的网络访问点,换供应商主要就是换它
邮票 / 身份证明api_key鉴权凭证,只从环境变量读,绝不写进源码
找哪位枪手model模型名,同一个 base_url 下通常有多个可选
写在最上面的工作要求role: system人设与规则,整轮对话都生效
你写的那页role: user本次提问
他之前回的那页role: assistant模型此前的回答,要由你亲手放回信封
整叠按序摞好的信纸messages对话的全部上下文,只追加、不修改
一次性寄回 / 电话里一句句念stream=False / True等全部生成完再返回,还是边生成边推送
⛔ 整讲只有一条铁律 接口是无状态的——模型不记得上一轮。 多轮对话的「记忆」完全是假象:它靠的是你每次把完整的 messages 重新发过去
这条铁律解释了后面几乎所有现象:为什么 token 越聊越贵、为什么必须手动把 assistant 的回答追加回去、为什么上下文长度是硬上限、为什么要做历史裁剪。

01概念:为什么全行业都长成 OpenAI 的样子

一套接口格式、四个必填项,以及它和 SDK 的关系

1.1 OpenAI 兼容协议是什么

你会发现一件很奇怪的事:国内外几十家模型服务商,接口文档长得几乎一模一样——都是往 /chat/completions 发一个带 modelmessages 的 JSON。这不是巧合,而是行业选择了一个事实标准

各家官方文档里的表述非常直白。DeepSeek 的文档写着:「DeepSeek API 使用与 OpenAI 兼容的 API 格式,通过修改配置,你可以使用 OpenAI SDK 或兼容 OpenAI API 的软件来访问 DeepSeek API」;智谱的文档写着:「智谱提供与 OpenAI API 兼容的接口,你可以使用现有的 OpenAI SDK 代码,只需要简单修改 API 密钥和基础 URL,就能无缝切换」;阿里云百炼的文档写着:「你只需调整 API Key、BASE_URL 和模型名称,即可将原有 OpenAI 代码迁移」

✅ 这对你意味着什么 学一次,通吃全行业。 你在本讲写下的每一行代码,换到任何一家标称「OpenAI 兼容」的服务上都能跑,包括本地跑的 Ollama 和 vLLM。
更重要的是:它让换模型变成一件低成本的事。上一讲说「换一组约束冠军就换人」,能换得动的前提,正是业务代码不和某一家绑死。

1.2 四个必填项

不管调哪一家,你要准备的东西就四样:

是什么怎么拿 / 注意什么
base_url服务的网络访问点官方文档首页一般直接给。注意有没有 /v1 后缀,各家不一致,写错就是 404
api_key鉴权凭证在各家控制台申请。只从环境变量读,不硬编码、不提交 Git
model模型名字符串以官方模型列表为准,会改名、会下线;写错报 404 或 model_not_found
messages对话消息列表本讲的主角,下一节详细拆

环境要求也很统一:Python 3.7.1 以上、OpenAI SDK 版本不低于 1.0.0(这是智谱文档给出的明确下限)。旧版 SDK 的调用写法完全不同,是新手最常见的报错来源之一。

环境准备
# 1. 装 SDK(版本必须 >= 1.0.0,旧版写法完全不同)
pip install --upgrade "openai>=1.0"

# 验证装好了
python3 -c "import openai; print(openai.__version__)"

# 2. 配好三个环境变量(写进 ~/.bashrc 或 .env,不要写进源码)
export LLM_API_KEY="你在控制台申请的key"
export LLM_BASE_URL="https://api.deepseek.com"
export LLM_MODEL="deepseek-flash"

# 3. 写代码之前,先用 curl 把这三个值验通
curl "$LLM_BASE_URL/chat/completions" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -d '{
        "model": "'"$LLM_MODEL"'",
        "messages": [{"role": "user", "content": "你好"}],
        "stream": false
      }'

# 能看到带 choices 的 JSON 就说明通了,可以进下一步。
# 注意:Authorization 头里必须用变量 $LLM_API_KEY,
#      不要把密钥明文粘进命令行——它会留在 shell 历史里。

1.3 三种调用方式,选哪种

同一个接口有三种调法,能力完全相同,差别只在工程体验:

方式优点什么时候用
curl / HTTP零依赖,最接近协议本身,排查问题时最可靠接入第一步先用它验通;确认 key、base_url、模型名没问题
OpenAI SDK自动处理鉴权、重试、流式解析;类型提示友好日常开发的默认选择,本讲全部用它
各家自研 SDK能用上该家特有的功能确实需要非标准能力时;代价是换供应商要重写
接入新服务的正确顺序 先 curl 验通,再写代码。 直接上代码的话,一旦报错你分不清是 key 错了、base_url 错了、模型名错了,还是自己代码写错了。
curl 成功之后,把同样的三个值填进 SDK,基本不会再出问题。

1.4 和邻近概念的区别

刚入门时最容易混的三组概念:

AB区别
调 API本地部署调 API 是把数据发到别人的服务器算;本地部署是权重下载到自己机器上算。本地部署起的服务,通常也提供 OpenAI 兼容接口,所以业务代码一样
chat/completions旧的 completions前者是对话式接口,输入是带 role 的消息列表;后者是纯文本续写接口,早期产物。现在一律用前者
SDK协议协议是 HTTP + JSON 的约定,SDK 只是它的一层封装。SDK 出问题时,退回 curl 看原始请求响应,几乎总能定位

02原理:一次请求里到底发生了什么

messages 的结构、参数怎么调、响应怎么读、流式与多轮的真相

2.1 messages:三种 role 各司其职

messages 是一个列表,顺序就是对话发生的顺序。每条消息只有两个必填字段:rolecontent

role谁写的作用位置
system设定人设、规则、输出格式要求;整轮对话都生效通常放在第一条,只有一条
user你 / 用户本次提问或指令交替出现
assistant模型模型此前的回答;必须由你手动追加回列表交替出现
图② 一次请求的解剖:四个必填项与三种 role
图② 一次请求的解剖:四个必填项与三种 role
⛔ 回到铁律:记忆是你造出来的假象 接口不保存任何会话状态。第二轮提问时,如果你只发新的那条 user 消息,模型对第一轮一无所知。
多轮对话的正确做法是:把 user 提问和 assistant 回答一条条追加进同一个 messages 列表,每次把整个列表重新发过去。所谓「记忆」,就是这么手工搬运出来的。

2.2 采样参数:控制「有多敢瞎说」

回到第一讲的铁律——模型在给下一个词算概率分布。采样参数干的事,就是决定怎么从这个概率分布里挑词

参数取值官方定义怎么用
temperature0 ~ 2采样温度。更高的值(如 0.8)让输出更随机,更低的值(如 0.2)让输出更聚焦、更确定抽取/分类/判定类任务压到 0;创意写作调高
top_p0 ~ 1核采样(nucleus sampling)。只考虑累计概率质量排在前 top_p 的那些 token,0.1 表示只考虑概率质量前 10% 的 token作用与 temperature 类似
max_tokens正整数限制生成的最大 token 数,可用来控制成本OpenAI 官方已标注该值现已弃用,改用 max_completion_tokens;但多数兼容服务仍只认 max_tokens
stream布尔设为 true 时,响应通过 server-sent events (SSE) 边生成边推送交互式界面一律开;批处理不用开
n正整数为每次输入生成几个候选回复官方建议保持为 1 以降低成本——按所有候选的生成 token 总数计费
stop字符串/数组最多 4 个停止序列,命中后停止生成,返回内容不含该序列让模型在指定标记处收口
⚠️ temperature 和 top_p 不要一起调 OpenAI 官方文档明确写着:「我们通常建议修改这个或 top_p,但不要两个都改」。两个都动,行为会变得难以预测,出了问题也说不清是哪个造成的。
实践上:先固定 top_p 用默认值,只调 temperature

2.3 响应结构:三个必看字段

非流式响应是一个对象,业务代码至少要读三处:

字段含义不读会怎样
choices[0].message.content模型生成的正文——这是你要的答案
choices[0].finish_reason结束原因:stop 正常收尾、length 被长度截断、tool_calls 要调工具把被截断的半句话当完整答案入库,是最隐蔽的线上事故
usagetoken 账单:prompt_tokens / completion_tokens / total_tokens成本完全失控,出账单时才发现

另外还有一个 model 字段,返回实际服务你的模型名——上一讲说过,它可能和你请求的不一样。

2.4 流式:从「一个对象」变成「一串 chunk」

开启 stream=True 后,服务端通过 SSE 把结果一段段推过来。代码形态的变化只有两处:响应变成可迭代对象;每个 chunk 里拿到的是增量而不是全文

图③ 非流式与流式:一次性寄回还是一句句念
图③ 非流式与流式:一次性寄回还是一句句念
非流式流式
取正文choices[0].message.contentchoices[0].delta.contentdelta 是增量,不是累计
拿完整答案直接就是自己把所有增量拼接起来
token 账单usage 直接有需传 stream_options={"include_usage": True},在最后一个 chunk
首字延迟等全部生成完几百毫秒就能看到第一个字
⚠️ 流式的两个必踩的坑带 usage 的收尾 chunk 里 choices 是空数组。直接写 chunk.choices[0] 会抛 IndexError,必须先判空。
delta.content 可能是 None(例如只携带 role 或工具调用信息的 chunk)。不判空就拼接,会抛 TypeError
还有一个体验坑:打印时不 flush(),输出会被缓冲住,打字机效果白做。

2.5 多轮对话:messages 是怎么长大的

把铁律落到具体的列表变化上,一轮一轮看:

图④ 多轮对话中 messages 是怎么长大的
图④ 多轮对话中 messages 是怎么长大的
时刻messages 条数内容
第 1 轮发出前2system 人设 + user 第一个问题
第 1 轮收到后3追加 assistant 第一个回答——这一步要你自己写代码做
第 2 轮发出前4再追加 user 第二个问题,整个列表一起发出
第 2 轮收到后5追加 assistant 第二个回答
由此推出三个必然结论越聊越贵:每轮都要把全部历史作为输入重发,prompt_tokens 随轮数线性增长。
越聊越慢:输入变长,处理时间也变长。
迟早会爆:历史总长度超过模型上下文长度就会报错。所以真实应用必须做历史裁剪——保留 system + 最近 N 轮,或把更早的历史压缩成摘要。

2.6 错误处理:哪些该重试,哪些重试一万次也没用

把错误分成两类,是写健壮客户端的第一步:

类型典型错误该怎么办
瞬时故障(该重试)429 限流、超时、连接失败、5xx 服务端错误指数退避 + 随机抖动后重试;服务端给了 Retry-After 就优先听它的
确定性错误(别重试)401 鉴权失败、404 模型名错、400 参数错、余额不足立刻抛出。重试只会浪费时间、刷爆日志,甚至触发风控
✅ 退避为什么要加随机抖动 如果一批请求同时被限流,又都按「1s、2s、4s」退避,它们会在同一毫秒集体重来,把服务再打垮一次,形成雪崩。
加一个小的随机量(如 0~0.5 秒)把重试时刻打散,这一行代码的价值在高并发下非常高。

2.7 上下文管理:把「越聊越贵」算成具体数字

2.5 节推出了三个必然结论,但「越聊越贵」到底贵多少?不算清楚就不会真去做裁剪。拿一组典型参数推演一遍(代码与完整输出见 4.5 节):system 120 token、每轮提问 60、每轮回答 220。

轮次本轮输入 token占 4096 上下文状态
第 1 轮1804.4%轻松
第 5 轮130031.7%开始有感觉
第 10 轮270065.9%已经吃掉三分之二
第 15 轮4100100.1%❌ 爆了
第 20 轮5500134.3%早已无法请求

这里最关键的认识是:输入 token 是线性增长的,不是常数。第 20 轮的输入是第 1 轮的 30.6 倍。很多人估算成本时拿单轮用量乘轮数,结果真实账单出来相差十几倍。

三种裁剪策略的总账对比

同样聊 20 轮,三种做法的累计输入 token:

策略累计输入第 20 轮输入是否爆上下文代价
全量历史568005500❌ 第 15 轮爆信息不丢,但贵且跑不完
滑动窗口(留最近 3 轮)187201020✅ 安全省 67%,但早期信息直接丢失
旧历史压缩成摘要211201170✅ 安全省 63%,保住了早期要点
✅ 怎么选:看业务要不要「记得久」滑动窗口——实现最简单、最省钱,适合单轮问答为主、上下文依赖弱的场景(客服 FAQ、搜索问答)。缺点很直白:用户翻回去问第一轮的事,它已经忘了。
摘要压缩——贵一点(而且生成摘要本身还要再调一次模型),但适合长程任务助手、多轮需求澄清这类必须记得早期约定的场景。
system 永远不裁——无论哪种策略,第一条 system 都要原封不动保留,否则模型会当场失忆人设与格式要求。
⚠️ 裁剪时不要把一轮对话拦腰切断 user 和它对应的 assistant 是一对,要删就成对地删。只删了 assistant 而留下孤零零的 user,模型会看到两条连着的用户提问,行为变得很奇怪,有些服务端还会直接报 400。
同理,裁剪应该从最早的一轮开始删,而不是从中间挖一块走。

2.8 让输出能被代码直接读

真实业务里,模型的输出很少是给人看的,大多数时候要被下一段代码解析。而模型天生爱加开场白——「好的,以下是您要的 JSON:」,再包一层 ```json 代码围栏,json.loads() 直接报错。

治这个问题有三层手段,优先级从上到下

手段做法可靠性
① 结构化输出参数response_format 让服务端约束输出为 JSON最高,但各家支持度不一,兼容服务可能直接忽略这个参数
② 提示词约束system 里写死「只输出 JSON,不要任何解释文字和代码围栏」,并给出示例中等,优点是所有服务都能用
③ 客户端傅佐解析前先剥掉代码围栏、提取第一个 { 到最后一个 } 的片段必须有——前两层都会偶尔失效
⛔ 第③层不是可选项 就算用了结构化输出参数,解析代码也必须包 try/except,并在失败时把原始输出记进日志
不记原始输出的后果是:线上偶发解析失败,你手里只有一个 JSONDecodeError 堆栈,永远不知道模型当时到底吐了什么,连复现都做不到。

2.9 并发与限流:批量任务的第一道坑

单次调用跑通之后,第一个真实需求往往是「把一万条数据跑一遍」。这时候天真的写法是 for 循环里直接开一堆线程,结果就是成片 429

限流通常不只一个维度,各家的命名不同但套路一致:

限制维度含义撞到了怎么办
并发数同一时刻未返回的请求个数上限信号量(Semaphore)固定并发度,而不是有多少任务开多少线程
请求频率每分钟请求次数上限控制发送节奏,而不是只控并发数
token 吞吐每分钟 token 总量上限长文本任务会先撞这个——请求数不多却已限流,很容易误判
✅ 批量任务的四条实用约束并发度从小开始调(比如 4),观察 429 比例再往上加,不要一上来就拉满。
重试要和限流配合:撞到 429 先听 Retry-After,没有才用指数退避,并且退避期间要把并发度降下来,否则重试流量会把自己再死一遍。
结果要随跑随落盘,不要攒在内存里最后统一写——跑到 90% 挂掉,前面的钱全白花。
要可断点续跑:给每条任务一个稳定 id,启动时跳过已完成的。批量任务中途挂掉是常态,不是意外。
⚠️ 一个很容易被忽略的乘积效应 SDK 自带重试(如默认 max_retries=2)会和你自己写的重试叠加成乘积:你以为最多重试 3 次,实际发出去九次请求。
在限流场景下这会直接把配额烧光。自己接管重试时,把 SDK 的 max_retries 显式设为 0

03最小代码:跑通第一次调用

五步,二十行,换任何一家服务只改两个字符串

剥掉所有业务之后,一次完整调用只有五步。对着代码里的编号看:

① 建 clientapi_key 从环境变量读,base_url 指定服务
② 摞 messagessystem 定规则,user 提问题
③ 发请求chat.completions.create
④ 取正文choices[0].message.content
⑤ 查两个字段finish_reason 与 usage
完成一次一问一答结束
min_call.py —— 最小可运行的一次调用可直接运行
"""最小可运行的一次大模型调用:OpenAI 兼容协议。

换任何一家 OpenAI 兼容的服务,只改 base_url 和 model 两处,
下面的业务代码一个字都不用动。

运行前先配好环境变量:
    export LLM_API_KEY="你的key"
    export LLM_BASE_URL="https://api.deepseek.com"
    export LLM_MODEL="deepseek-flash"
依赖:pip install "openai>=1.0"
"""
import os

from openai import OpenAI

# ① 密钥只从环境变量读,绝不硬编码进源码
client = OpenAI(
    api_key=os.environ["LLM_API_KEY"],
    base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
)

# ② messages 是一个列表,顺序就是对话发生的顺序
messages = [
    {"role": "system", "content": "你是一个简洁的中文助手,回答不超过两句话。"},
    {"role": "user", "content": "用一句话说明什么是大语言模型。"},
]

# ③ 发请求。temperature 控制随机性,max_tokens 给输出长度封顶
response = client.chat.completions.create(
    model=os.environ.get("LLM_MODEL", "deepseek-flash"),
    messages=messages,
    temperature=0.7,
    max_tokens=200,
)

# ④ 取正文:choices 是个数组,默认只生成 1 条,所以取 [0]
print("回复:", response.choices[0].message.content)

# ⑤ 两个必看字段:结束原因和 token 账单
print("结束原因:", response.choices[0].finish_reason)
print("token 用量: 输入 %d + 输出 %d = %d"
      % (response.usage.prompt_tokens,
         response.usage.completion_tokens,
         response.usage.total_tokens))
换一家服务,只改两个字符串 base_urlmodel——其余代码一个字都不用动。这正是 OpenAI 兼容协议的全部意义。
换成本地的 Ollama 就是 base_url="http://127.0.0.1:11434/v1",换成别家云服务就填别家的地址。业务逻辑与供应商解耦,是接入阶段最该守住的东西。
⚠️ API-KEY 一律走环境变量 代码里用 os.environ["LLM_API_KEY"] 读取,不要硬编码进源码,更不要连同 .env 一起提交到 Git。
os.environ[...] 而不是 os.environ.get(...) 是故意的:缺变量时立刻抛 KeyError,好过带着 None 跑出一个含糊的 401。
✅ 第⑤步不是凑数的 finish_reasonlength 就说明答案被 max_tokens 截断了——这是最隐蔽的线上事故:程序不报错,入库的却是半句话。
usage 是你唯一能实时看到的成本信号。第一天就把它打出来,比月底看账单强。

04完整案例:流式 · 重试 · 多轮

三个案例分别解决体验、稳定性、记忆三件事

4.1 流式输出:让字一个个吐出来

非流式调用要等模型把整段话生成完才返回,长答案可能等十几秒,用户会以为卡死了。流式把首字延迟压到几百毫秒。

stream_call.py —— 流式输出,含 usage 与判空处理可直接运行
"""流式输出:让字一个个吐出来,而不是等十几秒蹦出一整段。

关键差别只有两处:
  请求时加 stream=True;响应从「一个对象」变成「一串 chunk」。
运行前配置 LLM_API_KEY / LLM_BASE_URL / LLM_MODEL 三个环境变量。
"""
import os
import sys

from openai import OpenAI

client = OpenAI(
    api_key=os.environ["LLM_API_KEY"],
    base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
)

# stream_options 让最后一个 chunk 带上 usage,否则流式下拿不到 token 账单
stream = client.chat.completions.create(
    model=os.environ.get("LLM_MODEL", "deepseek-flash"),
    messages=[{"role": "user", "content": "用三句话讲讲 Transformer 为什么快。"}],
    temperature=0.7,
    stream=True,
    stream_options={"include_usage": True},
)

pieces = []      # 攒完整答案,落库/写进下一轮 messages 都要用它
usage = None
finish_reason = None

for chunk in stream:
    # 带 usage 的收尾 chunk 里 choices 是空数组,先判空再取下标
    if chunk.usage is not None:
        usage = chunk.usage
    if not chunk.choices:
        continue

    choice = chunk.choices[0]
    # delta 是「增量」:本次新增的那几个字,不是累计值
    piece = choice.delta.content
    if piece:
        pieces.append(piece)
        sys.stdout.write(piece)   # 边收边打印,这就是打字机效果
        sys.stdout.flush()        # 不 flush 会被缓冲住,白做流式
    if choice.finish_reason:
        finish_reason = choice.finish_reason

print("\n" + "-" * 40)
answer = "".join(pieces)
print("完整答案长度:", len(answer))
print("结束原因:", finish_reason)
if usage:
    print("token 用量: 输入 %d + 输出 %d" % (usage.prompt_tokens, usage.completion_tokens))
else:
    print("token 用量: 本次未随流返回")
这段代码里三处不能省的防御if chunk.usage is not None —— 收尾 chunk 才带账单;
if not chunk.choices: continue —— 带 usage 的收尾 chunk 里 choices 是空数组,不判空直接取 [0]IndexError
if piece: —— delta.content 可能是 None,不判空拼接会 TypeError
再加一个 sys.stdout.flush():不 flush 的话输出被缓冲,打字机效果白做。

注意 pieces 这个列表:流式下你必须自己把增量拼成完整答案,因为落库、写进下一轮 messages 都要用它。只顾着打印不攒起来,是流式改造时最常见的疏漏。

4.2 重试封装:让网络抖动不再毁掉整个任务

真实环境里 429 限流、超时、5xx 都是常态。没有重试的批处理脚本,跑到一半失败是迟早的事。

robust_call.py —— 指数退避 + 随机抖动 + 尊重 Retry-After可直接运行
"""带重试的调用封装:把「网络抖一下就整个任务失败」这件事解决掉。

三条工程原则写进了代码:
  1. 只重试「重试了可能会好」的错误,配额不足、参数写错重试一万次也没用;
  2. 指数退避 + 随机抖动,避免一批请求在同一毫秒集体重来把服务打垮;
  3. 尊重服务端给的 Retry-After,它比你的猜测准。
运行前配置 LLM_API_KEY / LLM_BASE_URL / LLM_MODEL。
"""
import os
import random
import time

from openai import (APIConnectionError, APITimeoutError, InternalServerError,
                    OpenAI, RateLimitError)

# 这几类是「瞬时故障」,退避后重试有意义
RETRYABLE = (RateLimitError, APITimeoutError, APIConnectionError, InternalServerError)

client = OpenAI(
    api_key=os.environ["LLM_API_KEY"],
    base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
    timeout=60.0,      # 单次请求超时,不设的话可能挂死几分钟
    max_retries=0,     # 关掉 SDK 自带重试,避免和下面的逻辑叠加成指数级请求
)


def retry_after_seconds(err):
    """服务端在 429 响应头里给的等待时间,拿得到就优先用它。"""
    response = getattr(err, "response", None)
    if response is None:
        return None
    value = response.headers.get("retry-after")
    if not value:
        return None
    try:
        return float(value)
    except ValueError:
        return None


def chat(messages, model=None, max_attempts=5, base_delay=1.0, **kwargs):
    """调用模型,失败时按指数退避重试;全部失败则把最后一个异常抛出去。"""
    model = model or os.environ.get("LLM_MODEL", "deepseek-flash")
    last_error = None

    for attempt in range(1, max_attempts + 1):
        try:
            response = client.chat.completions.create(
                model=model, messages=messages, **kwargs)
            # 被长度截断时提醒一句,否则容易把半句话当完整答案入库
            if response.choices[0].finish_reason == "length":
                print("[warn] 输出被 max_tokens 截断,答案可能不完整")
            return response
        except RETRYABLE as err:
            last_error = err
            if attempt == max_attempts:
                break
            # 指数退避:1s、2s、4s、8s……再叠一个随机抖动打散并发
            delay = retry_after_seconds(err) or base_delay * (2 ** (attempt - 1))
            delay += random.uniform(0, 0.5)
            print("[retry] 第 %d 次失败(%s),%.1fs 后重试"
                  % (attempt, type(err).__name__, delay))
            time.sleep(delay)
        except Exception as err:
            # 参数错误、鉴权失败、余额不足等,重试没有意义,直接抛
            print("[fatal] 不可重试的错误:", type(err).__name__, err)
            raise

    raise RuntimeError("重试 %d 次仍失败" % max_attempts) from last_error


if __name__ == "__main__":
    resp = chat(
        [{"role": "user", "content": "一句话解释什么叫指数退避。"}],
        temperature=0.3,
        max_tokens=120,
    )
    print(resp.choices[0].message.content)
设计点为什么这么写
max_retries=0关掉 SDK 自带重试。不关的话会和自己的重试逻辑叠加,5×5 变成 25 次请求
RETRYABLE 元组只重试限流、超时、连接失败、5xx。鉴权失败、参数错误、余额不足直接抛,重试没有意义
优先用 Retry-After服务端明确告诉你等多久时,它比任何猜测都准
退避 + 抖动1s、2s、4s、8s 再叠 0~0.5s 随机量,避免一批请求同时重来造成雪崩
timeout=60.0不设超时的话,一个卡住的请求能挂死几分钟
截断告警finish_reason == "length" 时打 warn,避免半句话被当成完整答案
⚠️ 重试不是万能药,先看清错误码401 鉴权失败重试一万次也不会变成 200。见到报错先分类:是「等一会儿可能会好」,还是「不改代码永远不会好」
盲目对所有异常重试,会把一个一眼能看出的配置错误,拖成半小时的诡异超时。

4.3 多轮对话:把「记忆」手工搬运出来

这个案例把前两个案例合起来,再加上本讲铁律的正面实现:每轮把完整 messages 重新发过去

chat_cli.py —— 命令行多轮对话,含历史裁剪可直接运行
"""命令行多轮对话:一个能真正聊起来的最小完整程序。

它演示三件在最小调用里看不到的事:
  1. 多轮记忆靠的是「把历史原样带回去」,接口本身不记事;
  2. 上下文会被撑爆,所以要有裁剪策略;
  3. 流式 + 重试要能叠在一起用。
运行前配置 LLM_API_KEY / LLM_BASE_URL / LLM_MODEL,然后 python3 chat_cli.py。
输入 exit 退出,输入 clear 清空记忆。
"""
import os
import sys

from robust_call import chat

SYSTEM_PROMPT = "你是一个中文技术助手,回答简洁、准确,不确定时直说不知道。"

# 只保留最近 N 轮,防止 messages 无限膨胀把上下文和钱一起烧光
MAX_TURNS = 8


def trim(messages, max_turns=MAX_TURNS):
    """裁剪历史:system 永远留着,其余只保留最近 max_turns 轮问答。"""
    system = messages[:1]
    body = messages[1:]
    keep = max_turns * 2          # 一轮 = 一条 user + 一条 assistant
    if len(body) > keep:
        body = body[-keep:]
        # 裁剪后第一条必须是 user,否则有些服务会拒绝
        while body and body[0]["role"] != "user":
            body.pop(0)
    return system + body


def stream_answer(messages):
    """流式打印回答,并返回完整文本,便于写回历史。"""
    response = chat(messages, temperature=0.7, stream=True)
    pieces = []
    for chunk in response:
        if not chunk.choices:
            continue
        piece = chunk.choices[0].delta.content
        if piece:
            pieces.append(piece)
            sys.stdout.write(piece)
            sys.stdout.flush()
    print()
    return "".join(pieces)


def main():
    if not os.environ.get("LLM_API_KEY"):
        print("请先设置环境变量 LLM_API_KEY")
        return
    messages = [{"role": "system", "content": SYSTEM_PROMPT}]
    print("开始对话(exit 退出,clear 清空记忆)")

    while True:
        try:
            user_input = input("\n你: ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\n再见")
            return
        if not user_input:
            continue
        if user_input == "exit":
            print("再见")
            return
        if user_input == "clear":
            messages = [{"role": "system", "content": SYSTEM_PROMPT}]
            print("记忆已清空")
            continue

        messages.append({"role": "user", "content": user_input})
        messages = trim(messages)

        print("助手: ", end="")
        answer = stream_answer(messages)
        # 关键一步:把模型的回答也追加进历史,下一轮它才知道自己说过什么
        messages.append({"role": "assistant", "content": answer})


if __name__ == "__main__":
    main()

它演示了三件在最小调用里看不到的事:

1记忆靠手工搬运

messages.append({"role": "assistant", ...}) 这一行如果漏掉,模型下一轮就不知道自己说过什么,对话会变得前言不搭后语。

2历史必须裁剪

trim() 保留 system + 最近 8 轮。不裁剪的话,token 线性增长,先变贵变慢,最后直接超出上下文长度报错。

3能力可以叠加

它直接 from robust_call import chat,于是白拿了重试能力,同时又开了流式。薄封装的好处在这里显出来。

裁剪里一个容易忽略的细节 裁完之后第一条必须是 user,所以代码里有个 while body and body[0]["role"] != "user": body.pop(0)
如果裁剪后第一条是 assistant,有些服务会直接拒绝请求——对话历史必须是合法的一问一答交替结构。

4.4 算清楚裁剪到底省了多少钱

4.3 里的 chat_cli.py 已经做了裁剪,但裁与不裁究竟差多少?下面这份脚本不调任何模型,只做账本推演,不需要 API Key 就能跑:

token_cost.py —— 多轮成本增长与三种裁剪策略对比可直接运行
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""多轮对话的成本增长与三种历史裁剪策略对比。

只用标准库,直接 python3 token_cost.py 就能跑,不需要 API Key。
它不调用任何模型,只做账本推演——目的是让「越聊越贵」变成具体数字。

⚠️ token 数用固定估算值代替真实 tokenizer,只为演示增长趋势。
   真实用量必须读响应里的 usage 字段。
"""

# 每轮对话的估算 token 数(演示用的固定值)
SYSTEM_TOKENS = 120
USER_TOKENS = 60
ASSISTANT_TOKENS = 220

TURNS = 20
CONTEXT_LIMIT = 4096          # 假设模型上下文上限
KEEP_RECENT = 3               # 滑动窗口保留最近几轮
SUMMARY_TOKENS = 150          # 摘要压缩后的固定开销


def full_history(turns):
    """策略一:不裁剪,每轮把全部历史重发。"""
    prompt = SYSTEM_TOKENS
    rows = []
    for t in range(1, turns + 1):
        prompt += USER_TOKENS                      # 本轮提问
        rows.append((t, prompt, ASSISTANT_TOKENS))
        prompt += ASSISTANT_TOKENS                 # 模型回答追加进历史
    return rows


def sliding_window(turns, keep=KEEP_RECENT):
    """策略二:只保留 system + 最近 keep 轮。"""
    rows = []
    for t in range(1, turns + 1):
        kept = min(t - 1, keep)                    # 已完成的历史轮数
        prompt = SYSTEM_TOKENS + kept * (USER_TOKENS + ASSISTANT_TOKENS) + USER_TOKENS
        rows.append((t, prompt, ASSISTANT_TOKENS))
    return rows


def summarize_old(turns, keep=KEEP_RECENT):
    """策略三:最近 keep 轮保留原文,更早的历史压缩成一段摘要。"""
    rows = []
    for t in range(1, turns + 1):
        done = t - 1
        kept = min(done, keep)
        has_summary = done > keep
        prompt = SYSTEM_TOKENS
        if has_summary:
            prompt += SUMMARY_TOKENS
        prompt += kept * (USER_TOKENS + ASSISTANT_TOKENS) + USER_TOKENS
        rows.append((t, prompt, ASSISTANT_TOKENS))
    return rows


def report(name, rows, limit=CONTEXT_LIMIT):
    total_prompt = sum(r[1] for r in rows)
    total_out = sum(r[2] for r in rows)
    last_prompt = rows[-1][1]
    overflow = next((r[0] for r in rows if r[1] > limit), None)
    print("【%s】" % name)
    print("   第 1 轮输入 %d token,第 %d 轮输入 %d token(涨了 %.1f 倍)"
          % (rows[0][1], len(rows), last_prompt, last_prompt / rows[0][1]))
    print("   累计输入 %d token,累计输出 %d token" % (total_prompt, total_out))
    if overflow:
        print("   ❌ 第 %d 轮就超过 %d 的上下文上限" % (overflow, limit))
    else:
        print("   ✅ %d 轮下来始终未超过 %d 的上下文上限" % (len(rows), limit))
    print()
    return total_prompt


def main():
    print("=" * 70)
    print("设定:system %d token,每轮提问 %d token,每轮回答 %d token,共 %d 轮"
          % (SYSTEM_TOKENS, USER_TOKENS, ASSISTANT_TOKENS, TURNS))
    print("=" * 70)
    print()

    print("一、不裁剪时,每轮输入 token 是怎么涨的")
    print("%-8s %-14s %s" % ("轮次", "本轮输入", "占上下文上限"))
    print("-" * 46)
    rows = full_history(TURNS)
    for t, prompt, _ in rows:
        if t in (1, 2, 3, 5, 10, 15, 20):
            bar = "█" * int(prompt / CONTEXT_LIMIT * 30)
            print("%-8d %-14d %5.1f%% %s" % (t, prompt, prompt / CONTEXT_LIMIT * 100, bar))
    print()
    print("注意这是线性增长,不是常数——第 20 轮的输入是第 1 轮的十几倍。")
    print()

    print("=" * 70)
    print("二、三种策略的总账对比")
    print("=" * 70)
    a = report("策略一:全量历史", full_history(TURNS))
    b = report("策略二:滑动窗口(保留最近 %d 轮)" % KEEP_RECENT, sliding_window(TURNS))
    c = report("策略三:旧历史压缩成摘要", summarize_old(TURNS))

    print("=" * 70)
    print("三、结论")
    print("=" * 70)
    print("累计输入 token:全量 %d / 滑窗 %d / 摘要 %d" % (a, b, c))
    print("滑窗相比全量省了 %.0f%%,摘要省了 %.0f%%"
          % ((1 - b / a) * 100, (1 - c / a) * 100))
    print()
    print("滑窗最省,但它把更早的信息直接丢了——用户翻回去问前面的事就答不上来。")
    print("摘要贵一点,但保住了早期信息的要点。")
    print("选哪个取决于业务:客服问答适合滑窗,长程任务助手适合摘要。")


if __name__ == "__main__":
    main()

运行输出

token_cost.py 的实际运行结果
======================================================================
设定:system 120 token,每轮提问 60 token,每轮回答 220 token,共 20 轮
======================================================================

一、不裁剪时,每轮输入 token 是怎么涨的
轮次       本轮输入           占上下文上限
----------------------------------------------
1        180              4.4% █
2        460             11.2% ███
3        740             18.1% █████
5        1300            31.7% █████████
10       2700            65.9% ███████████████████
15       4100           100.1% ██████████████████████████████
20       5500           134.3% ████████████████████████████████████████

注意这是线性增长,不是常数——第 20 轮的输入是第 1 轮的十几倍。

======================================================================
二、三种策略的总账对比
======================================================================
【策略一:全量历史】
   第 1 轮输入 180 token,第 20 轮输入 5500 token(涨了 30.6 倍)
   累计输入 56800 token,累计输出 4400 token
   ❌ 第 15 轮就超过 4096 的上下文上限

【策略二:滑动窗口(保留最近 3 轮)】
   第 1 轮输入 180 token,第 20 轮输入 1020 token(涨了 5.7 倍)
   累计输入 18720 token,累计输出 4400 token
   ✅ 20 轮下来始终未超过 4096 的上下文上限

【策略三:旧历史压缩成摘要】
   第 1 轮输入 180 token,第 20 轮输入 1170 token(涨了 6.5 倍)
   累计输入 21120 token,累计输出 4400 token
   ✅ 20 轮下来始终未超过 4096 的上下文上限

======================================================================
三、结论
======================================================================
累计输入 token:全量 56800 / 滑窗 18720 / 摘要 21120
滑窗相比全量省了 67%,摘要省了 63%

滑窗最省,但它把更早的信息直接丢了——用户翻回去问前面的事就答不上来。
摘要贵一点,但保住了早期信息的要点。
选哪个取决于业务:客服问答适合滑窗,长程任务助手适合摘要。
✅ 输出里三个值得记住的数第 20 轮的输入是第 1 轮的 30.6 倍——拿单轮用量乘轮数去估成本,会错到离谱。
不裁剪的话第 15 轮就撞破 4096 上下文——不是贵不贵的问题,是直接报错跑不下去。
滑动窗口省 67%,摘要省 63%——两者省的钱差不多,真正的差别在“早期信息丢不丢”,按业务选,不按价格选。
⚠️ 脚本里的 token 数是估算值,不是真实计量 它用固定常量代替真实 tokenizer,目的是看清增长趋势,不是拿来对账单
真实用量只有一个权威来源:响应里的 usage 字段。上生产后把每次调用的 prompt_tokenscompletion_tokens 记进日志,才能真正看清钱花在哪里。

4.5 四个案例的关系

案例解决的问题关键 API可以省吗
min_call.py跑通chat.completions.create入门必看
stream_call.py体验stream=True + delta批处理可省,交互式不可省
robust_call.py稳定性异常分类 + 退避上生产不可省
chat_cli.py记忆messages 追加 + 裁剪单轮任务可省,对话不可省
token_cost.py成本预估不调模型,纯账本推演报预算前不可省

05骨架模板:一个能换供应商的客户端

把「连哪家、怎么重试、怎么流式」收进一个类,业务只看见两个方法

前面三个案例各自解决一件事,但散着放进项目里会重复得厉害:每个调用点都要写一遍重试、每次换模型都要全局搜索替换。这份骨架把它们收口:上层业务只看见 ask()ask_stream()

llm_client_skeleton.py —— 通用客户端骨架,只改 TODO 处可复用模板
"""通用 LLM 客户端骨架:换供应商只改配置,不改业务代码。

把「连哪家、用哪个模型、怎么重试、怎么流式」全收进一个类,
上层业务只看见 ask() 和 ask_stream() 两个方法。
复制后改 5 处 TODO 即可。
"""
import os
import random
import time

from openai import (APIConnectionError, APITimeoutError, InternalServerError,
                    OpenAI, RateLimitError)

RETRYABLE = (RateLimitError, APITimeoutError, APIConnectionError, InternalServerError)

# TODO 1:登记你要用的供应商。base_url 与模型名都以各家官方文档为准。
#         凡是标称「OpenAI 兼容」的服务,都能塞进这张表。
PROVIDERS = {
    "deepseek": {
        "base_url": "https://api.deepseek.com",
        "key_env": "DEEPSEEK_API_KEY",
        "model": "deepseek-flash",
    },
    "zhipu": {
        "base_url": "https://open.bigmodel.cn/api/paas/v4/",
        "key_env": "ZHIPU_API_KEY",
        "model": "glm-5.3-flash",
    },
    "local": {                       # Ollama / vLLM 等本地服务
        "base_url": "http://127.0.0.1:11434/v1",
        "key_env": "LOCAL_API_KEY",  # 本地服务通常不校验,给个占位值即可
        "model": "qwen3:8b",
    },
}


class LLMClient:
    """一层薄封装:统一重试、统一超时、统一默认参数。"""

    def __init__(self, provider="deepseek", model=None, timeout=60.0):
        conf = PROVIDERS[provider]
        api_key = os.environ.get(conf["key_env"])
        if not api_key:
            raise RuntimeError("缺少环境变量 %s" % conf["key_env"])

        self.model = model or conf["model"]
        self.client = OpenAI(
            api_key=api_key,
            base_url=conf["base_url"],
            timeout=timeout,
            max_retries=0,          # 重试逻辑自己管,避免叠加
        )
        # TODO 2:按业务改默认采样参数。抽取/分类类任务把 temperature 压到 0。
        self.defaults = {"temperature": 0.7, "max_tokens": 1024}

    # ----------------------------------------------------------------- 内部
    def _call(self, messages, max_attempts=5, **kwargs):
        params = dict(self.defaults)
        params.update(kwargs)
        last_error = None

        for attempt in range(1, max_attempts + 1):
            try:
                return self.client.chat.completions.create(
                    model=self.model, messages=messages, **params)
            except RETRYABLE as err:
                last_error = err
                if attempt == max_attempts:
                    break
                delay = 1.0 * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
                time.sleep(delay)
            except Exception:
                raise      # 鉴权、参数、余额类错误直接抛,重试无意义
        raise RuntimeError("重试 %d 次仍失败" % max_attempts) from last_error

    # ----------------------------------------------------------------- 对外
    def ask(self, prompt, system=None, **kwargs):
        """一问一答,返回纯文本。"""
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": prompt})
        response = self._call(messages, **kwargs)
        return response.choices[0].message.content

    def ask_stream(self, prompt, system=None, **kwargs):
        """生成器版本:逐段 yield,前端可以边收边渲染。"""
        messages = []
        if system:
            messages.append({"role": "system", "content": system})
        messages.append({"role": "user", "content": prompt})
        stream = self._call(messages, stream=True, **kwargs)
        for chunk in stream:
            if not chunk.choices:
                continue
            piece = chunk.choices[0].delta.content
            if piece:
                yield piece


if __name__ == "__main__":
    # TODO 3:换成你要用的那一家
    llm = LLMClient(provider="deepseek")

    # TODO 4:换成你的提示词
    print(llm.ask("用一句话解释什么是上下文长度。", system="你是简洁的中文助手。"))

    # TODO 5:需要打字机效果时改用流式
    # for piece in llm.ask_stream("讲讲流式输出的好处。"):
    #     print(piece, end="", flush=True)
✅ 复制后你只需要改这五处 TODO 1PROVIDERS 里登记你要用的服务(base_url / key 环境变量名 / 默认模型)· TODO 2 按业务改默认采样参数 · TODO 3 选用哪一家 · TODO 4 换提示词 · TODO 5 需要打字机效果时改用流式。其余代码不用动。

5.1 为什么值得多写这一层

收益没有这层会怎样
换供应商只改一个字符串每个调用点都写着 base_url,换一家要全局搜索替换,漏一处就是线上事故
重试策略只有一处有的调用点写了重试、有的忘了;出问题时排查要翻遍全项目
默认参数统一有的地方 temperature=0.7、有的地方忘了传,同一业务表现不一致
缺 key 时立刻报错带着 None 一路跑到服务端,拿回一个含糊的 401
流式与非流式同一套入口两套代码各写一遍异常处理,改一处忘一处

PROVIDERS 里特意留了一条 local——指向本地 Ollama。本地部署的服务同样提供 OpenAI 兼容接口,所以开发时用云端 API、上线切内网模型,业务代码一行都不用改。这正是下一个模块(私有化部署)能顺利接上的原因。

5.2 上生产之前还要补的三件事

1日志与埋点

每次调用记下模型名、usage、耗时、finish_reason。没有这些数据,成本和质量都无从优化。

2并发与限流

各家都有并发上限。批量任务要用信号量控制并发数,否则一上量就被 429 打回来。

3超时分级

交互式接口超时设短些、快速失败;批处理任务可以放宽。一个全局 60 秒不适合所有场景。

⚠️ 别把这层封装做厚 这层的职责边界很清楚:只管连接、重试、默认参数。一旦开始往里塞提示词拼接、业务判断、结果解析,它就会变成一个谁都不敢改的上帝类。
提示词和业务逻辑应该留在各自的业务模块里。薄,才换得动。

5.3 每次调用该记哪些字段

5.2 说了「要埋点」,这里把字段列到可以直接拄进表结构的程度。原则只有一条:出事时你想知道什么,就提前记什么

字段来源不记的后果
request_id自己生成(uuid)用户报障时无法把一次投诉定位到一次具体调用
model响应里的response.model被静默降级到轻量版都不知道,效果变差查不出原因
prompt_tokens / completion_tokensusage成本完全失控;也无法判断是输入胀了还是输出啰嗦
finish_reasonchoices[0]截断事故零告警——半句话入库且永不曝露
耗时 / 首字耗时自己计时只有「感觉变慢了」,没有可对比的基线
重试次数 / 最终状态重试逻辑限流情况被重试掩盖,发现不了已经靠近配额天花板
解析失败时的原始输出异常分支只剩一个 JSONDecodeError 堆栈,连复现都做不到
⛔ 日志里不能出现的两样东西api_key——包括报错时把整个请求对象 repr() 打进日志这种间接泄露。打日志前先把凭证字段抹掉。
未脱敏的用户原文——对话内容里可能含手机号、身份证、病历。要么只记长度与哈希,要么脱敏后再落盘,并设定保留期。
一条很实用的经验finish_reason != "stop" 的比例做成一个监控指标,超过阈值就告警
它能同时捕捉两类问题:max_tokens 设小了,以及提示词让模型写得越来越长。这两件事都不会抛异常,只会静静地损害质量。

06易错点汇总

按「环境与鉴权 / messages / 参数 / 流式 / 错误与成本」五类归并

⚠️ 一、环境与鉴权

  • SDK 版本太旧。 必须 openai>=1.0.0,旧版的调用写法完全不同,照着新文档写会各种报错。先 python3 -c "import openai; print(openai.__version__)" 确认。
  • base_url/v1 后缀写错。 各家不一致——有的要带、有的不带、有的带斜杠结尾。写错直接 404,且报错信息往往很含糊。照官方文档原样复制
  • 把 API-KEY 硬编码进源码。 一律 os.environ[...],且 .env 不要提交 Git。用 os.environ["K"] 而不是 .get("K"),缺变量时立刻 KeyError,好过带着 None 跑出含糊的 401。
  • curl 测试时把密钥明文粘进命令行。 它会留在 shell 历史里。命令里必须用变量
  • Key 与 endpoint 地域不匹配。 有的平台 API Key 按地域绑定,跨地域调用会返回 401 Incorrect API key provided、错误码 invalid_api_key——看着像 Key 失效,实际是地域错了。
  • 不先用 curl 验通就直接写代码。 报错时分不清是 key、base_url、模型名还是自己代码的问题。

⚠️ 二、messages 与多轮

  • 以为接口记得上一轮。 这是本讲铁律:接口无状态。第二轮只发新问题,模型对第一轮一无所知。
  • 忘了把 assistant 的回答追加回列表。 漏掉 messages.append({"role":"assistant", ...}),对话立刻前言不搭后语。
  • 不做历史裁剪。 token 随轮数线性增长,先变贵变慢,最后超出上下文长度直接报错。要保留 system + 最近 N 轮,或把更早历史压缩成摘要。
  • 裁剪后第一条是 assistant。 对话历史必须是合法的一问一答交替结构,裁完要把开头的非 user 消息弹掉,否则有些服务直接拒绝。
  • 把 system 写成一长串还每轮重复堆。 system 只需一条放在最前面,它整轮生效;重复堆只会白烧 token。

⚠️ 三、参数

  • temperaturetop_p 一起调。 官方明确建议只改其中一个。两个都动,行为难以预测,出了问题说不清是哪个造成的。
  • 判定类任务不把 temperature 压到 0。 每次结果都不一样,会误判成模型不稳定,其实是自己没锁随机性。
  • max_tokens 设得太小。 答案被截断,finish_reason 变成 length,而程序不报错——半句话被当完整答案入库,这是最隐蔽的线上事故。
  • 分不清上下文长度与最大输出。 这是两个数。上下文 1M 不代表能一次吐出 1M。
  • n 调大。 官方建议保持为 1 以降低成本——按所有候选的生成 token 总数计费。
  • 用了超过 4 个 stop 序列。 官方限制最多 4 个

⚠️ 四、流式

  • 直接写 chunk.choices[0]usage 的收尾 chunk 里 choices空数组,会抛 IndexError。必须先判空。
  • 不判 delta.content 是否为 None 有的 chunk 只携带 role 或工具调用信息,直接拼接会 TypeError
  • delta 当累计值。 它是增量,是本次新增的那几个字,不是到目前为止的全文。
  • 只顾打印不攒完整答案。 落库、写进下一轮 messages 都需要完整文本,必须自己拼。
  • 忘了 flush() 输出被缓冲住,打字机效果白做。
  • 流式下直接读 usage 需要传 stream_options={"include_usage": True},且只在最后一个 chunk 里。

⚠️ 五、错误处理与成本

  • 对所有异常一律重试。 401、404、400、余额不足重试一万次也不会变成 200,只会浪费时间、刷爆日志、甚至触发风控。先分类再决定
  • 忘了关掉 SDK 自带重试。 不设 max_retries=0,自带重试会和你的逻辑叠加成乘积,5×5 变 25 次请求。
  • 退避不加随机抖动。 一批被限流的请求会在同一毫秒集体重来,把服务再打垮一次,形成雪崩。
  • 不设 timeout 一个卡住的请求能挂死几分钟,拖垮整个批处理。
  • 批量任务有多少条就开多少线程。 必须用信号量固定并发度,从小(如 4)往上调。直接拉满的结果就是成片 429。
  • 退避期间不降并发度。 重试流量叠在原有流量上,把自己再死一遍。撞 429 时要同时降并发
  • 把限流当成只有「请求数」一个维度。 还有 token 吞吐限制——长文本任务请求数不多却已被限流,很容易误判成服务异常。
  • 结果攒在内存里最后统一写。 跑到 90% 挂掉,前面的钱全白花。随跑随落盘,并给每条任务稳定 id 以支持断点续跑
  • api_key 或未脱敏的用户原文打进日志。 包括报错时直接 repr() 整个请求对象这种间接泄露。对话内容里可能含手机号、身份证,要么只记长度与哈希,要么脱敏后再落盘。
  • 不读 usage 成本完全失控,出账单时才发现。第一天就把它打出来。
  • 不核对响应里的 model 字段。 别名、灰度、降级都会改写它,你以为在用旗舰版,实际被路由到轻量版。
  • 拿单轮用量乘轮数估成本。 多轮对话的输入 token 是线性增长的。实测推演:第 20 轮的输入是第 1 轮的 30.6 倍,累计输入 56800 token。按单轮估算会错到离谱。
  • 裁剪时把一轮对话拦腰切断。 user 和它对应的 assistant成对地删,并且从最早一轮开始删。留下孤零零的 user,模型行为会变得很奇怪,有些服务端直接报 400。
  • 裁剪时把 system 一起裁掉。 无论哪种策略,第一条 system 都要原封保留,否则模型当场失忆人设与格式要求。

⚠️ 六、结构化输出

  • 直接把模型输出丢给 json.loads() 模型爱加开场白和 ```json 代码围栏,直接解析必爆。解析前先剥围栏、取第一个 { 到最后一个 }
  • 以为传了 response_format 就万无一失。 各家支持度不一,有的兼容服务会直接忽略这个参数。客户端的容错解析永远不能省。
  • 解析失败时不记原始输出。 线上偶发解析失败,手里只有一个 JSONDecodeError 堆栈,连复现都做不到。失败分支必须把模型原文记进日志。
  • 只在提示词里写「输出 JSON」却不给示例。 给一个完整的目标格式示例,比写十句要求都管用。

07自测题

点击题目展开答案;能把这 15 题说清楚,这一讲就通了

一、协议与概念
什么是 OpenAI 兼容协议?它对开发者最大的价值是什么?

指各家模型服务采用与 OpenAI 相同的接口格式(往 /chat/completions 发带 modelmessages 的 JSON)。各家官方文档的说法基本一致:只需修改 API Key、base_url 和模型名称,就能把原有 OpenAI 代码迁移过来
最大价值是业务代码与供应商解耦——学一次通吃全行业,而且让「换模型」变成低成本操作。本地跑的 Ollama、vLLM 同样提供这套接口。

调用任何一家服务,需要准备哪四样东西?各自最容易出什么错?

base_url注意 /v1 后缀各家不一致,写错就 404)、api_key只从环境变量读)、model会改名会下线,以官方列表为准)、messages(对话消息列表)。
环境要求:Python 3.7.1 以上、OpenAI SDK 不低于 1.0.0——旧版写法完全不同,是新手最常见的报错来源。

接入一个新服务,正确的顺序是什么?为什么不能直接写代码?

先用 curl 验通,再写代码。 直接上代码的话,一旦报错你分不清是 key 错了、base_url 错了、模型名错了,还是自己代码写错了。curl 成功后把同样三个值填进 SDK,基本不会再出问题。
注意 curl 命令里必须用变量引用密钥,明文粘贴会留在 shell 历史里。

二、messages 与多轮
本讲的铁律是什么?它能解释哪些现象?

接口是无状态的——模型不记得上一轮。 多轮对话的记忆是假象,靠的是你每次把完整 messages 重新发过去。
它解释了:① 为什么越聊越贵(每轮都要把全部历史作为输入重发,prompt_tokens 线性增长);② 为什么越聊越慢;③ 为什么迟早会超出上下文长度报错;④ 为什么必须手动把 assistant 的回答追加回去。

三种 role 各是什么作用?assistant 消息是谁放进列表的?

system:设定人设、规则、输出格式,整轮对话生效,通常只有一条放在最前;user:本次提问;assistant:模型此前的回答。
assistant 消息必须由你手动追加回列表——接口不会帮你存。漏掉这一步,对话立刻前言不搭后语。

为什么必须做历史裁剪?裁剪时有个容易忽略的细节是什么?

因为不裁剪的话 token 随轮数线性增长,先变贵变慢,最后超出模型上下文长度直接报错。常见策略是保留 system + 最近 N 轮,或把更早历史压缩成摘要。
容易忽略的细节:裁剪后第一条必须是 user。对话历史必须是合法的一问一答交替结构,如果裁完开头是 assistant,有些服务会直接拒绝请求。

三、参数与响应
temperaturetop_p 分别是什么?为什么不要一起调?

temperature 是采样温度,取值 0~2,更高的值让输出更随机,更低的值让输出更聚焦、更确定top_p 是核采样,只考虑累计概率质量排在前 top_p 的 token,0.1 表示只考虑前 10% 概率质量。
官方明确建议「修改这个或 top_p,但不要两个都改」——两个都动行为难以预测。实践上固定 top_p 用默认值,只调 temperature。判定类任务压到 0。

响应里哪三个字段必须读?不读 finish_reason 会有什么后果?

choices[0].message.content(正文)、choices[0].finish_reason(结束原因)、usage(token 账单)。
finish_reasonlength 说明答案max_tokens 截断,而程序不会报错——半句话被当成完整答案入库,这是最隐蔽的线上事故。stop 是正常收尾,tool_calls 表示要调工具。

四、流式与错误处理
流式与非流式在代码上差别在哪?流式有哪两个必踩的坑?

差别两处:响应从一个对象变成可迭代的一串 chunk;正文从 message.content 变成 delta.content,而且 delta 是增量不是累计,要自己拼接成完整答案。
两个坑:① usage 的收尾 chunk 里 choices 是空数组,直接取 [0]IndexError;② delta.content 可能是 None,不判空拼接会 TypeError。另外流式下拿 usage 要传 stream_options={"include_usage": True}

哪些错误该重试、哪些不该?退避为什么要加随机抖动?为什么要关掉 SDK 自带重试?

该重试(瞬时故障):429 限流、超时、连接失败、5xx;服务端给了 Retry-After 就优先听它的。不该重试(确定性错误):401 鉴权失败、404 模型名错、400 参数错、余额不足——重试一万次也不会变成 200。
随机抖动:一批同时被限流的请求如果都按 1s、2s、4s 退避,会在同一毫秒集体重来,把服务再打垮一次形成雪崩。
关掉 SDK 自带重试max_retries=0):否则会和自己的重试逻辑叠加成乘积,5×5 变成 25 次请求。

五、上下文成本与结构化输出
多轮对话的输入 token 是怎么增长的?能不能拿单轮用量乘轮数估成本?

线性增长,不是常数——因为每轮都要把全部历史作为输入重发一遍。
实测推演(system 120、每轮提问 60、每轮回答 220):第 1 轮输入 180 token,第 20 轮 5500 token,涨了 30.6 倍,累计输入 56800 token。
所以绝对不能拿单轮乘轮数估算,那会错到离谱。

不做裁剪会出什么事?三种策略各自适合什么场景?

上面那组参数下,第 15 轮就撞破 4096 上下文上限——不是贵不贵的问题,是直接报错跑不下去。
全量历史:累计 56800 token,信息不丢但贵且跑不完;
滑动窗口(留最近 3 轮):18720 token,省 67%,适合客服 FAQ 这类上下文依赖弱的场景,缺点是早期信息直接丢;
摘要压缩:21120 token,省 63%,适合长程任务助手。
两者省的钱差不多,真正的差别在「早期信息丢不丢」,按业务选而不是按价格选

裁剪历史时有哪三个不能犯的错?

不能把 system 裁掉——模型会当场失忆人设与格式要求;
不能把一轮对话拦腰切断——user 与对应的 assistant 要成对地删,留下孤零零的 user 会让模型行为变异,有些服务端直接报 400;
要从最早一轮开始删,不是从中间挖一块走;并且裁完后第一条必须是 user

要让输出能被 json.loads() 直接读,要做哪三层?哪一层不能省?

结构化输出参数 response_format:可靠性最高,但各家支持度不一,兼容服务可能直接忽略
提示词约束system 写死「只输出 JSON」并给出完整示例,所有服务都能用;
客户端傅佐:剥代码围栏、取第一个 { 到最后一个 }
第③层不能省——前两层都会偶尔失效。而且解析失败时必须把模型原始输出记进日志,否则线上偶发失败连复现都做不到。

真实 token 用量到底该以什么为准?

只有一个权威来源:响应里的 usage 字段prompt_tokens / completion_tokens / total_tokens)。
估算脚本、字数折算、「1 汉字 = 1 token」都只能看趋势,不能对账单。流式下要拿 usage,还得传 stream_options={"include_usage": True},且它只在最后一个 chunk 里。

术语表

术语含义
OpenAI 兼容协议以 OpenAI 接口格式为事实标准;换供应商只需改 base_urlapi_key、模型名
base_url服务的网络访问点;注意 /v1 后缀各家不一致
api_key鉴权凭证,一律从环境变量读取,不硬编码、不提交 Git
messages对话消息列表,顺序即对话顺序;只追加不修改,是上下文的全部载体
role消息角色:system 定规则 / user 提问 / assistant 模型回答
temperature采样温度,0~2;越高越随机,越低越聚焦确定
top_p核采样;只考虑累计概率质量排前 top_p 的 token。与 temperature 不要同时调
max_tokens生成的最大 token 数;OpenAI 已标注弃用、改用 max_completion_tokens,多数兼容服务仍用前者
stop停止序列,最多 4 个;命中后停止生成,返回内容不含该序列
stream是否流式返回;为 true 时通过 SSE 边生成边推送
SSEServer-Sent Events,服务端单向推送的协议,流式输出的底层机制
delta流式 chunk 里的增量内容,不是累计全文,需自行拼接
stream_options流式选项;传 {"include_usage": true} 才能在收尾 chunk 拿到 token 账单
finish_reason结束原因:stop 正常 / length 被截断 / tool_calls 要调工具
usagetoken 账单:prompt_tokenscompletion_tokenstotal_tokens
指数退避重试间隔按 1s、2s、4s 倍增,并叠加随机抖动避免雪崩
Retry-After服务端在限流响应头里给出的建议等待时间,优先级高于自己的猜测
信号量 Semaphore控制同时未返回请求个数的并发闸门;批量任务的并发度靠它固定,而不是有多少任务开多少线程
token 吞吐限制每分钟 token 总量上限;长文本任务往往先撞它,请求数不多却已限流,容易误判为服务异常
断点续跑给每条任务稳定 id,重启时跳过已完成项;批量任务中途挂掉是常态而非意外
max_retriesSDK 自带重试次数;自己接管重试时必须显式设为 0,否则两层重试叠加成乘积
无状态接口不保存会话;多轮记忆靠每次重发完整 messages 实现
滑动窗口历史裁剪策略:只保留 system + 最近 N 轮;实测 20 轮场景下相比全量历史省 67% 输入 token
摘要压缩历史裁剪策略:旧对话压成一段摘要;比滑窗略贵(省 63%),但保住早期信息要点
response_format结构化输出参数,让服务端约束输出为 JSON;各家支持度不一,兼容服务可能直接忽略
prompt_tokensusage 里的输入计量;多轮对话中它随轮数线性增长,是成本失控的主因
前缀缓存相同请求前缀命中时输入价格大幅下降;把固定 system 放最前且保持不变就能持续命中
✅ 一句话收束本模块 这个模块走完了「从听说过到调得通」:先搞清模型在做什么(给下一个词算概率),再学会怎么挑(硬门槛 + 加权排序 + 自己的测试集),最后把它接进代码(OpenAI 兼容协议 + 流式 + 重试 + 多轮)。
手里有了一个能换供应商的客户端,后面的提示工程、私有化部署、应用开发,都是在这个地基上往上盖。