Learn-Claude-Code学习s02:TOOLs使用介绍
在构建一个能真正“动手干活”的AI编程助手的路上,我们面临一个核心挑战:大模型本身只能输出文本,如何让它安全地执行命令、读写文件、修改代码?今天我们就来深入剖析一个实用的Tool Use(工具调用)框架,看看如何让大模型安全、可控地操作文件系统和执行Shell命令。
本文聚焦三块内容:
- 如何声明工具 —— 让模型知道它能用哪些“技能”。
- 如何调用工具 —— 模型发出调用请求,我们解析并执行。
- 安全设计 —— 路径越界拦截、危险命令过滤,为什么不能瞎信模型。
- Bash vs 专用读写工具 —— 明明一行
cat或echo就能读写文件,为什么还要单独造轮子?
本文基于一个真实的
agent_loop实现,支持bash、read_file、write_file、edit_file四个工具。代码见文末附录。
一、工具声明:让模型知道“我会什么”
要让大模型调用工具,必须按照OpenAI/百炼等平台要求的格式,描述每一个工具的名称、描述、参数结构。我们的工具列表定义如下:
TOOLS = [
{
"type": "function",
"function": {
"name": "bash",
"description": "执行一个 shell 命令。",
"parameters": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "读取文件内容",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["path"]
}
}
},
# ... write_file, edit_file 类似
]
关键点:
type: "function"是必需的,表示这是一个函数调用类型的工具。function.name是模型选择工具时使用的标识符。function.description告诉模型这个工具的用途,好的描述能大幅提升调用准确率。parameters使用JSON Schema格式,模型会根据这个结构生成参数JSON。required数组标记必填参数。
这个声明就相当于一份“API文档”,模型在决定调用工具时,会按照这个格式填充参数。
二、工具调用:从模型输出到实际执行
2.1 调度的核心:agent_loop
主循环中,我们向模型发送对话历史(加上系统提示词),并附上tools=TOOLS。模型返回的response.choices[0].message有两种情况:
- 直接文本回复(
finish_reason != "tool_calls") - 要求调用工具(
finish_reason == "tool_calls")
当需要调用工具时,response_content.tool_calls 包含一个或多个调用请求。每个请求的结构如下:
{
"id": "call_xxx",
"function": {
"name": "bash",
"arguments": '{"command":"ls -la"}'
}
}
注意:arguments 是字符串形式的JSON,必须用json.loads解析。
2.2 处理器映射表
我们预先建立了一个字典,把工具名映射到实际的Python函数:
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}
在循环中解析出function_name和function_args后,直接调用handler(**function_args)即可。最后将执行结果以role: "tool"的消息追加到对话历史中,返回给模型,让模型看到工具输出后继续决策。
2.3 完整调用流程示意图
用户输入 → agent_loop → 模型思考 → 返回 tool_calls
↓
解析每个tool_call
↓
从TOOL_HANDLERS取函数
↓
执行 (如 run_bash, run_read)
↓
返回结果 → 追加为tool消息 → 循环(模型继续处理)
三、安全设计:不要让模型“乱跑”
赋予模型执行Shell命令和读写文件的能力,如同给一个孩子一把刀。我们必须加装多重安全护栏。
3.1 路径越界防护 —— safe_path
run_read、run_write、run_edit都调用了safe_path函数:
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError("路径越界")
return path
为什么需要它?
- 用户可能输入
../../../etc/passwd,或者模型被诱导读取系统敏感文件。 resolve()会解析所有..和符号链接,得到绝对路径。is_relative_to(WORKDIR)检查最终路径是否仍在工作目录内。越界则直接抛出异常,拒绝操作。
这种做法叫沙箱路径,确保Agent只能访问当前项目目录,无法窥探或破坏系统文件。
3.2 危险命令过滤 —— run_bash
在run_bash中,我们对命令字符串做简单的黑名单检查:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot"]
if any(d in command for d in dangerous):
return "警告:检测到危险操作,已拦截执行。"
这是一个基础的防御,更完善的做法还包括:
- 禁止管道符
|、重定向>、命令替换`等,防止注入。 - 限制命令超时(已做120秒超时)。
- 使用白名单机制,只允许
ls、cat、grep等安全命令。
注意:这个黑名单很容易被绕过(比如
rm -rf /*或sudu拼写错误),生产环境建议使用shlex.quote或容器隔离。
3.3 执行超时
r = subprocess.run(..., timeout=120)
避免模型发起一个死循环或长时间运行的命令阻塞整个Agent。
四、为什么有了Bash还要专门的文件读写工具?
你可能会问:“Bash命令可以cat file.txt读取文件,也可以echo 'content' > file.txt写入,为什么还要单独实现read_file、write_file?”
这是一个很好的设计问题。以下是几个核心理由:
| 考虑维度 | Bash方式 | 专用工具方式 |
|---|---|---|
| 安全性 | 命令注入风险高,模型可能构造cat /etc/passwd; rm -rf / | 参数是纯路径和内容,不解析Shell元字符,天然防注入 |
| 控制粒度 | 无法限制读取行数,大文件会把整个内容塞进上下文 | limit参数可以只读前N行,避免撑爆token上限 |
| 输出稳定性 | 依赖系统的cat、echo,不同环境行为可能有差异 | 纯Python实现,跨平台一致 |
| 返回结构 | 混有stdout/stderr,错误信息不规则 | 统一返回字符串,错误可捕获为异常信息 |
| 编辑能力 | 需要sed、awk等复杂命令,模型容易写错 | edit_file做文本替换,原子操作,清晰可靠 |
| 审计与日志 | 难以精确知道模型读写了哪个文件 | 可以在工具层精细记录每一次文件操作 |
一个典型场景:模型想修改一个配置文件里的某一行。用Bash可能需要组合sed -i 's/old/new/g',其中正则表达式稍有错误就可能破坏整个文件。而edit_file工具只需要提供old_text和new_text,我们在底层安全地完成替换(只替换第一次出现),模型完全不必关心正则细节。
兼顾两者 —— 保留Bash作为“瑞士军刀”
为什么不干脆废除Bash?因为总有些任务是专用工具覆盖不了的:
- 批量删除特定文件(
rm logs/*.tmp) - 复杂的管道过滤(
ps aux | grep python | wc -l) - 调用系统工具(
git status,docker ps)
所以最佳实践是:提供有限的、安全的Bash能力,同时给高频文件操作提供专用工具。
五、总结与扩展思考
通过今天的学习,我们掌握了:
- 工具声明:按照OpenAI的Function Calling规范定义工具的元数据。
- 工具调用:解析模型返回的
tool_calls,映射到具体函数,返回结果再喂回模型。 - 安全保障:路径沙箱、危险命令过滤、超时控制,缺一不可。
- 职责拆分:专用读写工具弥补Bash的不足,实现更安全、更可控的文件操作。
*附:完整代码文件
import os
import subprocess
import json
from openai import OpenAI
from dotenv import load_dotenv
from pathlib import Path
load_dotenv(override=True)
client = OpenAI(
api_key= os.getenv("DASHSCOPE_API_KEY"),
base_url = os.getenv("BASE_URL")
)
MODEL = os.getenv("LLM_MODEL")
SYSTEM = f"你是一个高效的编程助手。你当前的工作目录是:{os.getcwd()}。请使用 bash 命令来完成用户交付的任务。请直接行动,不要做多余的解释。"
WORKDIR = Path.cwd()
def safe_path(p:str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"路径越界")
return path
# 定义执行函数
def run_bash(command: str) -> str:
# 基础安全过滤:拦截危险命令
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot"]
if any(d in command for d in dangerous):
return "警告:检测到危险操作,已拦截执行。"
try:
# 执行命令,设置 120 秒超时防止卡死
r = subprocess.run(command, shell=True, cwd=os.getcwd(),
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out if out else "(执行成功,无回显输出)"
except Exception as e:
return f"运行时错误: {str(e)}"
def run_read(path:str, limit:int = None) ->str:
try:
text = safe_path(path).read_text()
lines = text.splitlines()#按行切分为列表
if limit and limit < len(lines):#限制模型的读取行数为limit,并告诉模型还有多少行没读
lines = lines[:limit] + [f"...({len(lines) - limit}行未显示)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path:str, content: str)->str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"在位置{path}写入了{len(content)}字节"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text:str, new_text:str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"出错了,编辑文本不存在于{path}"
fp.write_text(content.replace(old_text,new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# 修复:必须增加 "type": "function" 层级
TOOLS = [
{
"type": "function",
"function": {
"name": "bash",
"description": "执行一个 shell 命令。",
"parameters": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "读取文件内容",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}, "limit": {"type": "integer"}},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "向文件写入内容",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
"required": ["path", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "替换文件中的特定文本",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"}
},
"required": ["path", "old_text", "new_text"]
}
}
}
]
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),#从名为 kw 的字典中,取出键(Key)为 "command" 的值。
"read_file": lambda **kw: run_read(kw["path"],kw["limit"]),
"write_file": lambda **kw: run_write(kw["path"],kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"],kw["old_text"],kw["new_text"]),
}
def agent_loop(messages:list):
while True:
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "system", "content": SYSTEM}] + messages,
tools=TOOLS,
max_tokens=8000,
extra_body={"enable_thinking": False}
)
response_content = response.choices[0].message
messages.append(response_content)
if response.choices[0].finish_reason != "tool_calls":
# 如果模型不是为了调用工具,说明任务完成或直接回复了文字
return response_content.content
# 3. 处理工具调用
tool_calls = response_content.tool_calls
for tool_call in tool_calls:
function_name = tool_call.function.name
# 百炼返回的参数是字符串形式的 JSON,需要解析
import json
function_args = json.loads(tool_call.function.arguments)
# 从调度映射表中查找并执行
handler = TOOL_HANDLERS.get(function_name)
output = handler(**function_args) if handler else f"未知的工具: {function_name}"
print(f"\n> 执行工具 [{function_name}]:")
print(f" 参数: {function_args}")
print(f" 输出: {str(output)[:200]}...")
# 4. 将工具执行结果添加到消息序列中
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": function_name,
"content": str(output),
})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms02 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
print()
3191



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



