Tutorial · 手写实现 · 2026-11-03

从零理解 AI AgentReAct 与 Code Mode

Agent 就是「大模型 + 工具 + 循环」:让一个只会输出文字的模型,通过反复调用工具,真正去完成一件事。三个文件、约 350 行 Python,把两种做法各实现一遍。

3 个文件约 350 行 Python只用标准库DeepSeek V4 Flash

同一任务 · 实测 统计 5 个日志文件的行数与 ERROR 总 prompt token −63%
ReAct 逐次点名 · 每步一个工具
5,389 prompt token / 5 次请求
Code Mode 写一段程序 · 只回传结果
2,006 prompt token / 3 次请求

01Agent 是什么

Agent 就是「大模型 + 工具 + 循环」:让一个只会输出文字的模型,通过反复调用工具,真正去完成一件事。

先看大模型本身能做什么。它本质上是一个函数:输入一段文字,输出一段文字。它不能读你电脑上的文件,不能上网,也不知道现在几点。你问它「我的日志里有几行 ERROR」,它只能猜。

Agent 给它补上了两样东西:

打个比方:模型是一位坐在办公室里的专家,很聪明,但出不了门。你的程序是他的助理。专家写张纸条「去档案室拿 log1.txt」,助理跑去拿回来;专家看完再写下一张纸条……直到专家写出最终报告。纸条怎么写,就是本教程要比较的两种模式:

本教程用三个加起来约 350 行的 Python 文件,把这两种模式各实现一遍。只用标准库,没有任何框架,每一行都能看懂。模型用的是 DeepSeek V4 Flash。

02准备工作

三个文件放在同一个目录里,分工很明确:公共部分一个文件,两种模式各一个文件。

文件 行数 里面有什么
common.py 125 配置、6 个工具函数、调用模型的 chat()、agent 主循环 run_agent()
react_agent.py 73 ReAct 模式:把工具函数转成「工具定义」交给模型
codemode_agent.py 148 Code Mode:只给模型一个 run_code 工具,让它写 Python

common.py 里的 6 个工具就是普通的 Python 函数,没有任何魔法:

工具 作用
list_dir(path) 列出目录里的文件
read_file(path) 读文本文件
write_file(path, content) 写文本文件
run_shell(command) 执行一条 shell 命令
fetch_url(url) 下载网页,返回标题和纯文本
current_time(timezone) 查当前时间

比如 read_file 就三行:

def read_file(path):
    """Read a text file and return its content as a string."""
    with open(path, encoding="utf-8") as f:
        return f.read()

注意那行 docstring(三引号里的说明)。后面会看到,它就是模型了解这个工具的唯一依据。

运行方法:只需要 Python 3.9 以上和一个 DeepSeek API key,不用装任何包。

export DEEPSEEK_API_KEY=sk-...
python3 react_agent.py "现在北京时间几点?"
python3 codemode_agent.py "统计当前目录下每个 .txt 文件的行数"

程序会把每一步都打印出来:第几步、消耗了多少 token、模型调用了什么、得到了什么结果。建议边读教程边跑,对照输出看。

⚠️ 这两个 agent 会真的执行 shell 命令、写文件。请在一个没有重要文件的空目录里练习。

03模型怎么「调用工具」

模型从来不会自己执行工具。它只是在回复里写下「我想调用哪个工具、参数是什么」,真正执行的是我们的程序。这套约定叫 function calling(函数调用),分三步。

第一步:告诉模型有哪些工具。 发请求时,除了对话内容 messages,还要带上 tools,列出每个工具的名字、用途和参数:

{
  "model": "deepseek-v4-flash",
  "messages": [
    {"role": "system", "content": "You are a helpful agent. Use the tools to act."},
    {"role": "user", "content": "log1.txt 有几行?"}
  ],
  "tools": [{
    "type": "function",
    "function": {
      "name": "read_file",
      "description": "Read a text file and return its content as a string.",
      "parameters": {"type": "object",
                     "properties": {"path": {"type": "string"}},
                     "required": ["path"]}
    }
  }]
}

