手搓 Claude Code(一):用 100 行代码实现一个最简 Agent
最近大半年用 Claude Code 写代码,太好用了,但是它是咋工作的呢
或者说我们应该怎么构建一个 agent???
抛开那些花哨的 UI 和工程细节,一个 coding agent 的核心其实没那么复杂。这篇文章就用 LangChain + DeepSeek,从零搓一个能在终端里跑的 agent。代码不到 150 行,但该有的都有:工具调用、多轮对话、安全防护。
先搞清楚一件事:Agent 到底在干什么
很多文章上来就讲 ReAct、CoT、各种框架。我们先退一步,看最本质的东西。
一个 agent 的工作流程其实就是一个循环:
用户说话 → 模型思考 → 要不要用工具?
├─ 不用 → 直接回答,结束
└─ 要用 → 执行工具 → 把结果喂回模型 → 再想想要不要继续用工具……
没了。就这么简单。
模型本身不能执行代码、不能读文件、不能跑命令。但模型可以"说"它想调用什么工具、传什么参数。我们在外面监听这个意图,帮它执行,再把结果塞回去。这个"监听-执行-反馈"的循环,就是 agent 的全部秘密。
定义工具:告诉模型它能做什么
工具就是普通的 Python 函数,加上 @tool 装饰器。装饰器做两件事:把函数签名转成 JSON Schema 给模型看,以及把 docstring 当作工具描述。
所以 docstring 不是写给人看的——是写给模型看的。模型根据这段描述来决定什么时候该调用这个工具。
from langchain_core.tools import tool
@tool
def run_bash(command: str) -> str:
"""run bash command"""
r = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
同样的方式,可以定义文件读取、文件写入、文件编辑等工具。一个 coding agent 最少需要"跑命令"和"读写文件"这两类能力。
定义好之后,需要做两件事。
第一,把工具绑定到模型上,这样模型才知道自己有哪些工具可用:
llm = ChatOpenAI(model="deepseek-chat", ...)
llm = llm.bind_tools([run_bash, run_read_file, run_write_file, run_edit_file])
第二,建一个工具名到函数的映射字典,方便后面根据模型返回的工具名找到对应函数来执行:
tools_dict = {
"run_bash": run_bash,
"run_read_file": run_read_file,
"run_write_file": run_write_file,
"run_edit_file": run_edit_file,
}
消息历史:agent 的"记忆"
LLM 本身是无状态的。每次调用都是一次全新的请求,模型不记得上一轮你说了什么。所谓的"多轮对话",其实是我们在客户端维护一个消息列表,每次把完整的历史都发过去。
messages: list[BaseMessage] = []
这个列表里会混着几种消息类型:
SystemMessage——系统提示词,告诉模型它是谁、能做什么。只在第一轮注入HumanMessage——用户输入AIMessage——模型的回复,可能包含文本,也可能包含tool_callsToolMessage——工具执行的结果,通过tool_call_id和对应的调用关联
第一轮对话比较特殊,需要通过 ChatPromptTemplate 把 system prompt 和用户输入一起格式化:
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("human", "{user_prompt}")
])
# 第一轮
response = (prompt | llm).invoke({"user_prompt": user_input})
messages.extend(prompt.invoke({"user_prompt": user_input}).to_messages())
后续轮次就简单了,直接往列表里追加 HumanMessage,然后把整个列表发给模型。
Agent 循环:最核心的 10 行代码
前面铺垫了那么多,这里是整个 agent 的核心:
while response.tool_calls:
for tool_call in response.tool_calls:
result = tools_dict[tool_call["name"]].invoke(tool_call["args"])
messages.append(ToolMessage(
content=result,
tool_call_id=tool_call["id"],
))
response = llm.invoke(messages)
messages.append(response)
逐行拆解:
response.tool_calls是模型返回的工具调用意图列表。如果为空,说明模型觉得不需要工具了,循环结束- 遍历每个 tool_call,从
tools_dict里找到对应函数,用模型给的参数执行 - 执行结果封装成
ToolMessage,注意tool_call_id必须对应上,模型靠这个 id 把调用和结果配对 - 把完整的消息历史再发给模型。模型看到工具结果后,要么继续调用工具,要么给出最终回答
一个实际的例子。当用户问"main.py 在哪":
用户: "main.py 在哪"
→ 模型返回 tool_calls: [{"name": "run_bash", "args": {"command": "find . -name main.py"}}]
→ 执行 find 命令,得到 "./main.py"
→ 把结果作为 ToolMessage 发回
→ 模型回复: "main.py 在项目根目录下,路径是 ./main.py"
模型可能一次搞定,也可能调用多次工具。比如"帮我把 main.py 里的 print 改成 logging",模型可能先 run_read_file 读文件,再 run_edit_file 改内容,最后 run_bash 跑一下确认没报错。这些都在同一个 while 循环里自动完成。
安全防护:别让 agent 把你的机器搞炸
让 LLM 执行 shell 命令这件事本身就有风险。我们做了两层基本防护。
命令层面,过滤掉明显危险的操作:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
文件层面,限制操作范围不能逃逸出工作目录:
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
这两层防护远算不上完善。真正的 Claude Code 有沙箱机制、权限确认、操作审计等更复杂的安全措施。但作为学习用的 demo,至少不会一不小心 rm -rf /。
跑起来看看
完整代码在 agent/a01_tool_use.py。配好 .env 里的 DEEPSEEK_API_KEY 之后就能跑。
启动后进入交互式终端,输入问题,agent 会自动决定是否需要调用工具,执行完后给出回答。输入 exit 退出。
回头看看
写完这 100 多行代码,再去看 Claude Code 的行为,很多事情就清晰了:
- 它"思考"后决定调用哪个工具——就是模型返回
tool_calls - 它能连续执行多个操作——就是 while 循环没退出
- 它记得之前的对话——就是 messages 列表一直在追加
- 它有时候会"犯错"然后自我纠正——就是工具返回了错误信息,模型换了个方式重试
当然,真正的 Claude Code 比这复杂得多。下一篇我们来加上流式输出,让 agent 的回答不再是等半天后一股脑吐出来。
下期更新内容
对于复杂的任务,通常我们需要先做子项拆分,产生一个 todo plan,所以下期逐渐给代码加上“todo plan”,让 agent 做任务拆分,按照任务步骤完成,并实施更新任务进度!!!