🛠️ 自定义工具开发
自定义工具是 Agent 能力的扩展点。无论是对接企业内部 API、操作数据库,还是执行特定的业务逻辑,都需要遵循一套规范化的开发流程,确保工具的安全性、可靠性和可维护性。
🏗️ 开发模式
根据工具复杂度选择合适的开发模式:
| 模式 | 描述 | 适用场景 | 示例 |
|---|---|---|---|
| 函数装饰器 | 用装饰器标记函数,自动提取 Schema | 简单函数、快速原型 | LangChain @tool、Python 类型注解 |
| 类继承 | 继承 BaseTool 基类,实现 _run 方法 | 有状态工具、复杂逻辑 | LangChain BaseTool、OpenAI function 封装 |
| 配置驱动 | YAML/JSON 定义 Schema + 实现函数分离 | 需要非开发者定义工具 | 企业工具市场、低代码平台 |
| MCP Server | 实现 MCP 协议的标准化工具服务 | 跨平台复用、社区分享 | MCP Python/TS SDK |
示例:三种方式实现天气查询工具
方式一:函数装饰器(LangChain)
from langchain.tools import tool
from pydantic import BaseModel, Field
class WeatherInput(BaseModel):
city: str = Field(description="城市名称")
unit: str = Field(default="celsius", description="温度单位")
@tool(args_schema=WeatherInput)
def get_weather(city: str, unit: str = "celsius") -> str:
"""获取指定城市的天气信息"""
# 实际调用天气 API
return f"{city}天气:晴,28{unit}"
# 自动生成 Schema,直接可用
print(get_weather.name) # "get_weather"
print(get_weather.description) # "获取指定城市的天气信息"
print(get_weather.args) # WeatherInput schema
方式二:MCP Server(Python SDK)
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationCapabilities
from mcp.types import Tool, TextContent
import mcp.server.stdio
server = Server("weather-server")
@server.list_tools()
async def handle_list_tools() -> list[Tool]:
return [
Tool(
name="get_weather",
description="获取指定城市的天气信息",
inputSchema={
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称"},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位"
}
},
"required": ["city"]
}
)
]
@server.call_tool()
async def handle_call_tool(name: str, args: dict) -> list[TextContent]:
if name == "get_weather":
city = args["city"]
unit = args.get("unit", "celsius")
# 实际调用天气 API
return [TextContent(type="text", text=f"{city}天气:晴,28{unit}")]
raise ValueError(f"Unknown tool: {name}")
async def main():
async with mcp.server.stdio.stdio_server() as (r, w):
await server.run(r, w, InitializationCapabilities(...))
if __name__ == "__main__":
import asyncio
asyncio.run(main())
🛡️ 安全最佳实践
自定义工具是 Agent 的"手和脚",也是最容易出安全问题的地方。
安全清单
永远不要信任 LLM 传来的参数。对字符串做长度限制、格式校验,对数值做范围检查。防止 SQL 注入、命令注入、路径遍历。
工具只授予完成其功能所需的最小系统权限。文件操作限制目录白名单,数据库操作使用只读账户,API 调用使用最小 Scope 的 Token。
不要将 API Key、密码、Token 等敏感信息硬编码在工具代码中。使用环境变量或密钥管理服务(如 AWS Secrets Manager)。不要在工具返回结果或日志中暴露敏感数据。
对于执行代码、Shell 命令等高风险工具,在独立沙箱中运行(Docker 容器、gVisor、Firecracker microVM)。限制 CPU/内存/磁盘/网络资源。
对不可逆的高风险操作(删除数据、发送消息、执行支付),必须经过用户二次确认。实现 "human-in-the-loop" 审批流程。
对工具调用频率进行限制,防止 LLM 陷入循环或遭遇滥用。按工具、用户、IP 等维度设置不同的限流策略。
常见安全漏洞及防护
| 漏洞类型 | 攻击示例 | 防护措施 |
|---|---|---|
| 命令注入 | LLM 传入 city="北京; rm -rf /" |
✅ 避免使用 os.system/subprocess;必须使用时做参数白名单校验 |
| 路径遍历 | LLM 传入 file="../../../etc/passwd" |
✅ 规范化路径后检查是否在白名单目录内 |
| Prompt 注入 | LLM 输出中包含恶意指令,通过工具参数二次注入 | ✅ 工具结果与 system prompt 隔离;使用独立的消息结构 |
| 信息泄露 | 工具返回结果包含数据库连接串、内部 IP | ✅ 工具输出做脱敏过滤;日志中敏感字段打码 |
| DoS 攻击 | LLM 反复调用耗时工具或传入超大参数 | ✅ 设置超时、最大输入大小、调用频率限制 |
🧪 测试方法
测试金字塔
LLM + 工具集成测试(少量)
工具 + 真实依赖(适中)
工具函数逻辑(大量)
各层测试要点
| 测试层 | 测试内容 | 工具/方法 |
|---|---|---|
| 单元测试 | Schema 校验、参数解析、边界值、异常处理、幂等性 | pytest、unittest |
| 集成测试 | 对接真实 API/数据库,验证请求响应格式、超时处理 | pytest + fixtures、WireMock(API mock) |
| E2E 测试 | LLM 在真实对话中正确选择并调用工具,验证输出质量 | 手动测试 + 自动化评测脚本 |
| 安全测试 | 输入注入、权限绕过、敏感信息泄露 | 手动渗透测试 + 自动化安全扫描 |
| 性能测试 | 并发调用压力、大数据量处理、内存泄漏 | locust、k6 |
📋 完整开发流程
明确工具的业务目的、输入输出、约束条件。回答:解决了什么问题?LLM 在什么场景下会用到它?
定义工具名称、描述、参数 JSON Schema。写好 description(这是 LLM 理解工具用途的关键),设置合理的 required 字段。
编写处理函数,进行输入验证,调用底层服务,格式化返回结果。遵循 SOLID 原则,保持函数职责单一。
对照安全清单逐项检查:参数校验、权限控制、敏感信息处理、速率限制、沙箱隔离。
单元测试覆盖正常/异常/边界场景,集成测试验证真实依赖,安全测试覆盖注入和越权场景。
将工具注册到工具注册中心(如果是 MCP Server 则配置到 Host),设置权限、速率限制、超时等运行参数。
上线后持续监控工具调用成功率、延迟、错误分布。根据实际使用反馈优化 Schema 描述和实现。
- 先跑通再优化:用最简实现验证工具思路正确,再完善错误处理和性能
- 优先使用 MCP:新工具建议按 MCP 协议实现,获得跨平台复用能力
- 注重返回格式:工具的返回值是 LLM 理解的唯一依据,返回结构化、信息密度高的结果
- 错误信息友好:工具出错时返回人类可读的错误描述,帮助 LLM 做出正确的后续决策
- 版本兼容:Schema 变更时尽量向后兼容,大版本升级提供迁移窗口