01 大模型接入与OpenAI库调用:APIKEY保护、流式输出与多轮对话

01 大模型接入与OpenAI库调用:APIKEY保护、流式输出与多轮对话

一、大模型接入概述

1.1 为什么要用 OpenAI 库接入

大模型部署在云端或本地的服务器上,程序要调用它,本质上是发一个 HTTP 请求过去。如果手写 HTTP 请求,需要自己拼装 JSON 报文、处理鉴权头、再解析流式响应,成本很高。

OpenAI 官方提供的 Python SDK 把这一层全部封装好了,对外只暴露一个 OpenAI 类。关键在于,这套 SDK 并不绑定 OpenAI 自家的模型——只要某个服务实现了”兼容 OpenAI 的接口”,把 base_url 指过去就能用同一份代码调用。阿里云百炼的通义千问、本地部署的 Ollama 都提供了这样的兼容接口,所以云端和本地可以共用一套写法。

后续学习 LangChain 时,它同样是在这一层调用能力之上再封装,所以这里的调用形态是后面所有内容的基础。

1.2 云端与本地两种部署方式

方式 说明 特点
云端(阿里云百炼) 模型由厂商部署,通过网络调用 模型能力强,需要 APIKEY,按量计费
本地(Ollama) 模型下载到本机,由 Ollama 提供兼容接口 免费、数据不出本机,模型规模小、能力有限

Ollama 是本地大模型的运行框架,启动后会监听 11434 端口,并额外暴露一个 /v1 路径用于兼容 OpenAI SDK。课程资料中打包了预下载的 models 文件夹,整个放进 C:\Users\<用户名>\.ollama 覆盖,就能免去下载过程直接使用演示模型。

1.3 调用流程

无论访问云端还是本地,调用都是三步,差别只在参数:

1
2
3
4
5
6
7
① 创建客户端 OpenAI(base_url=...)      # 决定请求发给谁
↓
② 调用 client.chat.completions.create( # 决定用哪个模型、聊什么
model=..., messages=[...], stream=...)
↓
③ 消费返回结果 # 非流式取 message.content
# 流式迭代 chunk.choices[0].delta.content

二、OpenAI 库的基础使用

2.1 客户端对象与 base_url

OpenAI 是 SDK 的入口类,构造时主要给两个参数:

参数 作用 说明
base_url 请求发往哪个服务 默认是 OpenAI 官方地址,接第三方必须修改
api_key 身份凭证 云端服务需要;本地 Ollama 不校验,可以不传
1
2
3
4
5
from openai import OpenAI

client = OpenAI(
base_url="http://localhost:11434/v1"
)

要点:接口形态一致,只有地址不同。更换模型不需要改动代码结构,只改这两个参数。这正是”兼容模式”的意义所在。

2.2 接入地址对比

场景 base_url model 示例 api_key
阿里云百炼(自定义开通的服务) https://ws-u7840hnq50vkil5d.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 qwen3.8-flash 需要
阿里云百炼(官方通义兼容地址) https://dashscope.aliyuncs.com/compatible-mode/v1 qwen3-max 需要
本地 Ollama http://localhost:11434/v1 redule26/huihui_ai_qwen2.5-vl-7b-abliterated 不需要

两者的 base_url 都以 /compatible-mode/v1 结尾,本地则以 /v1 结尾。Ollama 的这个 /v1 后缀是它为兼容 OpenAI SDK 额外暴露的路径,写漏了会直接 404。

2.3 messages 消息结构

messages 是一个字典列表,每个字典必须包含 role 和 content 两个键。role 决定这条消息由谁发出:

role 含义 常见用法
system 系统设定,描述模型的身份和语气 放在最前面,对整个会话全程生效
user 用户输入 每轮对话追加一条
assistant 模型此前的回复 手动构造历史时才需要

system 的作用非常直接,同一句提问,只改 system 得到的结果完全不同:

1
2
3
4
messages=[
{"role":"system","content":"你是一个编程助手,简洁不废话"},
{"role":"user","content":"python的for循环咋写"}
]

除了 system 和用户提问,还可以在 messages 中间插入一条 assistant,例如”好的,有啥想问的,我会简洁回答,绝不废话”。这条不是真实的历史记录,而是预置一次”模型已经这样答过”的上下文,用来强化 system 设定的约束。这实际上已经是提示词工程中”少样本”思路的雏形。

