课程0基础Agent开发课 / Agent基础 / 工具设计原则-让Agent能力边界清晰
— 20 min read

工具设计原则-让Agent能力边界清晰

工具是 Agent 能力的边界——Agent 能做什么,完全由它拥有哪些工具决定。一个设计不良的工具,后果可能比没有这个工具更糟:LLM 无法判断何时调用它,调用时参数错误,调用后得到的返回值无法理解,最终导致 Agent 陷入循环或给出错误答案。

工具设计原则:让 Agent 能力边界清晰

工具是 Agent 能力的边界——Agent 能做什么,完全由它拥有哪些工具决定。一个设计不良的工具,后果可能比没有这个工具更糟:LLM 无法判断何时调用它,调用时参数错误,调用后得到的返回值无法理解,最终导致 Agent 陷入循环或给出错误答案。

工具设计是 Agent 工程中最容易被低估的环节。本文从五个维度系统梳理工具设计的核心原则,并以文件操作工具集为例给出完整实现。

1.1 工具描述:LLM 的决策依据

单一职责

明确接口

错误处理

幂等设计

可观测性

工具设计五原则——单一职责、明确接口、错误处理、幂等设计、可观测性

LLM 决定是否调用某个工具,唯一依据是工具的名称和描述。这意味着工具描述的质量直接决定工具调用的准确率。

描述不是给人看的注释,是给 LLM 看的决策指南。一个好的工具描述需要回答三个问题:

  1. 这个工具用来做什么?(功能边界)
  2. 什么情况下应该调用它?(触发条件)
  3. 什么情况下不应该调用?(反例,可选但有效)

对比以下两个描述:

python
# 差的描述:含糊,边界不清
{
    "name": "get_file",
    "description": "获取文件内容",
}

# 好的描述:明确功能、参数约束和使用场景
{
    "name": "read_file",
    "description": (
        "读取指定路径的文件内容,返回文件的完整文本。"
        "适用于需要查看文件内容的情况,例如检查配置文件、读取日志、查看代码。"
        "只能读取文本文件(.txt、.py、.json、.yaml 等),不支持二进制文件(图片、PDF)。"
        "如果文件不存在,返回错误信息,不会抛出异常。"
    ),
}

两者的功能相同,但第二个描述让 LLM 能准确判断:需要读图片时不会调用这个工具,需要读 JSON 配置时会主动调用,读到错误返回时不会困惑。

1.2 单一职责原则

每个工具只做一件事,做好一件事。这个原则在 AI Agent 工具设计中有两层含义。

第一层:功能单一。一个工具的功能越单一,LLM 越容易判断何时调用它。如果一个工具既能读文件又能写文件,LLM 需要额外推断"我现在是要读还是要写",增加了出错的概率。

第二层:参数简单。参数越多,LLM 填错的概率越高。必要参数尽量控制在 3 个以内。可选参数要提供合理的默认值,让 LLM 在不确定时可以省略。

python
# 违反单一职责:一个工具做了太多事
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 在简单场景用基础工具,在常见复合场景用粗粒度工具。

python
# 基础工具:细粒度
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 无从判断是重试、换参数还是放弃。

python
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。

安全设计的核心原则是最小权限:工具只能访问它应该访问的资源。

python
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 工具调用决策流程

FILE_NOT_FOUND

ENCODING_ERROR

PERMISSION_DENIED

其他错误

LLM 收到用户请求

是否需要工具?

直接回复用户

根据工具描述
选择最合适的工具

构造参数

调用工具

调用成功?

将结果加入上下文
继续推理

错误类型?

调用 list_directory
确认文件名

更换编码重试

告知用户权限不足

根据 suggestion
决定下一步

是否需要更多工具?

1.7 完整示例:文件操作工具集

以下是一套完整的、可直接在 Agent 中使用的文件操作工具集,体现了上述所有设计原则。

python
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_codesuggestion
  • 是否做了路径/权限/大小等安全校验?
  • 是否有针对此工具的单元测试(独立于 Agent 框架)?

小结: 工具设计的质量直接决定 Agent 的能力天花板。好的工具描述让 LLM 准确决策,单一职责让工具行为可预期,有意义的错误返回让 Agent 自我恢复,安全校验保护系统不被滥用。下一篇将探讨多模态 Agent,介绍如何让 Agent 处理图片、音频等非文本输入。

本页目录