手把手教你在不用 LangChain 等框架的前提下,仅用 Claude API 和纯 Python 构建可调用工具、做算术和词数统计的 Agent 完整流程。
每隔几周就会出现一个新的框架,承诺让"Agent式 AI"变得简单。它们大多数都围绕一个核心概念包装:模型不再只是生成文本——它可以暂停,说"我需要用这些参数调用这个函数",等待结果,然后拿着这些信息继续往下走。
就这样。这就是全部的诀窍。Anthropic 把这叫做 tool use(工具调用),而它正是驱动一切的核心机制——从"让 Claude 查天气"到多步骤编程 Agent。
本教程从头构建一个可工作的版本——不依赖 LangChain,不依赖任何 Agent 框架,只有 Claude API 和纯 Python。学完之后你会得到一个小 Agent,它能通过调用真实的 Python 函数做算术和计数单词,自己决定何时使用它们,以及当问题需要同时用到多个工具时将它们串联起来。
前置条件
pip install anthropic就这些。没有向量数据库,没有 Docker,不需要别的东西。
这是人们经常过度复杂化的部分。所谓"工具"就是一个普通的 Python 函数,加上一段小型 JSON 描述,告诉 Claude 这个工具是做什么的、需要什么参数。
我们写两个:一个计算器和一个单词计数器。保存为 tools.py:
"""
The actual Python functions our agent can call, plus the JSON-schema
descriptions of those tools that we hand to the Claude API.
"""
import ast
import operator
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
}
def calculate(expression: str) -> str:
"""Safely evaluate a basic arithmetic expression like '12 * (3 + 4)'."""
def _eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.operand))
raise ValueError(f"Unsupported expression: {expression!r}")
tree = ast.parse(expression, mode="eval")
result = _eval(tree.body)
return str(result)
def count_words(text: str) -> str:
"""Count the words in a piece of text."""
return str(len(text.split()))
TOOLS = [
{
"name": "calculate",
"description": (
"Evaluate a basic arithmetic expression and return the numeric "
"result as a string. Supports +, -, *, /, **, parentheses, and "
"negative numbers. Use this any time the user asks for a "
"calculation, even a simple one -- do not do math in your head."
),
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A valid arithmetic expression, e.g. '127 * 38' or '(12 + 4) / 2'.",
}
},
"required": ["expression"],
},
},
{
"name": "count_words",
"description": (
"Count how many words are in a given piece of text and return "
"the count as a string. Use this when the user asks for a word "
"count of something rather than estimating it yourself."
),
"input_schema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The text to count words in.",
}
},
"required": ["text"],
},
},
]
TOOL_FUNCTIONS = {
"calculate": calculate,
"count_words": count_words,
}
有几个值得指出的刻意设计。calculate 函数使用 Python 的 ast 模块将表达式解析为语法树并手动遍历,而不是直接调用 eval()——eval("import os; os.system(...)") 正是你不希望在任何 AI 控制函数附近出现的东西,尽管 ast.parse(mode="eval")本身会拒绝像import` 这样的语句。描述字段也比直觉上感觉的要长。这是刻意的——Claude 对工具的选择质量很大程度上取决于每个工具对自己功能和适用场景的描述有多清晰。
这是真正让它变得"Agentic"的部分。保存为 agent.py:
"""
The agent loop: send a message, check whether Claude wants to use a tool,
run that tool locally, send the result back, and repeat until Claude
gives a final text answer.
"""
from tools import TOOLS, TOOL_FUNCTIONS
def run_agent(client, user_message, model="claude-sonnet-4-6", max_iterations=5, verbose=True):
messages = [{"role": "user", "content": user_message}]
for step in range(max_iterations):
response = client.messages.create(
model=model,
max_tokens=1024,
tools=TOOLS,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return "".join(
block.text for block in response.content if block.type == "text"
)
tool_results = []
for block in response.content:
if block.type == "text" and verbose and block.text.strip():
print(f" [Claude says]: {block.text.strip()}")
if block.type == "tool_use":
func = TOOL_FUNCTIONS.get(block.name)
if verbose:
print(f" [tool call]: {block.name}({block.input})")
if func is None:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": f"Unknown tool: {block.name}",
"is_error": True,
})
continue
try:
result = func(**block.input)
if verbose:
print(f" [tool result]: {result}")
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
except Exception as exc:
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(exc),
"is_error": True,
})
messages.append({"role": "user", "content": tool_results})
return "Reached max_iterations without a final answer -- something is looping."
这里有三处容易出错的地方,做错了 API 会返回 400 拒绝你的请求:
tool_result 块必须放在一个新的 user 消息里,而不是追加到 assistant 的消息中。tool_result 块必须放在该消息 content 数组的最前面——你这一侧的任何文本都必须放在它们之后。Assistant 响应中的每个 tool_use 块都需要一个具有相同 tool_use_id 的匹配 tool_result,包括工具出错时——所以即使出错的情况仍然追加了 tool_result,只是带有 is_error: true。
这是大多数教程跳过但实际上最有用的部分。Claude API 的 tool-use 响应有一个有文档记录的、可预测的形状——一个 stop_reason,以及一个 content 块列表,每个块要么是 text 要么是 tool_use。所以我们可以伪造这个形状,喂给 run_agent,验证循环、工具分发、以及实际的数学/单词计数逻辑都能正常工作——无需 API key,不花任何 token。
保存为 test_agent_offline.py:
from types import SimpleNamespace
from agent import run_agent
from tools import calculate, count_words
def block(**kwargs):
return SimpleNamespace(**kwargs)
class FakeMessages:
def __init__(self, script):
self.script = script
self.calls = 0
def create(self, **kwargs):
response = self.script[self.calls]
self.calls += 1
return response
class FakeClient:
def __init__(self, script):
self.messages = FakeMessages(script)
def test_parallel_tool_calls():
turn1 = SimpleNamespace(
stop_reason="tool_use",
content=[
block(type="text", text="I'll do both of those."),
block(type="tool_use", id="toolu_010", name="calculate",
input={"expression": "(12 + 4) / 2"}),
block(type="tool_use", id="toolu_011", name="count_words",
input={"text": "the quick brown fox jumps over the lazy dog"}),
],
)
turn2 = SimpleNamespace(
stop_reason="end_turn",
content=[block(type="text", text="(12 + 4) / 2 is 8.0, and that sentence has 9 words.")],
)
client = FakeClient([turn1, turn2])
answer = run_agent(client, "Two things for you...", verbose=True)
assert "8.0" in answer
assert "9 words" in answer
print("test_parallel_tool_calls passed\n")
def test_underlying_functions_directly():
assert calculate("127 * 38") == "4826"
assert calculate("(12 + 4) / 2") == "8.0"
assert calculate("-3 + 7 ** 2") == "46"
assert count_words("the quick brown fox jumps over the lazy dog") == "9"
try:
calculate("import os")
raise AssertionError("should have raised")
except (ValueError, SyntaxError):
pass
print("test_underlying_functions_directly passed\n")
if __name__ == "__main__":
test_underlying_functions_directly()
test_parallel_tool_calls()
print("All offline tests passed.")
用 python3 test_agent_offline.py 运行会产生:
test_underlying_functions_directly passed
[Claude says]: I'll do both of those.
[tool call]: calculate({'expression': '(12 + 4) / 2'})
[tool result]: 8.0
[tool call]: count_words({'text': 'the quick brown fox jumps over the lazy dog'})
[tool result]: 9
test_parallel_tool_calls passed
All offline tests passed.
这个输出是实际运行上述代码的结果——不是我手写的记录。它同时确认了三件事:计算器正确处理了运算符优先级和负数,Agent 循环正确处理了单轮中的多个工具调用(Claude 通常会并行执行两个计算而不是一次一个),以及消息历史以真实 API 期望的形状构建。
如果你改了任何东西——加了一个工具、改了 schema、重写了循环——先重新运行这个文件。它能在几秒钟内、以零 API 成本捕获大多数"为什么我的 Agent 刚才 400 了"的问题。
一旦离线测试通过,换上真实的 client。保存为 run.py:
"""
Run with a real API key:
export ANTHROPIC_API_KEY="sk-ant-..."
pip install anthropic
python3 run.py
"""
from anthropic import Anthropic
from agent import run_agent
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
if __name__ == "__main__":
question = (
"What's 127 * 38, and how many words are in the sentence "
"'the quick brown fox jumps over the lazy dog'?"
)
answer = run_agent(client, question)
print("\nFinal answer:", answer)
将你的 API key 设为环境变量,安装 SDK,然后运行:
export ANTHROPIC_API_KEY="sk-ant-your-key-here"
pip install anthropic
python3 run.py
因为离线测试已经用与真实 API 响应形状相同的方式执行了完全相同的 run_agent 函数,所以这里真正测试的只是"我的 API key 能否工作,以及真实模型的行为是否符合文档描述的形状"——这是一个更小、更便宜、更容易调试的问题。
对于上面的问题,以下是真实的执行序列:
Claude 收到问题,同时收到两个工具定义。它判断这需要两个工具,而且——因为 Claude 4 系列模型默认并行调用工具——它可以在单次响应中返回两个 tool_use 块,通常前面先带一句简短的上下文("我来帮你计算并统计单词")。
我们的循环检测到 stop_reason == "tool_use",在本地运行 calculate("127 * 38") 和 count_words(...),并将两个结果一起发回一个新的 user 消息,tool_result 块放在前面。
Claude 收到这些结果后,既然已经拥有所需的一切,就会以 stop_reason == "end_turn" 和普通文本回答进行响应。我们的循环检测到这个条件后返回文本。完成——总共两次 API 调用,真实的计算发生在真实的 Python 中间过程中。
为任务选择合适的模型。Anthropic 自己的指导是:对于参数模糊或选项众多的工具,使用 Opus 这样的大模型;对于简单、定义明确的工具,使用 Haiku 这样的小模型——小模型更可能猜测缺失的参数而不是主动询问。
不要跳过 max_iterations。如果一个工具的结果经常导致 Claude 再次调用同一个工具,你可能会陷入循环。run_agent 中的上限是开发过程中一个粗暴但有效的安全网。
工具描述是大部分工作。如果 Claude 选错了工具,或者用奇怪的参数调用了正确的工具,解决方案几乎总是更清晰的描述——工具做什么、何时使用、何时不用、以及每个参数是什么意思——而不是改变你的循环逻辑。
对于玩具级别以上的东西,看看 SDK 的 tool runner。一旦你熟悉了上面的手动循环(并且理解了它为什么是那个形状),Anthropic 的 Python、TypeScript 和 Ruby SDK 都包含了一个 beta"tool runner",帮你处理请求/响应循环和对话状态。先学手动版本是值得的——tool runner 底层做的正是这些,而当出问题的时候,手动版本更容易调试。