教程展示如何用最小化代码构建 AI 编码助手的核心架构,适合想理解 LLM 编码工具设计的开发者。
如今的 AI 编码助手让人觉得像是魔法。你用有时甚至并不连贯的英文描述你想要什么,它就能读取文件、编辑你的项目,并编写可运行的代码。
但这里有个关键:这些工具的核心并不是魔法。本质上就是大约 200 行直白的 Python。
让我们从零开始构建一个功能性的编码 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 需要知道哪些工具存在以及如何调用它们。我们从函数签名和文档字符串动态生成这个:
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 软件工程课程第一个模块的一部分。在这里查看。