ReAct 的执行循环是怎样的?每一轮模型和代码各做什么?
简化版
ReAct 是「推理(Reason)+ 行动(Act)」交替进行的循环:每一轮,代码把系统提示、任务、到目前为止的完整对话历史和可用工具清单一起发给模型;模型决定这一轮是调用工具(给出工具名和参数),还是认为信息够了直接给出最终答案;如果要调工具,代码按工具名找到真实的执行方法去执行,把结果作为 Observation(工具消息)追加到历史里,进入下一轮。模型不再请求工具时循环结束,这一轮的正文就是最终答案。分工上,模型负责「下一步做什么」,代码负责「真的去做、把结果交回去、控制最多转几圈、处理模型给错的东西」。
详细版
messages = [system, user]
for round in 1..最大轮数:
response = 模型(messages, 工具清单)
messages += 模型这一轮的回复(含工具调用请求)
if 没有工具调用请求:
return response.正文 ← 最终答案
for 每个工具调用请求:
结果 = 执行器[工具名](参数) ← 找不到工具名:把错误作为结果回给模型
messages += 工具消息(对应请求, 结果) ← Observation
抛出「达到最大轮数仍未得出结论」
| 环节 | 谁负责 | 做什么 |
|---|---|---|
| 组织输入 | 代码 | 系统提示放角色和规则,用户消息放任务数据,附上工具规格 |
| 决定下一步 | 模型 | 调哪个工具、传什么参数,或者给出最终答案 |
| 执行动作 | 代码 | 按工具名路由到真实方法,查库、调接口 |
| 回传观察 | 代码 | 把执行结果作为工具消息拼回历史 |
| 判断结束 | 模型 + 代码 | 模型不再调工具即结束;代码设最大轮数兜底 |
| 处理异常 | 代码 | 工具名不存在、参数错误、执行失败,决定回给模型还是中止 |
完整版教学
一、为什么需要「边想边做」
普通问答是一次「文本进、文本出」:把问题发给模型,模型凭训练记忆回答。一旦答案依赖系统里的实时数据,这种方式就会出问题:
患者:上腹隐痛三天,还有点恶心,挂哪个科?
模型:建议挂消化内科。 ← 没查任何数据:这个科室存在吗?今天有医生出诊吗?
要回答得准,模型必须先查数据,而且查什么、查几次,取决于前面查到了什么:症状指向哪个科室,才知道要核实哪个科室;科室确认了,才知道去查哪个科室的排班。这种「下一步取决于上一步结果」的任务,没法事先写死步骤,于是把「查什么、查几次」的决定权交给模型,代码只负责执行和兜底,这就是 ReAct。
二、一轮循环里消息是怎么流动的
ReAct 在工程上就是一个消息列表不断变长的循环。以一次分诊为例,前两轮之后消息列表是这样的:
[system] 你是分诊助手,只输出挂号科室建议……
[user] 患者档案ID=12,主诉:上腹隐痛三天,伴恶心
[assistant] 调用 symptom_tag_search(query="上腹隐痛 恶心") ← 第 1 轮模型回复
[tool] [{"symptomName":"上腹疼痛","deptId":1,"weight":8}, …] ← 第 1 轮 Observation
[assistant] 调用 department_search(deptName="消化内科") ← 第 2 轮模型回复
[tool] {"matches":[{"deptId":1,"deptName":"消化内科",…}]} ← 第 2 轮 Observation
两个细节必须做对。第一,模型这一轮的回复要原样追加进历史,包括它的工具调用请求,否则下一轮工具消息无法和调用请求对上。第二,一轮里模型可能同时请求好几个工具,要逐个执行、逐条回填,每条工具消息对应各自的调用请求。
三、模型做什么,代码做什么
ReAct 常被理解成「模型自己在跑」,其实每一轮真正干活的是代码:
| 问题 | 模型能做 | 必须由代码做 |
|---|---|---|
| 这一轮该调哪个工具? | ✓ | |
| 参数传什么? | ✓(给出 JSON) | 解析、校验参数 |
| 工具真的执行 | ✓ 查库、调接口 | |
| 数据是否真实 | ✓ 结果来自数据库,模型只读 | |
| 最多转几圈 | ✓ 最大轮数、调用次数 | |
| 什么时候结束 | ✓ 不再调工具 | ✓ 到上限强制结束 |
模型的输出只是「意图」:它说要调某个工具,并不等于这个工具存在,更不等于执行成功。把意图变成动作、把动作结果变成下一轮输入,全是代码的职责。
记忆钩子:模型出主意,代码跑腿、记账、踩刹车。
四、循环怎么结束
结束有三种方式,处理方式不同:
| 结束方式 | 判断 | 结果 |
|---|---|---|
| 模型给出最终答案 | 这一轮回复里没有工具调用请求 | 取这一轮的正文作为结论,继续做校验和落库 |
| 模型调用「提交」类工具 | 约定一个专门的提交工具,调用即结束 | 提交的参数就是结构化结果 |
| 达到上限 | 轮数或调用次数超过上限 | 判失败,写明原因,不把半截结果当结论 |
第一种最常见,但要注意:模型说「够了」只代表它认为够了。拿到最终答案之后,还要检查它是不是真的查过数据(比如数一数实际调用了几次工具),结论里的关键字段还要回数据库核对。具体怎么核对,见「Agent 给出的最终结论能直接落库吗?怎么让它可信?」。
五、模型给错东西时怎么办
模型会犯几类错误,代码要分别处理:
| 错误 | 例子 | 推荐处理 |
|---|---|---|
| 编造工具名 | 请求一个清单里没有的 check_insurance | 不中断,把「工具不存在,请改用清单里的工具」作为结果回给模型 |
| 参数查不到数据 | 传了一个不存在的 ID | 工具返回「未找到」,让模型决定下一步 |
| 参数格式错 | 参数不是合法 JSON | 回给模型让它重发,反复写坏由次数上限兜住 |
| 执行真的失败 | 数据库异常 | 视业务而定:可以回给模型,也可以终止整次运行 |
原则是:模型能自己改正的,就把错误当成 Observation 回给它;改不了、或者继续下去会造成错误结果的,就终止并记录原因。直接抛异常中断,会让前面几轮已经查到的信息全部白费。
六、每一轮都在付全量历史的钱
ReAct 每一轮都要把完整历史重新发给模型,输入 token 随轮数线性增长。用一组示意数字算一下:
首轮输入(系统提示 + 任务 + 工具规格):1500 token
每多一轮,历史增加约 600 token(模型回复 + 工具结果)
第 1 轮 1500 → 第 2 轮 2100 → 第 3 轮 2700 → 第 4 轮 3300 → 第 5 轮 3900
5 轮累计输入 = 1500 + 2100 + 2700 + 3300 + 3900 = 13500 token
5 轮的输入总量是单次调用的 9 倍。这就是为什么 ReAct 必须有最大轮数、工具结果要精简(只返回下一步决策需要的字段)、统计成本时要把每一轮的用量累加,而不是只看最后一轮。
七、自己写循环还是交给框架
上面的循环可以自己写,也可以交给框架:Spring AI 的 ChatClient、LangChain4j 的 AiServices 都能在一次调用里跑完整个循环。自己写的好处是每一步都在手里,能在工具执行前后写步骤记录、做次数检查、处理未知工具名;交给框架代码更少,但循环对业务是黑盒,记录和拦截要借助框架的扩展点。两者的取舍见「手写 Function Calling 循环和框架托管有什么区别?怎么选?」。
另外,界面上展示 ReAct 的过程时,展示的应该是可审计的事实:调了哪个工具、传了什么参数、返回了什么,而不是去伪造或还原模型的「思考过程」。
八、常见误区与追问
- 误区:ReAct 就是模型自己在循环。 循环是代码写的,模型每一轮只给出意图,执行、回传、上限都由代码负责。
- 误区:只把工具结果回填就行。 模型那一轮的回复(含工具调用请求)也要原样追加,否则下一轮工具结果对不上请求。
- 误区:模型给了个不存在的工具名就该报错中断。 把错误作为结果回给模型,它通常能改用正确的工具,中断会浪费前面的所有轮次。
- 误区:模型说够了,结论就可信。 要检查它是否真的调用过工具,关键字段还要回库核对。
- 误区:轮数多一点无所谓。 每一轮都发全量历史,输入 token 随轮数累加,轮数失控就是成本失控。
- 追问:一轮里有多个工具调用请求怎么处理? 按模型给出的顺序逐个执行,每个结果单独作为一条工具消息回填,和各自的请求对应。
- 追问:最大轮数设多少? 看正常任务一般几轮收敛,留出余量;跑满上限的运行几乎都是在原地打转,应当判失败并记录原因。
九、加强记忆
ReAct 记「一轮四步、两个结束、三类错误」:一轮四步是代码发出历史和工具清单、模型给出工具调用或最终答案、代码按工具名执行、把结果作为工具消息追加回历史,模型的回复本身也要原样追加,一轮多个调用就逐个执行逐条回填。结束有两类:模型不再调工具(或调用提交工具)是正常结束,到达轮数上限是失败结束,正常结束后还要核对是否真的查过数据。模型给错东西时,编造工具名、查不到数据、参数写坏都尽量作为 Observation 回给模型,改不了的才终止。每一轮都付全量历史的钱,所以要有上限、结果要精简、成本要逐轮累加。
项目实战落地
项目里怎么做的
《AI Agent 智慧医院智能导诊就诊系统》的分诊 Agent 是手写的 ReAct 循环,在 AI 统一出口 HospitalAiService.chatWithTools 里:
- 起点:
system放分诊模板(角色与规则),user放患者档案 ID、主诉、档案摘要、对话背景和 JSON 输出格式要求;工具池从工具中心按「启用」现查现建,每个工具是「工具规格 + 执行器」一对,另按工具名建一张执行器索引; - 每一轮:把完整历史和全部工具规格发给 LangChain4j 的
ChatModel,模型这一轮的回复先追加进历史;没有工具执行请求就说明取证够了,这一轮的正文就是最终结论; - 执行工具:模型一轮里可能要调好几个工具,逐个按名字找执行器执行,结果以
ToolExecutionResultMessage拼回历史,作为下一轮的 Observation; - 编造的工具名:执行器索引里找不到时不中断整次运行,把「工具不存在或已停用,请改用工具清单里列出的工具」作为这次工具的结果回给模型;
- 轮数上限:
MAX_REACT_ROUNDS = 12,分诊正常 3 到 6 轮就收敛,跑满几乎都是模型在原地打转,抛出「推理轮数达到上限,仍未得出结论」。
《AI Agent岗位匹配与求职规划系统》用的是框架托管的 ReAct:ChatClient 带上由工具表构建的 toolCallbacks、把运行 ID 放进 toolContext 后一次 call(),循环在 Spring AI 内部完成,每次工具调用进入 ToolCallback.call 时写一条步骤记录。
为什么这样取舍
- 分诊把 ReAct 放进统一出口:业务 Service 调模型只准走
HospitalAiService,带工具的循环和普通对话共用同一套截断检测、报错翻译和调用日志。 - 查什么交给模型:患者一句话和五句话需要查的信息量完全不同,查数据的动作写不死在代码里。
面试官还会追问
- 工具规格里每个参数的类型是怎么从
input_schema这种「字段名 → 说明」的简写推出来的? - 分诊结论要求模型输出什么格式?模型把 JSON 包在代码块里时,代码怎么把它取出来?
- 每一步的步骤号为什么取「这次运行已有步骤数 + 1」,而不是在内存里自己计数?
学完《AI Agent 智慧医院智能导诊就诊系统》,上面这些追问你都会迎刃而解。