Day52|从0学习Claude Code(二):从一台机床到一个工具箱,循环一行没改

苦猿的大模型日记 · Day52 · 从0学习Claude Code(二)工具分发-帮普通人把AI学进简历系列

前言:第二篇,给模型长出双手

上一篇,咱们把 Claude Code 的心脏拆了出来:一个 while 循环、一个 bash 工具、一本只追加的账本,142 行 Python,模型就能在你的终端里自己敲命令、自己看报错、自己修到通。

但那台内核有个一眼可见的糙:模型手里只有一把 bash 。想读文件,得拼 cat;想写文件,得拼 echo;想改文件,得拼 sed。模型脑子里想的是"把这段代码写进文件",出口却只有一条 shell 命令可走。

这一篇就干一件事:把 1 个工具扩成 5 个 ------bash、read_file、write_file、edit_file、glob。过程中你会亲眼看到一个有点反直觉的事实:工具翻了五倍,主循环一行都不用改

上一篇结尾我留了四个问题:echo 写文件到底怎么翻车的?模型会不会一轮同时调好几个工具?同时调的工具会不会互相踩?工具的 description 到底要怎么写模型才"懂事"?这篇全部还掉。

读完你会拿到三样东西:

  1. 查表分发这套架构:往后加任何新工具,都是"加两行"的事,循环永不再动
  2. 四个文件工具的完整实现,外加一道把路径锁死在工作区里的安全围栏
  3. 多工具调用的真实行为观察,和一套写工具描述的实用心法

门槛不变:会 Python 基础语法、有上篇跑通的那份代码。直接开始。


PART 01:还债------用 echo 写文件,到底有多容易翻车

先还第一笔债:给 bash 当唯一工具,写文件到底有多痛。

看一个真实场景。你对上一版的 Agent 说:

复制代码
帮我创建一个 config.py,内容是:MSG = "It's a $test"

模型给出的命令大概率长这样:

复制代码
echo 'MSG = "It'"'"'s a $test"' > config.py

没看懂这串鬼画符?正常,我也看不懂。它想用单引号包裹整体,但内容里恰好有个单引号,于是 shell 语法要求把字符串切碎、中间单独转义、再拼回去。这是一个会呼吸的 bug:引号少一层,文件内容就坏一段。

就算模型侥幸拼对了,$test 这一关还在后面等着------双引号里 $test 会被 shell 当变量展开,写进文件的实际是 MSG = "It's a "$ 没了,变量名没了,你跑代码之前根本发现不了。

最阴险的就在这:bash 写文件,坏了是不报错的。 命令执行成功、退出码为 0、工具返回"(no output)",模型满心以为写好了,直到哪天程序跑炸,你翻出文件一看,内容早被 shell 搞得面目全非。报错不可怕,报错是反馈;静默出错才可怕,它把坑埋进未来

多行内容更是一场灾难。echo 天生只吃单行,想写个十行的脚本,模型得拼十条命令、或者玩 -e\n 转义,每一步都在给出错概率充值。

把这些坑摆在一起,你会发现它们其实是同一个病:

模型想的是"写这些内容",却被逼先当一遍 shell 语法翻译官。 意图和动作之间,隔了一整层命令行转义。每次翻译,都是一次出错机会;每次防错,都是一圈多余 token。

读文件同理:模型想要"前 50 行",cat 给不了,得 head -n 50;想知道"改哪一行",sed 的正则方言分分钟能让模型当场翻车。

解法已经写在问题里了:它想读,就直接给它 read;想写,就直接给它 write;想找文件,就直接给它 glob。 让意图直达动作,把翻译层整个拆掉。

怎么拆,而不把上篇写好的循环拆坏?看下一部分,一场只动一行的手术。


PART 02:核心手术------循环一行不动,把硬编码换成查表

先把上篇的主循环请回来,盯住工具执行那两行:

复制代码
for block in tool_calls:
    print(f"$ {block.input['command']}")
    output = run_bash(block.input["command"])

问题就出在 run_bash硬编码的------写死了"任何工具都去调 bash 的执行器"。现在要加四个新工具,你当然可以写成这样:

复制代码
if block.name == "bash":
    output = run_bash(**block.input)
elif block.name == "read_file":
    output = run_read(**block.input)
elif block.name == "write_file":
    output = run_write(**block.input)
# ...每加一个工具,再加一个 elif

能跑,但丑。五个工具五条 elif,五十个工具五十条,这条链会越长越臃肿,而且每一段都在重复同一件事:拿名字找函数

"拿名字找函数",Python 里有个现成的数据结构就是专门干这个的------字典:

复制代码
TOOL_HANDLERS = {
    "bash":       run_bash,
    "read_file":  run_read,
    "write_file": run_write,
    "edit_file":  run_edit,
    "glob":       run_glob,
}

循环里那两行硬编码,换成两行查表:

复制代码
for block in tool_calls:
    handler = TOOL_HANDLERS.get(block.name)
    output = handler(**block.input) if handler else f"Unknown: {block.name}"
    results.append({
        "type": "tool_result",
        "tool_use_id": block.id,
        "content": output,
    })

手术到此结束。 while True 没动、tool_use 判断没动、账本追加没动、退出条件没动------上一篇拆的三个机制原封不动,变的只是"工具怎么找到自己的执行器"这一处。

一个字典,就是一块插座板

我喜欢把 TOOL_HANDLERS 想象成一块插座板:每个插座贴着工具名,插上什么电器,就有什么功能。主循环是墙里固定的电线,从来不问插座上插的是什么。

这里面还有个值得单独拎出来的细节:**block.input

模型调工具时,给的是一段 JSON,比如 {"path": "a.py", "limit": 50}。而 run_read 的函数签名是 run_read(path, limit=None)**block.input 一展开,JSON 的 key 直接变成函数的关键字参数------模型给的 JSON 和 Python 函数入参,天然对齐 。你不需要写任何"参数翻译"代码,工具的 input_schema 定义成什么样,函数签名照着写就行。

加一个工具 = 在两个地方各加一行

现在,给这台 Agent 加新工具的完整流程,收敛成了两件事:

  1. TOOLS 数组加一条 schema ------这是给模型看的,告诉它"你有这个能力、参数长什么样"
  2. TOOL_HANDLERS 字典加一行映射 ------这是给代码用的,工具名来了找谁执行

一个管"告示",一个管"接线",两个注册点各司其职。

但正因为是两个地方,就埋了一个新的坑:它们必须保持同步 。如果你在 TOOLS 里告诉模型"你有 write_file",却在 TOOL_HANDLERS 里忘了注册,会发生什么?模型兴冲冲申请调用,查表查到一个空------这就是上面代码里 if handler else f"Unknown: {block.name}" 的作用:把"没接线的插座"变成一句返回给模型的错误文本,而不是让整个程序当场崩溃。

注意这个处理方式的味道:错误不是抛给程序员看的,是塞回给模型看的。 模型收到 "Unknown: write_file",下一圈会自己换 bash 绕路完成任务。又呼应了上篇那句话------报错是模型的眼睛。

这行查表,就是扩展性的种子

别小看这次替换。if-else 到字典的升级,表面上是从"五条分支"变成"五行映射",实质是把加工具的成本从"改代码"降到了"填表格"------改代码要理解逻辑,填表格只需要守格式。

真实 Claude Code 里那个庞大的工具生态------读写编辑、搜索、子代理、MCP 服务器带进来的各种外部工具------底层就是这同一个模式:循环不变,注册表往里加条目。你后面接 MCP 插件时会看到,挂一个新 MCP 服务器,本质就是往这张表里动态塞进一批新映射,模型立刻就会用了。

心脏一次到位,之后只长器官,不动心脏。这是整个复刻系列最重要的架构基调,这一篇正式立起来了。


PART 03:四个新工具逐个拆------每个都短,但每个都有讲究

插座板装好了,该造电器了。四个新工具,一个一个拆,每个都附一段值得停下来想的设计点。

read_file:把"看多少"的控制权交给模型

复制代码
def run_read(path: str, limit: int | None = None) -> str:
    try:
        lines = safe_path(path).read_text(encoding="utf-8").splitlines()
        if limit and limit < len(lines):
            lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
        return "\n".join(lines)
    except Exception as e:
        return f"Error: {e}"

核心就一行 read_text,讲究在 limit 参数:模型可以只要前 50 行。超出的部分不硬砍,而是补一行 ... (3200 more lines)------截断要留痕,让模型知道文件还有多少没看,想看随时再来。

回忆一下上篇 bash 执行器里那个"输出截断到 50000 字符"的创可贴:那是工具替模型做主、一刀切到固定长度。现在 limit 把裁量权还给了模型自己------同一个问题的两种解法,从"被动保险"进化成"主动可控"。

write_file:PART 01 那些坑的总清算

复制代码
def run_write(path: str, content: str) -> str:
    try:
        file_path = safe_path(path)
        file_path.parent.mkdir(parents=True, exist_ok=True)
        file_path.write_text(content, encoding="utf-8")
        return f"Wrote {len(content)} bytes to {path}"
    except Exception as e:
        return f"Error: {e}"

