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

从代码调用模型

docs.scrimba.com

上一章构建了一份消息列表。本章从 Python 将该列表发送到真实模型并获得回答。这种往返是使用这些模型构建的最小完整循环,你编写的几乎每个功能都建立在这个基础之上。

你输入一个提示词,你的代码将其发送到某处,然后文本返回来。感觉像模型就在你的程序中。它不在,看看它实际运行的地方可以解释成本、速度和你会遇到的怪癖。

所以想象一下物理上发生的事情。模型是提供商服务器上的一个巨大参数集,运行在你永远看不到的硬件上。当你调用它时,你的代码通过互联网发送一个普通的网络请求,携带你的消息。

提供商在他们的机器上运行预测循环,并将生成的令牌发送回给你。模型调用是租用他人计算机上的几个时刻这个框架解释了很多事情:延迟是模型在他们的端生成令牌的时间,成本是他们为该计算收费,整个事情是一个网络调用,伴随着所有相应的含义。

示例使用官方 OpenAI 库。其他提供商有细节差异,但形状几乎到处都一样:你发送一个消息列表,你得到一条消息回来。这种相同性是你对供应商锁定的保险,因为相同的代码可以指向你自己托管或在其他地方租用的开放模型,通常只需改变基础 URL(开放和封闭模型涵盖了这个选择)。

你调用一个库函数,几秒钟过去,文本返回来。库使它看起来像本地的,但模型上没有任何东西在你的机器上。

要理解的举动是那个函数在包装什么。你的代码将请求打包为 JSON,打开与提供商的 HTTPS 连接,并等待他们的硬件运行预测循环并流回令牌。SDK(提供商的软件库,如 openai)是一个围绕一个 POST 请求的薄便利层。

知道底层的网络格式是纯 JSON 在实践中会有回报。当调用表现得很奇怪时,你可以检查确切的请求和响应。当你想要 SDK 尚未暴露的功能时,你可以手动发送该字段。

整个事情是一个网络调用,这是驱动本章其余部分的成本、延迟和错误处理的原因。示例使用 OpenAI,但请求形状(输入消息列表,输出消息)在提供商之间几乎完全相同。

模型调用看起来像函数调用,在不可靠的基础设施上的行为像远程过程。这种不匹配是生产问题的来源,所以从一开始就坚持真实的情况是值得的。

在 SDK 底层,每次调用都是一个 HTTPS POST:JSON 主体上升,提供商在他们的硬件上运行推断,令牌返回,可以通过开放连接流式传输。SDK(提供商客户端库)为你带来身份验证、重试、类型和流式解析,它耗费了你对你不控制的变化的一层抽象。把它当作一个你可以看穿的包装,而不是一个黑盒。

两个后果为本章的其余部分做了设置。首先,这是一个按令牌计费的网络调用,所以成本、延迟、幂等性和失败处理是设计关注点,而不是你稍后才附加的事后诸葛亮。其次,SDK 及其字段名是你依赖的最不稳定的表面,所以耐用的举动是将提供商特定的部分隔离在你自己的边界后面。示例使用 OpenAI;请求形状几乎是通用的,但围绕它的包装正是你不想在整个代码库中传播的。

请求

pip install openai 安装库,创建一个客户端,然后调用它。客户端从环境变量读取你的 API 密钥,所以密钥从不出现在你的代码中。

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请求 调用只需要两样东西:modelmessages。模型名称属于单个常量,因为更便宜或更好的模型每隔几个月就会出现,你不想去寻找它。我以缓慢的方式学到的,在一个模型被重命名后,我发现它粘贴在九个文件中。

两个字段是必需的,modelmessages,其他一切都有默认值。常量中的模型名称不是一个风格要点:它是一个不稳定的细节,你想在一个地方,因为更便宜或更好的模型足够经常地到达,以至于在文件中硬编码它把交换变成了一场狩猎。

现在向下看一层。那个 create 调用序列化为 JSON 主体并 POST 它。拼写出来,**网络格式**是提供商接收的字面请求:

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请求 必需字段是 modelmessages,别的没有。SDK create 调用在底层是一个 JSON POST,所以当某些东西看起来不对时,你可以下降到网络并检查字面主体。将模型名称保留在常量中:它是会变化的部分,你想要它在一个地方。

