从零理解 AI Agent:ReAct 与 Code Mode

0.1. 01Agent 是什么

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

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

Agent 给它补上了两样东西:

  • 工具 :一些普通的程序函数,比如读文件、跑命令、抓网页。模型自己不会执行它们,但可以说「请帮我调用 read_file,参数是 log1.txt」。
  • 循环 :我们的程序替模型执行工具,把结果喂回给它,再问它下一步做什么,直到它说「我答完了」。

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

  • ReAct 模式 :每张纸条写一个具体动作,「拿 log1.txt」。助理拿回来、专家看完,再写下一张。
  • Code Mode :专家写一张「任务单」,就是一段程序:「把所有 txt 都拿来,数一下每个有几行 ERROR,只把统计表给我」。助理照单子一口气做完,只交回结果。

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

0.2. 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 命令、写文件。请在一个没有重要文件的空目录里练习。

0.3. 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 行」了。

有三个容易忽略的点:

  • 模型没有记忆。 每次请求都要把完整的对话历史和工具列表重新发一遍。对话越长、工具越多,每次请求就越贵。这是后面理解两种模式差别的关键。
  • arguments 是一个 JSON 字符串 ,不是对象,要先 json.loads() 。
  • 模型只看得到 description 和参数名 。工具描述写得不清楚,模型就会用错。

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"]

0.4. 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)"               # ⑥ 安全阀

逐段看:

  • messages 就是 agent 的全部「记忆」。 开头只有系统提示词和用户任务,之后每一轮都往里追加模型的回复和工具结果,越来越长。
  • ② 必须把模型的回复原样追加。 否则下一轮模型看到一条工具结果,却找不到是哪次调用产生的,API 会报错。
  • ④ 一轮可能有多个工具调用。 模型可以一次说「同时查时间和抓网页」,这叫并行调用。
  • handle_call 是传进来的参数。 循环不关心工具怎么执行,只管「给我名字和参数,还我一段文字」。 两种模式的全部区别,就在传进来的 tools 和 handle_call 不同。
  • truncate() 防止一次结果太大 (比如读了一个 10MB 的文件),把对话撑爆。超过 4000 字符就截断。
  • ⑥ MAX_STEPS 是安全阀。 模型有时会陷入尝试—失败—再尝试的死循环,限制最多 10 轮,避免无限烧钱。

0.5. 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 的两个特点:

  • prompt 越滚越大。 每一步的工具结果都留在对话里,下一步要连同它们一起重发。五步加起来,模型一共读了 5389 个 prompt token。
  • 模型会挑最顺手的工具。 虽然有 read_file ,它全程只用了万能的 run_shell 。多做的两轮核对是模型自己决定的,换一次运行可能就没有。

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

0.6. 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)"

三个细节:

  • namespace 决定了代码能用什么。 把工具函数放进这个字典,模型的代码里就能直接写 read_file("a.txt") 。每次调用都新建一个,所以变量不会带到下一次。
  • redirect_stdout 把 print 的输出存进 out 。 只有这部分会回到对话里。代码里读了 5 个文件、存了多少中间变量都不要紧,模型只看得到最后打印的统计表。
  • 出错时指出模型代码的第几行 ,比如 Error at line 4: re.search(...) 加上异常信息,模型据此修改代码重试。

部件二:精简目录。 模型需要知道代码里有哪些函数可用。我们不发完整的工具定义,只在 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 这类只能调用指定函数的小型解释器。

0.7. 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" 空 没有相关工具,返回空列表

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

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

  • 用子串匹配,不是整词匹配 : "a" 能匹配任何含字母 a 的单词, "time" 也能匹配 "timezone" 。
  • 所有词权重一样 : "a" 和 "download" 都算 1 分,但后者明显更有区分度。
  • 只认字面 :搜 "browse" 找不到写着 "web page" 的工具,不理解同义词,也不支持中文。

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

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

0.7.1. 工具搜索不是 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 加工具搜索,常见有两种做法:

  • 做法一:搜到后加进下一次请求的 tools 列表。 一开始只给少数常用工具和一个 search_tools 。模型搜索后,程序把匹配到的工具的完整定义加入后续请求,下一步模型就能像普通工具一样直接调用。Claude Code 就是这么做的:很多工具一开始只列名字,要先调 ToolSearch 加载定义才能用。
  • 做法二:用一个通用的「转发」工具。 只暴露三个固定工具: search_tools(query) 、 describe_tool(name) 、 call_tool(name, arguments) 。模型先搜、再看说明,最后用 call_tool("fetch_url", {...}) 间接调用。OpenClaw 的 Tool Search 模式就是这种,而且文档写明它和 Code Mode 在同一次运行里二选一——正好说明这是两种不同的「隐藏工具」实现。

两种组合各有代价:

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

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

0.8. 08两种模式对比

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

REACT 逐次点名,每个工具一来一回 5 次请求 1 2 3 4 5 tool tool tool tool 每一步的工具结果都留在对话里,下一步连同历史一起重发 CODE MODE 写一段程序,只交回 print 的结果 3 次请求 1 2 3 run_code #1 run_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

