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.

pythonfrom 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)
Output
--- 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:

  1. Send back only content, and throw the thinking away. It was scratch work, after all.
  2. Send back both content and reasoning_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.

ModeHow you trigger itWhat happens to reasoning_content
Direct ChatNo tools parameterIgnored — the server drops it from history
Agent modetools=[...] is presentRequired — 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.

pythonmessages = [{"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)
Output
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:

pythondef 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)
Output
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.

Key insight In Direct Chat, reasoning is ephemeral. The model re-thinks from scratch every turn, and last turn's thinking is gone the moment the response is delivered.
✏️ Try it out! Run the Direct Chat example and try passing 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.

pythonimport 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)
Output (loop trace)
[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?

python# Same loop, but sending only role + content back messages.append({"role": "assistant", "content": msg.content, "tool_calls": msg.tool_calls}) # reasoning_content dropped
Output
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. The request is rejected before the model even runs.

Critical difference In Agent mode, 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.

SymbolMeaningAPI 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 imessage.reasoning_content
CᵢContent (final answer) at turn imessage.content
TᵢTool call(s) at turn imessage.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:

$$\text{input}_{\text{agent}}(n) = S_0 + U_0 + \sum_{i=1}^{n}(R_i + C_i + T_i + TR_i + U_i)$$

And in Direct Chat, the same conversation is shorter, because every $R_i$ is gone:

$$\text{input}_{\text{chat}}(n) = S_0 + U_0 + \sum_{i=1}^{n}(C_i + U_i)$$

One More Thing: the Token Counter

Every response carries a usage block, and it is more detailed than you might expect:

pythonprint(response.usage)
Output
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_content and content.
  • 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。下面是能跟推理模型对话的最小程序:一条消息进去,一条回复出来。

pythonfrom 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 是无状态的 —— 服务端什么都不记,所以每一轮都要由你把完整历史重新发一遍。

假设用户接着追问了一句。那么第二轮该把什么传回去?有两个看起来都合理的选项:

  1. 只把 content 传回去,思考过程扔掉。毕竟那只是草稿。
  2. contentreasoning_content 原样一起传回去。

两种都说得通,这正是坑所在。正确答案取决于一个你可能还没注意到的参数。

两种模式,两套规则

你有没有传 tools 参数,决定了这个请求属于哪种模式,而两种模式对推理内容的处理方式正好相反。

模式怎么触发reasoning_content 的命运
直聊模式不传 tools 参数被忽略,服务端从历史里丢掉它
Agent 模式带上 tools=[...]必须传,漏掉就报错

两种都跑一遍,看看区别。

模式一:直聊,推理会被忽略

这是最普通的聊天机器人场景:没有工具,就是一问一答。我们把整个 assistant 消息(含思考过程)追加进去,然后问下一个问题。

pythonmessages = [{"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。

能跑通。现在同样的事再做一次,但这回在传回去之前把思考过程剥掉:

pythondef 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。它只读 rolecontent,assistant 消息里其余的字段都被静默丢掉。

关键认知 直聊模式下推理是一次性的。模型每一轮都从零重新想一遍,上一轮的思考在回复送出的那一刻就没了。
✏️ 动手试试! 把直聊那段跑一遍,再试着把 reasoning_content 传回去 —— 你会看到输出没有任何区别。然后对两个版本都打印一下 response.usage.prompt_tokens:数字几乎一样,这就是服务端在计数之前就把这个字段丢掉了的证据。

模式二:Agent 模式,推理是必需的

现在给模型一个工具。结构上我们只多加了一个 tools 参数,但规则被这一个参数改写了。

Agent 不是一次调用,而是一个循环:模型申请调工具,我们执行,把结果递回去,模型接着往下走,直到它不再申请工具为止。

pythonimport 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 轮的决定 —— 模型是在把一个念头接着往下想,不是重新起一个。

那如果在这里也像直聊那样把思考剥掉呢?

python# 同样的循环,但只把 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。请求在模型开跑之前就被拒了。

关键区别 Agent 模式下 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$ 轮的总输入,就是到目前为止发出去的所有东西之和:

$$\text{input}_{\text{agent}}(n) = S_0 + U_0 + \sum_{i=1}^{n}(R_i + C_i + T_i + TR_i + U_i)$$

同一段对话在直聊模式下会短一些,因为每个 $R_i$ 都没了:

$$\text{input}_{\text{chat}}(n) = S_0 + U_0 + \sum_{i=1}^{n}(C_i + U_i)$$

还有一件事:token 计数器

每个响应都带一个 usage 块,它比你预期的要细:

pythonprint(response.usage)
输出
CompletionUsage( prompt_tokens=4820, completion_tokens=612, prompt_cache_hit_tokens=4736, # ← 按折扣价计费 prompt_cache_miss_tokens=84 # ← 按全价计费 )

4820 个输入 token 里,真正是新的只有 84 个。另外 4736 个是之前见过的,而它们的价格只是全价的一小部分。这个拆分是你账单上最大的那根杠杆。

小结

  • 推理模型返回两个并列字段:reasoning_contentcontent
  • 不带 tools 就是直聊模式,服务端会忽略推理内容。
  • 带上 tools 就是 Agent 模式,推理内容必须原样传回,否则报 400。
  • 我们现在有了一套符号 —— S₀、Uᵢ、Rᵢ、Cᵢ、Tᵢ、TRᵢ —— 可以精确地讨论 token 数量了。

知道了消息是怎么流动的,接下来我们看看 GPU 上到底发生了什么,以及它为什么和你的钱包有关。