2.4 非流式返回结果

不加 stream 参数时,返回的是一个完整的响应对象,答案位于 choices[0].message.content:

1
2
3
4
5
6
response = client.chat.completions.create(
model="qwen3-max",
messages=messages
)

print(response.choices[0].message.content)

三、APIKEY 的保护

3.1 为什么不能硬编码

把 sk-xxx 直接写进代码,一旦代码提交到仓库,就等于把 key 公开了。别人拿到后可以用它调用模型,产生的费用记在账号名下。所以 APIKEY 必须与代码分离。

3.2 三种写法

1
2
3
4
5
6
7
8
9
10
11
12
# ① 硬编码:能用,但绝对不能提交仓库
client = OpenAI(base_url="...", api_key="sk-xxx")

# ② 环境变量:推荐做法
import os
client = OpenAI(
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://ws-u7840hnq50vkil5d.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
)

# ③ 本地 Ollama:不需要 key
client = OpenAI(base_url="http://localhost:11434/v1")

把三种方式并列写成注释留在客户端构造处,切换云端和本地时只改注释开关,改动最小:

1
2
3
4
5
6
client = OpenAI(
# 如果没有配置环境变量,请用阿里云百炼API Key替换:api_key="sk-xxx"
# api_key=os.getenv("DASHSCOPE_API_KEY"),
# base_url="https://ws-u7840hnq50vkil5d.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
base_url="http://localhost:11434/v1",
)

3.3 环境变量配置

只对当前终端会话生效:

1
$env:DASHSCOPE_API_KEY="sk-xxx"

写入用户环境变量永久生效,需要重开终端:

1
[Environment]::SetEnvironmentVariable("DASHSCOPE_API_KEY","sk-xxx","User")

Python 中读取回来验证:

1
2
import os
print(os.getenv("DASHSCOPE_API_KEY"))

3.4 注意事项

  • 环境变量读不到值时返回 None,不会报错。错误要等到客户端初始化时才抛出,报错信息不容易看懂,所以先 print 一次确认能取到值。
  • .env 文件配合 python-dotenv 是另一种常见做法,但必须把 .env 写进 .gitignore,否则等于没做保护。
  • 已经提交进仓库的 key,应当立刻去控制台吊销重建,仅仅”后来把文件删了”没有意义——历史提交里仍然存在。

四、流式输出模式

4.1 为什么需要流式输出

非流式调用要等模型把整段话全部生成完,才一次性返回结果。大模型逐字生成,一段长回答可能要十几秒,用户端表现为长时间空白然后突然刷出一整段。

流式输出(stream=True)把生成过程拆开,生成一段就返回一段,程序边接收边打印,用户端立刻能看到文字逐个出现。模型侧的总耗时并没有减少,但体感快得多。

4.2 基本用法

只需要在 create() 里加上 stream=True,然后把取值方式从”读完整消息”改成”遍历碎片”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
response = client.chat.completions.create(
model='redule26/huihui_ai_qwen2.5-vl-7b-abliterated',
messages=[
{"role":"system","content":"你是一个编程助手,简洁不废话"},
{"role":"user","content":"python的for循环咋写"}
],
stream=True
)

for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content,
end='', # 末尾不换行,片段直接拼接
flush=True # 立即刷新缓冲区
)

关键点:

  • stream=True 是 create() 的一个参数,不是单独的方法调用。
  • 打印时必须用 end='',否则每个片段都会自动换行,输出变成一列。
  • flush=True 让输出立刻刷到屏幕上。不写的话内容会先攒在缓冲区里,等攒够才一次性显示,效果等同于没开流式。

4.3 两种取值路径

流式和非流式的取值路径完全不同,同一个 response 对象不能按两种方式消费:

模式 参数 取值方式
非流式 默认 response.choices[0].message.content
流式 stream=True 遍历 chunk.choices[0].delta.content

一个是 message(完整消息),一个是 delta(增量片段),写混了会报错。不用的那种写法注释掉留在原地,方便来回对照:

1
2
# 处理非流式返回结果
# print(response.choices[0].message.content)

4.4 深度思考模型:reasoning_content

部分模型(qwen3 系列等)支持深度思考,会先输出一段思维链再给最终答案。两部分不在同一个字段里:

  • 思维链在 delta.reasoning_content
  • 正式答案在 delta.content

