零基础复现Claude Code(四):双手篇——赋予读写文件的能力
开篇:从"纸上谈兵"到"真刀真枪"
上一篇,我们实现了ReAct循环的骨架——Agent已经会"想"了。它能输出:
Thought: 我需要读取main.py Action: read_file('main.py')但这只是一段文本,文件并没有真的被读取。
第3篇的成就:我们实现了完整的ReAct循环——模型能思考、能输出Action、能看到Observation、能根据结果继续思考。但工具还是模拟的。
💡回到"实习生"比喻:现在的Agent就像一个只会"嘴上说说"的实习生。
你问他:“帮我修Bug。”
他说:“好的,我需要先看看代码,然后改一下,最后跑测试。”
你问:“那你看了吗?”
他愣住了:“呃…我只是说说而已,我不知道怎么真的去看文件…”这一篇,我们就要给实习生装上"双手"——让他真的能打开文件、修改文件。
这一篇是整个系列的实操转折点——从模拟到真实,从理论到实践。
本节目标
读完这篇文章,你将:
- 理解工具调用的完整闭环:从模型输出到函数执行到结果反馈的全流程
- 实现真正的文件读写工具:不再是硬编码,而是真的操作文件系统
- 掌握工具分发器的设计:如何把字符串
"read_file('main.py')"转换成真正的函数调用 - 学会安全地操作文件:避免误删、误改重要文件
原理深潜:工具调用的完整闭环
📍 回到第一篇和第三篇的公式
还记得我们在第一篇建立的公式吗?
循环 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)还是硬编码的模拟:
defexecute_action(self,action):if'read_file'inaction:return"文件内容:..."# 假数据这一篇我们要实现真正的Execute(Action_t):
defexecute_action(self,action):# 解析action字符串,提取函数名和参数# 调用真正的read_file函数# 返回真实的文件内容工具调用闭环的4个步骤
让我们用图解展示完整的闭环:
PC端完整版:
┌─────────────────────────────────────────────────────────┐ │ 步骤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列表 → 模型看到结果 → 继续思考 │ └─────────────────────────────────────────────────────────┘手机端简化版:
模型输出 "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)
tools=[{"type":"function","function":{"name":"read_file","description":"读取文件内容","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径"}},"required":["path"]}}}]优点:模型直接输出JSON格式的工具调用,不需要解析字符串
缺点:需要API支持Function Calling
方式B:Python函数签名(我们的简化版)
defread_file(path:str)->str:"""读取文件内容"""withopen(path,'r')asf:returnf.read()优点:简单直观,不依赖特殊API
缺点:需要解析模型输出的字符串
我们的教学版用方式B,因为它更容易理解"模型输出什么,我们怎么执行"。
动手实操:实现真正的文件读写工具
现在我们开始写代码。目标是替换第3篇中的硬编码模拟,接入真实的文件操作。
第一步:实现read_file工具
创建一个新文件tools.py:
importosdefread_file(path:str)->str:""" 读取文件内容 参数: path: 文件路径(相对或绝对路径) 返回: 文件内容(字符串) 异常: 如果文件不存在或无法读取,返回错误信息 """try:# 🔑 安全检查:确保路径存在ifnotos.path.exists(path):returnf"错误:文件不存在 -{path}"# 🔑 安全检查:确保是文件而不是目录ifnotos.path.isfile(path):returnf"错误:{path}是一个目录,不是文件"# 🔑 读取文件内容withopen(path,'r',encoding='utf-8')asf:content=f.read()# 🔑 限制返回内容的长度,避免超出Token限制MAX_LENGTH=5000# 约1000个Tokeniflen(content)>MAX_LENGTH:returnf"{content[:MAX_LENGTH]}\n\n... (文件太长,已截断,共{len(content)}字符)"returncontentexceptExceptionase:returnf"错误:无法读取文件 -{str(e)}"代码解读:
- 用
try-except捕获所有异常,避免程序崩溃 - 检查文件是否存在、是否是文件(而不是目录)
- 限制返回内容长度,避免超出模型的Token限制
- 返回错误信息而不是抛出异常,让Agent能看到错误并调整策略
第二步:实现write_file工具
继续在tools.py中添加:
defwrite_file(path:str,content:str)->str:""" 写入文件内容 参数: path: 文件路径 content: 要写入的内容 返回: 成功或错误信息 """try:# 🔑 安全检查:确保父目录存在parent_dir=os.path.dirname(path)ifparent_dirandnotos.path.exists(parent_dir):returnf"错误:父目录不存在 -{parent_dir}"# 🔑 安全检查:如果文件已存在,先备份ifos.path.exists(path):backup_path=f"{path}.backup"withopen(path,'r',encoding='utf-8')asf:backup_content=f.read()withopen(backup_path,'w',encoding='utf-8')asf:f.write(backup_content)# 🔑 写入文件withopen(path,'w',encoding='utf-8')asf:f.write(content)returnf"成功:文件已保存到{path}"exceptExceptionase:returnf"错误:无法写入文件 -{str(e)}"代码解读:
- 检查父目录是否存在(避免写入到不存在的路径)
- 如果文件已存在,先创建
.backup备份(防止误改) - 用
with open确保文件正确关闭
第三步:实现工具分发器(核心)
这是本篇最关键的部分——如何把字符串"read_file('main.py')"转换成真正的函数调用?
继续在tools.py中添加:
importredefparse_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())ifnotmatch:returnNone,[]tool_name=match.group(1)args_str=match.group(2)# 🔑 解析参数列表# 安全注意:使用ast.literal_eval而不是eval,只能解析字面量,防止代码注入ifargs_str.strip():try:importast# 将参数字符串包装成元组再解析args=ast.literal_eval(f"({args_str},)")# 如果只有一个参数,返回的是值本身,需要转成列表ifnotisinstance(args,tuple):args=(args,)returntool_name,list(args)except:returntool_name,[]returntool_name,[]defexecute_tool(action:str)->str:""" 执行工具调用 参数: action: 字符串,如 "read_file('main.py')" 返回: 工具执行结果(字符串) """# 🔑 步骤1:解析action字符串tool_name,args=parse_action(action)iftool_nameisNone:returnf"错误:无法解析Action -{action}"# 🔑 步骤2:根据工具名分发到对应的函数iftool_name=="read_file":iflen(args)!=1:return"错误:read_file需要1个参数(文件路径)"returnread_file(args[0])eliftool_name=="write_file":iflen(args)!=2:return"错误:write_file需要2个参数(文件路径, 内容)"returnwrite_file(args[0],args[1])else:returnf"错误:未知工具 -{tool_name}"代码解读:
parse_action:用正则表达式提取工具名和参数- 用
eval解析参数(简化版,生产环境应该用ast.literal_eval) execute_tool:根据工具名分发到对应的函数- 返回错误信息而不是抛出异常
第四步:集成到ReActAgent
现在我们修改第3篇的react_agent.py,替换硬编码的execute_action:
# 在文件开头导入tools模块fromtoolsimportexecute_toolclassReActAgent:# ... 其他代码保持不变 ...defexecute_action(self,action):""" 执行Action(现在接入真实工具) 参数: action: 字符串,如 "read_file('main.py')" 返回: 执行结果(字符串) """# 🔑 直接调用tools模块的execute_toolreturnexecute_tool(action)就这么简单!我们只需要替换一个函数,整个Agent就从"模拟"变成了"真实"。
第五步:测试真实的文件读写
创建一个测试文件test_real_tools.py:
fromreact_agentimportReActAgentimportos# 🔑 创建一个测试目录和测试文件os.makedirs("test_workspace",exist_ok=True)# 创建一个包含Bug的测试文件withopen("test_workspace/buggy.py","w")asf:f.write("""def calculate(a, b): # Bug: 这里应该是加法,但写成了减法 return a - b result = calculate(5, 3) print(f"5 + 3 = {result}") """)# 创建Agentagent=ReActAgent(max_iterations=10)# 测试任务:让Agent读取文件并找出Bugresult=agent.run("请读取test_workspace/buggy.py文件,找出里面的Bug并告诉我")print("\n"+"="*60)print(f"最终结果:{result}")运行这段代码,你会看到类似这样的输出:
用户:请读取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,也可能误删重要文件。
安全原则
在测试目录中运行
# ✅ 好的做法os.chdir("test_workspace")# 切换到测试目录agent.run("帮我修Bug")# ❌ 危险的做法agent.run("帮我修Bug")# 在项目根目录运行,可能误改重要文件写入前检查路径
defwrite_file(path:str,content:str)->str:# 🔑 只允许写入test_workspace目录ifnotpath.startswith("test_workspace/"):return"错误:只能写入test_workspace目录"# ... 其他代码先用Git备份
gitadd.gitcommit-m"备份:测试Agent前的状态"# 现在可以放心测试了,出问题就git reset --hard限制可执行的命令(下一篇会讲)
# ❌ 危险:允许任意命令run_cmd("rm -rf /")# ✅ 安全:只允许只读命令ALLOWED_COMMANDS=["ls","cat","grep","git status"]
真实案例:一个误删文件的故事
有个开发者让Agent"清理临时文件",Agent执行了:
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的ToolExecutortrait | 真实版用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:工具应该返回字符串,而不是抛出异常
❌ 不好的设计:
defread_file(path):withopen(path,'r')asf:# 文件不存在时抛出异常returnf.read()✅ 好的设计:
defread_file(path):try:withopen(path,'r')asf:returnf.read()exceptFileNotFoundError:returnf"错误:文件不存在 -{path}"为什么?Agent需要看到错误信息才能调整策略。如果抛出异常,循环就中断了。
原则2:工具应该有明确的输入输出格式
❌ 不好的设计:
defprocess_file(path,mode=None,encoding=None,...):# 参数太多,模型容易搞混✅ 好的设计:
defread_file(path:str)->str:"""读取文件内容"""defwrite_file(path:str,content:str)->str:"""写入文件内容"""为什么?参数越简单,模型越不容易出错。
原则3:工具应该有安全边界
❌ 危险的设计:
defrun_cmd(cmd):returnsubprocess.run(cmd,shell=True,capture_output=True).stdout✅ 安全的设计:
defrun_cmd(cmd):# 检查命令是否在白名单中ifnotis_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篇:反思与展望——我们得到了什么,还缺什么?