# 阶段一 · 小点 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 层的"可重试异常"抽象,让重试策略集中维护。