Skip to content
This page has been auto-translated and may contain errors.View in English

从代码中调用模型 ​

docs.scrimba.com

上一章我们把一组消息组装了出来。这一章要做的是把这组消息从 Python 发给真正的模型,并拿到回答。这一来一回,就是用这些模型做开发的最小完整闭环,你日后写的几乎每个功能都建立在这个基础之上。

你输入一段提示词,代码把它发送出去,文字就传了回来。这种体验会让人觉得模型就住在你的程序里。但事实并非如此,搞清楚它到底在哪里运行,能解释清楚成本、速度以及你会遇到的种种奇怪之处。

所以想象一下实际发生的事情。模型是一大堆参数,存放在服务商的服务器上,跑在你永远不会接触到的硬件上。你调用它时,代码只是通过互联网发送了一个普通的网络请求,里面装着你的消息。

服务商在自己的机器上运行预测循环,再把生成的 token 发回给你。调用一次模型,本质上就是在别人的电脑上租用了片刻时间。 这个理解方式能解释很多现象:延迟就是模型在对方那边一个个生成 token 所花的时间,成本就是对方对这些算力的收费,而整件事从头到尾都是一次网络调用,网络调用会有的问题它都会有。

下面的示例用的是 OpenAI 官方库。其他服务商在细节上会有所不同,但整体结构在各家几乎都一样:你发送一组消息,拿回一条消息。正是这种一致性给了你不被某家服务商锁死的保障——同样的代码稍微改一下 base URL,就能指向你自己托管或从别处租用的开放模型(开放模型与闭源模型一章会讲到这个选择)。

你调用一个库函数,等上几秒钟,文字就回来了。这个库让整个过程感觉像是在本地完成的,但模型其实完全不在你的机器上。

要理解的关键是,这个函数到底封装了什么。你的代码把请求打包成 JSON,打开一条 HTTPS 连接发给服务商,然后等待对方的硬件跑完预测循环,把生成的 token 流回来。SDK(也就是服务商提供的软件库,比如 openai)只是一层很薄的封装,底层就是一次 POST 请求。

搞清楚底层传输格式其实就是普通的 JSON,在实践中很有用。当调用行为不对劲时,你可以直接查看具体的请求和响应内容;当你想用某个 SDK 还没支持的功能时,也可以自己手动把字段拼上去发送。

整件事本质上是一次网络调用,这一点决定了本章后面要讲的成本、延迟和错误处理方式。下面的示例用的是 OpenAI,但请求的结构——发一组消息进去、拿一条消息出来——在各家服务商之间几乎是一样的。

一次模型调用看起来像函数调用,行为上却更像是运行在不太靠谱的基础设施上的远程过程调用。生产环境里的问题往往就出在这种落差上,所以从一开始就得看清真实情况。

在 SDK 底层,每次调用都是一次 HTTPS POST:一个 JSON 请求体发出去,服务商在自己的硬件上跑推理,token 再回传给你,如果开了流式传输,还会通过这条打开的连接持续传回来。SDK(服务商提供的客户端库)帮你搞定了鉴权、重试、类型定义和流式解析,但代价是在一层你控制不了、随时可能变动的东西外面又包了一层抽象。把它当成一个可以看透的封装,而不是黑盒。

这一点带来两个后续影响。第一,这是按 token 计费的网络调用,所以成本、延迟、幂等性和失败处理都得在设计阶段就考虑进去,而不是事后补上去的东西。第二,SDK 及其字段名是你所依赖的东西里变动最频繁的部分,所以稳妥的做法是把服务商相关的特殊细节隔离在你自己定义的边界之后。下面的示例用的是 OpenAI;请求结构在各家几乎是通用的,但围绕它的那层封装,恰恰是你最不想让它扩散到整个代码库里的东西。

请求 ​

用 pip install openai 装好库,创建一个客户端,然后调用它。客户端会从环境变量里读取你的 API key,这样密钥就永远不会出现在你的代码里。

python
from openai import OpenAI

client = OpenAI()  # 从环境变量中读取 OPENAI_API_KEY

MODEL = "gpt-4o-mini"  # 想换模型时只需要改这一行

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "You are a concise assistant. Answer in one sentence."},
        {"role": "user", "content": "Why is the sky blue?"},
    ],
)

必须要有的只有两样:model,也就是你想要运行的模型名称;messages,也就是上一章讲的那种带角色标签的消息列表。其余的都有默认值。