在请求中通过 extra_body 打开这一能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
completion = client.chat.completions.create(
model="redule26/huihui_ai_qwen2.5-vl-7b-abliterated",
messages=messages,
extra_body={"enable_thinking": True},
stream=True
)

is_answering = False # 是否进入回复阶段
print("\n" + "=" * 20 + "思考过程" + "=" * 20)

for chunk in completion:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
# 思维链
if hasattr(delta, "reasoning_content") and delta.reasoning_content is not None:
if not is_answering:
print(delta.reasoning_content, end="", flush=True)
# 正文
if hasattr(delta, "content") and delta.content:
if not is_answering:
print("\n" + "=" * 20 + "完整回复" + "=" * 20)
is_answering = True
print(delta.content, end="", flush=True)

处理流程:

1
2
3
逐 chunk 遍历
├─ delta.reasoning_content 非空 → 打印思考过程(不切段)
└─ delta.content 非空 → 先换行打分隔标题,再用 is_answering 锁定,后续只打正文

is_answering 标志位的作用是给输出分段。思维链是分成很多个碎片流式返回的,如果不加这个标志,每个碎片都会重复打印一次”完整回复”的标题。

4.5 易错点

  • 必须先判断 chunk.choices 是否为空。流式过程中会出现 choices 为空的心跳包,不加 if not chunk.choices: continue 会抛 IndexError。
  • 用 hasattr 判断字段是否存在,而不是直接访问 delta.reasoning_content。不支持深度思考的模型没有这个属性,直接访问会抛 AttributeError。
  • extra_body 是 SDK 的逃生通道,用来传各家自定义的参数。放进这个字典意味着该参数不是 OpenAI 标准参数,换一个不支持的模型可能直接被拒。

五、附带历史消息实现多轮对话

5.1 模型为什么会”失忆”

每次调用都是一次独立的 HTTP 请求,服务端不会为某次对话保留状态,模型本身不知道上一轮说了什么。所谓”多轮对话”,本质是每请求一次,就把之前的全部对话重新发送一遍。

这也意味着对话历史需要由调用方自己维护。

5.2 messages 累加实现

把历史放进 messages,每轮追加而不是重建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from openai import OpenAI

client = OpenAI(
base_url='https://ws-u7840hnq50vkil5d.cn-beijing.maas.aliyuncs.com/compatible-mode/v1'
)

# 预设的历史:system 定规则,assistant 给一个示范回答
messages = [
{"role": "system", "content": "你是一个编程助手,简洁不废话"},
{"role": "assistant", "content": "好的,有啥想问的,我会简洁回答,绝不废话"},
]

while True:
userin = input('你:')
messages.append({"role": "user", "content": userin})

print('ai:')
response = client.chat.completions.create(
model="qwen3.8-flash",
messages=messages,
stream=True,
extra_body={"enable_thinking": True},
)

ai = []
for chunk in response:
if(not chunk.choices):continue
if chunk.choices and chunk.choices[0].delta.content:
ai.append(chunk.choices[0].delta.content)
print(chunk.choices[0].delta.content, end='', flush=True)
print()

messages.append({"role": "assistant", "content": ''.join(ai)})
print('\n' + str(messages) + '\n')

关键点:

  • 用户输入通过 append 加入 messages,然后把整个列表传给模型,而不是只传这一句。
  • 模型回复结束后,必须把这一轮 AI 的完整回复再 append 回 messages,成为下一轮的历史。这一步最容易漏。
  • 流式返回的是一堆碎片,所以要先用 ai 列表收集,循环结束后 ''.join(ai) 拼成完整字符串再写回。直接 append 碎片会导致历史里存的是残缺内容。
  • 最后那句 print('\n' + str(messages) + '\n') 用于调试,把每轮结束后的完整消息列表打出来,可以直观看到历史是如何堆积的。排查”模型好像忘了前面说过什么”这类问题时,先看这里。

5.3 两种构造历史的方式

方式 特点 适用场景
一次性写死多轮 历史在调用前就固定好 验证模型能否利用历史推理
循环中累加 每轮把用户输入和模型回复追加进去 可交互的连续会话

写死多轮的典型例子如下,用来验证模型确实能利用历史做推理(2 + 3 = 5):