必需字段是 modelmessages。有趣的问题不是它们是什么,而是它们在你的代码中的位置,因为 model 是请求中最不稳定的单个值,SDK 是围绕它最不稳定的依赖。

create 调用序列化为 JSON POST。这很重要,因为这意味着请求是你拥有的数据,而不是 SDK 执行的魔法。你可以构建主体、记录它、跨运行进行差异比较,或者当 SDK 落后新提供商功能时手工发送它。了解**网络格式**就是当抽象对你隐藏调用时能够调试它。

生产习惯是将每个调用路由通过你自己的一个狭窄函数:

python
def complete(messages, *, model=DEFAULT_MODEL, **params):
    # 一个控制点:在这里交换提供商、添加日志、改变重试
    return client.chat.completions.create(model=model, messages=messages, **params)

那个控制点是模型交换成为一行改变的地方,提供商特定的变化保持包含而不是泄漏到每个调用者。端点路径和字段名是 OpenAI 的;另一个提供商重命名路由和一些密钥,所以边界的值是重命名触及一个文件,而不是整个代码库。同时固定模型版本:在你下面默默移动"latest"是在你的请求主体中覆盖的相同回归风险,现在在 LLM 如何工作 中。

Juno请求 只有 modelmessages 是必需的,两者都值得活在你控制的边界后。将每个调用路由通过一个函数:那是模型交换是一行的地方,日志去的地方,提供商变化停止在它到达你的调用者之前的地方。同时固定模型版本,因为在你下面移动"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" 意味着它撞到了你的令牌上限并被中途中止。检查那个字段是你如何在代码中检测被截断回复而不是用眼睛。

usage 报告了调用花费的令牌,分为你发送的提示和它写的完成。那个**使用**对象是你的账单,分条列出。每个调用准确地告诉你它在令牌中花费了什么,这是你在构建时要注意的数字。

Juno读取响应 回复文本在 response.choices[0].message.content,但两个邻居也很重要。finish_reason 告诉你为什么它停止了:"stop" 意味着完成,"length" 意味着它撞到了你的令牌上限并被截断。而 response.usage 是你的令牌账单,提示和完成分开,所以你永远不用猜测调用花费了什么。

三个字段传递信号。choices[0].message.content 是文本。finish_reason 是模型停止的原因:"stop" 是一个干净的完成,"length" 意味着它撞到了你的令牌上限并被截断,所以检查它是你如何在代码中捕获中止回复而不是发送半答案。而 usage 是令牌计数,提示和完成分开。

这里有回报的举动是将 usage 变成真实的钱。**使用**对象是按调用计费的数据,所以你可以实时计算成本:

python
# 提供商价格是按令牌;检查当前费率,它们会改变
PROMPT_RATE = 0.15 / 1_000_000      # 美元每提示令牌
COMPLETION_RATE = 0.60 / 1_000_000  # 美元每完成令牌

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

提示和完成通常价格不同,完成令牌通常成本更高,所以当你优化时分割很重要。记录每个调用的成本,你可以看到哪个功能在月度发票之前就很昂贵。这里的字段名是 OpenAI 的;其他提供商在稍微不同的密钥下暴露相同的计数,所以读形状,而不是确切的属性。

Juno读取响应 读三个字段:content 用于文本,finish_reason 在代码中捕获 "length" 截断,usage 用于令牌。将 usage 变成实时的美元,提示和完成分别价格,并按调用记录。你会在发票之前发现昂贵的功能。

响应携带三个字段你要采取行动:contentfinish_reasonusage。一个构建者跳过而后悔的是 finish_reason"length" 值意味着模型撞到了你的 max_tokens,输出被截断,通常是损坏的 JSON 或一个半完成的句子,你的下游解析器随后会卡住。将任何不是 "stop" 的值视为一个失败的调用并处理它,不要假设 content 是完整的。

**使用**对象是你的单位经济学遥测,它值得被连接到可观察性中,而不是被打印和遗忘。提示和完成令牌分别计价,完成通常更高,所以按调用发出两者,由功能标记:

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)