第二步:模型决定调用工具。 如果它觉得需要读文件,回复里就不是普通文字,而是一个 tool_calls 字段:

{
  "role": "assistant",
  "content": "",
  "tool_calls": [{
    "id": "call_001",
    "type": "function",
    "function": {"name": "read_file", "arguments": "{\"path\": \"log1.txt\"}"}
  }]
}

第三步:我们执行工具,把结果送回去。 程序调用真正的 read_file("log1.txt"),把返回值包成一条 role 为 tool 的消息追加到对话里,用 tool_call_id 对上号,然后把整段对话再发一遍:

{"role": "tool", "tool_call_id": "call_001", "content": "line 0 ok\nline 1 ERROR\n..."}

模型看到文件内容后,就能回答「18 行」了。

有三个容易忽略的点:

common.py 里的 chat() 就是第一步和第二步:用标准库 urllib 发一个 POST 请求,返回模型的回复和 token 用量。

def chat(messages, tools):
    body = json.dumps({"model": MODEL, "messages": messages, "tools": tools}).encode()
    req = urllib.request.Request(API_URL, data=body, headers={
        "Content-Type": "application/json",
        "Authorization": f"Bearer {os.environ['DEEPSEEK_API_KEY']}",
    })
    reply = json.load(urllib.request.urlopen(req, timeout=120))
    return reply["choices"][0]["message"], reply["usage"]

04Agent 循环

把上一节的三步放进一个循环,就是一个完整的 agent。循环的结束条件只有一个:模型不再要求调用工具。

FOR STEP IN RANGE(MAX_STEPS) chat(messages, tools) ① 问模型 有 tool_calls? ② 看回复 是 执行工具,回填结果 ③ handle_call() 结果截断后追加进 messages,再问一次 否 → return message.content ④ 最终回答
Agent 就是一个循环:模型要调工具就继续转,不要就结束
common.run_agent 的工作流程 · 两种模式共用

下面是 common.py 里的 run_agent(),去掉打印语句后只有十几行:

def run_agent(task, system_prompt, tools, handle_call):
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": task},
    ]
    for step in range(1, MAX_STEPS + 1):
        message, usage = chat(messages, tools)       # ① 问模型
        messages.append(message)                     # ② 记下它的回复

        if not message.get("tool_calls"):            # ③ 不调工具了?
            return message.get("content", "")        #    那就是最终回答

        for call in message["tool_calls"]:           # ④ 逐个执行工具
            name = call["function"]["name"]
            arguments = json.loads(call["function"]["arguments"] or "{}")
            result = truncate(handle_call(name, arguments))
            messages.append({"role": "tool",            # ⑤ 结果放回对话
                             "tool_call_id": call["id"],
                             "content": result})

    return "(stopped: too many steps)"               # ⑥ 安全阀

逐段看:

05ReAct 模式

ReAct 模式把每个工具都原原本本地交给模型,模型每一步直接点名调用一个或几个工具,看到结果再决定下一步。这是目前最常见的 agent 写法。

名字来自 2022 年的一篇论文,是 Reason(推理)+ Act(行动)的缩写:想一想 → 做一步 → 看结果 → 再想一想。当年是让模型在文字里写「Thought: … Action: …」然后由程序解析;现在的模型都原生支持 function calling,就直接用上一节讲的 tool_calls。

react_agent.py 只做两件事。

第一件:把 Python 函数自动转成工具定义。 用标准库 inspect 读出函数的参数名和 docstring:

def tool_schema(fn):
    params = inspect.signature(fn).parameters
    return {
        "type": "function",
        "function": {
            "name": fn.__name__,
            "description": inspect.getdoc(fn),
            "parameters": {
                "type": "object",
                "properties": {name: {"type": "string"} for name in params},
                "required": [n for n, p in params.items() if p.default is inspect.Parameter.empty],
            },
        },
    }

TOOL_SCHEMAS = [tool_schema(fn) for fn in ALL_TOOLS]  # 6 个工具全部发给模型

有默认值的参数(比如 list_dir(path="."))就是可选参数,没有的就是必填。为了简单,所有参数类型都当作字符串。

