手搓 AI Agent 第 4 篇:后厨扩招——AI 从此能读、能写、能改、能跑

为什么会有本系列

本系列是手搓 Agent 教学项目 Teernage/powercode 的 Python 重实现版本;为什么要做这个系列呢?是因为当我在掘金社区刷到 @不一样的少年_ 大佬的【不用框架,手搓 AI Agent】系列时,感觉写得很好,但是想深入学习时,发现代码都是 Node/TS 实现。想掌握技术细节,但看得似懂非懂,如果先学习 Node/TS,又感觉成本太高。于是就想着实现一个 Python 版本的手搓 Agent 系列,以便让自己能更深入学习 Agent 的相关技术细节。整理成博客,以便那些熟悉 Python 但不熟 Node/TS 的小伙伴能一起进步成长;当然如果你已有 Node/TS 基础,强烈建议去学习 【不用框架,手搓 AI Agent】系列,真的很棒。再次感谢 Teernage 大佬的开源分享。

本系列的源码 powercode-py,本篇对应 commit d7ecb11;如果你觉得对你有帮助,请点个 star ⭐。


上篇回顾:上一篇我们给 AI 装上了 Agent Loop,它会自己盘算"信息够没够、要不要再读一个文件"。

本篇目标:把只会"读文件"这一道菜,扩成"读、写、改、跑"四道菜,再配个前台统一调度。

一、只会看、不会改,AI 干着急

上篇结束,咱们的 AI 已经会循环读文件了,能读 README、读 pyproject、读得头头是道。

但真让它干活,还是抓瞎。你试试:

bash 复制代码
powercode "把 README.md 里那句 powercode-py 改成 powercode"

它会读一遍 README,然后一本正经地回你:"这句在 README 第 5 行,你可以手动改一下。"------它看得见,就是动不了手。

为啥?因为它工具箱里只有一道菜 :read_file。会看,不会写、不会改、更不会跑。

这一篇,我们要给它扩招后厨:加上 write_file(写/覆盖文件)、edit_file(精确改一段)、bash(跑命令)。凑齐"读、写、改、跑"四件套,它才算一个能真正动手的编程助手。

但别急着写代码,先看一个更头疼的问题。

二、菜一多,后厨就乱套

现在只有一道菜,后厨很省事。上一篇 agent.py 里,客人点啥,直接判断:

python 复制代码
if tool_call.function.name != "read_file":
    raise AgentError(f"暂时不支持工具:{tool_call.function.name}")

就一个 read_file,if 一下完事。

可一旦菜加到 4 道、10 道、几十道,代码会变成一长串:

python 复制代码
if name == "read_file":
    ...  # 读文件
elif name == "write_file":
    ...  # 写文件
elif name == "edit_file":
    ...  # 改文件
elif name == "bash":
    ...  # 跑命令

菜加到 10 道、20 道,这 if...else 就长得没法看了。更糟的是,每上一道新菜,Agent 除了管自己的循环,还得亲自记住每道菜该找谁做------菜单、厨师、循环,三摊事全搅在一起。客人(AI)跟厨师(工具)越缠越死。

这时候,就差一个"前台":客人点菜,前台按菜名喊对应厨师,做完端回来。客人不用认识每个厨师,厨师也不用知道客人坐哪桌。

这个前台,代码里叫 Registry(注册中心)。用图看是这样:

三、先看效果

写完这期代码,你敲:

bash 复制代码
powercode "创建一个 hello.py 文件,让它输出 Hello, powercode-py,然后用 python 运行它验证结果"

会看到类似这样的过程(这是我真机跑出来的,中间还翻了个车):

text 复制代码
AI 正在思考...

第 1 轮:AI 调用 write_file
✓ 已写入文件:hello.py

第 2 轮:AI 调用 bash
✗ 工具执行失败:命令执行失败(退出码 2)
python: can't open file 'D:\...\hello.py;': [Errno 2] No such file or directory

