从零用Python构建你自己的Claude Code:250行实现智能体循环与工具调用

从零用Python构建你自己的Claude Code:250行实现智能体循环与工具调用
张文保 更新 41 分钟阅读 3,255 阅读
本文目录
  1. 一个AI编程助手,到底由哪几块组成?
  2. 动手前要准备什么环境?
  3. V1:20行能跑起来的最小聊天循环长什么样?
  4. V2:怎么让它像打字机一样逐字蹦出来?
  5. V3:怎么让终端输出像样地渲染Markdown?
  6. V4:让它真正会"动手"的工具系统怎么搭?
  7. 第一步:用Anthropic格式定义工具
  8. 第二步:写工具执行函数
  9. 第三步:搭智能体循环
  10. 这段循环里,Anthropic的消息协议到底怎么转?
  11. 这个手搓版和真正的Claude Code差在哪?
  12. 这副骨架上还能长出哪些肉?
  13. 常见问题解答
  14. 为什么用Anthropic SDK而不是OpenAI接口来构建?
  15. Anthropic和OpenAI的工具调用格式,最关键的区别是什么?
  16. 模型不会自己执行命令,那危险操作怎么防?
  17. 这250行真能干活吗,还是只是玩具?
  18. 智能体循环为什么要用while而不是一次调用?
  19. 把模型换成Opus或Haiku要改什么?
  20. 权威参考资料

一句话结论:所谓AI编程助手,去掉外壳后只剩三个核心概念——智能体循环(Agentic Loop)、工具调用(Tool Use)和消息协议。本文用大约250行Python,调用Claude官方的Anthropic SDK,从一个20行的聊天循环一步步迭代到能读写文件、执行命令、搜索代码的完整工具系统。读完你会发现,Claude Code、Cursor这些工具的"魔法"内核其实只有一个while循环那么简单,而真正的门道全在消息协议的几个字段里。

市面上讲"250行手写一个Claude Code"的教程不少,但绝大多数有个让人哭笑不得的硬伤:它们用OpenAI的接口去构建一个叫"Claude Code"的东西。这就像教人造特斯拉,结果装了台燃油发动机。既然要复刻Claude Code的架构,最地道、也最能学到真东西的做法,当然是用Claude自己的引擎——Anthropic官方SDK。保哥这篇就把代码全部换成真正的Claude接口重写一遍,顺带把两家API协议的关键差异讲清楚,这恰恰是理解工具调用最值钱的部分。

别担心,这不是什么高深的工程。把它拆开看,一个AI编程智能体的工作方式朴素得很:你说"帮我写个hello world",它就创建文件、写入代码、运行、把结果报告给你。这一整套自主行为,背后就是下面要讲的三块积木。

一个AI编程助手,到底由哪几块组成?

先建立全局认知。普通聊天机器人和AI编程智能体的根本区别,在于后者能"动手"——它不只是回答你,还能在你的环境里读文件、跑命令、改代码。支撑这种能力的,是三个层层递进的概念。

第一块,智能体循环(Agentic Loop)。这是灵魂。它的流程是:你发消息→模型思考、决定要不要用工具→执行工具、把结果发回去→模型基于结果再思考→可能继续用工具,也可能直接回复→如此往复,直到任务完成。注意,这是一个循环,不是一问一答。模型可以读完一个文件后决定再读另一个,跑完测试发现报错后自己去改——多轮自主推理,全靠这个循环撑起来。

第二块,工具调用(Tool Use)。这里有个最容易被误解的关键点:模型永远不会自己执行工具。它只负责决定"调用哪个工具、传什么参数",真正的执行发生在你的Python代码里。模型说"我要调用write_file,路径是hello.py,内容是这段",你的代码收到这个意图后,才真的去写那个文件。这个"决策与执行分离"的设计,既是安全的根基,也是你能完全掌控它行为的原因。