第二件:按名字调用对应的函数。 模型说调 read_file,就去字典里找到 read_file 函数,用 **arguments 把参数传进去:

def handle_call(name, arguments):
    try:
        result = TOOLS[name](**arguments)
        text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
    except Exception as e:
        text = f"Error: {type(e).__name__}: {e}"   # 出错也告诉模型,让它换个办法
    return text

注意出错时不要让程序崩溃,而是把错误信息当作结果还给模型。模型看到「文件不存在」,通常会自己换个路径再试。

一次真实运行:任务是「统计当前目录下每个 .txt 文件的总行数和包含 ERROR 的行数」,目录里有 5 个日志文件。

第几步 本次请求的 prompt token 模型做了什么
1 629 run_shell("pwd && ls -la"),先看看目录里有什么
2 858 run_shell(...),用 wc -l 和 grep -c ERROR 循环统计
3 1009 run_shell(...),用 awk 交叉核对行数
4 1310 run_shell(...),再核对一遍 ERROR 的数量
5 1583 不再调工具,输出统计表

从这张表可以看到 ReAct 的两个特点:

再看另一个任务「现在北京时间几点?再抓取 example.com 告诉我标题」。模型在第 1 步就同时发出了 current_time("Asia/Shanghai") 和 fetch_url("https://example.com") 两个调用,第 2 步直接回答,只用了 2 次请求。互不依赖的事情,现代模型会自己并行去做。

06Code Mode

Code Mode 只给模型一个工具 run_code:模型写一段 Python,在代码里把其他工具当普通函数调用,最后只把 print 出来的内容交回给模型。对应开头的比喻,就是专家写一张任务单,助理照单做完只交结果。

这个思路在业界也叫 PTC(Programmatic Tool Calling,程序化工具调用)。codemode_agent.py 的设计参考了开源 agent OpenClaw 的 Code Mode,有三个关键部件。

部件一:run_code,执行模型写的代码。 核心是 Python 内置的 exec():

def run_code(code):
    namespace = {fn.__name__: fn for fn in QUICK_INDEX}   # 代码里能用的函数
    namespace["search_tools"] = search_tools
    out = io.StringIO()
    try:
        with contextlib.redirect_stdout(out):              # 把 print 的内容截下来
            exec(code, namespace)
    except Exception as e:
        ...                                                # 告诉模型第几行出了什么错
    return out.getvalue() or "(no output -- use print() to return results)"

三个细节:

部件二:精简目录。 模型需要知道代码里有哪些函数可用。我们不发完整的工具定义,只在 run_code 的说明里列出 4 个常用工具的一行签名。模型实际看到的就是这段:

Run a Python program. Only what it print()s is returned to you, so filter and
summarize inside the code instead of printing raw data. Each call starts with
a fresh namespace.

Available functions:
- list_dir(path='.')
- read_file(path)
- write_file(path, content)
- run_shell(command)
- search_tools(query) -> list of tool handles for other tools; call a handle
  like a function, or print(handle.describe()) for its docs.
Example: fetch = search_tools('download web page')[0]; print(fetch('https://...'))

部件三:search_tools,按需查找隐藏工具。 fetch_url 和 current_time 不在目录里。模型要用时,在代码里写 search_tools("download web page"),程序按关键词匹配函数名和 docstring,返回可以直接调用的「工具句柄」;句柄的 .describe() 能查看完整说明。工具一多,这样做能大大缩短每次发给模型的内容。

注意:部件二和部件三并不是 Code Mode 独有的。「工具怎么调用」(写代码 vs 直接点名)和「工具怎么让模型知道」(一次给全 vs 按需搜索)是两个独立的设计选择。这个 demo 把它们搭在了一起,ReAct 一样可以加工具搜索,详见下一节末尾。

真实运行一:同一个日志统计任务。 模型只用了 3 步(prompt 分别是 461、594、951 token,合计 2006)。关键一步它写的代码大致是这样的:

rows = []
for path in sorted(p for p in list_dir() if p.endswith(".txt")):
    total = err = 0
    for line in read_file(path).splitlines():
        total += 1
        if "ERROR" in line:
            err += 1
    rows.append((path, total, err))

