模块 00 · 第 3 课

第一次调用大模型

写一个十几行的程序调用 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 这个角色这一课还用不上。你可能会以为模型会记得你上一次问了什么,其实不会,每次调用都是独立的。想让它"记住"之前的对话,得把之前的问答作为 userassistant 消息一条条放进 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.contentfinish_reasonusage。SDK 只是把这段 JSON 变成了 Python 对象,外加帮你处理重试、超时这些琐事。明白了这一点,你用任何语言都能调用大模型,出了问题也可以直接用 curl 排查,看看是你的代码有问题还是服务端有问题。

注意 thinking 在 curl 里是直接写在 JSON 顶层的。在 Python 里用 extra_body 传,SDK 最后也是把它合并进这段 JSON。

还有一个细节值得看:我要求"用五个字形容秋天",模型给了五个字,然后又自作主张加了六条。模型经常会多说,这一点在第 02 模块讲提示词时会专门处理。

常见问题

contentNone 或者空字符串:先看 finish_reason。如果是 length,说明 max_tokens 太小,被思考过程用光了。如果是 tool_calls,说明模型想调用工具,这时回答在别的字段里。

报错 429:请求太频繁,被限流了。等几秒再试。03 模块第 4 课会讲怎么自动重试。

程序卡住很久没反应:思考模式下,复杂问题的思考可能持续几十秒。先换个简单的问题确认程序本身没问题。03 模块第 2 课会讲流式输出,让回答一边生成一边显示。

练习

  1. system 消息改成"你是一个只用文言文回答问题的老学究",再问一遍同样的问题,看看回答怎么变。
  2. 在调用里加上 extra_body={"thinking": {"type": "disabled"}},比较关掉思考前后的 completion_tokens 和运行时间。
  3. 假设你的应用每天被调用一万次,每次输入 2000 个词元、输出 500 个词元(关掉思考),全部按高峰价算,一个月(30 天)要花多少钱?用 Python 算出来。
  4. 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 积分。内容经审核后公开。

正在加载讨论…