数据截至 (上游 commit f72f75d8c3eb)
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-184、217-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.py | MultiTurnReactAgent._run |
| 调 vLLM + stop token | inference/react_agent.py | MultiTurnReactAgent.call_server |
| 工具派发 | inference/react_agent.py | MultiTurnReactAgent.custom_call_tool |
| 数 token | inference/react_agent.py | MultiTurnReactAgent.count_tokens |
| 系统提示 / 工具清单 / 标签约定 | inference/prompt.py | SYSTEM_PROMPT |
| 工具注册表 | inference/react_agent.py | TOOL_CLASS、TOOL_MAP |