一个带多轮上下文记忆的 ReAct Agent 脚本:用户输入问题后,模型自行判断要不要调用工具、调哪个工具,拿到工具返回结果后再作答;支持联网搜索与查询当前时间。
目录
- [1. 运行方式](#1. 运行方式)
- [2. 依赖文件](#2. 依赖文件)
- [3. 文件结构](#3. 文件结构)
- [4. 组件逐一说明](#4. 组件逐一说明)
- [5. 执行流程](#5. 执行流程)
- [6. AgentExecutor 的运行机制](#6. AgentExecutor 的运行机制)
- [7. 多轮记忆](#7. 多轮记忆)
- [8. 文本路线的解析器为什么需要特殊处理](#8. 文本路线的解析器为什么需要特殊处理)
- [9. 配置速查](#9. 配置速查)
- [10. 实测行为](#10. 实测行为)
- [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 处理三种情形:
- 输出里含 Action ------ 优先按「要调工具」处理,并在自撰的
Observation处截断,防止幻觉内容被当成工具入参。注意只在确实存在 Action 时才截断,否则最终答案里若出现「Observation:」字样会被误裁。 - 只有 Final Answer ------ 交给标准解析器按常规流程处理。
- 既无 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. 使用边界
Search依赖外网。纯内网环境下会返回「搜索失败」文本,模型据此如实作答或改走其他途径;可配置代理,或替换为 Tavily 等搜索源。- 推理模型的 token 消耗包含 reasoning 部分,
.env的MODEL_MAX_TOKENS不宜低于 3000。 handle_parsing_errors仅作用于文本解析路线,tools 路线不会触发。- 若
prompts/react_chat.py被删除且网络不可用,会回落到REACT_PROMPT_CN,此时多轮记忆失效。 multi_turn_conversation_agent/ReAct_local.py是另一份自带依赖的同名副本,走固定的文本解析路线,不含本文件的能力探测分支。