开篇:从"纸上谈兵"到"真刀真枪"
上一篇,我们实现了ReAct循环的骨架------Agent已经会"想"了。它能输出:
vbnet
Thought: 我需要读取main.py
Action: read_file('main.py')
但这只是一段文本,文件并没有真的被读取。
第3篇的成就:我们实现了完整的ReAct循环------模型能思考、能输出Action、能看到Observation、能根据结果继续思考。但工具还是模拟的。
💡 回到"实习生"比喻:现在的Agent就像一个只会"嘴上说说"的实习生。
你问他:"帮我修Bug。" 他说:"好的,我需要先看看代码,然后改一下,最后跑测试。" 你问:"那你看了吗?" 他愣住了:"呃...我只是说说而已,我不知道怎么真的去看文件..."
这一篇,我们就要给实习生装上"双手"------让他真的能打开文件、修改文件。
这一篇是整个系列的实操转折点------从模拟到真实,从理论到实践。
本节目标
读完这篇文章,你将:
- 理解工具调用的完整闭环:从模型输出到函数执行到结果反馈的全流程
- 实现真正的文件读写工具:不再是硬编码,而是真的操作文件系统
- 掌握工具分发器的设计 :如何把字符串
"read_file('main.py')"转换成真正的函数调用 - 学会安全地操作文件:避免误删、误改重要文件
原理深潜:工具调用的完整闭环
📍 回到第一篇和第三篇的公式
还记得我们在第一篇建立的公式吗?
ini
循环 t = 0, 1, 2, ...:
Thought_t, Action_t = LLM(S_t) ← 第2篇解决了这部分
Observation_t = Execute(Action_t) ← 第3篇实现了循环,本篇实现真实执行
S_{t+1} = S_t + (Thought_t, Action_t, Observation_t)
第3篇我们实现了循环框架 ,但Execute(Action_t)还是硬编码的模拟:
python
def execute_action(self, action):
if 'read_file' in action:
return "文件内容:..." # 假数据
这一篇我们要实现真正的Execute(Action_t):
python
def execute_action(self, action):
# 解析action字符串,提取函数名和参数
# 调用真正的read_file函数
# 返回真实的文件内容
工具调用闭环的4个步骤
让我们用图解展示完整的闭环:
PC端完整版:
css
┌─────────────────────────────────────────────────────────┐
│ 步骤1:模型输出 │
│ LLM → "Thought: ...\nAction: read_file('main.py')" │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 步骤2:解析Action字符串 │
│ "read_file('main.py')" → { │
│ tool_name: "read_file", │
│ args: ["main.py"] │
│ } │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 步骤3:执行真实工具 │
│ 调用Python函数:read_file("main.py") │
│ → 打开文件 → 读取内容 → 返回字符串 │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 步骤4:格式化结果,反馈给模型 │
│ "Observation: 文件内容:\ndef main():\n ..." │
│ → 加入messages列表 → 模型看到结果 → 继续思考 │
└─────────────────────────────────────────────────────────┘
手机端简化版:
makefile
模型输出
"Action: read_file('main.py')"
↓
解析字符串
tool_name="read_file"
args=["main.py"]
↓
执行真实函数
read_file("main.py")
↓
返回结果
"Observation: 文件内容..."
↓
反馈给模型
关键洞察:步骤2(解析字符串)是最容易出错的地方,也是本篇的重点。
两种工具定义方式的对比
在真实的Agent系统中,有两种主流的工具定义方式:
方式A:JSON Schema(OpenAI Function Calling)
python
tools = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "读取文件内容",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"}
},
"required": ["path"]
}
}
}
]
优点:模型直接输出JSON格式的工具调用,不需要解析字符串 缺点:需要API支持Function Calling
方式B:Python函数签名(我们的简化版)
python
def read_file(path: str) -> str:
"""读取文件内容"""
with open(path, 'r') as f:
return f.read()
优点:简单直观,不依赖特殊API 缺点:需要解析模型输出的字符串
我们的教学版用方式B,因为它更容易理解"模型输出什么,我们怎么执行"。
动手实操:实现真正的文件读写工具
现在我们开始写代码。目标是替换第3篇中的硬编码模拟,接入真实的文件操作。
第一步:实现read_file工具
创建一个新文件tools.py:
python
import os
def read_file(path: str) -> str:
"""
读取文件内容
参数:
path: 文件路径(相对或绝对路径)
返回:
文件内容(字符串)
异常:
如果文件不存在或无法读取,返回错误信息
"""
try:
# 🔑 安全检查:确保路径存在
if not os.path.exists(path):
return f"错误:文件不存在 - {path}"
# 🔑 安全检查:确保是文件而不是目录
if not os.path.isfile(path):
return f"错误:{path} 是一个目录,不是文件"
# 🔑 读取文件内容
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# 🔑 限制返回内容的长度,避免超出Token限制
MAX_LENGTH = 5000 # 约1000个Token
if len(content) > MAX_LENGTH:
return f"{content[:MAX_LENGTH]}\n\n... (文件太长,已截断,共{len(content)}字符)"
return content
except Exception as e:
return f"错误:无法读取文件 - {str(e)}"
代码解读:
- 用
try-except捕获所有异常,避免程序崩溃 - 检查文件是否存在、是否是文件(而不是目录)
- 限制返回内容长度,避免超出模型的Token限制
- 返回错误信息而不是抛出异常,让Agent能看到错误并调整策略
第二步:实现write_file工具
继续在tools.py中添加:
python
def write_file(path: str, content: str) -> str:
"""
写入文件内容
参数:
path: 文件路径
content: 要写入的内容
返回:
成功或错误信息
"""
try:
# 🔑 安全检查:确保父目录存在
parent_dir = os.path.dirname(path)
if parent_dir and not os.path.exists(parent_dir):
return f"错误:父目录不存在 - {parent_dir}"
# 🔑 安全检查:如果文件已存在,先备份
if os.path.exists(path):
backup_path = f"{path}.backup"
with open(path, 'r', encoding='utf-8') as f:
backup_content = f.read()
with open(backup_path, 'w', encoding='utf-8') as f:
f.write(backup_content)
# 🔑 写入文件
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
return f"成功:文件已保存到 {path}"
except Exception as e:
return f"错误:无法写入文件 - {str(e)}"
代码解读:
- 检查父目录是否存在(避免写入到不存在的路径)
- 如果文件已存在,先创建
.backup备份(防止误改) - 用
with open确保文件正确关闭
第三步:实现工具分发器(核心)
这是本篇最关键的部分------如何把字符串"read_file('main.py')"转换成真正的函数调用?
继续在tools.py中添加:
python
import re
def parse_action(action: str) -> tuple:
"""
解析Action字符串,提取工具名和参数
参数:
action: 字符串,如 "read_file('main.py')" 或 "write_file('test.py', 'content')"
返回:
(tool_name, args) 元组
例如:("read_file", ["main.py"])
"""
# 🔑 正则表达式解析:工具名(参数1, 参数2, ...)
# 匹配模式:函数名 + 括号 + 参数列表
match = re.match(r'(\w+)\((.*)\)', action.strip())
if not match:
return None, []
tool_name = match.group(1)
args_str = match.group(2)
# 🔑 解析参数列表
# 安全注意:使用ast.literal_eval而不是eval,只能解析字面量,防止代码注入
if args_str.strip():
try:
import ast
# 将参数字符串包装成元组再解析
args = ast.literal_eval(f"({args_str},)")
# 如果只有一个参数,返回的是值本身,需要转成列表
if not isinstance(args, tuple):
args = (args,)
return tool_name, list(args)
except:
return tool_name, []
return tool_name, []
def execute_tool(action: str) -> str:
"""
执行工具调用
参数:
action: 字符串,如 "read_file('main.py')"
返回:
工具执行结果(字符串)
"""
# 🔑 步骤1:解析action字符串
tool_name, args = parse_action(action)
if tool_name is None:
return f"错误:无法解析Action - {action}"
# 🔑 步骤2:根据工具名分发到对应的函数
if tool_name == "read_file":
if len(args) != 1:
return "错误:read_file需要1个参数(文件路径)"
return read_file(args[0])
elif tool_name == "write_file":
if len(args) != 2:
return "错误:write_file需要2个参数(文件路径, 内容)"
return write_file(args[0], args[1])
else:
return f"错误:未知工具 - {tool_name}"
代码解读:
parse_action:用正则表达式提取工具名和参数- 用
eval解析参数(简化版,生产环境应该用ast.literal_eval) execute_tool:根据工具名分发到对应的函数- 返回错误信息而不是抛出异常
第四步:集成到ReActAgent
现在我们修改第3篇的react_agent.py,替换硬编码的execute_action:
python
# 在文件开头导入tools模块
from tools import execute_tool
class ReActAgent:
# ... 其他代码保持不变 ...
def execute_action(self, action):
"""
执行Action(现在接入真实工具)
参数:
action: 字符串,如 "read_file('main.py')"
返回:
执行结果(字符串)
"""
# 🔑 直接调用tools模块的execute_tool
return execute_tool(action)
就这么简单!我们只需要替换一个函数,整个Agent就从"模拟"变成了"真实"。
第五步:测试真实的文件读写
创建一个测试文件test_real_tools.py:
python
from react_agent import ReActAgent
import os
# 🔑 创建一个测试目录和测试文件
os.makedirs("test_workspace", exist_ok=True)
# 创建一个包含Bug的测试文件
with open("test_workspace/buggy.py", "w") as f:
f.write("""def calculate(a, b):
# Bug: 这里应该是加法,但写成了减法
return a - b
result = calculate(5, 3)
print(f"5 + 3 = {result}")
""")
# 创建Agent
agent = ReActAgent(max_iterations=10)
# 测试任务:让Agent读取文件并找出Bug
result = agent.run("请读取test_workspace/buggy.py文件,找出里面的Bug并告诉我")
print("\n" + "=" * 60)
print(f"最终结果:{result}")
运行这段代码,你会看到类似这样的输出:
less
用户:请读取test_workspace/buggy.py文件,找出里面的Bug并告诉我
============================================================
[第 1 轮]
💭 Thought: 用户想让我读取buggy.py文件并找出Bug,我应该先读取文件内容
🔧 Action: read_file('test_workspace/buggy.py')
👀 Observation: def calculate(a, b):
# Bug: 这里应该是加法,但写成了减法
return a - b
result = calculate(5, 3)
print(f"5 + 3 = {result}")
[第 2 轮]
💭 Thought: 我看到了Bug!注释说应该是加法,但代码写的是减法(a - b)。而且print语句也显示"5 + 3",但实际计算的是减法。
✅ Answer: 找到Bug了!在calculate函数中,注释说应该是加法,但代码写的是减法(return a - b)。应该改成 return a + b。这会导致程序输出错误的结果(5 - 3 = 2,而不是5 + 3 = 8)。
============================================================
最终结果:找到Bug了!在calculate函数中,注释说应该是加法,但代码写的是减法(return a - b)。应该改成 return a + b。这会导致程序输出错误的结果(5 - 3 = 2,而不是5 + 3 = 8)。
恭喜!你的Agent现在真的能读取文件了!
⚠️ 安全警告(必读)
⚠️ 在继续之前,我们必须谈谈安全问题。
让Agent能写文件是一把双刃剑------它能帮你修Bug,也可能误删重要文件。
安全原则
-
在测试目录中运行
python# ✅ 好的做法 os.chdir("test_workspace") # 切换到测试目录 agent.run("帮我修Bug") # ❌ 危险的做法 agent.run("帮我修Bug") # 在项目根目录运行,可能误改重要文件 -
写入前检查路径
pythondef write_file(path: str, content: str) -> str: # 🔑 只允许写入test_workspace目录 if not path.startswith("test_workspace/"): return "错误:只能写入test_workspace目录" # ... 其他代码 -
先用Git备份
bashgit add . git commit -m "备份:测试Agent前的状态" # 现在可以放心测试了,出问题就git reset --hard -
限制可执行的命令(下一篇会讲)
python# ❌ 危险:允许任意命令 run_cmd("rm -rf /") # ✅ 安全:只允许只读命令 ALLOWED_COMMANDS = ["ls", "cat", "grep", "git status"]
真实案例:一个误删文件的故事
有个开发者让Agent"清理临时文件",Agent执行了:
python
Action: run_cmd('rm -rf temp*')
结果把temp_important_data.json也删了。
教训:
- 永远不要让Agent执行
rm -rf - 写入/删除前,先让Agent列出会影响哪些文件
- 重要文件用Git管理
与真实代码的对照
在真实的Claude Code实现中(rust版本),这部分对应的是:
| 我们的实现 | 真实代码位置 | 关键差异 |
|---|---|---|
read_file() |
crates/runtime/src/file_ops.rs 的 read_file() |
真实版支持二进制文件、大文件分块读取 |
write_file() |
crates/runtime/src/file_ops.rs 的 write_file() |
真实版支持diff模式、权限检查 |
execute_tool() |
crates/runtime/src/conversation.rs 的 ToolExecutor trait |
真实版用trait实现,支持动态注册工具 |
parse_action() |
不需要,真实版用Function Calling API | 真实版模型直接输出JSON,不需要解析字符串 |
想深入研究的读者:
- 打开
crates/runtime/src/file_ops.rs,搜索pub fn read_file,你会看到完整的文件操作逻辑 - 打开
crates/tools/src/lib.rs,可以看到工具注册和分发的机制
为什么我们用字符串解析,真实版用JSON?
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 字符串解析 | 简单,不依赖特殊API | 容易出错,难以处理复杂参数 | 教学版、原型 |
| Function Calling | 健壮,模型直接输出结构化数据 | 需要API支持 | 生产环境 |
我们的教学版用字符串解析是为了让你看清楚"模型输出什么,我们怎么执行"。真实的Claude Code用OpenAI的Function Calling API或Anthropic的Tool Use API,模型直接输出JSON格式的工具调用。
工具设计的3个原则
通过上面的实现,我们总结出设计Agent工具的3个原则:
原则1:工具应该返回字符串,而不是抛出异常
❌ 不好的设计:
python
def read_file(path):
with open(path, 'r') as f: # 文件不存在时抛出异常
return f.read()
✅ 好的设计:
python
def read_file(path):
try:
with open(path, 'r') as f:
return f.read()
except FileNotFoundError:
return f"错误:文件不存在 - {path}"
为什么? Agent需要看到错误信息才能调整策略。如果抛出异常,循环就中断了。
原则2:工具应该有明确的输入输出格式
❌ 不好的设计:
python
def process_file(path, mode=None, encoding=None, ...):
# 参数太多,模型容易搞混
✅ 好的设计:
python
def read_file(path: str) -> str:
"""读取文件内容"""
def write_file(path: str, content: str) -> str:
"""写入文件内容"""
为什么? 参数越简单,模型越不容易出错。
原则3:工具应该有安全边界
❌ 危险的设计:
python
def run_cmd(cmd):
return subprocess.run(cmd, shell=True, capture_output=True).stdout
✅ 安全的设计:
python
def run_cmd(cmd):
# 检查命令是否在白名单中
if not is_safe_command(cmd):
return "错误:不允许执行此命令"
# ... 执行命令
为什么? Agent可能会犯错,安全边界能防止灾难性后果。
📝 自检清单(读完本篇请确认)
在进入下一篇之前,请确认你能回答以下问题:
- 工具调用闭环的4个步骤是什么?
- 为什么工具应该返回字符串而不是抛出异常?
-
parse_action函数的作用是什么? - 为什么需要限制
read_file返回内容的长度? - 你能说出3个让Agent写文件时的安全注意事项吗?
如果都能回答,恭喜你,Agent的"双手"部分你已经掌握了。下一篇见!
⚠️ 新手容易踩的坑
-
坑1:忘记处理文件不存在的情况
- 后果:Agent执行
read_file时程序崩溃 - 正确做法:用
try-except捕获异常,返回错误信息
- 后果:Agent执行
-
坑2:
write_file没有创建父目录- 后果:写入
test/data/file.txt时,如果test/data不存在,会失败 - 正确做法:检查父目录是否存在,或者用
os.makedirs(parent_dir, exist_ok=True)
- 后果:写入
-
坑3:用
eval解析参数时没有处理异常- 后果:如果模型输出格式错误,
eval会抛出异常 - 正确做法:用
try-except包裹eval,或者用ast.literal_eval
- 后果:如果模型输出格式错误,
-
坑4:没有限制文件读取的长度
- 后果:读取一个10MB的文件,Token预算瞬间耗尽
- 正确做法:限制返回内容长度,超过则截断
下一步:给Agent装上"终端"
现在你已经学会了:
- 实现真正的文件读写工具
- 设计工具分发器(从字符串到函数调用)
- 处理工具执行中的异常
- 设置安全边界
但有一个关键能力还没有:
Agent还不能执行命令。
比如,Agent修改了代码后,它不能自己运行pytest验证修改是否正确。它只能"盲改",然后等你手动测试。
下一篇,我们将实现终端工具------让Agent能够:
- 执行只读命令(
ls、cat、git status) - 看到命令的输出
- 根据输出调整策略
这就是Agent从"能改代码"到"能验证代码"的关键一步。
预告一个核心问题 :如何防止Agent执行危险命令(如rm -rf)?答案在下一篇揭晓。
系列进度
- ✅ 第1篇:总览与前置准备------Claude Code到底是什么?
- ✅ 第2篇:地基篇------让模型开口说话(System Prompt的艺术)
- ✅ 第3篇:灵魂篇------ReAct循环的骨架
- ✅ 第4篇:双手篇------赋予读写文件的能力
- ⏭️ 第5篇:终端篇------赋予执行命令的超能力
- 第6篇:整合篇------组装Mini Claude Code
- 第7篇:上下文篇------让Agent看懂整个文件夹
- 第8篇:反思与展望------我们得到了什么,还缺什么?