别把执行权交给概率:六大契约重新定义工具设计

简介:工具调用把带副作用的执行权交给了概率模型;用设计、决策、拦截、执行、反馈、资源六大契约,定义 Agent 能做什么、不能做什么、做错了怎么兜底

事故现场:二十行工具代码引发的半夜告警

简介:工具本身不防重、不设限,Agent 的每一次重试都在制造生产事故

  • 很多人第一轮的工具写法
java 复制代码
public class NaiveTools {

    /** 发送邮件 */
    public Object sendEmail(String to, String subject, String body) {
        return emailClient.send(to, subject, body);
    }

    /** 查询订单 */
    public Object queryOrder(String orderId) {
        return db.query(orderId);
    }

    /** 账户管理(查询/修改/删除/冻结) */
    public Object manageAccount(String action, String userId, Map<String, Object> kwargs) {
        return switch (action) {
            case "query" -> fetchUser(userId);
            case "delete" -> hardDeleteUser(userId);
            default -> throw new IllegalArgumentException(action);
        };
    }
}
  • 接入几个第三方 MCP Server 后,6 小时内工具调用异常 1024 次
画面 现象
无限重试 循环查询一个不存在的订单 ID,报 Order not found 也不停,连续调了 847 次
重复发送 网络超时后 Loop 自动重试,第三方邮件 MCP Server 没做防重,同一客户收到 50 封通知
状态覆盖与上下文爆炸 工单原始状态被直接覆盖;查询工具一次返回 10MB 日志,撑爆 Context Window,后续对话全部丢失
  • 根因
    • 常见理解:模型决定调哪个工具、传什么参数,代码执行完返回结果,模型继续思考
    • 陷阱:把带有副作用的执行权,毫无保留地交给了概率模型
      • 传统软件的重试由人触发,人知道分寸
      • Agent Loop 的重试由模型触发:遇到超时重试、遇到空数据重试、崩溃恢复后接着重试
    • MCP 协议只管通信,不区分读写,也不管幂等
      • 改不了别人的代码,就把工具当成对抗性系统的入口来设计
  • 工具设计的本质
    • 不是"实现功能"
    • 而是定义 Agent 能做什么、不能做什么,以及做错了怎么兜底

六大契约总览

简介:工具调用生命周期的六个环节,每个环节都要建立强契约

环节 契约 防什么 核心做法
设计 设计契约 防混 工具原子化、正交化拆分
模型决策 决策契约 防错 按六要素写 description
系统拦截 拦截契约 防越权 工具元数据声明"安全指纹"
执行 执行契约 防重 服务端无状态 + 系统级幂等
异常反馈 反馈契约 防卡死 超时控制 + 红绿灯错误语义
结果返回 资源契约 防爆 返回大小上限 + 游标分页

设计契约:工具原子化,拒绝瑞士军刀

简介:只有工具拆细了,后面的拦截、并发控制、元数据打标才有挂载点

  • 瑞士军刀式工具是灾难起点
    • manage_account 同时包含查询、修改、删除、冻结
    • 模型想删数据时,工具名看起来人畜无害,权限防线根本拦不住
  • 最佳实践:原子化与正交化
    • Loop 的权限拦截挂在**工具级别(工具名)**上
    • 拆成三个工具,各自打上不同风险等级
工具 性质
get_user_profile 只读
freeze_user_account 写操作
delete_user_account 高危操作
  • MCP 场景
    • 第三方只给了不可拆分的瑞士军刀时,Host 在网关层按条件判断,把它路由到不同的安全策略

决策契约:描述工程让模型做对选择

简介:模型看不到实现代码,选择工具的唯一依据就是 name 和 description

  • 实现完美但描述模糊的工具,对模型来说是隐形的
  • 生产级 description 的六要素
要素 写什么
一句话总结 它干什么
使用场景 何时用
与其他工具的区别 为什么选它而不选另一个
参数语义 关键参数的含义、格式和边界
重要约束 什么情况下不能用,副作用是什么
使用示例 典型的输入输出
  • 生产版示例:六要素结构化描述
