Make Your First API Call
In this section, we'll make our first API call to a reasoning model and discover something interesting: these models think before they answer. Let's see what that looks like in practice.
Sending One Message
We'll use the OpenAI-compatible Python client pointed at DeepSeek. Here is the smallest program that can talk to a reasoning model — one message in, one response out.
from openai import OpenAI
client = OpenAI(
api_key="<DeepSeek API Key>",
base_url="https://api.deepseek.com"
)
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=[{"role": "user", "content": "9.11 and 9.8, which is greater?"}],
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}}
)
msg = response.choices[0].message
print("--- reasoning_content ---")
print(msg.reasoning_content)
print("--- content ---")
print(msg.content)--- reasoning_content ---
The user is comparing 9.11 and 9.8. A common trap here is reading
"11 > 8" and concluding 9.11 is bigger. But these are decimals:
9.11 = 9 + 0.11, and 9.8 = 9 + 0.80. Since 0.80 > 0.11, 9.8 wins.
Let me align the decimal places to be sure: 9.80 vs 9.11. Yes, 9.8.
--- content ---
9.8 is greater than 9.11.
Aligning the decimal places makes it obvious: 9.80 vs 9.11.Notice that two fields came back, not one:
- reasoning_content — the model's private scratchpad. This is where it works out the problem, catches its own mistakes, and talks itself into an answer.
- content — the polished answer meant for your user.
They are siblings on the same message object. Your UI usually shows only content, and collapses reasoning_content behind a "show thinking" toggle.
Turn 2: What Should We Send Back?
One call is easy. Conversations are where it gets interesting, because the API is stateless — the server remembers nothing, so you resend the whole history every turn.
So the user asks a follow-up question. Now, what should we send back for Turn 2? We have two plausible options:
- Send back only
content, and throw the thinking away. It was scratch work, after all. - Send back both
contentandreasoning_content, exactly as we received them.
Both sound reasonable, and that's exactly the trap. The correct answer depends on one parameter you may not have noticed yet.
Two Modes, Two Sets of Rules
Whether you pass a tools parameter puts your request into one of two modes, and the two modes treat reasoning in opposite ways.
| Mode | How you trigger it | What happens to reasoning_content |
|---|---|---|
| Direct Chat | No tools parameter | Ignored — the server drops it from history |
| Agent mode | tools=[...] is present | Required — omitting it is an error |
Let's run both and watch the difference.
Mode 1: Direct Chat, Where Reasoning Is Ignored
This is the plain chatbot case: no tools, just a back-and-forth. We append the whole assistant message (thinking included) and ask the next question.
messages = [{"role": "user", "content": "9.11 and 9.8, which is greater?"}]
messages.append(response.choices[0].message) # still carries reasoning_content
messages.append({"role": "user", "content": "How many r's in 'strawberry'?"})
response2 = client.chat.completions.create(
model="deepseek-v4-pro",
messages=messages, # no tools= parameter → Direct Chat
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}}
)
print(response2.choices[0].message.content)There are 3 r's in "strawberry": st-r-awbe-rr-y.That works. Now let's do the same thing again, but strip the thinking out before sending it back:
def strip_reasoning(msg):
# keep only role + content, drop reasoning_content
return {"role": "assistant", "content": msg.content}
messages = [
{"role": "user", "content": "9.11 and 9.8, which is greater?"},
strip_reasoning(response.choices[0].message),
{"role": "user", "content": "How many r's in 'strawberry'?"},
]
response3 = client.chat.completions.create(
model="deepseek-v4-pro",
messages=messages,
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}}
)
print(response3.choices[0].message.content)There are 3 r's in "strawberry": st-r-awbe-rr-y.Same answer. That is not luck — in Direct Chat the server never looked at reasoning_content in the first place. It reads role and content, and quietly discards the rest of the assistant message.
reasoning_content back — you'll see it makes no difference to the output. Then print response.usage.prompt_tokens for both versions: the counts stay nearly identical, which is your proof that the server dropped the field before counting.
Mode 2: Agent Mode, Where Reasoning Is Required
Now let's give the model a tool. Structurally we only add one parameter, tools, but that single parameter changes the rules.
An agent is a loop, not a single call. The model asks for a tool, we run it, we hand back the result, and the model continues — until it stops asking for tools.
import json
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
}]
TOOL_CALL_MAP = {
"get_weather": lambda city: json.dumps({"temp": "22°C", "condition": "sunny"})
}
messages = [{"role": "user", "content": "What's the weather in Tokyo?"}]
while True:
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=messages,
tools=tools, # ← this makes it Agent mode
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}}
)
# Append the message OBJECT, untouched — reasoning_content included
messages.append(response.choices[0].message)
tool_calls = response.choices[0].message.tool_calls
if tool_calls is None:
break # no more tools → final answer
for tool in tool_calls:
result = TOOL_CALL_MAP[tool.function.name](
**json.loads(tool.function.arguments)
)
messages.append({
"role": "tool",
"tool_call_id": tool.id,
"content": result
})
print(response.choices[0].message.content)[iter 1] reasoning_content: The user wants live weather for Tokyo. I have a
get_weather tool that takes a city name, so I should call it
instead of guessing from memory.
[iter 1] tool_calls: get_weather({"city": "Tokyo"})
[tool ] {"temp": "22°C", "condition": "sunny"}
[iter 2] reasoning_content: The tool returned 22°C and sunny. That is all
the user asked for, so I can answer directly now.
[iter 2] content: It's 22°C and sunny in Tokyo right now.
It's 22°C and sunny in Tokyo right now.The loop ran twice: once to request the tool, once to turn the tool result into an answer. Notice that iteration 2's thinking refers back to iteration 1's decision — the model is continuing a thought, not starting a new one.
So what happens if we strip the thinking here, the way we did in Direct Chat?
# Same loop, but sending only role + content back
messages.append({"role": "assistant", "content": msg.content,
"tool_calls": msg.tool_calls}) # reasoning_content droppedopenai.BadRequestError: Error code: 400 - {'error': {'message':
'Invalid assistant message at index 1: reasoning_content is required
when tools are enabled.', 'type': 'invalid_request_error'}}HTTP 400. The request is rejected before the model even runs.
reasoning_content is part of the conversation state, not a debug field. The model's plan for a multi-step task lives in there, so it must come back verbatim on every subsequent call.
Side by Side
Here is how the same conversation is assembled in each mode. Look at what survives in the history:
Direct Chat
# reasoning_content IGNORED
messages = [
{role: system, content: S₀},
{role: user, content: U₀},
{role: assistant, content: C₁},
# ↑ R₁ silently dropped
{role: user, content: U₁},
{role: assistant, content: C₂},
{role: user, content: U₂},
...
]Agent mode (with tools)
# reasoning_content REQUIRED
messages = [
{role: system, content: S₀},
{role: user, content: U₀},
{role: assistant,
reasoning_content: R₁,
content: C₁,
tool_calls: [T₁]},
{role: tool, content: TR₁},
{role: assistant,
reasoning_content: R₂,
content: C₂},
{role: user, content: U₁},
...
]Naming the Building Blocks
Before we move on, let's give names to these building blocks. We'll use these throughout the rest of the tutorials, because writing "the reasoning tokens produced at turn 3" gets old fast.
| Symbol | Meaning | API field |
|---|---|---|
| S₀ | System prompt (sent once, at the very start) | {"role": "system", "content": ...} |
| U₀ … Uₙ | User message at turn i | {"role": "user", "content": ...} |
| Rᵢ | Reasoning (thinking) at turn i | message.reasoning_content |
| Cᵢ | Content (final answer) at turn i | message.content |
| Tᵢ | Tool call(s) at turn i | message.tool_calls |
| TRᵢ | Tool result at turn i | {"role": "tool", "content": ...} |
Each symbol stands for a token count, not the text itself. So the total input at turn $n$ in Agent mode is the sum of everything sent so far:
And in Direct Chat, the same conversation is shorter, because every $R_i$ is gone:
One More Thing: the Token Counter
Every response carries a usage block, and it is more detailed than you might expect:
print(response.usage)CompletionUsage(
prompt_tokens=4820,
completion_tokens=612,
prompt_cache_hit_tokens=4736, # ← charged at a discount
prompt_cache_miss_tokens=84 # ← charged at full price
)Of 4,820 input tokens, only 84 were actually new. The other 4,736 had been seen before — and they cost a fraction of the price. That split is the single biggest lever on your bill.
Recap
- A reasoning model returns two sibling fields:
reasoning_contentandcontent. - Without
tools, you are in Direct Chat and reasoning is ignored by the server. - With
tools, you are in Agent mode and reasoning must be returned verbatim or you get a 400. - We now have symbols — S₀, Uᵢ, Rᵢ, Cᵢ, Tᵢ, TRᵢ — for talking about token counts precisely.
Now that we know how messages flow, let's look at what happens behind the scenes on the GPU — and why it matters for your wallet.
发出你的第一个 API 请求
这一节我们发出第一个请求,调用一个推理模型,然后会发现一件有意思的事:这类模型在回答之前会先思考。我们直接跑代码看看是什么样子。
先发一条消息
我们用 OpenAI 兼容的 Python 客户端,把地址指向 DeepSeek。下面是能跟推理模型对话的最小程序:一条消息进去,一条回复出来。
from openai import OpenAI
client = OpenAI(
api_key="<DeepSeek API Key>",
base_url="https://api.deepseek.com"
)
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=[{"role": "user", "content": "9.11 和 9.8 哪个更大?"}],
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}}
)
msg = response.choices[0].message
print("--- reasoning_content ---")
print(msg.reasoning_content)
print("--- content ---")
print(msg.content)--- reasoning_content ---
用户在比较 9.11 和 9.8。这里常见的坑是看到 "11 > 8" 就以为 9.11 更大。
但这是小数:9.11 = 9 + 0.11,9.8 = 9 + 0.80。因为 0.80 > 0.11,
所以 9.8 更大。对齐小数位再确认一遍:9.80 vs 9.11,没错,是 9.8。
--- content ---
9.8 比 9.11 大。
把小数位对齐就很清楚了:9.80 vs 9.11。注意回来的是两个字段,不是一个:
- reasoning_content —— 模型的草稿纸。它在这里推演问题、发现自己的错误、把自己说服到一个答案上。
- content —— 给用户看的、已经整理好的回答。
这两个字段是同一个 message 对象上的并列字段。产品界面通常只展示 content,把 reasoning_content 收进一个「查看思考过程」的折叠区里。
第二轮:该把什么传回去?
发一次很简单,多轮才是有意思的地方,因为这个 API 是无状态的 —— 服务端什么都不记,所以每一轮都要由你把完整历史重新发一遍。
假设用户接着追问了一句。那么第二轮该把什么传回去?有两个看起来都合理的选项:
- 只把
content传回去,思考过程扔掉。毕竟那只是草稿。 - 把
content和reasoning_content原样一起传回去。
两种都说得通,这正是坑所在。正确答案取决于一个你可能还没注意到的参数。
两种模式,两套规则
你有没有传 tools 参数,决定了这个请求属于哪种模式,而两种模式对推理内容的处理方式正好相反。
| 模式 | 怎么触发 | reasoning_content 的命运 |
|---|---|---|
| 直聊模式 | 不传 tools 参数 | 被忽略,服务端从历史里丢掉它 |
| Agent 模式 | 带上 tools=[...] | 必须传,漏掉就报错 |
两种都跑一遍,看看区别。
模式一:直聊,推理会被忽略
这是最普通的聊天机器人场景:没有工具,就是一问一答。我们把整个 assistant 消息(含思考过程)追加进去,然后问下一个问题。
messages = [{"role": "user", "content": "9.11 和 9.8 哪个更大?"}]
messages.append(response.choices[0].message) # 里面还带着 reasoning_content
messages.append({"role": "user", "content": "strawberry 里有几个 r?"})
response2 = client.chat.completions.create(
model="deepseek-v4-pro",
messages=messages, # 没有 tools= 参数 → 直聊模式
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}}
)
print(response2.choices[0].message.content)"strawberry" 里有 3 个 r:st-r-awbe-rr-y。能跑通。现在同样的事再做一次,但这回在传回去之前把思考过程剥掉:
def strip_reasoning(msg):
# 只保留 role 和 content,丢掉 reasoning_content
return {"role": "assistant", "content": msg.content}
messages = [
{"role": "user", "content": "9.11 和 9.8 哪个更大?"},
strip_reasoning(response.choices[0].message),
{"role": "user", "content": "strawberry 里有几个 r?"},
]
response3 = client.chat.completions.create(
model="deepseek-v4-pro",
messages=messages,
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}}
)
print(response3.choices[0].message.content)"strawberry" 里有 3 个 r:st-r-awbe-rr-y。结果一样。这不是碰巧 —— 直聊模式下服务端本来就没看 reasoning_content。它只读 role 和 content,assistant 消息里其余的字段都被静默丢掉。
reasoning_content 传回去 —— 你会看到输出没有任何区别。然后对两个版本都打印一下 response.usage.prompt_tokens:数字几乎一样,这就是服务端在计数之前就把这个字段丢掉了的证据。
模式二:Agent 模式,推理是必需的
现在给模型一个工具。结构上我们只多加了一个 tools 参数,但规则被这一个参数改写了。
Agent 不是一次调用,而是一个循环:模型申请调工具,我们执行,把结果递回去,模型接着往下走,直到它不再申请工具为止。
import json
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询某个城市的当前天气",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
}]
TOOL_CALL_MAP = {
"get_weather": lambda city: json.dumps({"temp": "22°C", "condition": "sunny"})
}
messages = [{"role": "user", "content": "东京现在天气怎么样?"}]
while True:
response = client.chat.completions.create(
model="deepseek-v4-pro",
messages=messages,
tools=tools, # ← 这一行让它变成 Agent 模式
reasoning_effort="high",
extra_body={"thinking": {"type": "enabled"}}
)
# 原样追加整个 message 对象,reasoning_content 一并保留
messages.append(response.choices[0].message)
tool_calls = response.choices[0].message.tool_calls
if tool_calls is None:
break # 不再申请工具 → 已经是最终回答
for tool in tool_calls:
result = TOOL_CALL_MAP[tool.function.name](
**json.loads(tool.function.arguments)
)
messages.append({
"role": "tool",
"tool_call_id": tool.id,
"content": result
})
print(response.choices[0].message.content)[第 1 轮] reasoning_content: 用户要东京的实时天气。我有一个 get_weather
工具,接收城市名,所以应该调它,而不是凭记忆猜。
[第 1 轮] tool_calls: get_weather({"city": "Tokyo"})
[工具 ] {"temp": "22°C", "condition": "sunny"}
[第 2 轮] reasoning_content: 工具返回了 22°C、晴。这就是用户问的全部内容,
可以直接回答了。
[第 2 轮] content: 东京现在 22°C,晴天。
东京现在 22°C,晴天。循环跑了两次:一次去申请工具,一次把工具结果变成回答。注意第 2 轮的思考是在回指第 1 轮的决定 —— 模型是在把一个念头接着往下想,不是重新起一个。
那如果在这里也像直聊那样把思考剥掉呢?
# 同样的循环,但只把 role 和 content 传回去
messages.append({"role": "assistant", "content": msg.content,
"tool_calls": msg.tool_calls}) # reasoning_content 被丢掉了openai.BadRequestError: Error code: 400 - {'error': {'message':
'Invalid assistant message at index 1: reasoning_content is required
when tools are enabled.', 'type': 'invalid_request_error'}}HTTP 400。请求在模型开跑之前就被拒了。
reasoning_content 是对话状态的一部分,不是调试字段。模型对一个多步任务的计划就存在里面,所以后续每一次调用都必须把它原样带回去。
并排看一眼
同一段对话在两种模式下的组装方式如下。重点看历史里留下了什么:
直聊模式
# reasoning_content 被忽略
messages = [
{role: system, content: S₀},
{role: user, content: U₀},
{role: assistant, content: C₁},
# ↑ R₁ 被静默丢弃
{role: user, content: U₁},
{role: assistant, content: C₂},
{role: user, content: U₂},
...
]Agent 模式(带 tools)
# reasoning_content 必须保留
messages = [
{role: system, content: S₀},
{role: user, content: U₀},
{role: assistant,
reasoning_content: R₁,
content: C₁,
tool_calls: [T₁]},
{role: tool, content: TR₁},
{role: assistant,
reasoning_content: R₂,
content: C₂},
{role: user, content: U₁},
...
]给这些积木起名字
往下走之前,先给这些积木起个名字。后面几篇教程会一直用这套记法,因为每次都写「第 3 轮产生的推理 token」太啰嗦了。
| 符号 | 含义 | 对应 API 字段 |
|---|---|---|
| S₀ | 系统提示词(只在最开头出现一次) | {"role": "system", "content": ...} |
| U₀ … Uₙ | 第 i 轮的用户消息 | {"role": "user", "content": ...} |
| Rᵢ | 第 i 轮的推理(思考) | message.reasoning_content |
| Cᵢ | 第 i 轮的内容(最终回答) | message.content |
| Tᵢ | 第 i 轮的工具调用 | message.tool_calls |
| TRᵢ | 第 i 轮的工具返回 | {"role": "tool", "content": ...} |
每个符号代表的是 token 数量,不是文本本身。所以 Agent 模式下第 $n$ 轮的总输入,就是到目前为止发出去的所有东西之和:
同一段对话在直聊模式下会短一些,因为每个 $R_i$ 都没了:
还有一件事:token 计数器
每个响应都带一个 usage 块,它比你预期的要细:
print(response.usage)CompletionUsage(
prompt_tokens=4820,
completion_tokens=612,
prompt_cache_hit_tokens=4736, # ← 按折扣价计费
prompt_cache_miss_tokens=84 # ← 按全价计费
)4820 个输入 token 里,真正是新的只有 84 个。另外 4736 个是之前见过的,而它们的价格只是全价的一小部分。这个拆分是你账单上最大的那根杠杆。
小结
- 推理模型返回两个并列字段:
reasoning_content和content。 - 不带
tools就是直聊模式,服务端会忽略推理内容。 - 带上
tools就是 Agent 模式,推理内容必须原样传回,否则报 400。 - 我们现在有了一套符号 —— S₀、Uᵢ、Rᵢ、Cᵢ、Tᵢ、TRᵢ —— 可以精确地讨论 token 数量了。
知道了消息是怎么流动的,接下来我们看看 GPU 上到底发生了什么,以及它为什么和你的钱包有关。