for p, t, e in rows:
    print(f"{p}  {t:>6}  {e:>9}")
mx = max(r[2] for r in rows)
print("ERROR 最多的文件:", [r[0] for r in rows if r[2] == mx], "共", mx, "行")

5 个文件的全部内容都只在代码的变量里流转,回到对话里的只有一张 6 行的小表。

真实运行二:模型也会猜错。 任务是「现在北京时间几点?再抓取 example.com 告诉我标题」。

  1. 第 1 步:模型用 search_tools('fetch web page') 找到了 fetch_url,但以为它返回 HTML 字符串,直接用正则去搜 <title>。实际上它返回的是一个 dict,于是报错 TypeError: expected string or bytes-like object, got 'dict'。
  2. 第 2 步:模型读了错误,改成先 print(type(res)) 和 print(repr(res)),看清了返回值的结构。
  3. 第 3 步:给出正确答案「Example Domain」。

这说明了 Code Mode 的一个弱点:目录里只有简短的签名,没写返回值是什么,模型就得靠猜。猜错一次,就多一轮往返。

⚠️ 安全提醒:exec() 会原样执行模型写的代码,没有任何沙箱。模型一旦写出删文件的代码,它就真的会删。真实系统会把代码放到隔离环境里跑,比如单独的容器,或者 OpenClaw 用的 QuickJS 这类只能调用指定函数的小型解释器。

07深入:search_tools 是怎么实现的

search_tools 用关键词在工具名和 docstring 里打分,返回可以直接调用的「工具句柄」。模型只有在自己写的代码里调用它,才能用上被藏起来的 fetch_url 和 current_time;它对应 OpenClaw 里的 catalog.search()。

def search_tools(query):
    """Keyword search over tool names and docstrings (English keywords work best)."""
    words = re.findall(r"\w+", query.lower())          # ① 把查询拆成单词
    scored = []
    for fn in ALL_TOOLS:                               # ② 逐个工具打分
        haystack = (fn.__name__.replace("_", " ") + " " + (fn.__doc__ or "")).lower()
        score = sum(word in haystack for word in words)
        if score:
            scored.append((score, fn))
    scored.sort(key=lambda pair: -pair[0])             # ③ 按分数从高到低
    return [ToolHandle(fn) for _, fn in scored[:5]]    # ④ 最多返回 5 个句柄
  1. 拆词:"download web page" → ["download", "web", "page"],统一转成小写。
  2. 打分:每个工具的「被搜索文本」是函数名(下划线换成空格)加 docstring。查询里的词每出现在这段文本中一个,就得 1 分。fetch_url 的文本是 "fetch url download a web page and return ...",三个词全中,得 3 分。
  3. 排序:分数高的排前面,0 分的直接丢掉。
  4. 返回句柄:不是直接返回函数,而是包一层 ToolHandle。

ToolHandle:搜出来的工具句柄

class ToolHandle:
    def __init__(self, fn):
        self.fn = fn
        self.name = fn.__name__
    def __call__(self, *args, **kwargs):   # 可以像函数一样直接调用
        return self.fn(*args, **kwargs)
    def describe(self):                    # 查看完整签名和说明
        return f"{signature(self.fn)}\n{inspect.getdoc(self.fn)}"
    def __repr__(self):                    # print 时显示签名
        return f"<tool {signature(self.fn)}>"

模型在代码里通常这样用:

fetch = search_tools("download web page")[0]   # 找到工具
print(fetch.describe())                        # 不确定用法时先看说明(可选)
print(fetch("https://example.com"))            # 像普通函数一样调用

这样设计的好处是「按需付费」:搜索结果和 describe() 的说明,只有模型真的打印出来时才进入对话;用不到的工具,说明永远不会发给模型。

实际查询效果

查询 返回 说明
"download web page" fetch_url 理想情况:关键词和 docstring 吻合
"time" current_time 命中函数名
"file" list_dir、read_file、write_file 都和文件有关,各得 1 分
"read a text file" 5 个工具,read_file 排第一 噪声:单词 "a" 几乎出现在每段说明里,fetch_url 的说明里还有 "text"
"下载网页" 空 docstring 是英文,中文查询完全匹配不上
"weather" 空 没有相关工具,返回空列表