第 3 轮:AI 调用 bash
✓ Hello, powercode-py

AI:已完成并验证通过。

注意第 2 轮------模型自作聪明写了 python hello.py; echo "exit=$?" 这种 bash 语法,可我这是 Windows,实际跑命令的是 cmd,不认 ;,直接报错。**结果它看到报错后,第 3 轮老老实实改成单独一句 python hello.py,成了。**这就是"工具失败喂回模型、模型自我纠正"的作用体现。

⚠️ 免责:上面这段是 LLM 生成、非固定,你跑出来轮数可能不一样(模型可能一轮就写完再跑,也可能多绕几轮),关键是看它真去调用了 write_file 和 bash,而不是背某句话。

为了跑出这个效果,要写的代码串成一条流水线:

一句话记图:第四节定规矩(Tool 协议)+ 开前台(Registry),第五节装门禁 + 配齐四个厨师,第六节把客人、前台、后厨串起来。

四、第一步:定规矩 + 开前台

4.1 每道菜都要交齐三样

想统一调度,第一步不是写前台,而是先把"一道菜到底长啥样"定死。别管它是读文件还是跑命令,进后厨都得带上三样家当:

  1. name:叫什么(菜名,比如 read_file);
  2. definition:给客人看的菜单介绍(有啥用、参数怎么传);
  3. execute:真正干活的厨师(程序真去读写改跑)。

新建 powercode_py/tools/types.py:

python 复制代码
from typing import Protocol


class ToolError(Exception):
    """工具执行或参数校验失败。"""


class Tool(Protocol):
    """一个工具要交齐的三样东西:name、definition、execute。"""

    name: str
    definition: dict

    def execute(self, arguments_json: str) -> str:
        """真正执行工具,返回结果文本。"""
        ...

这里最要命的一个区分,是这两行:

text 复制代码
definition:给 AI 看,帮它决定要不要点这道菜
execute:给程序用,真正动手干活的厨师

一句话分清:**definition 是菜单上的字,谁都能看;execute 是后厨里的锅,只有程序能碰。**客人就算把菜单背得滚瓜烂熟,也炒不出一道菜来。

4.2 前台:Registry

菜单备好了,还得有个前台按菜名喊人。新建 powercode_py/tools/registry.py:

python 复制代码
class Registry:
    """按名字保存工具,供 Agent 统一分发。"""

    def __init__(self):
        self._tools = {}

    def register(self, tool: Tool) -> None:
        """注册一个工具;重名则报错。"""
        if tool.name in self._tools:
            raise ToolError(f"工具重复注册:{tool.name}")
        self._tools[tool.name] = tool

    def get_definitions(self) -> list[dict]:
        """返回所有工具的 definition(给模型看的工具说明)。"""
        return [tool.definition for tool in self._tools.values()]

    def execute(self, name: str, arguments_json: str) -> str:
        """根据工具名找到工具并执行。"""
        tool = self._tools.get(name)
        if tool is None:
            raise ToolError(f"找不到工具:{name}")
        return tool.execute(arguments_json)

前台一共就三个动作:

text 复制代码
register        新菜上架,登记进后厨名册
get_definitions 把整本菜单递给客人翻
execute         客人点哪道,就去喊哪个厨师

self._tools 是一本后厨名册,菜名 → 厨师一一对应,翻开就能找到人:

text 复制代码
read_file  → ReadFileTool
write_file → WriteFileTool
edit_file  → EditFileTool
bash       → BashTool

以后 Agent 循环里,只把菜名和参数甩给前台就行,厨师在哪个文件、怎么干活,它一概不用管。

五、第二步:装门禁 + 四个厨师

5.1 先装一道门禁

读、写、改三个厨师都会拿到模型给的路径。这路径不能直接用------万一模型使坏传 ../secret.txt 呢?

