ReAct Agent 创建:tools 协议与文本 ReAct 双路线实践

一个带多轮上下文记忆的 ReAct Agent 脚本:用户输入问题后,模型自行判断要不要调用工具、调哪个工具,拿到工具返回结果后再作答;支持联网搜索与查询当前时间。

目录

  1. [1. 运行方式](#1. 运行方式)
  2. [2. 依赖文件](#2. 依赖文件)
  3. [3. 文件结构](#3. 文件结构)
  4. [4. 组件逐一说明](#4. 组件逐一说明)
  5. [5. 执行流程](#5. 执行流程)
  6. [6. AgentExecutor 的运行机制](#6. AgentExecutor 的运行机制)
  7. [7. 多轮记忆](#7. 多轮记忆)
  8. [8. 文本路线的解析器为什么需要特殊处理](#8. 文本路线的解析器为什么需要特殊处理)
  9. [9. 配置速查](#9. 配置速查)
  10. [10. 实测行为](#10. 实测行为)
  11. [11. 使用边界](#11. 使用边界)

TL;DR

  • 用途 :一个带多轮上下文记忆的 ReAct Agent 脚本,模型自行决定是否调用工具(联网搜索 Search、查当前时间 CurrentTime),拿到结果后再作答。
  • 两条路线 :supports_tools() 探测为真就走 tools 协议路线(create_tool_calling_agent,格式由服务端保证);不支持才退回文本 ReAct 路线(build_react_agent + StopAwareReActParser)。能走 tools 就优先走 tools。
  • 多轮记忆 :维护一个 chat_history 列表,每轮把「用户问题 + 最终答案」追加进去,下一轮随 payload 一起重发;tools 路线始终生效,文本路线取决于命中的提示词是否含 chat_history 占位符。
  • MODEL_MAX_TOKENS :当前 3000。两个模型都是推理模型,token 先消耗在 reasoning 上,给太小会在拿到正文或 tool_calls 前被截断。
  • MODEL_SUPPORTS_TOOLS :留空即自动探测;填 0 / 1 可跳过探测请求,直接指定走哪条路线。
  • 代码详见 :
    https://download.csdn.net/download/applehunqiu/93764107

1. 运行方式

bash 复制代码
cd ".../02-demo-agent"
python llm_config.py       # 自检:打印当前配置 + 探测服务是否在线
python ReAct_local.py      # 进入交互式问答

交互命令:

输入 作用
exit / quit / 退出 退出
clear / 清空 清空对话历史
直接回车 跳过,继续等待输入

模型来源由本目录 .env 的 MODEL_PROVIDER 决定,切换模型只改这一行,本文件无需改动。


2. 依赖文件

文件 职责
llm_config.py 按 .env 构造统一的 LangChain 模型对象;提供 supports_tools() 能力探测
react_compat.py 针对「不尊重 stop 参数的模型」的 ReAct 解析器兼容层
prompts/react_chat.py ReAct 提示词的本地副本,保证断网时也能用上并保留多轮能力
.env 端点地址、模型名、token 预算、网络设置

四个依赖与本文件同级,均为同级模块直接导入,不受当前工作目录影响。


3. 文件结构

复制代码
① 提示词常量     REACT_PROMPT_CN        (文本路线的最末兜底)
                 TOOL_CALLING_PROMPT    (tools 路线专用)
② 工具层         safe_search / get_current_time  →  两个 Tool 对象
③ 构建函数       build_prompt()  三级取词
                 build_agent()   按模型能力二选一
④ 主流程         main():读配置 → 探测服务 → 探测能力 → 组装 Agent → 多轮循环

4. 组件逐一说明

4.1 提示词

REACT_PROMPT_CN(第 43-60 行)

PromptTemplate,输入变量为 tools / tool_names / input / agent_scratchpad。模板里规定了输出格式:

复制代码
Question / Thought / Action / Action Input / Observation ... Final Answer

它是最末兜底,只有本地副本和联网拉取都失败时才使用。它没有 chat_history 占位符,一旦落到它身上,多轮记忆会失效------这也是为什么提示词的第一顺位不是它。

TOOL_CALLING_PROMPT(第 64-75 行)

python 复制代码
TOOL_CALLING_PROMPT = ChatPromptTemplate.from_messages([
    ("system", "你是花语秘境的智能助手。有合适的工具就调用工具......"),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
    MessagesPlaceholder("agent_scratchpad"),
])

只在支持 OpenAI tools 协议的模型上启用。它不规定 Thought/Action 的文字格式------那条路线不需要格式约束。

4.2 工具

Tool 名 底层函数 说明
Search safe_search DuckDuckGo 联网搜索,无需 Key
CurrentTime get_current_time 返回本机当前时间,离线可用

safe_search(第 81-92 行)把搜索引擎异常转成一段文本返回,而不是向上抛出:

python 复制代码
except Exception as e:
    hint = "(多为网络受限所致:可配置代理后重试,或改用 Tavily 等搜索源)"
    return f"搜索失败:{type(e).__name__}: {str(e)[:200]} {hint}".strip()

这样 Agent 会把「搜索失败」当作一次 Observation 继续推理并如实作答,而不是整个链路崩掉。

4.3 build_prompt()(第 100-122 行)

按三级优先级取 ReAct 提示词:

顺位 来源 含 chat_history 失败时的处理
1 prompts/react_chat.py 的 load_react_chat() 是 打印提示,转下一级
2 hub.pull("hwchase17/react-chat") 是 打印提示,转下一级
3 REACT_PROMPT_CN 否 无

刻意不做 .partial(chat_history=[]):那样会把历史写死为空,每轮都记不住上一轮说过什么。

4.4 build_agent()(第 125-141 行)

这是本文件的核心分支,依据模型是否支持 OpenAI Tools 协议选择路线:

python 复制代码
if can_call_tools:
    return create_tool_calling_agent(llm, tools, TOOL_CALLING_PROMPT)
return build_react_agent(llm, tools, react_prompt)
can_call_tools=True can_call_tools=False
构造函数 create_tool_calling_agent build_react_agent(来自 react_compat)
调用依据 OpenAI Tools 协议,参数由服务端按 schema 产出并校验 模型在正文里写 Thought/Action/Action Input,由解析器提取
使用提示词 TOOL_CALLING_PROMPT build_prompt() 的返回值
格式可靠性 高,服务端保证 依赖模型遵循度,靠 handle_parsing_errors 兜底
适用范围 需模型支持该协议 任何模型

4.4.1 两条路线选型对照

把 build_agent() 的两个分支放在一起看,差异集中在下面几个维度:

维度 tools 协议路线(can_call_tools=True) 文本 ReAct 路线(can_call_tools=False)
适用模型 必须支持 OpenAI Tools 协议,由 supports_tools() 探测为真 任何模型,只要能按格式输出文本即可
格式可靠性 高,工具名与参数由服务端按 schema 产出并校验 依赖模型对 Thought/Action/Action Input 格式的遵循度,需 StopAwareReActParser 兜底
多轮记忆是否默认生效 是,TOOL_CALLING_PROMPT 内置 chat_history 占位符,始终传入 视命中的提示词而定:本地副本与 hub 版有占位符,末位兜底的 REACT_PROMPT_CN 没有
handle_parsing_errors 是否起作用 不起作用,走结构化协议时不会触发解析异常 起作用,格式解析失败时把提示塞回模型重试纠正
token 消耗特点 参数走结构化字段,正文更短;但每轮都要携带完整 chat_history 与工具 schema 正文里要写完整的 Thought/Action/Observation 文本,单轮更长;若后端忽略 stop 还会多生成一段自撰 Observation
典型失败表现 模型不支持协议时直接 400(does not support tools);参数 schema 不匹配时调用被拒 模型不守格式 → 反复重试直到 max_iterations 报「Agent stopped due to iteration limit」;忽略 stop → 自撰 Observation 导致工具未真正执行

一句话结论:模型支持 OpenAI Tools 协议就优先走 tools 路线,只有不支持时才退回文本 ReAct 路线。

4.5 能力判定 supports_tools()

位于 llm_config.py,在 main() 中被调用一次,结果按「地址 + 模型」缓存。

判定依据是服务端有没有拒绝该请求:不支持的模型会返回 400 并附带 does not support tools;支持的模型正常返回 200。

注意两点:

  • 不能用「这次有没有真的发起调用」来判定------token 预算不足或提问不触发时,模型可能不调工具但仍返回 200。
  • 也不能用「显式 tool_choice 指定函数名」强制------Ollama 侧不认这种写法,会对支持 tools 的模型给出假阴性。

在 .env 中可用 MODEL_SUPPORTS_TOOLS=0 / 1 直接指定结果,跳过这次探测请求。

同一台 Ollama 服务上不同模型的能力并不相同,所以必须实际探测,不能按服务地址或模型名字推断。


5. 执行流程

复制代码
main()
|
+- get_provider()  ---> 读 .env 的 MODEL_PROVIDER 并校验合法性
+- describe()      ---> 打印一行配置摘要(provider / model / target / max_tokens / timeout)
+- check_server()  ---> provider 非 huggingface 时先探测服务,未就绪则 SystemExit 退出
+- build_llm()     ---> 构造 ChatOpenAI 对象(端点取 .env)
|
+- supports_tools(llm)
|        +- True  --> build_agent(can_call_tools=True)  --> create_tool_calling_agent
|        +- False --> build_agent(can_call_tools=False) --> build_react_agent
|                                                           --> react_compat.StopAwareReActParser
|
+- 组装 tools = [Search, CurrentTime]
+- build_prompt()  ---> 三级取词
|
+- AgentExecutor(agent, tools, verbose=True, max_iterations=5, handle_parsing_errors=...)
|        +- 内部循环:模型输出 -> 解析 -> 执行工具 -> Observation 拼回 -> 再次询问模型
|
+- while True 交互循环
         +- 读输入并处理 exit / clear
         +- payload = {"input": question}(needs_history 为真时附 chat_history)
         +- agent_executor.invoke(payload) -> 取 result["output"]
         +- 把「问 + 答」追加进 chat_history

上面的 ASCII 图对应的可视化版本如下,节点命名与正文保持一致:
#mermaid-svg-ikv0l5lICWu8If5J{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ikv0l5lICWu8If5J .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ikv0l5lICWu8If5J .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ikv0l5lICWu8If5J .error-icon{fill:#552222;}#mermaid-svg-ikv0l5lICWu8If5J .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ikv0l5lICWu8If5J .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ikv0l5lICWu8If5J .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ikv0l5lICWu8If5J .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ikv0l5lICWu8If5J .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ikv0l5lICWu8If5J .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ikv0l5lICWu8If5J .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ikv0l5lICWu8If5J .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ikv0l5lICWu8If5J .marker.cross{stroke:#333333;}#mermaid-svg-ikv0l5lICWu8If5J svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ikv0l5lICWu8If5J p{margin:0;}#mermaid-svg-ikv0l5lICWu8If5J .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ikv0l5lICWu8If5J .cluster-label text{fill:#333;}#mermaid-svg-ikv0l5lICWu8If5J .cluster-label span{color:#333;}#mermaid-svg-ikv0l5lICWu8If5J .cluster-label span p{background-color:transparent;}#mermaid-svg-ikv0l5lICWu8If5J .label text,#mermaid-svg-ikv0l5lICWu8If5J span{fill:#333;color:#333;}#mermaid-svg-ikv0l5lICWu8If5J .node rect,#mermaid-svg-ikv0l5lICWu8If5J .node circle,#mermaid-svg-ikv0l5lICWu8If5J .node ellipse,#mermaid-svg-ikv0l5lICWu8If5J .node polygon,#mermaid-svg-ikv0l5lICWu8If5J .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ikv0l5lICWu8If5J .rough-node .label text,#mermaid-svg-ikv0l5lICWu8If5J .node .label text,#mermaid-svg-ikv0l5lICWu8If5J .image-shape .label,#mermaid-svg-ikv0l5lICWu8If5J .icon-shape .label{text-anchor:middle;}#mermaid-svg-ikv0l5lICWu8If5J .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ikv0l5lICWu8If5J .rough-node .label,#mermaid-svg-ikv0l5lICWu8If5J .node .label,#mermaid-svg-ikv0l5lICWu8If5J .image-shape .label,#mermaid-svg-ikv0l5lICWu8If5J .icon-shape .label{text-align:center;}#mermaid-svg-ikv0l5lICWu8If5J .node.clickable{cursor:pointer;}#mermaid-svg-ikv0l5lICWu8If5J .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ikv0l5lICWu8If5J .arrowheadPath{fill:#333333;}#mermaid-svg-ikv0l5lICWu8If5J .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ikv0l5lICWu8If5J .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ikv0l5lICWu8If5J .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ikv0l5lICWu8If5J .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ikv0l5lICWu8If5J .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ikv0l5lICWu8If5J .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ikv0l5lICWu8If5J .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ikv0l5lICWu8If5J .cluster text{fill:#333;}#mermaid-svg-ikv0l5lICWu8If5J .cluster span{color:#333;}#mermaid-svg-ikv0l5lICWu8If5J div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ikv0l5lICWu8If5J .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ikv0l5lICWu8If5J rect.text{fill:none;stroke-width:0;}#mermaid-svg-ikv0l5lICWu8If5J .icon-shape,#mermaid-svg-ikv0l5lICWu8If5J .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ikv0l5lICWu8If5J .icon-shape p,#mermaid-svg-ikv0l5lICWu8If5J .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ikv0l5lICWu8If5J .icon-shape .label rect,#mermaid-svg-ikv0l5lICWu8If5J .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ikv0l5lICWu8If5J .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ikv0l5lICWu8If5J .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ikv0l5lICWu8If5J :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 未就绪
就绪
True
False
main()
get_provider()

读 .env 的 MODEL_PROVIDER 并校验
describe()

打印配置摘要
check_server()

探测服务是否在线
SystemExit 退出
build_llm()

构造 ChatOpenAI
supports_tools(llm)
build_agent(can_call_tools=True)

create_tool_calling_agent
build_agent(can_call_tools=False)

build_react_agent
react_compat.StopAwareReActParser
组装 tools = Search, CurrentTime
build_prompt()

三级取词
AgentExecutor

max_iterations=5 / handle_parsing_errors
while True 交互循环
读输入并处理 exit / clear
payload = {input, chat_history?}
agent_executor.invoke(payload)
取 result'output' 作答
把「问 + 答」追加进 chat_history


6. AgentExecutor 的运行机制

python 复制代码
agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    max_iterations=5,
    handle_parsing_errors="你的输出不符合规定格式,请重新按 Question/Thought/Action 的格式输出。",
)
参数 作用
max_iterations=5 限制「思考 - 行动」循环轮数,防止模型原地打转耗尽资源
handle_parsing_errors 输出格式解析失败时,把这段提示塞回给模型让它重试纠正。仅对文本解析路线有效,走 tools 协议时不会触发
verbose=True 打印每一步的 Thought / Action / Observation,便于观察链路

7. 多轮记忆

python 复制代码
chat_history: list[HumanMessage | AIMessage] = []
needs_history = True if can_call_tools else "chat_history" in prompt.input_variables
  • tools 路线 :始终传 chat_history(TOOL_CALLING_PROMPT 里有对应占位符)。
  • 文本路线 :取决于命中的提示词有没有 chat_history 占位符------本地副本与 hub 版有,末位兜底的 REACT_PROMPT_CN 没有。

每轮结束后把「用户问题 + 最终答案」追加进去:

python 复制代码
chat_history.append(HumanMessage(content=question))
chat_history.append(AIMessage(content=answer))

只记最终问答,中间那些 Thought/Action/Observation 过程不进历史,避免污染后续上下文。

语言模型本身无状态,所谓多轮就是每轮把完整历史重新发送一遍。


8. 文本路线的解析器为什么需要特殊处理

build_react_agent() 来自 react_compat.py,它会在标准解析器前挂一层 StopAwareReActParser。原因是:

标准 ReAct 依赖 stop=["Observation:"] 让模型写完 Action 就停住,由 AgentExecutor 执行工具并把真实的 Observation 拼回去。若后端忽略 stop,模型会一路把 Observation 和 Final Answer 都替你写出来;而标准解析器一看到 Final Answer: 就判定结束,于是工具根本不会被执行,答案全是模型编造的。

StopAwareReActParser 处理三种情形:

  1. 输出里含 Action ------ 优先按「要调工具」处理,并在自撰的 Observation 处截断,防止幻觉内容被当成工具入参。注意只在确实存在 Action 时才截断,否则最终答案里若出现「Observation:」字样会被误裁。
  2. 只有 Final Answer ------ 交给标准解析器按常规流程处理。
  3. 既无 Action 也无 Final Answer(模型直接说散文) ------ 标准实现会抛 OutputParserException,随后在 handle_parsing_errors 的提示下反复重试,而模型往往重复同样的散文,直到耗尽 max_iterations 报「Agent stopped due to iteration limit」。这里直接把这段散文当作最终答案收尾,让链路正常终结。

模型规规矩矩输出时,它的行为与标准解析器完全一致;需要严格使用标准行为可用 LOCAL_IGNORE_STOP=0 关闭。


9. 配置速查

.env 中与本脚本相关的项:

变量 说明
MODEL_PROVIDER ollama_direct(默认,deepseek-r1:32b)/qwen_vl(qwen3-vl:8b)/openai 等
OLLAMA_DIRECT_BASE_URL 直连 Ollama 的 OpenAI 兼容地址
OLLAMA_DIRECT_MODEL / QWEN_VL_MODEL 两个 provider 各自使用的模型名
MODEL_MAX_TOKENS 当前 3000。两个模型都是推理模型,token 会先消耗在 reasoning 字段上,给太小会在拿到正文或 tool_calls 前被截断
MODEL_TIMEOUT 当前 300 秒
MODEL_SUPPORTS_TOOLS 留空即自动探测;填 0/1 则跳过探测请求
LOCAL_IGNORE_STOP 设为 0 可关闭 react_compat 的兼容解析器
MODEL_TRUST_ENV 0 即直连,忽略 shell 代理变量(内网服务需要)

10. 实测行为

两种配置下均验证通过:

复制代码
MODEL_PROVIDER=qwen_vl
  工具能力:支持 OpenAI tools 协议,走结构化工具调用路线
  > Invoking: `CurrentTime` with ``
  Agent:现在是2026年10月27日 15:33:20

MODEL_PROVIDER=ollama_direct(默认)
  工具能力:不支持 tools,退回文本解析版 ReAct
  > Thought: Do I need to use a tool? Yes
  > Action: CurrentTime
  Agent:现在是下午3点33分

多轮记忆在两条路线上均正常:先说「我叫小明」,再问「我叫什么名字」,都能正确答出「小明」。


11. 使用边界

  1. Search 依赖外网。纯内网环境下会返回「搜索失败」文本,模型据此如实作答或改走其他途径;可配置代理,或替换为 Tavily 等搜索源。
  2. 推理模型的 token 消耗包含 reasoning 部分,.env 的 MODEL_MAX_TOKENS 不宜低于 3000。
  3. handle_parsing_errors 仅作用于文本解析路线,tools 路线不会触发。
  4. 若 prompts/react_chat.py 被删除且网络不可用,会回落到 REACT_PROMPT_CN,此时多轮记忆失效。
  5. multi_turn_conversation_agent/ReAct_local.py 是另一份自带依赖的同名副本,走固定的文本解析路线,不含本文件的能力探测分支。
相关推荐
Topskys40 分钟前
DeepSeek 稀疏注意力
agent
小七-七牛开发者40 分钟前
多 Agent 架构中的 Subagent 拆分与合并策略
agent·multiagent·subagent·agent架构·harness #工程实践
jerrywus43 分钟前
DeepSeek Harness 和 Pi Agent 怎么选?先想想你愿意折腾什么
前端·agent·deepseek
武子康43 分钟前
HunyuanVideo-1.5 跑得更快,先减步数还是开缓存?
人工智能·后端·agent
vipxieliang1 小时前
Python 面向对象编程:类、继承、多态与组合
python
Nuanyt1 小时前
Agent 学习笔记(下篇二):Claude Code 生态、RAG 与 Memory(Stage 5–6)
笔记·python·memory·rag·aiagent·claude code·agentskill
货拉拉技术1 小时前
一句话叫车:货拉拉小程序 AI 叫车实战
前端·agent·ai编程
用户298698530141 小时前
Python 实战:Markdown 转 PDF 的代码实现与页面设置
python·api·markdown
thusloop1 小时前
零门槛接入 GPT-6-astra 与 claude-opus-5-5,三大场景实战指南
python·gpt·算法·agent·claude·codex