多维度终止控制:让 Agent 运行更安全
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
上一篇咱们把 TinyAgent 从文本解析升级到了 Function Calling——工具调用的通信方式从自由文本协议换成了 API 结构化协议,parseAction() 和 stop 序列全部删掉,代码更简洁、格式 100% 稳定。
但如果你回头看目前的 ReActAgent.run(),会发现循环的退出条件非常粗暴——只有两个出口:
- 模型不再调工具(
hasToolCalls()返回false)——正常结束。 - 跑满
MAX_STEPS = 10圈——兜底超时,返回一句“我思考了太多步”。
这两个条件够吗?跑简单场景没问题,但稍微复杂一点的情况就会暴露问题:
- 用户说了句“你好”,大脑直接回复不需要调工具——没问题,走第一个出口。
- 退款流程 4 圈搞定——没问题。
- 但如果某个工具返回的结果不够用,大脑又找不到别的办法,它会用同样的参数反复调同一个工具——第 3 圈调、第 4 圈还在调,期待一个不会变的结果里刷出新内容。Token 在烧,任务没有推进,而
MAX_STEPS要等到第 10 圈才兜底。 - 又或者,一个跨品类对比的任务确实需要 8 圈工具调用,每圈的消息列表越来越长,Token 消耗加速增长——等你反应过来,上下文窗口已经快撑爆了。
这一篇,咱们系统地解决这个问题:Agent 循环什么时候停,不能只靠一个硬编码的最大步数。
本项目中具体代码已上传 GitHub TinyAgent,大家 Clone 项目后 ,将代码分支切换到 1.4.x,默认主分支是最新代码。运行前复制
.env.example为.env,把自己的 API Key 填进去,默认阿里云百炼平台;.env已加入.gitignore,切分支时不会丢。
先把问题分个类
在写代码之前,先想清楚 Agent 循环可能以哪些方式不正常地跑下去。归纳一下,无非三类:
1. 死循环:一直转不停
大脑每圈都决定调工具,但任务始终没有完结。比如用户问了一个超出工具能力范围的问题,大脑反复尝试不同的工具组合,希望能拼凑出答案——结果每次都不对,但它就是不肯说“我不知道”。
这是最危险的情况:Token 不断消耗,用户在等,服务器资源被占用,但没有任何产出。
2. 空转:在做无用功
大脑在调工具,但做的是重复劳动。最典型的场景:连续两圈用相同的参数调了同一个工具,拿到了一模一样的结果。
为什么大脑会做这种明显没意义的事?根本原因是工具返回的结果不足以让大脑推进到下一步,但它又不知道该换什么思路。大脑拿到一个结果,发现回答不了用户的问题,于是 本能地重试——就像一个人对着自动售货机反复按同一个按钮,期待掉出不同的东西。
打个比方:用户问“订单 88231 的物流到哪了”,大脑第 1 圈调 queryOrder 查到了运单号,第 2 圈调 queryLogistics,结果返回的是 {"status": "运输中"}——只有一个笼统的状态,没有具体位置、没有预计到达时间。大脑觉得光回复运输中太敷衍,但手头又没有其他工具能查到更详细的信息,于是第 3 圈用同样的运单号又调了一遍 queryLogistics,拿到了一模一样的结果。对于咱们比特严选的这些查询工具来说,同一个运单号在几秒钟内查出来的结果不会变——但大脑不理解这一点,它只会反复尝试。
空转不像死循环那么致命,但在生产环境里,一个空转 3 圈的请求比正常请求多花 50% 以上的 Token 成本——量大了很心疼。
3. 超预算:Token 用量失控
即使每圈都在做有意义的事,累计的 Token 消耗也可能超出预期。Agent 循环有一个让人容易忽略的特性:消息列表是累积增长的。每圈循环往消息列表里追加 assistant 消息和 tool 消息,下一圈调 API 时要把整个消息列表都发过去。
第 1 圈发 3 条消息(system + user + assistant),第 2 圈发 5 条,第 3 圈发 7 条……到第 8 圈就是 17 条消息。如果每个工具结果返回 500 字,8 圈下来光工具结果就 4000 字。再加上系统提示词和用户消息,输入 Token 可能已经到了大几千。
更麻烦的是,有些工具返回的数据量不可控——比如知识库搜索可能 返回一大段文档,物流轨迹可能包含十几条记录。一旦某个工具返回了超长结果,后续每圈的输入 Token 都会被这段长文本拖累。
下面这张图把三类问题放在一起对比,帮你建立一个直觉——它们的危险程度和表现形式完全不同,所以后面的防线也不是一招通吃:

四道防线
针对上面三类问题,咱们设计四道防线,从粗到细依次拦截:
| 防线 | 解决的问题 | 原理 | 实现复杂度 |
|---|---|---|---|
| 最大步数 | 死循环 | 硬编码上限,超过就强制停止 | 最简单 |
| 重复调用检测 | 空转 | 连续 N 圈调同一个工具 + 同样参数 → 先提醒后停止 | 简单 |
| Token 预算 | 超预算 | 估算累计 Token,超过阈值 → 强制停止 | 中等 |
| 无进展检测 | 空转 + 死循环 | 连续 N 圈大脑的 content 高度相似 → 强制停止 | 中等 |
下面逐个拆解,每道防线都给出原理、代码实现和实际效果。
第一道防线:最大步数(已有)
这是最粗暴也最可靠的兜底——不管发生什么,跑满 N 圈就停。
目前代码里已经有了:
private static final int MAX_STEPS = 10;
for (int step = 1; step <= MAX_STEPS; step++) {
// ... 循环体
}
return "抱歉,我思考了太多步仍未完成任务,请尝试换一种方式描述您的问题。";
这道防线的价值不在于精确——它拦不住空转,也拦不住超预算——而在于确定性。不管其他检测机制有没有 bug,最大步数保证 Agent 一定会停。
1. MAX_STEPS 设多少合适
10 是一个经验值。太小会截断正常的多步任务,太大又起不到保护作用。用比特严选的场景做个参考:
| 场景 | 典型步数 | 说明 |
|---|---|---|
| 查订单状态 | 1-2 步 | 查订单 → 回复 |
| 查物流轨 迹 | 2-3 步 | 查订单拿运单号 → 查物流 → 回复 |
| 退款流程 | 3-4 步 | 查订单 + 查政策 + 查时间 + 申请退款 |
| 跨品类对比 | 4-6 步 | 查多个商品 → 对比 → 推荐 |
| 复杂售后诊断 | 5-8 步 | 查订单 → 查保修 → 逐步诊断 → 推荐方案 |
最复杂的场景大约需要 8 步,MAX_STEPS = 10 留了 2 步的余量。如果你的业务场景工具链路更长,可以适当调大,但一般不建议超过 15——超过 15 步的任务,往往需要重新拆解需求或引入 Plan-and-Execute 模式(第 13 篇会讲)。
最大步数是兜底线,不是目标线。正常任务应该在
MAX_STEPS之前就通过其他条件正常退出。如果你发现大量请求都跑到了MAX_STEPS才停,说明要么MAX_STEPS设太小了,要么 Agent 的工具设计或提示词有问题。
2. 把它变成可配置的
硬编码 MAX_STEPS = 10 在 demo 里没问题,但生产环境里不同场景可能需要不同的上限。把它改成构造参数:
public class ReActAgent {
private static final int DEFAULT_MAX_STEPS = 10;
private final LlmClient llmClient;
private final ToolRegistry toolRegistry;
private final ObjectMapper objectMapper;
private final int maxSteps;
public ReActAgent(LlmClient llmClient, ToolRegistry toolRegistry) {
this(llmClient, toolRegistry, DEFAULT_MAX_STEPS);
}
public ReActAgent(LlmClient llmClient, ToolRegistry toolRegistry, int maxSteps) {
this.llmClient = llmClient;
this.toolRegistry = toolRegistry;
this.objectMapper = llmClient.getObjectMapper();
this.maxSteps = maxSteps;
}
}
改动很小,但给了调用方灵活性:简单查询场景可以设 maxSteps = 5,复杂诊断场景可以设 maxSteps = 15。
第二道防线:重复调用检测
大脑连续两圈用相同的工具名 + 相同的参数调同一个工具——对于确定性的只读查询来说,这几乎可以断定是空转。但检测到就直接掐掉未免可惜——大脑手里已经有前几圈收集到的信息,给它一句提醒,往往就能自己组织出一个有用的回复。所以咱们的策略是先提醒、再停止。
1. 检测逻辑
记录上一圈的工具调用信息(工具名 + 参数),跟当前圈对比。如果完全一致,计数器加 1;如果不一致,计数器清零。检测到重复后分两步处理:
- 第一次重复(计数器 = 1)——提醒:不执行工具,把工具结果替换成一段提示文本,告诉大脑"你已经用相同的参数调过这个工具了,结果不会变,请根据已有信息直接回复用户"。这相当于给大脑一个台阶——它手里已经有前几圈收集到的信息,很多时候一句提醒就够让它收手了。
- 第二次重复(计数器 ≥ 2)——停止:提醒过了还在重复,说明大脑真的卡住了,强制停止循环。
用一张时序图看这个两阶段策略的完整过程:

如果两次相同调用之间穿插了一次不同的调用——比如先查订单、再查物流、然后又查了一遍订单想确认信息——计数器会被中间那次不同的调用清零,不会误判。只有连续重复才会触发提醒。
2. 实现代码
用一个枚举表示检测结果,用一个内部类封装检测逻辑:
private enum RepeatAction {
NORMAL, WARN, STOP
}
private static class RepeatDetector {
private String lastCallSignature = "";
private int repeatCount = 0;
RepeatAction check(List<ToolCallInfo> toolCalls) {
String currentSignature = buildSignature(toolCalls);
if (currentSignature.equals(lastCallSignature)) {
repeatCount++;
} else {
repeatCount = 0;
lastCallSignature = currentSignature;
}
if (repeatCount >= 2) {
return RepeatAction.STOP;
}
if (repeatCount == 1) {
return RepeatAction.WARN;
}
return RepeatAction.NORMAL;
}
private String buildSignature(List<ToolCallInfo> toolCalls) {
StringBuilder sb = new StringBuilder();
for (ToolCallInfo tc : toolCalls) {
sb.append(tc.functionName()).append(":").append(tc.arguments()).append(";");
}
return sb.toString();
}
}
buildSignature() 把一圈里所有的工具调用拼成一个字符串签名——工具名和参数用冒号分隔,多个调用用分号分隔。这样即使一圈里调了多个工具,也能精确比较是否跟上一圈完全一致。
3. 集成到主循环
在循环里加入检测和两阶段处理:
RepeatDetector repeatDetector = new RepeatDetector();
for (int step = 1; step <= maxSteps; step++) {
// ... 调模型、判断是否结束
RepeatAction repeatAction = repeatDetector.check(response.toolCalls());
// 第二次重复:强制停止
if (repeatAction == RepeatAction.STOP) {
System.out.println("[终止] 提醒后仍重复调用,强制停止");
return "抱歉,我在处理您的问题时遇到了困难。请尝试换一种方式描述,或联系人工客服获取帮助。";
}
// ... 追加 assistant 消息到消息列表(WARN 和 NORMAL 都需要)
// 第一次重复:注入提示,跳过工具执行
if (repeatAction == RepeatAction.WARN) {
System.out.println("[提醒] 检测到重复调用,注入提示");
String hint = "你已经用相同的参数调用过这个工具,结果不会变化。"
+ "请根据已有信息直接回复用户,不要重复调用。";
for (ToolCallInfo tc : response.toolCalls()) {
ObjectNode toolMsg = messages.addObject();
toolMsg.put("role", "tool");
toolMsg.put("tool_call_id", tc.id());
toolMsg.put("content", hint);
}
continue;
}
// ... 正常执行工具、追加消息
}
关键在 WARN 分支的处理:不执行工具,而是把每个 tool_call 对应的 tool 消息替换成一段提示文本。对模型来说,这就像工具返回了一条"别再调了"的结果——它会基于这个"结果"重新推理,大概率会切换到总结模式,用已有信息给用户一个回复。
注意:跳过执行、注入提示的策略,前提是工具是确定性的只读操作(如
queryOrder、queryLogistics)。如果工具有副作用(如applyRefund提交退款),重复调用可能导致重复扣款——这类工具即使参数相同也不能跳过,而应该用幂等键去重。如果工具结果随时间变化(如实时库存查询),重复调用可能拿到不同结果,不算空转。生产环境里,建议在工具注册时标记deterministic和readOnly属性,只对确定性只读工具启用跳过执行策略。
第三道防线:Token 预算控制
Agent 循环的 Token 消耗有一个容易被忽略的特性:它不是线性增长,而是二次增长。
每圈循环往消息列表里追加 2-3 条消息(assistant + tool),下一圈的输入 Token 就增加了这些消息的长度。假设每圈新增 500 Token 的消息:
| 圈数 | 新增消息 Token | 累计消息 Token | 本圈输入 Token |
|---|---|---|---|
| 第 1 圈 | 500 | 500 | 500 |
| 第 2 圈 | 500 | 1000 | 1000 |
| 第 3 圈 | 500 | 1500 | 1500 |
| 第 5 圈 | 500 | 2500 | 2500 |
| 第 8 圈 | 500 | 4000 | 4000 |
| 第 10 圈 | 500 | 5000 | 5000 |
10 圈下来,总输入 Token 不是 5000,而是 500 + 1000 + 1500 + … + 5000 = 27500。如果每圈新增的不是 500 而是 1000(工具返回了较长的数据),10 圈的总输入就是 55000 Token。
这还没算输出 Token。加上模型每圈的回复,实际消耗更高。
下面这张图把消息累积的过程画出来——重点不是每圈新增了多少,而是每圈要把前面所有消息重新发一遍:

所以 Token 预算要控制的核心是单次请求的上下文大小——也就是消息列表的总长度。上下文越大,单次调用越贵、延迟越高、模型注意力也越分散。只要卡住上下文大小,二次增长的总成本也就被间接控制住了。至于精确的 Token 成本核算,生产环境里一般直接读 API 响应里的 usage 字段(包含 prompt_tokens 和 completion_tokens),比自己估算靠谱得多。
1. 估算策略
精确计算 Token 数量需要 Tokenizer(分词器),不同模型的 Tokenizer 不一样,引入依赖太重。工程上通常用一个简单的估算规则:
OpenAI 官方给出的英文粗略估算是:1 token ≈ 4 个英文字符,或 100 tokens ≈ 75 个英文单词。因此英文可粗略理解为 1 个单词 ≈ 1.3 tokens。中文没有官方统一换算,不同模型和 tokenizer 差异较大,工程估算时可以保守按 1 个汉字约 1~2 tokens 预估;精确数量应使用 OpenAI Tokenizer 或
tiktoken按目标模型实际计算。
这个估算不精确——只覆盖消息正文内容,不算结构开销和输出——但用来做粗粒度的安全阈值够用了。咱们要的是别撑爆上下文,不是精确到个位数。
实现上,直接用字符数乘以一个系数来估算:
private static class TokenBudget {
private static final double TOKENS_PER_CHAR = 1.0;
private final int maxTokens;
private int estimatedTokens = 0;
TokenBudget(int maxTokens) {
this.maxTokens = maxTokens;
}
void addMessage(String content) {
if (content != null) {
estimatedTokens += estimateTokens(content);
}
}
boolean isExceeded() {
return estimatedTokens >= maxTokens;
}
int getEstimatedTokens() {
return estimatedTokens;
}
private int estimateTokens(String text) {
return (int) Math.ceil(text.length() * TOKENS_PER_CHAR);
}
}
TOKENS_PER_CHAR = 1.0 是一个折中估算:按照上面的规则,中文 1 个汉字约 1~2 Token,这里取下限 1;英文 1 个字符约 0.25 Token,用 1 会偏高,但对预算控制来说宁可多算。咱们的客服场景以中文为主,夹杂少量英文字段名和 JSON 数据,整体用 1.0 不会差太远。另外这里只估算了消息的文本内容,没有算工具定义(tool schema)、请求结构(role、tool_calls JSON)、输出 Token 和推理 Token。精确数字要靠 API 返回的 usage 字段,这里只是做一个量级上的安全阈值——宁可早停一步,不要撑爆上下文。
2. 预算上限设多少
先看主流大模型当前的上下文窗口大小(截至 2026 年 7 月,以各厂商官方文档为准,可能随版本更新变化):
| 模型 / 系列 | 上下文窗口 | 备注 |
|---|---|---|
| DeepSeek V4 Flash / Pro | 1M Token | 官方 API 文档标注 deepseek-v4-flash、deepseek-v4-pro 均为 1M 上下文,最大输出上限为 384K;deepseek-chat / deepseek-reasoner 后续会作为 V4 Flash 的非思考 / 思考模式兼容名。 |
| 通义千问 Qwen3.7 Max / Plus | 1M Token | 阿里云百炼文档标注 qwen3.7-max、qwen3.7-plus 均为 1M 上下文;同时也提醒常规任务 128K~256K 已经足够。 |
| OpenAI GPT-5.5 / GPT-5.4 | 1M Token | OpenAI 模型文档标注 GPT-5.5、GPT-5.4 为 1M 上下文,最大输出 128K。 |
| Claude Opus 4.8、Claude Sonnet 5 | 1M Token | Anthropic 模型概览页标注 Opus 4.8、Sonnet 5 为 1M 上下文;其他型号(如 Sonnet 4.5)多数为 200K,具体以 Models API 查询为准。 |
但要注意:上下文窗口不等于业务预算上限。
上下文窗口只是模型一次请求理论上能接收的最大 Token 数,实际请求里还要放:
- system prompt
- 用户问题
- 历史对话
- 工具定义
- 工具返回结果
- RAG 检索片段
- 模型最终回复
- reasoning / thinking token(如果使用思考模型)
Anthropic 文档也明确提醒:context window 包含模型生成的回复本身;并且上下文越长并不一定越好,Token 数增长后,准确率和召回可能下降,也就是所谓的 context rot。
所以预算不要简单按上下文窗口的 30%~50% 来设。这个规则在 32K / 64K 时代还可以粗略参考,但在 1M 上下文时代会过大。比如 1M 的 30% 就是 300K Token,对普通客服、商品咨询、订单查询场景完全没有必要,反而会增加成本、延迟和干扰信息。
更推荐按场景分层设置:
| 场景 | 建议 Token 预算 |
|---|---|
| 普通客服对话 / 订单查询 / 物流查询 | 4K~8K |
| 带少量历史对话 + 工具调用 | 8K~16K |
| RAG 问答,检索 3~8 个知识片段 | 16K~32K |
| 长文档问答 / 多文档总结 | 32K~128K |
| 大型代码库 / 超长合同 / 多轮复杂 Agent | 128K~256K 起步,按需放大 |
| 真正的超长上下文任务 | 再考虑 300K、500K 甚至 1M |
对于比特严选这种客服场景,单次对话通常不需要很大的上下文。用户问题、最近几轮历史、工具 schema、工具返回结果等真实上 下文加起来,大多数情况下 8K Token 以内就够(注意:代码里的 TokenBudget 只统计消息正文,没有算工具 schema 和 tool_calls 结构,实际统计值会比真实上下文偏小。但 8K 是按真实场景定的安全线,偏小的估算不会让预算失去保护作用,只是触发时机会比实际消耗略滞后)。
因此建议这样设:
private static final int DEFAULT_MAX_TOKENS = 8000;
如果后续加入知识库检索,可以放宽到:
private static final int DEFAULT_MAX_TOKENS = 16000;
// 或者 RAG 场景使用 32000 等
最终原则是:
上下文窗口是模型能力上限,不是你应该塞满的目标。业务里的 Token 预算应该从任务需要出发,而不是从模型最大窗口出发。
对于 TinyAgent 当前的客服 Agent,DEFAULT_MAX_TOKENS = 8000 作为安全线是合理的;如果后续加入大量知识库片段、长文档处理或复杂多工具 Agent,再按场景升级到 16K、32K 或更高。
3. 集成到主循环
在循环体里,每次追加消息后更新 Token 估算,在调模型前检查预算:
TokenBudget tokenBudget = new TokenBudget(maxTokens);
tokenBudget.addMessage(buildSystemPrompt());
tokenBudget.addMessage(userMessage);
for (int step = 1; step <= maxSteps; step++) {
// Token 预算检查
if (tokenBudget.isExceeded()) {
System.out.println("[终止] Token 预算耗尽(约 " + tokenBudget.getEstimatedTokens() + " Token)");
return "抱歉,本次对话信息量较大,已达到处理上限。以下是我目前了解到的信息,请参考。";
}
ChatResponse response = llmClient.chatWithTools(messages, tools);
// ... 追加 assistant 消息
tokenBudget.addMessage(response.content());
// ... 执行工具、追加 tool 消息
for (ToolCallInfo tc : response.toolCalls()) {
String observation = toolRegistry.execute(new Action(tc.functionName(), tc.arguments()));
tokenBudget.addMessage(observation);
// ...
}
}
注意检查时机:在调模型之前检查,而不是之后。因为调模型是最贵的操作——如果预算已经超了,不要再花 Token 去调一次。