第三块,消息协议。这是把前两块粘起来的胶水——一个精心组织的消息数组,记录着谁说了什么、调用了什么工具、工具返回了什么。理解了消息协议的字段结构,你就理解了整个机器的运转。后面会专门拆它,因为这正是Anthropic和OpenAI两家差异最大、也最值得学的地方。

动手前要准备什么环境?

前置条件很轻:Python 3.10以上(建议3.12+)、一个Anthropic API密钥、一个终端。

初始化项目:

mkdir magiccode && cd magiccode
python3 -m venv venv
source venv/bin/activate   # Windows用 venv\Scripts\activate
pip install anthropic rich

两个依赖:anthropic是Claude官方Python SDK,原生支持工具调用;rich负责终端里的Markdown渲染、语法高亮和面板美化。配置密钥:

export ANTHROPIC_API_KEY="sk-ant-你的密钥"

SDK会自动读取这个环境变量,代码里一行anthropic.Anthropic()就完成初始化。如果你还没装真正的Claude Code、想先体验官方版本再来手搓,可以参考Claude Code安装配置完全指南

V1:20行能跑起来的最小聊天循环长什么样?

从最小可用版本起步。这一版只有基础聊天,没有流式、没有工具:

#!/usr/bin/env python3
"""MagicCode v1 — 20行的终端AI助手。"""
import anthropic

client = anthropic.Anthropic()
SYSTEM = "You are MagicCode, a terminal AI coding assistant. Be concise and helpful."
messages = []

print("MagicCode v1 — 输入 exit 退出")
while True:
    user_input = input("\nYou > ")
    if user_input.strip().lower() in ("exit", "quit"):
        break
    messages.append({"role": "user", "content": user_input})
    resp = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        system=SYSTEM,
        messages=messages,
    )
    reply = resp.content[0].text
    messages.append({"role": "assistant", "content": reply})
    print(f"\nMagicCode: {reply}")

这里就藏着第一个和OpenAI写法的关键差异,新手最容易栽:Claude的系统提示是一个独立的顶层参数system=,不是塞进消息数组里的一条role: "system"消息。Anthropic的messages数组里只有userassistant两种角色,系统指令单拎出来。如果你照搬OpenAI那套把system塞进messages,直接就报错。

另一个细节:响应的内容在resp.content里,它是一个内容块(content block)列表,不是一个字符串。纯文本回复时取resp.content[0].text。这个"内容是块列表"的设计先记住,到了工具调用那一节它就是主角。

V2:怎么让它像打字机一样逐字蹦出来?

一次性等完整回复,体验很憋。流式输出让文字逐字显示,观感立刻不一样。Anthropic SDK提供了专门的流式上下文管理器:

#!/usr/bin/env python3
"""MagicCode v2 — 流式输出。"""
import anthropic

client = anthropic.Anthropic()
SYSTEM = "You are MagicCode, a terminal AI coding assistant. Be concise."
messages = []

print("MagicCode v2(流式)— 输入 exit 退出")
while True:
    user_input = input("\nYou > ")
    if user_input.strip().lower() in ("exit", "quit"):
        break
    messages.append({"role": "user", "content": user_input})

    print("\nMagicCode: ", end="", flush=True)
    full_reply = ""
    with client.messages.stream(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        system=SYSTEM,
        messages=messages,
    ) as stream:
        for text in stream.text_stream:
            print(text, end="", flush=True)
            full_reply += text
    print()
    messages.append({"role": "assistant", "content": full_reply})

关键改动:用client.messages.stream(...)配合with上下文,遍历stream.text_stream拿到一段段文本增量,实时打印;flush=True强制立即输出、不被缓冲卡住。这比OpenAI那种手动遍历chunk、层层取delta.content的写法清爽不少——SDK帮你把文本增量直接抽好了。

V3:怎么让终端输出像样地渲染Markdown?

AI的回复满是Markdown:代码块、列表、加粗。直接打印一堆星号和井号很难看。rich能把它渲染成带语法高亮的漂亮面板,配合流式实时刷新:

#!/usr/bin/env python3
"""MagicCode v3 — Rich渲染 + 实时流式。"""
import anthropic
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.live import Live

