一、本篇要解决什么问题
第 02 篇已经能看见 tool_calls。模型面对「股价多少,并介绍一下」,完全可能在同一条 AIMessage 里同时申请 get_price 和 get_info。这对「两个查询互相独立」的题目很省事。
有一类题不行:下一步依赖上一步的观察。更常见的教学约束是------先想清楚再伸手,一次只做一件事,方便人把轨迹读成故事,而不是并行火花。
本篇用 BMI 把这件事做死:身高、体重来自两把工具,公式在模型脑子里。系统提示强制 ReAct,并且每轮只准调用一个工具。
1.1 学习目标
-
能默写 ReAct 的四个节拍:思考 → 行动 → 观察 → 再思考
-
理解「每轮只调一个工具」是提示词纪律,不是
create_agent的默认开关 -
能根据 stream 日志,把一次 BMI 计算还原成完整循环
-
分清本篇和上一篇在轨迹形态上的差别
1.2 一句对照
第 02 篇问:模型选了哪些工具? 本篇问:模型有没有按「想完再做、做完再看」的节奏走?
代码骨架几乎照抄上一篇的 stream,变化集中在工具含义 和系统提示。
二、ReAct 是什么
ReAct 是 Reason + Act 的合成:让语言模型在同一条轨迹里交替进行推理 和行动,而不是先写完整篇分析再一次性调用,也不是闷头调工具不解释。
四个节拍可以画成:
┌──────────┐
│ 思考 │ 现在缺什么?下一步做什么?
└────┬─────┘
│
▼
┌──────────┐
│ 行动 │ 调用恰好一把工具
└────┬─────┘
│
▼
┌──────────┐
│ 观察 │ 读 ToolMessage
└────┬─────┘
│
▼
够不够算最终答案?
│否 │是
▼ ▼
回到思考 输出结论
对应到 LangChain 的消息:
| ReAct 节拍 | 落在哪条消息上 |
|---|---|
| 思考 | AIMessage.content(本课要求按「思考、行动、观察」结构写出来) |
| 行动 | 同一条 AIMessage 的 tool_calls,且长度应为 1 |
| 观察 | 随后的 ToolMessage.content |
| 再思考 | 下一轮 AIMessage |
框架帮你把「行动 / 观察」在协议层跑通;「思考必须说出来、一次只准一把工具」要靠 system_prompt。
三、案例:计算我的 BMI
对照文件:P5_Agent智能体/03ReAct案例.py。
3.1 两把互不包含的工具
@tool(description="获取体重,返回值是整数,单位千克")
def get_weight() -> int:
return 90
@tool(description="获取身高,返回值是整数,单位厘米")
def get_height() -> int:
return 172
设计是刻意的:
-
没有一把「直接算 BMI」的工具。 模型必须自己记公式,而不能把计算外包掉。
-
身高体重拆开。 一次只调一把时,循环至少两轮,ReAct 才看得出来。
-
描述里写清单位。 体重千克、身高厘米。BMI 要用米:
172 / 100 = 1.72。单位写在 description 里,模型才不会把 172 当成米。
桩数据固定:体重 90 kg,身高 172 cm。
属于肥胖区间。最终答复里出现这个数(或四舍五入后的 30.4),说明观察被正确带进了计算,而不是模型随口编了一个 BMI。
3.2 系统提示把 ReAct 写成硬纪律
system_prompt="""你是严格遵循ReAct框架的智能体,必须按「思考→行动→观察→再思考」的流程解决问题,
且**每轮仅能思考并调用1个工具**,禁止单次调用多个工具。
并告知我你的思考过程,工具的调用原因,按思考、行动、观察三个结构告知我""",
三句话,三层约束:
| 句子 | 约束的是 |
|---|---|
| 必须按「思考→行动→观察→再思考」 | 节奏,不许跳步 |
| 每轮仅能调用 1 个工具 | 轨迹形态,禁止第 02 篇那种一次两个 tool_calls |
| 按思考、行动、观察三个结构告知我 | content 的版式,方便人读日志 |
注意:create_agent 不会 因为这句话就在运行时拒绝第二个 tool call。它仍然把模型发出的调用都执行掉。纪律能否被遵守,取决于模型是否听话。教学上把要求写进系统提示,再在 stream 日志里验收 tool_calls 的长度是不是 1。
3.3 执行方式沿用 stream
for chunk in agent.stream(
{"messages": [{"role": "user", "content": "计算我的BMI"}]},
stream_mode="values"
):
latest_message = chunk['messages'][-1]
if latest_message.content:
print(type(latest_message).__name__, latest_message.content)
try:
if latest_message.tool_calls:
print(f"工具调用: { [tc['name'] for tc in latest_message.tool_calls] }")
except AttributeError as e:
pass
和上一篇相比,变的是用户问题和工具集合,读帧方式一字未改。专栏故意这么写:学会一种观察手段,后面只换任务。
用户问题极短------「计算我的BMI」。身高体重都没给。模型若直接输出一个数字,就是幻觉;按 ReAct,它必须先承认「我还没有这两个数」。
四、一次合格循环长什么样
模型听话时,日志应接近下面这条时间线(先体重还是先身高都可以,不能并行):
HumanMessage 计算我的BMI
AIMessage 【思考】要算 BMI,缺体重和身高。先取体重。
【行动】调用 get_weight
工具调用: ['get_weight']
ToolMessage 90
AIMessage 【观察】体重 90 kg
【思考】还缺身高。
【行动】调用 get_height
工具调用: ['get_height']
ToolMessage 172
AIMessage 【观察】身高 172 cm
【思考】BMI = 90 / (1.72^2) ≈ 30.4
(本轮不再带 tool_calls,循环结束)
用表格验收:
| 检查项 | 合格 | 不合格 |
|---|---|---|
| 最终数字 | 约 30.4 | 编造 22、24 这种「标准身材」 |
每一帧 tool_calls 长度 |
0 或 1 | 出现 ['get_weight', 'get_height'] |
| 两把工具是否都出现过 | 都出现 | 只调一把,另一半靠猜 |
| content 结构 | 能看到思考 / 行动 / 观察 | 只有一句「你的 BMI 是 30」 |
4.1 为什么禁止一次调两把
技术上,身高体重互不依赖,并行完全正确,也更快。课程禁止它,是为了把循环拉成可讲解的带子:
-
学生能指着某一帧说「这是思考,这是行动」
-
和第 02 篇形成对照:同一套 stream,提示词能改变轨迹形状
-
为后面更难的依赖题打样------比如「先查库存再下单」,第二步必须看见第一步的观察
生产里要不要禁止并行,按任务定,不要把教学纪律当成框架默认。
4.2 模型若一次点了两把怎么办
日志会打出:
工具调用: ['get_weight', 'get_height']
说明系统提示没完全管住。处理办法通常是收紧提示、换更听话的模型,或像第 04 篇那样在中间件里拦截。本课停留在「先把合格轨迹跑出来」。
4.3 公式必须由模型算
两把工具只返回原始整数。90 / (1.72 ** 2) 出现在最后一轮 AIMessage 里,这是 LLM 在当计算器。教学上够用;对精度敏感的业务,应再提供一把 calc_bmi(weight, height) 工具,让观察里直接出现可靠数字。本课不提供,就是为了让「再思考」这一拍有实质工作。
五、和上一篇并排看
| 第 02 篇 股票 | 本篇 BMI | |
|---|---|---|
| 用户问题 | 两个并列意图 | 一个计算目标 |
| 工具关系 | 互相独立 | 结果要在最后一步汇合 |
| 提示词 | 告知思考过程 | ReAct + 每轮 1 个工具 + 固定版式 |
| 合格的 tool_calls | 可以一次两个名字 | 每一帧最多一个名字 |
| 循环最少轮次 | 可能 1 轮工具 + 1 轮总结 | 至少 2 轮工具 + 1 轮总结 |
同一份 create_agent + stream,换工具和提示词,智能体从「能并行查数的助手」变成「按节拍解题的助手」。这是 Agent 开发里比堆工具更要紧的一课:人设即控制流。
六、本篇小结
-
ReAct = 思考 → 行动 → 观察 → 再思考,直到能给结论
-
行动落在
tool_calls,观察落在ToolMessage,思考落在content -
「每轮一个工具」写在
system_prompt里,用日志里的列表长度验收 -
BMI 案例故意不提供计算工具,逼模型在最后一轮完成公式
-
stream 读帧代码可以原样复用,换的是任务而不是观察手段