第1932篇:工具返回值的错误处理——当Function Call失败时Agent如何恢复
第1932篇:工具返回值的错误处理——当Function Call失败时Agent如何恢复
有一次做演示,现场给客户展示我们的AI客服Agent。演示到一半,查询物流的工具突然超时了,返回了一个HTTP 504。结果整个Agent直接停在那了,既没有向用户解释,也没有任何降级行为,就这么僵着。客户在旁边问:"它怎么不动了?"那场面,相当难堪。
那次之后我才认真研究了工具调用的错误处理机制。这个话题听起来不性感,但在生产环境里的重要性不亚于主流程的功能开发。工具调用失败是常态,不是异常——依赖的下游服务会超时,API限流,网络抖动,参数在边界条件下偶发无效。你的Agent必须能优雅地处理这一切,而不是在第一个错误面前就瘫痪。
工具调用失败的几种模式
先把常见的失败类型梳理清楚,不同类型要用不同的处理策略。
工具调用失败
├── 参数错误(400类)
│ ├── 参数缺失
│ ├── 参数格式不符
│ └── 参数值非法(业务层校验失败)
├── 工具执行错误(500类)
│ ├── 下游服务不可用
│ ├── 超时
│ └── 内部逻辑异常
├── 权限错误(403类)
│ ├── 用户无权限操作
│ └── 认证过期
└── 数据错误(404类)
├── 资源不存在
└── 数据已被删除每种类型的恢复策略完全不同。参数错误说明模型理解有偏差,需要重新生成参数;超时需要重试;权限错误需要提示用户;资源不存在需要告知用户并可能引导到其他操作。
一刀切的"遇到错误就告诉用户失败了"是最差的处理方式。
工具返回值的规范化设计
在讲错误处理之前,先说一个前提:工具的返回值必须规范化。如果你的工具有时返回正常JSON,有时返回异常栈,有时返回空字符串,那模型根本无法稳定地处理结果。
我用的是一个统一的工具结果封装:
public class ToolResult {
private final boolean success;
private final String data; // 成功时的结果数据(JSON字符串)
private final String errorCode; // 失败时的错误码
private final String errorMessage; // 面向模型的错误描述(不是面向用户的)
private final ErrorType errorType; // 错误类型枚举
public enum ErrorType {
INVALID_PARAMS, // 参数错误,不应重试
RESOURCE_NOT_FOUND, // 资源不存在,不应重试
PERMISSION_DENIED, // 权限拒绝,不应重试
SERVICE_UNAVAILABLE,// 服务不可用,可重试
TIMEOUT, // 超时,可重试
RATE_LIMITED, // 限流,延迟后重试
UNKNOWN_ERROR // 未知错误,谨慎重试
}
// 工厂方法
public static ToolResult success(String data) {
return new ToolResult(true, data, null, null, null);
}
public static ToolResult failure(ErrorType errorType, String errorCode, String errorMessage) {
return new ToolResult(false, null, errorCode, errorMessage, errorType);
}
// 序列化为给LLM看的字符串
public String toPromptString() {
if (success) {
return data;
}
// 告诉模型发生了什么,以及它可以怎么做
return String.format("""
{
"success": false,
"error_code": "%s",
"error_message": "%s",
"error_type": "%s",
"suggestion": "%s"
}
""",
errorCode,
errorMessage,
errorType.name(),
getSuggestion()
);
}
private String getSuggestion() {
return switch (errorType) {
case INVALID_PARAMS -> "请检查参数格式后重新调用,或者向用户确认参数信息";
case RESOURCE_NOT_FOUND -> "查询的资源不存在,建议告知用户并询问是否需要其他帮助";
case PERMISSION_DENIED -> "用户没有权限执行此操作,建议告知用户联系管理员";
case SERVICE_UNAVAILABLE, TIMEOUT -> "服务暂时不可用,可以稍后重试一次";
case RATE_LIMITED -> "接口调用频率超限,请等待几秒后重试";
default -> "发生未知错误,建议告知用户暂时无法完成此操作";
};
}
// getter省略
}这里最关键的设计是suggestion字段——直接告诉模型接下来应该怎么做。这大幅提升了模型在失败后的恢复行为准确性。
Agent的错误感知与恢复循环
Agent处理工具结果的核心逻辑,我一般这样设计:
@Service
public class AgentExecutor {
private final LlmClient llmClient;
private final ToolRegistry toolRegistry;
private final RetryPolicy retryPolicy;
// Agent主执行循环
public AgentResponse execute(AgentContext context) {
int maxIterations = 10;
int iteration = 0;
while (iteration < maxIterations) {
iteration++;
// 1. 调用LLM,得到下一步行动
LlmResponse llmResponse = llmClient.chat(context.getMessages());
// 2. 如果是直接回复,结束循环
if (llmResponse.isTextResponse()) {
return AgentResponse.success(llmResponse.getContent());
}
// 3. 如果是工具调用,执行工具
if (llmResponse.hasToolCalls()) {
List<ToolCallResult> results = executeToolCalls(
llmResponse.getToolCalls(), context
);
// 4. 把工具结果加入上下文
context.addAssistantMessage(llmResponse);
context.addToolResults(results);
// 5. 检查是否有不可恢复的错误
if (hasUnrecoverableError(results)) {
String errorSummary = summarizeErrors(results);
// 让模型基于错误信息生成用户友好的回复
context.addSystemHint("工具调用遇到无法恢复的错误:" + errorSummary + "。请向用户解释情况并给出替代方案。");
}
// 继续下一轮循环,让模型决定下一步
continue;
}
// 6. 其他情况,退出
break;
}
if (iteration >= maxIterations) {
return AgentResponse.failure("执行超过最大迭代次数,请简化您的问题");
}
return AgentResponse.failure("执行异常终止");
}
private List<ToolCallResult> executeToolCalls(
List<ToolCall> toolCalls, AgentContext context) {
List<ToolCallResult> results = new ArrayList<>();
for (ToolCall call : toolCalls) {
ToolCallResult result = executeWithRetry(call, context);
results.add(result);
}
return results;
}
private ToolCallResult executeWithRetry(ToolCall call, AgentContext context) {
String toolName = call.getName();
Map<String, Object> params = call.getArguments();
// 先做参数校验
ValidationResult validation = toolRegistry.validate(toolName, params);
if (!validation.isValid()) {
// 参数错误,直接返回,不用重试
return ToolCallResult.fromToolResult(
call.getId(),
ToolResult.failure(
ToolResult.ErrorType.INVALID_PARAMS,
"INVALID_PARAMS",
"参数校验失败:" + validation.getErrorMessage()
)
);
}
// 执行工具,带重试逻辑
return retryPolicy.execute(toolName, () -> {
ToolResult result = toolRegistry.invoke(toolName, params, context);
return ToolCallResult.fromToolResult(call.getId(), result);
});
}
private boolean hasUnrecoverableError(List<ToolCallResult> results) {
return results.stream().anyMatch(r ->
!r.isSuccess() && !r.getErrorType().isRetryable()
);
}
}重试策略的精细化设计
重试不是无脑重试,要区分错误类型,还要有退避策略:
@Component
public class RetryPolicy {
// 不同错误类型的重试配置
private static final Map<ToolResult.ErrorType, RetryConfig> RETRY_CONFIGS = Map.of(
ToolResult.ErrorType.TIMEOUT, new RetryConfig(3, 1000, 2.0),
ToolResult.ErrorType.SERVICE_UNAVAILABLE, new RetryConfig(3, 2000, 1.5),
ToolResult.ErrorType.RATE_LIMITED, new RetryConfig(2, 5000, 1.0),
ToolResult.ErrorType.UNKNOWN_ERROR, new RetryConfig(1, 1000, 1.0)
// INVALID_PARAMS, RESOURCE_NOT_FOUND, PERMISSION_DENIED 不重试
);
public ToolCallResult execute(String toolName, Callable<ToolCallResult> action) {
ToolCallResult lastResult = null;
try {
lastResult = action.call();
if (lastResult.isSuccess()) {
return lastResult;
}
// 检查是否可重试
ToolResult.ErrorType errorType = lastResult.getErrorType();
RetryConfig config = RETRY_CONFIGS.get(errorType);
if (config == null) {
// 不可重试的错误类型,直接返回
return lastResult;
}
// 执行重试
return executeRetries(action, config, lastResult);
} catch (Exception e) {
log.error("工具{}执行异常", toolName, e);
return ToolCallResult.fromException(e);
}
}
private ToolCallResult executeRetries(
Callable<ToolCallResult> action,
RetryConfig config,
ToolCallResult firstFailure) throws Exception {
ToolCallResult lastResult = firstFailure;
long delay = config.getInitialDelayMs();
for (int attempt = 1; attempt <= config.getMaxRetries(); attempt++) {
log.info("第{}次重试,等待{}ms", attempt, delay);
Thread.sleep(delay);
lastResult = action.call();
if (lastResult.isSuccess()) {
log.info("第{}次重试成功", attempt);
return lastResult;
}
// 指数退避
delay = (long) (delay * config.getBackoffMultiplier());
}
log.warn("重试{}次后仍然失败", config.getMaxRetries());
return lastResult;
}
@Data
@AllArgsConstructor
public static class RetryConfig {
private int maxRetries;
private long initialDelayMs;
private double backoffMultiplier;
}
}模型感知错误的Prompt设计
上面说的都是代码层面的处理,但别忘了另一个关键:模型要能正确理解工具返回的错误,并做出合理的恢复行为。
这里有个细节:工具结果在对话历史里是以tool角色的消息出现的。如果工具返回的错误信息太技术化,模型可能不知道怎么办。反过来如果写得太含糊,模型可能会自行脑补一个错误原因。
我的实践是:在System Prompt里明确告诉模型工具错误的处理规则,同时在工具结果里附上建议动作。
System Prompt中的工具错误处理规范部分:
当工具调用返回错误时,遵循以下规则:
1. INVALID_PARAMS错误:说明你生成的参数有问题。仔细分析错误信息,尝试用正确的参数重新调用。如果无法确定正确参数,向用户确认。
2. RESOURCE_NOT_FOUND错误:说明用户提供的信息不存在于系统中。告知用户找不到对应的记录,并询问是否可能输入有误。
3. PERMISSION_DENIED错误:不要重试。直接告知用户没有权限,并建议联系相关负责人。
4. TIMEOUT/SERVICE_UNAVAILABLE错误:如果你已重试过一次仍然失败,告知用户系统暂时繁忙,建议稍后再试。不要无限重试。
5. 任何情况下,都不要向用户暴露技术性的错误码或错误堆栈。用简洁友好的语言说明情况。并行工具调用的错误处理
现代LLM支持在一次响应中调用多个工具,这带来了新的错误处理复杂性:部分成功、部分失败时怎么处理?
public class ParallelToolExecutor {
private final ExecutorService executorService;
public List<ToolCallResult> executeParallel(List<ToolCall> toolCalls, AgentContext context) {
// 提交所有工具调用
List<CompletableFuture<ToolCallResult>> futures = toolCalls.stream()
.map(call -> CompletableFuture.supplyAsync(
() -> executeSingle(call, context),
executorService
))
.collect(Collectors.toList());
// 等待所有结果,最多等待30秒
CompletableFuture<Void> allOf = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
try {
allOf.get(30, TimeUnit.SECONDS);
} catch (TimeoutException e) {
// 超时的工具调用直接取消,返回超时错误
futures.forEach(f -> f.cancel(true));
log.warn("部分工具调用超时");
} catch (InterruptedException | ExecutionException e) {
log.error("并行工具执行异常", e);
}
// 收集所有结果,超时的给超时错误
return futures.stream()
.map(f -> {
try {
return f.isDone() ? f.get() : ToolCallResult.timeout();
} catch (Exception e) {
return ToolCallResult.fromException(e);
}
})
.collect(Collectors.toList());
}
// 处理部分失败场景:生成给LLM的汇总消息
public String summarizePartialResults(List<ToolCallResult> results) {
long successCount = results.stream().filter(ToolCallResult::isSuccess).count();
long failureCount = results.size() - successCount;
if (failureCount == 0) {
return null; // 全成功,不需要特殊处理
}
StringBuilder sb = new StringBuilder();
if (successCount > 0) {
sb.append(String.format("以下%d个工具调用成功完成。", successCount));
}
sb.append(String.format("\n以下%d个工具调用失败:\n", failureCount));
results.stream()
.filter(r -> !r.isSuccess())
.forEach(r -> sb.append(String.format(
"- %s:%s\n", r.getToolName(), r.getErrorMessage()
)));
sb.append("\n请基于成功获取的信息给用户一个部分回答,并说明哪些信息暂时无法获取。");
return sb.toString();
}
}这个"部分成功"的处理很重要。Agent在部分工具失败时不应该放弃全部结果,而应该用成功的信息给用户一个尽可能完整的回答,同时说清楚哪部分信息获取失败了。
无限循环的预防
一个我真实遇到的陷阱:模型在某些情况下会陷入"工具调用失败→重新生成参数→再次调用→再次失败"的无限循环。
public class LoopDetector {
// 检测工具调用循环
public boolean isInLoop(List<Message> recentMessages) {
// 获取最近的工具调用历史
List<ToolCall> recentToolCalls = extractToolCalls(recentMessages, 10);
if (recentToolCalls.size() < 3) {
return false;
}
// 检查是否有同一个工具用相同参数调用了多次
Map<String, Long> callSignatureCount = recentToolCalls.stream()
.collect(Collectors.groupingBy(
call -> call.getName() + ":" + call.getArgumentsHash(),
Collectors.counting()
));
boolean hasRepeatedCalls = callSignatureCount.values().stream()
.anyMatch(count -> count >= 2);
if (hasRepeatedCalls) {
log.warn("检测到工具调用循环,相同参数重复调用");
return true;
}
// 检查是否同一个工具连续失败超过3次
String lastToolName = recentToolCalls.get(recentToolCalls.size() - 1).getName();
long consecutiveFailures = 0;
for (int i = recentMessages.size() - 1; i >= 0; i--) {
Message msg = recentMessages.get(i);
if (msg.isToolResult() && msg.getToolName().equals(lastToolName)) {
if (!msg.isSuccess()) {
consecutiveFailures++;
} else {
break;
}
}
}
if (consecutiveFailures >= 3) {
log.warn("工具{}连续失败{}次,判定为循环", lastToolName, consecutiveFailures);
return true;
}
return false;
}
}在AgentExecutor中加入循环检测:
// 在每次工具调用后检查
if (loopDetector.isInLoop(context.getMessages())) {
context.addSystemHint(
"检测到你在重复调用同一工具但持续失败。请停止重试,直接告知用户当前无法完成这个操作,并提供其他帮助选项。"
);
}错误上报与监控
工具调用失败的数据对产品迭代非常有价值,要做好埋点:
@Component
public class ToolCallMonitor {
private final MeterRegistry meterRegistry;
private final ToolCallEventRepository eventRepository;
public void recordToolCallResult(String toolName, ToolResult result,
long durationMs, String sessionId) {
// Prometheus指标
Counter.builder("tool_call_total")
.tag("tool", toolName)
.tag("status", result.isSuccess() ? "success" : "failure")
.tag("error_type", result.isSuccess() ? "none" : result.getErrorType().name())
.register(meterRegistry)
.increment();
Timer.builder("tool_call_duration")
.tag("tool", toolName)
.register(meterRegistry)
.record(durationMs, TimeUnit.MILLISECONDS);
// 失败事件持久化(用于后续分析)
if (!result.isSuccess()) {
ToolCallEvent event = ToolCallEvent.builder()
.toolName(toolName)
.errorCode(result.getErrorCode())
.errorType(result.getErrorType())
.durationMs(durationMs)
.sessionId(sessionId)
.timestamp(Instant.now())
.build();
eventRepository.save(event);
}
}
// 定时统计失败率,超过阈值告警
@Scheduled(fixedRate = 60000)
public void checkFailureRate() {
Map<String, Double> failureRates = calculateRecentFailureRates();
failureRates.forEach((toolName, rate) -> {
if (rate > 0.1) { // 失败率超过10%触发告警
log.error("工具{}最近1分钟失败率:{:.1f}%,请关注", toolName, rate * 100);
// 可以接入告警系统
}
});
}
}设计的核心原则回顾
这块设计的核心我总结成四句话:
错误要分类——不同类型的错误处理策略完全不同,不能一刀切; 失败要透明——工具结果里的错误信息要让模型能理解并做出决策; 重试要克制——不是所有失败都值得重试,重试也要有退避; 降级要优雅——部分失败时用已有信息给出尽可能完整的回答,而不是全部放弃。
把这四点做好,你的Agent在生产环境的稳定性会有质的提升。