client = anthropic.Anthropic()
console = Console()
SYSTEM = "You are MagicCode. Format responses in Markdown."
messages = []

console.print(Panel("[bold cyan]MagicCode v3[/] — 输入 exit 退出", border_style="cyan"))
while True:
    user_input = console.input("\n[bold green]You >[/] ")
    if user_input.strip().lower() in ("exit", "quit"):
        break
    messages.append({"role": "user", "content": user_input})

    full_reply = ""
    with client.messages.stream(
        model="claude-sonnet-4-6", max_tokens=2048,
        system=SYSTEM, messages=messages,
    ) as stream:
        with Live(console=console, refresh_per_second=8) as live:
            for text in stream.text_stream:
                full_reply += text
                live.update(Panel(Markdown(full_reply),
                    title="MagicCode", border_style="blue"))
    messages.append({"role": "assistant", "content": full_reply})

核心是rich.Live不断重渲染面板,Markdown组件把累积的文本实时格式化。到这一步,它看起来已经很像个正经工具了——但它还只会"说",不会"做"。下一版才是真正的分水岭。

V4:让它真正会"动手"的工具系统怎么搭?

这是核心版本,把前面三版的聊天能力升级成能读写文件、执行命令、搜索代码的智能体。分三步:定义工具、写执行函数、搭智能体循环。

第一步:用Anthropic格式定义工具

这里是和OpenAI差异最大的地方,必须看清楚。OpenAI的工具定义是{"type": "function", "function": {...}}这种双层嵌套;Anthropic的格式是扁平的,直接name + description + input_schema三个字段。照搬OpenAI的嵌套结构,Claude会直接拒绝。用一个小helper统一生成:

def tool(name, desc, props, required):
    return {
        "name": name,
        "description": desc,
        "input_schema": {
            "type": "object",
            "properties": props,
            "required": required,
        },
    }

TOOLS = [
    tool("read_file", "Read file contents. Returns text with line numbers.",
         {"path": {"type": "string", "description": "File path"}}, ["path"]),
    tool("write_file", "Write content to a file. Creates directories if needed.",
         {"path": {"type": "string", "description": "File path"},
          "content": {"type": "string", "description": "Complete file content"}},
         ["path", "content"]),
    tool("edit_file", "Replace old_text with new_text in a file (first match).",
         {"path": {"type": "string", "description": "File path"},
          "old_text": {"type": "string", "description": "Text to find"},
          "new_text": {"type": "string", "description": "Replacement"}},
         ["path", "old_text", "new_text"]),
    tool("run_command", "Execute a shell command with 30s timeout.",
         {"command": {"type": "string", "description": "Shell command"}}, ["command"]),
    tool("list_files", "List directory structure (max 3 levels, ignores .git etc.).",
         {"path": {"type": "string", "description": "Directory path"}}, []),
    tool("search_code", "Search a pattern across files in a directory.",
         {"pattern": {"type": "string", "description": "Search pattern"},
          "path": {"type": "string", "description": "Search directory"}}, ["pattern"]),
]

这个description不是写给人看的,是写给模型看的——它越清楚,Claude越知道该在什么时候调用这个工具。这是工具调用里一门容易被忽视的手艺。Anthropic官方工具调用文档把每个字段的作用和强制调用的tool_choice选项都讲得很细,值得对着读一遍。

第二步:写工具执行函数

模型只决策,执行全在这个函数里。注意所有工具都返回字符串(协议要求),并且run_command带了一道危险命令黑名单——这就是"执行权在你手里"带来的安全可控:

import os, glob, subprocess

IGNORED = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build"}