注意模型名称放在一个单独的常量里。在这个领域,每隔几个月就会出现更好、更便宜的模型,这样安排是有意为之的。把**模型名称**统一放在一个地方,换模型时改一行代码就行。 这正是应对变动的实际做法:把容易变化的细节放在你随时能找到的地方,而不是散落在代码各处。

Juno请求 一次调用只需要两样东西:model 和 messages。模型名称应该放在一个常量里,因为每隔几个月就会冒出一个更便宜或更好的模型,你不会想到处翻代码去改它。这个教训我是吃了亏才学到的——有次一个模型改了名字,我发现它被粘贴在了九个文件里。

必须提供的是两个字段,model 和 messages,其余都有默认值。把模型名称放进常量并不只是代码风格问题:这是那个唯一会频繁变动的细节,你希望它只出现在一个地方,因为更便宜或更好的模型经常出现,如果硬编码在各个文件里,换模型就会变成一场大搜索。

现在往下看一层。这个 create 调用会被序列化成一个 JSON 请求体,然后发送出去。具体展开来说,**传输格式**就是服务商实际收到的那个原始请求:

python
import httpx, os

# 这是 SDK 在幕后发送的同一个请求,只是手动写出来
resp = httpx.post(
    "https://api.openai.com/v1/chat/completions",
    headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
    json={
        "model": "gpt-4o-mini",
        "messages": [
            {"role": "system", "content": "You are a concise assistant."},
            {"role": "user", "content": "Why is the sky blue?"},
        ],
    },
    timeout=20,
)
data = resp.json()  # SDK 解析成对象时用的就是这个结构

在实际代码里你还是会用 SDK:它帮你处理了鉴权、重试和类型。但看清楚请求体其实就是一个普通的字典,能让后面的内容更加具体。这里的 URL 和字段名是 OpenAI 的,具体的接口地址和字段名因服务商而异,但那个套路——JSON 进,JSON 出——在哪里都一样。

Juno请求 必需字段是 model 和 messages,别无其他。SDK 的 create 调用底层就是一次 JSON POST,所以当有些行为看起来不对劲时,你可以下沉到传输层去查看原始的请求体。把模型名称放进常量里:这是最容易变的部分,你会希望它只出现在一个地方。

必需字段是 model 和 messages。真正有意思的问题不是它们是什么,而是它们该放在代码的什么位置,因为 model 是请求里变动最频繁的值,而 SDK 又是围绕它最容易变动的依赖。

create 调用会被序列化成一次 JSON POST。这一点很重要,因为它意味着请求是你能掌控的数据,而不是 SDK 施展的什么魔法。你可以自己构造请求体、记录日志、在多次运行之间做 diff,或者在 SDK 还没跟上某个新功能时手动发送请求。了解**传输格式**,才能在抽象层把问题藏起来的时候仍然把调用调试清楚。

生产环境里的好习惯,是让每一次调用都经过你自己写的一个窄接口:

python
def complete(messages, *, model=DEFAULT_MODEL, **params):
    # 唯一的关卡:换服务商、加日志、改重试逻辑都在这里做
    return client.chat.completions.create(model=model, messages=messages, **params)

这个关卡,就是换模型只需改一行代码的地方,也是服务商相关的变动能被限制住、不会渗透到每个调用方的地方。上面展示的接口路径和字段名是 OpenAI 的;换一家服务商,路由和某些字段名都会变,而这个边界的价值就在于:改名这件事只影响一个文件,而不是整个代码库。顺便把模型版本也钉死:"latest" 在你不知情的情况下悄悄变动,和大模型是怎么工作的一章里提到的那种回归风险是一回事,只不过现在出现在了你的请求体里。

Juno请求 只有 model 和 messages 是必需的,而这两个都值得放在你自己控制的边界之后。让每次调用都经过同一个函数:那里就是换模型只需一行代码的地方,是记日志的地方,也是服务商的变动在触及调用方之前被拦下的地方。顺带把模型版本也钉死,因为 "latest" 在你不知情时悄悄变动,是一个你没主动部署、也看不见的回归问题。

读取响应 ​

返回的结果会被包在一层结构里。你想要的文字只是其中一条路径,但了解剩下的结构也值得,因为它告诉你发生了什么。

python
choice = response.choices[0]

print(choice.message.content)   # 回复的文字
print(choice.finish_reason)     # "stop"  -> 模型自己结束了生成
print(response.usage)           # Usage(prompt_tokens=24, completion_tokens=18, total_tokens=42)

这里面有三样东西值得关注。message.content 就是文字内容,放在 choices[0] 里面,是因为 API 本来可以返回不止一个选项,不过你几乎总是只要一个,读第一个就行。

