ReAct 不是框架,是一个 while 循环:把 Agent 的推理模式拆到能自己写出来

假设你在做一个内部运维助手,需求很朴素:用户丢一句「帮我把今天三台机器的错误日志对一下,找出共同的报错,写个简报」。你第一反应大概是:这不就是一次模型调用的事吗?把日志拼进 prompt,让它输出简报。

第一版你多半会这么写:读三个日志文件,拼成一大段文本,塞进 prompt,调用一次模型,把返回写进 report.md。跑起来你会发现几个问题,而且都不是「模型不够聪明」能解释的。

日志可能有一百多兆,根本塞不进上下文窗口,你只能截断,一截断就漏掉关键报错。就算塞进去了,模型也没法「去查一下」------它只能基于你给的那段文字说话,你给什么它看什么。最要命的是第三个:如果简报要求带上「这三台机器今天各自的 CPU 峰值」,这个数字不在日志里,模型只能编一个看起来合理的数,而且编得非常像真的。

这三个问题指向同一件事:一次模型调用是「封闭」的------输入在调用前就固定了,模型无法在中途获取新信息,也无法在中途执行任何动作。它能做的只有「基于已有上下文继续生成文字」。

所以要做的不是换个更强的模型,而是换一种组织方式:让模型有机会在中途说「我需要先查一下 X」,你的代码去执行这个「查」,把结果再交给它。这个「说一句、做一步、看一眼结果、再说下一句」的过程,就是 ReAct。我第一次写这类东西时踩的坑是:以为要在 prompt 里写一堆「请你先思考再行动」的咒语,其实真正让循环转起来的是代码侧的判断逻辑,prompt 只负责让模型愿意输出结构化的调用意图。

这一章先把痛点摆清楚:Agent 要解决的不是「模型不够强」,而是「一次调用拿不到中途才需要的信息、也做不了中途才该做的动作」。

模型不执行任何东西:把「工具调用」这层窗户纸捅破

要打破上面那个封闭,得先接受一件反直觉的事:模型从头到尾没有执行过任何操作。它不会读文件、不会发请求、不会跑命令。它唯一会做的事,是输出文字。

那「调用工具」是怎么回事?其实是三方配合。你的代码在请求里附带一份「工具清单」,说明有哪些函数可用、每个函数叫什么、需要什么参数、是干什么的。这份清单是给模型看的说明书。模型读到用户的诉求后,如果判断需要某个工具,它不返回自然语言,而是返回一段结构化的调用意图------大意是「我要调用 search_logs,参数是 file=a.log」。这段意图通常放在响应里一个专门的字段里,各家 API 叫法不同,OpenAI 风格叫 tool_calls,字段里带一个 id、一个函数名、一段 JSON 字符串形式的参数。你的代码解析这段意图,找到本地对应的函数,真正执行它,拿到结果,然后把结果作为一条新消息追加进对话历史,再发起下一次请求。

这里的关键在于:模型输出的是「意图」,代码负责「执行」。你可以把模型想成餐厅里下单的人,它只写单子,不碰锅;后厨(你的代码)才真正开火。这个分工决定了后面所有的设计。模型不知道文件存不存在、命令会不会超时、返回的数据有多长;这些全是你代码的事。反过来,代码不知道「下一步该查什么」,那是模型的判断。我第一次实现时把这两边搞混了,试图在代码里硬编码「先查 A 再查 B」,结果一旦任务变了就全废------判断下一步该干什么,必须交回给模型。

现在把三个动作合起来看。Reason(推理,就是「想」):模型看着当前对话历史,决定下一步做什么,产物可能是「我要调用某工具」,也可能是「我已经能回答了」。Act(行动,就是「做」):你的代码执行模型指定的工具,拿到原始结果。Observe(观察,就是「看」):把执行结果作为一条消息追加进对话历史,让模型在下一轮「看到」它。

一轮结束,带着新观察结果回到 Reason,如此往复。注意这里没有魔法:所谓「循环」,就是你的代码里一个 forwhile,每轮把完整的对话历史重新发给模型。模型是无状态的,它不记得上一轮发生了什么,它「记得」的唯一原因是你把历史又发了一遍。这就像你每轮都要把之前所有对话重新念一遍给对方听,因为它天生记不住。

为什么设计成这样,而不是让模型一次把计划全列出来再执行?因为很多任务的下一步依赖上一步的结果。你不知道日志里有哪些报错,就不知道该 grep 什么关键词;不知道搜索结果里哪条相关,就不知道该抓哪个页面。ReAct 把「规划」拆散到每一轮里,用最新的观察结果去修正下一步------它牺牲了全局视野,换来了对不确定信息的适应能力。这个取舍是它的优点,也是它的软肋,后面会专门讲。