所以先写一个公共入口,把"碰文件"的活全关在项目里。新建 powercode_py/tools/path.py:

python 复制代码
from pathlib import Path

from .types import ToolError


def resolve_in_work_dir(work_dir: str, relative_path: str) -> Path:
    """把相对路径解析成绝对路径,并确保它仍在工作目录内。"""
    if not relative_path.strip():
        raise ToolError("文件路径不能为空。")

    root = Path(work_dir).resolve()
    target = (root / relative_path).resolve()

    if target != root and root not in target.parents:
        raise ToolError("只能操作当前项目里的文件。")

    return target

核心就这一行判断:

python 复制代码
# target 不是项目根目录本身,且项目根目录也不是 target 的上级 → 越界,拒绝
if target != root and root not in target.parents:

这道"路径越界"的检查,第 2 篇讲 read_file 时已经掰开揉碎过一遍,这里不重复。区别只有一个:以前它只护着 read_file 一个人,现在收进 resolve_in_work_dir,读、写、改三个厨师共用这一个入口,不用再各自重复写一遍。

5.2 读文件:换身工作服

上一篇的 read_file 本来是个散装的函数。这回它的本事一点不用改,只是装进类的壳里、补上 name 和 definition,让它也变成一道能上台面的标准菜。新建 powercode_py/tools/read_file.py:

python 复制代码
class ReadFileTool:
    name = "read_file"

    definition = {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取当前项目中的文本文件。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "相对于项目根目录的文件路径"},
                },
                "required": ["path"],
                "additionalProperties": False,
            },
        },
    }

    def __init__(self, work_dir):
        self.work_dir = work_dir

    def execute(self, arguments_json: str) -> str:
        path = _parse_path(arguments_json)
        target = resolve_in_work_dir(self.work_dir, path)
        if not target.exists():
            raise ToolError(f"文件不存在:{path}")
        if target.is_dir():
            raise ToolError(f"这是一个目录,不是文件:{path}")
        data = target.read_bytes()
        if len(data) > MAX_BYTES:
            head = data[:MAX_BYTES].decode("utf-8", errors="replace")
            return f"{head}\n\n...[文件太长了,只返回前 {MAX_BYTES} 个字节]..."
        return data.decode("utf-8", errors="replace")

跟上一篇比,真正读文件那一下还是 target.read_bytes(),没变。变的只是把它收进类里,交齐 name、definition、execute 三样。(_parse_path 负责把 AI 传来的 JSON 解析成 path 字符串,不展开。)

5.3 写文件:能创建、能覆盖

前面几个工具都只动眼睛,到 write_file 才第一次真正改东西。新建 powercode_py/tools/write_file.py:

python 复制代码
class WriteFileTool:
    name = "write_file"

    definition = {
        "type": "function",
        "function": {
            "name": "write_file",
            "description": "创建或完整覆盖当前项目中的文件,目录不存在时会自动创建。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "相对于项目根目录的路径"},
                    "content": {"type": "string", "description": "完整文件内容"},
                },
                "required": ["path", "content"],
                "additionalProperties": False,
            },
        },
    }

    def __init__(self, work_dir):
        self.work_dir = work_dir

    def execute(self, arguments_json: str) -> str:
        path, content = _parse_args(arguments_json)
        target = resolve_in_work_dir(self.work_dir, path)
        # 目录不存在就自动补上,AI 想一口气写 nested/deep/xx.py 也不用先建目录
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(content, encoding="utf-8")
        return f"已写入文件:{path}"

两行关键代码:

python 复制代码
target.parent.mkdir(parents=True, exist_ok=True)  # 目标目录不存在就自动建
target.write_text(content, encoding="utf-8")      # 完整写入(文件在则覆盖)

write_file 的语义是"创建或完整覆盖":文件不在就新建,在就整个重写。所以它适合"新建文件"或"模型已经掌握完整内容、要整体重写"这两种情况。