怎么读这张表:

  • 第 1 次请求就差了约 170 token。 这时还没有任何工具结果,差额全部来自工具定义。这个 demo 只有 6 个很短的工具,所以差得不多;真实产品里常有几十个工具,单个工具的定义就可能上万字。OpenClaw 实测里,每次请求因此从约 5 万 token 降到约 2.5 万。
  • 日志任务 Code Mode 省了一半以上。 一段循环比好几轮往返划算,而且文件内容没进对话。
  • 网页任务几乎打平。 ReAct 一步并行发出了两个调用;Code Mode 虽然第 1 步就拿到了时间和网页,但写代码本身要花更多输出 token,还要先搜索隐藏工具。
  • 只跑了一次,噪声很大。 模型每次的决策都可能不同,严谨的对比要每个任务跑多轮取平均。

0.9. 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 是一种更强、但门槛也更高的工具。成熟的系统一般不会二选一,而是混合使用:常用工具直接暴露给模型,再提供一个运行代码的工具,让模型在需要批量处理时自己选择写代码。具体怎么选,见下一节。

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

  • Anthropic:Programmatic Tool Calling。 Claude 在代码执行容器里写代码调用你的工具,中间结果不进上下文。参见 官方文档 ,以及工程博客《 Introducing advanced tool use on the Claude Developer Platform 》(2025-11-24)。这篇博客同时介绍了 Tool Search Tool,把「写代码调用」和「按需搜索工具」当作两个独立功能,正好印证了上一节的说法。
  • Cloudflare:Code Mode。 《 Code Mode: the better way to use MCP 》(2025-09-26,Kenton Varda、Sunil Pai)把 MCP 工具转成 TypeScript API,让模型写代码调用,而不是一个个调工具。
  • DeepSeek Harness:PTC 模式。 《 DeepSeek Harness:Creator 与 PTC,两种拆开 agent 的方式 》里的 PTC 模式:模型只有一个 run_code 工具,其余工具变成 SDK 绑定,只有 console.log 和 return 的内容回到对话。
  • OpenClaw:Code Mode。 本教程参考的开源实现,见 官方文档 。

0.10. 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 是一致的:

  • 常规任务省了约 32% 。主要来自每次请求不再带 55 个工具的完整定义,prompt 从约 5 万 token 降到约 2.5 万。
  • 浏览器任务反而贵了 3–6 倍。 浏览器工具被藏进了目录,模型不去搜它,而是改用一个「看得见的」桌面操作工具,在屏幕上一下下点,每点一下都是一轮请求。
  • 模型经常没用好「只 print 摘要」。 它常常把文件全文直接打印回来自己看,Code Mode 最大的优势就浪费了。

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

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

0.11. 11动手练习

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

  • 练习 1:加一个工具。 在 common.py 里写一个 calculator(expression) ,把它加进 ALL_TOOLS 。分别用两种模式问「123 乘 456 加 789 等于多少」,观察:ReAct 版自动多了这个工具;Code Mode 版的模型可能根本不用它,直接在 Python 里算。想一想为什么。
  • 练习 2:观察工具描述的影响。 把 read_file 的 docstring 改成一句含糊的话(比如「Do something with a path.」),再跑日志统计任务,看模型还会不会用它。
  • 练习 3:让 Code Mode 不再猜返回值。 给每个工具加上返回类型注解,比如 def fetch_url(url) -> dict:, inspect.signature 会自动把它显示在签名里。重跑「抓取 example.com 的标题」,看是否还会猜错。
  • 练习 4:统计总花费。 在 run_agent() 里累加每步的 prompt_tokens 和 completion_tokens ,结束时打印总数。然后每种模式各跑 5 遍,算平均值,比较哪个更省。
  • 练习 5:给 run_code 加超时。 现在模型写一个 while True: pass ,程序就会卡死。提示:可以用 multiprocessing 把代码放到子进程里跑,超过 10 秒就终止它。
  • 练习 6:做一个混合模式。 写一个 hybrid_agent.py :把 read_file 、 list_dir 按 ReAct 方式直接暴露,再加上 run_code ,系统提示词里写「简单操作直接调工具,批量处理时写代码」。只要在 handle_call 里按 name 分两种情况处理就行。
  • 练习 7:给 ReAct 加上工具搜索。 在 react_agent.py 里一开始只暴露 list_dir 、 read_file 、 write_file 、 run_shell 和一个新的 search_tools(query) 工具;模型搜索后,把匹配到的工具定义加进下一次请求的 tools (上文的做法一)。提示: run_agent() 每步都重新读 tools ,所以传一个列表进去,在 handle_call 里往里 append 就行。重跑「查时间 + 抓网页标题」,对比第 1 步的 prompt token 和总步数。
  • 练习 8(思考题): Code Mode 每次 run_code 都新建命名空间,变量不能跨次保留。如果让它保留,有什么好处?会带来什么问题?(提示:想想程序重启、内存,以及模型怎么知道里面存了什么。)

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