还有两个必须交代的机制,否则循环写出来就是半成品。

退出条件。循环不能永远转。正常的退出是:某一轮模型返回的响应里没有工具调用意图,只有一段自然语言------这表示它认为信息够了,可以给答案了,代码直接把这段文字返回给用户。但光靠这个不够,模型可能反复调同一个工具停不下来。所以代码侧要有兜底:一是迭代轮数上限(比如 10 轮或 15 轮),二是 token 预算上限(累计消耗接近模型上下文窗口的某个比例就强制停)。这两个上限谁先触发谁生效,触发后返回一句「达到上限,任务未完成」之类的提示,而不是假装成功。

对话历史即记忆。上面反复说「把历史重新发一遍」,那历史里到底有什么?大致是:用户的原始诉求、每一轮模型的响应(包括它要求调用工具的意图)、每一次工具执行的结果。这就是 Agent 的全部「记忆」------没有额外的记忆模块,它就是这个不断增长的数组。理解这一点,就能预判两个必然的工程问题:历史会越来越长,迟早撑爆上下文;以及,工具返回的内容如果很长(比如一次搜索返回几万字),会迅速把预算烧掉。这两个问题在实例和边界两章会具体处理。

最后把 ReAct 和它的几个「亲戚」摆在一起看,你会发现它们改的都是同一个循环的不同部位。Plan-Execute(先规划再执行),在进入循环之前,先让模型产出一份完整步骤清单,然后逐步执行,某一步失败再重新规划;它改的是「规划发生的时机」------把散在每轮的规划提到前面,换来可控性,代价是计划赶不上变化时要重来。Router(路由),前面加一个轻量判断,先给任务分类,再分发给专门的循环处理;它改的是「谁来选下一步」------不让一个万能循环硬扛所有任务类型。Multi-Agent(多智能体协作),同时跑多个循环,由一个调度方协调;它改的是「几个循环并行」。

所以「Agent 推理模式有哪些」这个问题,答案不是一堆并列的名词,而是同一个循环在三个位置上的变体。ReAct 是那个最朴素的基线,其他模式都是在它暴露出的某个短板上做文章。我一般的判断顺序是:先用 ReAct 把任务跑通,观察它到底卡在哪------是没全局规划、还是任务类型太杂、还是单循环扛不住------再决定要不要换,而不是一上来就上多智能体。

手写一个最小 ReAct:让助手自己去查日志、自己算、自己写简报

回到开头那个运维助手。我们不用任何 Agent 框架,就用一个 LLM API 加一个循环,把它跑起来。目标很小:给一个日志文件名,让助手自己决定要不要读、读完之后能不能回答「有哪些错误类型」。我刻意把 demo 做小,因为框架会藏起你最该看清的那层。

第一步:先定义工具,也就是给模型的说明书

工具本身是普通函数,重点是配套的「描述」------模型只能靠这段描述判断什么时候该用它。描述写得含糊,模型就不会用或者乱用;这是我在实际项目里花时间最多的地方,比调循环逻辑还费神。

python 复制代码
import json

def read_file(path: str) -> str:
    """读取指定路径的文本文件,返回前 4000 个字符。"""
    with open(path, encoding="utf-8") as f:
        return f.read()[:4000]

def count_errors(path: str, keyword: str) -> str:
    """统计文件中包含 keyword 的行数,返回一个数字字符串。"""
    with open(path, encoding="utf-8") as f:
        n = sum(1 for line in f if keyword in line)
    return str(n)

TOOLS = {"read_file": read_file, "count_errors": count_errors}

为什么 read_file 要截断到 4000 字符?因为工具返回的内容会原样进对话历史,返回多少就烧多少 token。这一步是后面「边界」一章的伏笔。

第二步:把工具清单翻译成模型能读的格式

python 复制代码
SCHEMA = [
    {"type": "function", "function": {
        "name": "read_file",
        "description": "读取文本文件内容,用于查看日志原文",
        "parameters": {"type": "object",
                       "properties": {"path": {"type": "string"}},
                       "required": ["path"]}}},
    {"type": "function", "function": {
        "name": "count_errors",
        "description": "统计文件中包含某个关键词的行数,用于计数",
        "parameters": {"type": "object",
                       "properties": {"path": {"type": "string"},
                                      "keyword": {"type": "string"}},
                       "required": ["path", "keyword"]}}},
]

这段就是每次请求都要带上的「工具清单」。注意它和上面的函数是两套东西:函数是给代码调的,schema 是给模型看的,两者靠 name 对应。模型永远不会看到函数体。

第三步:写循环本体

