Claude Code 和 Codex 的出现让"AI 写代码"从概念变成了日常工具。但如果你只是用它们,你并不真正理解它们。
在前面几篇文章里,我们把 Agent 的核心机制逐一拆解清楚了:ReAct 范式提供了推理-行动交替的基础结构,Tool Use 是 Agent 与外部世界连接的接口层,上下文与记忆解决了长任务中的状态维持问题,System Prompt 设计决定了 Agent 的行为质量。
这篇文章把这些机制拼在一起,做一件具体的事:用 Python 从零构建一个结构上真实的 CLI Coding Agent。
理解一个系统最好的方式,是把它从零构建一遍。
先给结论
- Coding Agent 的本质是"上下文即状态"的 ReAct 循环。 每次工具调用的结果都追加进对话历史,驱动下一轮推理——这是它与普通代码补全工具的根本区别。
- 工具的粒度要粗,返回值要丰富。
read_file要同时返回内容和行数,run_command要同时返回 stdout、stderr 和退出码。LLM 获得的信息越完整,决策质量越高。 - System prompt 的"先探索,再行动"原则,解决了 Coding Agent 最常见的失败模式。 不了解项目结构就直接写代码,是导致路径错误和代码冲突的首要原因。
- 每个能控制路径的工具都必须做边界检查。 任何允许 LLM 控制文件路径的工具,不做路径越界检查就是一个安全漏洞。
- MAX_ITERATIONS 不是可选的。 没有硬上限的 Agent,遇到推理循环时会不停调用 API 直到账单爆炸。
整体架构
在写任何代码之前,先把架构想清楚。
一个 CLI Coding Agent 由三层组成:用户通过 Shell 界面输入任务,Agent 核心(ReAct 循环)负责推理和调度,工具集是 Agent 与文件系统和操作系统交互的唯一接口。
图 1:CLI Coding Agent 的三层结构。Shell 接口只负责 I/O,Agent 核心只负责推理循环,工具集只负责执行——每一层的职责边界清晰,互不依赖。
这个结构里最关键的是 ReAct 循环。它不是一次性的"输入→输出",而是一个持续运转的推理引擎:每次行动的结果都作为新的上下文注入,驱动下一步决策。这正是 Coding Agent 与普通代码补全工具的本质区别。
项目结构
coding-agent/
├── agent/
│ ├── __init__.py
│ ├── core.py # ReAct 循环主逻辑
│ ├── tools.py # 工具定义与注册
│ └── prompts.py # System prompt
├── cli/
│ └── main.py # CLI 入口
├── requirements.txt
└── README.md
设计原则:core.py 不依赖 CLI,tools.py 不依赖 LLM。这让每一层都可以独立测试,也方便以后替换任意一层的实现。
第一步:定义工具集
工具是 Agent 和外部世界之间的唯一接口。设计工具时有一个反直觉的原则:工具的粒度要粗,返回值要丰富。
read_file 应该同时返回内容和行数,而不是只返回内容;run_command 应该同时返回 stdout、stderr 和退出码,而不是只返回 stdout。LLM 需要的信息越完整,它做出正确决策的概率越高。
# agent/tools.py
import subprocess
from pathlib import Path
from typing import Any
WORKSPACE = Path(".").resolve()
def _safe_path(path: str) -> Path:
"""
将相对路径解析为绝对路径,并验证路径在工作区范围内。
path: 用户或 LLM 提供的文件路径字符串
"""
target = (WORKSPACE / path).resolve()
if not str(target).startswith(str(WORKSPACE)):
raise ValueError(f"路径越界:{path}")
return target
def read_file(path: str) -> dict[str, Any]:
"""
读取文件内容,同时返回行数信息。
path: 相对于工作区根目录的文件路径
"""
try:
p = _safe_path(path)
content = p.read_text(encoding="utf-8")
lines = content.splitlines()
return {
"success": True,
"content": content,
"lines": len(lines),
"path": str(p.relative_to(WORKSPACE)),
}
except FileNotFoundError:
return {"success": False, "error": f"文件不存在:{path}"}
except Exception as e:
return {"success": False, "error": str(e)}
def write_file(path: str, content: str) -> dict[str, Any]:
"""
将内容写入文件,自动创建不存在的父目录。
path: 目标文件路径(相对路径)
content: 要写入的完整文件内容
"""
try:
p = _safe_path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return {
"success": True,
"path": str(p.relative_to(WORKSPACE)),
"bytes_written": len(content.encode()),
}
except Exception as e:
return {"success": False, "error": str(e)}
def run_command(command: str, timeout: int = 30) -> dict[str, Any]:
"""
在工作区目录执行 Shell 命令,返回完整的执行结果。
command: 要执行的 Shell 命令字符串
timeout: 命令超时秒数,超时后强制终止
"""
if is_dangerous(command):
return {"success": False, "error": f"拒绝执行危险命令:{command}"}
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=timeout,
cwd=str(WORKSPACE),
)
return {
"success": result.returncode == 0,
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode,
}
except subprocess.TimeoutExpired:
return {"success": False, "error": f"命令超时({timeout}s):{command}"}
except Exception as e:
return {"success": False, "error": str(e)}
def list_dir(path: str = ".") -> dict[str, Any]:
"""
列出目录下的文件和子目录(跳过隐藏文件)。
path: 目录路径,默认为工作区根目录
"""
try:
p = _safe_path(path)
items = []
for item in sorted(p.iterdir()):
if item.name.startswith("."):
continue
entry = {
"name": item.name,
"type": "dir" if item.is_dir() else "file",
"path": str(item.relative_to(WORKSPACE)),
}
if item.is_file():
entry["size"] = item.stat().st_size
items.append(entry)
return {"success": True, "path": str(p.relative_to(WORKSPACE)), "items": items}
except Exception as e:
return {"success": False, "error": str(e)}
DANGEROUS_PATTERNS = ["rm -rf", "sudo", "curl | bash", "wget | sh", "> /dev/"]
def is_dangerous(command: str) -> bool:
"""
检测命令是否包含危险操作模式。
command: 待检测的命令字符串
"""
return any(p in command for p in DANGEROUS_PATTERNS)
# 工具注册表:name → callable
TOOL_REGISTRY = {
"read_file": read_file,
"write_file": write_file,
"run_command": run_command,
"list_dir": list_dir,
}
# OpenAI function calling 格式:每个工具包裹在 {"type": "function", "function": {...}} 中
TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "读取指定路径的文件内容。返回文件内容和行数。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "相对于工作区根目录的文件路径"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "将内容写入指定路径的文件。如果目录不存在,会自动创建。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "目标文件路径"},
"content": {"type": "string", "description": "要写入的内容"},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "run_command",
"description": "在工作区目录执行 Shell 命令。返回 stdout、stderr 和退出码。",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "要执行的 Shell 命令"},
"timeout": {"type": "integer", "description": "超时秒数,默认 30"},
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "列出目录下的文件和子目录。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "目录路径,默认为工作区根目录"}
},
},
},
},
]
注意 _safe_path 这个函数——任何允许 LLM 控制路径的工具都必须做路径边界检查,防止 Agent 意外(或被诱导)访问工作区以外的文件。is_dangerous 也已经内嵌进 run_command,在执行前拦截危险命令。
第二步:设计 System Prompt
System prompt 是 Coding Agent 最容易被忽视的部分,但它几乎决定了 Agent 的行为质量。在 System Prompt 设计那篇里,我们已经讨论了一般性的设计原则。对于 Coding Agent,有几个针对性的约束格外重要。
# agent/prompts.py
SYSTEM_PROMPT = """你是一个运行在命令行的 Coding Agent,帮助用户完成编程任务。
## 能力范围
你可以读取、创建、修改文件,执行 Shell 命令,并基于执行结果继续推理。
## 工作原则
**先探索,再行动。** 在修改任何文件之前,先用 list_dir 和 read_file 了解项目结构。
不要假设文件的内容或结构。
**小步执行,验证推进。** 每次只做一件事。写完代码就运行测试。执行命令后检查输出。
不要一次写完所有代码再测试。
**错误是信息。** 命令返回非零退出码、文件读取失败,都是你需要处理的信息,而不是需要隐藏的问题。
把错误信息传递给用户。
**保守修改。** 只修改任务要求修改的文件。不要"顺便"优化无关代码。
## 工具使用规范
- 每次只调用一个工具,等待结果后再决定下一步
- run_command 执行的命令必须是幂等的,或者你已经理解它的副作用
- write_file 会完全覆盖目标文件,写入前确认内容正确
- 路径使用相对路径,相对于工作区根目录
## 完成标准
当任务完成时,向用户简洁地说明:做了什么、验证结果是什么、有什么需要用户注意的地方。
"""
这个 system prompt 有几个值得注意的设计决策:
“先探索,再行动” 解决了 Coding Agent 最常见的失败模式——在不了解项目结构的情况下直接写代码,导致路径错误或与现有代码冲突。这一条规则能消除大约一半的无效工具调用。
“小步执行,验证推进” 对应的是 ReAct 的核心思想:每个 Action 之后都应该有 Observation 来验证结果,而不是一次性执行大量操作。
“错误是信息” 这一条解决了 Agent 的一个微妙倾向:在遇到错误时倾向于"假装没看见",绕过错误继续执行,而不是分析并上报。明确说出来可以抑制这种倾向。
第三步:实现 ReAct 循环
这是整个项目的核心。ReAct 循环做的事情很简单:持续调用 LLM,LLM 要么返回工具调用(继续循环),要么返回文本(结束任务)。
图 2:Coding Agent 的 ReAct 执行循环。每轮循环从 LLM 调用开始;如果 finish_reason 是 tool_calls,执行工具并将结果注入对话历史;如果是 stop,任务完成退出。硬上限 MAX_ITERATIONS 保证循环不会无限进行。
# agent/core.py
import json
import openai
from typing import Iterator
from .tools import TOOL_REGISTRY, TOOL_SCHEMAS
from .prompts import SYSTEM_PROMPT
# 兼容任意 OpenAI 格式的接口:修改 base_url 即可切换到 DeepSeek、Qwen 等模型
client = openai.OpenAI(
base_url="https://api.openai.com/v1", # 替换为你的 API 地址
api_key="your-api-key", # 或从环境变量读取
)
MODEL = "gpt-4o"
MAX_ITERATIONS = 30
def trim_history(history: list[dict], max_turns: int = 20) -> list[dict]:
"""
截断过长的对话历史,只保留最近 max_turns 轮。
history: 当前完整对话历史
max_turns: 保留的最大轮次数
"""
if len(history) <= max_turns * 2:
return history
trimmed = history[-(max_turns * 2):]
while trimmed and trimmed[0]["role"] != "user":
trimmed = trimmed[1:]
return trimmed
def run_agent(
user_message: str,
conversation_history: list[dict],
) -> Iterator[str]:
"""
运行一轮 Agent 交互,以 generator 形式流式 yield 状态和输出。
user_message: 用户本轮输入的任务描述
conversation_history: 跨轮次共享的对话历史列表(会被原地修改)
"""
conversation_history.append({"role": "user", "content": user_message})
for iteration in range(MAX_ITERATIONS):
yield f"\n[思考中... 第 {iteration + 1} 轮]\n"
# system prompt 以第一条消息的形式传入,符合 OpenAI 格式
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + trim_history(conversation_history)
response = client.chat.completions.create(
model=MODEL,
max_tokens=8096,
tools=TOOL_SCHEMAS,
messages=messages,
)
choice = response.choices[0]
finish_reason = choice.finish_reason
message = choice.message
# 将 assistant 消息(含可能的 tool_calls)追加进历史
assistant_msg: dict = {"role": "assistant", "content": message.content}
if message.tool_calls:
assistant_msg["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in message.tool_calls
]
conversation_history.append(assistant_msg)
if message.content:
yield message.content
if finish_reason == "stop":
yield "\n[✓ 任务完成]\n"
break
if finish_reason == "tool_calls":
for tc in message.tool_calls:
tool_name = tc.function.name
tool_input = json.loads(tc.function.arguments)
tool_call_id = tc.id
yield f"\n[调用工具] {tool_name}({json.dumps(tool_input, ensure_ascii=False)})\n"
if tool_name in TOOL_REGISTRY:
try:
result = TOOL_REGISTRY[tool_name](**tool_input)
except Exception as e:
result = {"success": False, "error": f"工具执行异常:{str(e)}"}
else:
result = {"success": False, "error": f"未知工具:{tool_name}"}
result_str = json.dumps(result, ensure_ascii=False, indent=2)
yield f"[工具结果] {result_str[:200]}{'...' if len(result_str) > 200 else ''}\n"
# OpenAI 格式:每个工具结果是一条独立的 tool 消息
conversation_history.append({
"role": "tool",
"tool_call_id": tool_call_id,
"content": result_str,
})
else:
yield f"\n[停止原因:{finish_reason}]\n"
break
else:
yield f"\n[达到最大迭代次数 {MAX_ITERATIONS},任务中止]\n"
这里有几个工程细节值得展开:
OpenAI 格式与 Anthropic 格式的核心差异在于工具结果的传递方式。 Anthropic 把所有工具结果打包成一条 user 消息;OpenAI 则要求每个工具结果是一条独立的 role: "tool" 消息,并通过 tool_call_id 与对应的工具调用绑定。这个细节如果搞错,模型会拒绝响应或产生幻觉。
conversation_history 是整个 Agent 的"记忆"。 每一轮循环都把 assistant 的输出(含 tool_calls 字段)和工具结果追加进去,下一轮 LLM 调用时完整传入。这就是 ReAct 的状态管理机制——上下文即状态。这与 上下文与记忆那篇里讨论的"过程记忆"是同一件事。
MAX_ITERATIONS = 30 是一个硬上限。 没有它,一个陷入推理循环的 Agent 会不停调用 API。这个值不是随意选的——30 轮对应的是一个中等复杂任务(读文件 → 写代码 → 运行测试 → 修复 → 验证)的上限,超过这个通常意味着 Agent 陷入了循环。
工具执行用 try/except 包裹,异常作为 tool 消息返回。 这让 Agent 有机会在遇到错误时自我恢复——比如发现路径不存在后,先调用 list_dir 探索目录结构,再重试。
第四步:构建 CLI 界面
CLI 层的职责很单纯:接收用户输入,展示 Agent 输出,管理会话历史。
# cli/main.py
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from agent.core import run_agent
from agent.tools import WORKSPACE
def print_banner():
"""打印启动横幅,显示工作区路径。"""
print("\n╔════════════════════════════════╗")
print("║ Coding Agent (CLI) ║")
print(f"║ 工作区:{str(WORKSPACE)[:20]:<20} ║")
print("╚════════════════════════════════╝\n")
print("输入你的编程任务,按 Enter 执行。")
print("输入 /exit 退出,/clear 清空对话历史。\n")
def main():
"""CLI 主入口,负责读取用户输入并调用 Agent。"""
print_banner()
conversation_history = []
while True:
try:
user_input = input("\n> ").strip()
except (KeyboardInterrupt, EOFError):
print("\n再见!")
break
if not user_input:
continue
if user_input == "/exit":
print("再见!")
break
if user_input == "/clear":
conversation_history.clear()
print("[对话历史已清空]")
continue
if user_input == "/history":
print(f"[当前对话轮次:{len(conversation_history)}]")
continue
print("\n" + "─" * 50)
try:
for chunk in run_agent(
user_message=user_input,
conversation_history=conversation_history,
):
print(chunk, end="", flush=True)
except KeyboardInterrupt:
print("\n\n[用户中断]")
except Exception as e:
print(f"\n[错误] {e}")
print("\n" + "─" * 50)
if __name__ == "__main__":
main()
一次完整的执行过程
以"给这个项目写一个简单的单元测试"为例:
> 帮我创建一个 Python 函数,计算斐波那契数列的前 n 项,并写测试
[思考中... 第 1 轮]
我来先创建实现文件,然后写测试。
[调用工具] write_file({"path": "fib.py", "content": "..."})
[工具结果] {"success": true, "path": "fib.py", "bytes_written": 156}
[调用工具] write_file({"path": "test_fib.py", "content": "..."})
[工具结果] {"success": true, "path": "test_fib.py", "bytes_written": 312}
[调用工具] run_command({"command": "python -m pytest test_fib.py -v"})
[工具结果] {"success": true, "stdout": "3 passed in 0.12s", ...}
[✓ 任务完成]
已创建 fib.py(递推实现)和 test_fib.py(3 个测试用例),
全部测试通过(0.12s)。
每一轮"思考"对应 LLM 的一次文本输出,每一轮"行动"对应一次 tool_use 调用。这个模式持续循环,直到 LLM 判断任务完成返回 end_turn。
必须处理的工程细节
上下文窗口管理
随着对话轮次增加,conversation_history 会越来越长,最终超出上下文窗口限制。trim_history 已经内嵌进 run_agent,保留最近 20 轮对话。
如果你需要更精细的控制,可以在截断时先做摘要压缩——让 LLM 把早期对话总结成一段文字,作为"第一条消息"保留语义。但对于本地 CLI 工具,简单截断通常够用。
流式输出
当前实现用 generator yield 状态信息,但没有真正的流式 LLM 输出。如果你想要像 Claude Code 那样的流式体验,可以开启 stream=True:
stream = client.chat.completions.create(
model=MODEL,
max_tokens=8096,
tools=TOOL_SCHEMAS,
messages=messages,
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
print(delta.content, end="", flush=True)
注意:开启流式后需要自行拼接完整的 tool_calls,因为工具调用参数会被分块传输(delta.tool_calls)。如果不需要流式推理文本,只要流式感知工具调用进度,保持非流式更简单。
安全边界
允许 Agent 执行 Shell 命令是一个高风险能力。is_dangerous 检测已经内嵌在 run_command 里,但这只是第一道防线。生产环境还应该考虑:
- 在 Docker 容器或沙箱中执行命令(隔离副作用)
- 对写操作请求用户确认(
write_file前显示 diff) - 记录所有工具调用到审计日志(便于事后排查)
常见失败模式
在实际使用中,这个 Coding Agent 有几种典型的失败场景:
失败模式一:跳过探索直接写代码。 Agent 在没有调用 list_dir 和 read_file 了解项目结构的情况下,直接猜测文件路径写代码。结果是路径错误或与现有代码冲突。根本原因是 system prompt 里的"先探索,再行动"没有被严格执行——有时 LLM 会在任务描述非常明确时跳过探索步骤。修复方法:在 system prompt 里把"探索"定义为强制的第一步,而不是建议。
失败模式二:工具结果截断导致信息丢失。 result_str[:200] 的截断在工具返回大量内容时会丢失关键信息(比如一个有 500 行的文件,截断后 LLM 只看到前几行)。修复方法:对于 read_file,返回文件内容时不截断;对于 run_command,只在 stdout/stderr 超过阈值时才截断,并附上"输出已截断,共 N 行"的提示。
失败模式三:推理循环。 Agent 陷入"调用工具 → 得到错误 → 以同样方式再次调用工具"的死循环,直到触发 MAX_ITERATIONS。根本原因是 LLM 没有从错误中提取有效信息。修复方法:在工具结果里加入更结构化的错误诊断信息,比如"文件不存在,当前目录下有以下文件:[…]",而不是只返回一个错误字符串。
安装与运行
pip install openai
export OPENAI_API_KEY=sk-...
python cli/main.py
如果使用其他 OpenAI 兼容接口(DeepSeek、Qwen、本地 Ollama 等),只需修改 core.py 里的 base_url 和 api_key,其余代码完全不变:
# DeepSeek
client = openai.OpenAI(base_url="https://api.deepseek.com/v1", api_key="sk-...")
# 本地 Ollama
client = openai.OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
从这里出发,能扩展什么
这个实现是一个有意保持简单的骨架。基于它,可以向几个方向自然演进:
更强的工具集:加入 search_files(grep)、get_git_diff、run_linter 等,让 Agent 具备更完整的代码理解和审查能力。这些工具的加入不需要修改 core.py,只需要扩展 TOOL_REGISTRY 和 TOOL_SCHEMAS。
持久化记忆:把 conversation_history 序列化到磁盘,支持跨会话继续之前的任务,就像 Claude Code 的 --resume 功能。这是 上下文与记忆那篇里讨论的"外部存储"在 Coding Agent 场景的直接应用。
Planning 层:对于复杂任务(“重构这个模块”),在 ReAct 循环之前加一个规划步骤,让 Agent 先把任务分解成若干子任务,再逐一执行。这对应的是 Plan-and-Execute 那篇里的结构。
多 Agent 协作:把这个 Agent 变成一个可调用的子 Agent,由一个 Orchestrator 统一调度——一个负责写功能代码,一个负责写测试,并行执行后 Orchestrator 合并结果。这正是 多 Agent 协作那篇里讨论的"分工 + 并行"模式。
MCP 接入:把工具层迁移到 MCP 协议,让这个 Agent 可以和其他支持 MCP 的工具生态互通。具体的接入方式在 MCP 那篇里有详细讨论。
这些扩展方向,本质上都是在当前骨架的某一层上加结构。骨架本身的清晰设计,让每个扩展都有明确的着力点。
总结
- Coding Agent = ReAct 循环 + 工具集 + 上下文管理。 三层结构各司其职,每一层都可以独立替换。
- “上下文即状态"是核心机制。 工具调用的结果通过
conversation_history传递给下一轮推理,这就是 Agent 与外部世界交互的完整闭环。 - System prompt 的行为约束,比工具实现更重要。 大多数 Coding Agent 的失败,不是因为工具写错了,而是因为 Agent 的行为约束不够清晰。
- 工程细节不可省略。 路径边界检查、危险命令拦截、硬迭代上限、上下文截断——这些不是优化项,是基础保障。
- 理解这个骨架,就理解了 Claude Code 的内部结构。 它的工具集更丰富、工程更完善,但驱动它的 ReAct 循环,和这里实现的是同一个东西。
评论
使用 GitHub 账号登录后即可发表评论,评论会同步到仓库 Discussions。