第1933篇:嵌套工具调用的复杂场景——Agent调用Agent的递归与终止条件
第1933篇:嵌套工具调用的复杂场景——Agent调用Agent的递归与终止条件
我第一次见到Agent调用Agent的需求是在一个企业内部的自动化报告生成系统里。顶层Agent负责理解用户的报告需求,然后把数据采集、数据分析、图表生成这几个子任务分别分发给专门的子Agent去执行,最后再汇总结果生成报告。
听起来很美,实现起来踩了不少坑。
最让我印象深刻的一个bug:有一次子Agent在执行数据采集时,因为需要关联查询,它又去调用了另一个数据查询Agent。那个数据查询Agent在某个边界条件下又触发了数据采集逻辑……你猜怎么着,无限递归,直到超出了调用栈深度,整个服务挂了。
那次之后我才真正重视嵌套工具调用的设计问题。递归调用本身不是坏事,但没有设计好终止条件,就是定时炸弹。
嵌套调用的几种合理使用场景
先说清楚什么情况下应该用嵌套Agent调用,什么情况下是过度设计。
适合用嵌套Agent的场景:
- 任务复杂度差异大:顶层任务是协调调度,子任务是专业执行,职责清晰分离
- 子任务可以并行:数据采集Agent、数据分析Agent可以同时跑
- 子任务有专业上下文:代码执行Agent有自己的沙箱状态,搜索Agent有自己的搜索策略
- 需要能力扩展:顶层Agent通过调用不同子Agent实现能力的动态组合
不适合嵌套的场景:
- 只是想绕过上下文长度限制(用RAG或者摘要更合适)
- 子Agent做的事情和父Agent完全一样(应该直接调用工具)
- 为了显得架构高大上(这种最常见)
递归调用的终止条件设计
这是嵌套Agent里最核心的问题。终止条件必须从以下几个维度来设计:
public class RecursionGuard {
// 调用链追踪上下文
@Data
public static class CallChainContext {
private final String rootSessionId;
private final List<String> agentCallStack; // 调用链路上的Agent名称
private final int currentDepth;
private final int maxDepth;
private final long startTimestamp;
private final long maxDurationMs;
private final Map<String, Integer> agentCallCount; // 每个Agent的调用次数
public CallChainContext createChild(String childAgentName) {
// 检查是否已超过深度限制
if (currentDepth >= maxDepth) {
throw new RecursionDepthExceededException(
String.format("调用深度已达%d层,拒绝继续嵌套调用", maxDepth)
);
}
// 检查是否超时
if (System.currentTimeMillis() - startTimestamp > maxDurationMs) {
throw new CallChainTimeoutException(
String.format("调用链已执行%dms,超过最大允许时间", maxDurationMs)
);
}
// 检查是否存在循环调用(A调用B,B又调用A)
if (agentCallStack.contains(childAgentName)) {
throw new CircularCallException(
String.format("检测到循环调用:%s -> %s(%s已在调用链中)",
agentCallStack.get(agentCallStack.size() - 1),
childAgentName,
childAgentName
)
);
}
// 检查同一个Agent被调用次数是否过多
int currentCount = agentCallCount.getOrDefault(childAgentName, 0);
if (currentCount >= 3) {
throw new AgentCallLimitException(
String.format("Agent%s在本次调用链中已被调用%d次,超过限制",
childAgentName, currentCount)
);
}
// 创建子上下文
List<String> newStack = new ArrayList<>(agentCallStack);
newStack.add(childAgentName);
Map<String, Integer> newCount = new HashMap<>(agentCallCount);
newCount.merge(childAgentName, 1, Integer::sum);
return new CallChainContext(
rootSessionId,
newStack,
currentDepth + 1,
maxDepth,
startTimestamp,
maxDurationMs,
newCount
);
}
}
}这个CallChainContext需要在整个调用链上传递,每个Agent在调用子Agent时都要通过它来检查和创建子上下文。
父Agent与子Agent的通信协议
父子Agent之间的通信不能随意设计,要有明确的输入/输出契约:
// 子Agent的任务请求
@Data
@Builder
public class SubAgentRequest {
private String requestId; // 用于追踪
private String parentAgentName; // 调用方Agent名称
private String taskDescription; // 任务描述(自然语言)
private Map<String, Object> parameters; // 结构化参数
private List<String> expectedOutputFields; // 期望的输出字段
private int timeoutMs; // 超时限制
private CallChainContext callChainContext; // 调用链上下文(必须传递)
}
// 子Agent的任务结果
@Data
@Builder
public class SubAgentResponse {
private String requestId;
private boolean success;
private Map<String, Object> results; // 结构化结果
private String summary; // 结果摘要(父Agent用于决策)
private String errorMessage;
private List<String> warnings; // 非致命问题
private long executionMs;
private int toolCallCount; // 子Agent使用了多少次工具调用
}关键点:子Agent必须返回一个结构化的摘要,而不只是原始数据。父Agent不应该去解析子Agent的详细输出,而是直接使用summary来做决策。这样父Agent的上下文不会被子Agent的细节撑爆。
实现一个可嵌套的Agent框架
下面是一个支持嵌套调用的基础框架实现:
@Service
public abstract class BaseAgent {
protected final LlmClient llmClient;
protected final ToolRegistry toolRegistry;
protected final AgentRegistry agentRegistry; // 注册了所有可调用的子Agent
public abstract String getAgentName();
public abstract String getAgentDescription();
// 执行入口,接受调用链上下文
public final SubAgentResponse execute(SubAgentRequest request) {
long startTime = System.currentTimeMillis();
// 验证调用链上下文
CallChainContext childContext;
try {
childContext = request.getCallChainContext().createChild(getAgentName());
} catch (RecursionDepthExceededException | CircularCallException e) {
log.error("Agent{}调用被拒绝:{}", getAgentName(), e.getMessage());
return SubAgentResponse.builder()
.requestId(request.getRequestId())
.success(false)
.errorMessage(e.getMessage())
.build();
}
// 执行Agent逻辑
try {
AgentContext agentContext = buildAgentContext(request, childContext);
String result = executeInternal(agentContext);
return SubAgentResponse.builder()
.requestId(request.getRequestId())
.success(true)
.summary(result)
.executionMs(System.currentTimeMillis() - startTime)
.toolCallCount(agentContext.getToolCallCount())
.build();
} catch (Exception e) {
log.error("Agent{}执行异常", getAgentName(), e);
return SubAgentResponse.builder()
.requestId(request.getRequestId())
.success(false)
.errorMessage("执行过程中发生异常:" + e.getMessage())
.executionMs(System.currentTimeMillis() - startTime)
.build();
}
}
// 子类实现具体执行逻辑
protected abstract String executeInternal(AgentContext context);
// 调用子Agent的辅助方法
protected SubAgentResponse callSubAgent(
String agentName,
SubAgentRequest.SubAgentRequestBuilder requestBuilder,
AgentContext currentContext) {
BaseAgent subAgent = agentRegistry.get(agentName);
if (subAgent == null) {
throw new AgentNotFoundException("找不到Agent:" + agentName);
}
SubAgentRequest request = requestBuilder
.parentAgentName(getAgentName())
.callChainContext(currentContext.getCallChainContext())
.build();
return subAgent.execute(request);
}
}一个具体的嵌套Agent案例:研究报告生成
用实际案例说明怎么设计:
// 顶层:报告编写Agent
@Service
public class ReportWriterAgent extends BaseAgent {
@Override
public String getAgentName() { return "report_writer"; }
@Override
protected String executeInternal(AgentContext context) {
// 第一步:理解报告需求
String requirement = context.getUserInput();
// 第二步:调用数据收集Agent(子Agent)
SubAgentResponse dataResponse = callSubAgent("data_collector",
SubAgentRequest.builder()
.taskDescription("收集以下报告所需的数据:" + requirement)
.timeoutMs(30000),
context
);
if (!dataResponse.isSuccess()) {
// 降级处理:数据收集失败,用公开信息写报告
context.addSystemHint("数据收集失败,请基于公开知识撰写报告,并注明数据来源有限");
} else {
context.addSystemHint("已收集到以下数据,请基于此撰写报告:\n" + dataResponse.getSummary());
}
// 第三步:调用图表生成Agent(可以和数据收集并行)
SubAgentResponse chartResponse = callSubAgent("chart_generator",
SubAgentRequest.builder()
.taskDescription("为以下报告生成关键图表说明:" + requirement)
.timeoutMs(20000),
context
);
if (chartResponse.isSuccess()) {
context.addSystemHint("图表规划:\n" + chartResponse.getSummary());
}
// 第四步:基于收集到的信息,让LLM生成最终报告
return callLlmForFinalReport(context);
}
private String callLlmForFinalReport(AgentContext context) {
// 构建最终请求,让LLM生成报告
LlmResponse response = llmClient.chat(context.getMessages());
return response.getContent();
}
}// 中层:数据收集Agent
@Service
public class DataCollectorAgent extends BaseAgent {
@Override
public String getAgentName() { return "data_collector"; }
@Override
protected String executeInternal(AgentContext context) {
// 数据收集Agent自己有工具:数据库查询、API调用等
// 但它不应该再调用其他数据收集类的Agent(防止循环)
// 工具调用循环:查询数据
int maxToolCalls = 5;
while (context.getToolCallCount() < maxToolCalls) {
LlmResponse response = llmClient.chat(context.getMessages());
if (response.isTextResponse()) {
// 数据收集完成,返回摘要
return response.getContent();
}
if (response.hasToolCalls()) {
// 验证工具调用权限:数据收集Agent不能调用Agent级别的工具
for (ToolCall call : response.getToolCalls()) {
if (isAgentLevelTool(call.getName())) {
throw new UnauthorizedToolCallException(
"数据收集Agent无权调用Agent级工具:" + call.getName()
);
}
}
executeToolCalls(response.getToolCalls(), context);
}
}
return "数据收集完成(已达到工具调用次数上限),以下是已收集的数据摘要:" +
buildDataSummary(context);
}
private boolean isAgentLevelTool(String toolName) {
// Agent级工具以"agent_"前缀标识
return toolName.startsWith("agent_");
}
}调用链可视化与调试
嵌套调用很难调试,必须有调用链追踪:
public class CallChainTracer {
// 在日志中输出调用链树形图
public String renderCallTree(CallChainEvent rootEvent) {
StringBuilder sb = new StringBuilder();
renderNode(rootEvent, "", true, sb);
return sb.toString();
}
private void renderNode(CallChainEvent event, String prefix, boolean isLast, StringBuilder sb) {
String connector = isLast ? "└── " : "├── ";
String status = event.isSuccess() ? "✓" : "✗";
sb.append(prefix)
.append(connector)
.append(String.format("[%s] %s (%dms, %d工具调用)\n",
status,
event.getAgentName(),
event.getDurationMs(),
event.getToolCallCount()
));
String childPrefix = prefix + (isLast ? " " : "│ ");
List<CallChainEvent> children = event.getChildren();
for (int i = 0; i < children.size(); i++) {
renderNode(children.get(i), childPrefix, i == children.size() - 1, sb);
}
}
}输出效果大概是这样:
[✓] report_writer (4523ms, 2工具调用)
├── [✓] data_collector (2103ms, 4工具调用)
│ └── [✓] database_query_tool (890ms)
└── [✗] chart_generator (1420ms, 1工具调用)
└── [✗] chart_api (1420ms) → TIMEOUT有了这个,调试嵌套Agent问题时清晰多了。
状态共享与隔离的平衡
嵌套Agent里一个容易踩的坑:子Agent的状态要和父Agent隔离,但某些信息需要共享。
public class AgentContext {
// 不应共享的:各自的对话历史
private final List<Message> privateMessages;
// 应该共享的:用户身份、权限信息
private final UserContext userContext;
// 应该共享的:调用链上下文(用于终止条件判断)
private final CallChainContext callChainContext;
// 可选共享的:会话级缓存(避免重复查询)
private final SharedCache sessionCache;
// 创建子Agent上下文
public AgentContext createChildContext(String childAgentSystemPrompt) {
return AgentContext.builder()
// 子Agent有独立的对话历史
.privateMessages(new ArrayList<>())
// 共享用户上下文(子Agent需要知道是谁在操作)
.userContext(this.userContext)
// 传递调用链上下文(终止条件控制)
.callChainContext(this.callChainContext)
// 共享会话缓存(避免父子Agent重复查询同一数据)
.sessionCache(this.sessionCache)
.systemPrompt(childAgentSystemPrompt)
.build();
}
}会话级缓存这个点很实用。父Agent和子Agent经常会查询同样的数据(比如用户信息、商品详情),如果各自独立查询,既浪费时间又浪费Token。用一个共享的缓存,子Agent查到的数据父Agent可以复用。
嵌套深度的权衡
最后说一个工程经验:实际项目里,我几乎从不允许嵌套深度超过3层。
用户请求
└── 顶层协调Agent(第1层)
├── 数据采集Agent(第2层)
│ └── 具体工具调用(不算Agent层)
└── 内容生成Agent(第2层)
└── 具体工具调用(不算Agent层)超过3层之后,调试难度指数级上升,出问题很难定位,而且上下文传递的overhead也开始显著影响性能。如果发现业务需要4层或更深的嵌套,通常是任务分解出了问题,应该重新审视架构设计。
另外一个取舍:是否真的需要Agent调用Agent?很多时候,一个Agent加上足够丰富的工具集,就能完成多个层级的任务。引入嵌套Agent的真正价值在于专业化分工和并行执行——如果这两个优势用不上,就不必要引入嵌套的复杂性。
嵌套Agent调用是一个强力武器,但也是一把双刃剑。用对了能让系统的能力呈指数级扩展,用错了就是噩梦级的调试地狱。终止条件的设计、通信协议的规范、状态隔离与共享的平衡——这三件事做好了,嵌套Agent才真的能用于生产。
