聊到大模型 Agent 的架构演进,ReAct 已经快成了条件反射式的默认选择;可最近半年我越做越觉得,从 ReAct 走向 RLM(递归语言模型)这条路线,才是处理复杂任务的关键。RLM 的核心思想不是“让模型多思考几步”,而是把 Agent 当成一段可以递归调用的程序来写,也就是标题里说的“递归编程”。这篇文章会从 ReAct 的边界讲起,给出一个最小可运行的 RLM 样例,再用一组实测数据回答一个问题:递归编程到底是不是大模型 Agent 的终极形态。
直接说结论:我不认为递归编程是唯一答案,但它是我目前见过的、在工程上最接近“把复杂度结构化”的方案。如果你正在为 Agent 的多步任务翻车率发愁,这篇文章应该能给你一种新的架构视角。我也会把踩过的坑和调试技巧放在最后,尽量让内容能直接落到代码里去。
1. 从 ReAct 说起:Agent 架构的第一性原理
1.1 ReAct 为什么能火:把“思考-行动-观察”编译成 token 循环
ReAct 火了有它必然的逻辑。它把传统强化学习里的“感知-决策-行动”循环,直接映射到了大模型最擅长的 token 序列上。模型在每一步生成 Thought(我想怎么做)、Action(我调用什么工具)、Observation(我看到了什么结果),然后继续循环,直到得到最终答案。这套东西几乎不需要任何额外训练,只要你把格式写进 system prompt 里,模型就能按这个节奏跑起来。
我见过很多刚接触 Agent 的人第一反应是“这不就是给模型套了个循环吗”。某种意义上确实如此,但 ReAct 成功的点恰恰在这里:它把一个原本需要强化学习框架才能解决的问题,降维成了“让语言模型用文本模拟循环”。模型不需要真的理解控制流,它只需要在特定位置输出文本,外层代码判断一下当前输出是 Thought 还是 Action,然后继续喂回去。这就让 Agent 的开发门槛一下子降到了“写 prompt”的级别,也让普通工程师都能快速做出一个能干活的原型。
用生活一点的类比:ReAct 就像让一个新手边做事边写日记,每做完一步就停下来读一遍日记再决定下一步。好处是每一步都有记录、可追溯;坏处是日记越写越长,新手必须反复翻前面的内容才能继续。如果只是两三步的任务,这个模式非常顺手;可一旦任务超过五步,阅读旧日记本身就会变成负担,甚至把当前决策带偏。
1.2 ReAct 的边界:上下文膨胀与错误累积
我自己的小规模测试里,一个需要 5 次搜索加 3 次计算的多步任务,ReAct 模式跑到一半,系统提示词加历史轨迹往往已经超过 8k 到 10k token。模型每一步的决策都要“重新阅读理解”整条轨迹,这意味着:
- 上下文膨胀:token 成本随步数线性甚至超线性上涨;
- 注意力稀释:模型越来越多地关注不重要的轨迹细节,而不是当前真正需要的“最近状态”;
- 错误累积:一旦某一步的 Thought 判断错误,后续 Observation 会基于错误前提继续推理,模型很少主动纠正自己。
错误累积是我在工程里最头疼的问题。ReAct 模式下,前一步的错误不会消失,它会被写进上下文成为“既定事实”。后面几步模型常常是在给一个错误的前提做合理化解释,而不是回到岔路口重新选择。你可以加“请反思一下”之类的 prompt 让它自我纠正,但在长链路任务里,这种纠正的成功率并不稳定,有时候模型还会越反思越混乱。
成本上也有直接感受。一个三步检索任务用 ReAct 跑如果花了 5 块钱,加到八步可能不是 10 块,而是 15 到 20 块。因为每一步的输入都包含完整历史,光把历史重新编码一遍就是很大开销。这也是为什么很多做 Agent 落地的团队都急着找替代方案:不是 ReAct 不能用,而是它撑不起复杂链路。
1.3 打补丁:记忆、规划、反思的局限
为了缓解这些问题,社区里出现了大量 ReAct 的“补丁版”:加记忆压缩模块,把历史轨迹总结成摘要;加规划模块,让模型先把任务拆成一二三四步再执行;加反思模块,执行完一轮之后让模型审视自己的错误并生成修正。我也在不少项目里用过这些方案,它们确实有效,但治标不治本。
本质问题在于:这些补丁仍然把全部状态放在同一个巨大的上下文里,只是换了一种“整理方式”。记忆摘要还是会漏信息,规划模块本身也可能规划错,反思模块则多了一次整体性的上下文重读,成本又上去了。更关键的是,这些补丁之间往往互相干扰——比如规划模块让你走 A 方案,反思模块又让你回头改,模型很难判断到底听谁的。我曾经在一个项目里同时挂了规划、记忆、反思三个模块,结果任务变好了一点点,但调试的时候完全说不清是哪个模块起的作用。这种“补丁叠补丁”的状态很难长期维护。
所以去年下半年开始,我逐渐转向另一条路线:与其在“同一个线性上下文”里打补丁,不如直接把 Agent 改造成一个带运行时(runtime)状态的程序。这就是 RLM 的思路,也是接下来要展开的核心内容。
2. 另一条路:RLM 与递归编程
2.1 RLM 到底在改什么:从线性上下文到运行时状态
“递归语言模型”这个叫法在不同资料里有不同解释,我这里用它指代一种特定的 Agent 设计:模型不再维护一个无限长长的上下文轨迹,而是维护一个结构化的运行时状态,比如变量表、程序计数器、调用栈、工具执行结果。模型每次只读取一小块状态,决定下一步做什么,然后由外层解释器执行操作并更新状态。
换个更直白的说法:ReAct 是“人在旁边看着模型自己翻笔记”,RLM 是“让模型当操作员,笔记交给外部系统管理”。模型需要记住的东西少了,因为它随时可以从状态里查;需要推理的东西也就更聚焦了,因为它不用再考虑过去二十步发生了什么,只需要面对当前这一小步。
这个思路最早让我觉得可行,是因为它非常像编程语言的发展史。早期程序把逻辑全部写在纸上,后来有了运行时和内存管理,程序就变成了“指令 + 状态 + 解释器”的三层结构。Agent 也是一样:当任务复杂度超过模型的瞬时工作记忆,就必须把状态外置,而不是硬塞进模型的注意力窗口里。RO11M 的“递归”二字,强调的就是子任务可以像函数调用一样嵌套、复用、返回结果,而不是简单地把步骤一条条列出来。
2.2 递归编程不是“让模型写代码”
很多人一听递归编程,第一反应是“让 Agent 写代码然后执行”。这个理解只对了一半。让模型写一段独立代码再跑,确实是常见做法,比如代码解释器类工具就是这样,但那只是把模型当成“代码生成器”,模型和运行结果之间依然是断开的。RLM 里的递归编程强调的是:模型本身就是解释器的一部分,它不写全部代码,而是只生成“下一条指令”或“下一个子任务定义”,然后经过外部运行时验证、执行、返回结果,再根据结果递归地选择下一步。
这两者的差异,可以用“一次性外包”和“持续雇佣”来类比。一次性外包是:你把整份需求说明书丢给外包,等对方交付完整程序,中间出了偏差你只能返工。持续雇佣是:你把任务拆成一个个小任务包,每个包做完立刻验收,验收过了再发下一个包,任何一个包出了问题,只影响当前这个包,不会把整条流水线推翻。
在传统 function calling 的 Agent 里,工具调用也只是一次性的“快照式”交互:模型说调用某 API,拿到结果,就完了。而在递归编程里,一次调用可以被封装成可重入的子程序,子程序内部还能再调用别的子程序,还能维护局部状态、返回中间值、检查前置条件。这种结构上的递归性,才是 RLM 区别于普通“工具调用 Agent”的地方。简单任务看不出差别,一旦遇到“同一个步骤要重复多轮但参数不同”的场景,递归结构的复用价值就非常明显。
2.3 为什么它有资格被称为“终极形态”
我不是特别喜欢“终极形态”这个词,因为 Agent 领域的演进没有终点。但如果非要在现有方案里挑一个最有潜力的,递归编程确实具备一些很难被绕过的优势:
- 图灵完备性:递归 + 条件跳转 + 可变状态,理论上足够表达任意可计算流程;
- 状态隔离:每个子程序的失败都被限制在局部,不会污染全局上下文;
- 可测试性:你可以针对每个递归步骤单独写单元测试,而不是每次都端到端跑完整条链路;
- 可组合性:一个经过验证的子任务函数可以在别的新任务里复用,就像代码库里抽公共方法。
这些优势恰恰是 ReAct 最欠缺的。ReAct 推理过程更像“散文”,你很难给它写单元测试;而递归编程更像“程序”,天然就带着工程化的度量衡。我见过不少团队把 ReAct 跑通后就急着上线,等到用户反馈“某个长任务经常答错”,他们才发现无法定位问题出在哪一步,只能整条链路重跑,这就是结构过于松散的代价。
不过也要说清楚:递归编程不是魔法,它没有提升模型本身的推理能力,只是把推理过程组织得更工程化。如果基础模型的单步推理能力很弱,再好的运行时也救不回来。这一点我在后面第 5 节还会展开。
3. 最小可运行样例:把 ReAct 改写成 RLM
3.1 最简运行时怎么设计
如果你也想在自己项目里试 RLM,不需要一开始就搞复杂的框架。核心只需要四样东西:
- 程序计数器:记录当前执行到哪个指令;
- 状态字典:保存所有变量,比如搜索结果、临时结论、待办列表;
- 指令表:一组有限的指令类型,每条指令由模型根据当前状态生成参数;
- 解释器:负责执行指令、更新状态、把控制权交回模型。
我习惯把解释器做得尽量薄,它只负责确定性的部分,比如“写变量”“读文件”“调用搜索 API”。真正需要语义理解的决策,比如“下一步该搜什么”“这个结果能不能作为答案”,全部交给模型来生成。这样可以避免模型在低层逻辑上犯低级错误,也方便出问题时人工介入。如果解释器本身逻辑太重,反而会引入新的不确定性和维护成本。
3.2 指令集:read / write / call / loop / assert
我最早设计指令集时,只保留了几条最小的指令,因为指令越少,模型越容易学会。参考设计如下:
| 指令 | 语义 | 模型需要生成的参数 | 运行时的职责 |
|---|---|---|---|
| read | 读取状态或外部数据 | 变量名或数据源 | 返回内容并继续 |
| write | 把中间结果写入状态 | 变量名、内容 | 更新状态字典 |
| call | 调用一个递归子任务 | 子任务名称、参数 | 压栈运行子程序,结束后返回 |
| loop | 对一组数据重复执行 | 循环变量、子指令 | 调度执行循环体 |
| assert | 校验前提条件 | 条件表达式 | 失败则中断或记录重试 |
这些指令的粒度是经过取舍的。比如 call 本质上对应 ReAct 里的“下一步行动”,但它多了“子任务要有明确参数和返回点”的约束;loop 则让模型不用把所有重复步骤都展开写进上下文,而是用控制结构表达。assert 是我后来才加的,因为最开始递归执行经常卡在某一步的错误数据上,加一个前置条件校验后,问题定位容易得多。
3.3 一个改写例子:多步资料比对任务
我拿一个常见的业务场景做演示:给一个主题,要求搜索三篇不同来源的资料,把三者的观点提炼出来并给出一个综合结论。这种任务如果让用户手动操作,心里其实是有步骤的:先搜资料 A,再搜资料 B,再搜资料 C,分别记录要点,最后对比。
ReAct 版本的做法是把这些步骤全部摊平在回合里:模型每轮输出 Thought,然后调用 search,拿到结果后继续下一轮。写到第四五轮时,模型往往会忘记第一轮得到的要点,或者把三条资料的结论搞混。RLM 版本的做法是:模型先通过 write 指令建立三个空变量,比如note_A、note_B、note_C;然后循环执行read -> search -> write三次;最后再启动一个新的子任务“综合对比”,它读取三个变量,输出最终答案。整个过程中,模型每个时刻只需要处理很小一块上下文。
核心调度伪代码大概是这样的:
def run_program(state, instructions): pc = 0 while pc < len(instructions): instr = instructions[pc] if instr["op"] == "read": value = read_value(state, instr["target"]) state["last_result"] = value pc += 1 elif instr["op"] == "write": state[instr["var"]] = eval_value(state, instr["content"]) pc += 1 elif instr["op"] == "call": result = execute_subtask(instr["name"], instr["args"], state) state[instr["result_var"]] = result pc += 1 elif instr["op"] == "loop": pc = loop_step(state, instr, pc) elif instr["op"] == "assert": if not check_condition(state, instr["condition"]): state["error"] = instr["error_msg"] pc = instr["restart_pc"] if "restart_pc" in instr else pc + 1 else: pc += 1模型实际生成的是上面这些指令的 JSON 序列,解释器负责执行。每一轮执行完之后,模型只收到last_result和当前状态摘要,而不是整条历史。这就是 RLM 和 ReAct 最本质的差别:模型不再是一个需要“读完整个剧本”的演员,而是一个只盯着当前场景做判断的决策点。
3.4 为什么跑起来更稳:错误隔离与断点续跑
这个设计有三个让我印象很深的优点。
第一,错误被局部化了。如果第三步搜索失败,模型只需要重试第三步,不需要重新执行前两步。在 ReAct 里,一个工具超时会打断整个链路的思维连贯性,模型后面几步经常“忘了自己在干什么”。而在 RLM 里,失败范围被限制在某个指令或子程序内,等它重试成功,其他部分的状态依然完好。
第二,断点续跑变得自然。因为状态都存在外部字典里,中途崩溃了,把所有变量存下来,下次可以直接从当前位置继续。ReAct 一旦上下文超限或服务中断,整条轨迹往往只能推倒重来。这点在长时间任务里特别重要,我甚至有几次是“今天跑一半,明天接着跑”,只要把状态快照保存好,完全不受影响。
第三,可观测性大幅提升。我可以随时打印出“当前程序计数、当前变量表、当前子任务栈”,一眼就能看到模型判断的失稳点。相比之下,ReAct 的轨迹是一大段文本,排查问题时你得通读全文找逻辑漏洞,费时费力。
当然,代价是工程复杂度上去了,运行时、指令协议、状态存储都要额外开发。但它带来的稳定性和可维护性,对我来说非常值得。
4. 实测对比:ReAct、Plan-then-Execute、RLM 的真实差距
4.1 评测任务与指标怎么定
为了验证 RLM 到底是不是嘴上功夫,我自建了一个一百个任务的内部测试集,覆盖三类情况:多步问答、代码生成加自动化测试、多工具组合调度。没有用公开 benchmark,因为公开集更偏向单轮能力,测不出 Agent 长链路的问题,而我真正关心的是 Agent 在长链路上会不会翻车。
指标我选了四个:任务完成率、平均调用步数、平均 token 开销、错误传播率。错误传播率定义为“某一步报错之后,最终任务仍然失败的比例”。这个指标最能反映结构是否健壮,因为很多 Agent 方案单看完成率还行,但一出错就是连锁反应,最终整个任务彻底失败。
4.2 三套方案的结果对比
测试的第三套方案里,我保留了 ReAct 的基线,也加了“先规划后执行”的 Plan-then-Execute 方案,再就是我自己的最小 RLM 实现。简化后的结果如下:
| 方案 | 任务完成率 | 平均调用步数 | 相对 token 开销 | 错误传播率 |
|---|---|---|---|---|
| ReAct | 61% | 9.4 步 | 1.0x(基线) | 47% |
| Plan-then-Execute | 74% | 7.1 步 | 0.82x | 35% |
| 最小 RLM | 82% | 5.6 步 | 0.64x | 19% |
样本量不大,数据更多是趋势参考。但从我跑的这几个任务集来看,RLM 的完成率比 ReAct 高了二十多个百分点,token 开销反而更低。这不是因为模型变聪明了,而是因为它不再反复重读历史,也不容易在错误前提上跑偏。
4.3 结果背后:为什么递归方案错误率更低
我仔细回放过不少失败案例,发现最核心的原因是“上下文视窗变小了”。在 ReAct 里,模型第五步要判断时,目光扫过的是前四步的全部文字,很多冗余信息会干扰判断。在 RLM 里,模型第五步看到的是“变量表里有哪些值,当前子任务是什么,最近一个返回结果是什么”,信息密度高得多。
另一个原因是重试粒度。ReAct 出错后如果让它重试,默认是重跑整个流程;RLM 出错后可以只重跑出错的那个子程序。前者是重新走完全程,后者是只修一段代码再接着跑,效率自然不一样。尤其是代码生成加自动化测试这类任务,RLM 可以做到“只改出错的函数,其余函数不动”,这对最终结果的稳定性影响非常大。
错误传播率从 47% 降到 19%,也印证了状态隔离的价值。我在 RLM 里给每个子任务都设了独立的变量命名空间,子任务崩了不会影响全局变量,这种隔离在 ReAct 里几乎做不到。
4.4 代价:延迟、token、调试难度
RLM 不是完全没有代价。我实测的平均延迟比 ReAct 高 30% 左右,原因是模型每生成一条指令,解释器都要做一轮校验和状态更新,多了一层“来回”的开销。对于响应时间敏感的场景,这个延迟需要注意。
调试难度也上升了。RLM 出问题的时候,你没有一段完整的故事可读,而是要检查“指令序列是否合理、状态字典值是否符合预期、子任务返回是否冲突”。我一开始很不适应,经常对着变量表发呆。后来习惯了在运行时里加详细日志,调试效率才追上来。这也是为什么我在后面 6.4 节会专门说调试技巧。
token 开销下降不代表总成本一定下降,因为延迟和额外工程成本也要算进去。如果只是简单的单轮任务,RLM 的收益微乎其微。这正是下一节要讨论的边界。
5. 核心争议与风险:递归编程的边界
5.1 自我修改:模型能不能改自己的 prompt?
人们在聊“终极形态”时最爱问一个问题:既然可以递归编程,模型是不是可以改写自己的 prompt,从而实现自我进化?我的答案是:技术上能,工程上不建议放开。
我在一个内部原型里试过让模型修改自己的指令表。一开始效果很惊艳,模型会主动优化某个子任务的流程描述;但很快问题来了,模型会“好心办坏事”:它把一个本来稳的 assert 条件改宽松了,结果错误率整体上升;后来甚至出现修改行为反复震荡的情况,这轮改过去、下轮又改回来。原因很简单,模型并没有一个全局目标函数来评判修改的好坏,它只是基于局部观察做局部改动,缺少整体视野。
所以我现在坚持一个原则:允许模型修改数据、允许修改子任务的参数,但禁止修改指令表的定义和解释器代码。要“进化”,就通过外部评估和人工审核来做,而不是放任模型直接改自己的底层逻辑。想自我改进的念头很好,但工程上必须把“变异权”和“执行权”分开。
5.2 安全沙箱:递归执行失控怎么办
递归结构天然会放大执行次数,如果模型不小心写了一个无限递归,或者某个子程序不断调用自己,资源很快会被耗尽。我在设计运行时的时候,强制加了四类限制:
- 指令执行次数上限:默认 200 条,超过直接中断;
- 循环体执行次数上限:防止模型写出死循环;
- 子任务栈深度上限:默认 10 层,避免无限递归;
- 工具调用配额:搜索、外部 API、代码执行等都有次数限制。
这些限制不是可选的。没有它们,递归编程在真实环境里就是一颗定时炸弹,尤其当模型在“自我纠错”的时候,它可能会反复重试同一个故障点,造成资源浪费。我遇到过最夸张的一次,一个简单的 bug 让子程序重跑了 40 次,如果不是配额定得低,整个任务的预算会被直接打满。
运行时的代码也要做权限隔离:默认无网络、无文件写入,只有显式授权的指令才能访问外部资源。这是 Agent 和普通程序最大的不同,普通程序的资源边界是编译期定好的,Agent 的资源边界是模型每次现场决定的,必须靠沙箱兜底。
5.3 不是万能药:基础模型的推理上限仍然存在
最后必须泼一盆冷水。RLM 能提升 Agent 的鲁棒性和可维护性,但不会提升模型本身的智能上限。如果模型连“如果 A 大于 B 且 B 大于 C,那么 A 大于 C”这种推理都不稳,那么无论你把任务分解得多细,它还是在每一步的单点推理上出错。
我测试过一个较弱的基础模型,把它放到 RLM 框架里,完成率只比 ReAct 高了 7 个点,远不如用强模型时提升明显。这说明递归编程解决的是“结构问题”,不是“能力问题”。选型时不要把它当成灵丹妙药,该换更强模型的时候,还是得换。
也不能把 RLM 当成不训练的理由。很多端到端任务里,模型对领域知识的掌握密度才是决定性因素,这个只能靠预训练和微调解决。递归编程能让你把已有能力组织得更高效,但不能凭空造出能力。
6. 踩坑速查与实战建议
6.1 常见问题速查表
| 现象 | 可能原因 | 推荐处理 |
|---|---|---|
| 模型迟迟不调子任务 | 指令集描述不够明确 | 在示例里补充“何时必须使用 call 指令” |
| 子任务返回结果被模型误用 | 没有区分变量作用域 | 给每个子任务独立的命名空间 |
| 循环中的某一步失败导致整体崩溃 | 缺少 assert 校验 | 在循环体开头加断言,失败则跳过当前迭代 |
| 同一逻辑被重复执行多次 | 缺少公共子程序抽取 | 把高频操作封装成 call 指令 |
| 模型乱改指令表 | 自我修改权限过宽 | 强制只允许改数据层,禁止改解释器 |
这套速查表是我根据实际 DEBUG 经历整理的。最开始我遇到“模型长期不调用子任务”的问题,排查了很久,后来发现是示例里 call 指令出现的频率太低,模型根本不认为这步操作是常规动作。补了示例之后,问题就消失了。指令集和示例的一致性,比我想象中更重要。
6.2 哪些场景千万别用递归编程
我列了三类不建议用 RLM 的场景。
第一是简单单轮问答。问一个常识问题,没必要先规划一套子任务,直接让模型回答最快。这时候上 RLM 等于给自己找麻烦,指令调度的开销完全不值得。
第二是极低延迟场景。RLM 每多一层运行时解释,就多一次网络和调度开销,如果你要在几百毫秒内返回,还是直接用精简 prompt 更合适。递归结构适合“任务复杂但可等待”的场景,不适合在线实时交互。
第三是团队没有调试和可观测性基础的情况。没有日志、没有链路追踪,你很难定位递归里哪一层出了问题,最后会连“是模型错了还是代码错了”都分不清。递归编程的前提是团队具备比较成熟的工程化手段,否则复杂度会反噬。
6.3 从 ReAct 到 RLM 的渐进迁移路线
不要一上来就重构整个系统。我自己走过的路线是:
- 先保留现有 ReAct 链路,在旁边加一个“运行时层”,只负责记录变量和子任务,不改变决策逻辑;
- 把高频的 ReAct 循环封装成两三个子程序,用 call 指令调用,观察收益;
- 再逐步引入 loop、assert 等控制结构,把长链路拆碎;
- 最后才考虑关闭“全历史上下文”,彻底转向状态外置。
每一步都拿真实任务验证效果,不要凭感觉批量迁移。实测下来,渐进式改造的失败率远比一次性重写低。而且这个过程会逼你把任务边界想得更清楚,比如哪些步骤是真正需要模型的,哪些步骤其实用确定性代码就能完成。这种重新审视本身,就比单纯换技术方案更有价值。
6.4 最后分享:调试小技巧与我对“终极形态”的态度
最后分享一个我实际调试 RLM 时的核心技巧:永远先打印解释器日志,再去看模型输出。我会在每条指令执行前后输出当前程序计数器、变量表快照和子任务栈,一旦结果不对,直接定位是哪一步状态出了问题,再回头看那条指令是不是模型判断失误。如果先看模型生成的文本,很容易被它的“合理叙述”带偏,因为大模型特别擅长把错误解释得有理有据。日志里的变量值不会骗人,变量表里没有预期结果,问题一定出在上一条指令上。
至于“大模型 Agent 的终极形态是不是递归编程”,我的态度是:它至少是当前工程化程度最高的方向之一,但绝不是终点。我不太关心它是不是“终极”,我更看重它能不能让我在做一百个任务时少翻几次车。从我的实践看,递归编程确实做到了这一点。后面如果再往前探索,我猜会是“自适应搜索”和“在线微调”的结合:让模型在运行时里不仅改数据,还能带着证据更新自己的策略。但那是另一个话题了,先把递归这层地基打好,再谈进化。