手写一个 Claude Code(1):从 Agent Loop 到工具、权限、Hooks 与任务规划

最近在学 AI Agent 开发,我把 learn-claude-code 的前五章整理成了这篇学习笔记:从最小的 Agent Loop 出发,逐步加入工具分发、权限检查、Hooks 和 TodoWrite,理解一个编码 Agent 的运行骨架。

**读完你会得到什么:**知道模型如何提出工具调用、程序如何执行并回传结果,以及如何在循环周围增加权限、扩展点和计划状态。

本文讲的是开源课程中的教学实现,不是 Claude Code 官方源码或完整复刻。文中代码为核心节选,需结合仓库中的完整脚本运行;不含真实 API 调用的性能评测结论。

零、先把环境跑起来

建议使用 Python 3.10+。以下命令以 macOS/Linux 终端为例,五章使用同一套依赖:

复制代码
git clone https://github.com/shareAI-lab/learn-claude-code
cd learn-claude-code
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
cp .env.example .env

在本地编辑 .env,填写 ANTHROPIC_API_KEY 和 MODEL_ID。如使用兼容服务,还需设置其文档指定的 ANTHROPIC_BASE_URL,并使用该服务提供的密钥及模型标识。不要把真实密钥写进代码、截图或提交到 Git。

在独立的练习目录启动,而不是直接让 Agent 修改课程源码:

复制代码
mkdir -p practice-workspace
cd practice-workspace
python ../s01_agent_loop/code.py
# 输入 q 退出,再依次启动其他章节
python ../s02_tool_use/code.py
python ../s03_permission/code.py
python ../s04_hooks/code.py
python ../s05_todo_write/code.py

这些脚本以启动时的当前目录作为工作区。独立目录能减少误改源码的机会,但目录不是沙箱:Shell 仍可能访问目录之外的资源。更严格的实验应在不挂载敏感目录的容器或隔离环境中进行。

首次可以输入:"在当前目录创建 hello.py,输出 Hello Agent,运行它并解释结果。"预期观察到的是"模型提出调用 → 程序执行 → 结果回传 → 模型继续"的闭环;具体调用顺序取决于模型。

版本说明:本文对照课程提交 0dcafa2 中的 s01_agent_loop~s05_todo_write 目录整理。仓库同时保留了 agents/、docs/ 下的另一套章节编号,请以本文给出的目录名为准。


一、先校准一个认知:Agent 产品 = 模型 + Harness

动手之前,先把课程开篇的一个观点搬过来,因为它决定了后面所有代码的写法:

课程强调:模型提供感知、推理和决策能力,Harness 提供它实际工作的环境。 这是一种帮助划分工程职责的视角,并不意味着工作流编排、提示词和外部状态没有价值。

但一个能干活的 Agent 产品,光有模型不够。模型是驾驶者,harness 是载具------工具、知识、观测接口、执行接口、权限边界,全都在 harness 这一层:

复制代码
Harness = Tools + Knowledge + Observation + Action + Permissions

为了理解编码 Agent,可以把常见机制概括成下面这张清单;它是教学上的抽象,不代表官方产品的完整内部实现:

复制代码
Coding Agent ≈ agent loop + 工具 + 按需技能加载 + 上下文压缩
            + 子 agent + 任务系统 + 权限治理 + hooks + memory + MCP

本文聚焦其中五个基础机制:循环、工具、权限、Hooks 和计划管理。子 Agent、技能加载和上下文压缩留到后续讨论。


二、s01 Agent Loop:一个循环 + 一个工具 = 一个 Agent

2.1 问题:模型不会自己"接着干"

你问大模型"帮我列一下目录里的文件,然后执行 xxx.py"。它能输出一条 bash 命令,但输出完就停了------不会自己执行,也看不到执行结果。你只能手动跑一遍、把输出贴回去、等下一条命令、再跑一遍......

每一个来回,你都是中间层。 把这个中间层自动化,就是 Agent 的全部起点。

2.2 解法:while True + tool_use 判断

核心判断逻辑只有一张表:

响应里的信号 含义 循环动作
包含 tool_use block 模型要调工具 执行 → 结果喂回去 → 继续循环
不包含 tool_use block 本轮没有工具请求 教学实现退出循环

翻译成代码,核心节选如下(client、MODEL、SYSTEM、TOOLS 和 run_bash 由完整脚本提供):

