「为什么 Agent 调我们的接口总是调错」
去年 12 月,业务方提了个需求:让客服 Agent 能直接查订单、查物流、发起退款。我当时的反应是「这不简单吗,把现有的订单服务接口包一层给 Agent 调就行了」。
两周后我收回这句话。Agent 调用我们接口的失败率是 37%,其中大部分不是超时或报错,而是它调了一个完全不该调的接口,或者参数填得乱七八糟。这篇记录我们是怎么把这套接口重做出来的。
第一次尝试:直接把 REST 接口暴露给 Agent
我们最开始的方案最省事:把订单服务的 23 个 REST 端点,逐个注册成工具,用 Spring AI 的 @Tool 注解包一层。
@Tool(description = "查询订单列表")
public List<OrderDTO> queryOrders(OrderQueryReq req) {
return orderService.query(req);
}
这个 OrderQueryReq 长这样(简化过):
public class OrderQueryReq {
private Long userId;
private Integer status; // 1待付款 2已付款 3已发货 4已完成 5已取消 6退款中
private Integer payType; // 1微信 2支付宝 3银行卡 4余额
private String startTime; // yyyy-MM-dd HH:mm:ss
private String endTime;
private Integer pageNo;
private Integer pageSize;
private String sortField;
private String sortOrder; // asc / desc
private Boolean includeDetail;
}
9 个字段,其中 status 和 payType 是魔法数字。Agent 的表现是灾难级的:
- 把「待发货」理解成
status=2(已付款),其实我们要的是 3 之前的所有状态; - 用户说「上周的订单」,它算出
startTime="2026-01-05",少了时分秒,后端解析直接 400; - 用户问「我买的东西到哪了」,它调
queryOrders而不是queryLogistics,因为前者的 description 里也有「物流」两个字。
问题不在模型。把这套接口丢给一个新来的后端同学,他照样会懵。问题在于:面向人写的接口,和面向模型调用的接口,设计目标是冲突的。
接口粒度:一个「意图」一个工具,不是一个「端点」一个工具
我们做的第一个改动是重新切粒度。原来 23 个端点按 CRUD 切,现在按「用户会怎么问」切。这张表是重构前后的对比:
| 用户问法 | 重构前 Agent 要调 | 重构后 |
|---|---|---|
| 我的订单到哪了 | queryOrders → queryLogistics(2 次,可能调错) | trackMyOrder(1 次) |
| 帮我退了这个 | queryOrders → checkRefundable → applyRefund(3 次) | requestRefund(1 次,内部编排) |
| 上个月花了多少钱 | queryOrders(然后自己算,经常算错) | summarizeSpending(1 次) |
关键变化:工具背后是编排逻辑,不是单纯的 CRUD 转发。requestRefund 内部会查订单、校验退款资格、算退款金额、再调申请接口。这些步骤以前让 Agent 自己串,每一步都是一次出错机会,现在收进服务端,Agent 只负责「决定要不要退」和「把订单号找出来」。
23 个工具收敛到了 11 个。工具数量变少这件事本身就在提升准确率——Agent 的选型错误率和候选工具数量是正相关的,我们测下来候选从 23 降到 11,选错率从 19% 降到 6%。
参数设计:把魔法数字换成枚举,把自由文本换成结构化
第二个改动是参数。几条我们定下的规则:
- 不用魔法数字,一律用枚举,让模型看到的是名字不是编号;
- 时间不传字符串,改成传「相对天数」或者让服务端解析自然语言;
- 能推导的参数不让模型填,比如 userId 从会话上下文拿,不进工具签名;
- description 里写清楚「什么时候不要用这个工具」,这条效果最明显。
改造后的退款工具:
public record RefundRequest(
@ToolParam(description = "订单号,形如 SO20260115xxxx。如果用户只说了商品名," +
"必须先调用 searchRecentOrders 拿到订单号")
String orderNo,
@ToolParam(description = "退款原因,必须是以下之一:NOT_RECEIVED(未收到货)、" +
"QUALITY_ISSUE(质量问题)、WRONG_ITEM(发错货)、CHANGED_MIND(不想要了)。" +
"用户没明说时填 CHANGED_MIND")
RefundReason reason
) {}
@Tool(description = """
为用户发起退款申请。
适用场景:用户明确表达要退货/退款,且订单状态允许退款。
不要用于:仅咨询退款政策(用 queryRefundPolicy);
查询退款进度(用 queryRefundStatus);
订单已完成超过 7 天(直接告知用户需走人工)。
调用前无需先检查资格,本工具内部会校验,不满足时返回具体原因。
""")
public RefundResult requestRefund(RefundRequest req) { ... }
加了「不要用于」这一段之后,误调率从 21% 降到 4%。这个成本极低,收益极高,是我们这次改动里性价比最高的一项。
另外一件事:工具返回值也要为模型优化。原来直接返回 OrderDTO,28 个字段里 20 个是内部用的。模型在生成回复时会被无关字段干扰。我们改成了专门的视图对象,只保留 8 个字段,并且把枚举翻译成中文描述:
// 改前:{"status":3,"payType":1,"gmtCreate":"2026-01-15 14:23:01",...}
// 改后:{"订单号":"SO20260115000123","状态":"已发货",
// "下单时间":"2026-01-15 14:23","金额":"¥129.00"}
这个改动让最终回复的准确率提升了 11 个百分点。
用 MCP 封装,解耦 Agent 和业务系统
工具做完之后是集成问题。我们有两个 Agent 运行时(一个 Spring AI 写的客服 Agent,一个 LangChain4j 写的运营 Agent),如果每个都写一遍工具适配,维护两份。
所以把这套工具封装成了 MCP Server,两个 Agent 都通过 MCP 接。用的是无状态的 Streamable HTTP 传输:
@Service
public class OrderTools {
@McpTool(name = "requestRefund",
description = "为用户发起退款申请...") // 同上,省略
public RefundResult requestRefund(
@McpToolParam(description = "订单号") String orderNo,
@McpToolParam(description = "退款原因") RefundReason reason,
McpSyncRequestContext ctx) {
String tenantId = ctx.requestId() != null
? resolveTenant(ctx) : DEFAULT_TENANT;
return refundService.apply(orderNo, reason, tenantId);
}
}
# application.yml
spring:
ai:
mcp:
server:
protocol: STREAMABLE # 不再用 SSE,避免长连接绑定
name: order-tools
version: 1.2.0
capabilities:
tool: true
resource: false
prompt: false
用 MCP 的好处不只是复用。更重要的是它强迫我们把「Agent 能力层」和「业务服务层」分开——MCP Server 是一个独立部署的服务,有自己的限流、自己的鉴权、自己的监控。这在后面救了我们一次:一次 Agent 死循环疯狂调退款接口,我们在 MCP 层直接熔断,订单服务毫发无损。
鉴权:Agent 不是用户,不能拿用户的 token 乱跑
这里有个容易犯的错误:把用户会话 token 直接透传给 MCP Server,让它拿着用户身份去调订单服务。逻辑上没错,但权限范围完全失控——Agent 能做任何用户能做的事,包括「把账户余额提现到陌生卡」。
我们的做法是给 Agent 发一个降级的、带作用域的令牌。MCP Server 拿这个令牌调业务系统,业务系统按 scope 限制可访问的端点:
@Component
public class AgentTokenIssuer {
// Agent 令牌固定 TTL 5 分钟,scope 按会话类型收窄
public String issue(String userId, AgentScene scene) {
return Jwts.builder()
.subject("agent:" + scene.name())
.claim("uid", userId)
.claim("scope", scene.allowedScopes()) // 例如 [order:read, refund:apply]
.claim("risk", scene.riskLevel())
.issuedAt(Instant.now())
.expiration(Instant.now().plusSeconds(300))
.signWith(agentKey)
.compact();
}
}
三条硬规则:
- 写操作必须二次确认。退款、改地址、取消订单这些,工具不直接执行,而是返回一个
confirmToken,前端弹窗让用户点确认,再带 token 调第二次; - 金额超过 500 元的退款不进 Agent 通道,直接转人工。这是我们和风控吵了两天定下的线;
- 令牌 TTL 5 分钟,且绑定单次会话,会话结束即失效。
限流:按「动作」限,不要按 QPS 限
传统接口限流按 QPS,对 Agent 场景完全不够用。Agent 的行为特征是低频但可能突发——一个用户在 3 秒内触发 40 次工具调用太正常了。
我们用的限流维度是三层:
| 层级 | 规则 | 触发后行为 |
|---|---|---|
| 单会话单工具 | 同一工具 60 秒内最多 5 次 | 返回明确错误,提示模型换个思路 |
| 单会话总量 | 单轮对话最多 20 次工具调用 | 强制结束工具循环,转人工 |
| 单用户日累计 | 退款类工具每日 3 次上限 | 拒绝并转人工 |
实现用 Resilience4j 的 RateLimiter 加一层自定义拦截器:
@Around("@annotation(mcpTool)")
public Object guard(ProceedingJoinPoint pjp, McpTool mcpTool) throws Throwable {
String sessionId = SessionContext.current();
String tool = mcpTool.name();
if (!toolBudget.tryConsume(sessionId, tool)) {
// 关键:返回给模型的是「可理解的错误」,不是 429
return ToolResult.error(
"该操作本轮调用次数已达上限,请换一种方式回答用户," +
"或者告知用户联系人工客服。不要重复尝试。");
}
return pjp.proceed();
}
注意那个错误文案。一开始我们返回标准 HTTP 429,模型看到之后会「重试」,形成更严重的雪崩。改成自然语言说明+明确禁止重试之后,收敛得很快。
上线三个月的数据:工具调用失败率从 37% 降到 4.2%,人工转接率从 28% 降到 9%,退款误操作 0 起。
留个问题
关于《把业务系统改造成 Agent 可用的工具》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。