看着平平无奇对吧?全部的威力在于 content 是一个结构化参数,而不是 shell 字符串。

模型调这个工具时,想写什么就原样放进 JSON 的 content 字段------带单引号?带双引号?带 $ 符号?带一百个换行?统统原样落盘,从头到尾没有一个字符需要转义。PART 01 里那场引号嵌套的噩梦,在这套机制下根本没有发生的土壤,因为 shell 根本不在场。

mkdir(parents=True) 也顺手把"目录还不存在"这种琐碎失败消掉了------写 src/utils/helpers.py 时自动建好两级目录。工具的返回信息也值得学:Wrote 523 bytes to config.py成功也要给回执,写入了多少字节、写到哪,模型下一圈心里有数。

edit_file:精确替换一次,找不到就明说

复制代码
def run_edit(path: str, old_text: str, new_text: str) -> str:
    try:
        file_path = safe_path(path)
        text = file_path.read_text(encoding="utf-8")
        if old_text not in text:
            return f"Error: text not found in {path}"
        file_path.write_text(text.replace(old_text, new_text, 1), encoding="utf-8")
        return f"Edited {path}"
    except Exception as e:
        return f"Error: {e}"

改文件的思路不是 sed 正则,而是最笨也最稳的:给原文,给新文,精确替换第一处

为什么"笨"反而是对的?想一下 sed 的方案:模型得生成一段正则,正则本身又是一层"翻译",转义翻车的故事在 regex 上重演一遍;而且正则是模糊匹配,.* 手一滑,误伤范围根本不可控。

精确文本替换没有这个问题:old_text 在文件里逐字找,找到才改,找不到立刻报 "Error: text not found" 。注意这个报错的去向------它不是抛给用户的,是塞回给模型的。模型收到"原文没找到",下一圈自然会先 read_file 看一眼文件现状,再修正 old_text 重试。改错了(比如找到了两处相似文本)也只动第一处,replace(..., 1) 的那个 1 就是爆炸半径上限。

一个"精确匹配、失败即反馈、爆炸半径有限"的编辑原语,比一个功能强大但行为含糊的正则机器,对 Agent 友好得多。给模型用的工具,确定性永远优先于表现力。

glob:找文件,顺便管住自己的嘴

复制代码
def run_glob(pattern: str) -> str:
    import glob as g
    try:
        matches = sorted({
            match for match in g.glob(pattern, root_dir=WORKDIR, recursive=True)
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
        })
        shown = matches[:200]
        if len(matches) > 200:
            shown.append("... (more matches omitted; narrow the pattern)")
        return "\n".join(shown) if shown else "(no matches)"
    except Exception as e:
        return f"Error: {e}"

模式匹配找文件,**/*.py 递归捞出全部 Python 文件。两个细节:结果排序去重,保证同样的问题每次得到同样的答案------工具输出越确定,模型行为越稳定;超过 200 条就截断并提示"收窄你的 pattern"------又是"截断留痕",跟 read_file 一个家风。

safe_path:一道只围了四分之三的围栏

你可能注意到上面每个函数的第一步都是 safe_path(path),它是什么:

复制代码
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

三步:把相对路径接到工作目录下、resolve() 展开所有 .. 和软链、然后检查结果还圈在工作区里吗 。模型想调 read_file("../../etc/passwd").. 被 resolve 展开后路径落在工作区外面,直接抛错。绕、编码、层层跳转,都逃不过 resolve 这一关------不看你说去哪,只看你实际到了哪

但我要指着这个设计里的一个洞,大声念三遍:

safe_path 只保护四个文件工具,bash 完全不设防。

模型想读工作区外的文件?read_file 被拦。但它转头调 bash 跑一句 cat /etc/passwd,畅通无阻;rm -rf ~/重要目录 同样拦不住------上篇那个粗糙黑名单,依然是目前唯一的防线。

这不是疏忽,是节奏:先把"工具分发"这个机制立起来,权限系统留到下一篇专门拆。但你必须现在就知道这个洞在哪,每个工具各自为政的安全检查,挡不住那条绕开所有检查的通用通道。这是安全设计里反复出现的教训,记住它,下一篇看权限系统时会更有感觉。


PART 04:跑起来------看模型怎么用新工具

代码拼完,跑:

复制代码
python code.py

四个实录,重点看模型行为的细节。

实录一:读文件,它不再绕 bash 了

复制代码
读一下 README.md 前 30 行,讲讲这个项目是干嘛的

终端输出:

复制代码
> read_file
(前 30 行内容)
This project is a minimal Claude Code clone...

模型直接调了 read_file,参数 {"path": "README.md", "limit": 30}------没有 cat,没有 head,没有管道 。更妙的是那个 limit: 30:你只说"前 30 行",它自己就把这个数填进了参数。意图直达动作,一步到位。

实录二:还债时刻------那段刁钻内容,一发入魂

把 PART 01 里翻车的那段内容原样再要一次:

复制代码
创建 config.py,内容是:MSG = "It's a $test",再创建一个 10 行的 demo.py,
里面每行都打印一句话,两个文件都写完

模型这轮调了两次 write_filecontent 参数里原封不动地躺着单引号、双引号、$ 符号、十行换行------落盘的文件和它的意图逐字节一致。上一版引号嵌套的鬼画符,这一版根本没有出场机会。

跑一下 python config.py,输出 It's a $test,完整无缺。债,清了。

实录三:一轮多工具------它真的会一拳打出三张牌

这是本篇最有观察价值的一刻。输入:

复制代码
读 README.md 和 requirements.txt,再列出目录下所有 py 文件

盯着终端,模型这一轮同时发出了三个申请:

复制代码
> read_file    (README.md)
> read_file    (requirements.txt)
> glob         (**/*.py)

