模組 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 告訴你實際是哪個模型回答的,排查問題、核對賬單時以它為準。