有了这个,每个功能和每个用户的成本就变成了一个查询,而不是一个猜测,一个静悄悄地三倍增长的提示会显示为一个度量,而不是一个意外的费用。观察不对称性:输入令牌便宜且并行处理,输出令牌是昂贵的、序列的部分,在 LLM 如何工作 中覆盖,所以一个发出长回复的功能花费和滞后更多比它的提示大小建议。属性名是 OpenAI 的,按提供商移位,所以在你自己的边界后记录计数,而不是在应用中分散 response.usage.prompt_tokens

Juno读取响应 将任何不是 "stop"finish_reason 视为一个失败的调用:"length" 截断向你的解析器交付损坏的 JSON,你会在下游追赶这个错误。将 usage 连接到你的日志中,由功能标记,提示和完成分开,所以按功能的成本是一个查询,而膨胀的提示是一个度量,而不是一个账单惊喜。输出令牌是昂贵的序列部分,所以一个爱说话的功能花费更多,而不是它的提示建议的。

值得了解的参数

超越 modelmessages,两个可选设置经常出现。

temperature 是来自 LLM 如何工作 的随机性拨盘,变成具体的。低值(接近 0)让模型保持接近它的最佳选择:专注且可重复,这是你对提取和分类想要的。更高的值(围绕 0.7 到 1)让它够得着不太可能的令牌:更多样化和创意,而且更容易漂移。你调节的是模型大胆采样的程度,仅此而已。

max_tokens 限制回复可以运行多少令牌。它保护你免受逃脱答案和逃脱账单。设置**max_tokens**太低,模型会被切断,这正是上一部分的 "length" 完成原因,所以留下真实的回旋余地。

python
response = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    temperature=0.2,  # 专注且一致
    max_tokens=300,   # 限制回复长度
)
Juno值得了解的参数temperature 调节模型大胆采样的程度:低于专注、可重复的答案,高于多样化、创意的答案。max_tokens 限制回复长度来保护你的账单,但设置太低会触发 "length" 切断,所以留下回旋余地。对于提取和分类,每次都选择低温度。

两个参数承载大部分权重:temperaturemax_tokens。机制在 LLM 如何工作;这里的举动是将值匹配到任务,而不是信任任何默认的船。

把**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 发货的默认值。

temperaturemax_tokens 看起来像拨盘,行为像与成本和可靠性附加的策略决定。这里的深度在于它们如何与你发货的其他一切交互。

将**temperature** 视为你的可靠性合约的一部分。对于任何你解析或存储的输出,在 0 运行,所以来自 LLM 如何工作 的采样驱动的变化尽可能小,是提供商允许的。即使在 0,它也不是完美确定的,所以不要在它的顶部构建精确匹配缓存或字节相同的测试,但它是你对结构化工作想要的底部。保留更高的温度用于多样性是产品的表面,并将那个变化视为你的评估测量跨运行,而不是一个你眼看的单一传递。

max_tokens 是一个延迟和成本杠杆,不只是一个安全网。输出令牌是生成的序列、昂贵的部分,所以上限长度界定你最坏情况的账单和最坏情况的等待。

陷阱是用眼睛调整大小:太紧截断有效答案到 "length" 失败,向你的解析器交付损坏的输出,太松让一个有缺陷的循环在任何东西停止它之前跑起来吃成本和延迟。按任务将它的大小设置为最长的合法答案加上边距,并记录当你撞到上限,所以一个保持截断的功能变成可见。两个参数名都是 OpenAI 的,一对提供商重命名它们,所以在边界函数后面设置它们,而不是在每个调用点。

Juno值得了解的参数temperature 是一个可靠性合约:0 用于任何你解析的东西,高用于多样性是产品的地方,永远不要在 0 的顶部精确匹配缓存,因为它不是字节确定性的。max_tokens 界定你最坏情况的成本和延迟,所以将它的大小设置为最长的真实答案加上边距,并记录当你撞到它,因为一个保持截断的功能是一个隐藏为参数的错误。在你的边界后面设置两者,而不是在每个调用点。

流式传输

默认情况下,你等待整个答案完成生成,然后它一下子到达。由于模型一次生成一个令牌,一个长回复意味着一个长等待盯着什么都没有。