python 复制代码
def run(goal, max_steps=8):
    messages = [{"role": "user", "content": goal}]
    for step in range(max_steps):
        resp = client.chat(messages=messages, tools=SCHEMA)   # 每轮重发历史+清单
        msg = resp.choices[0].message
        messages.append(msg)                                  # 模型的响应也进历史

        if not msg.tool_calls:                                # 没有调用意图 → 收工
            return msg.content

        for call in msg.tool_calls:                           # 可能有多个,逐个执行
            fn = TOOLS[call.function.name]
            args = json.loads(call.function.arguments)
            result = fn(**args)
            messages.append({"role": "tool",                 # 结果回填,带上 id 对应
                             "tool_call_id": call.id,
                             "content": result})
    return "达到步数上限,任务未完成"

逐段说。messages 是全部记忆,初始只有用户那句话。每轮把 messagesSCHEMA 一起发出去------历史每轮都重发,这是无状态模型能被当成有记忆用的唯一原因。拿到响应后先把它自己追加进历史,因为下一轮模型需要看到「我上轮说了什么」。然后判断 tool_calls:为空就说明它要直接回答,循环结束。

有调用意图时,遍历执行。call.function.arguments 是一段 JSON 字符串,要自己解析------模型偶尔会给出格式不对的参数,真实项目里这里必须包一层 try,解析失败就把错误信息当作工具结果回填,让模型看到「你参数写错了」并自我纠正。执行完把结果作为 role: tool 的消息追加,并且必须带上 tool_call_id:一轮里模型可能同时要求调三个工具,这个 id 是让每个结果对上号的唯一凭据,漏了会直接报错。

第四步:跑起来看每一轮

给它的目标是「读 a.log,告诉我里面有多少行包含 ERROR」。正常你会看到三轮:第一轮模型要求 read_file(a.log),你的代码执行并回填;第二轮它可能要求 count_errors(a.log, "ERROR"),回填一个数字;第三轮它不再要求调用工具,直接输出「a.log 中共有 37 行包含 ERROR」。

跑完该看到什么:终端里打印出最终那句回答,同时你可以把每轮的 messages 长度打出来,会明显看到它一轮比一轮长。这个观察很重要------它就是你后面会遇到的上下文膨胀问题的现场证据。

第五步:故意让它失败一次

count_errors 临时改成对不存在的路径读文件,再跑一遍。你会看到工具抛出异常,如果没做处理,整个循环直接崩。加上 try 之后,把异常信息当结果回填,模型下一轮通常会换个路径重试,或者告诉你「文件读不到」。这一步是我强烈建议每个人亲手做一遍的:它让你直观感受到「模型负责决策、代码负责兜底」这条分工线到底落在哪。

到这里,开头那个痛点就闭环了:模型能在中途要求读文件、要求计数,是因为循环把它的「意图」变成了真实的执行,又把执行结果喂回给它。它没有变聪明,只是从「一次性生成」变成了「多步生成」。

循环转起来之后,麻烦才刚开始:什么时候不该用 ReAct

最小 demo 跑通会给人很大的信心,但真实项目里,翻车点几乎都集中在下面几处。

第一,上下文会单向膨胀,而且不可逆。每轮都要重发全部历史,历史里又混着工具返回的原始内容。搜索结果一次几万字、日志一次几千行,几轮下来预算就没了。常见的做法是压缩:保留最近几轮不动,把更早的对话摘要成一段短文本。这里有个容易被忽略的细节------切分点必须落在用户消息的边界上,不能把「模型要求调用工具」和「工具返回结果」这对消息拆开,拆开之后模型看到的是一段无头无尾的历史,行为会变得很奇怪。压缩通常能压到原来的两三成,但摘要本身也是模型生成的,会丢信息,所以它是缓解不是解决。

第二,它会「走一步看一步」地迷路。ReAct 每轮只看当前历史,没有全局计划,遇到步骤多的任务容易反复打转------比如反复读同一个文件、反复搜同一个关键词。兜底的迭代上限能防死循环,但防不住「转了很多圈还是没进展」。判断信号是:连续几轮的工具调用参数高度相似。如果你观察到这个,说明这个任务可能不适合纯 ReAct。

第三,它不适合步骤明确、可以提前规划的任务。如果流程是固定的(先校验、再转换、再入库),用 ReAct 是浪费------每个步骤都要多花一次模型调用来「决定」本来就已知的事,又慢又贵又不可控。这种场景应该用 Plan-Execute 或者干脆写成普通代码加几次定点调用。我一般的判断标准是:下一步该做什么,是否依赖上一步的结果?依赖,用 ReAct;不依赖、能提前列清楚,就别用。

