Files
python-agent/docs/01-阶段一-前置核心基础/06-主流模型API实战.md
T
2026-09-22 15:42:25 +08:00

283 lines
15 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阶段一 · 小点 6:主流模型 API 实战
> 所属:阶段一 大模型基础与 Prompt 工程(Agent 核心根基)
> 定位:看懂 API 文档不算本事,能"一套代码换来换去对接不同模型、还能稳得住限流和流式"才是。这一节把"换模型只需改 base_url"和"流式 + 错误处理"这两件事彻底讲透。
## 精简大纲
1. 国际模型:OpenAI / Claude API 调用、流式、错误码、限流
2. 国内模型:DeepSeek / 通义千问 / 智谱,OpenAI 兼容接口
3. 本地大模型部署:Ollama 调试、vLLM 生产推理
4. 本地模型做 Agent 的局限与对策
## 学习内容详情
### 1. OpenAI 兼容生态(base_url 是钥匙)
#### 1.1 核心:替换 base_url 就完成换模型
```mermaid
graph LR
A[你的OpenAI兼容代码] --> B[base_url = api.openai.com]
A --> C[base_url = DeepSeek 地址]
A --> D[base_url = Ollama 11434]
A --> E[base_url = vLLM 8000]
```
- OpenAI SDK 开放 `base_url` 参数:换成 DeepSeek / Ollama / vLLM 的地址即完成模型切换,代码几乎不动。
- ⚠️ **兼容 ≠ 等同**:国内模型兼容的是"请求 / 响应格式",能力并不等同。尤其小模型的 function calling 稳定性、JSON 输出质量差异大。**换模型后必须重跑评测**,不能想当然。
> 🔬 **深度理解 · OpenAI 兼容接口(base_url)**:
> 1. **本质**:各家服务遵循 OpenAI SDK 定义的"HTTP 协议 + JSON 消息结构"这套接口契约,你只需把 `base_url` 指到别家,就复用几乎同一套调用代码——兼容的是"协议/格式",不是"能力大小"。
> 2. **机制**:`OpenAI(api_key=..., base_url=...)` 之后所有请求都发往该 base_url 下的 `/v1/chat/completions` 等端点;DeepSeek / Ollama / vLLM 实现了同构端点,故可无缝替换;而 Claude 走的是另一套 anthropic 协议,得用官方 SDK。
> 3. **为什么重要**:它让 Agent 的模型接入层"可插拔"——用环境变量换 base_url+model,即可在云端/本地/不同厂商间横跳,降低绑定成本,也是做模型评测对比的工程基础。
> 4. **易错点**:协议兼容 ≠ 能力对齐——小模型的 function calling 稳定性、JSON 输出质量差异大;"能出字"不等于"能力到位",换模型必须重跑评测,不能想当然。
```python
import os
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("OPENAI_API_KEY"), # 密钥走环境变量
base_url=os.getenv("BASE_URL", "https://api.openai.com/v1"),
# ↑ 想切到 DeepSeek 就把它换成 "https://api.deepseek.com/v1"
)
resp = client.chat.completions.create(
model="gpt-4o", # 换成 DeepSeek 要换成其模型名
messages=[{"role": "user", "content": "你好"}],
temperature=0.2, # 结构化场景调低
)
print(resp.choices[0].message.content)
```
#### 1.2 Claude 原生 SDK 对照
海外直连 Claude 不走 OpenAI 兼容格式,而是用官方 `anthropic` SDK——整体写法高度相似,但有三处结构差异要记牢。
```bash
pip install anthropic
```
```python
import os
from anthropic import Anthropic
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
resp = client.messages.create(
model="claude-sonnet-4-20250514", # 如需最新可替换为 Claude Sonnet 4.5 系列 ID(见官方 docs)
max_tokens=1024, # 必传: 限制输出长度
system="你是一位严谨的技术助手。", # 差异1: system 是顶层参数, 不塞进 messages
messages=[{"role": "user", "content": "用一句话介绍 Agent"}],
)
# 差异2: 响应是 content blocks 列表, 文本要取每个块的 .text
print("".join(b.text for b in resp.content if b.type == "text"))
```
流式则换成 `client.messages.stream(...)`(上下文管理器,逐事件消费):
```python
with client.messages.stream(
model="claude-sonnet-4-20250514",
max_tokens=1024,
system="你是一位严谨的技术助手。",
messages=[{"role": "user", "content": "写一段300字介绍AI Agent"}],
) as stream:
for text in stream.text_stream: # text_stream 只吐文本增量, 相当于 delta.content
print(text, end="")
```
与 OpenAI 格式的三个差异点:
| 差异点 | OpenAI 兼容写法 | Claude 原生写法 |
|-|-|-|
| system 位置 | messages 首条 `{"role": "system", ...}` | 顶层参数 `system="..."` |
| 响应结构 | `resp.choices[0].message.content`(字符串) | `resp.content` 是 content blocks 列表,文本块要取 `.text` |
| 工具调用 | `tools` / `tool_choice` | 参数同名、无独立命名差异,照常支持 function calling |
> ⚠️ 国内生产环境常用 DeepSeek / Qwen 的 OpenAI 兼容接口,Claude 原生 SDK 主要用于海外模型直连;两者切换成本就集中在「system 位置」与「content 结构」这两处,改对它们基本就完成了平移。
### 2. 流式调用(stream=True)
#### 2.1 为什么流式重要
```mermaid
sequenceDiagram
participant C as 你的程序
participant S as 服务端
C->>S: 发送请求 stream=True
S-->>C: [第1片] 几百毫秒就到
S-->>C: [第2片] ...
Note over C,S: 首字延迟从数秒降到几百毫秒
S-->>C: [最后一片] 流结束
```
- 服务端边生成边返回分片,**首字延迟从数秒降到几百毫秒**。Agent 的 SSE 输出、前端打字机效果全建立在它之上。
> 🔬 **深度理解 · 流式调用(stream=True / SSE)**:
> 1. **本质**:把"等服务端出完整答复"改成"服务端生成一个 token 就回传一个分片",客户端边收边用,把首字延迟从秒级降到接近模型第一 token 的耗时。
> 2. **机制**:服务端返回一段 SSE 事件流(`data: {...}\n\n` 逐块发送),客户端 `for chunk in stream` 逐块消费;每个 chunk 的 `choices[0].delta.content` 只含本步增量,多数 chunk 是空的元数据,需过滤后拼接成完整文本。
> 3. **为什么重要**:Agent 要边推理边把结果实时推给用户,"打字机"体验依赖它;也避免一次性等全场而拉高延迟与超时风险。
> 4. **易错点**:delta 为 null/空是"结束或元数据"信号而非 bug;流式下超时判定应看"多久没数据"(ReadTimeout)而不是总时长;要先把分片拼进 buffer 再处理,别把单块当完整结果。
```python
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
# stream=True: 用 for 循环逐块接收; 每块都是一个增量片段
stream = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "写一段300字介绍AI Agent"}],
stream=True, # 关键开关: 开启流式
)
buffer: list[str] = []
for chunk in stream:
# 每个 chunk 里才真正有增量文本; 有的 chunk 是空的/元数据
delta = chunk.choices[0].delta.content
if delta: # 为空说明是结束或元数据, 跳过
buffer.append(delta)
print(delta, end="") # 前端在这里实时渲染 (打字机)
print() # 换行
print("完整回答:", "".join(buffer))
```
### 3. 错误码与限流处理
| 错误码 | 含义 | 处理 |
|-|-|-|
| 429 | 触发限流 | 等待退避后重试 |
| 401 | 密钥无效 | 检查 Key |
| 404 | 模型名写错 | 检查模型名 |
| 5xx | 服务商故障 | 重试或降级兜底 |
- 防御组合拳:信号量控并发 + 退避重试(tenacity)+ 必要时申请提额。
```python
import os
import time
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
def call_with_retry(messages, max_attempts: int = 3):
"""对 429/5xx 做指数退避重试, 401/404 直接失败不重试。"""
wait = 1
for attempt in range(1, max_attempts + 1):
try:
resp = client.chat.completions.create(model="gpt-4o", messages=messages)
return resp.choices[0].message.content
except Exception as e:
code = getattr(e, "status_code", None) # 拿到HTTP状态码
if code in (401, 404): # 密钥/模型名错误, 重试没用
raise
print(f"第{attempt}次失败({code}), {wait}s后重试: {e}")
time.sleep(wait)
wait *= 2 # 1s→2s→4s 指数退避
if attempt == max_attempts:
raise # 最后也失败就抛出去给上层兜底
```
### 4. 本地模型部署
| 工具 | 用途 | 特点 |
|-|-|-|
| Ollama | 本地调试 | 一键 `ollama pull` + `ollama serve`,自带 OpenAI 兼容端点 |
| vLLM | 生产吞吐 | 连续批处理 + PagedAttention 拉满 GPU 利用率 |
```bash
# Ollama: 拉一个 7B 小模型并启动 (默认端口 11434)
ollama pull qwen2.5:7b
ollama serve # 启动服务, 自带 OpenAI 兼容端点
# vLLM: 生产用, 端口 8000
vllm serve Qwen/Qwen2.5-7B-Instruct --port 8000
```
> ⏳ **短期可不深究**:vLLM 的 PagedAttention、连续批处理等内核级优化属于"生产规模化推理"的专业话题,第一遍会用 `ollama pull` + `ollama serve` 起本地模型做开发验证即可;真要到卡上规模部署时再研究 vLLM。
```python
# 本地模型调用: 只需换 base_url 和 model, 其他代码完全一致
from openai import OpenAI
local = OpenAI(
api_key="EMPTY", # 本地服务一般不需要真实key
base_url="http://localhost:11434/v1", # Ollama 的 OpenAI 兼容端点
)
resp = local.chat.completions.create(
model="qwen2.5:7b",
messages=[{"role": "user", "content": "1+1等于几"}],
)
print(resp.choices[0].message.content)
```
### 5. 本地模型做 Agent 的局限与对策
| 局限 | 现象 | 对策 |
|-|-|-|
| function calling 不稳 | 常漏参 / 传错参 | 工具 Function 描述写细 + Few-shot 给足示范 |
| JSON 输出不稳 | 结构错 / 被截断 | pydantic 强校验 + 「让模型重新输出」兜底 |
| 能力天花板 | 复杂推理差 | 关键业务场景直接切换云端大模型 |
```python
# 本地模型 JSON 兜底: 解析失败就让模型重新输出一次
def ask_llm_structured(prompt: str, max_try: int = 3):
import json
for i in range(max_try):
out = local.chat.completions.create( # local 为上文定义的本地 OpenAI 客户端(跨节依赖, 需先定义)
model="qwen2.5:7b",
messages=[{"role": "user", "content": prompt}],
temperature=0.1, # 结构化输出调低
).choices[0].message.content
try:
return json.loads(out) # 成功解析就返回
except json.JSONDecodeError:
prompt += f"\n上次输出不是合法JSON: {out}\n请重新只输出JSON。" # 反馈重试
raise RuntimeError("多次重试仍无法得到合法JSON,建议切换云端大模型")
```
## 本节自检
- [ ] 至少实操一家国内 + 一家国际模型 API(含流式调用)
- [ ] 能用 Ollama 本地部署并完成一次工具调用全流程
- [ ] 能为 429/5xx 写退避重试,对 401/404 直接 fail-fast
## 本节配套思考题(快速入门的检验)
1. 如果把 `base_url` 从 OpenAI 换成 DeepSeek,代码需要改哪几处?"兼容"到底兼容什么,不兼容什么?
2. 流式调用里 `chunk.choices[0].delta.content` 为空说明什么?你在 `for chunk in stream` 里如何过滤无用分片?
3. 本地小模型做 function calling 老出错,你会有哪三步具体对策(描述、示范、校验/兜底)?
## 本节常见面试题(深度解析)
> 针对本节核心知识的面试高频点,配合"本质+机制"式理解,能让你答得既有深度又有广度。
### 面试题 1:把模型的 `base_url` 从 OpenAI 换成 DeepSeek 就能"无缝换模型",这句话对在哪里、错在哪里?
- **面试官想考察**:能否分清"协议兼容"与"能力对等",理解 OpenAI 兼容生态的真相。
- **专业作答(含深度)**:
1. **对的地方**:OpenAI SDK 把请求发往 `base_url` 指定的 `/v1/chat/completions` 等端点,DeepSeek/Ollama/vLLM 实现了同构协议,所以聊天/补全这类基础的调用确实几乎零改动。
2. **要改的地方**:`model` 名要换成目标模型(模型名不通用);若有流式、工具调用、JSON 模式等高级特性,各家支持度并不一致,可能仍需微调。
3. **错/风险的地方**:兼容的是"协议格式"不是"能力"——小模型的 function calling 稳定性、JSON 输出质量差异大;**换模型后必须重跑评测**,"能出字"≠"能力到位"。
4. **例外**:Claude 原生走另一套 anthropic 协议(system/content blocks 结构不同),需要另一套 SDK,不是 base_url 能直接覆盖。
- **加分亮点 / 深度追问**:可提"通用兼容层"(如 LiteLLM)把各家协议统一成一套 API 的业界实践,以及 base_url+model 由环境变量注入以便无侵入切换。
### 面试题 2:流式调用里 `for chunk in stream` 收到的 chunk,为什么 `choices[0].delta.content` 经常是空?
- **面试官想考察**:是否真正理解 SSE 事件流与增量文本的结构,而不是只会抄样例。
- **专业作答(含深度)**:
1. **流式结构**:服务端把响应拆成多个 chunk 通过 SSE 逐块推送,真正的文本增量放在 `choices[0].delta.content` 里。
2. **空的含义**:非首块的很多 chunk 是"用法统计/结束标记/角色切换"等元数据,`delta.content` 为 None,不是 bug 是该跳过。
3. **正确做法**:迭代时判断 `if delta:` 才 append,最后用 `"".join(buffer)` 拼完整文本;不能把单个 chunk 当完整结果用。
4. **超时注意**:流式下要按"多久没有新数据"判定(ReadTimeout)而非总时长,因为流可能持续。
- **加分亮点 / 深度追问**:可提 SSE 协议本身(`data:` 前缀、`[DONE]` 结束标记)、以及"一个分片可能是半个 token/半个字"的定位问题——增量按 token 边界切,中文可能需要 buffer 后再裁切完整语义。
### 面试题 3:面对 429 / 401 / 404 / 5xx 四类错误码,你会怎么处理?为什么不能一刀切地重试?
- **面试官想考察**:是否掌握"按错误语义分类处置"的工程思维,理解重试的前提。
- **专业作答(含深度)**:
1. **429 限流**:等待退避(含 `Retry-After` 响应头优先)后重试,同时配合信号量控并发,从源头减少触发。
2. **401 密钥 / 404 模型名**:属于配置性错误,重试必败,应 fail-fast——立即排查 Key/模型名,而不是空耗重试。
3. **5xx 服务商故障**:属瞬时系统错误,可重试或降级兜底(换备用模型/提示用户),配合指数退避避免重试风暴。
4. **为什么不一刀切重试**:确定性错误(401/404、参数非法)重试一万次也无效,还浪费配额、拖延主线;只有"瞬时/可自愈"的错误才值得重试,故要先按状态码分类。
- **加分亮点 / 深度追问**:可延伸"幂等性"——重试在写入类接口上的风险、以及把错误分类下沉到 SDK 层的"可重试异常"抽象,让重试策略集中维护。