让Agent根据工具结果回答问题
课程归属:打造能干活的Agent
当资料分析 Agent
被问到"哪个商品销售额最高"时,不能只凭模型记忆或语言概率作答,而应先调用可验证的
Python 工具,再把工具返回的计算结果交给模型组织语言。本文用一份教学 CSV
搭建最小闭环:模型提出工具调用、程序执行并校验结果、模型依据结果回答。最终可检查工具结果与回答是否一致。
一、明确目标:回答必须有工具证据
本节承接"把 Python 函数封装成可调用工具"的知识点,重点不是再写一个复杂
Agent 框架,而是把工具调用后的结果正确交还给模型。
案例使用模拟销售数据。用户询问"哪个类别销售额最高?总销售额是多少?",Agent
调用 analyze_sales_csv 读取
CSV、计算每行销售额并汇总,随后让模型根据真实计算结果生成中文回答。
本例验收三件事:
- 工具确实执行了计算,而不是让模型猜数。
- 工具结果被完整放回同一轮对话,并与工具调用 ID 对应。
- 最终回答只引用工具返回的数据;工具失败时明确说明失败,不编造结果。
表 1:本节的输入、工具与交付物
项目 约定 验收方式
用户输入 关于模拟销售 CSV 的问题 输入自然语言问题
工具 analyze_sales_csv 返回行数、总销售额、分类汇总
工具结果 JSON 字符串 检查数值来自 Python 计算
模型回答 中文自然语言 与工具结果逐项对照
失败处理 返回明确错误,不伪造数据 使用缺失文件或非法数据测试
二、理解闭环:模型提出调用,程序执行,模型再回答
工具调用并不是模型直接运行本地
Python。模型返回的是一条结构化的调用请求,其中包含工具名称和参数;宿主程序负责找到函数、校验参数、执行代码,再把结果作为工具消息发回模型。
图
1:工具结果驱动回答的闭环。关键点是程序必须先执行工具,再把返回值发给模型;不能把"模型请求调用工具"误当成"工具已经执行"。
表 2:一次工具调用的消息流
顺序 发送方 内容 作用
1 用户 哪个类别销售额最高? 提出问题
2 模型 analyze_sales_csv(path="data/input/sales.csv") 请求调用工具
3 程序 真实计算得到分类汇总 执行工具
4 程序 工具名、调用 ID、JSON 结果 将结果关联回原调用
5 模型 最高类别及总销售额 根据工具结果组织回答
工具调用 ID
用于把某条工具结果与模型之前提出的调用对应起来。使用支持工具调用的接口时,应保留模型返回的
assistant 消息,并把每个工具结果以 role="tool"
的消息回传,tool_call_id 必须与请求中的 ID 一致。
三、准备最小项目
本文不引入 LangGraph 或数据库;先用普通 Python
函数理解调用闭环。后续章节再将相同逻辑迁移到图编排与持久化执行中。
3.1 环境与目录
以下命令在 WSL2 Ubuntu 终端执行,Python 使用
3.11。项目只访问自己创建的教学目录。
mkdir -p agent_lab/data/input
cd agent_lab
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install "openai>=1.0.0" python-dotenv
本例依赖 OpenAI 兼容的 Chat Completions 工具调用接口。阿里云百炼的
base_url
与模型名称应以账号所在地域的官方控制台和文档为准,不能跨地域混用 API Key
和服务地址。
表 3:项目文件职责
文件 用途 是否需要手动创建
.env 保存模型密钥、接口地址和模型名 是
.env.example 展示配置字段,不存真实密钥 是
data/input/sales.csv 教学模拟销售数据 是
agent.py 工具函数、工具调用循环与入口 是
requirements.txt 依赖记录 是
创建 .env.example:
DASHSCOPE_API_KEY=replace-with-your-api-key
MODEL_BASE_URL=https://YOUR_WORKSPACE_ID.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
MODEL_NAME=replace-with-a-model-supported-by-your-region
复制为 .env 后,填入自己在模型服务控制台创建的密钥、真实工作空间 ID
和已开通的模型名称。.env 不要提交到 Git 仓库。
创建 requirements.txt:
openai>=1.0.0
python-dotenv>=1.0.0
这里使用最低兼容范围而非声称已锁定某个最新版本。用于团队复现时,可在实际验证后生成锁定文件,并记录
Python 与依赖版本。
3.2 准备教学数据
创建 data/input/sales.csv:
category,quantity,unit_price
键盘,12,199
鼠标,20,79
显示器,5,899
键盘,3,199
鼠标,8,79
销售额按 quantity × unit_price
计算。该数据仅用于演示程序逻辑,不代表真实经营数据。
四、实现工具与 Agent 调用循环
下面的代码可以保存为 agent.py。为了让边界清晰,工具只读取项目内的固定
CSV,不允许模型任意传入路径;金额由 Python
计算,模型只负责选择工具和解释结果。
import csv
import json
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
ROOT = Path(__file__).resolve().parent
CSV_PATH = ROOT / "data" / "input" / "sales.csv"
load_dotenv(ROOT / ".env")
SYSTEM_PROMPT = """
你是资料分析助手。
需要具体销售统计时,必须调用提供的分析工具,不要自行猜测数字。
工具返回的 JSON 是本题统计事实的依据。
回答时说明关键结果;如果工具返回错误,明确告知无法完成分析。
不要把工具未返回的数据写成确定事实。
"""
def analyze_sales_csv() -> dict:
"""读取教学销售 CSV,计算总额和各类别销售额。"""
if not CSV_PATH.is_file():
return {
"status": "error",
"data": None,
"source": [str(CSV_PATH.relative_to(ROOT))],
"error": {
"code": "FILE_NOT_FOUND",
"message": "找不到教学数据文件 data/input/sales.csv",
},
}
totals = {}
row_count = 0
total_sales = 0
try:
with CSV_PATH.open("r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
required = {"category", "quantity", "unit_price"}
if not reader.fieldnames or not required.issubset(reader.fieldnames):
raise ValueError("CSV 必须包含 category、quantity、unit_price 三列")
for line_no, row in enumerate(reader, start=2):
category = (row.get("category") or "").strip()
if not category:
raise ValueError(f"第 {line_no} 行 category 为空")
try:
quantity = int(row["quantity"])
unit_price = int(row["unit_price"])
except (TypeError, ValueError):
raise ValueError(
f"第 {line_no} 行 quantity 和 unit_price 必须是整数"
)
if quantity < 0 or unit_price < 0:
raise ValueError(f"第 {line_no} 行存在负数")
amount = quantity * unit_price
totals[category] = totals.get(category, 0) + amount
total_sales += amount
row_count += 1
if row_count == 0:
raise ValueError("CSV 没有数据行")
ranking = sorted(
[{"category": k, "sales": v} for k, v in totals.items()],
key=lambda item: (-item["sales"], item["category"]),
)
return {
"status": "ok",
"data": {
"row_count": row_count,
"total_sales": total_sales,
"category_sales": ranking,
"highest_category": ranking[0]["category"],
"highest_sales": ranking[0]["sales"],
"currency_note": "教学数据使用未指定币种的整数金额单位",
},
"source": ["data/input/sales.csv"],
"error": None,
}
except (OSError, UnicodeError, csv.Error, ValueError) as exc:
return {
"status": "error",
"data": None,
"source": ["data/input/sales.csv"],
"error": {"code": "INVALID_SALES_CSV", "message": str(exc)},
}
TOOLS = [
{
"type": "function",
"function": {
"name": "analyze_sales_csv",
"description": (
"读取项目内固定的教学销售 CSV,计算总销售额、各类别销售额"
"和销售额最高的类别。需要准确统计时调用此工具。"
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": False,
},
},
}
]
def run_agent(question: str) -> str:
api_key = os.getenv("DASHSCOPE_API_KEY")
base_url = os.getenv("MODEL_BASE_URL")
model = os.getenv("MODEL_NAME")
if not api_key or not base_url or not model:
raise RuntimeError(
"请先在 .env 中配置 DASHSCOPE_API_KEY、MODEL_BASE_URL 和 MODEL_NAME"
)
client = OpenAI(api_key=api_key, base_url=base_url)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question},
]
# 限制循环次数,避免模型持续请求工具。
for _ in range(3):
response = client.chat.completions.create(
model=model,
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
assistant_message = response.choices[0].message
# 保留模型原始 assistant 消息,包括 tool_calls。
messages.append(assistant_message.model_dump(exclude_none=True))
if not assistant_message.tool_calls:
return assistant_message.content or "模型没有返回文本回答。"
for tool_call in assistant_message.tool_calls:
if tool_call.function.name != "analyze_sales_csv":
tool_result = {
"status": "error",
"data": None,
"source": [],
"error": {
"code": "UNKNOWN_TOOL",
"message": "请求了未注册的工具",
},
}
else:
try:
args = json.loads(tool_call.function.arguments or "{}")
if args:
raise ValueError("该工具不接受任何参数")
tool_result = analyze_sales_csv()
except (json.JSONDecodeError, ValueError) as exc:
tool_result = {
"status": "error",
"data": None,
"source": [],
"error": {
"code": "INVALID_TOOL_ARGUMENTS",
"message": str(exc),
},
}
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(
tool_result, ensure_ascii=False
),
}
)
return "工具调用次数达到上限,本次未能得到最终回答。"
if __name__ == "__main__":
question = input("请输入销售分析问题:").strip()
if not question:
raise SystemExit("问题不能为空。")
try:
print("\nAgent 回答:")
print(run_agent(question))
except Exception as exc:
# 这里只显示错误摘要;生产环境应使用日志记录异常细节。
print(f"运行失败:{type(exc).__name__}: {exc}")
4.1 代码中最重要的三个位置
第一,TOOLS 向模型描述可用工具的名称、用途和参数
Schema。这里的函数没有参数,因此 Schema
不允许额外参数;这比允许模型任意传入文件路径更安全。
第二,assistant_message.tool_calls
表示模型请求执行工具,不代表工具已经运行。程序根据名称分发到白名单中的
Python 函数,实际执行后才获得结果。
第三,工具结果以 role="tool" 回传,并携带原调用的
tool_call_id。如果只把工具结果打印到终端,却没有追加到
messages,模型就无法依据这次计算继续回答。
图 2:模型与 Python
工具之间的时序。模型负责决定是否请求工具以及如何表达结论,程序负责执行、校验、关联结果和限制调用次数。
五、运行并核对结果
在 agent_lab/ 目录且虚拟环境已激活时执行:
python agent.py
输入:
哪个类别销售额最高?总销售额是多少?请列出各类别销售额。
先独立计算教学数据,作为对照基准:
- 键盘:
(12 + 3) × 199 = 2985 - 鼠标:
(20 + 8) × 79 = 2212 - 显示器:
5 × 899 = 4495 - 总销售额:
2985 + 2212 + 4495 = 9692
表 4:工具的预期结果
以下为根据示例 CSV 手工核算的预期值,不是本次真实模型 API
调用的运行截图或实测日志。
字段 预期值
status ok
row_count 5
total_sales 9692
highest_category 显示器
highest_sales 4495
category_sales 显示器 4495、键盘 2985、鼠标 2212
模型可能采用不同措辞,但回答中的最高类别和总额应与表 4
一致。若回答不一致,先检查回传给模型的工具消息,再检查提示词和模型是否遵循了工具结果,不要只通过修改措辞掩盖数据错误。
六、失败用例与排错路径
6.1 缺少文件或 CSV 字段错误
先把 sales.csv 临时改名,再运行工具。工具应返回
FILE_NOT_FOUND,模型收到错误结果后应说明无法完成统计,而不是给出一组看似合理的数字。测试结束后恢复文件名。
再将 CSV 中的 quantity 改成 abc。工具应返回
INVALID_SALES_CSV,错误消息指出问题所在的行。修复数据后重新运行,正常用例应恢复。
表 5:常见问题定位
现象 常见原因 检查与修复
模型没有调用工具 问题不明确、模型不支持工具调用或工具描述不清 检查模型能力与 TOOLS
描述,明确要求准确统计
工具已运行但模型仍猜数 没有把工具结果追加到消息列表 检查 role="tool" 与
tool_call_id
API 返回认证错误 密钥无效、地域或接口地址不匹配 核对控制台密钥、工作空间
ID 和地域
INVALID_SALES_CSV 列名缺失、空类别、非法数值或负数 按错误信息修正教学 CSV
最终回答为空 模型响应没有文本内容或工具循环未正常结束 查看响应中的
tool_calls、content
和调用次数
图
3:从结果向前追踪故障。先验证工具本身,再验证消息传递,最后检查模型表达,避免把数据处理错误误判为提示词问题。
七、从演示代码走向可交付 Agent
本例故意把工具限制为只读固定文件,且只支持一种工具。真实项目至少还要补齐以下控制:
- 工具白名单与参数校验: 不允许模型通过工具名称或参数执行任意
Python 代码、读取任意路径。 - 调用上限与超时:
本例最多进行三轮模型请求,但尚未配置网络超时、重试策略或完整日志。 - 结果可追溯: 记录任务
ID、工具名称、参数摘要、工具状态、来源文件和结果摘要;不要把 API Key
写入日志。 - 错误分层:
工具执行成功不等于整个任务完成。只有最终回答生成并通过业务验收后,任务才应标记为
completed。 - 回答验收:
对金额、数量、排名等可计算字段,使用程序对最终结构化结果进行校验;重要业务场景不要仅凭自然语言回答判断成功。 - 数据边界: 将教学路径改为经过校验的相对路径,并限制在
data/input/
目录中。涉及写入、发送或删除时,需由应用层执行权限和确认流程。
表 6:本节完成标准
验收项 通过条件 验收证据
工具可用 正常 CSV 返回 status=ok 工具结果 JSON
统计正确 总额 9692,最高类别为显示器 与手工计算对照
消息闭环 工具结果包含匹配的调用 ID 并回传 检查 messages
构造逻辑
失败可识别 缺失文件或非法数值返回错误对象 失败用例输出
回答受事实约束 最终回答中的数值与工具结果一致 对照表 4
结语
让 Agent
根据工具结果回答问题,关键不在于让模型"看起来像做过计算",而是建立可追踪的证据链:工具定义清楚、执行由程序负责、结果关联回原调用、模型基于结果组织回答,并在失败时停止编造。本节的普通
Python 实现可作为后续执行循环、LangGraph 编排和任务状态持久化的基础。
本文聚焦于构建可验证、可追溯的Agent工具调用闭环:模型提出结构化工具请求,程序执行Python函数并校验结果,再将带ID关联的JSON结果回传给模型,最终生成严格基于工具输出的回答。强调工具白名单、调用ID绑定、消息列表追加、失败不编造等关键实践,并以销售数据统计为教学案例完成端到端验证。
24

被折叠的 条评论
为什么被折叠?



