构建mini Claude Code:02 - 把 Bash 拆成专用工具(read_file, write_file 等)

构建mini Claude Code:02 - 把 Bash 拆成专用工具(read_file, write_file 等)

📍 导航指南

这是「从零构建 Claude Code」系列的第二篇。根据你的背景,选择合适的阅读路径:


目录

第一部分:为什么要拆工具 🧠

  • [v0 的局限:一把锤子打天下](#v0 的局限:一把锤子打天下 "#v0-limits")
  • [Unix 哲学:一个程序只做好一件事](#Unix 哲学:一个程序只做好一件事 "#unix-one-thing")
  • 拆分带来的四个好处

第二部分:工具粒度法则 ⚙️

第三部分:代码实现 💻

附录

  • [常见问题 FAQ](#常见问题 FAQ "#faq")

引言

上一篇我们用 1 个 bash 工具构建了最小 Agent。它能工作,但有明显的局限。

本篇的问题是:什么时候应该拆工具?拆到什么粒度?

答案来自 Unix 哲学的第二条原则:一个程序只做好一件事(Do one thing and do it well)


第一部分:为什么要拆工具 🧠

v0 的局限:一把锤子打天下

v0 只有一个 bash 工具,模型要读文件得这样做:

python 复制代码
# 模型调用 bash 读文件
{"command": "cat src/main.py"}

# 模型调用 bash 修改文件(外科手术式)
{"command": "sed -i 's/old_function/new_function/g' src/main.py"}

# 模型调用 bash 写新文件
{"command": "cat << 'EOF' > config.py\nDEBUG = True\nEOF"}

这有几个问题:

bash 复制代码
bash 工具的问题
├── 安全边界模糊   → cat ../../etc/passwd 和 cat src/main.py 一样能过
├── 意图不透明     → 日志里只有 "bash: cat src/main.py",不知道是读还是搜索
├── 模型容易出错   → sed 语法复杂,模型经常写错转义
└── 输出难以控制   → 大文件 cat 出来直接撑爆上下文

Unix 哲学:一个程序只做好一件事

Unix 工具链的设计哲学:

matlab 复制代码
Unix 工具分工
├── cat    → 只负责输出文件内容
├── grep   → 只负责模式匹配
├── sed    → 只负责流编辑
├── awk    → 只负责字段处理
└── find   → 只负责文件查找

每个工具职责单一,通过管道组合完成复杂任务:
cat access.log | grep 500 | awk '{print $7}' | sort | uniq -c

映射到 Agent 工具设计:

复制代码
Agent 工具分工
├── bash       → 只负责执行命令(git, npm, python...)
├── read_file  → 只负责读取文件内容
├── write_file → 只负责创建/覆写文件
└── edit_file  → 只负责精确替换文件中的文本

拆分带来的四个好处

1. 安全边界更清晰

python 复制代码
# bash 工具:危险命令黑名单,但路径穿越难拦截
dangerous = ["rm -rf /", "sudo", "shutdown"]
# cat ../../etc/passwd → 很难用黑名单拦截

# read_file 工具:路径沙箱,一行代码搞定
def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path

2. 模型意图更明确

css 复制代码
日志对比:

bash 工具日志:
  > bash: {"command": "cat src/main.py"}        ← 是读文件?还是检查内容?
  > bash: {"command": "cat src/utils.py"}
  > bash: {"command": "python main.py"}

专用工具日志:
  > read_file: {"path": "src/main.py"}           ← 一眼看出:读文件
  > read_file: {"path": "src/utils.py"}          ← 一眼看出:读文件
  > bash: {"command": "python main.py"}          ← 一眼看出:执行程序

3. edit_file 是关键优势

纯 bash 做精确修改很麻烦:

bash 复制代码
# bash 方式:sed 语法复杂,特殊字符需要转义,模型经常写错
sed -i 's/def old_function(x, y):/def new_function(x, y, z):/g' main.py

# edit_file 方式:直接描述「把什么换成什么」,模型不需要懂 sed
{
  "path": "main.py",
  "old_text": "def old_function(x, y):",
  "new_text": "def new_function(x, y, z):"
}

4. 输出可控

python 复制代码
# bash:输出不可控,大文件直接撑爆上下文
{"command": "cat large_file.py"}  # 可能输出 10 万行

# read_file:内置 limit 参数,精确控制读取范围
{"path": "large_file.py", "limit": 100}  # 只读前 100 行

第二部分:工具粒度法则 ⚙️

不是越细越好

既然拆工具有这么多好处,是不是应该拆得越细越好?

不是。 工具数量增加有代价:

复制代码
工具数量的代价
├── Context 消耗   → 每个工具定义都要传给模型,占用 token
├── 模型选择成本   → 工具太多,模型选错工具的概率上升
└── 维护成本       → 工具越多,代码越复杂

极端反例------把 read_file 拆成这样:

javascript 复制代码
❌ 过度拆分(错误示范)
├── read_first_line    → 读第一行
├── read_last_line     → 读最后一行
├── read_line_range    → 读指定行范围
├── read_python_file   → 读 Python 文件
└── read_json_file     → 读 JSON 文件

这不是「一个程序只做好一件事」,这是「把一件事拆成了五件事」。

黄金法则:意图驱动粒度

工具的粒度应该对齐模型的意图,而不是对齐实现细节。

复制代码
意图层面的分工(正确):
├── 我想读文件内容    → read_file
├── 我想写/创建文件   → write_file
├── 我想修改文件      → edit_file
└── 我想执行命令      → bash

实现层面的分工(错误):
├── 我想读前 N 行     → read_first_n_lines
├── 我想读后 N 行     → read_last_n_lines
└── 我想读中间 N 行   → read_middle_n_lines

判断标准:如果两个操作在模型的「思维」里是同一件事,就不应该拆成两个工具。

读文件的前 100 行和读文件的后 100 行,在模型看来都是「读文件」------只是参数不同。

参数细化 vs 工具拆分

这是工具设计中最重要的区分:

复制代码
同一个意图 → 用参数细化(不要拆工具)
不同的意图 → 用工具拆分(不要合并工具)

参数细化的正确示范:read_file

python 复制代码
# v1 基础版:只有 path 和 limit
def read_file(path: str, limit: int = None) -> str:
    ...

# 更好的版本:增加 offset 参数,支持分段读取大文件
def read_file(path: str, offset: int = 1, limit: int = None) -> str:
    """
    offset=50, limit=100 → 读取第 50-149 行
    这让模型可以「翻页」读大文件,而不是一次性读完
    """
    ...

offsetlimit 参数让 read_file 更强大,但工具的意图没有变------还是「读文件」。这是正确的细化方向。

对比工具拆分的正确示范:

yaml 复制代码
bash vs read_file → 意图完全不同,必须拆分
  bash:      执行命令,有副作用,可能改变系统状态
  read_file: 只读操作,无副作用,只返回内容

write_file vs edit_file → 意图不同,必须拆分
  write_file: 完整覆写,适合创建新文件
  edit_file:  精确替换,适合修改现有代码

工具粒度决策树:

markdown 复制代码
面对一个新操作,问自己:

这个操作和现有工具是「同一个意图」吗?
    │
    ├── 是 → 给现有工具加参数
    │         例:read_file 加 offset 参数
    │
    └── 否 → 创建新工具
              例:bash 和 read_file 意图不同,分开

第三部分:代码实现 💻

四个工具的设计

bash 复制代码
v1 工具集
├── bash       → 执行任意 shell 命令(git, npm, python...)
│               安全:危险命令黑名单
│
├── read_file  → 读取文件内容
│               安全:路径沙箱(safe_path)
│               效率:limit 参数防止上下文溢出
│
├── write_file → 写入文件(创建或覆写)
│               安全:路径沙箱
│               便利:自动创建父目录
│
└── edit_file  → 精确替换文件中的文本
                安全:路径沙箱
                精确:只替换第一次出现(防止误改)

关键实现细节

safe_path:路径沙箱

python 复制代码
def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path

# 效果:
# safe_path("src/main.py")      → ✅ 允许
# safe_path("../../etc/passwd") → ❌ 拒绝

edit_file:为什么只替换第一次出现

python 复制代码
# 只替换第一次出现,防止意外批量修改
new_content = content.replace(old_text, new_text, 1)

# 如果文件里有 3 个相同的函数名,模型应该明确指定要改哪一个
# 而不是一次性全改------那样太危险

工具分发:execute_tool

python 复制代码
def execute_tool(name: str, args: dict) -> str:
    if name == "bash":      return run_bash(args["command"])
    if name == "read_file": return run_read(args["path"], args.get("limit"))
    if name == "write_file":return run_write(args["path"], args["content"])
    if name == "edit_file": return run_edit(args["path"], args["old_text"], args["new_text"])
    return f"Unknown tool: {name}"

这个分发函数是工具层和 Agent 循环之间的桥梁。每个工具返回字符串,统一送回给模型。

完整代码

python 复制代码
#!/usr/bin/env python3
import os
import subprocess
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv(override=True)

if os.getenv("ANTHROPIC_BASE_URL"):
    os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)

WORKDIR = Path.cwd()
MODEL = os.getenv("MODEL_ID", "claude-sonnet-4-5-20250929")
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))