局限,以及真实系统怎么做

这是为了教学写得最简单的版本,有几个明显的局限:

OpenClaw 的真实实现在这些方面做了改进:用 BM25 算法打分,出现得越少的词权重越高,自然压低 "a"、"the" 这类常见词;工具名完全一致时直接排第一;工具名按下划线、驼峰拆成单词建索引。但它同样是词法匹配,文档也明确说要用英文查询。所以在 Code Mode 下,docstring 写得好不好直接决定模型能不能找到工具。

想动手改进的话,可以先用 re.findall(r"\w+", haystack) 把文本也拆成单词,改成整词匹配;再加一个停用词表,过滤掉 "a"、"the"、"and"。改完再搜 "read a text file",read_file 就会明显排在最前面,无关的工具也会少很多。

工具搜索不是 Code Mode 专属

工具搜索和 Code Mode 是两件独立的事:Code Mode / ReAct 决定「工具怎么调用」,工具搜索决定「工具怎么让模型知道」。两个维度可以任意组合:

全部工具定义一次性给出 工具藏起来,按需搜索
ReAct(直接调用) 本教程的 react_agent.py,最常见的写法 OpenClaw 的 Tool Search 模式;Claude Code 的延迟加载工具
Code Mode(写代码调用) 把所有工具签名都写进 run_code 的说明 本教程的 codemode_agent.py;OpenClaw 的 Code Mode

之所以常常放在一起说,是因为 Code Mode 只有一个工具,「隐藏其他工具」在这里很自然:搜索就是代码里的一次函数调用,搜到后在同一段代码里马上就能用,不用多一轮往返。但这只是搭配,不是必须。

ReAct 加工具搜索,常见有两种做法:

两种组合各有代价:

ReAct + 工具搜索 Code Mode + 工具搜索
搜到后调用 至少多一轮往返:先搜,下一步才能调用 同一段代码里搜完就能调用
参数怎么知道 做法一能拿到完整 schema,参数不容易写错 只有精简签名,容易猜错
提示缓存 做法一每加载一个工具,tools 列表就变一次,前缀缓存失效 tools 列表始终不变,缓存友好

最后一点在真实系统里很重要。模型服务的提示缓存要求请求前缀完全一致,工具列表一变,后面的内容都要重新按全价计费。

08两种模式对比

两种模式用的是同一个循环、同一组工具,区别只在于:模型是「一次点一个工具」,还是「写一段程序把工具串起来」。

REACT 逐次点名,每个工具一来一回 5 次请求 123 45 tooltool tooltool 每一步的工具结果都留在对话里,下一步连同历史一起重发 CODE MODE 写一段程序,只交回 print 的结果 3 次请求 123 run_code #1run_code #2 文件内容只在代码变量里流转,回到对话的只有一张六行小表
ReAct 每个工具一来一回,Code Mode 一段程序跑完再汇报
同一个「统计 5 个日志文件」任务在两种模式下的交互
对比项 ReAct(react_agent.py) Code Mode(codemode_agent.py)
模型看到的工具 6 个,每个带完整定义 1 个 run_code,说明里只有 4 行签名
模型每一步的输出 工具名 + 参数 一段 Python 程序
什么回到对话里 每个工具的完整结果 只有 print 的内容
循环、判断、计算由谁做 模型在脑子里做 代码里的 for、if
工具很多时 默认每次都带全部定义,很贵;也可以加工具搜索 常用的列签名,其余用 search_tools 查
出错时 工具报错,模型换个工具或参数 代码报错,模型改代码重跑
安全风险 只能做工具允许的事 能执行任意代码,必须隔离

实测数据(DeepSeek V4 Flash,每个任务各跑 1 次,答案都正确):

任务 模式 请求次数 prompt token 合计 第 1 次请求的 prompt
统计 5 个日志文件 ReAct 5 5,389 629
统计 5 个日志文件 Code Mode 3 2,006 461
查时间 + 抓网页标题 ReAct 2 1,453 621
查时间 + 抓网页标题 Code Mode 2 1,357 453

