单次 Function Calling 撑不住,我上了 ReAct
去年 11 月我们给内部运维做了个助手,能查监控、查日志、查工单、执行标准操作。第一版用的是大模型的 Function Calling:把工具列表丢给模型,模型返回一个函数调用,我们执行完把结果拼进上下文,再问一次,直到它不再调工具。
这个"再问一次"的循环我当时写得很随意,就是个 while 加个计数器。上线后发现它对简单问题没问题,但遇到需要两三步推理的就崩。典型失败案例:
用户:昨天下午订单服务响应时间变长,帮我看看是什么原因
模型第 1 轮:调用 query_metrics(service="order", metric="p99_latency")
→ 返回一堆数字
模型第 2 轮:直接输出"根据监控数据,订单服务 P99 在昨天 15:00 左右
从 200ms 上升到 1.2s,可能是流量增长导致"
(它根本没去查流量、没查发布记录、没查下游依赖)
模型跳过了推理,直接给结论。原因是我们的 Prompt 里只说了"可以调用这些工具",没有要求它在调用前先说明思考过程。
ReAct 的核心:把思考显式化
ReAct(Reasoning + Acting)的做法是强制模型按固定格式输出,每一步必须显式写出 Thought:
Thought: 用户说订单服务响应时间变长。我需要先确认是真的变长了,
以及变长的时间点。先查 P99 延迟。
Action: query_metrics
Action Input: {"service": "order", "metric": "p99_latency", "range": "24h"}
系统执行后返回:
Observation: 2025-01-26 14:20 p99=198ms
2025-01-26 15:00 p99=412ms
2025-01-26 15:05 p99=1180ms
...(截断)
模型继续:
Thought: P99 在 15:00 到 15:05 之间突然从 412ms 跳到 1180ms,5 分钟内
涨了近 3 倍,不是渐进式的流量增长,更像是某个变更或者依赖故障。
我应该查这个时间段有没有发布,以及下游依赖的状态。
Action: query_deploy_records
Action Input: {"service": "order", "start": "2025-01-26 14:50", "end": "2025-01-26 15:10"}
这个改动带来的差别是质变。我拿 60 条真实运维问题做了对比:
| 方案 | 答案可用率 | 平均工具调用次数 | 平均 token 消耗 |
|---|---|---|---|
| 朴素 Function Calling | 38.3% | 1.4 | 1,840 |
| ReAct(显式 Thought) | 71.7% | 3.2 | 6,720 |
可用率从 38% 涨到 72%,代价是 token 消耗涨了 3.7 倍。这个代价我们接受了,因为运维场景对正确性远比对成本敏感。
Java 侧的实现
工具的定义与注册
public interface Tool {
String name();
String description();
List<Param> params();
String invoke(Map<String, Object> args) throws Exception;
}
@Component
public class MetricsTool implements Tool {
private final PrometheusClient prom;
@Override public String name() { return "query_metrics"; }
@Override public String description() {
return "查询指定服务的监控指标。支持 p99_latency、qps、error_rate、cpu。"
+ "时间范围用 1h/6h/24h/7d 或者 ISO 时间区间,默认 1h。";
}
@Override public List<Param> params() {
return List.of(
Param.required("service", "服务名,如 order、payment"),
Param.required("metric", "指标名"),
Param.optional("range", "时间范围,默认 1h"));
}
@Override public String invoke(Map<String, Object> args) {
try {
return prom.query(args);
} catch (Exception e) {
// 关键:工具失败不要把异常抛给框架,要返回可读的错误让模型自己调整
return "查询失败: " + e.getMessage()
+ "。请检查服务名是否正确,或换一个时间范围重试。";
}
}
}
最后这点很重要。工具执行失败时,要把失败原因当作 Observation 返回给模型,而不是中断循环。模型看到错误信息后往往会换个参数重试,这比我们自己在代码里写重试逻辑聪明得多。我们统计过,约 23% 的工具调用第一次是失败的(参数格式不对、服务名拼错),其中 78% 靠模型自己纠正了。
系统提示词
String SYSTEM = """
你是一个运维助手。通过循环执行:Thought → Action → Observation 来解决问题。
严格按以下格式输出,每次只输出一个 Thought 和一组 Action:
Thought: 你当前的推理。必须包含:已知什么、还缺什么、为什么选这个工具。
Action: 工具名称(必须是 <tools> 列表中的一个)
Action Input: JSON 格式的参数
当你已经有足够信息回答时,输出:
Thought: 我已经收集到足够信息,可以回答了
Action: finish
Action Input: {"answer": "你的回答"}
规则:
1. 每次只调用一个工具,不要一次调多个
2. 不要编造 Observation 里没有的数据
3. 如果连续两次调用同一个工具且参数相同,说明思路卡住了,换一个角度
4. 最多 %d 步,超出后必须给出当前已知的信息并说明不确定性
<tools>
%s
</tools>
""".formatted(MAX_STEPS, toolRegistry.describeAll());
几个设计点:
- "每次只调一个工具"。试过允许并行调用多个,模型的推理质量明显下降,它会一次性把所有工具都调一遍而不思考。串行虽然慢,但准确率高。
- 规则 3 是防死循环的软约束。光靠提示词不够,代码里还得有硬检测,后面说。
- 步数上限写进提示词,让模型知道自己的预算,实测比不告诉它效果更好。
循环主体
@Service
public class ReActLoop {
private static final int MAX_STEPS = 8;
private static final long TIME_BUDGET_MS = 90_000;
private static final int TOKEN_BUDGET = 32_000;
public AgentResult run(String question) {
List<Step> steps = new ArrayList<>();
long deadline = System.currentTimeMillis() + TIME_BUDGET_MS;
int usedTokens = 0;
for (int i = 1; i <= MAX_STEPS; i++) {
// 终止条件 1:时间预算
if (System.currentTimeMillis() > deadline) {
return AgentResult.timeout(steps, summarize(question, steps));
}
// 终止条件 2:token 预算
if (usedTokens > TOKEN_BUDGET) {
return AgentResult.tokenExceeded(steps, summarize(question, steps));
}
String prompt = buildPrompt(question, steps);
String output = llm.chat(SYSTEM, prompt, 0.0);
usedTokens += estimateTokens(prompt) + estimateTokens(output);
ParsedStep parsed = parse(output);
if (parsed == null) {
// 格式解析失败,把错误反馈给模型让它重来
steps.add(Step.parseError(output));
continue;
}
if ("finish".equals(parsed.action())) {
return AgentResult.done(parsed.answer(), steps, usedTokens);
}
// 终止条件 3:重复动作检测
if (isRepeating(steps, parsed)) {
return AgentResult.stuck(steps, summarize(question, steps));
}
String obs = toolRegistry.invoke(parsed.action(), parsed.args());
steps.add(Step.of(parsed.thought(), parsed.action(),
parsed.args(), truncate(obs, 1500)));
}
return AgentResult.maxStepsReached(steps, summarize(question, steps));
}
}
终止条件:这是生产可用性的关键
ReAct 最容易出问题的地方不是推理质量,是停不下来。我们设了五道闸:
| 终止条件 | 阈值 | 触发比例 | 兜底行为 |
|---|---|---|---|
| 模型主动 finish | — | 68.3% | 正常返回 |
| 最大步数 | 8 步 | 14.2% | 用已有 Observation 生成总结 |
| 时间预算 | 90 秒 | 6.8% | 同上 |
| Token 预算 | 32,000 | 3.1% | 同上 |
| 重复动作检测 | 相同 action+args 连续 2 次 | 7.6% | 提示模型换思路,再重复则强制结束 |
重复动作检测的实现
这个看着简单,实现时有细节。完全相同的参数才算重复,参数不同但语义一样(比如时间范围 1h 和 60m)不算:
boolean isRepeating(List<Step> steps, ParsedStep next) {
if (steps.size() < 2) return false;
Step last = steps.getLast();
String sig = next.action() + "|" + normalizeArgs(next.args());
String lastSig = last.action() + "|" + normalizeArgs(last.args());
if (sig.equals(lastSig)) {
log.warn("ReAct loop stuck on {}", sig);
return true;
}
return false;
}
// 归一化:排序 key、统一时间单位
String normalizeArgs(Map<String, Object> args) {
Map<String, String> m = new TreeMap<>();
args.forEach((k, v) -> m.put(k, TimeUnit.isTimeLike(k) ? toMinutes(v) : v.toString()));
return m.toString();
}
触发强制结束时,我们不是简单返回错误,而是拿已经收集到的所有 Observation 再问一次模型,让它基于已知信息给出带不确定性说明的回答:
String summarize(String question, List<Step> steps) {
String known = steps.stream()
.filter(s -> s.observation() != null)
.map(s -> STR."- \{s.action()}: \{s.observation()}")
.collect(Collectors.joining("\n"));
return llm.chat("""
以下是针对问题「%s」已经收集到的信息,但推理未能完成。
请基于这些信息给出:1) 已能确定的结论 2) 还需要什么才能确定
已收集信息:
%s
""".formatted(question, known), 0.0);
}
这个降级路径上线后,用户对"没跑完"的任务的满意度明显提升。以前是直接报错,现在是给部分结论加后续建议。
输出解析:别指望模型永远守格式
即使提示词写得很死,模型还是会有各种不规范的输出现象。我们统计了 2000 次调用,格式问题占 8.7%:
- 3.1%:在 Thought 前面加了"好的,我来帮你分析"这类寒暄
- 2.4%:Action Input 的 JSON 里有注释或者尾逗号
- 1.9%:一次输出了两组 Action(想并行调用)
- 1.3%:把 Action 写成了
<action>query_metrics</action>的 XML 风格
public ParsedStep parse(String output) {
// 1. 用宽松正则提取,容忍前后的废话
Matcher actionM = Pattern.compile("Action:\\s*(\\w+)").matcher(output);
Matcher inputM = Pattern.compile("Action Input:\\s*(\\{.*?\\})\\s*$",
Pattern.DOTALL).matcher(output);
if (!actionM.find()) return null;
String action = actionM.group(1);
Map<String, Object> args;
if (inputM.find()) {
try {
args = JSON.parseObject(inputM.group(1));
} catch (Exception e) {
args = repairJson(inputM.group(1)); // 修尾逗号、补引号
}
} else {
args = Map.of();
}
return new ParsedStep(extractThought(output), action, args);
}
repairJson 里处理了常见的几种畸形:尾逗号、单引号、缺少右括号。实在修不了就不修,把原始输出作为错误 Observation 反馈回去。实测这一层能救回 2.4% 里的绝大部分。
上下文膨胀与历史压缩
ReAct 每走一步,上下文就多一段 Thought + Action + Observation。8 步之后很容易到 2 万 token,其中大部分是过时的中间步骤。
我们的处理:只保留最近 3 步的完整内容,更早的压缩成一行摘要。
String buildPrompt(String question, List<Step> steps) {
StringBuilder sb = new StringBuilder("Question: ").append(question).append("\n\n");
int cut = Math.max(0, steps.size() - 3);
for (int i = 0; i < steps.size(); i++) {
Step s = steps.get(i);
if (i < cut) {
sb.append(STR."[步骤 \{i+1}] 调用 \{s.action()},得到:\{oneLine(s.observation())}\n");
} else {
sb.append(STR."""
Thought: \{s.thought()}
Action: \{s.action()}
Action Input: \{s.args()}
Observation: \{s.observation()}
""");
}
}
return sb.toString();
}
static String oneLine(String obs) {
String s = obs.replaceAll("\\s+", " ");
return s.length() > 120 ? s.substring(0, 120) + "…" : s;
}
这个改动把 8 步任务的平均 token 从 23,400 降到 11,800,成本减半,可用率只掉了 1.2 个点(从 71.7% 到 70.5%)。
工具集的设计:数量比能力更重要
我们第一版注册了 23 个工具,想着"能力越全越好"。结果发现两个问题:模型选错工具的概率显著上升,以及系统提示词长到 4000 多 token,光是描述工具就吃掉一轮调用预算的 12%。
做了减法,砍到 9 个:合并了功能重叠的,删掉了三个月内零调用的。砍完之后:
| 工具数 | 工具选择错误率 | 系统提示词长度 | 答案可用率 |
|---|---|---|---|
| 23 | 17.4% | 4,180 token | 64.2% |
| 14 | 9.1% | 2,640 token | 69.8% |
| 9 | 4.3% | 1,720 token | 71.7% |
砍工具的标准是看三个月的调用日志。有 6 个工具从来没被调用过,其中 4 个是因为描述写得不清楚,模型不知道它存在;另外 2 个是功能被别的工具覆盖了。
工具描述怎么写
描述的质量直接影响模型的选择。我总结的三条:
- 说清楚"什么时候该用它",而不只是"它做什么"。我们把
query_log的描述从"查询日志"改成"查询服务的错误日志和异常堆栈,适用于定位报错原因;如果要看延迟和流量请用 query_metrics",选择错误率立刻降了一半。 - 把参数的合法取值写进描述。
metric参数支持四个值,就在描述里列出来,而不是让模型猜。 - 说明返回值的规模。加一句"默认返回最近 100 条,可用 limit 参数调整,最大 1000",模型就不会盲目地要一万条数据然后把上下文撑爆。
// 改进后的描述
return """
查询指定服务的监控时序数据,用于分析延迟、流量、错误率的变化趋势。
支持的 metric:p99_latency、qps、error_rate、cpu_usage。
时间范围支持相对值(1h/6h/24h/7d,默认 1h)或 ISO 时间区间。
返回值为采样点列表,默认每 5 分钟一个点,最多 288 个点。
如果需要查看具体的错误内容,请用 query_log 而不是本工具。
""";
一个完整的可观测 trace
ReAct 的每一步我们都落了日志,线上排查全靠它:
[step 1] thought: 用户反馈订单服务响应变慢,先确认 P99 延迟的真实变化
action : query_metrics(service=order, metric=p99_latency, range=24h)
observe: [截断] 15:00 412ms → 15:05 1180ms
elapsed: 1.2s tokens: 892
[step 2] thought: 5 分钟内涨 3 倍,不是流量渐进增长,查这个时间窗的发布记录
action : query_deploy_records(service=order, window=14:50~15:10)
observe: 15:02 发布 v2.8.1,变更内容:连接池参数调整
elapsed: 0.9s tokens: 1240
[step 3] thought: 定位到发布,查这次发布改了什么配置
action : query_deploy_diff(version=v2.8.1)
observe: hikari.maximumPoolSize: 50 → 200
elapsed: 0.7s tokens: 1103
[step 4] finish: 订单服务 P99 在 15:05 突增,原因是 15:02 发布的 v2.8.1
把 HikariCP 连接池从 50 调到 200,导致数据库连接数打满...
总耗时 6.1s,4 步,token 6820
这个 trace 我们按 traceId 存在 ES 里,可以检索"哪些问题用了超过 6 步"、"哪些工具错误率高"、"平均 token 消耗趋势"。它也是持续优化提示词的主要依据——每月拉出失败和超步数的 case,人工看一遍,改提示词或者补工具。
跟 MCP 的关系
2024 年 11 月 Anthropic 提出了 MCP(Model Context Protocol),要统一模型和工具之间的通信协议。这个东西刚出来不久,我们目前只是把工具层按 MCP 的概念做了抽象,还没有真正接入,因为生态还太早期,Java 侧的库都在起步阶段。但可以提前做的准备是:把工具的"描述、参数 schema、调用"这三件事从业务代码里剥离出来,将来换成 MCP 的传输层时,业务代码不用动。
// 现在的接口设计已经预留了 MCP 的形状
public record ToolSpec(String name, String description, JsonSchema inputSchema) {}
public interface ToolInvoker { String invoke(String name, JsonNode args); }
// 将来只需要实现 MCP 版本的 ToolInvoker
一点反思
ReAct 不是银弹。它的 token 消耗是普通 Function Calling 的 3 倍以上,延迟也更久(我们平均 6.2 秒完成一轮,最长 90 秒)。所以它只适合"值得多轮推理"的任务。
我们现在的路由规则是:先用一次便宜模型的意图判断,只有判定为"需要多步排查"的请求才走 ReAct 循环,其余走单次调用。这样 ReAct 的调用量只占总量的 17%,成本和延迟都可控。
另外一个体会:Agent 的工程复杂度,80% 在循环控制和错误处理,不在提示词。提示词我们改了 11 版,但真正让系统可用的,是那五道终止条件和工具失败的错误反馈机制。