**流式传输**将每个令牌在生成时立即发送给你,所以文本立即出现并实时填充,就像聊天应用显示回复在自己打字出来。

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)

总生成时间两种方式都是一样的;模型没有更快。流式传输只改变当你看到令牌,在他们被生成时表面他们而不是等到最后才拿回。

你通过迭代块并打印每个文本的 delta 来处理它。权衡是多一点代码而大大更好的等待。流式传输改变你看到令牌,而不是他们到达多快。

Juno流式传输 模型一次生成一个令牌,所以默认情况下一个长回复是一个长沉默的等待。流式传输在它登陆时转发每个令牌,所以答案实时填充,这是为什么聊天应用感觉响应式。相同的总时间,你只是通过迭代块并打印每个 delta 更早看到令牌。

流式传输在模型生成它时转发每个令牌,所以用户立即看到文本,而不是等待完整回复。总生成时间不变;下降的是到首个令牌的时间,这是大部分"感觉快"的意思。

干净的版本处理玩具循环跳过的两样东西:没有文本的块,和在你显示时积累完整回复。

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:          # 仅角色和最终块不携带文本
        continue
    full.append(piece)
    print(piece, end="", flush=True)

reply = "".join(full)          # 完整消息,用于历史或存储

空的 delta 检查很重要:第一个块通常携带角色且没有文本,而**最终块**可能携带一个 finish_reason 且没有内容。跳过 None,你避免将"None"打印到你的输出。

积累到 full 给你整个消息追加到历史,因为流式传输显示文本但不为你保存。一个要知道的权衡:一个流式调用默认不返回 usage 块,所以如果你需要令牌计数,你明确请求它们或自己计数。流式传输存在于提供商之间,但块形状 deltacontentfinish_reason 登陆的地方,是提供商特定的。

Juno流式传输 流式传输削减到首个令牌的时间,而不是总时间。防护空的 `delta`:第一个块通常仅角色且最后的块携带 `finish_reason` 且没有文本,所以跳过 `None` 或你打印"None"到回复。为历史自己积累各片,并记住一个流式调用除非你要求否则跳过 `usage`。

流式传输用一个更复杂的响应交易一个更好的延迟概况:你削减到首个令牌的时间,但你放弃单个干净的响应对象,并承担在块到达时组装状态。对于任何人类看的东西值得,对于你完整解析的后端调用很少值得。

在块边界咬住生产中的细节。第一个块通常仅角色,**最终块**携带 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:        # 在最后一个块到达
        finish = choice.finish_reason
reply = "".join(full)
if finish == "length":              # 截断仍在流式中间发生
    handle_truncated(reply)

三个生产现实:

  • finish_reason 仍在流的末端到达,所以 "length" 截断不会在你流式传输时消失,你只是稍后注意到,你在信任组装的文本之前检查它。
  • 一个流可以在飞行中中断:连接在四十个中的十个令牌后断开,让你一个部分回复,所以流式调用需要它们自己的部分失败处理,并且重新尝试干净地困难。
  • 流式响应通常忽略 usage,所以你传一个标志来包含它或自己令牌化组装的文本用于成本跟踪。

SSE 框架和块模式是提供商特定的,所以保持流解析在边界后面,与其他部分一样,并让调用者看到你的组装结果,而不是原始块。

Juno流式传输 流式传输购买到首个令牌的时间并耗费你干净的响应对象,所以在人类观看的地方用它,并为后端解析跳过它。最后的块仍携带 finish_reason,所以 "length" 截断在流式中间是真的,在信任文本之前检查它。而一个流可能在四十个中的十个令牌后死亡,所以处理部分回复并保持提供商特定的块解析在你的边界后面。

每个调用都是独立的

这是章节中最重要的行为,它直接遵循 上下文窗口 课程。API 是**无状态的**:每个请求完全独立。提供商的服务器不记得你的之前调用。它在你发送的确切消息上运行预测,返回结果,并对下一次的交换保留任何东西。

所以对话不被模型或 API 存储任何地方。它活在你的代码中。如果你想第二轮知道第一轮发生了什么,你自己保持消息的运行列表并再次发送整个东西。一个来回聊天是你通过在每个调用上重新发送一个不断增长的历史创建的幻觉。