def execute_tool(name, params):
    try:
        if name == "read_file":
            with open(params["path"], encoding="utf-8", errors="replace") as f:
                lines = f.read().split("\n")
            return "\n".join(f"{i+1:4d} | {ln}" for i, ln in enumerate(lines))

        elif name == "write_file":
            path = params["path"]
            os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
            with open(path, "w", encoding="utf-8") as f:
                f.write(params["content"])
            return f"Written to {path} ({len(params['content'])} chars)"

        elif name == "edit_file":
            with open(params["path"], encoding="utf-8") as f:
                content = f.read()
            if params["old_text"] not in content:
                return "Target text not found"
            content = content.replace(params["old_text"], params["new_text"], 1)
            with open(params["path"], "w", encoding="utf-8") as f:
                f.write(content)
            return f"Edited {params['path']}"

        elif name == "run_command":
            cmd = params["command"]
            if any(d in cmd for d in ["rm -rf /", "mkfs", "dd if=", "> /dev/sd"]):
                return "Refused: dangerous command"
            r = subprocess.run(cmd, shell=True, capture_output=True,
                               text=True, timeout=30)
            return (r.stdout + ("\n--- stderr ---\n" + r.stderr if r.stderr else "")).strip() or "(no output)"

        elif name == "list_files":
            out = []
            def walk(d, prefix="", depth=0):
                if depth >= 3:
                    return
                for e in sorted(os.listdir(d)):
                    if e in IGNORED or e.startswith("."):
                        continue
                    full = os.path.join(d, e)
                    if os.path.isdir(full):
                        out.append(f"{prefix}{e}/")
                        walk(full, prefix + "  ", depth + 1)
                    else:
                        out.append(f"{prefix}{e}")
            walk(params.get("path", "."))
            return "\n".join(out[:200]) or "Empty"

        elif name == "search_code":
            hits = []
            base = params.get("path", ".")
            for fp in glob.glob(os.path.join(base, "**", "*"), recursive=True):
                if any(d in fp for d in IGNORED) or not os.path.isfile(fp):
                    continue
                try:
                    with open(fp, encoding="utf-8", errors="replace") as f:
                        for i, ln in enumerate(f, 1):
                            if params["pattern"].lower() in ln.lower():
                                hits.append(f"{fp}:{i}: {ln.rstrip()}")
                                if len(hits) >= 50:
                                    break
                except OSError:
                    continue
            return "\n".join(hits) or "No matches"
    except Exception as e:
        return f"{type(e).__name__}: {e}"

第三步:搭智能体循环

重头戏来了。这个循环就是Claude Code的内核,逻辑和OpenAI版同构,但消息协议的字段名和结构完全是Anthropic的一套:

import json
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel

MODEL = os.getenv("MAGIC_MODEL", "claude-sonnet-4-6")
SYSTEM_PROMPT = """You are MagicCode, a terminal AI coding assistant.
Tools: read_file, write_file, edit_file, run_command, list_files, search_code.
Principles:
1. Always read a file before modifying it.
2. Break complex tasks into steps; verify each step.
3. Never run destructive commands.
4. Respond in Markdown."""

class MagicCode:
    def __init__(self):
        self.console = Console()
        self.messages = []

    def chat(self, user_input):
        self.messages.append({"role": "user", "content": user_input})
        tool_count = 0

        while True:
            resp = client.messages.create(
                model=MODEL, max_tokens=4096,
                system=SYSTEM_PROMPT, tools=TOOLS,
                messages=self.messages,
            )
            # 把assistant完整响应(含工具调用)原样存回历史
            self.messages.append({"role": "assistant", "content": resp.content})

            # 显示其中的文本块
            for block in resp.content:
                if block.type == "text":
                    self.console.print(Panel(Markdown(block.text), title="MagicCode"))

            # 没有工具调用 → 任务完成,跳出
            if resp.stop_reason != "tool_use":
                break

            # 逐个执行工具,结果用tool_result块回传
            results = []
            for block in resp.content:
                if block.type == "tool_use":
                    tool_count += 1
                    self.console.print(f"  [yellow]工具[{tool_count}] {block.name}[/] [dim]{block.input}[/]")
                    output = execute_tool(block.name, block.input)
                    results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": output,
                    })
            self.messages.append({"role": "user", "content": results})

            if tool_count > 20:
                self.console.print("[red]工具调用上限(20)[/]")
                break

    def run(self):
        self.console.print("[bold cyan]MagicCode[/] — 你的终端AI编程助手 (exit退出)")
        while True:
            try:
                ui = self.console.input("[bold green]You >[/] ").strip()
                if ui.lower() in ("exit", "quit"):
                    break
                if not ui:
                    continue
                self.chat(ui)
            except KeyboardInterrupt:
                break