复制代码
def agent_loop(messages: list):
    while True:
        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})

        # 教学约定:没有工具请求就结束本轮循环
        tool_calls = [b for b in response.content if b.type == "tool_use"]
        if not tool_calls:
            return

        # 执行每个工具调用,收集结果
        results = []
        for block in tool_calls:
            output = run_bash(block.input["command"])
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": output,
            })

        # 工具结果作为 user 消息喂回去,循环继续
        messages.append({"role": "user", "content": results})

配套的 bash 工具包含示意性的拒绝列表、120 秒超时和输出截断。下面保留异常处理,避免超时直接打断示例:

复制代码
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:
        r = subprocess.run(
            command, shell=True, cwd=os.getcwd(),
            capture_output=True, text=True, errors="replace", timeout=120,
        )
        out = (r.stdout + r.stderr).strip()
        return out[:50000] if out else "(no output)"
    except subprocess.TimeoutExpired:
        return "Error: Timeout (120s)"
    except OSError as exc:
        return f"Error: {exc}"

核心循环只需要几十行;完整程序还需要客户端初始化、工具声明、执行函数和命令行入口。分工非常清晰:

  • 模型负责决策------要不要调工具、调哪个、什么时候停;
  • harness 负责执行 ------真跑命令,把结果塞回 messages。

这里的 shell=True 会让 Shell 解释模型生成的命令。s01 已有简单拒绝列表,但它和 s03 的权限示例都不能替代真正的隔离机制。

一次可能的执行轨迹是 ls → 写入 hello.py → python hello.py → 总结结果。轨迹不是固定流程,模型也可能直接创建并执行文件。

2.3 比 while True 更重要的是消息协议

先把包含 tool_use 的完整 assistant 响应加入历史,再把对应的 tool_result 作为下一条 user 消息回传。每个结果的 tool_use_id 必须匹配原调用的 id;这里的 user 角色是在承载工具结果,不代表又有一个人输入了指令。

同一响应中可能有多个调用,应该逐个处理并返回对应结果。未知工具、执行失败和权限拒绝也应给出明确结果,避免调用与结果失配。生产实现还应区分 stop_reason、输出截断和网络失败;"没有工具调用"不等于"任务已经验证成功",max_tokens=8000 也不是整个任务的预算。


三、s02 Tool Use:用分发表扩展工具

3.1 只有 bash 的痛点

模型想的是"读这个文件",却被迫翻译成 cat path/to/file;想写文件得拼 echo "..." >。多一层翻译,浪费 token,还容易拼错。

3.2 解法:dispatch map 查表分发

s02 加了 4 个专用工具(read_file / write_file / edit_file / glob),循环里唯一的变动是把硬编码的 run_bash() 换成查表:

复制代码
# 将执行位置改为工具分发
handler = TOOL_HANDLERS[block.name]   # 查表
output = handler(**block.input)       # 调用

而 TOOL_HANDLERS 就是个普通的字典:

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

新增工具需要实现 handler、补充 TOOLS 中的 JSON Schema,并注册到分发表。建立通用分发后,通常不必再改循环的控制结构。 这就是开闭原则在 Agent 架构里的样子。

两个实现细节值得抄走:

① safe_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

② 多工具调用 ------模型经常一次返回多个 tool_use(比如"读 a.py 和 b.py 再列出所有 .py"),按 response.content 的原始顺序逐个执行即可,不用你自己搞并发。

专用工具让"读文件""替换一段文本"等意图直接对应函数调用,减少手工拼接 Shell 命令的需要,但不能据此断言模型出错率一定下降。运行时仍需校验参数、处理未知工具和捕获执行异常。

safe_path 只约束使用它的文件工具,不能限制 bash 内部的行为,也不能消除路径检查与实际打开文件之间的竞态。


四、s03 Permission:先划边界,再给自由

4.1 问题

s02 的 Agent 有 5 个工具了,文件工具有 safe_path 检查,但 bash 仍只有简单字符串拦截------让它"清理一下项目",它真可能给你 rm -rf。

安全边界必须由代码 负责,而且判断要发生在工具执行之前。

4.2 解法:三道闸门的权限管线

每个工具调用都经过权限判断,但只有命中审批规则时才需要询问用户:

复制代码
tool_use → 拒绝列表:命中则拒绝
         → 审批规则:未命中则允许;命中则询问用户
         → 用户批准后执行;拒绝则回传拒绝结果

闸门 1:硬拒绝列表,命中就没得商量:

复制代码
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if=", "> /dev/sda"]

def check_deny_list(command: str) -> str | None:
    for pattern in DENY_LIST:
        if pattern in command:
            return f"Blocked: '{pattern}' is on the deny list"
    return None

闸门 2:规则匹配 ,描述"什么情况需要问人"。比如用正则识别独立的 rm/del 命令(注意不会误伤 model、delimiter 这种词):

复制代码
DESTRUCTIVE_COMMAND_WORD = re.compile(
    r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
)

def contains_destructive_command(command: str) -> bool:
    return bool(DESTRUCTIVE_COMMAND_WORD.search(command))

PERMISSION_RULES = [
    {"tools": ["read_file", "write_file", "edit_file"],
     "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
     "message": "Access outside workspace"},
    {"tools": ["bash"],
     "check": lambda args: contains_destructive_command(args.get("command", "")) or
               any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
     "message": "Potentially destructive command"},
]

闸门 3:用户审批,终端暂停,等你按 y/N。

在工具执行前接入权限判断,并补上拒绝分支:

复制代码
for block in tool_calls:
    if not check_permission(block):                # ← s03 新增
        results.append({"type": "tool_result",
                        "tool_use_id": block.id,
                        "content": "Permission denied."})
        continue
    output = TOOL_HANDLERS[block.name](**block.input)   # s02 原有

有个细节很讲究:被拒绝也要把 "Permission denied." 作为 tool_result 喂回给模型,而不是静默丢弃。这样模型能理解限制,选择获准的替代方案或向用户说明无法完成;不应把拒绝理解成可以绕过同一权限去重试。

⚠️ 教学诚实度拉满的一点:课程明确说了拒绝列表用简单字符串匹配只是示意闸门的位置,不能当完整安全边界。生产环境要上真正的命令解析和沙箱。


五、s04 Hooks:挂在循环上,不写进循环里

5.1 问题:循环在膨胀

s03 的权限检查是硬编码在循环里的。如果再想加"记录每次 bash 调用"、"操作后自动 git add",就得继续往 agent_loop 里塞:

复制代码
for block in response.content:
    log_to_file(block)          # 加一行
    check_permission(block)     # 加一行
    notify_slack(block)         # 又加一行
    output = execute(block)
    auto_git_add(block)         # 再加一行......循环很快认不出来了

你想扩展的是 Agent 的行为 ,改的却是循环本身。循环应该是稳定内核,扩展应该挂在外面。

5.2 解法:事件注册表 + 触发器

四个事件,覆盖一次完整的 agent cycle:

事件 触发时机 典型用途
UserPromptSubmit 用户输入提交后、进 LLM 前 输入校验、注入上下文
PreToolUse 工具执行前 权限检查、日志
PostToolUse 工具执行后 副作用、输出检查
Stop 循环即将退出 收尾统计、决定要不要继续

实现是个极简的注册表:

复制代码
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}

def register_hook(event: str, callback):
    HOOKS[event].append(callback)

def trigger_hooks(event: str, *args):
    for callback in HOOKS[event]:
        result = callback(*args)
        if result is not None:   # 短路后续回调,含义由调用方决定
            return result
    return None

s03 的权限检查思路封装进 permission_hook,注册为 PreToolUse;再加日志、大输出告警、会话统计等 hook,各管各的:

复制代码
register_hook("UserPromptSubmit", context_inject_hook)
register_hook("PreToolUse", permission_hook)   # s03 的逻辑,从循环里搬出来
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
register_hook("Stop", summary_hook)

循环里的控制流变得非常干净,而且有两个精巧的返回值约定:

  • PreToolUse 返回非空的拒绝理由字符串 → 调用方阻止工具执行,并将理由作为 tool_result 回传;

  • Stop 返回非空的继续提示字符串 → 调用方将其作为新消息注入,继续循环:

    if not tool_calls:
    force = trigger_hooks("Stop", messages)
    if force:
    messages.append({"role": "user", "content": force})
    continue # hook 说还没完,那就接着跑
    return

这里有个细节:分发器使用 is not None,调用方却使用 if force / if blocked。空字符串和 False 会短路后续 Hook,但不会触发调用方的阻止或续跑分支。因此最好统一约定"无动作返回 None,有动作返回非空字符串"。

Hook 的返回值是否生效还取决于接入点。课程中的 UserPromptSubmit 示例只打印工作目录,并未真正注入上下文;PostToolUse 的返回值也没有被用于替换工具输出。若要扩展这些能力,需要显式处理返回值。

