
先说一个真实感很强的场景:用户随口一句"查上周的订单",Agent 生成的查询参数里 startDate 却是 2026-01-01,工具正常执行、链路无报错,错的数据就这么流进业务里。问题不在于模型"笨",而在于把一个概率采样器当成了参数源,却没有给它配一层裁决机制。
参数为什么会错:三种形态,三种拦截点
第一种是"格式合法、取值越界"。模型能编出一个长得完全像订单号的字符串,因为它是按真实订单号的分布续写的;状态字段给一个枚举之外的新值,也是同一类问题。JSON Schema 的 type 只回答"是不是字符串",不回答"是不是合法的字符串"。即便描述里写了 enum,长上下文里模型照样会把枚举值记串。
第二种是"类型正确、语义错位"。时区、周起始日、秒与毫秒、元与分、两个相似订单号之间的指代,全都属于这一类。参数本身是个合法日期字符串,但"上周"被换算错了,类型层面永远查不出来。
第三种是"参数全对、前置条件不满足"。订单已关闭还要取消、库存为零还要下单、无权限却要操作——错不在参数,在"现在能不能做"。
把三类摆在一起看,结论自然浮出来:它们全都落在 JSON Schema 的视野之外。Schema 描述信封,不描述信里的东西。
为什么提示词救不了这个场

模型生成参数时做的是逐 token 概率采样,内部没有一张可以查询的订单表,所以它不具备"知道这个 ID 不存在"的能力。由此推出三件事:
- 它不会主动表达"我不确定",因为输出目标是续写最可能的序列,不是量化置信度;
- 调低 temperature 只是让分布变尖,"确定地错"和"随机地错"对下游没有区别——这里修正一个流传很广的说法:temperature 只影响采样方差,它既不能提升事实正确性,也不能弥补工具描述里的信息缺口;
- 上下文越长、工具越多、参数名越含糊,采样被干扰的因素就越多。
所以"请确保参数准确"这类提示词只能改善概率,提供不了保证。有副作用的操作需要的是保证,保证只能来自代码。
校验落点:模型提议,代码裁决
工程上把校验拆成三层,边界要清楚:
| 层 | 管什么 | 成本 |
|---|---|---|
| Schema 层 | 类型、必填、结构 | 最低,框架基本免费提供 |
| 语义层 | 枚举白名单、数值上下界、正则格式 | 低,一次性写进代码 |
| 业务层 | 存在性、状态机、权限 | 最高,要查库和鉴权 |
执行器骨架(Java,思路与语言无关):
private static final Set<String> ALLOWED_STATUS =
Set.of("PENDING", "PAID", "SHIPPED", "CLOSED");
public ToolResult execute(CancelOrderArgs args, UserContext user) {
if (args.orderId() == null || args.orderId().isBlank()) {
return ToolResult.retryable("缺少 orderId,请从上下文重新提取");
}
if (!args.orderId().matches("^ORD-\\d{4}-\\d{5}$")) {
return ToolResult.retryable("orderId 格式应为 ORD-年份-5位数字");
}
if (args.targetStatus() != null && !ALLOWED_STATUS.contains(args.targetStatus())) {
return ToolResult.retryable("targetStatus 只能是 " + ALLOWED_STATUS);
}
Order order = orderRepository.findById(args.orderId()).orElse(null);
if (order == null) {
return ToolResult.retryable("订单 " + args.orderId() + " 不存在,请确认订单号");
}
if (!"PENDING".equals(order.getStatus()) && !"PAID".equals(order.getStatus())) {
return ToolResult.fatal("订单当前状态 " + order.getStatus() + ",不允许取消");
}
if (!permissionService.canOperate(user, order)) {
return ToolResult.fatal("当前用户无权操作该订单");
}
orderService.cancel(order);
return ToolResult.ok("订单 " + args.orderId() + " 已取消");
}

三个容易踩的坑:查库判断不能省,它是唯一能确认 ID 真实存在的地方;状态判断要放在存在性之后;权限判断放最后,避免无权限时泄露订单状态。另一个隐蔽问题是"配置漂移"——参数定义里的 enum 和代码里的白名单如果是两份手写常量,迟早不一致,日志看起来像模型不听话,实际是配置分裂。两边必须引用同一个常量源。
错误回传:决定 Agent 是自纠还是打转
错误信息要包含三样:哪个参数错、期望是什么、下一步做什么。NullPointerException at OrderToolExecutor.java:47 对模型毫无意义,"订单 ORD-2024-88731 不存在,请确认订单号"才有可执行性。
更关键的是区分可重试与不可重试:参数错、ID 不存在,模型拿到提示有机会改对;权限不足、内部错误,重试一万次结果一样,直接终止并告知用户。还要给同一个工具设调用上限(实践中 3 次够用),超限后不再喂错误回去,改为降级输出。没有这个上限,"编一个新订单号→被拒→再编一个"的循环能烧掉大量 token——按一次失败循环平均多消耗 2~4k token 估算,一个失控任务跑几十轮的开销足够覆盖一台小型缓存服务器的日成本。
增量:怎么验证这套防线真的有效
光有校验代码不等于有防线,建议配一组对抗性测试用例,直接打进自己的工具执行器:
- 枚举注入:对每个枚举参数,喂一个格式相似但不在白名单的值,断言返回 retryable 且提示包含合法值列表;
- 单位陷阱:时间戳参数分别传秒级和毫秒级值,断言语义层能识别量纲异常(数量级校验:毫秒级时间戳必然大于 10^12);
- 幻觉 ID:用符合正则但确定不存在的 ID 打业务层,断言查库拦截生效,并确认错误文案不含内部堆栈;
- 重试风暴:mock 一个永远返回"订单不存在"的仓库,断言执行器在第 3 次后触发降级,不再产生新的模型调用。
如果用 OpenAI 的 function calling,还可以把 strict: true 的结构化输出(Structured Outputs)打开,让 enum 和必填约束由推理端在解码层强制生效——它能消灭大部分"枚举记串"类错误,但存在性、权限这类现实约束依然只能靠业务层,两者是互补关系不是替代关系。最后补一条风控原则:扣款、删除这类不可逆操作,除了前置校验,还应落一个幂等键(idempotency key),即便模型层出现重复调用,业务侧也只执行一次。

评论0