SYSTEM = f"""You are a coding agent at {WORKDIR}.
Loop: think briefly -> use tools -> report results.
Rules:
- Prefer tools over prose. Act, don't just explain.
- Never invent file paths. Use bash ls/find first if unsure.
- Make minimal changes. Don't over-engineer.
- After finishing, summarize what changed."""

TOOLS = [
    {
        "name": "bash",
        "description": "Run a shell command. Use for: ls, find, grep, git, npm, python, etc.",
        "input_schema": {
            "type": "object",
            "properties": {"command": {"type": "string"}},
            "required": ["command"],
        },
    },
    {
        "name": "read_file",
        "description": "Read file contents. Returns UTF-8 text.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "limit": {"type": "integer", "description": "Max lines to read"},
            },
            "required": ["path"],
        },
    },
    {
        "name": "write_file",
        "description": "Write content to a file. Creates parent directories if needed.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "content": {"type": "string"},
            },
            "required": ["path", "content"],
        },
    },
    {
        "name": "edit_file",
        "description": "Replace exact text in a file. Use for surgical edits.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string"},
                "old_text": {"type": "string", "description": "Exact text to find"},
                "new_text": {"type": "string", "description": "Replacement text"},
            },
            "required": ["path", "old_text", "new_text"],
        },
    },
]


