手写ReAct能跑,为什么还要换框架
上一篇把Agent拆成了大脑、工具、记忆、规划四个部件,比特严选的十一类任务也按循环圈数分了四级。篇末那张地图把后面五章的去向都标好了,从这一篇开始进Ragent真实源码。
第一个问题不是循环怎么写,而是循环由谁来写。
序章拿AirPods Pro左耳无声想退那个三星任务演过一遍ReAct循环:查订单拿签收时间,查当前日期算窗口,窗口还在就发起退款。三圈下来,每一圈都在重复同一件事:把历史轨迹拼成提示词、调模型、解析模型要调哪个工具、执行工具、把结果塞回轨迹。写成代码,一个while加几个方法调用,两三百行跑得通。
1.0时代Ragent的RAG链路几乎全是手搓的,那时候选择手写是合理的:大模型对多数人还是黑盒,很多同学连模型调用OpenAI的接口出入参都不清楚,手写是为了搞明白底层每一步在干什么,哪个环节卡了能按着代码查到根。到了2.0加Agent,AI已经普及了不少,底层不再是谜,面试的时候讲框架也比讲手搓循环更容易和面试官产生共鸣。更关键理由是:跑通到上生产之间差了一整层东西,而这层东西里最值钱的那个,中间件扩展点,手写的话设计代码需要非常考究。比如短中长期记忆以及AI调用链路追踪等,如果完全从零到一手搓代码复杂性太高,所以选择站在巨人的肩膀上。
这一篇先摊开手写循环的全貌,再讲框架选型是怎么走的,最后看它是怎么接到已有链路上的。
如何手写一个ReAct循环?
1. 选工具这件事,协议已经管掉了
工具怎么告诉模型,不用自己设计,协议规定死了:一个工具就是一段JSON Schema,随messages一起发过去。
下面是仓库里query_order的工具声明(只读查询,一次调用拿结果,序章消息序列里的queryOrder就是它)。description原文有一百多字,这里截到第一句:
{
"name": "query_order",
"description": "查询当前登录用户的订单。传订单号返回单笔详情,含状态、下单支付发货签收四个时间、……",
"inputSchema": {
"type": "object",
"properties": {
"orderNo": {
"type": "string",
"description": "订单号,如 88231;不填则返回最近若干笔订单摘要"
},
"limit": {
"type": "integer",
"description": "不填订单号时返回几笔,默认 5,最多 20",
"default": 5
}
},
"required": []
}
}
模型读完这段,回复里就不再是“我需要先查一下这单的签收时间”这样一句自然语言,而是结构化字段:
"tool_calls": [
{
"id": "call_0_8f3c",
"type": "function",
"function": {
"name": "query_order",
"arguments": "{\"orderNo\":\"88231\"}"
}
}
]
选哪个工具、参数怎么从用户那句话里抠出来、缺省值取什么,全在模型那边完成。代码这侧只负责把Schema发出去、把tool_calls收回来,arguments反序列化就能直接送去执行。
所以循环本身很薄:拼消息 → 调模型 → 看有没有tool_calls → 有就执行、把结果塞回消息列表 → 回到开头。一个方法体装得下。
2. 一个能跑的循环长什么样
public String run(String question) {
// messages 就是这轮对话的全部记忆,每一步都往后追加
List<ChatMessage> messages = new ArrayList<>();
messages.add(ChatMessage.system(sysPrompt));
messages.add(ChatMessage.user(question));
// 所有可用工具的 JSON Schema 列表
List<Tool> tools = toolRegistry.listAll();
for (int i = 0; i < maxIters; i++) {
ChatResponse response = llm.chat(messages, tools);
// 模型不再要工具,说明它已经能回答了,循环结束
if (response.getToolCalls().isEmpty()) {
return response.getContent();
}
// 先记下模型这一步的决定,再把工具结果追加进去
messages.add(ChatMessage.assistant(response.getContent()));
appendToolResults(response.getToolCalls(), messages);
}
return "已达到最大轮数,任务未完成";
}
maxIters是必需品,不是保险丝。模型完全可能在两个工具之间来回打转:查了订单去查日期,拿到日期回头再查订单,自己判断不出已经够了。没有上限,一次请求能一直烧Token到HTTP超时。
工具分发单独拆出来:
private void appendToolResults(List<ToolCall> toolCalls, List<ChatMessage> messages) {
for (ToolCall call : toolCalls) {
// 按名字找到这个工具真正的执行逻辑
ToolExecutor executor = toolRegistry.find(call.getName());
if (executor == null) {
messages.add(toolMessage(call, "工具不存在: " + call.getName()));
continue;
}
try {
// 身份从登录态传进去,不让模型自己编
String result = executor.execute(call.getArguments(), currentUserId());
messages.add(toolMessage(call, result));
} catch (Exception ex) {
// 工具异常也要回给模型让它换条路走,不能直接抛出去把循环打断
messages.add(toolMessage(call, "工具执行失败: " + ex.getMessage()));
}
}
}
注意两点:
-
catch里那行决定循环能不能自愈:异常往外抛,整轮对话结束,用户拿到一个HTTP 500;异常文本回塞给模型,它有机会换个参数重试或换条路走。模型猜错工具名、少传必填参数,走的都是这条路。
-
currentUserId()同理不可省:发起售后这类写操作要落一条带申请人的业务记录,身份不能让模型从对话里猜,必须从登录态透传下去。
到这里循环是完整的。挂上一个查商品资料的工具,再挂几个只读查询,问“AirPods Pro左耳没声怎么办”,它真的能转起来。
3. 把它接到一个真实请求上
用户说“上周买的AirPods Pro左耳没声了,想退”。先查故障手册确认自助修复无效,再查订单拿签收时间,查当前日期算是否还在七天窗口内,最后发起退款。四圈。
本机demo跑得挺好。放进真实Web服务,下面这些会一件件撞上来。
-
用户在盯白屏:四次模型调用加四次工具执行,二十几秒起步。循环的返回值得从
String换成事件流,而且不止一种事件:文本增量、工具即将调用、工具返回、最终答复、异常。循环里每个位置都要知道自己该发哪一种——在最外层包个SseEmitter不解决问题。 -
第四圈不能直接跑:发起退款是写操作,得先让用户确认,而确认是一次新的HTTP请求,可能三十秒后回来。循环必须停在第四圈工具执行之前,把消息列表、已执行的工具结果、中间推理整块存下,确认后从该点续跑。所以持久化不是跑完存一次,是从中间恢复——哪个点可以安全中断,只有循环自己知道。
-
用户按了停止:此刻可能在等确认,可能在第二圈调模型,也可能工具请求已发出正等MCP返回。断开下游订阅只是不再推送,循环线程还在跑;若它正停在
apply_after_sale,售后单提没提就说不清了。停止得两步:先置中断标记让循环跑到安全点自行收尾存盘,超时才硬断。安全点仍在循环内部。 -
用户又点了一次发送:第二个循环拿同一会话起来,两份消息列表交错写入,上下文就乱了。锁可以加在外面,但随之而来的是业务问题:排队、报错还是顶掉前一次,那张待确认的卡怎么办。
-
模型选错工具怎么查:第二圈它可能跳过查订单直接想退。只看最终输出定位不到是哪一圈走偏,得记下每圈的输入输出、选了哪个工具、参数、耗时。这些数据同样产生在循环内部。
没有一件能写在while外面。它们要的是同一样东西:循环在特定位置让出控制权——发事件、查中断、存盘、记span。
所以循环本体几百行,外面再套一层基础设施是错的心理。真实形态是那个while被从内部撑开,业务判断和基础设施代码在同一个方法体里缠着,加到第三第四个能力时,每加一个都得把整个循环重读一遍。
框架替你解决的正是这个:不是替你写那个while,而是把这几个让出控制权的位置定成接口,让新能力挂到循环外面去。