本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。

Function Calling 把工具调用收进协议字段,模型发一个结构化的 tool_calls,程序照着执行。可靠,代价是模型为什么选这个工具、为什么这么填参数,这些判断在协议里没有位置。ReAct 在 2022 年给出了另一种写法,时间上早于 Function Calling。

三段循环

ReAct 要求模型每一步输出一对内容:Thought 说明打算做什么,Action 指定具体动作。程序执行 Action,把返回值作为 Observation 拼回上下文,再让模型输出下一对。

每一轮的输入都要重新拼。可用工具说明、原始任务、已经发生的历史,组装成一段新 prompt 发出去;模型看不到上一轮的原始响应,只看到程序格式化后的历史。历史怎么写,直接决定下一轮能不能接上。

以”北京今天适合去哪儿”为例,轨迹大致是:

1
2
3
4
5
6
7
8
Thought: 需要先确认北京天气
Action: get_weather(city="北京")
Observation: 北京 晴 25°C
Thought: 天气晴朗,适合户外,查一下景点
Action: get_attraction(city="北京", weather="晴")
Observation: 故宫、颐和园、长城
Thought: 信息够了
Action: Finish[今天北京晴 25°C,可以去故宫、颐和园或长城]

三轮结束。Thought、Action、Observation 交替出现,直到 Action 写成 Finish[…]。程序侧的工作是两段正则,一段取 Thought 与 Action,一段判断 Action 是工具调用还是结束。

1
2
3
4
5
6
7
8
9
10
def parse_action(action: str):
finish = re.match(r"Finish\[(.*)\]", action, re.DOTALL)
if finish:
return ("finish", finish.group(1), None)
name = re.match(r"(\w+)\(", action)
paren = re.search(r"\((.*)\)", action)
if not name or not paren:
return ("error", action, None)
kwargs = dict(re.findall(r'(\w+)\s*=\s*"([^"]*)"', paren.group(1)))
return ("tool", name.group(1), kwargs)

这段解析只认双引号形式的参数。换成单引号,kwargs 会解析成空字典,工具照常被调用,只是所有参数都丢了。

历史每轮追加三条记录,prompt 逐轮变长。工具返回的内容越长、步数越多,token 消耗上升得越快;一个需要七八步的任务,末尾一轮的输入可能已经包含前面所有工具输出。

Thought 不是装饰

让模型跳过 Thought 直接输出 Action,多步任务的成功率会下降。原因不复杂:Thought 是 chain-of-thought 的文本形式,模型写下”天气晴朗,适合户外”的同时,完成了三件事:解释上一步的观察、判断信息是否足够、决定下一步查什么。

Thought 还会被写回历史,下一步的模型能看到上一步的理由,推理因此可以接续。

把思考写进上下文还有调试价值。轨迹是纯文本,每一步的判断都能读到;结果不对时,能定位到是哪一步想偏了,而不是只看到一个工具调用和一段最终回答。

跟 tool_calls 的分工

维度 tool_calls ReAct
调用怎么表达 协议字段 prompt 约定的 Action 字符串
解析方式 读结构化字段 正则
思考过程 无显式位置 每步一段 Thought
健壮性 协议保证 模型多写一句就可能失败
可读性 JSON,对人一般 整条轨迹可读

真实工程里两者可以叠加。工具执行走 tool_calls 拿到可靠性和并行支持,system prompt 要求模型在 assistant.content 里先写一段思考再发 tool_calls。协议负责执行,文本负责推理。落地时改动很小:system prompt 多写一句,assistant 的 content 跟着消息一起追加进历史。

prompt 约定的脆弱

纯 prompt 约定格式的失败方式很具体:

  • 模型在 Thought 前多写一句开场白,输出就不再是约定的那一种格式
  • 模型一次输出两对 Thought 与 Action,程序要决定取哪一对
  • 参数写成单引号,或者省略参数名,解析出空字典
  • Action 换行写,后面跟一段解释

每一条都要补一层 fallback,容错代码很快超过主循环本身。换一种格式约定,脆弱点只是换个地方:把 Action 改成 JSON,解析器好写一些,模型仍然可能在外层多写一段解释。Function Calling 出现后大家集体迁移,原因在于字段解析不再依赖模型是否守规矩。课程示例把步数上限定为 6,到点直接返回,避免解析失败后无限重试。

今天还值得写一遍

ReAct 有几处价值至今成立。它的表述足够简单,三个词能概括 Agent 的工作流;它是纯 prompt,不需要框架就能跑起来;整条轨迹可读,调试时能直接看到模型的判断。后来的 Plan-and-Solve、Reflection、Tree-of-Thoughts,都能从这三段里找到影子。

课程示例故意用旧写法实现一遍,让两面都露出来:Thought 让推理可读,正则让工程脆弱。写过一次,协议化为什么更可靠就不需要解释了。

三种范式的示例共用同一套工具和同样的 5 个任务,横向跑一遍,差别来自范式本身,与工具集无关。

这对搭 Agent 意味着什么

执行交给协议,推理留给文本。工具调用用 tool_calls 拿确定性,思考过程用 Thought 拿可解释性,两者叠在同一个循环里,是现在比较常见的做法。需要权衡的是成本。Thought 要占 token,历史里的 Observation 同样要占,简单任务上多写一段思考未必划算。

ReAct 是走一步看一步。任务步骤一多,每步都重新决策就容易在中途偏离目标。下一篇的 Plan-and-Solve 换个顺序,先把计划列出来,再按计划逐步执行。