这也是为什么一个长对话随时间每条消息成本更多:每个调用重新发送所有先前的轮次作为输入令牌,所以提示不断增长,即使用户的新问题很短。

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 Beijing. What should I see?")
chat("Which of those is best for kids?")  # 仅工作因为历史携带轮一

第二个问题仅使意义因为第一个问题和它的答案仍在 history 中并一起被发送。你是内存。这一个想法,你组装并每次重新发送完整上下文,在对话下面,稍后在工具使用、检索和代理下面也下面。他们都是关于决定在每个独立调用之前什么进入那个消息列表的变化。

Juno每个调用都是独立的 API 是无状态的:每个请求在你发送的确切消息上运行,服务器在回复后忘记交换。一个对话活在你的代码中,不是模型,所以你保持增长的消息列表并每轮重新发送全部。这是为什么长聊天随时间每条消息成本更多,每个调用重新发送每个之前轮作为输入令牌。

API 是**无状态的**:服务器在你发送的确切消息上运行,并在之后保留任何东西。对话活在你的代码中,你重新发送整个历史每轮假装连续性,这是为什么一个长聊天随时间每条消息成本更多,即使新问题是短的。

那个增长是你管理的问题,不是一个你可以忽略的,因为历史最终超出了 上下文窗口。第一个举动是一个滑动窗口:保持系统消息加最近的轮次。

python
def trim(history, keep_recent=10):
    system = history[:1]               # 总是保持系统消息
    recent = history[1:][-keep_recent:]  # 仅最新轮次
    return system + recent

这是便宜的并界定你的成本,但它默默删除更早的轮次,所以模型忘记对话早期的事实用户仍然期望它知道。当那个很重要时,下一步上升是总结删除的轮次成一个短消息而不是完全删除它们。你选择哪个取决于功能:一个支持机器人引用原始问题需要总结,一个快速问答对窗口好。无状态本身对提供商是通用的;仅围绕它的消息格式变化。

Juno每个调用都是独立的 无状态意味着服务器在每个回复后忘记,所以你重新发送整个历史并为它增长付费。用最近轮次加系统消息的滑动窗口修剪界定成本,但知道它默默删除用户仍然期望你记住的早期事实。当那个很重要时,总结删除的轮次而不是删除它们。

API 是**无状态的**:你拥有对话并每个调用重新发送它。幼稚版本,永远追加并发送全部,在规模上两种方式失败:它最终溢出 上下文窗口,并它使每个轮次比最后更昂贵且更慢,因为输入无界增长。

所以历史管理是一个有三个标准策略的预算问题,每个都以自己的方式失败:

  • 一个滑动窗口保持最近的 N 轮:便宜且界定,但它默默删除用户仍然引用的早期事实。
  • 总结压缩旧轮到一个运行总结:每令牌保持更多历史,但失去细节,可能在它自己的错误中烘烤每个后来轮。
  • 检索在外部存储轮并仅拉回相关的:扩展到长历史,但取决于你的索引返回正确的片并添加一个查找。

按用例选择并检测它,因为每个最终将表面错误的上下文。

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

按令牌计数预算,不是轮计数,因为轮大小狂野变化,轮计数窗口在某人粘贴文档时立即吹掉你的上下文预算。策略对提供商是独立的;你用来计数的令牌化工具不是,所以用模型自己的计数。这是相同的上下文构造工作 提示词RAG 构造在,现在在对话本身上。

Juno每个调用都是独立的 无状态意味着历史管理是你的预算问题,且"永远追加"溢出窗口并按轮膨胀成本。按用例选择策略:滑动窗口便宜但删除早期事实,总结保持更多但在它自己错误中烘烤,检索扩展但依靠你的索引。按令牌计数预算而不是轮计数,因为一个粘贴的文档立即吹掉轮计数窗口。

当事情出错