1
2
3
4
5
6
7
8
messages=[
{"role": "system", "content": "你是AI助理,回答很简洁"},
{"role": "user", "content": "小明有2条宠物狗"},
{"role": "assistant", "content": "好的"},
{"role": "user", "content": "小红有3只宠物猫"},
{"role": "assistant", "content": "好的"},
{"role": "user", "content": "总共有几个宠物?"}
]

两种方式本质完全一样,都是把历史完整放进 messages 一起发送,区别只在历史是静态写死的还是动态累积的。

5.4 现有实现的局限

对话轮数越多,messages 越长,每次请求携带的 token 数量和响应延迟都会随之上升,最终还会顶到模型的上下文长度上限。手动无限 append 无法解决这个问题。

这一局限由 LangChain 的 Memory 组件处理,它提供历史的自动裁剪、摘要和持久化能力,后面会单独学习。


六、问题与解决

问题 1:变量名拼写错误

  • 现象:客户端对象被写成了 clinet。
  • 原因:手写时漏掉了字母 i。
  • 解决:改成 client,与其余脚本保持一致。
  • 提醒:对象名拼错后,后续所有调用都跟着错,且报错位置在调用处而不是定义处,排查时容易被带偏。

问题 2:and 运算符的结果被当成数据使用

  • 现象:收集流式片段时写成了 ai.append(chunk.choices and chunk.choices[0].delta.content)。
  • 原因:Python 的 and 返回的是其中一个操作数本身,而不是布尔值。当 chunk.choices 为空列表时,整个表达式返回 []。把这个结果 append 进 ai,后续 ''.join(ai) 就会把非字符串内容混进去,历史直接被污染。
  • 解决:判断和取值分开写。
1
2
3
if chunk.choices and chunk.choices[0].delta.content:
ai.append(chunk.choices[0].delta.content)
print(chunk.choices[0].delta.content, end='', flush=True)
  • 提醒:这种写法放在 if 条件里没问题(空列表是 falsy),一旦把表达式的结果当值来用就会出错。凡是 and 的结果要用作数据,先拆开写。

问题 3:未判断空的 chunk

  • 现象:只有一处代码写了 if not chunk.choices: continue,其余流式循环都没有。
  • 原因:流式返回中会穿插 choices 为空的心跳包,用于保活,不带实际内容。
  • 解决:所有遍历 chunk 的循环统一加上判空。
  • 提醒:这类问题属于偶发崩溃而非必现,本地跑几次可能都正常,压力上来才暴露。防御性判断应当保持一致。

问题 4:循环没有退出条件

  • 现象:多轮对话用 while True 实现,输入 exit 会被当成正常提问发给模型。
  • 解决:补一个出口。
1
2
if userin.lower() in ('exit', 'quit', '退出'):
break
  • 提醒:交互式脚本都应当留一个明确的退出方式,否则只能靠 Ctrl+C 强制中断。

七、未解决的问题

  • enable_thinking 对本地模型是否生效:测试时用的是本地 Ollama 模型,同时传了 extra_body={"enable_thinking": True},但本地模型是否响应这个参数没有实际验证过。如果报参数错误,去掉 extra_body 即可,不影响非流式和流式本身。
  • 自定义服务的模型名归属:百炼上自定义开通的服务对应的模型名(如 qwen3.8-flash)与官方通义地址的模型名(如 qwen3-max)之间的对应关系,需要结合实际开通情况确认。

今日总结

模块 核心要点
接入方式 用 OpenAI SDK 作为兼容层,云端百炼与本地 Ollama 共用同一套代码
客户端 OpenAI(base_url=..., api_key=...),切换服务只改这两个参数
调用地址 百炼用 /compatible-mode/v1,本地 Ollama 用 localhost:11434/v1
APIKEY 禁止硬编码,用 os.getenv("DASHSCOPE_API_KEY") 读取
消息结构 system 定人设、user 提问、assistant 存历史,顺序即生效顺序
非流式 取 response.choices[0].message.content
流式 stream=True + 遍历 chunk.choices[0].delta.content,配合 end='' 与 flush=True
深度思考 extra_body={"enable_thinking": True};delta.reasoning_content 是思维链,delta.content 是正文
多轮对话 模型不保存状态,每轮把完整历史重新发送;AI 回复需收集后再 append 回 messages
防御性写法 遍历 chunk 前先判 chunk.choices 是否为空,避免偶发 IndexError