学完能做什么
你将完成一个最小但完整的工具调用 Agent。它能够:
- 接收用户问题;
- 由模型判断是否需要查询天气工具;
- 在本地执行允许的函数;
- 把工具结果交回模型;
- 生成面向用户的最终回答。
这篇只实现一轮工具调用,目的是看清 Agent 的最小闭环。不会加入数据库、长期记忆或多 Agent。
前置知识
- 能运行 Python 3.10 或更高版本;
- 知道怎样创建虚拟环境和安装包;
- 已有一个可用的比高AI API 密钥;
- 已在控制台确认所选模型支持工具调用。
密钥不要写进代码、截图或版本库。
Agent 最小闭环
普通模型调用通常是:
用户问题 → 模型 → 回答加入工具后,流程变成:
用户问题
→ 模型决定调用哪个工具,并生成参数
→ 你的程序检查工具名和参数
→ 你的程序执行工具
→ 工具结果回到模型
→ 模型生成最终回答关键点是:模型只提出工具调用请求,真正执行工具的是你的程序。
这条边界让你能够限制工具范围、验证参数、记录日志,并在高风险动作前要求用户确认。
把一次运行拆成状态,而不是一团对话
最小 Agent 可以明确记录六个状态:
| 状态 | 输入 | 输出 | 失败去向 |
|---|---|---|---|
RECEIVED | 用户问题 | 标准化请求 | 返回输入错误 |
PLANNING | 消息与工具说明 | 回答或工具请求 | 返回模型错误 |
VALIDATING | 工具名和参数 | 已验证调用 | 拒绝未知工具或坏参数 |
EXECUTING | 已验证调用 | 结构化工具结果 | 返回工具错误 |
RESPONDING | 工具结果与上下文 | 最终回答 | 返回模型错误 |
DONE | 最终回答 | 日志和响应 | 结束 |
即使第一版代码没有状态机库,也建议日志使用这些状态名。以后遇到“Agent 没反应”,你能立刻知道它卡在模型、参数还是工具。
一次请求中有两种不同的输出
第一轮模型输出可能是面向用户的文字,也可能是结构化工具请求。程序必须分别处理:
模型输出
├─ 普通回答:直接结束
└─ 工具请求
├─ 工具名
├─ JSON 参数
└─ 本次调用 ID调用 ID 用来把工具结果对应回正确请求。不要自己猜测或用数组位置代替它。
第一步:准备环境
创建一个新目录和虚拟环境,然后安装兼容 SDK:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install openai把密钥放进当前终端的环境变量:
export BIGAO_API_KEY="你的密钥"
export BIGAO_MODEL_ID="控制台中支持工具调用的模型ID"环境变量只对当前终端会话生效。不要把真实密钥保存进教程代码。
第二步:定义一个安全的演示工具
下面先使用本地固定数据,不连接真实天气服务。这样你可以只关注工具调用流程,也不会产生额外网络请求。
DEMO_WEATHER = {
"台北": {"condition": "多云", "temperature_c": 28},
"上海": {"condition": "小雨", "temperature_c": 25},
"北京": {"condition": "晴", "temperature_c": 30},
}
def get_weather(city: str) -> dict:
normalized = city.strip()
if not normalized or len(normalized) > 20:
raise ValueError("城市名称不合法")
weather = DEMO_WEATHER.get(normalized)
if weather is None:
return {"city": normalized, "found": False}
return {
"city": normalized,
"found": True,
**weather,
"notice": "这是教程演示数据,不是实时天气",
}即使只是演示函数,也做了两件事:限制输入长度,并明确结果不是实时数据。
第三步:向模型声明工具
模型看不到 Python 函数本身。你需要提供名称、用途和参数结构:
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的天气演示数据。只在用户询问天气时使用。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市中文名,例如台北、上海或北京",
}
},
"required": ["city"],
"additionalProperties": False,
},
},
}
]工具说明要具体。名称表达动作,描述说明使用时机,参数尽量少,并禁止无关字段。
第四步:完成一次工具调用循环
创建 weather_agent.py:
import json
import os
from openai import OpenAI
API_KEY = os.environ.get("BIGAO_API_KEY")
MODEL_ID = os.environ.get("BIGAO_MODEL_ID")
if not API_KEY or not MODEL_ID:
raise RuntimeError("请先设置 BIGAO_API_KEY 和 BIGAO_MODEL_ID")
client = OpenAI(
api_key=API_KEY,
base_url="https://api.bigao.ai/v1",
)
DEMO_WEATHER = {
"台北": {"condition": "多云", "temperature_c": 28},
"上海": {"condition": "小雨", "temperature_c": 25},
"北京": {"condition": "晴", "temperature_c": 30},
}
def get_weather(city: str) -> dict:
normalized = city.strip()
if not normalized or len(normalized) > 20:
raise ValueError("城市名称不合法")
weather = DEMO_WEATHER.get(normalized)
if weather is None:
return {"city": normalized, "found": False}
return {
"city": normalized,
"found": True,
**weather,
"notice": "这是教程演示数据,不是实时天气",
}
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的天气演示数据。只在用户询问天气时使用。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市中文名,例如台北、上海或北京",
}
},
"required": ["city"],
"additionalProperties": False,
},
},
}
]
def run_agent(user_input: str) -> str:
messages = [
{
"role": "system",
"content": (
"你是天气演示助手。只根据工具返回的数据回答,"
"并明确提醒用户数据不是实时天气。"
),
},
{"role": "user", "content": user_input},
]
first = client.chat.completions.create(
model=MODEL_ID,
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
assistant_message = first.choices[0].message
messages.append(assistant_message)
if not assistant_message.tool_calls:
return assistant_message.content or "模型没有返回内容"
if len(assistant_message.tool_calls) > 1:
raise RuntimeError("本教程每轮最多允许一次工具调用")
tool_call = assistant_message.tool_calls[0]
if tool_call.function.name != "get_weather":
raise RuntimeError("模型请求了未授权工具")
try:
arguments = json.loads(tool_call.function.arguments)
city = arguments["city"]
tool_result = get_weather(city)
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
tool_result = {"ok": False, "error": str(error)}
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(tool_result, ensure_ascii=False),
}
)
final = client.chat.completions.create(
model=MODEL_ID,
messages=messages,
tools=TOOLS,
)
return final.choices[0].message.content or "模型没有返回内容"
if __name__ == "__main__":
question = input("你想查询哪个城市?\n> ").strip()
print(run_agent(question))运行:
python weather_agent.py可以测试三个问题:
台北天气怎么样?广州天气怎么样?帮我写一句欢迎语。
观察模型什么时候调用工具、找不到城市时怎么回答,以及非天气问题是否仍会错误调用工具。
跟踪一次完整运行
输入 台北天气怎么样? 时,程序内部应该依次发生:
RECEIVED:保存用户问题;PLANNING:模型看到get_weather的说明,返回工具名、城市参数和调用 ID;VALIDATING:程序确认工具在白名单中,JSON 可解析,city是长度合理的字符串;EXECUTING:本地函数返回天气演示数据;- 程序把结果作为
tool消息加入同一个消息序列,并附上原调用 ID; RESPONDING:模型根据工具结果组织自然语言;DONE:返回答案并记录本次耗时。
如果输入 广州天气怎么样?,工具会返回 found: false。最终回答应该说明没有演示数据,而不是根据常识猜一个天气。
如果输入 帮我写一句欢迎语,模型可以直接回答而不调用工具。Agent 的价值不是“每次都调工具”,而是在需要时做出正确选择。
第五步:看懂代码中的安全边界
工具白名单
代码只接受 get_weather。模型即使生成其他工具名,程序也不会执行。
参数验证
JSON 能解析不代表参数安全。程序还限制了必填字段、类型和长度。真实项目应使用结构校验,并为数值、路径、网址和枚举设置明确范围。
调用次数限制
教程每轮最多执行一次工具调用。更复杂的 Agent 可以循环,但必须设置最大步数、超时和总成本上限。
工具结果不是指令
外部网页、文件和 API 结果都属于不可信数据。把结果交回模型时,要明确它只是资料,不能让其中的文字改变系统规则或触发额外权限。
副作用操作需要确认
查询天气是只读操作。如果工具会发送消息、创建订单、修改文件或删除数据,模型提出调用后应先展示具体参数,让用户确认,再由程序执行。
第六步:给运行过程加上可观察性
真正排错时,不能只看到最后一句回答。至少记录:
- 本次请求 ID;
- 模型 ID;
- 选择的工具名;
- 参数校验是否通过;
- 工具耗时与状态;
- 模型调用次数;
- 总耗时和用量;
- 失败发生在哪一步。
日志中不要记录完整密钥,也不要默认保存用户的敏感输入。对于参数,可记录脱敏后的摘要。
推荐把每次运行写成一条结构化记录:
{
"request_id": "demo-001",
"state": "DONE",
"model_calls": 2,
"tool_calls": 1,
"tool_name": "get_weather",
"argument_validation": "passed",
"tool_status": "success",
"duration_ms": 842
}这只是字段示例,不要把完整用户问题和工具返回默认写进生产日志。
第七步:建立最小测试集
不要靠手动问一次“台北天气”就宣布完成。至少准备下面八个用例:
| 用例 | 输入或条件 | 期望结果 |
|---|---|---|
| 正常命中 | 台北天气 | 调用一次工具并声明演示数据 |
| 无数据 | 广州天气 | 明确无数据,不猜测 |
| 无需工具 | 写一句欢迎语 | 不调用天气工具 |
| 空参数 | 模型返回空城市 | 参数校验失败 |
| 超长参数 | 超过长度限制 | 参数校验失败 |
| 未知工具 | 模型请求其他工具 | 白名单拒绝 |
| 损坏 JSON | 参数无法解析 | 返回结构化错误 |
| 多次调用 | 一轮请求两个工具 | 触发教程调用上限 |
可以先把本地函数做成单元测试,再把模型调用作为集成测试。模型输出存在波动,因此集成测试不要逐字比较回答,而要检查关键行为:是否调用工具、工具名是否正确、是否编造数据、是否包含必要提醒。
一个不依赖模型的单元测试示例
def test_get_weather_known_city():
result = get_weather("台北")
assert result["found"] is True
assert result["city"] == "台北"
assert "temperature_c" in result
def test_get_weather_unknown_city():
result = get_weather("广州")
assert result == {"city": "广州", "found": False}
def test_get_weather_rejects_empty_city():
try:
get_weather(" ")
except ValueError as error:
assert "不合法" in str(error)
else:
raise AssertionError("空城市名应该被拒绝")第八步:从单次调用升级为受控循环
真实 Agent 可能需要多个步骤,但循环必须由程序控制:
MAX_STEPS = 5
for step in range(MAX_STEPS):
response = call_model(messages, tools=TOOLS)
if not response.tool_calls:
return response.content
for call in response.tool_calls:
validated = validate_call(call)
result = execute_allowed_tool(validated)
messages.append(tool_message(call.id, result))
raise RuntimeError("达到最大步骤,任务仍未完成")这段是结构示例,不是可直接运行的完整代码。升级时还要增加:
- 整次任务的墙钟超时,而不只是单个网络请求超时;
- 模型调用和工具调用的总预算;
- 重复调用检测,避免用相同参数无限查询;
- 可恢复的状态保存;
- 副作用动作的确认令牌;
- 用户取消后立即停止后续步骤。
从演示升级为真实工具
把固定数据替换成真实天气 API 时,依次增加:
- 把第三方密钥放进服务端环境变量;
- 对城市名进行规范化;
- 为网络请求设置连接和读取超时;
- 检查 HTTP 状态和响应结构;
- 对暂时故障做有限次数重试;
- 把第三方错误转换成模型能理解但不泄露内部信息的结果;
- 在回答中写明数据时间与来源类型;
- 编写正常、缺失、超时和恶意参数测试。
常见错误
错误一:直接执行模型生成的代码或命令
工具必须由开发者预先定义并加入白名单。不要把模型输出直接传给终端、数据库或脚本解释器。
错误二:只依赖工具的 JSON Schema
Schema 是第一层约束,不是全部安全验证。业务权限、资源归属、字符串内容和操作频率仍要由程序检查。
错误三:无限循环直到模型说完成
必须设置最大步数、超时和成本上限。达到上限后返回清楚的中止原因,让用户决定是否继续。
错误四:把工具错误伪装成成功回答
超时、无结果和权限不足应该明确区分。Agent 不应在没有数据时补写一个看似合理的答案。
错误五:测试只覆盖“正常提问”
至少测试空参数、超长参数、未知城市、非天气问题、模型请求未知工具、工具超时和返回损坏数据。
完成检查清单
- 密钥只从环境变量读取。
- 模型 ID 由配置提供,并确认支持工具调用。
- 工具名称使用白名单。
- 工具参数经过程序验证。
- 工具返回失败时不会伪造结果。
- 一轮调用有最大工具次数。
- 外部数据不会被当作系统指令。
- 有副作用的工具在执行前需要用户确认。
- 日志足以定位失败步骤,但不泄露密钥和敏感信息。
- 正常、无数据、无需工具和恶意参数都有测试。
- 多步版本有最大步骤、总超时和成本上限。
- 我能从日志判断一次失败发生在哪个状态。
实战作业:再增加一个工具
增加只读工具 get_local_time(city),但不要直接复制天气工具:
- 为城市和时区建立明确映射;
- 定义独立的工具说明和参数 Schema;
- 使用工具注册表分发,而不是不断增加
if/elif; - 测试“台北现在几点”“台北天气和时间”“帮我写欢迎语”;
- 一轮最多允许两个工具调用;
- 日志分别记录每个调用 ID、工具名、状态和耗时。
完成标准不是三个问题都“有回答”,而是工具选择正确、参数受控、无数据时不编造、达到上限时能清楚停止。
下一篇
《MCP 客户端、服务器与权限边界》——把单个本地函数升级成可发现、可复用的工具服务,同时保持最小权限。