一轮回复里塞了三个 tool_use 块。上一篇欠的第二个问题有了答案:会,一轮多工具是模型的原生行为 ,不用你做任何特殊支持------只要 API 响应里出现多个 tool_use 块,你的循环自然就逐个处理了。

那第三个问题------会不会互相踩?也不会,原因简单得出人意料:

复制代码
for block in tool_calls:
    handler = TOOL_HANDLERS.get(block.name)
    output = handler(**block.input)

这个 for串行 的:第一个 read_file 执行完、拿到结果,才轮到第二个,最后才是 glob。全程一条车道,没有任何两个工具同时碰文件系统,没有并发,就没有竞态 。三个结果各自带着自己的 tool_use_id,像三张不同编号的回执,配对塞回账本,下一圈模型一次性读到三份结果,合并推理。

当然,串行的代价是慢------三个互不依赖的读取,本可以同时跑。真实 Claude Code 后来确实把互不依赖的工具调用改成并发执行,那是"提速"的需求倒逼出来的复杂度;而我们的原则不变:先把正确的跑通,再让快的起飞。初学者直接上并发,九成时间在调锁的 bug,不在理解机制。

实录四:分寸感------五个工具之间的选择

多玩几轮,你会看到模型在五个工具之间自如切换:读文件用 read_file、改文件用 edit_file、找文件用 glob,但"删掉 test 目录"它转身就抄 bashrm -rf test/)------因为工具箱里没有"删除"这个原语,bash 就是那个万能的兜底。

专用工具管常用高频路径,bash 兜住长尾。这个组合的分工感,跟 Claude Code 本尊如出一辙:它也有自己的一整套专用读写编辑工具,但遇到没覆盖的场景,从不犹豫切 bash。

最后照例一句严肃提醒:bash 依旧不设防,safe_path 围栏管不到它。继续在临时目录里玩,别拿有重要文件的目录做实验田。


PART 05:description 才是模型的"工具说明书"

最后一个债,也是最值钱的一个:工具的 description 到底要怎么写。

先站到模型视角想一件事:它面对工具列表时,能看见什么?三样------工具名、description、参数 schema。名字是缩写,schema 是骨架,description 是唯一能说人话的地方。模型决定"这个活儿用哪个工具、参数怎么填",依据几乎全在这一句描述上。

所以写 description 不是写注释,是写一份影响模型决策的行为契约

一个正例的解剖

看 edit_file 的描述,全文十一个词:

复制代码
Replace exact text in a file once.

"exact"和"once"这两个词,每个都是承诺。"exact"告诉模型:old_text 必须逐字从文件里抄,不许自己凭记忆概括------模型最容易犯的错就是"我以为文件里是这么写的";"once"告诉模型:只替换第一处,想改多处就多调几次,别指望一条调用通杀。

如果你把它改成一句废话版:"Edit a file."------会发生什么?模型会把它当万能改写器:old_text 随手一编、指望着模糊匹配、一处不中就反复重试同一个错的入参。工具不会变笨,是说明书把它用笨了。

从中可以拧出一条通用写法:

好描述 = 什么时候用(触发场景)+ 边界在哪(行为契约)。"它是什么"这种词典式定义,信息量接近于零。

再对照看这一版全部五个的描述,感受一下分寸:

复制代码
{"name": "bash",      "description": "Run a shell command."},
{"name": "read_file", "description": "Read file contents."},
{"name": "write_file","description": "Write content to a file."},
{"name": "edit_file", "description": "Replace exact text in a file once."},
{"name": "glob",      "description": "Find files matching a glob pattern; ** matches recursively."},

bash 只有一句"Run a shell command"------因为它的适用场景是"一切",写场景反而画蛇添足;glob 那句特意补了"** matches recursively"------因为这个语法细节模型拿不准,而 description 正是递知识给模型的正规渠道。长短不是重点,命中模型的"决策盲区"才是。

工具多了,描述的权重只会更高

上一篇聊过"为什么只给一个 bash",答案是选择负担最小。现在工具到了五个,那个负担正式回来了:模型每轮都要在五个选项里做一次选择。五个还好,五十个呢?

真实 Claude Code 挂着几十个内置工具,还要动态接入 MCP 带来的外部工具,靠什么不乱?靠的就是把 description 当正经工程做:什么工具什么时机暴露、描述怎么分层引导、不常用的怎么收起来按需检索------这些机制后面拆到对应章节会展开。今天你只需要带走这条主线:工具列表是模型的"能力菜单",description 是每道菜的说明,菜单越长,说明写得越较真。

顺带一个彩蛋级的细节。上篇系统提示是:

复制代码
Use bash to solve tasks.

这一版悄悄改成了:

复制代码
Use tools to solve tasks.

一个词的差别,世界观换了:从"你有一把锤子"变成"你有一个工具箱"。模型对自身能力的认知,一大半来自这些不动声色的小地方。


结尾:心脏不变,器官开始生长

回顾这一篇干了什么:从一个"echo 写文件翻车"的痛点出发,给内核做了场只动一行的手术------硬编码的 run_bash 换成 TOOL_HANDLERS 查表分发,然后造出 read/write/edit/glob 四个专用工具,围上一道 safe_path 栅栏,最后亲眼看到模型一轮打出三张牌、串行执行不互踩、在五个工具间自如分工。

两篇连起来看,一条主线浮出来了:循环从第一天写完就再也没改过,改的只是插座板上插了多少工具。 心脏一次到位,之后只长器官------这是 Claude Code 这类系统能从 142 行长成庞大产品还不崩盘的根本原因。

模型的手有多巧,一半写在权重里,另一半写在你的工具描述里。工具箱给到什么份上、每条说明写到多诚实,决定了它发挥出几成。

互动时间 :作业------跑通这一版,然后构造一个能让模型一轮同时调 3 个以上工具的任务,截图发到评论区,比比谁的任务设计得更刁钻。留个讨论题:你觉得给模型写工具描述,最致命的失误是"写得太模糊"还是"写得太啰嗦"?都有什么后果?评论区聊聊。


下一篇预告:「从0学习 Claude Code」第三篇------权限系统 。这一篇留下了个明晃晃的洞:safe_path 只围住四个文件工具,bash 完全不设防,rm -rf 照跑不误。下一篇在工具执行之前加一道门:哪些操作直接放行、哪些直接拦下、哪些要先问用户一句"确定吗"?Claude Code 那个让人又爱又恨的审批弹窗,底层就是这张门禁表。上篇说"先有自由,再上枷锁"------枷锁来了。

--- END ---

苦猿 · 帮普通人把 AI 学进简历

相关推荐
richard_first18 分钟前
从 ChatGPT 到机器人:NVIDIA Jetson Orin Nano 2 背后的 Physical AI 浪潮
人工智能·chatgpt·机器人
余俊晖19 分钟前
Self-OPD:去掉教师机的流匹配模型 On-Policy 蒸馏
人工智能·算法·机器学习
手写码匠20 分钟前
华为云Flexus+DeepSeek征文|华为云MaaS DeepSeek推理服务 × Flexus云服务器 × Dify一键部署:性能评测实战
人工智能·深度学习·算法·aigc
tzc_fly22 分钟前
Claude Science设计哲学:把 AI Agent 设计成可校准的科研仪器
人工智能
进击的横打30 分钟前
【人工智能】像管理团队一样管理 AI
人工智能
长江后浪博客36 分钟前
陶瓷喷墨 RIP 中的 8 色 ICC Profile 技术原理与 LittleCMS 实现
人工智能·色彩管理·陶瓷喷墨·littlecms·icc profile
江畔柳前堤36 分钟前
字节跳动产品全景图:从应用表象到技术深海的七层解剖
人工智能·chatgpt·架构·json·batch
山西茄子39 分钟前
【无标题】
人工智能
Fxkj88842 分钟前
企业新媒体IP陪跑真实价值解析:合作体验与效果评判标准
大数据·人工智能·tcp/ip·媒体