finish_reason 告诉你模型为什么停下来。"stop" 表示它自然说完了想说的话,而 "length" 表示它撞上了你设的 token 上限,回答在中途被截断了。检查这个字段,就是让代码而不是靠肉眼去发现回复被截断的方式。

而 usage 报告的是这次调用花掉的token,分成你发送的提示部分和它生成的回复部分。这个 usage 对象,就是你的详细账单。 每次调用都会准确告诉你花了多少 token,而这正是你在开发过程中要盯紧的数字。

Juno读取响应 回复文字在 response.choices[0].message.content,但旁边两个字段也很重要。finish_reason 告诉你为什么停下:"stop" 表示完成,"length" 表示撞上了你的 token 上限被截断了。而 response.usage 就是你的 token 账单,提示和补全分开算,你永远不用猜一次调用花了多少钱。

有三个字段承载了关键信息。choices[0].message.content 是文字内容。finish_reason 告诉你模型为什么停下:"stop" 是正常结束,"length" 表示撞上了 token 上限被截断了,检查这个字段就是让代码而不是上线后才发现回复被切断的方式。而 usage 是 token 计数,提示和补全分开统计。

这里真正值得做的,是把 usage 换算成实实在在的钱。usage 对象就是每次调用的计费数据,你可以当场算出成本:

python
# 服务商的价格是按 token 计的;要用当前费率,它们会变
PROMPT_RATE = 0.15 / 1_000_000      # 每个提示 token 的美元价格
COMPLETION_RATE = 0.60 / 1_000_000  # 每个补全 token 的美元价格

u = response.usage
cost = u.prompt_tokens * PROMPT_RATE + u.completion_tokens * COMPLETION_RATE
print(f"{u.total_tokens} tokens, ${cost:.6f}")

提示和补全通常定价不同,补全 token 往往更贵,所以在做优化时这个区分很重要。把每次调用的成本记录下来,你就能在月度账单告诉你之前,先一步看出哪个功能是"吞金兽"。这里的字段名是 OpenAI 的;其他服务商会用略有不同的键名暴露同样的计数,所以要看的是这个结构本身,而不是具体的属性名。

Juno读取响应 读三个字段:content 拿文字,finish_reason 在代码里捕捉 "length" 截断,usage 拿 token 数。当场把 usage 换算成钱,提示和补全分开定价,并按调用记日志。你会比账单更早发现哪个功能费钱。

响应里有三个字段值得你据此采取行动:content、finish_reason 和 usage。开发者最容易忽略、事后又后悔的是 finish_reason。"length" 这个值意味着模型撞上了你设的 max_tokens,输出被截断了,常见后果是格式不完整的 JSON,或者半句话戛然而止,下游的解析器就会因此崩掉。除了 "stop" 以外的任何值,都应当当成调用失败来处理,不要想当然地认为 content 是完整的。

usage 对象是你的单位经济学遥测数据,应该接入可观测性系统,而不是打印出来就没人管了。提示和补全 token 分开定价,补全通常更贵,所以按功能打上标签,把两者都记录下来:

python
u = response.usage
log.info("llm_call", feature="invoice_extract", model=MODEL,
         prompt_tokens=u.prompt_tokens, completion_tokens=u.completion_tokens,
         finish_reason=response.choices[0].finish_reason)

有了这些数据,按功能、按用户统计成本就变成了一次查询,而不是靠猜;某个提示词悄悄膨胀了三倍,也会以指标的形式暴露出来,而不是变成一笔意外的账单。留意其中的不对称性:输入 token 便宜,而且能并行处理,而输出 token 是大模型是怎么工作的一章讲过的那种昂贵的串行部分,所以一个爱输出长回复的功能,其成本和延迟都会比提示词的长度暗示的要高。这些属性名是 OpenAI 的,不同服务商会有所不同,所以要在你自己的边界之后记录这些计数,而不是让 response.usage.prompt_tokens 散落在整个应用里。

Juno读取响应 把任何不是 "stop" 的 finish_reason 都当成调用失败:"length" 截断会把不完整的 JSON 丢给你的解析器,你只会在下游追查这个 bug。把 usage 接入日志系统,按功能打标签,提示和补全分开,这样按功能算成本就是一次查询,提示词膨胀也会变成一个指标,而不是账单上的意外。输出 token 是昂贵的串行部分,所以一个话多的功能花的钱会比提示词暗示的更多。

值得了解的几个参数 ​

除了 model 和 messages,还有两个可选设置经常会用到。

