无状态聊天循环:在 Python 中手动调用 OpenAI API
通过自行管理消息历史记录,使用 OpenAI Python SDK 构建多轮对话,从而了解为何同样的循环机制是 LangChain 内存系统与智能体的基础。
像 LangChain 这样的框架让聊天模型看起来像是具有记忆能力的实体,但实际上底层的 API 并不会记住任何内容。每次调用都是独立的,“对话”实际上只是代码每次重新构建并发送的一系列消息。使用 OpenAI Python SDK 手动编写一次这样的循环,就能清楚地看出智能体框架自动化处理了哪些工作、为什么在对话过程中令牌成本会上升,以及需要提前防范哪些错误。
托管模型端点究竟是什么
OpenAI API 的结构非常简单:服务提供商在自身的 GPU 上运行模型,并通过 HTTPS 提供推理功能。你发送文本后,模型会按照常规的下一令牌生成流程产生令牌,而你需要为双向传输的每个令牌付费。由此会产生三个后果:
- 它是无状态的。之前的请求内容不会被保留,因此每个请求都必须包含模型所需的所有信息。
绝不要将 API 密钥放入源代码中。密钥可能通过 Git 历史记录、截图以及共享笔记本泄露,一旦密钥被窃,就意味着会有其他人使用你的账户消费。SDK 会自动从环境变量中读取 OPENAI_API_KEY,因此你的脚本完全无需编写任何处理密钥的代码。API 使用费用与 ChatGPT 订阅费是分开计费的,新账户通常需要一定的预付费余额。
角色:共享消息格式
一个请求会携带一系列消息,每条消息都有一个角色:
system用于存储你的指令,模型会给予这些指令更高的权重。user用于存储用户输入的内容。
assistant用于存储模型之前的回复,在智能体中则用于保存其调用的工具信息。这种格式在整个生态系统中都是通用的。Claude和Gemini采用了类似的理念但存在细微差别,Ollama也模仿了这一结构,而LangChain的SystemMessage、HumanMessage和AIMessage则是以类形式体现的这些角色。在生成内容之前,提供方会将列表整合为一个token序列,因此这些角色实际上构成了结构化的提示工程。
单个请求
第一个示例创建了一个客户端,该客户端从环境变量中读取密钥,发送系统指令和一个问题,然后打印出回复以及来自usage的提示词和完成token数量:
from openai import OpenAI
client = OpenAI() # key from env
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are a concise "
"Python assistant.",
},
{
"role": "user",
"content": "Why resend the whole "
"chat history each call?",
},
],
temperature=0,
)
print(resp.choices[0].message.content)
u = resp.usage
print(u.prompt_tokens, u.completion_tokens)
gpt-4o-mini 是一款适合学习的低成本模型;要换成更大型的模型只需修改一行代码,不过模型名称和价格会变化,因此请查看最新列表。temperature=0 可最大程度降低采样随机性,这是问答场景以及后续工具调用场景的理想默认值。每次调用时都要记录usage数据,它就是你的成本计量器。
自行进行对话
由于服务器会忘记所有内容,历史记录由你的代码负责管理:每次调用后,代码都会存储回复、添加下一个问题,然后再将所有内容重新提交。下面的辅助函数通过模块级的msgs列表来实现这一功能,该列表以一条系统消息作为开头:
msgs = [{
"role": "system",
"content": "You are a concise assistant.",
}]
def ask(text: str) -> str:
msgs.append(
{"role": "user", "content": text}
)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=msgs, # full history
temperature=0,
)
reply = resp.choices[0].message.content
msgs.append({
"role": "assistant",
"content": reply,
})
return reply
print(ask("Define a context window."))
print(ask("Now for a five-year-old."))
print(ask("Which answer was shorter?"))
第三个问题正好证明了这一点。模型只能比较这两个答案,因为它们都在msgs中并被重新发送。如果去掉添加助手回复的那行代码,模型就完全无法理解你的意图。
这个ask()函数会以多种形式出现。ChatGPT网页应用本质上就是带有用户界面的该函数实现。LangChain的RunnableWithMessageHistory则是对添加内容并重新发送这一过程的封装版本。智能体的内部循环也采用相同模式,只是多了工具调用和结果处理步骤。需要注意的是,三次重新发送的操作实际上等价于一次或两次,因此每次交互后输入的标记数量都会增加。
运行示例
先安装所需依赖,然后在shell中导出密钥,最后运行脚本。此处显示的密钥仅为占位符,请通过shell或密钥管理工具提供自己的密钥,绝不能将其保存在已提交的文件中:
pip install -r requirements.txt
export OPENAI_API_KEY="sk-..."
python examples/part02_chat.py
该脚本首先进行单轮调用,然后执行三轮对话;每次请求后都会报告令牌使用情况以及大致费用。如果缺少密钥,它会立即停止运行,并且故意不将其纳入持续集成流程中,因为该功能需要实际支付费用并使用真实密钥。如果想要为这类代码编写测试,可以对客户端进行模拟。
需提前考虑的隐患
- 密钥缺失:会出现HTTP 401状态的
AuthenticationError错误,通常是因为该变量在此环境中未被设置、拼写有误或包含粘贴带来的空白字符。应在程序启动时进行检查并立即报错。 - 速率限制:出现HTTP 429状态的
RateLimitError错误,表示请求次数过多或预付费余额已耗尽。循环运行的代理程序容易遇到此问题,因此应立即添加带延迟的重试机制。
不同服务提供商的格式一致
Claude 接收用户和助手的消息列表,系统提示被移到了单独的顶层参数中。Gemini 采用相同的对话列表模型,角色分别命名为 user 和 model。Ollama 提供了兼容 OpenAI 的接口,因此通过修改基础 URL 和模型名称,该代码即可调用本地模型;详情请参见 通过兼容 OpenAI 的接口调用 Claude、GPT 和 Gemini。正是这种一致性使得 LangChain 能够为众多服务提供商提供统一的抽象接口。
关键要点
- 聊天 API 是无状态的;代码负责管理并重新发送对话内容。
- 带有角色标签的消息列表实际上已成为跨服务提供商的标准格式。
- 由于每轮对话的输入token量都会增加,需记录每次调用的
usage数据。