跳到主要内容

01 · ReAct 主循环

本章讲整个项目的心脏:MultiTurnReactAgent._run 怎么把「思考 + 工具调用 + 观察」滚成多轮,直到给出答案。读完你能讲清:一轮里发生什么、标签怎么被解析、循环靠什么退出。

3.1 它要解决的小问题

一个深度研究问题不可能一次答完——得先搜、看结果、再决定搜什么、再算一下……这是天然的多轮过程。ReAct(Reason + Act,「边推理边行动」)就是把这个过程结构化:每一轮模型都先推理(想),再行动(调工具),看到观察(工具结果)后进入下一轮。

3.2 思路 / 直觉

关键设计:模型和外界只通过文本标签沟通。模型不「真的」调用函数,它只是在输出里写一段特殊格式的文本,由外层 Python 代码去识别、执行、再把结果以文本塞回去。约定的四种标签:

标签谁写含义
<think>…</think>模型内心推理(评测时会统计其长度)
<tool_call>…</tool_call>模型要调用某工具,内容是 JSON
<tool_response>…</tool_response>外层代码工具执行结果,回填给模型
<answer>…</answer>模型最终答案,出现即收尾

这套约定写死在 SYSTEM_PROMPT 里(inference/prompt.py:1-35),开头一句就是「当你准备好给出最终回答时,必须把整个答案包进 <answer></answer>」。

3.3 一轮里发生什么(图示)

怎么读: 从左到右是一轮的顺序;下方两条虚线是把结果追加回消息列表,构成下一轮的输入。

messages ──► call_server ──► 模型输出 content
▲ (打 vLLM) │
│ ├─ 含 <tool_call>? ──► 解析 JSON ──► custom_call_tool ──┐
│ │ │
│ └─ 含 <answer>? ──► 记 termination='answer' ──► break │
│ │
└──────── append <tool_response>\n{结果}\n</tool_response> ◄──── 包装工具结果 ◄─────────┘

3.4 原理演示(简化,非源码)

下面这段把主循环的骨架抽出来,帮你建立直觉。重点看「解析标签 → 派工具 → 回填」这三步怎么循环。

# 示意,非源码
messages = [system_prompt, user_question]
calls_left = 100
while calls_left > 0:
calls_left -= 1
content = call_model(messages) # ① 让模型想 + 决定动作
content = cut_before(content, "<tool_response>") # 防模型自己幻觉出观察
messages.append({"role": "assistant", "content": content})

if "<tool_call>" in content: # ② 要调工具
call = extract(content, "<tool_call>", "</tool_call>")
result = dispatch_tool(call) # 派发执行
obs = f"<tool_response>\n{result}\n</tool_response>"
messages.append({"role": "user", "content": obs}) # ③ 观察回填

if "<answer>" in content: # ④ 交卷,退出
break

3.5 真实实现

主循环在 inference/react_agent.py:138-226(MultiTurnReactAgent._run)。几个关键点:

① 调模型时用 stop token 卡住幻觉。 call_server 里给 vLLM 传了 stop=["\n<tool_response>", "<tool_response>"](react_agent.py:77)。意思是:一旦模型试图自己「续写」出工具返回,立刻截断——观察必须由真实工具产生。保险起见,循环里还会二次裁剪(react_agent.py:155-157):若 content 里仍混进了 <tool_response>,只保留它前面的部分。

② 工具调用的解析分两条路。react_agent.py:159-176:

if "python" in tool_call.lower(): # Python 走特殊格式
code_raw = content.split('<code>')[1].split('</code>')[0].strip()
result = TOOL_MAP['PythonInterpreter'].call(code_raw)
else: # 其余工具走 JSON
tool_call = json5.loads(tool_call)
result = self.custom_call_tool(tool_call['name'], tool_call['arguments'])

为什么 Python 要单开一条?因为代码里含大量特殊字符,塞进 JSON 会频繁转义出错,所以约定成「JSON 里 arguments 留空 + 代码单独放 <code></code>」——这条约定见 prompt.py:11-25 的工具描述。派发细节见 02-tools.md。用 json5(比标准 JSON 宽松,容忍尾逗号/单引号)是为了扛住模型偶尔不严谨的输出。

③ 观察统一包壳回填。 无论哪个工具,结果都套上 <tool_response>\n…\n</tool_response> 再作为一条 user 消息追加(react_agent.py:177-179)。注意工具结果是以 user 角色回填的,不是 function 角色——这是这套实现的取舍。

④ 三种退出条件。 循环靠这几处退出:

退出原因触发代码
正常交卷content 含 <answer></answer>react_agent.py:180-182
超时单题运行超 150 分钟react_agent.py:140-150
轮数耗尽num_llm_calls_available 归零react_agent.py:183-184217-218
上下文超长token 数 > 110K,强制收尾react_agent.py:186-209(详见 03)

3.6 答案是怎么抠出来的

收尾后从最后一条消息里切出 <answer></answer> 之间的文本作为 prediction(react_agent.py:211-213):

if '<answer>' in messages[-1]['content']:
prediction = messages[-1]['content'].split('<answer>')[1].split('</answer>')[0]
termination = 'answer'

最终 _run 返回一个 dict:question / answer(参考答案) / messages(完整轨迹) / prediction / termination。这个结构直接被评测脚本消费(见 04)。

3.7 关键细节 / 坑

  • 问题抽取有 fallback。 数据可能是 {question, answer},也可能是 chat 格式的 messages_run 先试 item['question'],失败再从 messages[1] 里按 "User:" 切(react_agent.py:122-126)。

  • 每轮都重新加载 tokenizer 数 token。 count_tokens 里每次都 AutoTokenizer.from_pretrained(...)(react_agent.py:112-118)——功能正确但重复加载,是个可优化点。

  • MAX_LLM_CALL_PER_RUN 被定义了两次。 qwen_agent 里导入一个,react_agent.py:29 又用环境变量覆盖成 100,后者生效。

代码地图

主题文件路径符号名
主循环本体inference/react_agent.pyMultiTurnReactAgent._run
调 vLLM + stop tokeninference/react_agent.pyMultiTurnReactAgent.call_server
工具派发inference/react_agent.pyMultiTurnReactAgent.custom_call_tool
数 tokeninference/react_agent.pyMultiTurnReactAgent.count_tokens
系统提示 / 工具清单 / 标签约定inference/prompt.pySYSTEM_PROMPT
工具注册表inference/react_agent.pyTOOL_CLASSTOOL_MAP