temperature 就是大模型是怎么工作的一章里讲的那个随机性旋钮,在这里变得具体起来。数值低(接近 0)会让模型紧贴着它最有把握的选项:结果集中、可复现,适合用在信息抽取和分类这类任务上。数值高一些(大约 0.7 到 1)会让模型愿意去尝试不太常见的 token:结果更多样、更有创意,但也更容易跑偏。你调的其实是模型采样时敢冒多大的险,仅此而已。

max_tokens 限制了回复最多能生成多少 token。它能保护你不至于遇到失控的回答和失控的账单。max_tokens 设得太低,模型的回答就会被截断,这正是上一节讲的 "length" 结束原因,所以要留足够的余量。

python
response = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    temperature=0.2,  # 集中且稳定
    max_tokens=300,   # 限制回复长度
)
Juno值得了解的几个参数temperature 调的是模型采样时敢冒多大的险:低值适合稳定、可复现的答案,高值适合多样、有创意的答案。max_tokens 限制回复长度以保护你的账单,但设得太低会触发 "length" 截断,所以要留够余量。做信息抽取和分类,每次都用低 temperature。

两个参数承担了大部分权重:temperature 和 max_tokens。它们的原理在大模型是怎么工作的一章里讲过;这里要做的是把数值匹配到具体任务上,而不是盲目信任默认值。

把 temperature 当成一个按任务设置的参数,而不是一个全局设置。根据任务需求来选:

python
TEMPERATURE_BY_TASK = {
    "extraction": 0.0,      # 只有一个正确答案,希望每次都一样
    "classification": 0.0,  # 选一个标签,不需要创造力
    "summary": 0.3,         # 大体忠实,留一点措辞上的自由
    "brainstorm": 0.9,      # 多样性就是重点
}

对于任何你事后要解析的东西——结构化数据、一个分类标签、从文档里提取的一个数字——都调到 0,不用再纠结随机性。对于起草和创意构思这类以多样性为价值的场景,就调高。

max_tokens 是一个上限,而不是一个目标值:它限制了开支和失控的回复,但如果设得比任务实际需要的还低,就会遇到上一节说的 "length" 截断,这比账单稍微多一点还要糟糕。把它设成最长的合理答案再加上一些余量。这两个字段名在各家服务商之间基本稳定;有些会重命名 max_tokens,换服务商时确认一下键名。

Juno值得了解的几个参数 按任务设置 temperature,而不是一次性全局设定:凡是事后要解析的都设成 0,起草这类以多样性为重点的场景就设高。max_tokens 是安全上限,把它设成最长的真实答案再加余量;设得太紧,你省下的一点点钱换来的是回复被截断。别再盲目信任 SDK 给的默认值。

temperature 和 max_tokens 看起来像是两个旋钮,实际上是关乎成本和可靠性的策略决定。它们的深度在于和你所做的一切是怎么相互影响的。

把 temperature 当成你可靠性约定的一部分。对于任何你要解析或存储的输出,都用 0 来运行,这样大模型是怎么工作的一章讲的那种由采样带来的方差就会被压到服务商允许的最小值。即便是 0,也不是完全确定性的,所以不要在上面构建精确匹配的缓存或字节级一致的测试,但对结构化任务来说,这是你想要的下限。把更高的 temperature 留给那些多样性本身就是产品价值的场景,并且要把这种方差当成需要跨多次运行去用评测衡量的东西,而不是看一眼单次结果就下结论。

max_tokens 不仅是安全网,更是一个延迟和成本的杠杆。输出 token 是生成过程中昂贵的串行部分,所以限制长度同时限制了你最坏情况下的账单和最坏情况下的等待时间。

这里的陷阱是靠感觉去设置它:设得太紧,会把本来有效的答案截断成 "length" 失败,把不完整的输出丢给你的解析器;设得太松,会让一个跑飞的循环在被叫停之前一路推高成本和延迟。按任务把它设成最长合理答案再加余量,并且在触及上限时记录日志,这样一个持续被截断的功能就会变得可见。这两个参数名都是 OpenAI 的,有几家服务商会重命名它们,所以把它们设在你的边界函数里,而不是每个调用点各设各的。

Juno值得了解的几个参数temperature 是一份可靠性约定:凡是要解析的都用 0,只有方差本身是产品价值时才调高,而且永远不要在 0 上做精确匹配缓存,因为它不是字节级确定性的。max_tokens 限制了你最坏情况下的成本和延迟,把它设成最长真实答案再加余量,并在触及上限时记日志,因为一个持续被截断的功能其实是伪装成参数问题的 bug。把这两者都设在你的边界之后,而不是每个调用点各设各的。