可要是让 AI 就为了改一个错别字,把整篇 README 从头到尾重抄一遍------它真干得出来,而且抄着抄着指不定把别的段落也顺手"润色"掉了。这时候就得换一把只动一小块的刀:edit_file。

5.4 改文件:只改一小段,且必须唯一

edit_file 的思路是:在文件里找到一段旧文本,换成新文本。新建 powercode_py/tools/edit_file.py:

python 复制代码
class EditFileTool:
    name = "edit_file"

    definition = {
        "type": "function",
        "function": {
            "name": "edit_file",
            "description": (
                "把文件中唯一出现的一段旧文本替换成新文本。修改前应先读取文件;"
                "如果 old_text 出现多次,请带上前后文后重试。"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "要修改的文件路径"},
                    "old_text": {"type": "string", "description": "文件中原有的完整文本"},
                    "new_text": {"type": "string", "description": "替换后的文本"},
                },
                "required": ["path", "old_text", "new_text"],
                "additionalProperties": False,
            },
        },
    }

    def __init__(self, work_dir):
        self.work_dir = work_dir

    def execute(self, arguments_json: str) -> str:
        path, old_text, new_text = _parse_args(arguments_json)
        target = resolve_in_work_dir(self.work_dir, path)
        if not target.exists():
            raise ToolError(f"文件不存在:{path}")
        content = target.read_text(encoding="utf-8")
        matches = content.count(old_text)   # 数一下 old_text 出现了几次
        if matches == 0:
            raise ToolError("没有找到 old_text,请重新读取文件后再试。")
        if matches > 1:
            raise ToolError(f"old_text 出现了 {matches} 次,请提供包含前后文的更长文本。")
        target.write_text(content.replace(old_text, new_text), encoding="utf-8")
        return f"已修改文件:{path}"

核心就三个参数:

text 复制代码
path      改哪个文件
old_text  文件里现在长啥样(要找到的那段)
new_text  要替换成啥样

最关键的是这句判断:

python 复制代码
matches = content.count(old_text)   # Python 自带 count,直接数出现次数

然后分三种情况:

text 复制代码
0 次:文件里根本没这段,要么读的内容旧了,要么 old_text 抄错了 → 报错,让它重新读
1 次:正好就这一处,放心换
N 次:撞上好几处一模一样的,分不清该改哪个 → 报错,让它把上下文写全了再来

注意:报错发生在 write_text 之前,所以匹配失败时,原文件一个字节都不会动。

这条是 edit_file 的底线:**改错地方,比不改更糟。**所以拿不准的时候,它宁可直接报错,把球踢回给模型。

5.5 跑命令:让 AI 自己验证

代码写完了不算完,得跑起来验证。这就是 bash 这道菜的活。新建 powercode_py/tools/bash.py:

python 复制代码
import subprocess

from .types import ToolError

TIMEOUT_SECONDS = 30
MAX_OUTPUT = 8_000


class BashTool:
    name = "bash"

    definition = {
        "type": "function",
        "function": {
            "name": "bash",
            "description": (
                "在当前项目目录中执行终端命令,用于构建、测试、运行代码和查看命令结果。"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {"type": "string", "description": "要执行的命令"},
                },
                "required": ["command"],
                "additionalProperties": False,
            },
        },
    }

    def __init__(self, work_dir):
        self.work_dir = work_dir

    def execute(self, arguments_json: str) -> str:
        command = _parse_command(arguments_json)
        try:
            proc = subprocess.run(
                command,
                shell=True,              # 通过系统 shell 执行,能识别 && 这类组合语法
                cwd=str(self.work_dir),  # 固定在项目目录里跑
                capture_output=True,
                text=True,
                timeout=TIMEOUT_SECONDS, # 最长 30 秒
            )
        except subprocess.TimeoutExpired as exc:
            ...

        if proc.returncode != 0:
            output = _truncate(_format_output(proc.stdout, proc.stderr))
            if output:
                raise ToolError(
                    f"命令执行失败(退出码 {proc.returncode})\n\n{output}"
                )
            raise ToolError(f"命令执行失败(退出码 {proc.returncode})")

        output = _truncate(_format_output(proc.stdout, proc.stderr))
        return output or "命令执行成功,没有终端输出。"