模型调用是到别人服务器的网络调用,所以它在所有网络调用失败的方式失败。从一开始就为它设计。

  • 错误和速率限制。提供商限制你可以在一个窗口内发送多少请求。撞到限制,调用失败且速率限制错误。标准响应是等待并重试,在每个失败后加长等待(这被称为退避)所以你不会锤击一个忙碌的服务。
  • **超时。**一个调用可能会挂起。设置一个超时,所以一个慢请求不会冻结你的应用。
  • **密钥保留在服务器上。**你的 API 密钥是一个花费你的钱的密码。永远不要把 API 密钥放在浏览器代码中。任何人可以打开开发工具并读取它。浏览器与你的后端交谈,而你的后端,持有密钥,与模型交谈。
  • **假设任何答案可能是错误的。**即使一个成功的调用可能返回一个 幻觉 或损坏的输出,所以下一章是关于使结果变成你的代码可以信任的东西。
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 密钥在服务器上,永远不要在浏览器中,因为它花费真实的钱,开发工具会读取它。而对待即使一个成功的回复为可能是错误的,下一章帮助你处理。

模型调用在网络调用失败的方式失败:速率限制、超时、短暂的服务器错误。举动是捕获特定失败并重试值得重试的,而不是包装一切在隐藏真实错误的裸露 except 中。

捕获输入异常并应用**退避**,在每个失败后等待更长时间,所以你停止锤击一个忙碌的服务:

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          # 1s, 2s, 4s: 指数退避
            print(f"retry {attempt + 1} after {err}")
            time.sleep(wait)

异常类型告诉你做什么:RateLimitErrorAPITimeoutError 是短暂的,值得重试,而一个坏请求(损坏的消息,未知模型)是你的错误并应该大声失败,不重试。加倍每个尝试的等待保持你不从已经挣扎的服务堆成一堆。SDK 为你重试一些失败,所以在添加你自己的层之前检查它的默认值。

保持密钥服务器端,并对待一个成功的回复为可能是错误,下一章解决。异常类名这里是 OpenAI 的;其他 SDK 提出他们自己的,所以在你的边界映射他们。

Juno当事情出错 捕获输入异常,不是裸露 exceptRateLimitErrorAPITimeoutError 是短暂的,值得重试,一个坏请求是你的错误并应该大声失败。指数退避,加倍等待,所以你停止锤击一个挣扎的服务。在添加你自己的层之前检查 SDK 是否已经重试,并保持密钥服务器端。

失败处理是这停止是函数调用并变成分布式系统工作的地方。调用在已知的方式失败,速率限制、超时、短暂的 5xx 错误,不安全的部分不是重试,它是在思考重试花费什么而不是重试。

陷阱是**幂等性**(运行一个操作两次是否与运行它一次有相同的效果)。一个朴素的重试在超时后可以双倍账单:第一个请求可能在提供商端完成和生成令牌,响应永远不到达你,所以你的重试为第二个完整生成付费。对于一个读式调用那是浪费的钱,对于任何有副作用的东西它是一个重复行动。在提供商支持幂等性密钥的地方,发送一个,所以一个重试被识别为相同请求;在它不支持的地方,使周围的操作安全可重复。

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())  # 退避 + 抖动

三个更多生产现实:

  • 抖动,添加到每个等待的随机分数,停止一个客户端的船队在步调重试和重新尖峰速率限制(雷鸣羊群问题)。
  • 连接重用在负载下很重要:创建一个客户端并分享它,所以连接被池而不是为每个调用付出一个新鲜的 TLS 握手,并并发运行独立调用而不是在一个慢序列循环中。
  • 在重试之前分类:一个 RateLimitError 是短暂的,一个损坏请求错误是你的错误,重试它浪费时间和钱在永远不会成功的调用上。

异常类和幂等性机制是提供商特定的,所以这个整个策略属于边界函数后面,你在一个地方映射每个提供商的错误到你的重试决定。密钥保留服务器端,一个成功的回复仍然不被信任直到验证,结构化输出安全章节 处理。

Juno当事情出错 重试是危险的部分:一个朴素的重试在超时后可以双倍账单,因为第一个调用可能生成了你永远没收到的令牌,所以使用幂等性密钥或使操作安全可重复。添加抖动到你的退避,所以一个客户端船队不会步调重试和重新尖峰限制,重用一个池客户端,并并发运行独立调用。首先分类,重试短暂错误,并大声失败在你自己的坏请求上,并保持整个策略在一个边界后面,所以每个提供商的怪癖在一个地方映射到你的重试决定。