Tool calling: letting the model act
A model can't look up live data or carry out any action by itself. Give it a real tool that checks a package's latest version on PyPI, and walk through the whole tool-calling flow, including parallel calls, error handling and what to watch in thinking mode.
- About 45 min
- Level: Intermediate
- Tested: 2026-09-14 deepseek-flash
Code and program output are shown exactly as they ran, so comments and printed output are in Chinese.
Ask a model "what's the latest version of httpx?" and it can only answer from its training data, with a version number that may be a year old, or made up. Ask it "add a meeting to my calendar" and all it can say is "done, I've added it for you", and nothing happens.
A model can only output text. To let it look up live data and actually do things, you give it tools: you write functions and tell the model which functions are available; when the model decides one is needed, it outputs "I want to call such-and-such function with these arguments"; your program actually runs the function and tells the model the result; and the model answers the user based on that result. This mechanism is called tool calling, also known as function calling.
It's the foundation of module 05's agents, and this lesson goes through every part of it.
The flow
First, the big picture. A Q&A with a tool call involves at least two calls to the model:
你的程序 模型
│ 1. 用户问题 + 工具说明书 │
│ ────────────────────────────────────────────▶ │
│ 2. "请调用 get_pypi_info(httpx)" │
│ ◀──────────────────────────────────────────── │
│ 3. 程序自己执行 get_pypi_info("httpx") │
│ 拿到结果 {"version": "0.28.1", ...} │
│ 4. 之前的全部消息 + 工具结果 │
│ ────────────────────────────────────────────▶ │
│ 5. "httpx 的最新版本是 0.28.1" │
│ ◀──────────────────────────────────────────── │
The key is in steps 2 and 3: the model never executes any code. It only outputs a structured "call request", and the power to execute stays entirely with your program. You can check what it wants to call and whether the arguments are right, and decide to run it or refuse. This matters a great deal for security, and lesson 8 of module 05 expands on it.
Step 1: write the tool and its description
A tool is just an ordinary Python function. Here's one that really works: it calls PyPI's public API to look up a package's latest version.
import httpx
def get_pypi_info(package: str) -> dict:
"""真正干活的函数:调用 PyPI 的公开接口。"""
r = httpx.get(f"https://pypi.org/pypi/{package}/json", timeout=10)
if r.status_code == 404:
return {"error": f"PyPI 上没有叫 {package} 的包"}
info = r.json()["info"]
return {"name": info["name"], "version": info["version"], "summary": info["summary"],
"requires_python": info["requires_python"]}
Incidentally, the library sending the HTTP request here is httpx itself. It was already installed as a dependency when you installed openai.
Then write a "description" telling the model the tool exists. The model can't see your function's code; it can only see this description:
TOOLS = [
{
"type": "function",
"function": {
"name": "get_pypi_info",
"description": "查询一个 Python 包在 PyPI 上的最新版本、简介和支持的 Python 版本。",
"parameters": {
"type": "object",
"properties": {
"package": {"type": "string", "description": "PyPI 上的包名,例如 httpx"},
},
"required": ["package"],
},
},
}
]
FUNCTIONS = {"get_pypi_info": get_pypi_info}
name is the tool's name, description says what it can do, and parameters describes the arguments with JSON Schema. The model decides when to use the tool from description and how to fill in the arguments from parameters. How well the description is written directly decides whether the model uses the tool and uses it correctly; lesson 3 of module 05 covers how to write one.
FUNCTIONS maps tool names to the actual functions; when the program receives a call request, it uses this to find the function to run.
Step 2: the loop
messages = [{"role": "user", "content": "httpx 和 requests 在 PyPI 上的最新版本分别是多少?各自要求什么 Python 版本?"}]
for step in range(1, 6): # 最多 5 轮,防止意外的死循环
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
extra_body={"thinking": {"type": "enabled" if THINKING else "disabled"}},
)
message = response.choices[0].message
print(f"第 {step} 轮:finish_reason={response.choices[0].finish_reason}")
if not message.tool_calls:
print("最终回答:", message.content)
break
# 把模型的这条消息原样放回历史。开思考时,里面的 reasoning_content 也必须带上
messages.append(message.model_dump(exclude_none=True))
for call in message.tool_calls:
args = json.loads(call.function.arguments)
print(f" 模型要求调用 {call.function.name}({args})")
try:
result = FUNCTIONS[call.function.name](**args)
except Exception as e: # 工具出错也要告诉模型,而不是让程序崩掉
result = {"error": f"{type(e).__name__}: {e}"}
print(f" 返回:{result}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False)})
Each round: call the model with tools. If the reply has no tool_calls, the model has given its final answer, and we're done. If it does, run each one, add the result back to the history as a message whose role is tool, and call the model again.
A few things to note:
- The model's call request goes back into the history.
messages.append(message.model_dump(exclude_none=True))adds the model's message containingtool_callsback exactly as it was. Skip this and in the next round the model sees a pile of tool results without knowing who asked for them. tool_call_idmust match. Every call request has anid, and the corresponding tool result must carry the sametool_call_id. When the model asks for several tools at once, this ID is how it knows which result goes with which request.- The arguments are JSON in a string.
call.function.argumentsis a string like'{"package": "httpx"}'and has to be parsed withjson.loads. - Don't let a tool error crash the program. Return the error message to the model as the result; the model can often adjust, say by trying different arguments, or tell the user honestly that it couldn't find the answer.
- Set a limit on rounds. The model might keep calling tools without stopping, and the limit is the last line of defence.
The result
第 1 轮:finish_reason=tool_calls
模型要求调用 get_pypi_info({'package': 'httpx'})
返回:{'name': 'httpx', 'version': '0.28.1', 'summary': 'The next generation HTTP client.', 'requires_python': '>=3.8'}
模型要求调用 get_pypi_info({'package': 'requests'})
返回:{'name': 'requests', 'version': '2.34.2', 'summary': 'Python HTTP for Humans.', 'requires_python': '>=3.10'}
第 2 轮:finish_reason=stop
最终回答: 两个包在 PyPI 上的最新信息如下:
| 包名 | 最新版本 | 要求 Python 版本 | 简介 |
|---|---|---|---|
| **httpx** | 0.28.1 | >=3.8 | The next generation HTTP client. |
| **requests** | 2.34.2 | >=3.10 | Python HTTP for Humans. |
几点说明:
- **httpx** 支持范围更宽,Python 3.8 及以上都能用,兼容性更好。
- **requests** 这边要求 Python 3.10 及以上,门槛更高一些。
- 光看"最低版本要求"的话,httpx 覆盖的老版本 Python 更多;但如果你跑在 3.10+ 环境上,两者都没问题。
如果你告诉我项目所用的 Python 版本,我可以帮你判断具体该选哪个。
(These are the version numbers on 14 September 2026; by the time you run it, the versions on PyPI may have moved on.)
The finish_reason in round 1 is tool_calls, the third case in the table from lesson 3 of module 00. And the model asked for two calls in the same round: one to look up httpx and one to look up requests. This is called parallel tool calling: the model judged that the two lookups don't depend on each other and requested them together, saving a round trip. In round 2, with two sets of real data in hand, the model gave its final answer, and the version numbers both came from PyPI, not from its imagination.
With thinking mode on
DeepSeek's models have thinking on by default. Using tools in thinking mode comes with a rule: the reasoning_content of every earlier round must be passed back to the API exactly as it was. Without tools it doesn't matter whether you pass it, and the server ignores it; with tools you must.
The code above uses message.model_dump(exclude_none=True) to turn the model's whole message into a dictionary and put it back into the history, which naturally includes reasoning_content, so it works with thinking on too:
python tool_calling.py --think
第 1 轮:finish_reason=tool_calls
模型要求调用 get_pypi_info({'package': 'httpx'})
返回:{'name': 'httpx', 'version': '0.28.1', 'summary': 'The next generation HTTP client.', 'requires_python': '>=3.8'}
模型要求调用 get_pypi_info({'package': 'requests'})
返回:{'name': 'requests', 'version': '2.34.2', 'summary': 'Python HTTP for Humans.', 'requires_python': '>=3.10'}
第 2 轮:finish_reason=stop
最终回答: 两个包在 PyPI 上的最新信息如下:
(后面的回答内容和不开思考时相近,这里省略)
A common way of writing it picks out only content and tool_calls and builds a dictionary by hand to put back into the history. That works without thinking, but with thinking on it drops reasoning_content. Putting the message back as-is with model_dump saves you the trouble.
When the model passes bad arguments
The arguments the model fills in aren't always right: a package name that doesn't exist, a missing required argument, the wrong type. There are several lines of defence:
- Write a clear description. Put the format and an example in the argument's
description; "PyPI 上的包名,例如 httpx" (a package name on PyPI, e.g. httpx) beats just "包名" (package name). - Check inside the function. Don't assume the arguments are valid: check whether the package name has odd characters, whether a number is within a sensible range.
- Return errors to the model. In the code above, any exception the function raises is caught and returned as
{"error": "..."}. For a package PyPI can't find, the function itself also returns an explanation of the error. When the model sees an error, it usually corrects itself or tells the user honestly. - Strict mode. Lesson 4 of module 02 covered DeepSeek's strict mode (
strict: true), which guarantees the arguments follow the schema's structure, but can't guarantee their content is right.
Common problems
The model should have called a tool but didn't, and invented an answer instead: check whether the tool's description clearly says what it can do. You can also write in the system message "涉及包的版本信息时,必须用 get_pypi_info 查询,不要凭记忆回答" (for package version information, always look it up with get_pypi_info; don't answer from memory). When a particular tool must be called, you can force it with tool_choice.
An error says the messages are in the wrong order: usually a tool message has no matching assistant message with tool_calls before it, or the tool_call_id doesn't match. Follow the code above: first the model's message, then each tool result.
Exercises
- Ask about a package that doesn't exist on PyPI, such as "httpxx 的最新版本是多少" (what's the latest version of httpxx?), and look at the error the tool returns and how the model responds to the user.
- Add another tool,
get_github_stars(repo), that calls GitHub's public APIhttps://api.github.com/repos/{repo}to get a repository's star count (no key needed, but there's an hourly limit). Ask "httpx 的最新版本和 GitHub 星数是多少" (what are httpx's latest version and GitHub star count?) and see whether the model calls two different tools at once. - Change
messages.append(message.model_dump(exclude_none=True))to a hand-built dictionary containing onlycontentandtool_calls, run it with--think, and see what happens.
Self-check
1. When a tool is called, is it the model that runs the get_pypi_info function?
No. The model only outputs a structured request along the lines of "I want to call get_pypi_info with the argument httpx". Your program is what actually runs the function. The power to execute stays entirely with the program, which can check, change or refuse the model's call request.
2. The model asks for two tools at once. How do you give it each result?
Every call request has a unique id. For each call, add a message whose role is tool, with the corresponding request's id in tool_call_id; that's how the model matches results to requests.
3. What do you need to watch when using tools with thinking mode on?
The reasoning_content in every earlier round's model message must be passed back to the API exactly as it was. The easiest way is to put the model's whole message back into the history with message.model_dump(exclude_none=True), rather than picking out content and tool_calls by hand.
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…