流式输出 ​

默认情况下,你要等整个答案生成完毕,才会一次性收到全部内容。因为模型是一个个生成 token 的,回复越长,你盯着空白等待的时间就越长。

**流式输出**会在每个 token 一生成出来就立刻发给你,所以文字会立即出现,并且是边生成边填充,就像聊天应用里那种回复逐字打出来的效果。

python
stream = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    stream=True,
)

for chunk in stream:
    piece = chunk.choices[0].delta.content
    if piece:
        print(piece, end="", flush=True)

不管用不用流式输出,总的生成时间都是一样的;模型本身并没有变快。流式输出改变的只是你看到 token 的时机——生成出来就立刻展示,而不是憋到最后才一起给你。

处理方式就是遍历这些 chunk,把每个 delta 里的文字打印出来。代价是多写一点代码,换来一次好得多的等待体验。流式输出改变的是你何时看到 token,而不是它们到达的速度。

Juno流式输出 模型是一个个生成 token 的,所以默认情况下,长回复就意味着长时间的沉默等待。流式输出会在每个 token 一落地就立刻转发过来,所以答案是边生成边填充的,这也是聊天应用感觉响应迅速的原因。总时间没变,你只是通过遍历 chunk、打印每个 delta,更早地看到了这些 token。

流式输出会在模型生成每个 token 的同时把它转发出来,所以用户能立刻看到文字,而不用等完整回复生成完。总生成时间没有变化;变短的是首个 token 到达的时间(time to first token),而这正是"感觉快"这件事的大部分含义所在。

规范一点的写法要处理两个玩具循环忽略的问题:没有文字的 chunk,以及在展示的同时累积完整回复。

python
stream = client.chat.completions.create(model=MODEL, messages=messages, stream=True)

full = []
for chunk in stream:
    delta = chunk.choices[0].delta
    piece = delta.content
    if piece is None:          # 只带角色信息的 chunk 和最后一个 chunk 都不带文字
        continue
    full.append(piece)
    print(piece, end="", flush=True)

reply = "".join(full)          # 完整的消息,用于存历史记录

对空 delta 的检查很重要:第一个 chunk 往往只带角色信息、没有文字,最后一个 chunk 也可能带着 finish_reason 而内容为空。跳过这个 None,才不会把 "None" 打印到你的输出里。

把内容累积到 full 里,能让你拿到完整消息去追加到历史记录中,因为流式输出只是把文字展示出来,并不会替你保存它。有一点要注意:流式调用默认不会返回 usage 数据块,所以如果你需要 token 计数,要么显式请求,要么自己去数。流式输出在各家服务商那里都有,但 chunk 的结构——delta、content、finish_reason 出现在哪里——是各家特有的。

Juno流式输出 流式输出缩短的是首个 token 到达的时间,而不是总时间。要防范空的 delta:第一个 chunk 往往只带角色信息,最后一个带 finish_reason 却没有文字,跳过 None,不然你会把 "None" 打印进回复里。自己把内容片段累积起来存历史记录,还要记得流式调用默认不带 usage,除非你主动请求它。

流式输出是用一个更简单的响应换来更好的延迟表现:你缩短了首个 token 到达的时间,但代价是失去了那个干净利落的单一响应对象,转而要在 chunk 陆续到达时自己组装状态。对于任何有人在盯着看的场景,这很值得;对于你要整体解析的后端调用,则很少值得。

真正在生产环境里咬人的细节都在 chunk 的边界上。第一个 chunk 通常只带角色信息,最后一个 chunk 带着 finish_reason 而内容为空,而且你必须自己累积文字,因为没有人会把组装好的消息直接递给你:

python
full, finish = [], None
for chunk in stream:
    choice = chunk.choices[0]
    if choice.delta.content:
        full.append(choice.delta.content)
    if choice.finish_reason:        # 出现在最后一个 chunk
        finish = choice.finish_reason
reply = "".join(full)
if finish == "length":              # 截断在流式传输中依然会发生
    handle_truncated(reply)

三个生产环境中的现实问题:

  • finish_reason 依然会出现在流的末尾,所以 "length" 截断不会因为你用了流式输出就消失,你只是更晚才发现它,你得在信任组装好的文字之前先检查它。
  • 流可能会在中途断开:连接在传完四十个 token 中的十个之后就掉线了,给你留下一个不完整的回复,所以流式调用需要自己的部分失败处理逻辑,而且很难干净地重试。
  • 流式响应通常不带 usage,所以你要么传一个参数要求它包含进来,要么自己对组装好的文字做分词来统计成本。

