第2章 调用大模型:你的第一个应用
上一章我们看清了大模型是什么、能做什么、有哪些边界。这一章是全书的第一堂"动手课":申请一个 API Key,发出人生第一次真实的大模型调用;然后依次学会流式输出、结构化输出(JSON)、函数调用,最后把成本、延迟、可靠性这三件"工程师的日常焦虑"一次讲透。学完这一章,你手里就有一个真正能聊天的应用 - 它是后面 RAG 与 Agent 的地基。
2.1 拿到 API Key,发出第一次调用
为什么从 API 开始?因为这是门槛最低、见效最快的方式:不需要 GPU,不需要部署,注册账号拿到 Key,十分钟就能跑通。更重要的是,主流厂商几乎都提供 OpenAI 兼容接口(OpenAI-compatible API) - 同一个 Python 库、同一套参数,只改 base_url 和 api_key 就能在 OpenAI、DeepSeek、通义、Kimi、智谱之间自由切换。这是今天大模型行业的"事实标准",也是本书全部代码的地基。
上手流程只有五步:注册账号 → 创建 API Key → 充值或领取免费额度 → 安装官方 SDK(pip install openai)→ 写代码调用。有两个细节值得强调:第一,API Key 就是账号的"钥匙",按用量计费,不要写死在代码里、不要提交进 Git 仓库,应通过环境变量读取;第二,Key 创建后通常只完整显示一次,务必立刻保存。
下面是最小的完整程序。它只做三件事:创建客户端、组装 messages、打印回答。其中 messages 是"对话内容":system 设置人设与规则,user 是你说的话,assistant 是模型此前说过的话(多轮对话时使用,见 2.5)。
# 安装:pip install openai
from openai import OpenAI
client = OpenAI(
api_key="sk-你的密钥", # 建议从环境变量读取
base_url="https://api.deepseek.com", # 换厂商就改这一行
)
resp = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": "你是资深 Python 工程师"},
{"role": "user", "content": "用一句话解释什么是 API"},
],
)
print(resp.choices[0].message.content)
响应对象里,choices[0].message.content 是模型生成的正文;usage 字段记录了本次消耗的输入/输出 token 数,它是成本核算的唯一依据(2.6 会用到)。请求→响应的完整结构见图 2-1:你的代码 → SDK 打包成 HTTP 请求 → 模型服务端推理 → 响应原路返回。整条链路本质上就是一次 HTTP 往返,没有别的魔法 - 这个模型会在后面所有章节反复出现。
| 厂商 | base_url | 常见模型 |
|---|---|---|
| OpenAI | https://api.openai.com/v1 |
gpt-4o-mini、gpt-4o |
| DeepSeek | https://api.deepseek.com |
deepseek-chat、deepseek-reasoner |
| 阿里通义 | https://dashscope.aliyuncs.com/compatible-mode/v1 |
qwen-plus、qwen-max |
| 月之暗面 Kimi | https://api.moonshot.cn/v1 |
moonshot-v1-8k |
| 智谱 GLM | https://open.bigmodel.cn/api/paas/v4 |
glm-4-flash、glm-4-plus |
Key 就是你的钱包
API Key 等同于账号凭证,按用量计费 - 泄漏的 Key 会被别人拿去调用,账单算在你头上。三件事务必做:用环境变量存 Key、别提交进 Git、在官方后台设置月度消费上限。
2.2 流式输出:像 ChatGPT 一样打字
把上面的代码跑起来,你会立刻发现一个尴尬的问题:程序会"卡住"很久,然后一次性吐出整段回答。模型生成一段 500 字的回答可能需要几十秒,让用户对着空白界面干等,是产品级的灾难。ChatGPT 打字机效果的秘密就是流式输出(streaming):服务端每生成一个 token 就立刻发送一个数据块,你的程序收到一块就渲染一块。
底层机制是 SSE(Server-Sent Events) - HTTP 长连接上的分块推送。openai 库把它封装得很干净:只要把 stream 参数设为 True,create 的返回值就从"一个完整的响应对象"变成"一个可迭代的块序列"。每个块里,choices[0].delta.content 是本块新增的增量文本 - 注意是增量不是累计,必须边收边拼,否则会越拼越重复。
改动只需要三步:加 stream=True;用 for 循环遍历返回的流;print(piece, end="", flush=True) 边收边打。其中 flush=True 强制立即输出到终端,少了它 Python 的缓冲会吞掉"打字机"效果。
# stream=True:把"等全部生成完"改成"生成一点收一点"
stream = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "写一首五言绝句"}],
stream=True, # 关键开关
)
for chunk in stream: # 每个 chunk 只含增量 delta
piece = chunk.choices[0].delta.content
if piece: # 有的 chunk 没有内容(如角色切换)
print(piece, end="", flush=True)
流程见图 2-2:模型边生成边发,token 一个接一个流过网络到达你的程序。流式带来两个必须区分的指标:首字延迟(TTFT,Time To First Token) - 从发出请求到收到第一个 token 的时间,决定用户"感觉快不快";总时长 - 整个流走完的时间,决定"任务何时完成"。流式并不能减少总时长(token 总数没变),它只是把"干等"变成"边等边看",并把首字延迟压到最短。
类比:水龙头 vs 水桶
非流式像"接满一桶水再端给你",流式像"打开水龙头边接边喝"。水量(token 总数)不变,但等待的体验完全不同 - 对写文章、生成代码这类长回答尤其明显。
2.3 结构化输出:让模型返回 JSON
聊天场景里,模型回答是给人看的;但工程里,模型经常要"回答给程序看" - 提取关键信息、做分类、填表单、喂给下游流程。这时如果模型回一段自由文本,程序就得用正则去猜,又脆又烦。正确做法是让模型直接输出 JSON,程序 json.loads 一下就能拿到结构化数据。
有两种主流做法。一是提示词约束:在 system 里写清楚"只输出 JSON、不要任何解释",再给一个输出示例(few-shot),多数模型会照做。二是平台级保障:在请求里加 response_format={"type": "json_object"},OpenAI 与 DeepSeek 等兼容厂商都支持,它会从解码层面约束模型只输出合法 JSON。这里有一个真实的坑:DeepSeek 的 json_object 模式要求提示词里出现 "json" 字样,否则可能报错或静默失效 - 写提示词时顺手带上即可。如果对字段类型要求更严,OpenAI 还支持 json_schema 模式,相当于把一份 JSON Schema 直接发给模型,让模型按字段类型与必填项输出。
但无论哪种方式,都要记住一条铁律:永远不要赌模型听话。生产代码必须给解析加兜底,顺序是:先直接解析,失败就正则抠出花括号部分再解析,还不行就重试或返回默认值。
import json, re
resp = client.chat.completions.create(
model="deepseek-chat",
response_format={"type": "json_object"}, # 强制输出合法 JSON
messages=[
{"role": "system", "content": "只输出 JSON,不输出任何解释"},
{"role": "user", "content": "把下面这段话总结为三条要点,"
"字段:summary(字符串数组)"},
],
)
raw = resp.choices[0].message.content
# 解析失败兜底:多数时候一次成功,但永远别赌模型
try:
data = json.loads(raw)
except json.JSONDecodeError:
m = re.search(r"\{.*\}", raw, re.S) # 抠出花括号部分
data = json.loads(m.group(0))
print(data["summary"])
上面代码里的兜底逻辑值得拆开讲:第一,json.loads 直接解析,大多数情况一次成功;第二,失败就用正则把花括号包围的部分抠出来再解析(覆盖"模型夹带解释文字"的情况);第三,仍然失败就重试一次,或降级返回预设默认值。另一个实战经验是"在提示词里堵住病根":明确要求"不要用代码块包裹、不要解释、使用半角符号",能把大半兜底逻辑省掉。
JSON 解析失败的三大主因
① 模型在 JSON 前后夹了解释文字;② 用 Markdown 的代码块围栏把 JSON 包了起来;③ 引号被写成中文全角引号、花括号被写成全角符号。对策:提示词里明确"不要代码块、不要解释、用半角符号",代码里按上面的兜底顺序处理。
2.4 函数调用:给模型一把工具
到目前为止,模型只是个"嘴上王者":它能说"北京今天 23 度",但它根本不知道真实天气 - 它不会联网、不会查数据库、不会算算术。Function Calling(函数调用)就是解决这个问题的官方协议:让模型声明"我想调用哪个函数、传什么参数",由你的代码真正执行,再把结果喂回给模型。这是第 5 章 Agent 的核心原语,务必吃透。
整个过程分三步。第一步,声明工具:把函数签名翻译成 JSON Schema,放进 tools 参数。注意模型只能"看到"这个 Schema(函数名、参数、说明),看不到你的实现代码 - 这是刻意的安全边界。第二步,模型返回 tool_calls:当模型判断需要工具时,它不直接回答,而是在响应的 tool_calls 字段里给出"想调哪个函数、参数是什么",此时 content 为空。第三步,你执行、再回传:代码调用真实函数拿到结果,把结果作为 role="tool" 的消息、带着 tool_call_id 追加进 messages,再发一次请求,模型综合工具结果给出最终回答。
三个容易踩的坑:第一,回传时必须带上第一轮模型返回的 assistant 消息(含 tool_calls)和对应的 tool_call_id,缺一个就报错;第二,模型只是"提议"调用,执行与否、怎么执行完全由你的代码决定 - 你可以校验参数,也可以拒绝执行;第三,第二轮请求也要带 tools,否则模型"不知道工具还在"。
tools = [{"type": "function", "function": {
"name": "get_weather", "description": "查询指定城市的当前天气",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]}}}]
# 第一轮:模型不直接回答,只返回"想调用 get_weather"
resp = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "北京现在几度?"}],
tools=tools)
msg = resp.choices[0].message # content 为空,tool_calls 非空
call = msg.tool_calls[0]
# 第二轮:代码执行真实函数,结果作为 tool 消息回传
final = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "北京现在几度?"},
msg, {"role": "tool", "tool_call_id": call.id,
"content": get_weather(city="北京")}],
tools=tools)
print(final.choices[0].message.content)
函数调用的三条铁律
① 模型只"提议",执行权永远在代码手里;② 回传时必须带上第一轮的 assistant 消息与 tool_call_id;③ 第二轮请求也要带 tools,否则模型不认账。类比点菜:工具声明是菜单,模型是点菜的顾客(只下单不掌勺),你的代码是后厨 - 角色必须分明。
2.5 多轮对话与记忆管理
有一个反直觉的事实:大模型 API 是无状态的。服务端不记得你是谁、不记得上次聊了什么 - "记忆"完全由你每次请求携带的 messages 数组决定。所谓多轮对话,就是把历史消息一条条追加进数组,再整个发给模型。上一轮的 assistant 回答、这一轮的 user 提问,都只是数组里的普通元素。
messages 数组的标准结构:system 常驻在最前(人设、规则、背景知识),之后 user 与 assistant 交替追加。角色本质上是"作者标注",让模型知道"这段话是谁说的",从而维持连贯的对话。有两个细节:assistant 消息必须由模型生成后原样存回,不要手写,否则上下文会"精神分裂";system 最好只有一条,多了容易互相打架。
但记忆不是免费的:上下文窗口是硬上限(常见 8K、32K、128K tokens),而每次请求都要把整个数组发出去 - 数组越长,越贵、越慢,一旦超过窗口还会直接报错或截断。所以工程上必须主动管理记忆,两种主流策略:裁剪(sliding window) - 只保留 system + 最近 N 轮,简单粗暴、最常用;摘要(summarization) - 把超出窗口的旧对话压缩成一段话,作为 system 摘要放回数组,信息保留更多,代价是多一次小模型调用(正好用上 2.6 的模型分级)。
# 多轮对话 = 每次把"完整历史"追加后重新发送
messages = [
{"role": "system", "content": "你是售后客服"},
{"role": "user", "content": "我的订单还没发货"},
{"role": "assistant", "content": "您好,请提供订单号"},
{"role": "user", "content": "订单号是 20240101"},
]
# 简单裁剪:system 常驻,只保留最近 6 条对话
if len(messages) > 7:
messages = [messages[0]] + messages[-6:]
resp = client.chat.completions.create(
model="deepseek-chat", messages=messages)
print(resp.choices[0].message.content)
上面的裁剪代码只有三行:判断超限、保留 system、截取最近 N 条。更精细的手段还有:对超长的单条消息单独截断或摘要、把敏感旧消息替换掉让模型"忘记"、按角色设置不同的保留策略。第 5 章我们会把这些升级成 Agent 的"记忆系统",本章先把"数组即记忆"这个模型刻进脑子里。
类比:对话是笔记本,不是金鱼
大模型没有金鱼记忆,它的记忆是你每次递给它的那本笔记本(messages 数组) - 递多厚,它就有多厚的记忆;但本子太厚会"装不进袋子"(超出上下文窗口)。所以聪明的做法不是一直加页,而是定期撕掉旧页(裁剪),或把旧页浓缩成一页摘要。
2.6 成本、延迟与可靠性
调 API 是按 token 计费的,而且输入与输出单价不同 - 通常"输出比输入贵"。一次调用的成本可以精确写成一条公式:
\[ \text{成本}=N_{\mathrm{in}}P_{\mathrm{in}}+N_{\mathrm{out}}P_{\mathrm{out}} \]
各家价格差异很大(旗舰模型可能是小模型的几十倍),且会随版本调整,具体以官方实时价格为准。别小看这个公式:本地测试一次几十个 token 没感觉,日活上万的线上服务,每个请求多带 500 个历史 token,一个月就是一笔实打实的开销。用一组"量级示意"的价格算一笔账(真实价格以官网为准):
| 场景 | 模型档位 | 输入单价(元/百万token) | 输出单价(元/百万token) | 单次调用估算 |
|---|---|---|---|---|
| 分类、提取等轻任务 | 便宜小模型 | ≈1 | ≈2 | 1500 入 + 100 出 ≈ 0.0017 元 |
| 客服多轮对话 | 中等模型 | ≈4 | ≈8 | 1500 入 + 300 出 ≈ 0.008 元 |
| 复杂推理、长文生成 | 旗舰模型 | ≈20 | ≈60 | 3000 入 + 1000 出 ≈ 0.12 元 |
延迟从哪来?三个主要来源:模型大小 - 旗舰模型参数量大、推理慢,同一道题可能差出数倍时间;输出长度 - 模型逐 token 生成,输出越长总时长越长,这是流式也改变不了的事实(流式只改善体感);排队与网络 - 服务端高峰期排队、客户端网络往返。所以"变快"的正确姿势是:能用小模型就别用大模型,能少输出就别让它长篇大论(提示词限字数、设置 max_tokens 封顶)。
可靠性三件套:超时、重试、退避。openai 库默认会自行重试几次,但你最好显式设置 timeout(比如 30 秒),避免请求无限挂起;网络抖动、服务端 5xx 错误可以重试,且必须退避(第一次等 2 秒、第二次 4 秒……),防止雪崩;4xx 错误(Key 无效、参数错误)重试无用,应该修代码而不是重试。最后是模型分级:贵模型打底 - 复杂推理、最终答案;便宜模型提速 - 意图识别、分类打标、摘要、格式转换。两条腿走路,既稳又省。
import time
client = OpenAI(
api_key="sk-你的密钥",
base_url="https://api.deepseek.com",
timeout=30.0, # 30 秒无响应就抛错,绝不无限等
)
def chat_with_retry(messages, retries=3):
for i in range(retries):
try:
return client.chat.completions.create(
model="deepseek-chat", messages=messages)
except Exception as e:
if i == retries - 1:
raise # 最后一次失败直接抛出
time.sleep(2 * (i + 1)) # 退避:2s、4s、6s
print(f"第{i+1}次失败:{e},重试中…")
省钱三连
少带历史(2.5 的裁剪/摘要)→ 用对档位(模型分级)→ 善用缓存(多数厂商对命中缓存的输入按量级更低的价格计费)。三个杠杆叠加,同样的功能成本能降一个数量级。
本章要点
- OpenAI 兼容接口是行业事实标准:一份代码,改
base_url与api_key即可切换厂商;一次调用 = 一次 HTTP 往返。 stream=True把响应变成增量块流,压短首字延迟、改善体感;总时长不变,想变快就减输出、换小模型。- 要 JSON 就上
response_format或提示词约束,但永远给json.loads准备兜底。 - 函数调用三步走:声明 tools → 模型返回
tool_calls→ 代码执行并回传 tool 结果;模型提议,代码执行。 - API 无状态:记忆就是 messages 数组;窗口有限,超限就裁剪或摘要。
- 成本按 token 计费;延迟来自模型大小与输出长度;超时 + 退避重试 + 模型分级是可靠性的三板斧。
衔接 · 下一站
本章的 OpenAI 兼容客户端与 messages 结构,是全书所有代码的统一入口。第 3 章 RAG 会在"生成"之前插入"检索":先查你的资料库,再把资料塞进 messages,让模型基于事实回答 - 本质还是本章的 chat.completions.create。第 5 章 Agent 会把 2.4 的函数调用升级为完整的"思考—行动—观察"循环:模型自主决定何时调用哪个工具、多轮迭代直到完成任务。前置知识回顾:第 1 章的 Token 与上下文窗口(1.4)、Prompt 工程(1.3)是 2.3 与 2.5 的理论依据;第 4 章微调则从另一条路提升模型在工具调用场景下的听话程度。



