Your first call to an LLM
Write a program of a dozen lines that calls DeepSeek, and read the request and response field by field: message roles, finish reason, token usage and cost. Then use curl to see it's just an HTTP request.
- About 30 min
- Level: Beginner
- Tested: 2026-09-14 deepseek-flash
Code and program output are shown exactly as they ran, so comments and printed output are in Chinese.
A quick search turns up example code for calling a large language model; copy it, change the question, and it runs. Plenty of people stop right there: the program works, but they don't know what's inside the big object it returns. So when problems come along later, like "why is the answer suddenly half a sentence?" or "why is this month's bill so high?", they have no idea where to start looking.
This lesson writes only one short program, but it explains every field in the request and the response.
The smallest call
In the ai-course directory from the previous lesson, create 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])
Run it:
uv run python first_call.py
What I got:
回答: 列表可变、用 `[]`,元组不可变、用 `()`。因此列表适合频繁修改的数据,元组更适合固定不变的数据。
结束原因: stop
实际使用的模型: deepseek-flash
输入词元: 52
输出词元: 145
思考过程(前 100 字): 我们需要回答中文。用户要求:你是一个说话简短的助手,每次回答不超过两句话。问题:Python 里的列表和元组有什么区别?需要不超过两句话。要准确。可以一句或两句。核心区别:列表可变,用方括号;元组不可
Your answer will be worded differently and the token counts will differ slightly; that's normal.
The program has just three steps: create a client, call chat.completions.create, and take things out of the return value. Let's take them apart.
The client
OpenAI(...) creates a client object. It sends your requests to the server at base_url and puts api_key in the request headers. The class is called OpenAI, but it can talk to any OpenAI-compatible service. Point base_url at DeepSeek and it goes to DeepSeek.
The name chat.completions comes from the "chat completion" API OpenAI designed early on. It has since become the de facto industry standard, and most model services, in China and elsewhere, offer an API in the same format. Learn this one and you can use almost any of them.
The request: model and messages
The request has just two required parameters.
model is the model name. The server uses it to decide which model answers.
messages is a list in which each element is one message with two fields, role and content. There are three roles:
| Role | Who says it | What it's for |
|---|---|---|
system |
The developer | Sets the rules for the model: who it plays, what tone it uses, what limits it has |
user |
The user | The user's question or instruction |
assistant |
The model | The model's earlier replies. In a multi-turn conversation, you put what it said before back in |
In the example above, the system message asks for "no more than two sentences per answer", and the model really did answer in two sentences. Users never see the system message, but it affects every answer the model gives. When you build an application, the product's persona and rules mostly live here.
This lesson doesn't use the assistant role yet. You might assume the model remembers what you asked last time; it doesn't, and every call stands on its own. To make it "remember" an earlier conversation, you put the earlier questions and answers into messages one by one as user and assistant messages and send them again. Lesson 1 of module 03 is devoted to this.
The response: choices, finish_reason, usage
The most-used parts of the returned response object are these:
response.choices[0].message.content: the model's answer. choices is a list because the API lets you ask for several candidate answers at once, but almost always there's only one, so you take element 0.
response.choices[0].finish_reason: why the model stopped. Common values:
| Value | Meaning |
|---|---|
stop |
The model decided it was done; a normal finish |
length |
It hit the length limit and was cut off. The answer is probably incomplete |
tool_calls |
The model wants to call a tool (module 03, lesson 3) |
content_filter |
The provider's safety filter blocked the content |
Your program should check it. If it's length, what you got may be half a sentence, and showing it to a user or parsing it as JSON will go wrong.
response.model: the model that actually answered. Usually it's the one you asked for, but not always: providers may map an old model name to a new model. As of September 2026, for example, a request for the old name deepseek-chat is answered by deepseek-flash in non-thinking mode. So when you're chasing a problem, this field is more reliable than the model name you wrote yourself.
response.usage: how many tokens this call used. prompt_tokens is the input and completion_tokens the output. A token is the basic unit a model processes text in: it may be one character, half an English word, or a group of characters. How many tokens a passage splits into differs from model to model; lesson 1 of the next module splits some text for you to see. Providers charge by the token, so usage is your bill.
What this call cost
As of September 2026, deepseek-flash costs (US dollars per million tokens):
| Peak hours | Off-peak hours | |
|---|---|---|
| Input (cache miss) | 0.30 | 0.15 |
| Input (cache hit) | 0.006 | 0.003 |
| Output | 1.20 | 0.60 |
Peak hours are 01:00–04:00 and 06:00–10:00 UTC, Monday to Friday, which is 9:00–12:00 and 14:00–18:00 on weekdays in Beijing time; everything else is billed at the off-peak rate, half price. A "cache hit" means this request's input begins the same way as an earlier request's, so the server can reuse the earlier computation; module 06 explains how to use that to save money.
At peak prices, the call above cost:
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 美元
A dollar buys more than five thousand questions like this one. That looks cheap, but note two things. First, output costs four times as much as input, so getting the model to cut the waffle saves money. Second, when your program stuffs a whole document into the input every time and is called tens of thousands of times a day, this number grows fast.
Where the 145 output tokens came from
The answer is just two sentences, about forty Chinese characters, yet the output was 145 tokens. The rest is the thinking.
deepseek-flash has thinking mode on by default: it first thinks things through in reasoning_content, then gives its formal answer in content. You can see its thinking in the output above: it first restated the "no more than two sentences" requirement, then put the answer together. Thinking tokens count toward completion_tokens and are billed at the output price. I ran this program three times in a row, and the thinking used 137, 110 and 189 tokens, several times longer than the answer itself.
Thinking makes the model more accurate on hard problems (lesson 3 of module 02 runs a comparison), but on simple questions it just costs money and time. DeepSeek lets you turn it off:
response = client.chat.completions.create(
model=MODEL,
messages=[...],
extra_body={"thinking": {"type": "disabled"}},
)
extra_body is an opening the OpenAI SDK leaves for passing a provider's own parameters. thinking is a DeepSeek parameter that other providers won't necessarily understand. When you switch providers, remove it, or look up what parameter they use to control thinking.
There's a small detail in the input tokens too. With the same two messages (43 Chinese characters in total), prompt_tokens was 27 in non-thinking mode and 52 with thinking on. The messages are identical; the extra 25 tokens are formatting markers the server adds: before handing the messages to the model, it marks which part is system, which part is user and where thinking starts, and those markers count as input tokens. That 43 characters became only twenty-odd tokens shows DeepSeek's tokenizer often merges two or three Chinese characters into one token; lesson 1 of the next module looks at this closely.
A trap: thinking uses up the budget
The max_tokens parameter caps how many tokens may be output, and it's commonly used to control cost and stop a model rambling on. But in thinking mode, the thinking uses this budget too.
I set max_tokens to 30 and asked "介绍一下 Python 的列表推导式" (explain Python's list comprehensions), once with thinking off and once with it on:
== 非思考: 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'
In non-thinking mode the answer was cut off mid-sentence, as expected. In thinking mode all 30 tokens went into thinking, and the formal answer content is an empty string. If your program only looks at content, it will think the model said nothing.
Two lessons, then: with thinking on, give max_tokens plenty of room, and always check finish_reason in your program; length means the result is incomplete.
Seeing the raw thing with curl
What the SDK does for you is really just send an HTTP request. You can make the same call without Python, using curl on the command line:
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 treats quotes differently, so this command may not work there. Run it in Git Bash or WSL, or skip this step; it doesn't affect anything later.
What comes back is a piece of JSON, which I've formatted:
{
"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"
}
The fields match what you saw in Python one for one: choices[0].message.content, finish_reason, usage. The SDK just turns this JSON into Python objects, and handles chores like retries and timeouts for you. Once you see that, you can call a large language model from any language, and when something goes wrong you can use curl directly to tell whether the problem is in your code or on the server.
Notice that with curl, thinking sits directly at the top level of the JSON. In Python you pass it through extra_body, and the SDK merges it into this same JSON in the end.
One more detail worth a look: I asked for "用五个字形容秋天" (describe autumn in five characters), and the model gave five characters and then, on its own initiative, added six more lines. Models often say more than asked; module 02, on prompting, deals with this.
Common problems
content is None or an empty string: look at finish_reason first. If it's length, max_tokens was too small and the thinking used it up. If it's tool_calls, the model wants to call a tool, and the answer is in other fields.
A 429 error: too many requests, and you've been rate-limited. Wait a few seconds and try again. Lesson 4 of module 03 shows how to retry automatically.
The program hangs for a long time: in thinking mode, thinking about a hard question can go on for tens of seconds. Try a simple question first to confirm the program itself is fine. Lesson 2 of module 03 covers streaming, which shows the answer as it's being generated.
Exercises
- Change the
systemmessage to "你是一个只用文言文回答问题的老学究" (you are an old pedant who answers only in classical Chinese), ask the same question again, and see how the answer changes. - Add
extra_body={"thinking": {"type": "disabled"}}to the call and comparecompletion_tokensand run time with thinking off and on. - Suppose your application is called ten thousand times a day, each time with 2,000 input tokens and 500 output tokens (thinking off), all at peak prices. What does a month (30 days) cost? Work it out in Python.
- Add two messages to
messagesby hand to simulate a conversation that already happened: first the user says "我叫小王,请记住" (my name is Xiao Wang, remember it), then the assistant replies "好的,小王" (OK, Xiao Wang), and finally the user asks "我叫什么?" (what's my name?). See whether the model gets it right, and think about why.
Self-check
1. What does a finish_reason of length mean? How should the program handle it?
It means the model's output hit the max_tokens limit and was cut off, so the answer is probably incomplete. The program must not treat it as a normal result: it can request again with a larger max_tokens, or at least warn the user that the answer is incomplete. If the answer was meant to be parsed as JSON, truncated JSON will certainly fail to parse.
2. With thinking mode on and max_tokens set to 50, content comes back empty. Why?
The thinking also uses the max_tokens budget. All 50 tokens went into thinking, and the limit was reached before any formal answer was written. With thinking on, give max_tokens plenty of room, or turn thinking off for simple tasks.
3. You requested deepseek-chat, but response.model says deepseek-flash. Is that normal?
Yes. Providers map old model names onto new models to keep them working. response.model tells you which model actually answered; go by it when you're chasing a problem or checking a bill.
Questions and discussion
Stuck on this lesson? Ask here. If you can answer someone else's question, please do.
A question earns 3 points, answering someone earns 6. Posts appear once reviewed.
Loading the discussion…