Files
2026-09-22 15:42:25 +08:00

15 KiB
Executable File
Raw Permalink Blame History

阶段一 · 小点 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 就完成换模型

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 输出质量差异大;"能出字"不等于"能力到位",换模型必须重跑评测,不能想当然。
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——整体写法高度相似,但有三处结构差异要记牢。

pip install anthropic
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(...)(上下文管理器,逐事件消费):

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 为什么流式重要

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 再处理,别把单块当完整结果。
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)+ 必要时申请提额。
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 利用率
# 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。

# 本地模型调用: 只需换 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 强校验 + 「让模型重新输出」兜底
能力天花板 复杂推理差 关键业务场景直接切换云端大模型
# 本地模型 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 层的"可重试异常"抽象,让重试策略集中维护。