java 复制代码
@Tool(name = "search_code", description = """
        用 ripgrep 在代码库中搜索文本模式或正则表达式。
        【用途】找函数定义位置、追踪变量使用。
        【区别】优先于 bash+grep:结果自动带行号和文件路径,排除 .gitignore 路径。
        【局限】不适用于语义级搜索,请改用 LSP 工具。
        【参数】pattern 支持正则;path 为空时搜索整个项目。
        【约束】只读操作;超大项目建议缩小 path 范围。
        【示例】search_code('User createUser', 'src/') → [{file: 'src/main/java/com/example/UserService.java', line: 12, text: '...'}]
        """)
public class SearchCodeTool implements AgentTool { /* ... */ }
  • 投入 80% 时间打磨描述,20% 时间写实现,而不是反过来

拦截契约:工具元数据让 Loop 看透工具本质

简介:工具不能只是一段代码,它必须是一个自描述的实体,向 Loop 暴露自己的"安全指纹"

  • 为什么需要元数据
    • Loop 要做串行/并发裁决、熔断、权限拦截,必须知道工具是读还是写、是快还是慢
    • 靠解析 description?模型会看错,但代码不能看错
  • 副作用等级
等级 语义
LOW 安全操作,无需审批
MEDIUM 一般写操作,记录审计日志
HIGH 高危操作,触发 WAIT_APPROVAL
  • 用注解把元数据直接声明在工具上,工具即契约
java 复制代码
public enum SideEffectLevel { LOW, MEDIUM, HIGH }

@Tool(
    name = "freeze_user_account",
    sideEffectLevel = SideEffectLevel.HIGH,
    enforcedIdempotent = true,      // Host 将强制注入幂等键
    estimatedLatencyMs = 2000,      // 超时上限 = 此值 × 3
    readonly = false
)
public class FreezeUserAccountTool implements AgentTool {

    /** 冻结用户账号(高危,需人工审批) */
    @Override
    public Map<String, Object> call(Map<String, Object> params) { /* ... */ }
}

// 注册时指定层级和角色,Loop 据此动态组装可见工具集
registry.register(new FreezeUserAccountTool(), Tier.L2, "cs_agent");
  • MCP 工具
    • Host 在加载时解析本地配置,为它动态打上这层 Metadata 补丁
  • 核心:把"工具是什么性质"的判断,从自然语言猜测下沉到代码级的强类型声明

执行契约:严格的无状态与系统级幂等

简介:重试随时可能发生,副作用工具必须幂等,服务端必须无状态

  • 第一条铁律:副作用工具必须幂等,且服务端必须无状态
    • LLM 会因网络波动、超时或幻觉重复发送请求
    • 在内存里保存会话状态,重试或并发调用时状态会错乱
    • 所有状态通过参数显式传递,或存入外部 Redis
  • 生产级解法:双向奔赴
    • Host 层强行注入幂等键:由 Loop 生成,不由 LLM 生成
    • Server 端利用唯一约束去重
java 复制代码
/** Agent Loop 的 EXECUTE 阶段:幂等键由 Loop 注入,不由 LLM 生成 */
public static String injectIdempotencyKey(AgentRun run, ToolCall call, int callIndex) {
    // 格式 {runId}:{turnCount}:{callIndex},与任务位置绑定而非参数内容,重试时完全一致
    String key = "%s:%d:%d".formatted(run.getRunId(), run.getTurnCount(), callIndex);
    call.params().put("idempotency_key", key);
    return key;
}
java 复制代码
/** 生产级 Tool / MCP Server 端:无状态去重拦截 */
public Map<String, Object> sendEmail(SendEmailParams params) {
    String dedupKey = "email_sent:" + params.idempotencyKey();
    // 1. 幂等性检查(利用 Redis 的原子脚本保证"检查 + 执行"安全)
    if (Boolean.TRUE.equals(redis.hasKey(dedupKey))) {
        return Map.of("status", "skipped", "reason", "邮件已发送,幂等拦截");
    }
    // 2. 执行业务逻辑(不依赖任何进程内状态)
    Map<String, Object> result = emailClient.send(params.to(), params.subject(), params.body());
    // 3. 记录幂等键,保留 1 小时
    redis.opsForValue().set(dedupKey, "1", Duration.ofHours(1));
    return result;
}
  • 注意点
    • 跨多个工具的长事务光防重还不够
      • 例如"先扣款再发货":发货失败时扣款不可逆
      • 需要引入分布式系统的 Saga 补偿模式,逆序回滚

