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 | ① 创建客户端 OpenAI(base_url=...) # 决定请求发给谁 |
二、OpenAI 库的基础使用
2.1 客户端对象与 base_url
OpenAI 是 SDK 的入口类,构造时主要给两个参数:
| 参数 | 作用 | 说明 |
|---|---|---|
base_url |
请求发往哪个服务 | 默认是 OpenAI 官方地址,接第三方必须修改 |
api_key |
身份凭证 | 云端服务需要;本地 Ollama 不校验,可以不传 |
1 | from openai import OpenAI |
要点:接口形态一致,只有地址不同。更换模型不需要改动代码结构,只改这两个参数。这正是”兼容模式”的意义所在。
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 | messages=[ |
除了 system 和用户提问,还可以在 messages 中间插入一条 assistant,例如”好的,有啥想问的,我会简洁回答,绝不废话”。这条不是真实的历史记录,而是预置一次”模型已经这样答过”的上下文,用来强化 system 设定的约束。这实际上已经是提示词工程中”少样本”思路的雏形。
2.4 非流式返回结果
不加 stream 参数时,返回的是一个完整的响应对象,答案位于 choices[0].message.content:
1 | response = client.chat.completions.create( |
三、APIKEY 的保护
3.1 为什么不能硬编码
把 sk-xxx 直接写进代码,一旦代码提交到仓库,就等于把 key 公开了。别人拿到后可以用它调用模型,产生的费用记在账号名下。所以 APIKEY 必须与代码分离。
3.2 三种写法
1 | # ① 硬编码:能用,但绝对不能提交仓库 |
把三种方式并列写成注释留在客户端构造处,切换云端和本地时只改注释开关,改动最小:
1 | client = OpenAI( |
3.3 环境变量配置
只对当前终端会话生效:
1 | $env:DASHSCOPE_API_KEY="sk-xxx" |
写入用户环境变量永久生效,需要重开终端:
1 | [Environment]::SetEnvironmentVariable("DASHSCOPE_API_KEY","sk-xxx","User") |
Python 中读取回来验证:
1 | import os |
3.4 注意事项
- 环境变量读不到值时返回
None,不会报错。错误要等到客户端初始化时才抛出,报错信息不容易看懂,所以先print一次确认能取到值。 .env文件配合python-dotenv是另一种常见做法,但必须把.env写进.gitignore,否则等于没做保护。- 已经提交进仓库的 key,应当立刻去控制台吊销重建,仅仅”后来把文件删了”没有意义——历史提交里仍然存在。
四、流式输出模式
4.1 为什么需要流式输出
非流式调用要等模型把整段话全部生成完,才一次性返回结果。大模型逐字生成,一段长回答可能要十几秒,用户端表现为长时间空白然后突然刷出一整段。
流式输出(stream=True)把生成过程拆开,生成一段就返回一段,程序边接收边打印,用户端立刻能看到文字逐个出现。模型侧的总耗时并没有减少,但体感快得多。
4.2 基本用法
只需要在 create() 里加上 stream=True,然后把取值方式从”读完整消息”改成”遍历碎片”:
1 | response = client.chat.completions.create( |
关键点:
stream=True是create()的一个参数,不是单独的方法调用。- 打印时必须用
end='',否则每个片段都会自动换行,输出变成一列。 flush=True让输出立刻刷到屏幕上。不写的话内容会先攒在缓冲区里,等攒够才一次性显示,效果等同于没开流式。
4.3 两种取值路径
流式和非流式的取值路径完全不同,同一个 response 对象不能按两种方式消费:
| 模式 | 参数 | 取值方式 |
|---|---|---|
| 非流式 | 默认 | response.choices[0].message.content |
| 流式 | stream=True |
遍历 chunk.choices[0].delta.content |
一个是 message(完整消息),一个是 delta(增量片段),写混了会报错。不用的那种写法注释掉留在原地,方便来回对照:
1 | # 处理非流式返回结果 |
4.4 深度思考模型:reasoning_content
部分模型(qwen3 系列等)支持深度思考,会先输出一段思维链再给最终答案。两部分不在同一个字段里:
- 思维链在
delta.reasoning_content - 正式答案在
delta.content
在请求中通过 extra_body 打开这一能力:
1 | completion = client.chat.completions.create( |
处理流程:
1 | 逐 chunk 遍历 |
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 | from openai import OpenAI |
关键点:
- 用户输入通过
append加入messages,然后把整个列表传给模型,而不是只传这一句。 - 模型回复结束后,必须把这一轮 AI 的完整回复再 append 回
messages,成为下一轮的历史。这一步最容易漏。 - 流式返回的是一堆碎片,所以要先用
ai列表收集,循环结束后''.join(ai)拼成完整字符串再写回。直接 append 碎片会导致历史里存的是残缺内容。 - 最后那句
print('\n' + str(messages) + '\n')用于调试,把每轮结束后的完整消息列表打出来,可以直观看到历史是如何堆积的。排查”模型好像忘了前面说过什么”这类问题时,先看这里。
5.3 两种构造历史的方式
| 方式 | 特点 | 适用场景 |
|---|---|---|
| 一次性写死多轮 | 历史在调用前就固定好 | 验证模型能否利用历史推理 |
| 循环中累加 | 每轮把用户输入和模型回复追加进去 | 可交互的连续会话 |
写死多轮的典型例子如下,用来验证模型确实能利用历史做推理(2 + 3 = 5):
1 | messages=[ |
两种方式本质完全一样,都是把历史完整放进 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 | if chunk.choices and chunk.choices[0].delta.content: |
- 提醒:这种写法放在
if条件里没问题(空列表是 falsy),一旦把表达式的结果当值来用就会出错。凡是and的结果要用作数据,先拆开写。
问题 3:未判断空的 chunk
- 现象:只有一处代码写了
if not chunk.choices: continue,其余流式循环都没有。 - 原因:流式返回中会穿插
choices为空的心跳包,用于保活,不带实际内容。 - 解决:所有遍历 chunk 的循环统一加上判空。
- 提醒:这类问题属于偶发崩溃而非必现,本地跑几次可能都正常,压力上来才暴露。防御性判断应当保持一致。
问题 4:循环没有退出条件
- 现象:多轮对话用
while True实现,输入exit会被当成正常提问发给模型。 - 解决:补一个出口。
1 | if userin.lower() in ('exit', 'quit', '退出'): |
- 提醒:交互式脚本都应当留一个明确的退出方式,否则只能靠
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 |