(_parse_command 解析命令、_format_output 合并 stdout/stderr、_truncate 截断超长输出,都是辅助函数,这里不展开。)

这里两个关键点:

  1. shell=True :让命令走系统 shell,这样 python hello.py && python test.py 这种组合语法才能被认出来。
  2. 失败时把退出码 + 输出一起抛出来:
python 复制代码
if proc.returncode != 0:
    raise ToolError(f"命令执行失败(退出码 {proc.returncode})\n\n{output}")

第三节里第 2 轮那句 bash 语法报错、第 3 轮又自己改对,靠的正是这一步:python hello.py; echo "exit=$?" 失败时,报错把退出码和 stderr 都带回来了,模型看见才知道错在哪,下一轮才改得过来。

另外加了三个保险丝:cwd(只在项目目录跑)、timeout(30 秒封顶)、_truncate(输出 8KB 截断)。但先记住:这几个是保险丝,不是保险箱,第七节细说。

六、第三步:串起来

厨师和前台都齐了,最后把三根线接上。

6.1 ChatClient:把菜单递给 AI

上一篇 chat.py 里 tools 写死成 [READ_FILE_TOOL],现在得改成接收参数。关键就 complete 多了一个 tools:

python 复制代码
def complete(self, messages, tools):
    """把完整聊天记录和工具说明发给模型,返回最新一条消息。"""
    completion = self._create(
        model=self.config.model,
        messages=messages,
        tools=tools,   # ← 菜单从外面传进来,不再写死
    )
    if not completion.choices:
        return None
    return completion.choices[0].message

ChatClient 彻底不认识具体工具了,它就一个动作:把菜单递出去、把回复收回来。

6.2 Agent:走前台,不再写 if...else

agent.py 的变化有两点。构造函数多收一个 registry:

python 复制代码
class Agent:
    def __init__(self, client: ChatClient, registry: Registry):
        self.client = client
        self.registry = registry

循环里,删掉上一篇那坨 if name == "read_file" 判断,统一走前台:

python 复制代码
for step in range(1, MAX_STEPS + 1):
    message = self.client.complete(messages, self.registry.get_definitions())
    ...
    for tool_call in tool_calls:
        name = tool_call.function.name
        print(f"第 {step} 轮:AI 调用 {name}")
        try:
            result = self.registry.execute(name, tool_call.function.arguments)
            print(f"✓ {result}\n")
        except Exception as exc:
            result = f"工具执行失败:{exc}"
            print(f"✗ {result}\n")
        messages.append(
            {"role": "tool", "tool_call_id": tool_call.id, "content": result}
        )

跟上一篇比,这段代码动的行不少,但核心是:**Agent 不再亲手碰任何具体工具,全丢给前台。**值得盯的就三行:

  1. self.registry.get_definitions() ------ 取整本菜单交给模型;
  2. self.registry.execute(name, ...) ------ 统一分发,点哪道菜都是这一行;
  3. 工具报错统一 except 收下,把报错原样塞回给模型。这就是"失败恢复":模型看见自己上一步栽在哪,下一步才换得了招。

6.3 CLI:开张,注册四道菜

最后 cli.py 里把四个厨师登记到前台:

python 复制代码
work_dir = Path.cwd().resolve()
registry = Registry()
registry.register(ReadFileTool(work_dir))
registry.register(WriteFileTool(work_dir))
registry.register(EditFileTool(work_dir))
registry.register(BashTool(work_dir))
agent = Agent(client, registry)

以后想加新工具(比如搜索、git),入口多登记一行 registry.register(...),Agent 循环那边一个字都不用动。

