
简介:工具调用把带副作用的执行权交给了概率模型;用设计、决策、拦截、执行、反馈、资源六大契约,定义 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,挂起转人工
- 不指望 ACID,把这类跨服务写操作提升为持久化 Workflow / Saga
- 注意点
- 邮件、扣款这类不可逆动作,尽量放在链路最后
面试/考试记忆点
- 工具设计的本质是定义 Agent 能做什么、不能做什么、做错了怎么兜底,而不是实现功能
- MCP 协议只管通信,不区分读写也不管幂等,工具要按对抗性系统的入口来设计
- 设计契约:瑞士军刀式工具让权限拦截失效,必须原子化、正交化拆分
- 决策契约:description 按六要素写,80% 时间打磨描述,20% 时间写实现
- 拦截契约:用注解声明副作用等级、幂等、延迟、只读等元数据,Loop 靠代码而不是靠猜
- 执行契约:幂等键由 Loop 按
runId:turn:callIndex注入,服务端用唯一约束去重;跨工具长事务用 Saga 补偿 - 反馈契约:严格超时 + 红绿灯错误(红灯不重试、黄灯退避重试、hint 指路)
- 资源契约:返回结果硬限大小 + 游标分页,防止撑爆上下文
- 多步写操作部分成功:Saga 逆序补偿或 Outbox 最终一致,不可逆动作放最后,补偿失败转人工