第四,简单单步任务用 Agent 是过度设计。如果任务就是「把这段话翻译一下」或者「提取这个 JSON 里的字段」,一次普通的函数调用就够了,套上循环只会增加失败面。工具调用本身是有成本的:模型可能选错工具、参数可能写错、返回可能超长。

第五,工具描述的质量直接决定成败。这是最容易被低估的一点。模型选不选某个工具、参数填得对不对,几乎完全取决于你写的那段 description。描述含糊、两个工具职责重叠、参数没说清格式,都会导致调用准确率下降。我在实际项目里的经验是:与其反复调循环逻辑,不如把工具描述当成产品文案来写------说清「什么时候用」「什么时候不用」「参数长什么样」。

第六,安全边界必须由代码守,不能交给模型。循环里执行的是模型指定的操作,如果工具里有「删除文件」「执行命令」这类能力,模型一旦判断失误就是真实损失。现实做法是给工具分级:只读的可以直接放行,写操作和危险操作要么二次确认,要么直接不给模型。指望在 prompt 里写「请谨慎操作」是没有约束力的。

第七,成本是线性叠加的。每多一轮就多一次完整的模型调用,而每次调用都要带上越来越长的历史。所以 Agent 的 token 消耗不是「一次调用的几倍」,而是随轮数加速增长。做预算时要按最坏情况估,别按平均轮数估。

把这七条合起来,什么情况下别用 ReAct 就清楚了:任务步骤固定且可提前规划、任务本身只需一次生成、延迟敏感(每轮都是一次网络往返)、或者操作涉及不可逆的写操作而你又没法做人工确认。这些场景下,ReAct 的灵活性换不来收益,只会带来成本和不确定性。还有一个常被忽略的维度是确定性:同样的输入,ReAct 每轮的工具选择可能不同,最终答案也可能不同。如果你的场景要求可复现、可审计(比如财务对账),纯 ReAct 的随机性本身就是风险,宁可把流程写死。

小结

回到最开始那句「帮我把三台机器的错误日志对一下」。现在你应该能判断:这个任务适合 ReAct,因为「要 grep 什么关键词」依赖「日志里有什么」这个上一步才知道的信息;但它也需要几条护栏------工具返回要截断、迭代要有上限、读文件之外的操作要谨慎。这就是从「知道有个东西叫 ReAct」到「知道该不该用它」之间的距离。

几句能记住的话。模型不执行任何操作,它只输出「我想调用哪个工具、参数是什么」,真正执行的是你的代码,这条分工线是理解一切 Agent 的起点。ReAct 就是一个循环:把历史和工具清单发出去、解析模型的调用意图、执行、把结果回填、再发一次;模型无状态,所谓记忆就是你每轮重发的那个数组。退出靠两个机制配合:模型不再要求调用工具是正常出口,迭代次数和 token 预算上限是兜底,两个上限都要有。上下文会单向膨胀,压缩是缓解不是解决;工具描述的质量比循环逻辑更影响成败。Plan-Execute、Router、Multi-Agent 不是另起炉灶,它们分别改了「规划在哪做」「谁来选下一步」「几个循环并行」。先跑通 ReAct,看它卡在哪,再决定换不换。

如果你现在要动手,我的建议顺序是:先用两三个只读工具把最小循环跑通,观察每轮的 messages 增长;然后故意制造一次工具失败,看模型会不会自我纠正;最后再考虑加第二个工具、加压缩、加预算控制。跳过前两步直接上框架,你会调不动也看不懂。

你手头有没有那种「需要先查一下才知道下一步查什么」的任务?可以拿它当第一个练手场景------如果发现它其实能提前把步骤列清楚,那正好说明你不需要 ReAct。

相关推荐
属于自己的天空44 分钟前
不用再手动查表结构了:配好 MCP,Claude Code 自己读数据库生成代码
数据库·后端
用户8181870627461 小时前
第28章 消息积压应急处理实录:一次真实大促故障复盘
java·后端
葡萄城技术团队1 小时前
GcExcel V9.2 新特性揭秘:让表单控件随形状一起成组
后端
葡萄城技术团队2 小时前
GcExcel V9.2 新特性揭秘:工作簿里的一层自定义 XML
后端
DyLatte2 小时前
AI 给了我 8 个优化方案,全都是对的,但没有一个有用
前端·后端·程序员
传奇开心果编程2 小时前
【Go入门练中学】第2课:条件与循环
后端·学习·golang
wno7043 小时前
Spring Security基础使用
java·后端·spring
Go_error3 小时前
那个让我们损失了 47,000 美元 AWS 账单的互斥锁错误
后端
Go_error3 小时前
接口何时冗余:一种用于 Go 测试的精简依赖注入方法
后端