def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path


def run_bash(command: str) -> str:
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    if any(d in command for d in dangerous):
        return "Error: Dangerous command blocked"
    try:
        result = subprocess.run(
            command, shell=True, cwd=WORKDIR,
            capture_output=True, text=True, timeout=120
        )
        output = (result.stdout + result.stderr).strip()
        return output[:50000] if output else "(no output)"
    except subprocess.TimeoutExpired:
        return "Error: Command timed out (120s)"
    except Exception as e:
        return f"Error: {e}"


def run_read(path: str, limit: int = None) -> str:
    try:
        text = safe_path(path).read_text()
        lines = text.splitlines()
        if limit and limit < len(lines):
            lines = lines[:limit]
            lines.append(f"... ({len(text.splitlines()) - limit} more lines)")
        return "\n".join(lines)[:50000]
    except Exception as e:
        return f"Error: {e}"


def run_write(path: str, content: str) -> str:
    try:
        fp = safe_path(path)
        fp.parent.mkdir(parents=True, exist_ok=True)
        fp.write_text(content)
        return f"Wrote {len(content)} bytes to {path}"
    except Exception as e:
        return f"Error: {e}"


def run_edit(path: str, old_text: str, new_text: str) -> str:
    try:
        fp = safe_path(path)
        content = fp.read_text()
        if old_text not in content:
            return f"Error: Text not found in {path}"
        fp.write_text(content.replace(old_text, new_text, 1))
        return f"Edited {path}"
    except Exception as e:
        return f"Error: {e}"


def execute_tool(name: str, args: dict) -> str:
    if name == "bash":       return run_bash(args["command"])
    if name == "read_file":  return run_read(args["path"], args.get("limit"))
    if name == "write_file": return run_write(args["path"], args["content"])
    if name == "edit_file":  return run_edit(args["path"], args["old_text"], args["new_text"])
    return f"Unknown tool: {name}"