if __name__ == "__main__":
    MagicCode().run()

这段循环里,Anthropic的消息协议到底怎么转?

代码能跑只是第一步,真正要装进脑子的是消息协议怎么流转。这是Claude和OpenAI差异的集中地,也是这篇相比那些"OpenAI冒充Claude"教程最值钱的部分。把一次"帮我写hello world"展开,messages数组长这样:

[
  # 1. 用户提问
  {"role": "user", "content": "帮我写个hello world"},

  # 2. assistant的响应:content是块列表,可同时含文本和工具调用
  {"role": "assistant", "content": [
      {"type": "text", "text": "好,我来创建这个文件。"},
      {"type": "tool_use", "id": "toolu_01abc",
       "name": "write_file",
       "input": {"path": "hello.py", "content": "print('hello world')"}}
  ]},

  # 3. 工具结果:用role=user,content里放tool_result块
  {"role": "user", "content": [
      {"type": "tool_result", "tool_use_id": "toolu_01abc",
       "content": "Written to hello.py (20 chars)"}
  ]},

  # 4. Claude基于结果继续……
]

四个魔鬼细节,记牢了你就真懂了:

  • 系统提示在system=参数里,不在messages里。这是Anthropic和OpenAI最显眼的结构差异。
  • assistant的content是块列表,一条回复里可以同时有text块和多个tool_use块。所以存回历史时要把整个resp.content原样塞回去,不能只取文本。
  • 工具结果用role: "user"回传(不是什么独立的tool角色),内容是tool_result块,靠tool_use_id和当初那个tool_useid精确配对。少一个id或对不上,整轮就乱套。
  • 循环的退出信号是stop_reason:等于"tool_use"说明Claude还想调工具,继续循环;不等于(通常是"end_turn")说明它说完了,跳出。

把这四点和那个while循环对照看,你会有种通透感:所谓智能体,就是"调模型→看它要不要用工具→用了就执行并把结果塞回去→再调模型"这么转圈,转到它不再要工具为止。Claude Code、Cursor、各路Agent框架,内核都是这个。Anthropic官方的工具调用智能体教程有一份完整的端到端走查,想再夯实一遍可以跟着做。

这个手搓版和真正的Claude Code差在哪?

别飘,250行复刻的是架构骨架,不是全部肌肉。摆张对照表心里有数:

能力MagicCode(手搓)Claude Code(官方)
读、写、编辑文件有,且基于精细Diff
执行命令、搜索代码
列目录
MCP集成(连外部工具)
多文件Diff、笔记本编辑
权限系统、计划模式、子代理
大致覆盖度约八成核心架构百分百

差距主要在工程化的深度和外围生态。比如官方版能通过MCP协议连数据库、连GitHub、连各种外部服务,这套机制怎么接,可以看MCP配置指南。但骨架你已经亲手搭出来了,剩下的都是在这副骨架上长肉。

这副骨架上还能长出哪些肉?

给几个高性价比的扩展方向,每个都是真实工具里有的功能,照着加能让你的MagicCode迅速变强。

权限确认。只读类工具(读文件、列目录、搜索)直接放行;写文件、执行命令这类有副作用的,先弹一句问你(y/n),确认了再执行。一道关,安全感天差地别。

加载项目上下文。启动时自动读取项目根目录的CLAUDE.mdAGENTS.mdREADME.md,拼进系统提示,让你的助手一开口就懂这个项目的规矩。这正是官方Claude Code记忆机制的简化版,背后的设计思路在CLAUDE.md记忆术指南里讲得很透。

对话持久化。messages数组用JSON存盘,下次启动恢复,跨会话记忆就有了。

