零基础复现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篇:反思与展望------我们得到了什么,还缺什么?
相关推荐
CoderJia程序员甲2 小时前
GitHub 热榜项目 - 日榜(2026-04-23)
人工智能·ai·大模型·github·ai教程
叹一曲当时只道是寻常2 小时前
Reference 工具安装与使用教程:一条命令管理 Git 仓库引用与知识沉淀
人工智能·git·ai·开源·github
har2 小时前
Claude Code Trace 可视化神器:Token 分析 + Agent 回放 + Session 对比,全有了
开源
不会编程的-程序猿2 小时前
PyCharm 直接把本地项目上传到 GitHub
ide·pycharm·github
月诸清酒3 小时前
AI 科技日报 (通义新开源模型27B参数打赢编程旗舰)
人工智能·开源
扬帆破浪3 小时前
免费开源的WPS AI插件 察元AI助手:generateMultimodalAsset:类型校验与分支派发
人工智能·开源·ai编程·wps
扬帆破浪4 小时前
免费开源的WPS AI插件 察元AI助手:installGlobalErrorLogger:启动写盘与 Vue 错误钩子
人工智能·开源·ai编程·wps
code_pgf4 小时前
PaLM-E 的改进版本及开源可行方案综述及讨论
开源·palm
OpenBayes4 小时前
强化文字渲染与海报排版:百度开源文生图模型 ERNIE-Image-Turbo;告别大模型「遗忘」:微软 OpenMementos 上下文压缩训练数据集上线
人工智能·深度学习·百度·语言模型·微软·开源