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

开篇:从"纸上谈兵"到"真刀真枪"

上一篇,我们实现了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,也可能误删重要文件。

安全原则

  1. 在测试目录中运行

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

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

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

    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.rsread_file() 真实版支持二进制文件、大文件分块读取
write_file() crates/runtime/src/file_ops.rswrite_file() 真实版支持diff模式、权限检查
execute_tool() crates/runtime/src/conversation.rsToolExecutor 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. 坑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篇:反思与展望------我们得到了什么,还缺什么?
相关推荐
Xu_youyaxianshen7 分钟前
Git 零基础常用指令手册(Gitee / GitHub 通用 )
git·gitee·github
阿里嘎多学长1 小时前
2026-07-22 GitHub 热点项目精选
开发语言·程序员·github·代码托管
小蠢驴打代码1 小时前
记忆库能通过测试,不等于回答值得信:Coding Agent Memory 的两层评估设计
github·ai编程
骆驼10242 小时前
基于 PVE 与 SELKS 搭建企业级开源 IDS 流量监控平台
开源·ids·selks
武子康2 小时前
Copilot Code Review 从固定 Reviewer 演进为可编程 Runtime,仓库控制面、Setup 供应链、Runner 资源和 MCP 工具同时被纳入审查决策
人工智能·github·aigc
正在走向自律3 小时前
用豆包Seed Evolving打造全功能【AI智能记账】小程序,开源可落地
人工智能·小程序·开源·智能记账
GuWenyue10 小时前
写Agent还要重复封装工具?一套MCP多服务方案,3个能力让AI自动查地图、读写文件、操控浏览器
人工智能·机器学习·开源
wangruofeng12 小时前
opencodex 解锁 Codex 任意模型,一个本地代理打通 Claude/Kimi/GLM/DeepSeek
llm·github·openai
一点一木14 小时前
从60首歌到1个网站:输入你的故事,还你一首歌
前端·github