SSE 的帧格式和 chunk 结构是各家服务商特有的,所以要把流的解析逻辑放在和其他部分同一个边界之后,让调用方只看到你组装好的结果,而不是原始的 chunk。

Juno流式输出 流式输出换来的是首个 token 到达时间,代价是失去了那个干净的响应对象,所以用在有人盯着看的场景,后端解析就跳过它。最后一个 chunk 依然带着 finish_reason,所以 "length" 截断在流式传输中是真实存在的,信任文字之前先检查它。而且流可能在传完四十个 token 中的十个之后就断掉,所以要处理这种不完整回复,并把服务商特有的 chunk 解析逻辑放在你的边界之后。

每次调用都是独立的 ​

这是本章最重要的行为特性,而且它直接源于上下文窗口那一课的内容。这个 API 是**无状态**的:每个请求都完全独立存在。服务商的服务器不会记得你上一次调用了什么。它只会针对你这次发送的消息运行一遍预测,返回结果,然后把这次交互的一切都忘得一干二净,不会为下一次留下任何痕迹。

所以对话内容不会被模型或 API 存在任何地方。它存在于你的代码里。如果你希望第二轮对话知道第一轮发生了什么,你就得自己维护那份不断累积的消息列表,并且每次都把整份东西重新发送一遍。一来一回的对话感,其实是你通过每次调用都重新发送一份不断增长的历史记录,人为制造出来的一种错觉。

这也是为什么长对话每条消息的成本会随着时间推移越来越高:每次调用都要把之前所有轮次重新作为输入 token 发送一遍,所以即便用户这次的新问题很短,提示词的总长度也会一直增长。

python
history = [
    {"role": "system", "content": "You are a friendly travel assistant. Keep answers short."},
]

def chat(user_text):
    history.append({"role": "user", "content": user_text})

    # 每次都会把整份历史记录发上去;服务器什么都不记得
    response = client.chat.completions.create(model=MODEL, messages=history)
    reply = response.choices[0].message.content

    history.append({"role": "assistant", "content": reply})  # 把回复留着,供下一轮使用
    return reply

chat("I have a weekend in Lisbon. What should I see?")
chat("Which of those is best for kids?")  # 之所以能成立,只是因为 history 里带着第一轮的内容

第二个问题之所以能说得通,只是因为第一个问题和它的答案还留在 history 里,并且被一起发送了出去。你自己,就是这份"记忆"。 这一个道理——你要在每次独立调用之前自己组装并重新发送完整的上下文——是对话功能的基础,后面工具调用、检索以及智能体(agent)也都建立在这个道理之上。它们其实都只是在决定,每次独立调用之前那份 messages 列表里到底该放什么。

Juno每次调用都是独立的 这个 API 是无状态的:每个请求都完全基于你发送的那些消息来运行,服务器一回复完就把这次交互忘得一干二净。对话存在于你的代码里,而不是模型里,所以你得自己维护这份不断增长的消息列表,并且每一轮都把它整份重新发送。这就是为什么长对话的成本会越来越高——每次调用都要把之前所有轮次重新作为输入 token 发送一遍。

这个 API 是**无状态**的:服务器只会针对你发送的这些消息运行一遍,之后什么都不会留下。对话存在于你的代码里,你每一轮都要重新发送整份历史记录,来伪造出连续性,这也是为什么即使新问题很短,长对话的每条消息成本也会越来越高。

这种增长是一个需要你去管理的问题,而不是可以忽略的问题,因为历史记录迟早会超出上下文窗口的容量。第一步能做的是滑动窗口:只保留系统消息加上最近的若干轮。

python
def trim(history, keep_recent=10):
    system = history[:1]               # 始终保留系统消息
    recent = history[1:][-keep_recent:]  # 只保留最新的若干轮
    return system + recent

这种做法成本低、可控,但会悄无声息地丢掉更早的对话轮次,导致模型忘记用户还指望它记得的、对话早期提到的事实。如果这一点很重要,下一步可以把被丢弃的轮次总结成一条简短的消息,而不是直接删掉。选哪种方式取决于具体功能:一个需要引用最初问题的客服机器人需要用摘要,一个简单的问答场景用窗口就够了。无状态这个特性本身在各家服务商之间是通用的,只有围绕它的消息格式会有所不同。

