Agent 初探:从一次 API 调用到最小工具调用
你是一个编程新手,看到 Agent 这么火,也想自己尝试一番。
但如果一上来就学 LangChain、LlamaIndex 或各种 Agent 框架,很容易只会“调包”,却不知道 Agent 到底多了什么。
所以这一篇先不碰框架,只从最小的大模型 API 调用开始,一步一步往上加:
单次 API 调用
-> 循环对话
-> 简单上下文记忆
-> 工具注册
-> 工具调用
先说结论:
Agent 不是“会聊天的大模型”,而是“大模型 + 上下文/记忆 + 工具 + 控制循环”的组合。
这一篇先做到最小工具调用。它还不是成熟 Agent,但已经能看到 Agent 的雏形。
最小 API 调用
第一步,先让 Python 能和大模型说上一句话。
import os
from dotenv import load_dotenv
from openai import OpenAI
from openai.types.chat import ChatCompletionMessageParam
load_dotenv()
def require_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise ValueError(f"请先配置 {name}。")
return value
api_key = require_env("LLM_API_KEY")
model_id = require_env("LLM_MODEL_ID")
base_url = require_env("LLM_BASE_URL")
llm = OpenAI(api_key=api_key, base_url=base_url)
user_input = input(">>: ").strip()
messages: list[ChatCompletionMessageParam] = [
{"role": "user", "content": user_input}
]
res = llm.chat.completions.create(
model=model_id,
messages=messages,
)
print("AI:", res.choices[0].message.content)
这里做了几件事:
load_dotenv():从.env读取环境变量。require_env():确保关键配置存在,并把返回值收敛成str。OpenAI(...):创建一个 OpenAI SDK 客户端。messages:描述这次对话输入。chat.completions.create(...):发起一次聊天模型请求。
.env 里通常会放:
LLM_API_KEY=你的 API Key
LLM_MODEL_ID=你的模型 ID
LLM_BASE_URL=你的服务地址
如果你调用的是兼容 OpenAI API 的第三方模型服务,例如 DeepSeek、Qwen 或本地网关,通常也是通过 base_url 切换服务地址。
ChatCompletionMessageParam 是什么
from openai.types.chat import ChatCompletionMessageParam
ChatCompletionMessageParam 是 OpenAI SDK 提供的聊天消息类型。
它表示 messages 里的每一条消息应该长什么样,例如:
{"role": "system", "content": "你是一个助手"}
{"role": "user", "content": "你好"}
{"role": "assistant", "content": "你好,有什么可以帮你?"}
它主要用于类型提示,不会改变程序运行逻辑。
如果你在 VS Code 里用了 Pylance,类型声明会让补全和报错更舒服。比如:
messages: list[ChatCompletionMessageParam] = [
{"role": "user", "content": "你好"}
]
这句话的意思是:
messages是一个列表,列表里的元素是 Chat Completions API 能识别的消息对象。
client.chat.completions.create 代表什么
你不了解 OpenAI SDK,于是查了文档,发现请求长这样:
response = client.chat.completions.create(
model=model,
messages=messages,
)
它可以拆成四层:
client OpenAI 客户端
.chat 使用聊天模型相关能力
.completions 创建“聊天补全”任务
.create() 发起一次新的请求
更直白一点:
chat.completions.create()的意思是:给模型一组对话消息,让模型补全下一条 assistant 回复。
比如你给模型:
[
{"role": "user", "content": "你好"}
]
模型会返回类似:
{"role": "assistant", "content": "你好!有什么可以帮你?"}
这类接口对应的 API 资源是 Chat Completions。它的核心输入是 messages,核心输出是模型生成的下一条消息。
为什么要取 response.choices[0].message.content
第一次运行时,你可能会直接:
print("AI:", res)
然后看到一大坨对象:
ChatCompletion(
id='...',
choices=[
Choice(
finish_reason='stop',
index=0,
message=ChatCompletionMessage(
content='你好!很高兴见到你!...',
role='assistant',
tool_calls=None,
...
)
)
],
usage=...
)
这是因为 SDK 返回的不是一个字符串,而是一个完整响应对象。
它大概长这样:
response
├── id
├── object
├── created
├── model
├── choices
│ └── [0]
│ ├── index
│ ├── finish_reason
│ └── message
│ ├── role
│ ├── content
│ ├── tool_calls
│ └── ...
└── usage
├── prompt_tokens
├── completion_tokens
└── total_tokens
其中:
id:这次请求的唯一 ID。model:实际使用的模型。choices:模型返回的候选答案列表。message:其中一条 assistant 消息。content:这条消息的正文。usage:token 用量,后续估算成本和优化上下文时很有用。
所以我们真正想展示给用户看的正文在:
res.choices[0].message.content
逐层看:
res
-> choices[0]
-> message
-> content
为什么是 choices[0]?
因为 API 设计上支持一次返回多个候选答案。默认通常只有一个,所以取第一个。
循环对话
单次调用只能问一句,答一句。
如果你想一直和 AI 聊,就可以加一个循环:
while True:
user_input = input(">>: ").strip()
if user_input in {"exit", "quit"}:
break
messages: list[ChatCompletionMessageParam] = [
{"role": "user", "content": user_input}
]
res = llm.chat.completions.create(
model=model_id,
messages=messages,
)
print("AI:", res.choices[0].message.content)
这时你可以连续输入问题。
但注意:这还不是多轮对话。
因为每次循环里,messages 都被重新创建了:
messages = [{"role": "user", "content": user_input}]
也就是说,模型每次只能看到当前这一句话。
它不知道上一轮你说过什么。
这就是一个非常重要的概念:
大模型 API 默认是无状态的。它不会自动记住上一轮请求。
所谓多轮对话,不是模型自己记住了,而是我们把历史消息重新发给它。
简单记忆:维护历史消息
如果你想让 AI 记住当前对话,就需要维护一个 history。
最小版本是:
history: list[ChatCompletionMessageParam] = []
while True:
user_input = input(">>: ").strip()
if user_input in {"exit", "quit"}:
break
history.append({"role": "user", "content": user_input})
res = llm.chat.completions.create(
model=model_id,
messages=history,
)
assistant_content = res.choices[0].message.content or ""
history.append({"role": "assistant", "content": assistant_content})
print("AI:", assistant_content)
这里的关键不是“列表”本身,而是列表里的结构必须是合法的消息。
每一轮都要按顺序追加:
user message
assistant message
user message
assistant message
...
这样下一次请求时,模型就能看到完整对话历史。
不过这只是“上下文记忆”,不是长期记忆。
它有几个限制:
- 程序一关,
history就没了。 - 对话越来越长,token 成本会越来越高。
- 超过模型上下文窗口后,旧消息需要裁剪或总结。
所以我们现在得到的是:
循环对话 + 短期上下文
这还不是完整 Agent,但已经比单轮问答更进一步了。
工具调用:让模型不只会说话
聊天还不够,你希望它能操作一下本地电脑。
比如:让它列出某个文件夹里的文件。
这里就进入了工具调用。
工具调用的核心分工是:
模型负责决定要调用哪个工具,以及传什么参数。
程序负责真正执行工具,并把结果返回给模型。
模型不会真的访问你的电脑。
它只会输出类似:
我要调用 list_folder_files,参数是 {"folder_path": "..."}
真正读取文件夹的是 Python 程序。
注册一个工具
先写一个普通 Python 函数:
from pathlib import Path
def list_folder_files(folder_path: str) -> str:
folder = Path(folder_path)
if not folder.exists() or not folder.is_dir():
return f"无效文件夹: {folder_path}"
return "\n".join(sorted(item.name for item in folder.iterdir())) or "(空文件夹)"
然后用 JSON Schema 描述这个工具:
tools = [
{
"type": "function",
"function": {
"name": "list_folder_files",
"description": "列出指定本地文件夹中的文件名",
"parameters": {
"type": "object",
"properties": {
"folder_path": {
"type": "string",
"description": "要列出文件的本地文件夹路径",
}
},
"required": ["folder_path"],
"additionalProperties": False,
},
},
}
]
这里有三个关键点:
name:工具名,模型会通过这个名字请求调用工具。description:工具说明,模型靠它判断什么时候该用这个工具。parameters:参数结构,告诉模型应该生成什么格式的参数。
把工具传给模型:
res = llm.chat.completions.create(
model=model_id,
messages=history,
tools=tools,
)
当 tools 存在时,模型可以选择:
- 直接回复文本。
- 请求调用一个或多个工具。
如果它决定调用工具,返回的 assistant message 里会出现:
assistant_message.tool_calls
工具调用的完整流程
工具调用不是一次请求就结束,而是两段式:
第一次请求:
用户问题 -> 模型决定是否调用工具
程序执行:
解析 tool_calls -> 调用本地函数 -> 得到工具结果
第二次请求:
把工具结果发回模型 -> 模型基于结果生成最终回答
可以理解为:
user -> model -> tool_call -> python function -> tool result -> model -> final answer
其中很容易漏掉一个字段:
tool_call_id
工具结果消息必须带上 tool_call_id,这样模型才知道:
这个工具结果是在回应哪一次工具调用。
最小工具调用代码
代码长起来像这样:
import json
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
def require_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise ValueError(f"请先配置 {name}。")
return value
api_key = require_env("LLM_API_KEY")
model_id = require_env("LLM_MODEL_ID")
base_url = require_env("LLM_BASE_URL")
llm = OpenAI(api_key=api_key, base_url=base_url)
def list_folder_files(folder_path: str) -> str:
folder = Path(folder_path)
if not folder.exists() or not folder.is_dir():
return f"无效文件夹: {folder_path}"
return "\n".join(sorted(item.name for item in folder.iterdir())) or "(空文件夹)"
tools = [
{
"type": "function",
"function": {
"name": "list_folder_files",
"description": "列出指定本地文件夹中的文件名",
"parameters": {
"type": "object",
"properties": {
"folder_path": {
"type": "string",
"description": "要列出文件的本地文件夹路径",
}
},
"required": ["folder_path"],
"additionalProperties": False,
},
},
}
]
history: list[dict] = [
{
"role": "system",
"content": "你是一个谨慎的本地文件助手。只有在需要查看文件夹内容时才调用工具。",
}
]
while True:
user_input = input(">>: ").strip()
if user_input in {"exit", "quit"}:
break
history.append({"role": "user", "content": user_input})
res = llm.chat.completions.create(
model=model_id,
messages=history,
tools=tools,
)
assistant_message = res.choices[0].message
history.append(assistant_message.model_dump(exclude_none=True))
tool_calls = assistant_message.tool_calls or []
if tool_calls:
for tool_call in tool_calls:
function_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments or "{}")
if function_name == "list_folder_files":
tool_result = list_folder_files(arguments["folder_path"])
else:
tool_result = f"未知工具: {function_name}"
history.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result,
}
)
res = llm.chat.completions.create(
model=model_id,
messages=history,
tools=tools,
)
assistant_message = res.choices[0].message
history.append(assistant_message.model_dump(exclude_none=True))
print("AI:", assistant_message.content)
这段代码已经具备了一个最小 Agent 的影子:
- 有对话历史。
- 有工具描述。
- 模型能决定是否调用工具。
- 程序能执行工具。
- 工具结果会回到模型上下文里。
但它还不是成熟 Agent。
现在它还缺什么
这个程序已经不只是普通聊天机器人了,但距离真正好用的 Agent 还有几个关键差距。
1. 缺少明确目标
现在它只是被动回答用户问题。
成熟 Agent 往往会围绕一个目标持续推进任务,比如:
帮我整理这个项目目录,找出入口文件,并总结运行方式。
它需要拆解任务、执行步骤、观察结果,再决定下一步。
2. 缺少稳定的循环控制
现在最多只处理一轮工具调用。
真正的 Agent Loop 通常是:
模型思考
-> 调用工具
-> 得到观察结果
-> 再思考
-> 再调用工具
-> 直到完成或达到最大步数
所以后面需要加:
max_steps- trace 日志
- 停止条件
- 错误处理
3. 缺少长期记忆
现在的 history 只是当前程序运行期间的上下文。
如果程序退出,历史就没了。
长期记忆通常需要保存到:
- Markdown
- JSON
- SQLite
- 向量数据库
但一开始不用急。先理解 history,再理解长期记忆。
4. 缺少安全边界
本地文件工具很敏感。
学习阶段可以直接传路径,但真实项目至少要考虑:
- 是否限制可访问目录。
- 是否允许读取隐藏文件。
- 是否允许删除、写入、执行命令。
- 工具异常是否会泄露敏感信息。
工具调用的原则是:
能让模型决定调用什么,但不要让模型绕过程序的权限边界。
小结
这一篇我们从最小 API 调用开始,逐步加到了工具调用:
单次调用:messages -> response
循环对话:while True
上下文记忆:history
工具注册:tools
工具执行:tool_calls -> Python 函数
最终回答:tool result -> model
你现在已经摸到了 Agent 的门槛。
下一步可以继续做两件事:
- 把工具调用放进一个多步循环,形成真正的 Agent Loop。
- 给每一步保存 trace,观察模型到底在什么时候选择了工具、什么时候直接回答。
最后,用一句话收束:
Agent 的核心不是“模型更聪明”,而是程序把模型、上下文、工具和循环组织到了一起。