反馈契约:超时控制与可操作的错误红绿灯

简介:传统软件的报错是给人看的,Agent 工具的报错是给模型看的,可操作性比详细度更重要

  • 两个问题
    • LLM 对延迟非常敏感:一个卡住的工具会让整个对话线程僵死
    • 模型遇到报错的默认动作是"换种说法再试一次":只给 500 Internal Server Error,它就像没头苍蝇一样瞎撞
  • 红绿灯错误语义
信号 含义 典型场景 给模型的 hint
红灯 永久失败,重试无意义 参数格式错误、订单不存在 检查参数格式,或换一个工具
黄灯 瞬态错误,等一下可能恢复 数据库查询超时 等待一段时间后重试,不要立即重试
绿灯 成功 正常返回 无
  • 生产版:超时控制 + 受控 reason + 自由 code + hint 引导
java 复制代码
public Object queryOrder(String orderId) throws Exception {
    // 1. 零信任输入校验(红灯:不要试了)
    if (orderId == null || !orderId.matches("^ORD-\\d{8}$")) {
        return ToolError.fromReason(ErrorReason.FORMAT_ERROR, "invalid_order_id",
                "order_id 格式错误: " + orderId,
                "请检查 order_id 必须为 ORD-XXXXXXXX 格式,不要传入 null 或自然语言。");
    }
    Order order;
    try {
        // 2. 严格的超时控制(通常 5-10 秒),避免对话线程僵死
        order = db.queryAsync(orderId).get(5, TimeUnit.SECONDS);
    } catch (TimeoutException e) {
        // 3. 黄灯:等一下再试,可能恢复
        return ToolError.fromReason(ErrorReason.TIMEOUT, "order_query_timeout",
                "数据库查询超时", "请等待 10 秒后重试,不要立即重试。");
    }
    if (order == null) {
        // 4. 红灯:别试了,重试无意义
        return ToolError.fromReason(ErrorReason.FORMAT_ERROR, "order_not_found",
                "订单 " + orderId + " 不存在",
                "该订单可能已取消或 ID 错误,请勿使用相同 ID 重试,尝试使用 query_user_orders 查询用户所有订单。");
    }
    return order;
}
  • MCP 场景
    • 第三方 Server 返回标准 JSON-RPC Error 时,Host 层需要一个错误翻译代理
    • 先翻译成红绿灯语义,再交给 LLM

资源契约:响应大小与 Token 经济

简介:这是 Agent 工具与传统 API 最大的不同点,返回结果本身就可能撑爆上下文

  • 传统软件返回 10MB JSON 没人在意
  • Agent 里会直接撑爆上下文,导致后续对话丢失,还产生巨额费用
  • 原则:硬性限制返回大小(如 4KB),并提供游标分页
java 复制代码
/**
 * 获取用户订单列表。
 * 【约束】为防止撑爆上下文,每次最多返回 limit 条(默认 10)。
 */
public Map<String, Object> listUserOrders(String userId, Integer limit, String cursor) {
    int size = Math.min(limit == null ? 10 : limit, 50);          // 硬性上限 50 条
    Page<Order> page = orderRepo.paginate(userId, size, cursor);
    return Map.of(
            "orders", page.items(),
            "next_cursor", Objects.requireNonNullElse(page.nextCursor(), ""),
            "has_more", page.nextCursor() != null,
            "hint", "如果需要查看更多,请使用 next_cursor 作为参数继续调用。");
}

