4.3 参数校验与失败回填
一句话结论:模型传错参数不是偶发事故,而是必然现象(2.3、2.4 已论证)。所以关键不在于"让它别错",而在于错了之后能不能自己改回来——这取决于你回传的错误信息质量。
读这篇之前
需要先读 4.1(工具调用是结构化输出)、3.2(schema 校验)、2.5(工具层的五项职责)。这一篇是工具设计里最影响"实际可用度"的一篇。
一、为什么参数一定会错
参数错误不是"模型不够聪明",有四条结构性原因:
| 原因 | 机制 | 例子 |
|---|---|---|
| 幻觉 | 它在预测"这个位置通常填什么" | 编出 date: "明天下午" 而不是 2026-10-26 |
| 采样随机 | 高温下选了次优 Token | 参数名拼错一个字母 |
| 描述含糊 | 不知道该填什么 | 只写"日期",它自创格式 |
| 上下文缺信息 | 拿不到该填的值 | 用户没说城市,它编一个 |
四条里有三条可以直接改善(描述、schema、上下文);剩下"幻觉"那条,靠的是校验与回填。
二、三层校验:语法、结构、语义
工具层的校验分三层,每层拦不同的问题:
| 层 | 检查什么 | 不做会怎样 |
|---|---|---|
| 语法层 | JSON 能否解析 | 解析异常,整轮崩溃 |
| 结构层 | 必填字段在不在、类型对不对 | 下游拿不到值,静默用默认值 |
| 语义层 | 值是否合法(枚举、范围、格式) | 误操作——最危险 |
第三层最容易被省,也最该做
举例:一个 delete_file 工具,参数 path: string。
- 语法:
{"path": "/etc/passwd"}—— 合法 JSON ✅ - 结构:
path存在且是字符串 ✅ - 语义:这个路径在允许的目录内吗? —— 没有这层检查,就等于把整个文件系统交出去了
语义校验的具体形态:
| 类型 | 校验内容 |
|---|---|
| 枚举 | 值必须在集合内(status 只能是 active/disabled) |
| 范围 | 数字在区间内(size 1~1000) |
| 格式 | 正则匹配(日期 YYYY-MM-DD) |
| 路径白名单 | 前缀必须匹配允许目录 |
| 业务规则 | 依赖其他数据(不能删除自己创建的最后一个项目) |
一条实用原则:
凡是"值域有限"的参数,都用枚举。凡是指向系统的参数(路径、命令、URL),都做白名单。
三、失败回填:让模型自己修
这是本篇的核心。参数错了之后,有两种处理方式:
❌ 直接失败:抛异常、任务终止
✅ 失败回填:把错误信息作为工具结果返回,让模型修正后重试
第二种之所以有效,是因为(1.3 讲过):工具结果会进入上下文,成为模型下一轮判断的依据。
所以"错误信息"本质上是一种特殊的上下文。它的质量决定了模型能不能自我修复。
错误信息的三种质量
① 最差:Error: 400 Bad Request
② 中等:参数校验失败:status 字段不合法
③ 最好:参数校验失败:status 只能是 active 或 disabled,
你传入的是 "enabled"。请改为 active 后重试。
差别在哪?
| 质量 | 模型能做什么 |
|---|---|
| ① | 不知道错在哪,只能瞎猜或放弃 |
| ② | 知道哪个字段错了,但不知道正确值是什么 |
| ③ | 知道错在哪、错成什么、该怎么改 |
第三种能让模型一次修正;第二种往往要试两三次;第一种基本修不好。
四、好的错误信息的五个要素
| 要素 | 说明 | 示例 |
|---|---|---|
| 哪个字段 | 精确定位 | "status 字段" |
| 错在哪 | 说清违反了什么约束 | "不合法" |
| 约束是什么 | 给出合法范围 | "只能是 active 或 disabled" |
| 你传了什么 | 让它看到自己的输入 | "你传入的是 enabled" |
| 怎么改 | 给可执行的指示 | "请改为 active" |
第五个要素最容易被漏,但收益最高。 因为它把"推理"变成了"执行"——模型不需要想"我该改成什么",只需要照做。
一个反例
❌ 参数错误
问题是:它既不知道哪个参数错,也不知道该怎么改,只能重试一次(大概率再错一次)。
五、重试机制该怎么设计
失败回填不是"无限重试",需要三条边界:
| 边界 | 建议 | 理由 |
|---|---|---|
| 最大重试次数 | 2-3 次 | 超过说明是设计问题,不是运气问题 |
| 每次重试要带新信息 | 必须带上错误详情 | 不带就是重复同一个错误 |
| 失败要能优雅退出 | 明确告诉用户"这个我做不了" | 避免无意义消耗(2.2 终止条件) |
一个更重要的设计:把校验前移
失败回填是"事后纠错"。更省成本的是事前减少错误:
| 手段 | 效果 |
|---|---|
| 参数用枚举而非自由文本 | 大幅降低取值错误 |
| 参数描述给格式和示例(4.1) | 降低格式错误 |
| 必需信息缺失时先向用户提问 | 避免模型编造 |
| 降温度(2.4) | 降低拼写/格式漂移 |
这些手段的成本都比"重试"低。 优先做事前,其次才是事后。
六、一个完整的处理流程
把校验与回填串成流程:
① 模型返回 tool_calls
② 解析 arguments
├─ 解析失败 → 回填:"无法解析参数,请确保输出合法 JSON"
└─ 成功 → ③
③ 结构校验(必填、类型)
├─ 不通过 → 回填:"缺少必填字段 X" / "字段 Y 应为数字,收到字符串"
└─ 通过 → ④
④ 语义校验(枚举、范围、白名单)
├─ 不通过 → 回填:"Z 不在允许范围内(允许:A/B/C),你传的是 D"
└─ 通过 → ⑤
⑤ 权限与副作用分级
├─ 需要审批 → 发起人工确认(第 14 章)
└─ 不需要 → ⑥
⑥ 真的执行
├─ 执行失败 → 回填:可理解的错误 + 建议(2.5 第五节)
└─ 成功 → ⑦
⑦ 结果归一化(排序固定、去噪声)
⑧ 追加为 tool 消息,进入下一轮
这个流程里有五个回填点,每个都要求错误信息是"可行动的"。
七、常见误区
误区一:参数错了就重试一次,不用管。
不带具体错误的重试,大概率重复同一个错误。
误区二:校验只在工具实现里做。
校验应该离模型越近越好——在调用工具之前做,避免半途失败或部分执行。
误区三:错误信息越简短越好。
越简短越没用。错误信息是模型纠错的唯一依据,值得写得详细。
误区四:多加约束总是更安全。
过严的约束(比如字段长度 20-25 字)会导致频繁违规、反复重试。约束应覆盖"必须为真"的部分。
误区五:模型不会传系统级参数(路径、命令)。
它会的。任何接收这类参数的工具都必须白名单。
误区六:校验失败说明模型不行。
更可能是工具描述不清、schema 设计不合理。先查设计,再怀疑模型。
八、动手:错误信息的四档质量
同一个错误,四种写法,效果差一个量级。
① 最差:{"error": true}
② 差: {"error": "参数错误"}
③ 中: {"error": "status 不合法"}
④ 好: {"error": "status 只能是 active 或 disabled,
你传入的是 enabled。请改为 active 后重试。"}
| 档 | 模型能做什么 | 平均修复轮数 |
|---|---|---|
| ① | 只能重试瞎猜 | 3 轮以上,或修不好 |
| ② | 知道有错,不知在哪 | 2-3 轮 |
| ③ | 知道哪个字段错 | 1-2 轮 |
| ④ | 直接改对 | 1 轮 |
四档对应的五个要素
| 要素 | ① | ② | ③ | ④ |
|---|---|---|---|---|
| 哪个字段 | ✅ | ✅ | ||
| 错在哪 | ✅ | ✅ | ||
| 约束是什么 | ✅ | |||
| 你传了什么 | ✅ | |||
| 怎么改 | ✅ |
从 ③ 到 ④ 的关键是最后两项(传了什么、怎么改),而它们也最容易被省。
一个实践模板
写错误信息时直接套:
{字段} 只能是 {合法取值范围},你传入的是 {实际值}。请改为 {建议值}。
填空即可,一致性也有保证。
三条补充规则
| 规则 | 理由 |
|---|---|
| 错误信息要"面向下一次尝试" | 不是记录故障,是指导修复 |
| 不要把堆栈直接给模型 | 它读不懂,会瞎猜 |
| 业务错误与协议错误分开 | 前者给模型,后者给开发者(8.2) |
一个反例
差:{"error": "Traceback (most recent call last):
File "app.py", line 42, in execute
..."}
模型看到堆栈只会绕开这个工具,而不会修正参数。堆栈应该进日志(10.5),不进上下文。
九、一个实操:给工具层写单测
工具层必须能脱离模型单独测试(2.5 讲的架构约束)。三个必测项:
def test_tool_rejects_unknown():
"""未知工具应返回可用工具清单"""
r = execute_tool(call(name="nope"), registry, policy)
assert r.type == "error"
assert "可用工具" in r.message
def test_tool_validates_required():
"""缺必填参数应指出缺哪个"""
r = execute_tool(call(name="get_weather", args={}), registry, policy)
assert "缺少必填参数" in r.message
def test_tool_blocks_irreversible():
"""不可逆动作未审批应被拦下"""
r = execute_tool(call(name="delete_file", args={"path":"a"}), registry, policy)
assert r.type == "need_approval"
三个测试都跑在"无模型"环境下——这证明工具层是独立的。
还有一个必测项:越权
def test_path_whitelist():
r = execute_tool(call(name="read_file", args={"path":"../../etc/passwd"}),
registry, policy)
assert r.type == "error" and "不允许" in r.message
这一条最该有,因为路径穿越是最常见的实现漏洞(14.4 第六节)。
十、一个补充:校验失败时该"重试"还是"放弃"
4.3 讲了失败回填,但没讲什么时候该停止重试。
| 情况 | 处理 |
|---|---|
| 参数格式错(可修正) | 重试,最多 3 次 |
| 值不在枚举内(可修正) | 重试 |
| 缺乏必要信息(不可修正) | 放弃,向用户提问 |
| 权限不足(不可修正) | 放弃,说明原因 |
| 工具本身故障(不可修正) | 跳过,换方案 |
关键区分是"模型能否靠自己的信息修正":
参数拼错 → 它能改
缺少用户信息 → 它改不了,必须问人
如果不区分,会出现"反复重试同一种不可修正错误",浪费轮次并最终失败。
一个可实现的做法:在错误类型上加标记。
{"error": {...}, "retryable": false, "reason": "缺少必要信息"}
retryable: false 让循环立刻停止重试,转入"向用户提问"分支。
十一、一个收尾:把三件套变成可复用的模板库
概念讲完,最后一件事是让它可复用。
建议的模板库结构
prompts/
├── _base.md # 通用骨架(角色/目标/输出/约束/失败策略)
├── review.md # 审查类
├── extract.md # 抽取类
├── classify.md # 分类类
├── generate.md # 生成类
└── chat.md # 对话类
每类模板只需要改三处:角色、目标、输出格式。
为什么值得建库
| 收益 | 说明 |
|---|---|
| 一致性 | 同类任务的失败策略统一 |
| 可评测 | 改一处,所有使用它的任务都受益(第 13 章) |
| 新人可上手 | 不用从零写 |
最关键的是第二条:如果"允许说不知道"这句话写在模板里,就不需要每个任务单独记得写。
十二、一个补充:错误信息的自我一致性
如果同一个错误在两次运行里给出不同的提示,模型学不到规律。
| 问题 | 后果 |
|---|---|
| 错误文案随机变化 | 模型在两次里学到不同"线索" |
| 同一字段的错误措辞不一致 | 增加理解成本 |
做法:错误信息由代码模板生成,不由模型生成。
def invalid(field, allowed, given, hint=None):
return (f"{field} 只能是 {allowed},你传入的是 {given}。"
+ (f"请改为 {hint}。" if hint else f"请从 {allowed} 中选择。"))
统一的错误文案有几个好处:
| 好处 | 说明 |
|---|---|
| 可预测 | 模型能形成稳定理解 |
| 可测试 | 可以断言错误文案 |
| 可统计 | 能按错误类型聚合(10.5) |
这也顺便解决了"错误信息漏要素"的问题——模板里要素齐全,填空即可。
十三、一个补充:把校验做成"可复用的校验器"
三个常见校验器写得好的样子。
def check_enum(value, allowed, field):
if value not in allowed:
return f"{field} 只能是 {' / '.join(allowed)},你传入的是 {value}。"
return None
def check_path_in(value, roots, field="path"):
real = os.path.realpath(value) # 规范化(防 ../ 穿越)
if not any(real.startswith(os.path.realpath(r)) for r in roots):
return f"{field} 必须位于 {roots} 内,你传入的是 {value}。"
return None
def check_int_range(value, lo, hi, field):
if not isinstance(value, int) or not (lo <= value <= hi):
return f"{field} 需为 {lo}-{hi} 的整数,你传入的是 {value}。"
return None
三个共性:
| 共性 | 作用 |
|---|---|
返回 None 表示通过 |
便于链式调用 |
| 错误信息里带"你传入的是" | 模型能对照自己传的值 |
| 入参用白名单而非黑名单 | 默认拒绝(10.3) |
中间那条最容易被忽略,但它直接决定模型能否一次改对(4.3 四档质量)。
十四、下一篇
参数对了,工具也执行了。但这只是"单个动作正确"。
真正难的是:如何设计一个让人(模型)用得顺手、不容易出错、出错也能理解的工具集。这个问题的答案不在编程规范里,而在人机交互设计里。
下一篇讲 ACI——把工具当成"给人用的界面"来设计。