工具设计原则-让Agent能力边界清晰
工具是 Agent 能力的边界——Agent 能做什么,完全由它拥有哪些工具决定。一个设计不良的工具,后果可能比没有这个工具更糟:LLM 无法判断何时调用它,调用时参数错误,调用后得到的返回值无法理解,最终导致 Agent 陷入循环或给出错误答案。
工具设计原则:让 Agent 能力边界清晰
工具是 Agent 能力的边界——Agent 能做什么,完全由它拥有哪些工具决定。一个设计不良的工具,后果可能比没有这个工具更糟:LLM 无法判断何时调用它,调用时参数错误,调用后得到的返回值无法理解,最终导致 Agent 陷入循环或给出错误答案。
工具设计是 Agent 工程中最容易被低估的环节。本文从五个维度系统梳理工具设计的核心原则,并以文件操作工具集为例给出完整实现。
1.1 工具描述:LLM 的决策依据
工具设计五原则——单一职责、明确接口、错误处理、幂等设计、可观测性
LLM 决定是否调用某个工具,唯一依据是工具的名称和描述。这意味着工具描述的质量直接决定工具调用的准确率。
描述不是给人看的注释,是给 LLM 看的决策指南。一个好的工具描述需要回答三个问题:
- 这个工具用来做什么?(功能边界)
- 什么情况下应该调用它?(触发条件)
- 什么情况下不应该调用?(反例,可选但有效)
对比以下两个描述:
# 差的描述:含糊,边界不清
{
"name": "get_file",
"description": "获取文件内容",
}
# 好的描述:明确功能、参数约束和使用场景
{
"name": "read_file",
"description": (
"读取指定路径的文件内容,返回文件的完整文本。"
"适用于需要查看文件内容的情况,例如检查配置文件、读取日志、查看代码。"
"只能读取文本文件(.txt、.py、.json、.yaml 等),不支持二进制文件(图片、PDF)。"
"如果文件不存在,返回错误信息,不会抛出异常。"
),
}
两者的功能相同,但第二个描述让 LLM 能准确判断:需要读图片时不会调用这个工具,需要读 JSON 配置时会主动调用,读到错误返回时不会困惑。
1.2 单一职责原则
每个工具只做一件事,做好一件事。这个原则在 AI Agent 工具设计中有两层含义。
第一层:功能单一。一个工具的功能越单一,LLM 越容易判断何时调用它。如果一个工具既能读文件又能写文件,LLM 需要额外推断"我现在是要读还是要写",增加了出错的概率。
第二层:参数简单。参数越多,LLM 填错的概率越高。必要参数尽量控制在 3 个以内。可选参数要提供合理的默认值,让 LLM 在不确定时可以省略。
# 违反单一职责:一个工具做了太多事
def file_operation(
operation: str, # "read", "write", "append", "delete", "move", "copy"
path: str,
content: str = None,
target_path: str = None,
encoding: str = "utf-8",
create_if_not_exists: bool = False,
) -> dict:
"""根据 operation 参数执行不同的文件操作"""
# LLM 需要记住 6 种 operation 值,参数组合复杂
...
# 符合单一职责:拆分成独立工具
def read_file(path: str, encoding: str = "utf-8") -> dict:
"""读取文件内容"""
...
def write_file(path: str, content: str, encoding: str = "utf-8") -> dict:
"""写入内容到文件(覆盖写)"""
...
def append_to_file(path: str, content: str) -> dict:
"""向文件末尾追加内容"""
...
def delete_file(path: str) -> dict:
"""删除文件"""
...
1.3 工具粒度设计
工具粒度是一个需要权衡的问题:太细会导致调用次数多、延迟高;太粗会损失灵活性,LLM 难以精确控制行为。
| 粒度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 细粒度 | 灵活,LLM 自由组合 | 调用次数多,延迟高,Token 消耗大 | 操作频繁、逻辑简单的工具 |
| 粗粒度 | 一次完成多步,效率高 | 不灵活,LLM 难以控制细节 | 固定流程、步骤多的复合操作 |
| 混合粒度 | 兼顾效率和灵活性 | 设计复杂 | 推荐的实践方式 |
混合粒度的实践方式是:提供细粒度的基础工具(读/写/删除),同时提供常用场景的粗粒度复合工具("读取并分析文件"、"搜索并替换内容")。LLM 在简单场景用基础工具,在常见复合场景用粗粒度工具。
# 基础工具:细粒度
def read_file(path: str) -> dict: ...
def write_file(path: str, content: str) -> dict: ...
def list_directory(path: str) -> dict: ...
# 复合工具:粗粒度,封装常见操作序列
def find_and_replace_in_file(
path: str,
search_text: str,
replace_text: str
) -> dict:
"""
在文件中查找并替换文本。
这是一个常见的复合操作(读取→替换→写回),
封装成单个工具减少 LLM 的调用次数。
"""
result = read_file(path)
if not result["success"]:
return result
new_content = result["content"].replace(search_text, replace_text)
return write_file(path, new_content)
1.4 工具错误处理:返回有意义的错误信息
工具失败时,必须返回有意义的错误信息。LLM 根据错误信息决定下一步——如果错误信息含糊,LLM 无从判断是重试、换参数还是放弃。
import os
import json
from pathlib import Path
from typing import Any
def read_file(path: str, encoding: str = "utf-8") -> dict:
"""
读取文件内容。
返回统一的结构:{"success": bool, "content": str} 或 {"success": False, "error": str, "error_code": str}
统一结构让 LLM 更容易判断调用结果。
"""
try:
file_path = Path(path)
# 明确的错误类型让 LLM 能采取对应的处理策略
if not file_path.exists():
return {
"success": False,
"error": f"文件不存在:{path}",
"error_code": "FILE_NOT_FOUND",
"suggestion": "请检查文件路径是否正确,或使用 list_directory 查看目录内容",
}
if file_path.is_dir():
return {
"success": False,
"error": f"{path} 是一个目录,不是文件",
"error_code": "IS_DIRECTORY",
"suggestion": "如需查看目录内容,请使用 list_directory 工具",
}
# 文件大小限制:防止读取超大文件导致 Token 超限
file_size = file_path.stat().st_size
if file_size > 1024 * 1024: # 1MB 限制
return {
"success": False,
"error": f"文件过大({file_size // 1024}KB),超过 1MB 限制",
"error_code": "FILE_TOO_LARGE",
"suggestion": "可以使用 read_file_lines 读取文件的前 N 行",
}
content = file_path.read_text(encoding=encoding)
return {
"success": True,
"content": content,
"size": file_size,
"lines": content.count("\n") + 1,
}
except UnicodeDecodeError:
return {
"success": False,
"error": f"文件编码错误,无法用 {encoding} 解码",
"error_code": "ENCODING_ERROR",
"suggestion": f"尝试其他编码,如 gbk 或 latin-1",
}
except PermissionError:
return {
"success": False,
"error": f"没有读取权限:{path}",
"error_code": "PERMISSION_DENIED",
}
注意 suggestion 字段的设计:它告诉 LLM 遇到这个错误时可以怎么做,大幅提升 Agent 的自我恢复能力。
1.5 工具安全设计:防止工具被滥用
赋予 Agent 工具,同时也在引入安全风险。文件操作工具如果不加限制,Agent 可能被诱导删除系统文件;数据库工具如果不加约束,可能执行 DROP TABLE。
安全设计的核心原则是最小权限:工具只能访问它应该访问的资源。
import re
from pathlib import Path
from functools import wraps
# ============================================================
# 安全装饰器:对所有文件操作工具统一做路径校验
# ============================================================
ALLOWED_WORKSPACE = Path("/tmp/agent_workspace").resolve()
def safe_path(func):
"""
路径安全检查装饰器。
防止路径遍历攻击(如传入 ../../etc/passwd)。
所有文件操作工具都应该使用此装饰器。
"""
@wraps(func)
def wrapper(*args, **kwargs):
# 提取 path 参数(位置参数或关键字参数)
path_str = args[0] if args else kwargs.get("path", "")
resolved = (ALLOWED_WORKSPACE / path_str).resolve()
# 检查解析后的绝对路径是否在允许的工作目录内
try:
resolved.relative_to(ALLOWED_WORKSPACE)
except ValueError:
return {
"success": False,
"error": "路径不在允许的工作目录内,拒绝访问",
"error_code": "PATH_TRAVERSAL_BLOCKED",
}
# 替换参数,确保使用规范化后的路径
if args:
args = (str(resolved),) + args[1:]
else:
kwargs["path"] = str(resolved)
return func(*args, **kwargs)
return wrapper
# ============================================================
# 输入验证:防止注入攻击
# ============================================================
def validate_filename(filename: str) -> bool:
"""
验证文件名是否安全。
只允许字母、数字、下划线、短横线、点,拒绝包含路径分隔符的文件名。
"""
return bool(re.match(r'^[\w\-. ]+$', filename)) and '/' not in filename and '\\' not in filename
@safe_path
def write_file(path: str, content: str, encoding: str = "utf-8") -> dict:
"""
写入文件内容(安全版)。
限制在工作目录内,防止路径遍历。
"""
try:
file_path = Path(path)
# 自动创建父目录(仅在工作目录内)
file_path.parent.mkdir(parents=True, exist_ok=True)
# 内容大小限制,防止写入超大文件占用磁盘
if len(content.encode(encoding)) > 10 * 1024 * 1024: # 10MB
return {
"success": False,
"error": "内容过大,超过 10MB 写入限制",
"error_code": "CONTENT_TOO_LARGE",
}
file_path.write_text(content, encoding=encoding)
return {
"success": True,
"path": path,
"size": file_path.stat().st_size,
}
except Exception as e:
return {"success": False, "error": str(e)}
1.6 工具调用决策流程
1.7 完整示例:文件操作工具集
以下是一套完整的、可直接在 Agent 中使用的文件操作工具集,体现了上述所有设计原则。
import os
import json
from pathlib import Path
from openai import OpenAI
# 初始化工作目录
WORKSPACE = Path("/tmp/agent_workspace")
WORKSPACE.mkdir(exist_ok=True)
client = OpenAI()
def _safe_resolve(path: str) -> Path | None:
"""统一的路径安全检查,返回安全路径或 None。"""
resolved = (WORKSPACE / path).resolve()
try:
resolved.relative_to(WORKSPACE.resolve())
return resolved
except ValueError:
return None
def read_file(path: str) -> dict:
safe = _safe_resolve(path)
if not safe:
return {"success": False, "error": "路径越界", "error_code": "PATH_TRAVERSAL_BLOCKED"}
if not safe.exists():
return {"success": False, "error": f"文件不存在:{path}", "error_code": "FILE_NOT_FOUND",
"suggestion": "使用 list_directory 查看可用文件"}
if safe.stat().st_size > 512 * 1024:
return {"success": False, "error": "文件超过 512KB,请使用 read_file_lines 读取部分内容",
"error_code": "FILE_TOO_LARGE"}
content = safe.read_text(encoding="utf-8", errors="replace")
return {"success": True, "content": content, "lines": content.count("\n") + 1}
def write_file(path: str, content: str) -> dict:
safe = _safe_resolve(path)
if not safe:
return {"success": False, "error": "路径越界"}
safe.parent.mkdir(parents=True, exist_ok=True)
safe.write_text(content, encoding="utf-8")
return {"success": True, "path": path, "size": safe.stat().st_size}
def list_directory(path: str = ".") -> dict:
safe = _safe_resolve(path)
if not safe or not safe.is_dir():
return {"success": False, "error": f"目录不存在:{path}"}
items = [
{"name": item.name, "type": "dir" if item.is_dir() else "file",
"size": item.stat().st_size if item.is_file() else None}
for item in sorted(safe.iterdir())
]
return {"success": True, "path": path, "items": items, "count": len(items)}
def search_in_file(path: str, keyword: str) -> dict:
"""在文件中搜索关键词,返回匹配行及行号。"""
result = read_file(path)
if not result["success"]:
return result
lines = result["content"].split("\n")
matches = [
{"line_number": i + 1, "content": line.strip()}
for i, line in enumerate(lines)
if keyword.lower() in line.lower()
]
return {"success": True, "keyword": keyword, "matches": matches, "total": len(matches)}
# 工具 Schema(给 LLM 的决策依据)
FILE_TOOLS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "读取工作目录内指定文件的完整内容。适用于查看代码、配置、日志等文本文件。文件大小限制 512KB。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "相对于工作目录的文件路径,如 'notes.txt' 或 'src/main.py'"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "将内容写入文件(覆盖写)。如果文件不存在会自动创建,父目录不存在也会自动创建。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径(相对工作目录)"},
"content": {"type": "string", "description": "要写入的完整内容"},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "list_directory",
"description": "列出目录下的文件和子目录。当不确定文件名时,先用此工具浏览目录结构。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "目录路径,默认为工作目录根目录", "default": "."}
},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "search_in_file",
"description": "在文件中搜索关键词,返回所有匹配的行及其行号。适用于在大文件中定位特定内容。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"keyword": {"type": "string", "description": "搜索关键词,大小写不敏感"},
},
"required": ["path", "keyword"],
},
},
},
]
TOOL_MAP = {
"read_file": read_file,
"write_file": write_file,
"list_directory": list_directory,
"search_in_file": search_in_file,
}
def run_file_agent(task: str) -> str:
"""运行文件操作 Agent,执行指定任务。"""
messages = [
{"role": "system", "content": "你是一个文件操作助手,可以读取、写入和搜索工作目录内的文件。"},
{"role": "user", "content": task},
]
for _ in range(10):
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=FILE_TOOLS,
tool_choice="auto",
temperature=0,
)
msg = response.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content
for tc in msg.tool_calls:
func_name = tc.function.name
func_args = json.loads(tc.function.arguments)
result = TOOL_MAP[func_name](**func_args)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result, ensure_ascii=False),
})
return "达到最大步数限制"
# 测试
if __name__ == "__main__":
# 创建测试文件
(WORKSPACE / "todo.txt").write_text("1. 学习 LlamaIndex\n2. 复习 AutoGen\n3. 完成项目报告\n")
result = run_file_agent("查看 todo.txt 的内容,找出所有关于 Agent 的任务")
print(result)
1.8 工具设计检查清单
在将工具交给 Agent 之前,对照以下清单验证设计质量:
- 工具描述能否让 LLM 准确判断"什么时候应该调用"?
- 工具描述中是否说明了边界条件和不适用场景?
- 参数数量是否控制在 3 个以内?必要参数是否标注了
required? - 失败返回值是否包含
error_code和suggestion? - 是否做了路径/权限/大小等安全校验?
- 是否有针对此工具的单元测试(独立于 Agent 框架)?
小结: 工具设计的质量直接决定 Agent 的能力天花板。好的工具描述让 LLM 准确决策,单一职责让工具行为可预期,有意义的错误返回让 Agent 自我恢复,安全校验保护系统不被滥用。下一篇将探讨多模态 Agent,介绍如何让 Agent 处理图片、音频等非文本输入。