一个小实验看懂 CoT 与 ReAct:让大模型"闭卷推理"还是"带工具干活"
一个小实验看懂 CoT 与 ReAct:让大模型”闭卷推理”还是”带工具干活”
本文基于一个可直接运行的 Python 小项目,用两个贴近真实业务的实验,带你彻底搞懂大模型领域两种最经典的提示模式——**CoT(Chain of Thought,思维链)**和 **ReAct(Reasoning + Acting,推理 + 行动)**的区别。不需要任何大模型开发经验,跟着文章走就能看懂。
目录
- 为什么要关心 CoT 和 ReAct?
- 两个概念:闭卷考试 vs 带着电脑干活
- 项目概览与环境准备
- 公共基础设施:3 分钟看懂 llm.py
- 实验一:CoT——电商售后「退货合规判定」
- 实验二:ReAct——运维值班「线上告警排查」
- 两个实验的对比总结
- 如何运行本项目
- 延伸思考:什么时候该用哪种模式?
1. 为什么要关心 CoT 和 ReAct?
你可能已经用过 ChatGPT、文心一言、智谱清言这类 AI 助手,也隐约感觉它们有时候”很聪明”,有时候又”一本正经地胡说八道”。
其实,大模型(LLM)的输出质量,很大程度上取决于我们怎么问它——也就是所谓的”提示工程”(Prompt Engineering)。而在众多提示模式中,有两位”顶流”:
- CoT(Chain of Thought,思维链):让模型”一步一步想”,把推理过程摊开在桌面上;
- ReAct(Reasoning + Acting):让模型”想一步、做一步、看结果、再想”,边推理边调用外部工具。
理解这两者的区别,你就能明白:
- 为什么 AI 客服能条理清晰地给你判一个”能不能退货”;
- 为什么 AI 运维助手能像真人工程师一样,一步步查日志、查监控,最后定位根因。
本项目就是用两个能跑起来的真实实验,把这两者的区别直观地展示出来——跑完之后,你看输出就能秒懂。
2. 两个概念:闭卷考试 vs 带着电脑干活
用两个类比来快速建立直觉:
| CoT(思维链) | ReAct(推理 + 行动) | |
|---|---|---|
| 类比 | 闭卷考试:所有已知条件都写在卷子上,靠脑子推 | 开卷 + 可以上网查资料:遇到不确定的就动手查 |
| 信息来源 | 全部写在 Prompt(提示词)里 | Prompt 里只有任务,信息靠调用工具现场获取 |
| 输出节奏 | Step 1 → Step 2 → … → 结论 | Thought → Action → Observation → 循环 → Final Answer |
| 适合场景 | 数学题、规则判定、文本分析 | 排查问题、操作系统的任务、需要实时数据的场景 |
一句话总结:
CoT 是”想清楚了再说话”,ReAct 是”边想边干,干完再看,看完再想”。
3. 项目概览与环境准备
3.1 项目结构
Cot VS ReAct 测试方案测试脚本/├── llm.py # LLM 客户端封装(两个实验共用)├── experiment1_cot.py # 实验一:CoT 退货合规判定├── experiment2_react.py # 实验二:ReAct 告警排查├── main.py # 入口占位脚本├── .env # 环境变量(API Key 等,需自己配置)├── pyproject.toml # 项目依赖定义└── Cot VS ReAct 测试方案.md # 实验设计文档整个项目非常精简:2 个依赖、3 个核心文件。
3.2 技术栈
- Python ≥ 3.12
- openai:官方 SDK,但不止能连 OpenAI——凡是兼容 OpenAI 接口协议的服务(智谱 GLM、DeepSeek 等)都能用;
- python-dotenv:从
.env文件读取环境变量,避免把 API Key 硬编码进代码。
3.3 依赖配置(pyproject.toml)
[project]name = "test"version = "0.1.0"requires-python = ">=3.12"dependencies = [ "openai>=3.1.0", "python-dotenv>=1.2.2",]使用 uv 或 pip 安装依赖即可:
uv sync # uv 用户# 或者pip install openai python-dotenv3.4 配置 .env
在项目根目录创建 .env 文件,填入你的模型服务信息:
# 选择供应商:glm / deepseek / openaiLLM_PROVIDER=glm# 模型名LLM_MODEL=glm-4-flash# 对应供应商的 API KeyGLM_API_KEY=你的Key安全提示:
.env文件务必加入.gitignore,永远不要把 API Key 提交到代码仓库。
4. 公共基础设施:3 分钟看懂 llm.py
两个实验都要和大模型对话,所以先封装一个公共模块 llm.py,它只做两件事:创建客户端 和 发起对话。
4.1 多供应商支持
import osfrom dotenv import load_dotenvfrom openai import OpenAI
load_dotenv()
_PROVIDERS = { # provider -> (base_url, api_key 环境变量名) "glm": ("https://open.bigmodel.cn/api/paas/v4", "GLM_API_KEY"), "deepseek": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY"),}
def get_client() -> tuple[OpenAI, str]: """返回 (OpenAI 兼容客户端, 模型名)。""" provider = os.getenv("LLM_PROVIDER", "glm").strip().lower() model = os.getenv("LLM_MODEL", "").strip() if not model: raise RuntimeError(".env 中未设置 LLM_MODEL")
if provider == "openai": base, key = os.getenv("OPENAI_API_BASE"), os.getenv("OPENAI_API_KEY") elif provider in _PROVIDERS: base, key_env = _PROVIDERS[provider] base = os.getenv(f"{provider.upper()}_BASE_URL") or base key = os.getenv(key_env) else: raise RuntimeError(f"未知 provider: {provider}")
if not base or not key: raise RuntimeError(f"provider={provider} 缺少 base_url 或 api_key")
return OpenAI(base_url=base, api_key=key), model设计要点:
- 只换
base_url就能换厂商。因为智谱、DeepSeek 都实现了 OpenAI 兼容接口,所以一个OpenAI类通吃三家——这是业界非常常见的做法; - 配置全部来自环境变量,代码里不出现任何密钥;
- 配置缺失时快速报错(fail fast),不等到请求发出去才发现问题。
4.2 统一的对话入口
def chat(messages, *, tools=None, temperature=0.2, max_tokens=None): """发起一次 chat.completions 请求,返回 choices[0].message。""" client, model = get_client() kwargs = {"model": model, "messages": messages, "temperature": temperature} if tools: kwargs["tools"] = tools if max_tokens: kwargs["max_tokens"] = max_tokens
resp = client.chat.completions.create(**kwargs) return resp.choices[0].messagechat() 封装了一次标准的对话调用:
messages:对话历史列表,每条形如{"role": "system"/"user"/"assistant"/"tool", "content": ...};tools:可选参数,传入工具定义就开启 function calling 能力(实验二的关键);temperature=0.2:调低随机性,让输出更稳定、更”讲道理”,适合做严谨的推理任务。
注意:CoT 实验调用
chat()时不传tools,ReAct 实验传入tools——这正是两个实验的本质区别在代码层的体现。
5.实验一:CoT——电商售后「退货合规判定」
5.1 业务场景
你是某电商平台的售后系统,每天要处理大量退货申请。现在一笔退货单进来了,需要 AI 根据平台规则,一步步推理出”能不能退、怎么赔”。
给模型的输入有两样:
① 退货政策(写死在 Prompt 里的规则):
- 签收后 7 天内可无理由退货
- 食品/贴身衣物类目不支持无理由退货
- 商品已拆封且影响二次销售的,仅支持换货不支持退款
- 因质量问题退货,运费由平台承担;非质量问题,运费买家自理
- 订单金额超过 5000 元需人工复核
② 一笔具体退货工单(模拟数据):
- 商品:某品牌蓝牙耳机,订单金额 329 元
- 类目:数码配件
- 签收时间:5 天前
- 退货原因:买家说”音质和描述不符”
- 商品状态:已拆封,包装完整,配件齐全
- 买家要求:全额退款
5.2 核心代码
POLICY = """【退货政策】1. 签收后 7 天内可无理由退货2. 食品/贴身衣物类目不支持无理由退货3. 商品已拆封且影响二次销售的,仅支持换货不支持退款4. 因质量问题退货,运费由平台承担;非质量问题,运费买家自理5. 订单金额超过 5000 元需人工复核"""
WORK_ORDER = """【退货工单】- 商品:某品牌蓝牙耳机- 订单金额:329 元- 类目:数码配件- 签收时间:5 天前- 退货原因:买家称「音质与描述不符」- 商品状态:已拆封,包装完整,配件齐全- 买家要求:全额退款"""
SYSTEM_PROMPT = ( "你是某电商平台的售后审核 AI,负责根据退货政策对退货工单做合规判定。\n" "你必须用「Step 1 → Step 2 → ... → 最终结论」的格式,逐条对照政策逐步推理," "推理过程要完整写在正文里,不要省略中间步骤。最后给出:" "①判定结果(退 / 换 / 驳回);②给买家的一句话话术。")
def main(): _, model = get_client() print("推理输出(Step 1 → Step 2 → ... → 最终结论):\n")
msg = chat([ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"{POLICY}\n\n{WORK_ORDER}\n\n请开始逐步推理。"}, ]) print(msg.content)5.3 代码里藏着哪些 CoT 技巧?
| 技巧 | 体现 |
|---|---|
| 角色设定 | ”你是某电商平台的售后审核 AI”,让模型进入专业角色 |
| 明确输出格式 | 强制要求「Step 1 → Step 2 → … → 最终结论」,逼模型显式展开推理 |
| 禁止跳步 | ”不要省略中间步骤”,这是 CoT 的灵魂——写出每一步,出错率会显著下降 |
| 规则与事实分离 | 政策是”法律”,工单是”案情”,让模型逐条对照,推理链更清晰 |
5.4 期望输出(示意)
Step 1:判断时效。工单显示签收于 5 天前,在 7 天无理由退货期内 → 时效满足。
Step 2:判断类目。商品是蓝牙耳机,属于数码配件,不属于食品/贴身衣物 → 类目限制不适用。
Step 3:判断拆封情况。商品已拆封,但"包装完整,配件齐全",是否影响二次销售需酌情判断; 音质与描述不符属于体验类问题,拆封是验机的必然结果 → 可支持退货。
Step 4:判断运费承担。退货原因是"音质与描述不符",属于商品与描述不符的质量/描述问题, 非买家无理由退货 → 运费应由平台承担。
Step 5:判断是否人工复核。订单金额 329 元 < 5000 元 → 无需人工复核。
最终结论:①判定结果:支持全额退款,退货运费由平台承担。②话术:您好,您的退货申请已审核通过,本单支持全额退款且运费由平台承担,请按指引寄回商品即可。5.5 这个实验体现了 CoT 的什么特点?
- 纯推理,零工具调用。所有信息都在 Prompt 里,模型不需要查任何外部数据;
- 能清楚看到模型拆解题目的思维链:判时效 → 判类目 → 看拆封 → 定运费 → 查金额,条理分明;
- 改一条规则(比如 7 天改成 15 天),推理结论会跟着变,但流程结构不变;
- 局限也一目了然:它无法验证”买家是不是真的 5 天前签收的”,只能信你给的数据。真实业务里如果数据有假,它会照样一本正经地推下去——因为它是”闭卷考试”。
6.实验二:ReAct——运维值班「线上告警排查」
6.1 业务场景
凌晨 3 点,监控告警:订单服务接口 /api/order/create 的 P99 延迟从 200ms 飙到 3.2s,错误率从 0.1% 涨到 12%。你是值班运维,让 AI Agent 帮你排查根因。
和实验一最大的不同:答案不在 Prompt 里。模型必须”动手查”才能破案。
6.2 第一步:造 4 个 mock 工具
不需要真的连生产环境,写几个本地函数模拟”运维工具”即可:
def query_metrics(service: str, metric: str = "", minutes: int = 30) -> dict: """查监控指标:CPU / 内存 / DB 连接池 等。""" key = (metric or "").lower() if any(w in key for w in ("db", "connection", "conn", "pool", "连接", "连接池")): return { "db_connection_pool": "已打满 10/10", "max_pool_size": 10, "active_connections": 10, "note": "数据库连接池耗尽,新请求拿不到连接", } if any(w in key for w in ("cpu", "memory", "mem", "资源")): return { "cpu_usage": "85%", "memory_usage": "60%(正常)", "note": "CPU 偏高但未打满,内存无压力", } return { "cpu_usage": "85%", "memory_usage": "60%(正常)", "db_connection_pool": "已打满 10/10", "note": "综合指标:CPU 偏高,连接池打满是最明显异常点", }
def query_logs(service: str, keyword: str = "ERROR", minutes: int = 30) -> dict: """按关键字查询服务日志。""" kw = (keyword or "").lower() error_like = any(w in kw for w in ("error", "exception", "conn", "pool", "连接", "报错")) if not error_like and kw not in ("", "log"): return {"count": 0, "note": f"关键字「{keyword}」无匹配日志"} return { "level": "ERROR", "count": 382, "samples": [ "com.zaxxer.hikari.pool.HikariPool: HikariPool-1 - Connection pool exhausted", "java.sql.SQLTransientConnectionException: Connection is not available, " "request timed out after 30000ms", "org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection", ], "note": "大量『连接池耗尽』类报错", }
def query_deploy_history(service: str) -> dict: """查询服务最近 24 小时的发布记录。""" return { "deploys": [{ "time": "2 小时前", "version": "v2.4.1", "operator": "ci-bot", "changes": [ "DB 连接池配置 max_pool_size: 50 → 10", "日志级别由 INFO 调整为 WARN", ], }], "note": "最近 24 小时仅此一次发布", }
def query_upstream(service: str) -> dict: """查询上下游依赖(MySQL / Redis 等)状态。""" return { "mysql_primary": "正常(连接数 30/500)", "redis": "正常(内存 40%,无慢查询)", "note": "上游依赖均正常,可排除外部原因", }再把工具注册进一张表,供后续统一调度:
TOOL_REGISTRY = { "query_metrics": query_metrics, "query_logs": query_logs, "query_deploy_history": query_deploy_history, "query_upstream": query_upstream,}关键点:这些工具不是一开始全调一遍,而是由 LLM 自己决定”我现在该查什么”——调一个、看结果、再决定下一步。这是 ReAct 与”把 4 个接口都塞进 Prompt 让它分析”的根本区别。
6.3 第二步:用 JSON Schema 告诉模型”有哪些工具可用”
这是 OpenAI 兼容的 function calling 协议(节选一个工具的定义):
TOOLS = [ { "type": "function", "function": { "name": "query_metrics", "description": "查询监控指标(CPU、内存、DB 连接池等)", "parameters": { "type": "object", "properties": { "service": {"type": "string", "description": "服务名"}, "metric": {"type": "string", "description": "指标名,可留空"}, "minutes": {"type": "integer", "description": "回溯最近多少分钟"}, }, "required": ["service"], }, }, }, # ... 其余 3 个工具定义结构相同]模型看到这份”工具说明书”后,就能在需要时返回结构化的调用请求(工具名 + JSON 参数)。
6.4 第三步:ReAct 的提示词——约定”想一步、查一步”的节奏
SYSTEM_PROMPT = """你是一名资深 SRE 值班工程师,正在排查线上告警。你有以下 4 个工具可用:- query_metrics(service, metric, minutes):查监控指标(CPU/内存/DB连接池等)- query_logs(service, keyword, minutes):按关键字查服务日志- query_deploy_history(service):查最近 24 小时发布记录- query_upstream(service):查上下游依赖(MySQL/Redis 等)状态
排查要求:1. 采用「想一步、查一步、看一步」的节奏:每次调用工具前,先输出一行「Thought:」说明你的分析和下一步要查什么。2. 每次只调用一个工具,根据返回的 Observation 结果再决定下一步,不要一次把所有工具都查了。3. 逐步缩小范围直到找到根因;找到根因后停止调用工具,输出「Final Answer:」给出根因、依据和处置建议。"""
USER_PROMPT = """【告警信息】- 服务:order-service- 接口:/api/order/create- P99 延迟:200ms → 3.2s(飙升 16 倍)- 错误率:0.1% → 12%
现在是凌晨 3 点,请开始排查,找出根因。"""6.5 第四步:核心循环——ReAct 的引擎
MAX_STEPS = 8 # 防止无限循环的保险丝
def main(): _, model = get_client() print("排查过程(Thought → Action → Observation 循环):\n")
messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": USER_PROMPT}, ]
step = 0 while step < MAX_STEPS: msg = chat(messages, tools=TOOLS)
# 模型不再调用工具 → 输出 Final Answer,循环结束 if not msg.tool_calls: print(f"\n[Final Answer]\n{msg.content}") break
step += 1 thought = re.sub(r"^Thought\s*[::]\s*", "", (msg.content or "").strip(), flags=re.I) print(f"[Thought {step}] {thought or '(模型未输出思考文本)'}") messages.append(msg)
for i, tc in enumerate(msg.tool_calls, start=1): label = f"[Action {step}]" if len(msg.tool_calls) == 1 else f"[Action {step}-{i}]" print(f"{label} {tc.function.name}({tc.function.arguments})") observation = run_tool(tc.function.name, tc.function.arguments) print(f"[Observation {step}] {observation}\n") messages.append({ "role": "tool", "tool_call_id": tc.id, "content": observation, }) else: print(f"\n[警告] 达到最大步数 {MAX_STEPS} 仍未给出 Final Answer,已终止。")其中 run_tool 负责真正执行工具并把结果变成字符串(Observation):
def run_tool(name: str, arguments: str) -> str: """执行 mock 工具,把返回结果序列化为 Observation 字符串。""" try: args = json.loads(arguments or "{}") result = TOOL_REGISTRY[name](**args) except Exception as e: # 工具参数异常时兜底,保证循环能继续 result = {"error": f"工具调用失败: {type(e).__name__}: {e}"} return json.dumps(result, ensure_ascii=False, indent=2)这段循环是整个 ReAct 的灵魂,逐行拆解它的工作机制:
- 发消息给模型(带上
tools=TOOLS),模型这次可能返回两种东西:tool_calls非空 → 它想调用工具(这就是 Action);tool_calls为空 → 它认为已破案,正文就是 Final Answer;
- 有
tool_calls时,本地执行对应函数,拿到结果作为 Observation; - 把模型消息和工具结果都追加回
messages(注意role: "tool"和tool_call_id,这是协议要求,让模型知道”这个结果对应哪次调用”); - 带着完整历史再问模型,它看到新的 Observation,会决定下一个 Action……如此循环;
MAX_STEPS = 8是保险丝:万一模型陷入死循环(反复查同一个指标),强制终止。
6.6 期望输出(示意)
[Thought 1] 延迟飙升且错误率上升,先看是不是资源瓶颈,查 CPU 和内存。[Action 1] query_metrics({"service": "order-service", "metric": "cpu_memory", "minutes": 30})[Observation 1] { "cpu_usage": "85%", "memory_usage": "60%(正常)", ... }
[Thought 2] CPU 偏高但不离谱,内存正常。错误率 12% 说明有具体报错,去翻日志。[Action 2] query_logs({"service": "order-service", "keyword": "ERROR", "minutes": 30})[Observation 2] { "count": 382, "samples": ["HikariPool-1 - Connection pool exhausted", ...] }
[Thought 3] 数据库连接池打满了。是资源泄漏还是配置问题?查一下最近有没有发布。[Action 3] query_deploy_history({"service": "order-service"})[Observation 3] { "deploys": [{ "version": "v2.4.1", "changes": ["DB 连接池配置 max_pool_size: 50 → 10"] }] }
[Thought 4] 破案了。连接池从 50 改成 10,扛不住流量。再确认不是上游数据库的问题。[Action 4] query_upstream({"service": "order-service"})[Observation 4] { "mysql_primary": "正常", "redis": "正常" }
[Final Answer]根因:2 小时前的发布 v2.4.1 将 DB 连接池 max_pool_size 由 50 误改为 10,导致连接池打满。依据:日志中大量 Connection pool exhausted;发布记录显示该配置变更;上游 MySQL/Redis 正常。建议:立即回滚 v2.4.1 或热改连接池配置恢复到 50。6.7 这个实验体现了 ReAct 的什么特点?
- 不是一次性给答案,而是”想一步、查一步、看一步”。每一步 Action 取决于上一步 Observation;
- 动态决策能力:如果把 mock 数据改一下(比如日志里没有连接池报错,而是
OOM Killed),Agent 的排查路径会完全不同,它会转向查内存和 JVM 参数; - 工具调用的顺序、次数、选哪个工具,全是 LLM 自己决定的,不是代码里写死的 if-else。
7.两个实验的对比总结
把两个实验的输出放在一起,区别一目了然:
| 实验一(CoT) | 实验二(ReAct) | |
|---|---|---|
| 核心看点 | 推理链的深度和条理性 | 推理与行动的交替循环 |
| 有没有工具调用 | 没有,全靠 Prompt 里的信息 | 有,4 个 mock 工具 + function calling |
| 输出结构 | Step 1 → Step 2 → 结论 | Thought → Action → Observation → 循环 → Final Answer |
| 代码层面 | 一次 chat() 调用 | while 循环 + 工具执行 + 历史回填 |
| 改输入数据会怎样 | 推理结论变,但推理路径基本固定 | 排查路径会完全改变,体现自适应 |
| 跑完你会感受到 | ”它想得真清楚" | "它真的在一步步查、一步步排除” |
用一张图概括两者的执行模型差异:
CoT: 输入(全部信息) ──→ [模型一次推理] ──→ 结论
ReAct: 输入(任务) ──→ Thought ──→ Action ──→ [工具执行] ──→ Observation ↑ │ └────────────── 未结束,带着结果继续想 ←────┘ (循环直到 Final Answer)8.如何运行本项目
8.1 准备
# 1. 安装依赖(uv 项目)uv sync# 或 pip install openai python-dotenv
# 2. 配置 .env(参见第 3.4 节)LLM_PROVIDER=glmLLM_MODEL=glm-4-flashGLM_API_KEY=你的Key8.2 运行两个实验
# 实验一:CoT 退货合规判定python experiment1_cot.py
# 实验二:ReAct 告警排查python experiment2_react.pyWindows 用户无需担心中文乱码——脚本里已经用
sys.stdout.reconfigure(encoding="utf-8")做了处理:
if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8")8.3 动手改一改(强烈推荐)
理解这两个模式最好的方式就是破坏性实验:
- 实验一:把政策里的「7 天」改成「15 天」,观察推理链如何随之变化;
- 实验二:把
query_logs返回的报错从Connection pool exhausted改成OOM Killed,观察 Agent 的排查路径如何彻底转向; - 实验二:把
MAX_STEPS改成 2,看看”保险丝”如何保护系统不陷入死循环。
9.延伸思考:什么时候该用哪种模式?
结合两个实验,可以总结出一条实用的选型原则:
| 场景特征 | 推荐模式 |
|---|---|
| 所有已知条件都能提前拿到(规则判定、数学计算、文本分析) | CoT |
| 信息散落在外部系统里(数据库、日志、API、文件系统) | ReAct |
| 需要根据中间结果动态调整策略(排障、调研、多步操作) | ReAct |
| 对成本和延迟敏感(ReAct 多轮调用更贵更慢) | 能用 CoT 就用 CoT |
最后留两个值得思考的问题:
- CoT 的致命弱点是”信息必须提前给定”——如果给它的数据本身是错的,它会一本正经地推出一个错误结论(垃圾进,垃圾出)。而 ReAct 能通过工具现场核实,这正是 Agent 化的价值所在。
- ReAct 不是万能药:多轮循环意味着更高的成本、更长的耗时、更大的不确定性(所以才需要
MAX_STEPS这样的保险丝)。工程上往往把两者结合——先用工具收集事实(ReAct),再对收集到的事实做一次纯粹的深度推理(CoT)出结论。
跑完这两个脚本,把输出往一起一放——CoT 和 ReAct 的区别,不用解释,看输出就全懂了。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!