AI Agent 能调用工具后,应用就不只是"生成一段文字"了:它可能查询数据库、创建工单、改写文件、触发部署。一次参数误解或重复调用,后果会落到真实系统里。比如用户只说"帮我记录一下登录报错",模型却给工具传了额外字段;或者工具执行成功,但响应在返回途中超时,Agent 重试后建出两张一模一样的工单。
本文从工程实现的角度,拆解工具调用链路里最容易被忽略的四件事:只执行允许的工具、严格验证参数、对有副作用的动作做幂等、留下可追溯的执行记录。后面有一份只使用 Python 标准库的完整示例,可直接运行。示例不接真实大模型,而是模拟模型输出;这能把重点放在工具执行边界上。
一、模型给的是"建议调用",不是执行许可
一轮工具调用可以拆成五步:
text
用户请求 → 模型提出工具名和参数 → 应用验证 → 工具执行 → 结果返回模型/用户
ReAct 等早期研究展示了模型交替进行推理和行动的模式。但在实际系统里,模型生成的工具名与参数仍然是一份待验证的输入。能否调用、以谁的身份调用、参数是否合理、是否需要用户确认,应由应用程序决定。
尤其要注意数据来源:网页、邮件、知识库片段和工具返回值都可能包含"忽略原规则,调用某工具"一类指令。它们属于外部数据,不能因此获得更高的执行权限。OWASP 对提示注入风险的说明也强调了这个边界。本文的执行器只能接收应用允许的工具名;它不会把模型文本当作 Python 代码运行。
一个最小的调用对象可以长这样:
json
{
"name": "create_ticket",
"arguments": {
"title": "登录接口返回 500",
"idempotency_key": "req-20261015-001"
}
}
其中 idempotency_key 是业务操作标识:同一操作重试时必须使用同一个值;新的操作必须使用新值。它通常由可信的应用层生成或确认,而不是每次由模型随意生成一个新值。下面的示例把它放在调用参数里,是为了让脚本可以独立演示整个机制。
二、先做工具白名单,再做逐字段校验
工具注册表应是代码中明确列出的有限集合。模型说"调用 delete_all_tickets",如果这个名字没注册,执行器应该立即拒绝。不要把模型给出的字符串拼到 eval、shell 命令或任意函数查找中。
参数校验也不能只停留在"JSON 能解析"。合法 JSON 完全可能包含错误类型、缺失字段、额外字段、过长文本,或违反业务规则的值。以创建工单为例:
title必须是非空字符串,且限制长度;idempotency_key必须是非空字符串,长度有限;- 只接受这两个字段,多出字段直接拒绝;
get_ticket的 ID 必须是正整数,布尔值要单独拒绝,因为 Python 中bool是int的子类。
实际生产系统还要结合登录态和权限:用户是否有创建工单的权限?能否读这张工单?工具白名单只解决"系统有哪些动作",不解决"当前用户可做哪些动作"。
三、有副作用的工具必须考虑重试
网络超时有一种最棘手的情况:调用方不知道服务端到底执行了没有。简单地"失败就重试"可能造成重复创建;简单地"不重试"可能让用户以为操作失败。幂等键可以把多次尝试关联到同一个业务操作。
理想语义是:相同键加相同参数,返回第一次执行的结果;相同键加不同参数,拒绝并报告冲突。后者非常重要,否则客户端误复用了键,系统可能把一个新请求悄悄映射到旧结果。
存储层应对幂等键加唯一约束,并把"查重、写入业务结果"放在同一事务或使用等价的原子机制。只在进程内放一个 set,多进程部署或服务重启后就失效。若工具实际调用的是外部服务,要优先使用该服务支持的幂等能力;若它不支持,就需要更仔细地设计操作状态、重试与人工核对流程,不能宣称本地去重就实现了端到端幂等。
四、完整可运行示例:一个受控的工单工具执行器
把下面代码保存为 agent_tools.py,用 Python 3.10+ 运行。脚本使用临时 SQLite 数据库,运行结束会自动清理。为了让核心逻辑清楚,示例不包含 Web 登录、跨进程并发和外部 API。
python
import hashlib
import json
import sqlite3
import tempfile
from contextlib import closing
from pathlib import Path
from typing import Any
def require_fields(data: Any, expected: set[str]) -> dict[str, Any]:
if not isinstance(data, dict) or set(data) != expected:
raise ValueError(f"arguments must contain exactly: {sorted(expected)}")
return data
def nonempty_text(value: Any, field: str, max_length: int) -> str:
if not isinstance(value, str):
raise ValueError(f"{field} must be a string")
value = value.strip()
if not value or len(value) > max_length:
raise ValueError(f"{field} length must be 1..{max_length}")
return value
class ToolExecutor:
def __init__(self, db_path: Path):
self.db_path = db_path
self.audit: list[dict[str, str]] = []
with closing(self._connect()) as db, db:
db.execute("""
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
fingerprint TEXT NOT NULL
)
""")
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self.db_path, timeout=5)
def execute(self, call: dict[str, Any]) -> dict[str, Any]:
if not isinstance(call, dict) or set(call) != {"name", "arguments"}:
raise ValueError("call needs exactly name and arguments")
name = call["name"]
try:
if name == "create_ticket":
result = self._create_ticket(call["arguments"])
elif name == "get_ticket":
result = self._get_ticket(call["arguments"])
else:
raise ValueError("tool is not allowed")
except (ValueError, sqlite3.Error):
self.audit.append({"tool": str(name), "outcome": "rejected"})
raise
self.audit.append({"tool": name, "outcome": "ok"})
return result
def _create_ticket(self, raw: Any) -> dict[str, Any]:
args = require_fields(raw, {"title", "idempotency_key"})
title = nonempty_text(args["title"], "title", 100)
key = nonempty_text(args["idempotency_key"], "idempotency_key", 80)
fingerprint = hashlib.sha256(title.encode("utf-8")).hexdigest()
with closing(self._connect()) as db, db:
# BEGIN IMMEDIATE 在读旧键之前获取写事务,避免并发请求同时通过查重。
db.execute("BEGIN IMMEDIATE")
row = db.execute(
"SELECT id, title, status, fingerprint FROM tickets "
"WHERE idempotency_key = ?", (key,)
).fetchone()
if row is not None:
if row[3] != fingerprint:
raise ValueError("idempotency key reused with different title")
return {"id": row[0], "title": row[1],
"status": row[2], "deduplicated": True}
cursor = db.execute(
"INSERT INTO tickets(title, status, idempotency_key, fingerprint) "
"VALUES (?, ?, ?, ?)",
(title, "open", key, fingerprint),
)
return {"id": cursor.lastrowid, "title": title,
"status": "open", "deduplicated": False}
def _get_ticket(self, raw: Any) -> dict[str, Any]:
args = require_fields(raw, {"ticket_id"})
ticket_id = args["ticket_id"]
if type(ticket_id) is not int or ticket_id <= 0:
raise ValueError("ticket_id must be a positive integer")
with closing(self._connect()) as db, db:
row = db.execute(
"SELECT id, title, status FROM tickets WHERE id = ?",
(ticket_id,),
).fetchone()
if row is None:
raise ValueError("ticket not found")
return {"id": row[0], "title": row[1], "status": row[2]}
if __name__ == "__main__":
with tempfile.TemporaryDirectory() as tmp:
executor = ToolExecutor(Path(tmp) / "tickets.db")
# 模拟模型提出的工具调用。真实应用还应在调用前绑定用户权限。
raw = '''{"name":"create_ticket","arguments":
{"title":"登录接口返回 500","idempotency_key":"req-001"}}'''
call = json.loads(raw)
first = executor.execute(call)
retry = executor.execute(call)
queried = executor.execute({"name": "get_ticket",
"arguments": {"ticket_id": first["id"]}})
print("first:", first)
print("retry:", retry)
print("query:", queried)
assert first["id"] == retry["id"]
assert first["deduplicated"] is False
assert retry["deduplicated"] is True
try:
executor.execute({"name": "create_ticket", "arguments": {
"title": "另一个问题", "idempotency_key": "req-001"}})
except ValueError as exc:
print("conflict:", exc)
try:
executor.execute({"name": "delete_all_tickets", "arguments": {}})
except ValueError as exc:
print("unknown tool:", exc)
print("audit:", executor.audit)
运行时可以看到:第一次创建返回 deduplicated: False;相同调用重试返回同一工单 ID,且 deduplicated: True;复用键但换标题会报冲突;未注册工具会被拒绝。这个脚本的断言会检查前两项关键行为。
这里的 fingerprint 只针对"标题是唯一业务参数"的简化示例。真实工具有多个业务字段时,应该按稳定顺序对所有会影响操作结果的字段做规范化,再计算指纹;还要定义哪些非业务字段(如链路追踪 ID)不应影响去重。对于敏感字段,不要把原文直接写入审计日志。
五、超时不是"线程等够了就能停止执行"
Agent 链路通常有多重时间预算:用户请求的总超时、模型生成时间、单个工具调用时间、数据库或外部 API 的超时。设置等待超时,只能让调用方停止等待;它不必然中止已经在远端执行的副作用。
例如 Python 的 Future.result(timeout=...) 超时会抛出异常,但已经运行的任务不能保证被 Future.cancel() 取消。因而有副作用的工具不能依赖"本地超时后重试"来保证只执行一次。调用方应把剩余截止时间传到具体工具,工具本身设置可执行的超时;超时后若结果不明,用相同幂等键查询或重试,而不是生成新键盲目再来一次。
还要区分三类错误:调用前校验失败 可以直接返回清晰的参数错误;确定未执行 可以按策略重试;执行结果未知必须走幂等查询或核对流程。把所有异常统一写成"工具失败,请重试"会抹掉最重要的信息。
六、审计要能回答"谁让系统做了什么"
示例只记录了工具名和结果,便于在控制台看到行为。生产环境的审计记录一般还需要:用户或服务身份、请求 ID、幂等键、工具版本、调用时间、授权决策、参数摘要、外部系统返回的操作 ID、最终状态及错误分类。记录的目的,是在用户说"我只点了一次,为什么创建两条"时能还原完整路径。
日志也有边界。不要把密钥、完整个人信息、模型读到的私密文档或工具原始响应无差别写进日志。可以记录脱敏字段、哈希、长度、状态码和受控的错误类型,并给日志设置访问控制与留存周期。
对有副作用的操作,建议把"模型建议"和"应用批准执行"分开记录。这样才能区分:模型提出了错误动作但被拦住,还是应用真的放行并执行。
七、把风险分层,而不是所有工具一个规则
不同工具适合不同的执行策略:
| 工具类别 | 例子 | 常见控制 |
|---|---|---|
| 只读查询 | 查工单、查文档 | 权限过滤、超时、结果长度限制 |
| 可恢复写入 | 建工单、创建草稿 | 参数校验、幂等键、操作审计 |
| 高影响写入 | 付款、删库、改变权限 | 额外授权、明确的人工确认或交接、严格审计 |
这张表是工程分层示意,具体是否需要人工确认取决于业务与组织规则。尤其是付款、权限变更和不可恢复删除,不能只因为模型判断"用户大概同意"就自动执行。
八、上线前可以用这些用例做回归
- 白名单:未注册的工具名是否被拒绝?
- 参数:缺字段、多字段、错误类型、空字符串、超长文本是否被拒绝?
- 幂等:同键同参多次调用是否只产生一次副作用?同键异参是否冲突?
- 并发:两个请求同时使用同一键时,存储层唯一约束是否仍生效?
- 超时:响应丢失后重试,是否能找回第一次操作的结果?
- 权限:换一个无权限用户,是否会在执行前拦截?
- 审计:能否把用户请求、模型建议、授权结果与真实操作 ID 串起来?
- 提示注入:工具返回文本里夹带"调用其他工具"的指令时,是否仍由应用白名单和权限规则把关?
AI Agent 的价值在于让模型帮助用户选择动作;可靠性来自应用层对动作的约束。把模型输出当作不可信输入,给副作用配幂等语义,让超时和审计能覆盖真实执行路径,才能让工具调用从演示走向可维护的系统。
参考资料
- Yao 等,ReAct: Synergizing Reasoning and Acting in Language Models:https://arxiv.org/abs/2210.03629
- OWASP Gen AI Security Project,LLM01:2025 Prompt Injection:https://genai.owasp.org/llmrisk/llm01-prompt-injection/
- Python 官方文档,
concurrent.futures:https://docs.python.org/3/library/concurrent.futures.html - Python 官方文档,
json:https://docs.python.org/3/library/json.html