从零理解 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。循环的结束条件只有一个:模型不再要求调用工具。
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 步:模型用
search_tools('fetch web page')找到了fetch_url,但以为它返回 HTML 字符串,直接用正则去搜<title>。实际上它返回的是一个 dict,于是报错TypeError: expected string or bytes-like object, got 'dict'。 - 第 2 步:模型读了错误,改成先
print(type(res))和print(repr(res)),看清了返回值的结构。 - 第 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 个句柄
- 拆词 :
"download web page"→["download", "web", "page"],统一转成小写。 - 打分 :每个工具的「被搜索文本」是函数名(下划线换成空格)加 docstring。查询里的词每出现在这段文本中一个,就得 1 分。
fetch_url的文本是"fetch url download a web page and return ...",三个词全中,得 3 分。 - 排序 :分数高的排前面,0 分的直接丢掉。
- 返回句柄 :不是直接返回函数,而是包一层
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 每个工具一来一回,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。
但它有几个绕不开的代价:
- 代码不会「理解」内容。 程序能数 ERROR、做排序和汇总,但「这段日志说明了什么问题」「这篇文章值不值得读」这类判断只有模型能做。需要判断中间结果的任务,数据迟早要回到模型那里,Code Mode 就省不了多少。这类任务在实际 agent 里很常见,比如排查 bug、做调研。
- 要求模型事先想清楚。 写程序意味着一次规划好多步;ReAct 是走一步看一步,看到结果再决定。探索性的任务,后者往往更稳。
- 对模型能力要求更高。 小模型写代码不可靠,OpenClaw 社区里有人用本地 9B 模型强开 Code Mode,6 个任务一个都没过。
- 工具不在眼前,就要付发现成本。 隐藏的工具要先搜、再猜参数,容易出错。OpenClaw 实测的浏览器任务里,模型干脆改用一个看得见的工具在桌面上一下下点,计费反而涨到 3–6 倍。
- 工程成本更高。 要执行模型写的任意代码,就必须做沙箱隔离、超时和资源限制。出了问题,也比看一串工具调用记录更难排查。
所以更准确的说法是: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 协作。