def agent_loop(messages: list) -> list:
    while True:
        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        tool_calls = []
        for block in response.content:
            if hasattr(block, "text"):
                print(block.text)
            if block.type == "tool_use":
                tool_calls.append(block)

        if response.stop_reason != "tool_use":
            messages.append({"role": "assistant", "content": response.content})
            return messages

        results = []
        for tc in tool_calls:
            print(f"\n> {tc.name}: {tc.input}")
            output = execute_tool(tc.name, tc.input)
            preview = output[:200] + "..." if len(output) > 200 else output
            print(f"  {preview}")
            results.append({
                "type": "tool_result",
                "tool_use_id": tc.id,
                "content": output,
            })

        messages.append({"role": "assistant", "content": response.content})
        messages.append({"role": "user", "content": results})


def main():
    print(f"Mini Claude Code v1 - {WORKDIR}")
    print("Type 'exit' to quit.\n")
    history = []
    while True:
        try:
            user_input = input("You: ").strip()
        except (EOFError, KeyboardInterrupt):
            break
        if not user_input or user_input.lower() in ("exit", "quit", "q"):
            break
        history.append({"role": "user", "content": user_input})
        try:
            agent_loop(history)
        except Exception as e:
            print(f"Error: {e}")
        print()


if __name__ == "__main__":
    main()

代码仓库:shareAI-lab/learn-claude-code


常见问题 FAQ

Q: 4 个工具够用吗?Claude Code 有 20 多个工具。

A: 4 个工具覆盖 90% 的编码场景。Claude Code 的额外工具(glob、grep、web_fetch 等)是在这个基础上按需添加的,每个都有明确的意图。本系列后续会逐步引入。

Q: edit_file 只替换第一次出现,如果我想替换所有呢?

A: 这是有意为之的安全设计。如果需要批量替换,应该明确告诉模型,让它多次调用 edit_file,或者用 bash + sed。明确的意图比隐式的批量操作更安全。更完善的实现可以加 replace_all: bool = False 参数------这正是「参数细化」的正确用法。

Q: 工具描述(description)重要吗?

A: 非常重要。description 是模型选择工具的依据。"Run a shell command. Use for: ls, find, grep, git, npm, python""Run a command" 好得多------前者告诉模型什么时候该用这个工具。

Q: 为什么 bash 工具还保留?read_file 不是更安全吗?

A: bash 负责「执行」,read_file 负责「读取」。执行 git statusnpm installpython test.py 这些操作没有对应的专用工具,bash 是必要的。两者意图不同,不能合并。


📝 结语

从 v0 的一个 bash 工具,到 v1 的四个专用工具,这条演化路径清晰地体现了 Unix 哲学:

makefile 复制代码
v0: bash(一把锤子)
    ↓ 按意图拆分
v1: bash + read_file + write_file + edit_file
    ↓ 每个工具只做一件事
    ↓ 安全边界清晰
    ↓ 模型意图明确
    ↓ 日志可审计

工具设计的黄金法则:

复制代码
同一个意图 → 参数细化(不要拆工具)
不同的意图 → 工具拆分(不要合并工具)

这不只是工程规范,这是让 Agent 更可靠、更安全、更可控的根本原则。

系列导航

  • 上一篇 :[01 - LLM + Bash = 最小 OS 接口](juejin.cn/post/760875...
  • 当前: 02 - 把 Bash 拆成专用工具(read_file, write_file 等)
  • 下一篇: 03 - TodoWrite:让模型按计划执行
相关推荐
~央千澈~2 小时前
06实战处理AI音乐技术详解第一阶段:频谱破坏·卓伊凡
人工智能
Hcoco_me2 小时前
车载摄像头核心知识点结构化总结
人工智能·深度学习·数码相机·算法·机器学习·自动驾驶
henry1010102 小时前
Ansible自动化运维全攻略(AI生成)
linux·运维·python·ansible·devops
逻辑君2 小时前
根与星辰【第2章】
人工智能·程序人生
施法老农2 小时前
openclaw和opencode组合使用
人工智能
2501_926978332 小时前
提高认知的位置---存在、认知、程序的逻辑位置
人工智能·经验分享
AI袋鼠帝2 小时前
豆包2.0来了!中国版Trae免费用~
人工智能·ai编程·豆包marscode
沪漂阿龙3 小时前
LLM底层机制深度解析:从Transformer到推理优化的完整技术地图
人工智能·深度学习·transformer