第 3 章 · Part 1
循环范式
本章你将回答:
- 没有 function calling API 的年代,agent 是怎么调工具的?
- 让模型直接写代码执行(CodeAct),好在哪、险在哪?
- 为什么现代框架几乎都默认原生 function calling?
同一个循环,三种方言
第 1 章和第 2 章里,你的循环从头到尾都靠 API 的 tool_calls 字段过活:模型的”决策”藏在 msg.tool_calls 里,工具的”观察”用 role: "tool" 的消息回灌。这一切顺理成章,顺到你多半从没停下来想过——为什么”调工具”非得长这样?其实不非得。tool_calls 只是”决策→行动→观察”这个循环的一种编码方式,是服务端替你约定好的一种线上格式。循环的本质三拍并不在乎行动到底是用 JSON 字段、还是用一行文本、还是用一段代码来表达。
这一章,我们把同一批工具(就是第 2 章那份 tools.py,原样复制了过来)、同一个任务,塞进三种不同的范式里跑一遍:原生 function calling(native.py)、ReAct 文本协议(react_text.py)、CodeAct(codeact.py)。registry 还是那个 registry,calculator、now、list_files、read_file 一个没变。变的只有一件事:模型怎么说出”我要调工具”,以及结果怎么回到它眼前。把它们并排放在一起,你就能看清循环范式的实质——它们是同一个循环的三种方言,不是三种互不相干的东西。
停下来想想假设你的 API 只会生成纯文本,没有 tool_calls 字段。你怎么让模型”调工具”?
先自己想一想,再展开参考思路
只剩一条路:约定输出格式。在 system prompt 里规定”要调工具就输出 Action: 工具名”,然后用正则把它解析出来、执行、把结果拼回 prompt。
这就是 2022 年 ReAct 论文的做法,也是 LangChain 早期版本的核心——function calling API 出现之前,整个 agent 生态都跑在正则表达式上。
范式一 · 原生 function calling
"""范式一 · 原生 function calling:与 ch02 完全相同——这就是重点。
运行:python native.py "1234 * 5678 等于多少?再开平方呢?"
"""
import os
import sys
from openai import OpenAI
from tools import registry
client = OpenAI(base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.environ["OPENAI_API_KEY"])
MODEL = os.getenv("MODEL", "gpt-4o-mini")
MAX_STEPS = 10
def run(task: str) -> str:
messages = [
{"role": "system", "content": "你是一个会用工具的助手。用工具获取事实,得到最终答案后直接回答。"},
{"role": "user", "content": task},
]
for step in range(1, MAX_STEPS + 1):
msg = client.chat.completions.create(model=MODEL, messages=messages, tools=registry.schemas()).choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
for call in msg.tool_calls: # 模型可能一次请求多个工具调用(并行工具调用)
result = registry.call(call.function.name, call.function.arguments)
print(f" [step {step}] {call.function.name}({call.function.arguments}) -> {str(result)[:80]}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
return "(超出最大步数,强制停止)"
if __name__ == "__main__":
print("最终答案:", run(sys.argv[1] if len(sys.argv) > 1 else "现在几点?顺便算一下 365*24。"))
打开 native.py,你大概会有种”就这?“的错觉——它和第 2 章的 agent.py 一字不差,唯一动过的地方是模块 docstring。这段代码块上没有任何高亮(不像上一章那样标注”本章新增/修改”的行),因为确实一行逻辑都没改:还是 create(..., tools=registry.schemas()) 把工具清单发出去,还是读 msg.tool_calls,还是 registry.call 分发、role: "tool" 回灌。这不是偷懒,恰恰是本节的全部要点。
你会觉得”没改”值得单开一节,是因为你一直把这段循环当成了”agent 本来的样子”,而没意识到它其实是一个做过选择的范式——原生 function calling 范式。我们只是从没给它起过名字。它的底气来自 tool_calls 这个字段:它是服务端在约束解码时保证出来的结构化产物,格式有硬保证,你拿到手直接用,不必解析、不必兜底、不必赌模型这次听不听话。记住它此刻这副”理所当然”的样子——接下来两种范式,会在没有这个字段的世界里,把同一件事从头做一遍,你就知道这份”理所当然”有多值钱了。
范式二 · ReAct 文本协议
"""范式二 · ReAct 文本协议:不依赖 function calling API,靠 prompt 约定 + 正则解析。
运行:python react_text.py "1234 * 5678 等于多少?再开平方呢?"
"""
import os
import re
import sys
from openai import OpenAI
from tools import registry
client = OpenAI(base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.environ["OPENAI_API_KEY"])
MODEL = os.getenv("MODEL", "gpt-4o-mini")
MAX_STEPS = 10
SYSTEM = """尽你所能回答问题。你可以使用以下工具:
{tools}
严格按照如下格式输出(一次只走一步):
Thought: 思考接下来该做什么
Action: 工具名,必须是 [{names}] 之一
Action Input: 工具的输入参数,JSON 格式
Observation: 工具返回的结果(由系统填写,你不要生成这一行)
...(Thought/Action/Action Input/Observation 可以重复多次)
Thought: 我已经知道最终答案了
Final Answer: 对问题的最终回答"""
ACTION_RE = re.compile(r"Action:\s*(.+?)\s*\nAction Input:\s*(\{.*?\})", re.S)
FINAL_RE = re.compile(r"Final Answer:\s*(.*)", re.S)
def run(task: str) -> str:
schemas = registry.schemas()
system = SYSTEM.format(
tools="\n".join(f"- {s['function']['name']}: {s['function']['description']}" for s in schemas),
names=", ".join(s["function"]["name"] for s in schemas),
)
scratchpad = f"Question: {task}\n"
for _ in range(MAX_STEPS):
text = client.chat.completions.create(
model=MODEL,
messages=[{"role": "system", "content": system}, {"role": "user", "content": scratchpad}],
stop=["Observation:"], # 关键:拦住模型,不让它自己编造观察结果
).choices[0].message.content
scratchpad += text
if m := FINAL_RE.search(text):
return m.group(1).strip()
if m := ACTION_RE.search(text):
obs = registry.call(m.group(1).strip(), m.group(2))
print(f" [{m.group(1).strip()}] {m.group(2)} -> {str(obs)[:80]}")
scratchpad += f"\nObservation: {obs}\n"
else: # 格式漂移:文本协议的最大痛点(本章挑战会改进这里)
scratchpad += "\nObservation: 输出格式不合法,请严格按 Thought/Action/Action Input 或 Final Answer 输出。\n"
return "(超出最大步数,强制停止)"
if __name__ == "__main__":
print("最终答案:", run(sys.argv[1] if len(sys.argv) > 1 else "1234 * 5678 等于多少?再开平方呢?"))
如果模型和服务端根本不给你 tool_calls 字段,你手里就只剩一根杠杆:prompt。react_text.py 的 SYSTEM 模板把两样东西一起写死进 system prompt——一是工具清单({tools} 展开成”- calculator: 计算一个 Python 数学表达式…”这样每个工具一行的自然语言说明,{names} 则是可选工具名的列表),二是一套死板的输出格式:Thought / Action / Action Input / Observation / Final Answer。从此模型不再”调用”工具,而是”写出”Action: calculator 这行文本,剩下的交给我们去解析、执行。注意 run() 里的 system 是用 registry.schemas() 动态拼出来的——工具还是第 2 章那一套,只是表达方式从 JSON schema 退回成了给模型读的文本清单。
这里最妙、也最容易被忽略的一行,是 create(...) 里的 stop=["Observation:"]。想想看:格式模板里 Observation 那行明明白白写着”由系统填写,你不要生成这一行”——可模型骨子里是个文本补全器,它完全可以顺手把 Observation: 42 也一起编出来。这就是幻觉观察:模型凭空捏造一个工具还没跑出来的结果,然后煞有介事地基于这个假结果往下推理,一步错步步错,你却看不出破绽。stop 序列的作用,就是在 API 生成到 "Observation:" 那一刻当场刹车,把行动权硬生生夺回到我们代码手里:由我们真正 registry.call 执行工具、把真实结果拼回去。少了这一行,ReAct 循环随时会滑进”自问自答”的幻觉里——这是文本协议的第一个隐形陷阱。
拿到模型吐出的纯文本,接下来就得靠正则把结构抠出来。ACTION_RE(r"Action:\s*(.+?)\s*\nAction Input:\s*(\{.*?\})")负责一次性抓出工具名和紧跟其后的 JSON 参数,FINAL_RE 负责抓 Final Answer。每一步的逻辑很直白:先看有没有 Final Answer(有就 return 结束),否则找 Action,命中就 registry.call、再把 f"\nObservation: {obs}\n" 追加进 scratchpad。这个 scratchpad 就是本范式的记忆——它不像 native.py 那样用结构化的 messages 列表,而是把 Question / Thought / Action / Observation 全揉成一根不断变长的字符串,每一轮再把整根 scratchpad 当成 user 消息重新发一遍。
但这套东西全靠模型”守规矩”,而这没有任何硬保证。真实场景里模型经常格式漂移:把 Action Input 写成不合法的 JSON、漏掉某一行、拿 markdown 代码块把它包起来、或者干脆把 Thought 和 Action 糊成一段。正则一旦抓不到,就落进那个 else 分支,我们只能回灌一句”输出格式不合法,请严格按 Thought/Action/Action Input 或 Final Answer 输出”逼它重来。这就是文本协议的最大痛点:可靠性全押在 prompt 约定 + 容错兜底上。连 Astron 这样的生产框架也没能彻底解决它:它的 CoT runner 同样脆弱,格式一漂移就直接抛 CotFormatIncorrectExc——本章末尾的动手挑战,就要你亲手去改进这个 else 分支。
范式三 · CodeAct
"""范式三 · CodeAct:模型的"行动"不是 JSON 工具调用,而是直接写 Python 代码。
运行:python codeact.py "统计 1 到 100 中所有质数的和"
"""
import os
import re
import subprocess
import sys
import tempfile
from openai import OpenAI
client = OpenAI(base_url=os.getenv("OPENAI_BASE_URL"), api_key=os.environ["OPENAI_API_KEY"])
MODEL = os.getenv("MODEL", "gpt-4o-mini")
MAX_STEPS = 8
TIMEOUT = 15 # 秒;沙箱的第一道防线
SYSTEM = """你通过编写 Python 代码来完成任务,一轮写一段。
- 用 ```python 代码块输出代码;用 print() 打印你需要观察的中间结果。
- 代码在独立进程里执行,轮与轮之间不共享变量;需要延续的状态请重新计算或写入文件。
- 得到最终答案后,不要再写代码块,直接输出:FINAL: 你的答案"""
CODE_RE = re.compile(r"```python\n(.*?)```", re.S)
def run_sandboxed(code: str) -> str:
"""子进程 + 超时 = 最简"沙箱"。它挡得住死循环,挡不住文件读写——真正的隔离见 runtime-execution。"""
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8") as f:
f.write(code)
path = f.name
try:
r = subprocess.run([sys.executable, path], capture_output=True, text=True, timeout=TIMEOUT)
return (r.stdout + r.stderr).strip() or "(无输出——记得用 print())"
except subprocess.TimeoutExpired:
return f"执行超时(>{TIMEOUT}s),代码可能死循环了"
finally:
os.unlink(path)
def run(task: str) -> str:
messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": task}]
for _ in range(MAX_STEPS):
text = client.chat.completions.create(model=MODEL, messages=messages).choices[0].message.content
messages.append({"role": "assistant", "content": text})
if "FINAL:" in text and not CODE_RE.search(text):
return text.split("FINAL:", 1)[1].strip()
if m := CODE_RE.search(text):
obs = run_sandboxed(m.group(1))
print(f" [exec] {len(m.group(1))} 字符代码 -> {obs[:120]}")
messages.append({"role": "user", "content": f"执行结果:\n{obs}"})
else:
messages.append({"role": "user", "content": "请输出 ```python 代码块,或用 FINAL: 给出最终答案。"})
return "(超出最大步数,强制停止)"
if __name__ == "__main__":
print("最终答案:", run(sys.argv[1] if len(sys.argv) > 1 else "统计 1 到 100 中所有质数的和"))
ReAct 把行动编码成文本、native.py 把行动编码成 JSON,CodeAct 则走得更彻底:行动本身就是一段 Python 代码。codeact.py 的 SYSTEM 要求模型用 ```python 代码块输出代码、用 print() 打印需要观察的中间结果;CODE_RE(r"```python\n(.*?)```")把代码块从回复里抠出来,run_sandboxed 执行它,再把 stdout + stderr 当作 observation 回灌;模型觉得做完了,就输出 FINAL: xxx(且不再带代码块)结束循环。注意这个文件连 tools.py 都不 import 了——CodeAct 的”工具”就是整门 Python 语言本身。
真正拉开差距的是表达力。native 和 ReAct 一轮只能发一个(或几个并行的)工具调用,遇到”统计 1 到 100 中所有质数的和”这种任务,要么指望模型硬算(不可靠),要么几十轮 JSON 往返一个一个数地试。CodeAct 一轮就写个循环加条件判断搞定,中间变量随手复用、分支随意组合——一段代码顶多轮对话。这正是 smolagents 把 CodeAgent 定为默认范式的理由:HuggingFace 的论点是,代码比 JSON 更贴近模型预训练时见过的数据分布,模型”用代码思考”更自然、步数更少、难任务上表现更好。Botpress 的 LLMz 让模型直接写 TypeScript,押的是同一个赌注。
但”让模型写代码并执行”这件事本身就是一把双刃剑。run_sandboxed 用 subprocess.run 起一个独立子进程去跑 sys.executable,配上 timeout=TIMEOUT(15 秒)。这是”沙箱”的第一道、也是最粗糙的一道防线:子进程隔离了 agent 自己的进程状态(模型写的代码碰不到我们循环里的变量),timeout 则挡得住死循环。可正如它 docstring 自己坦白的那样——它挡得住死循环,挡不住文件读写。子进程照样能 open('/etc/passwd'),照样能联网发数据。真正的隔离得靠容器、microVM、权限收敛这些更重的手段,而那正是下面这道思考题、以及 运行时与执行 这个组件要展开讲的全部主题。
停下来想想run_sandboxed
若改成进程内
exec()
,会有什么问题?换成 subprocess 就”安全”了吗?
三种范式没有赢家,只有取舍
检查你的理解
ReAct 文本协议最大的工程痛点是?
查看解析
文本协议依赖模型严格遵守格式约定,而这没有任何硬保证。生产框架为此写了大量容错:解析失败回灌重试、多种格式兼容……这正是原生 function calling 解决的问题。
CodeAct 范式的核心优势是?
查看解析
“求 100 个数的均值再排序”用 JSON 工具要几十轮,写代码一轮搞定。代价恰恰是安全性——所以 CodeAct 必须配沙箱。
为什么现代框架几乎都默认原生 function calling?
查看解析
tool_calls 字段是服务端约束解码的产物,格式有硬保证。工具 schema 照样占上下文,速度也没有优势——可靠性才是关键。
动手挑战
给 react_text.py 的格式漂移分支升级:解析失败时,把具体错在哪(缺 Action?JSON 不合法?)作为 Observation 回灌,并统计连续失败次数,3 次即中止。再对比:同一个任务跑 native.py 和 react_text.py 各 5 遍,谁的成功率高?
小结
- 范式就是循环的方言。三种写法跑的是同一个”决策→行动→观察”循环,用的是同一批工具、同一个任务,区别只在行动怎么编码——JSON 工具调用、Thought/Action 文本、还是 Python 代码。
native.py和第 2 章一字不差这件事本身就在提醒你:你早就在用一种范式了,只是没给它起名字。 - 选型是”模型能力 × 安全需求”的权衡,没有赢家。模型和服务端支持 function calling,就用原生(结构由服务端保证,最省心);不支持,就退回文本协议(模型无关,但解析脆弱、要大量容错);想让模型一轮组合复杂逻辑、又备好了安全沙箱,才上 CodeAct(表达力最强,但等于在跑任意代码)。这几条取舍,循环范式和 设计权衡里有更完整的谱系。
- 三种范式共享同一个隐患。无论行动编码成 JSON、文本还是代码,
scratchpad(ReAct)和messages(native)都在随循环一圈圈变长——ReAct 每轮把整根scratchpad重发,native 每轮把全部messages重发。循环跑得越久、走的步数越多,上下文就越臃肿,撞上模型的窗口上限只是时间问题。下一章 上下文工程,就来收拾这个越滚越大的雪球。