怎么读这张表:

09Code Mode 更先进吗

不能简单这么说。Code Mode 的表达能力更强,但它是一种取舍,不是全面升级。

它强在哪里。 ReAct 能做的事,Code Mode 基本都能做:只调用一个工具的程序,就等于一次 ReAct 调用。反过来,循环、条件、在调用之间传递数据、精确计算,ReAct 只能靠模型多轮对话来实现,Code Mode 用几行代码就写完了。从这个角度看,Code Mode 是更通用的形式。业界也在往这个方向走,见本节末尾的延伸阅读;OpenClaw 从 2026.9.6 版起,也对它认为合适的模型默认开启了 Code Mode。

但它有几个绕不开的代价:

  1. 代码不会「理解」内容。 程序能数 ERROR、做排序和汇总,但「这段日志说明了什么问题」「这篇文章值不值得读」这类判断只有模型能做。需要判断中间结果的任务,数据迟早要回到模型那里,Code Mode 就省不了多少。这类任务在实际 agent 里很常见,比如排查 bug、做调研。
  2. 要求模型事先想清楚。 写程序意味着一次规划好多步;ReAct 是走一步看一步,看到结果再决定。探索性的任务,后者往往更稳。
  3. 对模型能力要求更高。 小模型写代码不可靠,OpenClaw 社区里有人用本地 9B 模型强开 Code Mode,6 个任务一个都没过。
  4. 工具不在眼前,就要付发现成本。 隐藏的工具要先搜、再猜参数,容易出错。OpenClaw 实测的浏览器任务里,模型干脆改用一个看得见的工具在桌面上一下下点,计费反而涨到 3–6 倍。
  5. 工程成本更高。 要执行模型写的任意代码,就必须做沙箱隔离、超时和资源限制。出了问题,也比看一串工具调用记录更难排查。

所以更准确的说法是:Code Mode 是一种更强、但门槛也更高的工具。成熟的系统一般不会二选一,而是混合使用:常用工具直接暴露给模型,再提供一个运行代码的工具,让模型在需要批量处理时自己选择写代码。具体怎么选,见下一节。

延伸阅读:业界的同类做法

10什么时候用哪种

没有哪种模式永远更好。任务需要「批量、循环、计算」时 Code Mode 占优;任务只有一两步,或者要用不常见的复杂工具时,ReAct 反而更省心。

场景 更合适的模式 原因
处理很多文件、很多条数据 Code Mode 一段循环做完,中间数据不进对话
需要精确计算(求和、排序、统计) Code Mode 代码算不会错,模型心算容易错
工具非常多(几十上百个) Code Mode 搭配工具搜索后,搜到的工具在同一段代码里就能调用;ReAct 加工具搜索也能省掉大部分定义,只是多一轮往返
一两步就能完成的简单任务 ReAct 写代码本身也要花 token,不划算
要用参数复杂、不常用的工具 ReAct 工具定义就在眼前,不用先搜索再猜参数
模型比较小、写代码不稳 ReAct Code Mode 对模型的编程能力要求更高
没法做安全隔离的环境 ReAct Code Mode 要执行任意代码

真实系统里的经验。 我们在开源 agent OpenClaw 上用 DeepSeek V4 Flash 做过一组实验:一个有 55 个工具的系统,开启 Code Mode 前后各跑了几十次任务。结论和这个小 demo 是一致的:

这提醒我们:模型不一定按你设想的方式使用工具。设计 agent 时,工具描述和系统提示词里的一句话,往往比架构更能决定效果;而唯一可靠的判断方法是用真实任务反复测量。

还有一个常见的做法是混合:常用的少数工具直接暴露(ReAct 方式),再额外提供一个运行代码的工具,让模型在需要批量处理时自己选择写代码。

11动手练习

读懂代码最好的办法是改它。下面的练习从易到难排列,每题都只需要改几行。

做完这些,你就已经理解了市面上大多数 agent 框架的核心。它们代码量大得多,但大部分是在这个循环外面加功能:工具更多、记忆压缩、权限控制、安全沙箱、多个 agent 协作。