Administrator
发布于 2026-02-01 / 93 阅读
2

把业务系统改造成 Agent 可用的工具

「为什么 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 个字段,其中 statuspayType 是魔法数字。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%。

参数设计:把魔法数字换成枚举,把自由文本换成结构化

第二个改动是参数。几条我们定下的规则:

  1. 不用魔法数字,一律用枚举,让模型看到的是名字不是编号;
  2. 时间不传字符串,改成传「相对天数」或者让服务端解析自然语言;
  3. 能推导的参数不让模型填,比如 userId 从会话上下文拿,不进工具签名;
  4. 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 可用的工具》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考