Juno每次调用都是独立的 无状态意味着服务器每次回复完就忘,所以你要重新发送整份历史记录,并为它不断增长的体量付费。用滑动窗口保留最近几轮加上系统消息来控制成本,但要知道它会悄悄丢掉用户还指望你记得的早期事实。当这变成问题时,把被丢弃的轮次总结一下,而不是直接删掉。

这个 API 是**无状态**的:对话由你自己维护,每次调用都得重新发送。最简单粗暴的做法——一直往后追加、每次都整份发送——在规模变大后会以两种方式失败:一是迟早会撑爆上下文窗口,二是随着输入无限增长,每一轮都会比上一轮更贵、更慢。

所以历史记录管理是一个预算问题,有三种标准策略,各有各的失效方式:

  • 滑动窗口保留最近的 N 轮:便宜且有界,但会悄悄丢掉用户还在引用的早期事实。
  • 摘要压缩把旧的轮次压缩成一份持续更新的摘要:能用更少的 token 承载更多历史,但会丢失细节,还可能把自己的错误固化进之后的每一轮里。
  • 检索把历史轮次存到外部,只取回相关的部分:能扩展到很长的历史,但依赖你的索引能不能返回正确的片段,还多了一次查找开销。

按具体场景来选,并且要监测它的效果,因为每种策略迟早都会在某个时刻取回错误的上下文。

python
def build_messages(system, history, user_msg, budget_tokens=6000):
    msgs = [system]
    running = count_tokens(system) + count_tokens(user_msg)
    for turn in reversed(history):           # 从最新的开始
        t = count_tokens(turn)
        if running + t > budget_tokens:
            break                             # 更早的轮次超出预算,被舍弃
        msgs.insert(1, turn)
        running += t
    msgs.append(user_msg)
    return msgs

按 token 数量而不是按轮次数量来做预算,因为轮次的长度差异很大,一旦有人粘贴进一份文档,按轮次数量做的窗口会瞬间撑爆你的上下文预算。这个策略是与服务商无关的;但你用来计数的分词器不是,所以要用模型自己的分词器来计数。这正是提示词工程和 RAG 一章里那种上下文构建工作的延续,只不过这次作用在对话本身上。

Juno每次调用都是独立的 无状态意味着历史记录管理是你自己的预算问题,"一直往后追加"会撑爆窗口,并推高每一轮的成本。按场景选策略:滑动窗口便宜但会丢早期事实,摘要压缩能装下更多但会固化自己的错误,检索能扩展但依赖你的索引。按 token 数量做预算而不是按轮次数量,因为一份粘贴进来的文档会瞬间撑爆按轮次计算的窗口。

出问题的时候 ​

一次模型调用是发向别人服务器的网络调用,所以网络调用会出的所有问题,它都可能出。从一开始就要为此做好设计。

  • 错误和速率限制。 服务商会限制你在一个时间窗口内能发送多少请求。一旦超过限制,调用就会因速率限制错误而失败。标准的应对方式是等待后重试,并且每次失败后把等待时间拉长(这叫做**退避(backoff)**),这样你就不会一直去冲击一个本来就很忙的服务。
  • 超时。 调用可能会卡住不动。设置超时时间,不要让一个缓慢的请求拖垮你的整个应用。
  • 密钥要留在服务器端。 你的 API key 就是一个能花掉你钱的密码。永远不要把 API key 放进浏览器端代码里。 任何人都可以打开开发者工具把它读出来。浏览器应该和你的后端通信,由持有密钥的后端去和模型通信。
  • 假设任何答案都可能是错的。 即便调用成功了,返回的也可能是幻觉或格式不对的内容,所以接下来几章的内容都是关于如何让结果变成你的代码能够信任的东西。
python
def ask_model(messages):
    try:
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            timeout=20,  # 20 秒后放弃
        )
        return response.choices[0].message.content
    except Exception as err:
        print("Model call failed:", err)
        return "Sorry, something went wrong. Please try again."
Juno出问题的时候 一次模型调用是发向别人服务器的网络调用,所以要预料到速率限制、超时和各种直接失败,把每次调用都包在带有重试加退避的错误处理里。把 API key 留在服务器端,永远别放进浏览器,因为它能真金白银地花钱,而开发者工具能把它读出来。而且要把哪怕调用成功也可能出错的回复,当成需要处理的对象——接下来几章会教你怎么做。

模型调用的失败方式和普通网络调用一样:速率限制、超时、临时性的服务器错误。正确做法是捕获具体的失败类型,只重试那些值得重试的,而不是用一个裸的 except 把所有情况都盖住,连真正的 bug 也一起藏了起来。