Token用量追踪。每次调用后从resp.usageinput_tokensoutput_tokens累加,退出时打印本次会话花了多少,成本心里有数。

模型切换。MODEL做成环境变量,硬任务切Opus、日常用Sonnet、批量活换Haiku——这正是按"纠正税"选模型的实践,详见Claude Code最佳实践

常见问题解答

为什么用Anthropic SDK而不是OpenAI接口来构建?

因为要复刻的是Claude Code,用Claude自己的引擎才地道,也才能学到真正的协议差异。Anthropic的消息协议在系统提示位置、内容块结构、工具定义格式、工具结果回传方式上都和OpenAI不同,这些差异恰恰是工具调用最核心的知识点。用OpenAI构建一个叫Claude Code的东西,逻辑上就拧着。

Anthropic和OpenAI的工具调用格式,最关键的区别是什么?

四点:系统提示在Anthropic是独立的system参数、不进messages;工具定义是扁平的name/description/input_schema、没有OpenAI那层function嵌套;工具结果用role为user的tool_result块回传、靠tool_use_id配对;循环退出看stop_reason是否等于tool_use。记住这四点,两家代码就能互相翻译。

模型不会自己执行命令,那危险操作怎么防?

模型永远只决定调用什么工具、传什么参数,真正执行在你的execute_tool函数里。所以安全完全可控:你可以在run_command里设危险命令黑名单、给写操作加权限确认、限制可访问的目录。这种决策与执行分离,正是智能体安全设计的根基。

这250行真能干活吗,还是只是玩具?

能干真活,但定位是学习骨架。它读写文件、跑命令、搜代码、多轮自主推理都没问题,覆盖了约八成核心架构。缺的是MCP集成、精细Diff、权限系统、计划模式这些工程化外围。把它当成理解所有AI编程工具底层的最佳教具,而不是生产工具。

智能体循环为什么要用while而不是一次调用?

因为一个任务往往需要多轮工具调用。比如改bug,Claude要先读文件、再搜相关代码、改完跑测试、看报错再改——每一步的下一步都取决于上一步的结果。while循环让它能基于工具返回继续推理,直到stop_reason不再是tool_use才停。这正是它从聊天机器人进化成智能体的关键。

把模型换成Opus或Haiku要改什么?

只改MODEL那一个值即可,比如claude-opus-4-8或claude-haiku-4-5,其余代码不动——这是把模型做成环境变量的好处。复杂、易错、不可逆的任务上Opus,日常开发用Sonnet平衡速度和智能,批量简单活换Haiku省成本,按纠正税的高低来选。

权威参考资料

FAQPage + Article AI 引用友好版

TL;DR · 60–80 字摘要 · 适用 ChatGPT / Perplexity / Gemini / 文心 引用

市面教程大多用OpenAI接口去构建一个叫Claude Code的东西,逻辑就拧着。这篇改用Claude官方Anthropic SDK从头手搓终端编程助手,拆解tool_use与tool_result块、stop_reason循环、system参数等协议细节及与OpenAI格式的关键差异,附可运行代码。

关键实体 · Key Entities

  • Claude Code
  • AI编程
  • Python
  • Anthropic SDK
  • Agent开发
  • AI编程与工具链

引用元数据 · Citation Metadata

title:       从零用Python构建你自己的Claude Code:250行实现智能体循环与工具调用
author:      张文保 (Paul Zhang) — PatPat SEO 经理
url:         https://zhangwenbao.com/build-magic-code.html
published:   2026-02-24
modified:    2026-06-03
source-type: First-hand expert commentary
language:    zh-CN
license:     CC BY-NC-SA 4.0 (要求保留原文链接与作者归属)
分享到
标签
版权声明

本文标题:《从零用Python构建你自己的Claude Code:250行实现智能体循环与工具调用》

本文链接:https://zhangwenbao.com/build-magic-code.html

版权声明:本文原创,转载请注明出处和链接。许可协议: CC BY-NC-SA 4.0

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交