手搓 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_calls
  • ToolMessage——工具执行的结果,通过 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)

逐行拆解:

  1. response.tool_calls 是模型返回的工具调用意图列表。如果为空,说明模型觉得不需要工具了,循环结束
  2. 遍历每个 tool_call,从 tools_dict 里找到对应函数,用模型给的参数执行
  3. 执行结果封装成 ToolMessage,注意 tool_call_id 必须对应上,模型靠这个 id 把调用和结果配对
  4. 把完整的消息历史再发给模型。模型看到工具结果后,要么继续调用工具,要么给出最终回答

一个实际的例子。当用户问"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 做任务拆分,按照任务步骤完成,并实施更新任务进度!!!