注册顺序同样重要:权限 Hook 放在日志 Hook 前面时,被拒绝的调用会跳过后面的日志 Hook。需要完整审计时,应把记录拒绝的逻辑放到明确的审计位置。Stop Hook 的续跑则应配合最大轮数、时间或费用预算,避免无限循环。


六、s05 TodoWrite:没有计划的 Agent,做着做着就偏了

6.1 问题:长任务的注意力稀释

给 Agent 一个复杂任务:"把所有 Python 文件改成 snake_case,跑测试,修好失败的。"

它改了 3 个文件、跑了个测试、发现 2 个失败,开始修------修着修着,忘了最初的目标是改命名,注意力全被测试失败吸走了。长对话中的工具输出和局部问题可能让模型偏离原始目标。这里描述的是需要防范的失败模式,并非所有模型必然出现的结果。

6.2 解法:一个"只管计划"的工具

s05 新增 todo_write 工具。注意它的定位:不增加文件或命令执行能力,而是提供可更新的计划状态------它只更新计划状态,实际工作仍由原有 5 个工具完成。

TodoManager 维护一份带状态的任务列表([ ] 待办、[>] 进行中、[x] 完成)。下面用简化实现展示校验和整体更新;课程源码另含字符串入参兼容处理:

复制代码
class TodoManager:
    def __init__(self):
        self.items = []

    def update(self, todos: list) -> str:
        if not isinstance(todos, list) or len(todos) > 20:
            raise ValueError("Expected a list with at most 20 todos")
        validated = []
        for todo in todos:
            if not isinstance(todo, dict):
                raise ValueError("Each todo must be an object")
            content = todo.get("content", "")
            status = todo.get("status", "pending")
            if not isinstance(content, str) or not content.strip():
                raise ValueError("Content must be a non-empty string")
            if status not in ("pending", "in_progress", "completed"):
                raise ValueError("Invalid status")
            validated.append({"content": content.strip(), "status": status})
        if sum(t["status"] == "in_progress" for t in validated) > 1:
            raise ValueError("Only one todo can be in_progress")
        self.items = validated
        return self.render()

    def render(self) -> str:
        markers = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}
        return "\n".join(
            f"{markers[t['status']]} {t['content']}"
            for t in self.items
        ) or "No todos."

更准确地说,这个实现允许最多一个 in_progress,也允许全部 pending 或全部 completed。它约束的是清单状态,不是对模型注意力的硬保证。update 会用新列表整体替换旧列表,因此调用时应提交希望保留的完整清单;completed 也只是状态声明,仍需以文件、执行结果或测试记录验证。

6.3 Reminder:harness 主动提醒,而不是祈祷模型自觉

光有工具不够,模型聊嗨了会忘了更新计划。s05 在循环里加了一个 reminder 计数器:连续三轮工具调用没用 todo_write,就把提醒追加到第三轮的工具结果里:

复制代码
rounds_since_todo = 0 if used_todo else rounds_since_todo + 1
if rounds_since_todo >= 3:
    results.append({"type": "text",
                    "text": "<reminder>Update your todos.</reminder>"})
    rounds_since_todo = 0

配合 SYSTEM 提示里的"先计划再执行"引导,Agent 收到复杂任务的典型行为变成:

复制代码
todo_write(列出 5 步,全 pending)
  → 做第 1 步:todo_write 标 in_progress
  → 用 bash/edit 干活
  → 完成后标 completed,看下一个 pending
  → ......直到全部 [x]

这是一个便于学习的 TodoWrite 实现,不等同于官方产品内部实现。它通过结构化工具、状态校验和周期提醒,让模型更容易持续跟踪任务。

注意,计数单位是一轮模型响应,不是单个工具。该实现以是否执行到 todo_write 分支重置计数,未进一步判断更新是否成功;提醒只是上下文中的普通文本,也不保证模型一定服从。计划保存在内存里,进程退出后不会自动持久化。


七、收个尾:五章下来,架构长什么样

回头看这五章,其实是一条非常干净的递进线:

章节 机制 对循环的改动 格言
s01 Agent Loop 从零建立 while True 一个工具 + 一个循环 = 一个 Agent
s02 Tool Use 执行处换成查表分发 工具实现、声明与注册配套
s03 Permission 执行前加入权限判断和拒绝分支 先检查,再执行
s04 Hooks 硬编码检查换成 trigger_hooks 挂在循环上,不写进循环里
s05 TodoWrite +6 号工具 + reminder 计数器 没有计划的 agent 走哪算哪

