200 行代码实现 Claude Code 核心功能
教程展示如何用最小化代码构建 AI 编码助手的核心架构,适合想理解 LLM 编码工具设计的开发者。
完整中文译文
如今的 AI 编码助手让人觉得像是魔法。你用有时甚至并不连贯的英文描述你想要什么,它就能读取文件、编辑你的项目,并编写可运行的代码。
但这里有个关键:这些工具的核心并不是魔法。本质上就是大约 200 行直白的 Python。
让我们从零开始构建一个功能性的编码 Agent。
理解编码 Agent 的工作原理
在编写任何代码之前,让我们先理解你使用编码 Agent 时实际发生了什么。它本质上就是与一个强大的 LLM 进行对话,而这个 LLM 拥有一个工具箱。
你发送一条消息("创建一个包含 hello world 函数的新文件")
LLM 判断它需要某个工具并以结构化的工具调用(或多个工具调用)来响应
你的程序在本地执行该工具调用(实际创建文件)
结果被发送回 LLM
LLM 使用该上下文继续或响应
这就是整个循环。LLM 从不直接接触你的文件系统。它只是要求某些事情发生,而你的代码使其发生。
核心能力
我们的编码 Agent 从根本上需要三种能力:
读取文件,以便 LLM 可以看到你的代码
列出文件,以便它可以浏览你的项目
编辑文件,以便它可以给出创建和修改代码的指令
就这样。生产级的 Agent(如 Claude Code)还有一些额外的能力,包括 grep、bash、web 搜索等,但出于我们的目的,我们会看到三个工具就足以做出令人难以置信的事情。
设置基础架构
我们从基本的导入和 API 客户端开始。这里我用的是 OpenAI,但这对任何 LLM 提供商都适用:
import inspect
import json
import os
import anthropic
from dotenv import load_dotenv
from pathlib import Path
from typing import Any, Dict, List, Tuple
load_dotenv()
claude_client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
添加一些终端颜色让输出可读:
YOU_COLOR = "\u001b[94m"
ASSISTANT_COLOR = "\u001b[93m"
RESET_COLOR = "\u001b[0m"
以及一个解析文件路径的工具函数(这样 file.py 就成为 /Users/you/project/file.py):
def resolve_abs_path(path_str: str) -> Path:
"""
file.py -> /Users/you/project/file.py
"""
path = Path(path_str).expanduser()
if not path.is_absolute():
path = (Path.cwd() / path).resolve()
return path
实现工具
注意,你应该详细说明你的工具函数文档字符串,因为它们会被 LLM 用来推理在对话中应该调用哪些工具。下面会详细说明。
最简单的工具。接收一个文件名,返回其内容:
def read_file_tool(filename: str) -> Dict[str, Any]:
"""
Gets the full content of a file provided by the user.
:param filename: The name of the file to read.
:return: The full content of the file.
"""
full_path = resolve_abs_path(filename)
print(full_path)
with open(str(full_path), "r") as f:
content = f.read()
return {
"file_path": str(full_path),
"content": content
}
我们返回一个字典,因为 LLM 需要关于发生了什么的结构化上下文。
通过列出目录内容来浏览目录:
def list_files_tool(path: str) -> Dict[str, Any]:
"""
Lists the files in a directory provided by the user.
:param path: The path to a directory to list files from.
:return: A list of files in the directory.
"""
full_path = resolve_abs_path(path)
all_files = []
for item in full_path.iterdir():
all_files.append({
"filename": item.name,
"type": "file" if item.is_file() else "dir"
})
return {
"path": str(full_path),
"files": all_files
}
这是最复杂的工具,但仍然很直白。它处理两种情况:
当 old_str 为空时创建新文件
通过查找 old_str 并用 new_str 替换来替换文本
def edit_file_tool(path: str, old_str: str, new_str: str) -> Dict[str, Any]:
"""
Replaces first occurrence of old_str with new_str in file. If old_str is empty,
create/overwrite file with new_str.
:param path: The path to the file to edit.
:param old_str: The string to replace.
:param new_str: The string to replace with.
:return: A dictionary with the path to the file and the action taken.
"""
full_path = resolve_abs_path(path)
if old_str == "":
full_path.write_text(new_str, encoding="utf-8")
return {
"path": str(full_path),
"action": "created_file"
}
original = full_path.read_text(encoding="utf-8")
if original.find(old_str) == -1:
return {
"path": str(full_path),
"action": "old_str not found"
}
edited = original.replace(old_str, new_str, 1)
full_path.write_text(edited, encoding="utf-8")
return {
"path": str(full_path),
"action": "edited"
}
这里的约定:空的 old_str 意味着"创建这个文件"。否则,查找并替换。真正的 IDE 在字符串未找到时会添加复杂的后备行为,但这样就可以了。
我们需要一种按名称查询工具的方式:
TOOL_REGISTRY = {
"read_file": read_file_tool,
"list_files": list_files_tool,
"edit_file": edit_file_tool
}
教 LLM 我们的工具
LLM 需要知道哪些工具存在以及如何调用它们。我们从函数签名和文档字符串动态生成这个:
def get_tool_str_representation(tool_name: str) -> str:
tool = TOOL_REGISTRY[tool_name]
return f"""
Name: {tool_name}
Description: {tool.__doc__}
Signature: {inspect.signature(tool)}
"""
def get_full_system_prompt():
tool_str_repr = ""
for tool_name in TOOL_REGISTRY:
tool_str_repr += "TOOL\n===" + get_tool_str_representation(tool_name)
tool_str_repr += f"\n{'='*15}\n"
return SYSTEM_PROMPT.format(tool_list_repr=tool_str_repr)
系统 prompt 本身:
SYSTEM_PROMPT = """
You are a coding assistant whose goal it is to help us solve coding tasks.
You have access to a series of tools you can execute. Here are the tools you can execute:
{tool_list_repr}
When you want to use a tool, reply with exactly one line in the format: 'tool: TOOL_NAME({{JSON_ARGS}})' and nothing else.
Use compact single-line JSON with double quotes. After receiving a tool_result(...) message, continue the task.
If no tool is needed, respond normally.
"""
这是关键洞察:我们只是告诉 LLM"这是你的工具,这是调用它们的格式"。LLM 自己推断何时以及如何使用它们。
当 LLM 响应时,我们需要检测它是否要求我们运行工具:
def extract_tool_invocations(text: str) -> List[Tuple[str, Dict[str, Any]]]:
"""
Return list of (tool_name, args) requested in 'tool: name({...})' lines.
The parser expects single-line, compact JSON in parentheses.
"""
invocations = []
for raw_line in text.splitlines():
line = raw_line.strip()
if not line.startswith("tool:"):
continue
try:
after = line[len("tool:"):].strip()
name, rest = after.split("(", 1)
name = name.strip()
if not rest.endswith(")"):
continue
json_str = rest[:-1].strip()
args = json.loads(json_str)
invocations.append((name, args))
except Exception:
continue
return invocations
简单的文本解析。查找以 tool: 开头的行,提取函数名和 JSON 参数。
API 周围的一个薄包装器:
def execute_llm_call(conversation: List[Dict[str, str]]):
system_content = ""
messages = []
for msg in conversation:
if msg["role"] == "system":
system_content = msg["content"]
else:
messages.append(msg)
response = claude_client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2000,
system=system_content,
messages=messages
)
return response.content[0].text
现在我们把它们放在一起。这是"魔法"发生的地方:
def run_coding_agent_loop():
print(get_full_system_prompt())
conversation = [{
"role": "system",
"content": get_full_system_prompt()
}]
while True:
try:
user_input = input(f"{YOU_COLOR}You:{RESET_COLOR}:")
except (KeyboardInterrupt, EOFError):
break
conversation.append({
"role": "user",
"content": user_input.strip()
})
while True:
assistant_response = execute_llm_call(conversation)
tool_invocations = extract_tool_invocations(assistant_response)
if not tool_invocations:
print(f"{ASSISTANT_COLOR}Assistant:{RESET_COLOR}: {assistant_response}")
conversation.append({
"role": "assistant",
"content": assistant_response
})
break
for name, args in tool_invocations:
tool = TOOL_REGISTRY[name]
resp = ""
print(name, args)
if name == "read_file":
resp = tool(args.get("filename", "."))
elif name == "list_files":
resp = tool(args.get("path", "."))
elif name == "edit_file":
resp = tool(args.get("path", "."),
args.get("old_str", ""),
args.get("new_str", ""))
conversation.append({
"role": "user",
"content": f"tool_result({json.dumps(resp)})"
})
外层循环:获取用户输入,加到对话中
内层循环:调用 LLM,检查工具调用
如果不需要工具,打印响应并退出内层循环
如果需要工具,执行它们,将结果加到对话中,再循环
内层循环继续直到 LLM 响应时不需要任何工具。这让 Agent 能够链接多个工具调用(读一个文件,然后编辑它,然后确认编辑)。
if __name__ == "__main__":
run_coding_agent_loop()
现在你可以进行这样的对话:
You: 为我创建一个叫 hello.py 的新文件并在其中实现 hello world
Agent 用 path="hello.py", old_str="", new_str="print('Hello World')" 调用 edit_file
A: 完成!创建了包含 hello world 实现的 hello.py。
或多步交互:
You: 编辑 hello.py 并添加一个乘以两个数字的函数
Agent 调用 read_file 查看当前内容。Agent 调用 edit_file 添加函数。
A: 为 hello.py 添加了 multiply 函数。
我们构建的与生产工具的对比
这大约是 200 行。Claude Code 这样的生产工具添加了:
更好的错误处理和后备行为
用于更好用户体验的流式响应
更聪明的上下文管理(总结长文件等)
更多工具(运行命令、搜索代码库等)
破坏性操作的审批工作流
但核心循环呢?它正是我们在这里构建的。LLM 决定做什么,你的代码执行它,结果流回来。这就是整个架构。
完整源代码大约 200 行。换上你喜欢的 LLM 提供商,调整 system prompt,作为练习添加更多工具。你会惊讶于这个简单模式的强大程度。
这是我基于我的斯坦福讲座的现代 AI 软件工程课程第一个模块的一部分。在这里查看。