当前位置: 首页 > news >正文

零基础复现Claude Code(四):双手篇——赋予读写文件的能力

零基础复现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,也可能误删重要文件。

安全原则

  1. 在测试目录中运行

    # ✅ 好的做法os.chdir("test_workspace")# 切换到测试目录agent.run("帮我修Bug")# ❌ 危险的做法agent.run("帮我修Bug")# 在项目根目录运行,可能误改重要文件
  2. 写入前检查路径

    defwrite_file(path:str,content:str)->str:# 🔑 只允许写入test_workspace目录ifnotpath.startswith("test_workspace/"):return"错误:只能写入test_workspace目录"# ... 其他代码
  3. 先用Git备份

    gitadd.gitcommit-m"备份:测试Agent前的状态"# 现在可以放心测试了,出问题就git reset --hard
  4. 限制可执行的命令(下一篇会讲)

    # ❌ 危险:允许任意命令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.rsread_file()真实版支持二进制文件、大文件分块读取
write_file()crates/runtime/src/file_ops.rswrite_file()真实版支持diff模式、权限检查
execute_tool()crates/runtime/src/conversation.rsToolExecutortrait真实版用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. 坑1:忘记处理文件不存在的情况

    • 后果:Agent执行read_file时程序崩溃
    • 正确做法:用try-except捕获异常,返回错误信息
  2. 坑2:write_file没有创建父目录

    • 后果:写入test/data/file.txt时,如果test/data不存在,会失败
    • 正确做法:检查父目录是否存在,或者用os.makedirs(parent_dir, exist_ok=True)
  3. 坑3:用eval解析参数时没有处理异常

    • 后果:如果模型输出格式错误,eval会抛出异常
    • 正确做法:用try-except包裹eval,或者用ast.literal_eval
  4. 坑4:没有限制文件读取的长度

    • 后果:读取一个10MB的文件,Token预算瞬间耗尽
    • 正确做法:限制返回内容长度,超过则截断

下一步:给Agent装上"终端"

现在你已经学会了:

  • 实现真正的文件读写工具
  • 设计工具分发器(从字符串到函数调用)
  • 处理工具执行中的异常
  • 设置安全边界

但有一个关键能力还没有:

Agent还不能执行命令。

比如,Agent修改了代码后,它不能自己运行pytest验证修改是否正确。它只能"盲改",然后等你手动测试。

下一篇,我们将实现终端工具——让Agent能够:

  1. 执行只读命令(lscatgit status
  2. 看到命令的输出
  3. 根据输出调整策略

这就是Agent从"能改代码"到"能验证代码"的关键一步。

预告一个核心问题:如何防止Agent执行危险命令(如rm -rf)?答案在下一篇揭晓。


系列进度

  • ✅ 第1篇:总览与前置准备——Claude Code到底是什么?
  • ✅ 第2篇:地基篇——让模型开口说话(System Prompt的艺术)
  • ✅ 第3篇:灵魂篇——ReAct循环的骨架
  • ✅ 第4篇:双手篇——赋予读写文件的能力
  • ⏭️ 第5篇:终端篇——赋予执行命令的超能力
  • 第6篇:整合篇——组装Mini Claude Code
  • 第7篇:上下文篇——让Agent看懂整个文件夹
  • 第8篇:反思与展望——我们得到了什么,还缺什么?
http://www.jsqmd.com/news/704961/

相关文章:

  • 框架篇第3节:PyTorch C++扩展(一)——环境搭建与一个简单的add算子
  • BetterNCM Installer深度解析:5个核心技巧助你打造个性化网易云音乐体验
  • 终极指南:用BthPS3驱动让PS3控制器在Windows上重获新生
  • 携程任我行卡怎么回收?鼎鼎收实测:几分钟搞定,比等过期强多了 - 鼎鼎收礼品卡回收
  • OpenClaw exec 工具超时控制与环境隔离机制
  • 极光信息社|4月26日科技速报:行业并购、超跑股权、AI算力、手机屏幕、资本市场
  • 终极QMC音频解密指南:3分钟解锁加密音乐文件
  • Casdoor
  • 如何快速掌握kohya_ss:面向新手的完整AI模型训练实践指南
  • 开发者内功修炼指南:从代码实践到架构设计的核心技能
  • VS Code插件生态失控危机(MCP时代成本暴雷预警):从日均$23.6运维损耗到零预算优化的完整路径
  • UOJ 950. 电子运动
  • 2026携程任我行卡回收渠道横评:鼎鼎收第一名实至名归,闲置变现不踩坑 - 鼎鼎收礼品卡回收
  • ncmdump终极指南:3步解锁网易云音乐NCM格式,让音乐自由播放
  • c++怎么在Linux下通过文件描述符获取详细的Inode节点信息【底层】
  • 从Ubuntu Base到可启动镜像:手把手教你制作、分区与烧录嵌入式Linux系统盘
  • 全网最全的医药数据库挖掘教学专栏,只需要399元,不断更新,欢迎订阅!
  • 携程任我行卡闲置怎么处理?鼎鼎收回收全流程与行情参考 - 鼎鼎收礼品卡回收
  • AI交易智能体框架TradingAgents-CN:面向中文市场的量化交易开发指南
  • STM32F103定时器避坑指南:为什么你的TIM1 PWM输出没波形?从时钟树到MOE使能全解析
  • 深度解析 Elasticsearch 搜索过程:Query Then Fetch 两阶段详解
  • 2026携程任我行卡回收平台排行榜:鼎鼎收实测第一,闲置卡处理避坑指南 - 鼎鼎收礼品卡回收
  • Python中如何快速创建全零数组_使用NumPy的zeros函数初始化内存
  • 10、FileInputStream和RandomAccessFile的源码分析和使用方法详细分析(windows操作系统,JDK8)
  • 【2026年AI DevOps分水岭】:Docker AI Toolkit全新Agent编排框架上线,支持AutoGen/MetaGPT原生集成——现在不装,下周CI/CD流水线将自动拒绝旧版镜像
  • 沃尔玛购物卡回收平台TOP榜:2026闲置商超卡安全处理实测 - 鼎鼎收礼品卡回收
  • 从LlamaDeploy到Llama-Agents:智能体工作流生产级部署实战指南
  • SpringBoot 集成 OAuth2.0 资源服务器与授权服务器
  • 解密高效PDF文本提取:3个创新方法提升工作效率
  • 魔兽世界API与宏工具实战指南:一站式开发与游戏优化方案