闭环:元数据驱动的决策流水线

简介:同一批调用里既有查订单(只读)、发邮件(写),又有删账户(高危)时,Loop 用预检层和错误处理层把六大契约串起来

  • 预检层:基于元数据决定每个调用怎么执行
  • 图中红色节点是高危审批拦截,绿色是两条执行路径
java 复制代码
public void runBatch(AgentRun run, List<ToolCall> calls, EventSink events) {
    List<ToolCall> readonlyCalls = new ArrayList<>();
    List<ToolCall> serialCalls = new ArrayList<>();
    for (int i = 0; i < calls.size(); i++) {
        ToolCall call = calls.get(i);
        // 1. 死循环检测
        Optional<String> loop = detectInfiniteRetry(run, call, 5);
        if (loop.isPresent()) {
            throw new InfiniteLoopDetected(loop.get());
        }
        ToolMeta meta = dispatcher.getMeta(call.name());
        // 2. 执行契约:幂等键注入
        if (meta != null && meta.enforcedIdempotent()) {
            injectIdempotencyKey(run, call, i);
        }
        // 3. 拦截契约:高危操作挂起,等待人工审批
        if (policy.decide(call, meta) == PolicyDecision.SUSPEND) {
            run.setState(RunState.SUSPENDED);
            throw new SuspendPendingApproval(call.name());
        }
        // 4. 并发分类:只读 → 并行,写操作 → 串行
        (dispatcher.isReadonly(call.name()) ? readonlyCalls : serialCalls).add(call);
        events.emit(new ToolCallStartEvent(call, run.getRunId()));
    }

    // 并行执行只读工具(总耗时 ≈ 最慢的那一个)
    List<CompletableFuture<Map<String, Object>>> futures = readonlyCalls.stream()
            .map(c -> dispatcher.dispatchAsync(c)
                    .exceptionally(ex -> Map.of("error", String.valueOf(ex))))
            .toList();
    for (int i = 0; i < readonlyCalls.size(); i++) {
        appendResult(run, events, readonlyCalls.get(i), futures.get(i).join());
    }

    // 串行执行写操作(带红绿灯重试)
    for (ToolCall call : serialCalls) {
        appendResult(run, events, call, dispatchWithRetry(call, run));
    }
}
  • 错误处理层:红绿灯驱动的重试路由
java 复制代码
private Map<String, Object> dispatchWithRetry(ToolCall call, AgentRun run) throws InterruptedException {
    Map<String, Object> result = null;
    for (int attempt = 0; attempt < 4; attempt++) {               // 初始调用 + 最多 3 次重试
        result = dispatcher.dispatch(call);
        ToolResult tr = ToolResult.fromRaw(result, call.name());
        if (tr.outcome() == ToolOutcome.OK || tr.outcome() == ToolOutcome.BLOCKED) {
            return result;                                         // 绿灯:成功
        }
        if (tr.outcome() == ToolOutcome.ABORT) {                   // 红灯:永久失败,告知模型换策略
            String hint = tr.error().hint() == null ? "" : "\n建议:" + tr.error().hint();
            run.addSystemMessage("工具 '%s' 永久失败(不要重试):%s%s"
                    .formatted(call.name(), tr.error().message(), hint));
            return result;
        }
        // 黄灯:瞬态错误,退避重试最多 3 次
        int retryCount = run.getRetryCounter().getOrDefault(call.name(), 0);
        if (retryCount >= 3) {
            run.addSystemMessage("工具 '%s' 重试 3 次依然失败,放弃。".formatted(call.name()));
            return result;
        }
        run.getRetryCounter().put(call.name(), retryCount + 1);
        run.addSystemMessage("工具 '%s' 遇到临时错误,等待 5 秒后重试(%d/3)"
                .formatted(call.name(), retryCount + 1));
        TimeUnit.SECONDS.sleep(5);
    }
    return result;
}
  • 总结
    • 六道锁:防混的原子化拆分、防错的描述工程、防越权的元数据、防重的无状态幂等、防卡死的红绿灯反馈、防爆的 Token 经济
    • 六道锁生效的前提是 Loop 知道何时、用什么方式上锁,这就是 ToolMeta 的价值
    • 从 Native Tool 走向 MCP,像软件架构从单体走向微服务
      • 超时熔断、无状态设计、限流降级一样都不能少
      • 不能信任别人的 Server 代码,只能信任自己定义的边界