七、踩坑:bash 不是你以为的 bash

7.1 Windows 下,它是 cmd

shell=True 在 Windows 上跑的是 cmd,不是 bash。所以模型一旦写了 bash 语法(比如 python hello.py; echo "exit=$?"),cmd 不认那个 ;,当场翻车:

text 复制代码
✗ 工具执行失败:命令执行失败(退出码 2)
python: can't open file 'D:\...\hello.py;': [Errno 2] No such file or directory

cmd 把 hello.py; 当成文件名的一部分,所以找不到文件。好在这套"失败喂回 + 自我纠正"机制,让模型看到报错后自己换了写法,跑通了。这就是把失败结果喂回模型的价值------不是让它碰运气,是让它"看着错误改"。

7.2 它也不是沙箱

bash 能做的事,跟你这个登录用户完全一个量级。cwd、timeout、截断这几个保险丝,根本挡不住 rm -rf 或 cat ~/.ssh/id_rsa 这种操作。

所以这一版只适合:自己本地、盯着跑、练手项目。API Key 别写进项目;没提交的改动先存好再试;更别不经隔离就把这套东西暴露给别人。

真要做成能对外跑的东西,得另加容器、命令白名单、人工审批那一整套------那是后面几篇的活,这里先提醒一句。

八、这期到底干成了啥?

表面看,这期就是给后厨多雇了三个厨师。可真正值钱的,是这四个厨师从此共用一套规矩:

text 复制代码
模型(客人)
└─ Agent(循环)
   └─ 前台(Registry)
      ├─ read_file
      ├─ write_file
      ├─ edit_file
      └─ bash

每个工具都交同样的三样:name、definition、execute。以后加新能力,三步搞定:

  1. 实现 Tool 协议;
  2. 注册到前台;
  3. 让模型从菜单里学会啥时候点它。

Agent 循环一个分支都不用加。

这也是几乎所有 Agent 框架底层都藏着的一层------框架可能把名字包装得花里胡哨,但剥开了就两样东西:一套统一的工具接口,一张按名字查表的字典。

九、下集预告

现在 AI 能读、能写、能改、能跑,短任务像模像样了。

但任务一长,新毛病又冒出来:对话记录越攒越多,Token 越烧越凶,AI 干着干着还可能把最早的目标给忘了。

下一篇,给它装一块"记忆"------会话记忆 + 上下文压缩,让它记得更久,又能避免把上下文窗口撑爆。

相关推荐
Dawson Zhu34 分钟前
【LLM推理与Serving⑨】业务模型推理引擎开发:以BEV自动驾驶为例的专用推理实践
人工智能·语言模型·架构·aigc·agi
袁俪1 小时前
数字孪生协同
人工智能
海拥✘1 小时前
让 AI 修 AI 平台自己的 Bug:我用 SoduWork 给 SoduWork 提了一个 PR
人工智能·bug
挖掘狂人1 小时前
AI 把代码写得飞快,然后呢?聊聊我验收生成代码的一套流程
人工智能·程序员·ai编程
汤姆宝库1 小时前
推理服务器的调度器:连续批处理与请求生命周期
人工智能
Rocky Ding*2 小时前
【三年面试五年模拟】2026-11-05_月之暗面大模型算法岗一面、二面面经全解析
论文阅读·人工智能·深度学习·机器学习·aigc·强化学习·ai-native
Mirawork2 小时前
Mirawork v0.32.0 更新体验:会话引用、任务分享与音频转写怎么用
人工智能·音视频
lucas_AI2 小时前
模型一升级缓存全作废?DeepSeek 实测:KV Cache 跨版本直接复用,首 token 快 5 倍
人工智能
在所不辞兄2 小时前
【人工智能驱动的电催化研究-05】条件、循环与数据检查
人工智能·深度学习·神经网络·机器学习·有限元·电化学·电催化