捕获带类型的异常,并使用**退避(backoff)**策略,每次失败后等待更长时间,避免持续冲击一个本来就繁忙的服务:

python
import time
from openai import RateLimitError, APITimeoutError, APIError

def ask_model(messages, retries=3):
    for attempt in range(retries):
        try:
            r = client.chat.completions.create(model=MODEL, messages=messages, timeout=20)
            return r.choices[0].message.content
        except (RateLimitError, APITimeoutError, APIError) as err:
            if attempt == retries - 1:
                raise
            wait = 2 ** attempt          # 1秒、2秒、4秒:指数退避
            print(f"retry {attempt + 1} after {err}")
            time.sleep(wait)

异常类型能告诉你该怎么做:RateLimitError 和 APITimeoutError 是临时性的,值得重试;而一个格式错误的请求(消息格式不对、模型名不存在)是你自己代码的 bug,应该大声报错,而不是去重试。每次尝试都把等待时间翻倍,能避免你继续往一个已经不堪重负的服务上添压力。SDK 本身会替你重试一部分失败情况,所以在自己加一层重试逻辑之前,先看看它的默认行为。

密钥要留在服务器端,并且要把调用成功的回复当成可能出错的东西对待,这一点接下来几章会讲到。这里的异常类名是 OpenAI 的;其他 SDK 会抛出各自的异常,所以要在你的边界处做映射。

Juno出问题的时候 捕获带类型的异常,而不是裸的 except:RateLimitError 和 APITimeoutError 是临时性的,值得重试,一个格式错误的请求是你自己的 bug,应该大声报错。用指数方式做退避,每次把等待时间翻倍,这样才能停止持续冲击一个已经不堪重负的服务。加自己的重试逻辑之前,先看看 SDK 是不是已经重试过了,并且把密钥留在服务器端。

失败处理正是这件事从"函数调用"变成"分布式系统工程"的地方。调用会以一些已知方式失败——速率限制、超时、临时性的 5xx 错误——真正危险的不是重试本身,而是不假思索地重试,不去想一次重试要付出什么代价。

这里的陷阱是**幂等性**(重复执行一次操作,和只执行一次相比,效果是否相同)。超时之后简单粗暴地重试,可能会让你被重复扣费:第一个请求可能已经在服务商那边完成、生成了 token,只是响应没能传回给你,那你的重试就是又付了一次完整生成的费用。对于只读式的调用,这只是浪费钱;对于任何带副作用的操作,这就是一次重复执行的动作。如果服务商支持幂等键,就发一个,让重试被识别为同一个请求;如果不支持,就让周围的整个操作本身能够安全地重复执行。

python
import random, time
from openai import RateLimitError, APITimeoutError, APIError

RETRYABLE = (RateLimitError, APITimeoutError, APIError)

def complete(messages, *, retries=3, **params):
    for attempt in range(retries):
        try:
            return client.chat.completions.create(messages=messages, model=MODEL, **params)
        except RETRYABLE as err:
            if attempt == retries - 1:
                raise
            time.sleep((2 ** attempt) + random.random())  # 退避 + 抖动

还有三个生产环境中的现实问题:

  • 抖动(jitter),也就是给每次等待加上一个随机的小数值,能防止一大批客户端同步重试、再次冲垮速率限制(也就是所谓的"惊群"问题)。
  • 连接复用在高负载下很重要:创建一个客户端并共享它,让连接被池化复用,而不是每次调用都重新握手一次 TLS,并且让相互独立的调用并发执行,而不是串成一个缓慢的循环。
  • 重试前先分类:RateLimitError 是临时性的,而一个格式错误的请求是你自己的 bug,重试它只是在一个注定不会成功的调用上浪费时间和金钱。

异常类和幂等机制都是服务商特有的,所以这整套策略都应该放在边界函数之后,让你在那里一次性把每家服务商的错误映射成自己的重试决策。密钥依然要留在服务器端,而一次成功的回复在被验证之前依然不可信任,这一点由结构化输出和安全边界那一章来处理。

Juno出问题的时候 重试才是危险的部分:超时之后简单粗暴地重试,可能会造成重复扣费,因为第一次调用可能已经生成了 token 只是你没收到,所以要用幂等键,或者让整个操作本身能安全地重复执行。给退避加上抖动,防止一大批客户端同步重试、再次冲垮限流,复用同一个池化的客户端,并且让相互独立的调用并发执行。先分类再重试,对临时性错误重试,对自己造成的格式错误大声报错,并把整套策略放在一个边界之后,让每家服务商的特殊之处都在同一个地方映射成你的重试决策。