延伸思考:多步写操作部分成功怎么办

简介:先冻结账户、再发冻结通知,第一步成功、第二步重试耗尽失败,留下"已冻结未通知"的脏状态

  • 问题
    • 工具调用是跨网络的独立 API,没有数据库事务(ACID)可以依托
    • 需要在 Loop 层设计通用机制,解决"多步写操作的部分成功"
  • 思路
    • 不指望 ACID,把这类跨服务写操作提升为持久化 Workflow / Saga
      • 每个动作执行前登记:幂等键、前后置条件、补偿动作、可逆性
      • 失败后按 LIFO 逆序补偿,补偿本身也要幂等
    • 允许最终一致时,用 Outbox
      • 冻结事务同库写一条待发事件
      • 后台 Worker 至少一次投递,消费端去重
    • 要求同成同败时
      • 只有确认邮件没发出,才能解冻
      • 响应未知时先查询投递状态或对账,不能盲目回滚
    • 补偿再失败,进入 ROLLBACK_PENDING,挂起转人工
  • 注意点
    • 邮件、扣款这类不可逆动作,尽量放在链路最后

面试/考试记忆点

  • 工具设计的本质是定义 Agent 能做什么、不能做什么、做错了怎么兜底,而不是实现功能
  • MCP 协议只管通信,不区分读写也不管幂等,工具要按对抗性系统的入口来设计
  • 设计契约:瑞士军刀式工具让权限拦截失效,必须原子化、正交化拆分
  • 决策契约:description 按六要素写,80% 时间打磨描述,20% 时间写实现
  • 拦截契约:用注解声明副作用等级、幂等、延迟、只读等元数据,Loop 靠代码而不是靠猜
  • 执行契约:幂等键由 Loop 按 runId:turn:callIndex 注入,服务端用唯一约束去重;跨工具长事务用 Saga 补偿
  • 反馈契约:严格超时 + 红绿灯错误(红灯不重试、黄灯退避重试、hint 指路)
  • 资源契约:返回结果硬限大小 + 游标分页,防止撑爆上下文
  • 多步写操作部分成功:Saga 逆序补偿或 Outbox 最终一致,不可逆动作放最后,补偿失败转人工
相关推荐
DigitalOcean2 小时前
DigitalOcean Agent 数据服务:为 AI Agent 构建数据基础
agent
武子康2 小时前
ESP32-S3 分核后仍报 AFE 缓冲区满?先查输入合同
人工智能·后端·agent
范中勤3 小时前
LangGraph Checkpointer 封装(下):with 的双层上下文与 MemorySaver 为什么不用 with
python·大模型·llm·agent·langgraph
hpoenixf4 小时前
Agent 发布治理为什么做了两次?
agent
jerrywus5 小时前
用 Pi 接入 Hindsight:安装配置、记忆检索与实战排查
chatgpt·agent·claude
救救孩子把5 小时前
AI面试八股文-03_3-RAG 系统面试题
人工智能·redis·面试·agent·rag
钝挫力PROGRAMER5 小时前
Chrome-devtools-mcp:闭环Agent的自我验证
ai·agent·chrome devtools·mcp·dsh
码哥字节6 小时前
WorkBuddy 装完就吃灰?3 个动作让 AI 真帮你干活
agent
for_ever_love__6 小时前
上下文工程:比提示词工程更重要的事
llm·agent·提示词·设计·context·engineering·上下文工程