手搓Claude Code-第二章 tool_use

第二章 tool_use

写在前面

  第二章,shareai讲述了给agent扩展工具的流程。完整代码见

https://github.com/shareAI-lab/learn-claude-code/tree/main/s02_tool_use

  我们的任务是:

  1,扩展四个工具,手把手感受整个过程

  2,构建沙箱,了解沙箱为什么重要。

  3,感受claude code处理并发的操作

  那在写之前,我们需要先把os.getcwd()换成Path.cwd(),用WORKDIR去接收它,这么做的目的是方便后续工具把它作为全局变量使用,并且Path这个包还支持许多路径拼接,方便操作。

  现在来思考一个问题,不是都说bash is all your need ,大模型通过bash一个工具就能实现所有操作,何必再写一些工具?其实后者是前者的细粒度,相当于把前者拆分成了一个个小工具,更具体的说是把一些模型能用bash实现的主流功能,专精成一个个不需要模型思考可以直接调用的硬编码。这样做的好处是什么?在这些细粒度的工具函数中,我们能够控制模型安全稳定的工作,并且还可以减少token输出。什么意思?还记得第一章末尾吗?当时只是让模型简单查看一下当前目录下的python文件,模型前后就输出了五次token,试想放到真实场景中,更复杂的任务会产生多少费用。

  所以有了这些细粒度的工具,我们不但能安全稳定的控制模型不超过规定的环境工作,还能有效的减少token的消耗。前者就是在防沙箱逃逸。

  那么开始吧,在做中去感受。


一、先写一些工具函数

  先直接复制shareAI的TOOLS,这里给出了这五个函数的说明,我们来把他们当成本章的任务。

python 复制代码
TOOLS = [
    {"name": "bash", "description": "Run a shell command.",
    "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
    {"name": "read_file", "description": "Read file contents.",
     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
    {"name": "write_file", "description": "Write content to a file.",
     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
    {"name": "edit_file", "description": "Replace exact text in a file once.",
     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
    {"name": "glob", "description": "Find files matching a glob pattern.",
     "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]

  虽然有五个工具函数,但其实只控制了三个功能(读、写和编辑文件),虽然在模型那里这些工具是没什么差别的。试想一下,当你想让模型操作一个文件,是不是需要先找到文件路径,然后拼接路径,再打开bash,最后再操作,所以真正控制的功能只有三个。逻辑如下。

  以最复杂的编辑文件的功能edit_file为例(因为可以顺带实现读和写的操作),一起来实现一遍这个流程。

  我们先来实现找文件路径的操作,需要通过通配符去寻找环境中匹配的文件。传入一个通配符,返还对应的绝对路径,可能有多个。

python 复制代码
def run_glob(pattern: str) -> str:
    """
    这个函数就是查找当前工作目录下通配符为pattern的文件
    """
    import glob as g
    try:
        results = []
        for match in g.glob(pattern, root_dir=WORKDIR):
            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
                results.append(match)
        return "\n".join(results) if results else "(no matches)"
    except Exception as e:
        return f"Error: {e}"

  解释下glob方法,传入一个通配符和根目录,返还匹配到的目录列表。然后依次检查一遍,返回绝对路径。

  再来实现拼接路径(防逃逸),保证模型始终在规定的安全环境中工作。创建一个safe_path函数。"(WORKDIR / p).resolve()"先用pathlib的"/"把根目录与传入路径拼接,再通过"resolve()"自动解析抵消".../"跳转符号、换算成操作系统真实绝对路径;通过"is_relative_to"校验,模型工作传入路径,返回经过检查的Path(绝对路径),来实现工作环境的管控。

python 复制代码
def safe_path(p: str) -> Path:
    # 把传入的p和WORKDIR拼接一下,这就是换了个包的原因,有很方便的功能。
    path = (WORKDIR / p).resolve()
    # 如果path不是WOKRDIR的子路径,则抛出异常,越界访问了。
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    
    return path

  bash工具我们在第一章实现过了,现在实现编辑文件的功能。在编辑文件之前,我们是不是需要先读到这个文件。创建一个工具用于读文件,传入路径和限制读入行数。这里的lines是一个列表,元素为要读入文件的每行的内容,如果有限制行数,则超过的行内容不进行存储。

python 复制代码
def run_read(path: str, limit: int | None = None) -> str:
    try:
        lines = safe_path(path).read_text().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}"

  接着,编辑本质是做替换。将old_text和new_text做替换后写入,那需要有一个工具函数用于写入,来实现它。传入文件路径和内容,拿到过滤后的path,不存在则创建,若已存在则覆盖。

python 复制代码
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)
        return f"Wrote {len(content)} bytes to {path}"
    except Exception as e:
        return f"Error: {e}"

  最后实现编辑文本,并且需要保证在允许的路径下进行。首先拿到safe_path过滤后的函数,判断要替换的文本肯定在文本中,然后去替换第一个匹配的内容,再写入。

python 复制代码
def run_edit(path: str, old_text: str, new_text: str) -> str:
    try:
        file_path = safe_path(path)
        text = file_path.read_text()
        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))
        return f"Edited {path}"
    except Exception as e:
        return f"Error: {e}"

  现在我们已经有了所有工具,试想一下,原先我们只有一个工具,现在变成了多个,是不是需要一个表去分发,这个分发表其实就是一个字典。

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

二、修改agent_loop

  那来修改下agent_loop的内容。我们只需要修改因tool_use而停滞的情况。先观察ToolUseBlock中需要调用的工具名,然后用handler接收分发表对应的值。

python 复制代码
if block.type == "tool_use":
                print(f"\033[33m$ {block.name}\033[0m")

                handler = TOOL_HANDLERS.get(block.name)
                output = handler(**block.input) if handler else f"Unknow: {block.name}"
                print(str(output)[:200])
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })

  接下来的三元表达式等同于:

python 复制代码
if handler:
    # handler 存在、不为空/None,解包入参调用函数
    output = handler(**block.input)
else:
    # handler 不存在,返回提示字符串
    output = f"Unknow: {block.name}" 

  至此,我们完成了第二章的全部内容,接下来创建一个文本文件测试一下。

bash 复制代码
s01 >> 将名为test_words的文件内容"我是小明"改为"我是小郭"
观察content的内容:[ThinkingBlock(signature='hirasgznou', thinking='The user wants to change "我是小明" back to "我是小郭" in the test_words file.', type='thinking'), ToolUseBlock(id='019ea094992aa272c8185779cd8c6331', caller=None, input={'command': 'powershell -Command "(Get-Content -Path test_words -Encoding UTF8) -replace \'小明\',\'小郭\' | Set-Content -Path test_words -Encoding UTF8"'}, name='bash', type='tool_use')]
$ bash
(no output)
观察content的内容:[ThinkingBlock(signature='kgzosearyr', thinking='Let me verify the change.', type='thinking'), ToolUseBlock(id='019ea094aa397df9616b5556f1ecd2c0', caller=None, input={'command': 'type test_words'}, name='bash', type='tool_use')]
$ bash
我是小郭,来自三年二班。
已完成!文件 `test_words` 中的"我是小明"已改为"我是小郭",当前内容为:

我是小郭,来自三年二班。

s01 >> 

写在最后

  可以看到模型输出很少的token就实现了编辑文件的操作。但有一点需要强调,输出很少token不代表agent_loop有很少轮,上述例子只是巧合,但token总归消耗是少的。

  试想一下,在run_bash中我们有这样的检查,这只是象征性的拦截,在实际中如何保证permission?

python 复制代码
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    if any(d in command for d in dangerous):
        return "Error: Dangerous command blocked"

  此外,当agent尝试读写编辑一个或多个文件时,如何实现并发?

相关推荐
阿聪谈架构2 小时前
第14章:多模态AI实战 —— 让AI"看懂"图片和文档
人工智能·后端
心.c2 小时前
AI Agent 的新战场:从会动手,到被允许动手
人工智能·ai
geminigoth2 小时前
python入门三:字典、输入、while循环
开发语言·python
救救孩子把2 小时前
89-机器学习与大模型开发数学教程-8-7 本书总结与展望
人工智能·机器学习
X54先生(人文科技)2 小时前
ELR-SELLM 碳硅光阴协同演进系统架构文档
人工智能·深度学习·系统架构·开源协议
云烟成雨TD2 小时前
Spring AI 1.x 系列【39】MCP Java SDK 与 Spring AI 集成
java·人工智能·spring
继续商行2 小时前
性能优化的工程美学与极致追求
人工智能
超梦dasgg2 小时前
详细讲解 AI 上下文(Context)
人工智能·状态模式
救救孩子把2 小时前
87-机器学习与大模型开发数学教程-8-5 微分方程与神经微分方程(Neural ODEs)
人工智能·机器学习