在本文对照的版本中,s05 完整脚本约 362 行(含注释和空行),不是前五章文件加起来只有这么多。到这里,教学骨架已经齐了:决策归模型,执行归 harness,扩展走 hook,安全走闸门,规划走结构化状态。保持稳定的是"请求模型 → 执行工具 → 回传结果"的闭环;循环内部确实随着权限、Hooks 和提醒机制而扩展。

后续章节还有更多好玩的东西:s06 子 Agent(上下文隔离)、s07 技能按需加载、s08 上下文压缩、s10 任务系统、s13 多 Agent 协作......如果我勤快的话,下篇继续整理 s06~s10,感兴趣的可以先去仓库自己跑。

三个实践建议收尾:

  1. 一定要动手跑 ,每一章的 code.py 都是独立可运行的,观察"模型什么时候调工具、什么时候停"比看十篇文章都有用;
  2. 所有章节都先在隔离练习环境运行,s03 加入审批并不代表脚本已经具备生产级安全性;
  3. 兼容端点要逐项验证。除了 base URL 和模型名称,还要使用对应服务的密钥,确认其支持 Anthropic Messages 协议、工具 Schema、多个工具结果及调用 ID 配对。只支持 OpenAI 风格接口的端点不能直接填入。

八、如何判断自己真的跑通了

不要只看最后一句"已完成",可以给五章分别设计一个小实验:

章节 实验 应检查的证据
s01 创建并运行 hello.py 文件内容、执行输出与总结一致
s02 读取并替换一个临时文件中的指定文本 使用专用工具,修改范围符合预期
s03 对练习目录中的测试文件提出删除请求,并在审批时拒绝 文件仍存在,模型收到拒绝结果
s04 执行一次读文件操作 日志体现执行前、执行后的接入顺序
s05 创建文件、运行、验证三个步骤 清单状态随工作更新,completed 有结果支撑

权限测试只用自己创建的临时文件,不要拿真实目录测试破坏性命令。若模型没有按预期调用某个工具,先观察实际响应和工具参数,不要把示例轨迹当成固定脚本。

常见问题可以按下面的顺序排查:

现象 优先检查
KeyError: MODEL_ID .env 是否位于正确位置,变量是否填写
401/403 密钥、端点和账户权限是否匹配
模型不存在或 404 服务实际支持的模型 ID 和 base URL 路径
tool_use / tool_result 相关 400 是否保留 assistant 调用块、ID 是否逐一配对、结果消息位置是否正确
模型只解释、不执行 工具声明是否传入,模型是否支持工具调用,提示是否明确要求操作
计划更新了但任务没有完成 查看实际文件和执行结果,不能只信清单状态

当教学脚本要变成长期运行的应用时,还需要补充循环预算、取消机制、重试策略、异常隔离和持久化。重试工具要区分读操作与有副作用的操作,避免重复写入;Hook 抛异常也应有明确处理规则。这些都是最小闭环之外的工程工作。

参考与代码来源

本文基于上述代码进行学习整理,节选有删减、注释调整和解释性改写;完整运行以对应版本的 code.py 为准。

相关推荐
m0_547486661 小时前
《Python高级编程》全套PPT课件2026(北京理工大学)
python·pycharm
Elaine3361 小时前
数据结构与算法-程序
数据结构·python·算法·计算机基础·编程基础
EatFan1 小时前
AI Agent 上生产前先加三道闸门:审批、限权、可回放的工程实践
人工智能·python·算法·多智能体·ai agent·mcp·harness
AI砖家2 小时前
Claude Code Skill 质量检查实战:用 /skill-doctor + Plugin Evals 找出“看似能用、实际没被调用“的问题
人工智能·ai编程·claude·codex·skill
光依旧2 小时前
herdr:给 Agent 一个专属终端运行时,是不是伪需求?
ai agent·运行时·agent安全·harness·agent架构·herdr·终端多路复用
xfan_me2 小时前
维修保养记录精准版 API 对接实战指南
java·大数据·python
海上小飞龙2 小时前
改一个数,右边全得重算,这题怎么扛住两万次查询
java·c++·python
XiaoMaqqqq2 小时前
目前知名的IP驱动产业新场景新工具有哪些
网络·python·网络协议·tcp/ip