第一次调用大模型
写一个十几行的程序调用 DeepSeek,逐个字段看懂请求和响应:消息角色、结束原因、词元用量和费用,再用 curl 看看它本质上只是一个 HTTP 请求。
- 约 30 分钟
- 难度:入门
- 实测:2026-09-14 deepseek-flash
网上随便一搜,就能找到调用大模型的示例代码,复制下来改个问题就能跑。可是很多人就停在这一步了:程序能跑,但返回的那一大坨对象里有什么,不知道。于是后来遇到"回答怎么突然只有半句"、"这个月账单为什么这么高"这类问题时,完全不知道从哪查起。
这一课只写一个很短的程序,但会把请求和响应里的每个字段都讲清楚。
最小的调用
在上一课建的 ai-course 目录里新建 first_call.py:
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
)
MODEL = os.environ.get("LLM_MODEL", "deepseek-flash")
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "你是一个说话简短的助手,每次回答不超过两句话。"},
{"role": "user", "content": "Python 里的列表和元组有什么区别?"},
],
)
message = response.choices[0].message
print("回答:", message.content)
print("结束原因:", response.choices[0].finish_reason)
print("实际使用的模型:", response.model)
print("输入词元:", response.usage.prompt_tokens)
print("输出词元:", response.usage.completion_tokens)
# DeepSeek 的模型默认先思考再回答,思考过程放在 reasoning_content 里。
# 别的服务商没有这个字段,所以用 getattr 取,取不到就是 None。
reasoning = getattr(message, "reasoning_content", None)
if reasoning:
print("思考过程(前 100 字):", reasoning[:100])
运行:
uv run python first_call.py
我运行的结果:
回答: 列表可变、用 `[]`,元组不可变、用 `()`。因此列表适合频繁修改的数据,元组更适合固定不变的数据。
结束原因: stop
实际使用的模型: deepseek-flash
输入词元: 52
输出词元: 145
思考过程(前 100 字): 我们需要回答中文。用户要求:你是一个说话简短的助手,每次回答不超过两句话。问题:Python 里的列表和元组有什么区别?需要不超过两句话。要准确。可以一句或两句。核心区别:列表可变,用方括号;元组不可
你的回答措辞会不一样,词元数也会略有出入,这很正常。
程序本身只有三步:创建一个客户端,调用 chat.completions.create,从返回值里取东西。下面拆开看。
客户端
OpenAI(...) 创建的是一个客户端对象,它负责把你的请求发到 base_url 指定的服务器,并在请求头里带上 api_key。这个类叫 OpenAI,但它能连任何兼容 OpenAI 接口的服务。我们把 base_url 指向 DeepSeek,它就去找 DeepSeek。
chat.completions 这个名字来自 OpenAI 最早设计的"对话补全"接口。它后来成了事实上的行业标准,国内外大多数模型服务都提供同样格式的接口。所以学会这一套,基本上哪家都能用。
请求:model 和 messages
请求里只有两个必填的参数。
model 是模型名。服务器用它决定由哪个模型来回答。
messages 是一个列表,每个元素是一条消息,有 role(角色)和 content(内容)两个字段。角色有三种:
| 角色 | 谁说的 | 用来做什么 |
|---|---|---|
system |
开发者 | 给模型定规矩:扮演什么身份、用什么语气、有什么限制 |
user |
用户 | 用户的问题或指令 |
assistant |
模型 | 模型之前的回答。多轮对话时,要把它之前说过的话放回来 |
在上面的例子里,system 消息要求"每次回答不超过两句话",模型就真的只回答了两句。用户看不到 system 消息,但它会影响模型的每一次回答。做应用时,产品的"人设"和规则基本都写在这里。
assistant 这个角色这一课还用不上。你可能会以为模型会记得你上一次问了什么,其实不会,每次调用都是独立的。想让它"记住"之前的对话,得把之前的问答作为 user 和 assistant 消息一条条放进 messages 里再发一次。03 模块的第 1 课会专门讲这件事。
响应:choices、finish_reason、usage
返回的 response 对象里,最常用的是这几样:
response.choices[0].message.content:模型的回答。choices 是个列表,因为接口允许一次要多个候选回答,但绝大多数时候只有一个,所以直接取第 0 个。
response.choices[0].finish_reason:模型为什么停下来。常见的值有:
| 值 | 含义 |
|---|---|
stop |
模型认为说完了,正常结束 |
length |
达到了长度上限,被强行截断。回答很可能不完整 |
tool_calls |
模型想调用一个工具(03 模块第 3 课讲) |
content_filter |
内容被服务商的安全过滤拦下了 |
写程序时要检查它。如果是 length,你拿到的可能是半句话,直接展示给用户或者当成 JSON 解析,就会出问题。
response.model:实际回答你的模型。大多数时候就是你请求的那个,但也有例外:旧的模型名可能被服务商映射到新模型上。比如截至 2026 年 9 月,请求 deepseek-chat 这个旧名字,实际由 deepseek-flash 的非思考模式回答。所以排查问题时,看这个字段比看你自己写的模型名更可靠。
response.usage:这次调用用了多少词元(token)。prompt_tokens 是输入,completion_tokens 是输出。词元是模型处理文字的基本单位,可能是一个字、半个英文单词,也可能是几个字的组合。一段话能切成多少个词元,每个模型都不一样,下一个模块的第 1 课会实际切给你看。服务商按词元收费,所以 usage 就是你的账单。
这次调用花了多少钱
截至 2026 年 9 月,deepseek-flash 的价格是(每一百万个词元,美元):
| 高峰时段 | 低谷时段 | |
|---|---|---|
| 输入(缓存未命中) | 0.30 | 0.15 |
| 输入(缓存命中) | 0.006 | 0.003 |
| 输出 | 1.20 | 0.60 |
高峰时段是 UTC 时间周一到周五的 01:00~04:00 和 06:00~10:00,也就是北京时间工作日的 9:00~12:00 和 14:00~18:00,其余时间都按低谷价,打五折。"缓存命中"指的是这次的输入开头和之前某次请求一样,服务器可以复用之前的计算,06 模块会讲怎么利用它省钱。
按高峰价算,上面那次调用:
input_cost = 52 * 0.30 / 1_000_000
output_cost = 145 * 1.20 / 1_000_000
print(f"{input_cost + output_cost:.6f} 美元")
0.000190 美元
一美元够问五千多次这样的问题。看起来很便宜,但注意两件事。第一,输出比输入贵四倍,让模型少说废话就是在省钱。第二,当你的程序每次都把一整本文档塞进输入、一天被调用几万次时,这个数字会涨得很快。
那 145 个输出词元是怎么来的
回答只有两句话,大约四十个字,输出却有 145 个词元。多出来的部分是思考过程。
deepseek-flash 默认开启思考模式:先在 reasoning_content 里想一遍,再在 content 里给出正式回答。上面的输出里能看到它的思考:它先复述了"不超过两句话"这个要求,再组织答案。思考用的词元算在 completion_tokens 里,按输出的价格收费。我连续跑了三次这个程序,思考部分分别用了 137、110、189 个词元,比正式回答长好几倍。
思考能让模型在复杂问题上更准确(02 模块第 3 课会做对比实验),但简单问题上就是在白花钱、白等时间。DeepSeek 允许关掉它:
response = client.chat.completions.create(
model=MODEL,
messages=[...],
extra_body={"thinking": {"type": "disabled"}},
)
extra_body 是 OpenAI SDK 留出来的一个口子,用来传服务商自己的特有参数。thinking 是 DeepSeek 的参数,别家不一定认识。换服务商时要把它去掉,或者查一下对方用什么参数控制思考。
输入词元也有个小细节。同样这两条消息(加起来 43 个字),我用非思考模式跑,prompt_tokens 是 27;开着思考模式,是 52。消息内容一样,多出来的 25 个词元是服务器加上的格式标记:它会在消息外面标明哪段是 system、哪段是 user、从哪里开始思考,再交给模型,这些标记也算输入词元。43 个字只切出了二十几个词元,说明 DeepSeek 的分词器经常把两三个汉字合成一个词元,下一个模块的第 1 课会细看。
一个坑:思考把额度用光了
max_tokens 参数可以限制最多输出多少个词元,常用来控制成本和防止模型没完没了。但在思考模式下,思考过程也占用这个额度。
我把 max_tokens 设成 30,问"介绍一下 Python 的列表推导式",分别在关掉和开着思考的情况下各跑一次:
== 非思考: finish_reason=length completion_tokens=30 reasoning_tokens=None
content: '## Python 列表推导式(List Comprehension)\n\n列表推导式是 Python 中一种**简洁优雅**的创建列表的方式,可以用一行代码'
reasoning: ''
== 思考: finish_reason=length completion_tokens=30 reasoning_tokens=30
content: ''
reasoning: 'We need answer in Chinese. User asks "介绍一下 Python 的列表推导式。" Need introduce Python'
非思考模式下,回答被截断在半句话,这在意料之中。思考模式下,30 个词元全用在了思考上,正式回答 content 是空字符串。如果你的程序只看 content,会以为模型什么都没说。
所以两条经验:开着思考时,max_tokens 要给得宽裕;程序里一定要检查 finish_reason,看到 length 就要知道结果不完整。
用 curl 看看它的真面目
SDK 帮你做的事情,其实就是发一个 HTTP 请求。不用 Python,用命令行里的 curl 也能调用:
curl https://api.deepseek.com/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $LLM_API_KEY" \
-d '{
"model": "deepseek-flash",
"messages": [{"role": "user", "content": "用五个字形容秋天"}],
"thinking": {"type": "disabled"}
}'
Windows 的 PowerShell 对引号的处理不一样,这条命令可能跑不通,可以在 Git Bash 或 WSL 里运行,或者跳过这一步,不影响后面的学习。
返回的是一段 JSON,我格式化了一下:
{
"id": "10bef209-3437-4efc-906b-dcb18fe30f7f",
"object": "chat.completion",
"created": 1789444049,
"model": "deepseek-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "**金风送爽凉**\n\n如果不局限于这五个字,还有其他不同角度的五字形容:\n\n- **秋高气爽天** — 天高云淡,气候宜人\n- **霜叶红于花** — 枫叶经霜比花还红\n- **硕果满枝头** — 丰收的景象\n- **一叶知秋来** — 落叶预示着秋天到来\n- **寒蝉鸣凄切** — 秋蝉叫声悲凉\n- **天凉好个秋** — 辛弃疾词句,凉爽舒适"
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 9,
"completion_tokens": 121,
"total_tokens": 130,
"prompt_tokens_details": {
"cached_tokens": 0
},
"prompt_cache_hit_tokens": 0,
"prompt_cache_miss_tokens": 9
},
"system_fingerprint": "aeb56401ca74e127821c4f9126dcb669"
}
和 Python 里看到的字段一一对应:choices[0].message.content、finish_reason、usage。SDK 只是把这段 JSON 变成了 Python 对象,外加帮你处理重试、超时这些琐事。明白了这一点,你用任何语言都能调用大模型,出了问题也可以直接用 curl 排查,看看是你的代码有问题还是服务端有问题。
注意 thinking 在 curl 里是直接写在 JSON 顶层的。在 Python 里用 extra_body 传,SDK 最后也是把它合并进这段 JSON。
还有一个细节值得看:我要求"用五个字形容秋天",模型给了五个字,然后又自作主张加了六条。模型经常会多说,这一点在第 02 模块讲提示词时会专门处理。
常见问题
content 是 None 或者空字符串:先看 finish_reason。如果是 length,说明 max_tokens 太小,被思考过程用光了。如果是 tool_calls,说明模型想调用工具,这时回答在别的字段里。
报错 429:请求太频繁,被限流了。等几秒再试。03 模块第 4 课会讲怎么自动重试。
程序卡住很久没反应:思考模式下,复杂问题的思考可能持续几十秒。先换个简单的问题确认程序本身没问题。03 模块第 2 课会讲流式输出,让回答一边生成一边显示。
练习
- 把
system消息改成"你是一个只用文言文回答问题的老学究",再问一遍同样的问题,看看回答怎么变。 - 在调用里加上
extra_body={"thinking": {"type": "disabled"}},比较关掉思考前后的completion_tokens和运行时间。 - 假设你的应用每天被调用一万次,每次输入 2000 个词元、输出 500 个词元(关掉思考),全部按高峰价算,一个月(30 天)要花多少钱?用 Python 算出来。
- 在
messages里手动加两条消息,模拟一段已经发生过的对话:先是 user 问"我叫小王,请记住",然后是 assistant 回答"好的,小王",最后是 user 问"我叫什么?"。看看模型能不能答对,想一想为什么。
自测
1. finish_reason 是 length 意味着什么?程序应该怎么处理?
意味着模型的输出达到了 max_tokens 的上限,被强行截断,回答很可能不完整。程序不能把它当成正常结果使用:可以提高 max_tokens 重新请求,或者至少提醒用户回答不完整。如果本来要把回答当 JSON 解析,截断的 JSON 一定会解析失败。
2. 开着思考模式,把 max_tokens 设成 50,结果 content 是空的。为什么?
思考过程也占用 max_tokens 的额度。50 个词元全部用在了思考上,还没来得及写正式回答就到上限了。开思考模式时要把 max_tokens 给得宽裕,或者对简单任务关掉思考。
3. 你请求的模型是 deepseek-chat,response.model 却显示 deepseek-flash。这正常吗?
正常。服务商会把旧的模型名映射到新模型上继续提供服务。response.model 告诉你实际是哪个模型回答的,排查问题、核对账单时以它为准。
提问与讨论
这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。
提问 +3 积分,回答别人 +6 积分。内容经审核后公开。
正在加载讨论…