模型给的参数别照单全收:工具入口怎么拦住类型、范围和越权路径
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
参数填错这件事,绝大多数人第一反应是去改工具的描述文字,或者换个更强的模型,这两个方向都是把因归错了。 模型输出的参数本质上是一段生成结果,它没有类型系统,没有你的数据库主键,不知道你的目录边界,也不知道你的时间戳是秒还是毫秒。它填对了是概率,填错了是常态。真正能收敛这类故障的位置只有一个:工具函数的第一行。你在那儿写不写校验,决定了这个错是变成一条清晰的报错回给模型让它自己改,还是变成一次删错文件、一次全表扫描、一次金额少两位数的转账。
这篇只谈参数进入工具之后的那几厘米。工具本身给多了、账号权限太宽,是另一回事,那部分在 给 agent 全放开权限之后 里讲;模型压根选错了工具、该读文件却去调了搜索,属于选择层的问题,看 Agent 选错工具、反复调同一个工具。本篇假定工具选对了、权限也给对了,参数依然是脏的——这时候怎么排。
一、先分因:参数错其实是四种病
把它们分开,是因为处置动作完全不同。混在一起查,你会一直在改描述文字,改到最后也不收敛。
第一种是类型错。 模型把数字写成了字符串,把布尔写成了 "false" 这样的字符串,把数组写成了逗号拼接的一整行文本,或者该给 null 的地方给了空字符串。这类错最阴险的地方是很多语言不会立刻炸:Python 里 "false" 是真值,JavaScript 里 "3" * 2 等于 6 但 "3" + 2 等于 "32"。你的工具照常返回成功,错误一路流到下游。
第二种是范围错。 类型对,值离谱。分页的条数填成了几万,时间区间跨了好几年,重试次数填成了负数,超时填成了 0。这类参数模型没有物理直觉,它只是照着字段名生成一个”看起来合理”的数。触发的后果通常是慢查询、限流(对面回 429)或者干脆把下游打挂(回 500)。
第三种是路径与标识越权。 文件路径带 ../ 往上跳,或者直接给了一个绝对路径;数据库操作里的租户 ID、用户 ID 被填成了另一个人的;对象存储的 key 前缀被抹掉了。这类是安全问题,不是质量问题,判别标准也不一样——不能等出事再看日志。
第四种是语义张冠李戴。 每个字段单独看都合法,合起来是错的。最典型的是把展示名当主键传(用户在对话里说的是”张三的订单”,模型就把 张三 填进了 user_id),把秒级时间戳填进毫秒字段,把金额的单位从分当成了元。这类校验器往往拦不住,只能靠约束设计和取值来源限定。
四种病的共同点是:模型不知道自己错了。它拿不到反馈就不会改。所以排查的第一步永远是——让工具报错,而不是让工具容错。
二、判别表:从现象反推成因
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 工具返回成功,但结果明显不对,下游数据脏 | 类型错被隐式转换吞掉了 | 在工具入口把原始参数原样打一条日志(含 type()),对比声明的类型 | 加严格类型校验,拒绝隐式转换,错了直接抛结构化错误 |
| 调用偶尔超时、下游偶发 429 或 500 | 范围错,某次生成的数值过大 | 按参数值分桶统计调用耗时,看长尾那几次的入参 | 给每个数值字段加上下界并做钳制,超界返回错误而非静默截断 |
| 同一个任务重跑,有时对有时错 | 结构化输出本身不稳定 | 固定输入连跑十次,统计参数字段的差异率 | 收紧输出约束,参考 要 JSON 却给了带包裹的文本,结构化输出 |
| 日志里出现本不该访问的目录或文件 | 路径越权 | 在入口把入参路径和真实解析后的路径都打出来,比对是否仍在根目录内 | 强制根目录归一化校验,越界直接拒绝并告警 |
| 报错提示对象不存在,但对象肉眼可见 | 标识张冠李戴,传的是展示名不是主键 | 打印实际用于查询的标识值 | 字段改名带上语义后缀,并要求先调查询工具拿到真实 ID |
| 权限相关拒绝(401/403)集中在某类调用 | 身份或租户字段被模型自行填写了 | 检查该字段是不是暴露给了模型 | 从参数里删掉这个字段,改由服务端从会话上下文注入 |
| 报错信息回给模型后它反复用同样的参数重试 | 错误信息没写清哪个字段错、期望什么 | 看模型收到的错误文本原文 | 改成机器可读的错误:字段名、收到的值、期望的约束 |
这张表的用法是:先定位到行,再做那一行的验证动作,别跳过验证直接改代码。参数类故障最容易的误判是”我以为是范围错,其实是类型错导致比较失效”。
三、在入口建三道闸
顺序有讲究,从便宜到贵。
第一道,模式校验,卡类型和形状。 用 JSON Schema 或者语言里的数据类做,关键是三件事:把 additionalProperties 关掉(模型很爱多塞字段),枚举类字段一律用 enum 而不是自由字符串(模型会编出你没定义的状态值),所有数值字段写明 minimum / maximum,字符串写明 maxLength。
关掉额外字段这条经常被跳过,代价是模型某次多塞了一个 force: true,而你的实现恰好把整个参数字典透传给了下游。
第二道,语义校验,卡业务范围。 模式校验只知道”是个整数”,不知道”这个租户下最多只有几百条记录”。这一层要查的是:时间区间是不是首尾颠倒,分页游标是不是属于当前查询,ID 是不是存在且属于当前会话可见的范围。这层查询有成本,所以放在第二道。
第三道,路径与身份闸。 这道必须自己写,不能指望框架。文件路径的通用做法是归一化后比对根目录:
import os
class PathOutOfRoot(Exception):
pass
def resolve_in_root(root: str, user_path: str) -> str:
root = os.path.realpath(root)
target = os.path.realpath(os.path.join(root, user_path))
try:
inside = os.path.commonpath([root, target]) == root
except ValueError:
# 不同盘符 / 无公共前缀,commonpath 直接抛异常,一律按越界处理
inside = False
if not inside:
raise PathOutOfRoot(f"path escapes root: {user_path!r}")
return target
两个容易被忽略的点:os.path.join(root, "/etc/passwd") 的结果是 /etc/passwd,绝对路径会直接覆盖前面的根,所以必须做 realpath 之后的比对而不是简单的字符串前缀判断;Windows 上如果两个路径落在不同盘符,commonpath 会直接抛 ValueError,上面那段 try 就是为它准备的——不接住,这个异常会冒泡成一次 500,看日志的人会以为服务挂了而不是有人在探路。另外注意异常类型要用自定义的越界异常,而不是复用 ValueError:上层要能把”越界”和普通参数格式错分开处置,前者该告警,后者只需回给模型改。软链接也归 realpath 管,这正是它比字符串拼接可靠的原因。
身份类字段的原则更简单:凡是服务端能自己确定的,就不要出现在参数表里。 当前用户 ID、租户 ID、环境标识、操作者身份,这些统统由服务端从会话里取。你把它们放进参数表,等于把越权的按钮递到模型手上,而模型对越权没有任何抵抗力——上游对话里只要有另一个人的名字,它就有概率填进去。这一条和权限收敛是配套的,只做一边补不住。
三道闸全过之后再执行副作用。执行前如果是不可逆动作(删除、发布、转账、覆盖写),把归一化后的最终参数摆出来让人确认一次,这个取舍在 Agent 里的人工介入怎么设计 里有更细的分级。
四、错误信息怎么写,模型才改得动
这一节单列,因为它是投入产出比最高的一块,而且几乎所有人第一版都写错了。
工具抛出的错误不是给人看的,是给模型看的。人看到 ValidationError: invalid input 能去翻代码,模型看到这句只会原样重试。有用的错误至少包含四段:哪个字段、收到的值是什么、期望的约束是什么、下一步该怎么办。
比如把 limit 填成了几万,好的返回是这样一句:字段 limit 收到的值超出允许范围,允许的是 1 到某个上限之间的整数,请改小后重试。再比如 ID 张冠李戴,好的返回是:字段 order_id 需要的是订单主键,收到的看起来是一个人名,请先调用查询工具按姓名换取订单主键。
第二句里那半句”请先调用查询工具”是关键。你在错误里指了路,模型下一轮的成功率会明显不一样;你只说错了,它就在原地打转。这也是判断一个工具设计好不好的快捷标准,更系统的取舍看 给 Agent 设计工具。
还有一个反模式要点名:不要在校验失败时静默纠正。你觉得”帮它把字符串转成整数挺贴心”,实际后果是模型永远学不到正确格式,而且某天它传了 "3.7",你的转换悄悄截断成 3,这个错会一路藏到生产。纠正动作要么改成明确报错,要么在纠正的同时明确记录并告警,绝不能悄悄发生。
五、什么情况下别再折腾
参数校验这条路有明确的止损点,超过就该换路子,别死磕。
止损点一:同一个字段你已经改了三轮约束描述,错误率还在两成以上。 这说明问题不在描述,在字段本身太自由。把它从自由输入改成枚举,或者拆成两步调用(先查后写),比继续雕琢文字有效得多。
止损点二:为了修一个参数错,你开始往工具里加分支逻辑。 一旦工具入口出现”如果模型传了 A 就当成 B”这种代码,技术债就开始滚了。此时正确的动作是拆工具:把那个歧义场景独立成一个签名更窄的工具。
止损点三:出现过任何一次越权访问或不可逆误操作。 这时不是继续调校验的时机,而是先收权。把这条链路上的写权限降到只读,跑通验证再逐步放开。校验是防错,权限是防灾,防灾没做好之前不要花时间打磨防错。
回滚点判断:如果你为了修参数问题改动了工具签名,而线上已经有在跑的任务依赖旧签名,先看变更范围再决定。
git log --oneline -10 -- path/to/tools/
git diff HEAD~1 -- path/to/tools/
git revert <commit>
用 revert 而不是 reset,历史留痕,出问题能查。改签名这类变更宁可新增一个工具名并行跑一段,也不要原地改——原地改会让所有历史对话里的调用记录变成错的示例,而这些记录还在上下文里影响模型的下一次生成。
换条路的信号:某个工具的参数复杂到需要模型同时填五六个互相约束的字段,这就不该由模型直接填了。改成模型只给一句意图,你自己写一段确定性代码把意图翻译成参数。确定性的部分交给代码,这是工程判断,不是模型能力问题。
六、避坑清单
坑一:把校验写在工具外面的编排层。 为什么会踩——编排层看起来更集中,改一处全生效。怎么避——校验必须贴在工具函数第一行,因为工具往往有多个调用入口(重试、人工触发、其他服务),编排层的校验会被绕过。集中的是校验规则的定义,不是执行位置。
坑二:只校验第一次调用,重试路径没校验。 为什么会踩——重试逻辑通常是复制的参数对象,写代码时默认它已经干净了。怎么避——校验函数做成幂等的纯函数,重试入口照样调一遍,成本可以忽略。
坑三:用字符串前缀判断路径是否越界。 为什么会踩——直觉上 path.startswith(root) 就够了。怎么避——../ 和软链接都能绕过前缀判断,必须先 realpath 再比对,而且比对的是路径组件而不是字符前缀(/data/app 和 /data/app-evil 的前缀是匹配的)。
坑四:把校验错误当异常抛到最外层,模型只看到一句调用失败。 为什么会踩——框架默认的异常处理会把细节吞掉,只回一个通用失败。怎么避——工具内部捕获校验异常,转成结构化的失败返回值,确保字段名和约束能传回模型。
坑五:时间和金额不带单位。 为什么会踩——字段名写 timeout、amount 看着很自然。怎么避——字段名直接带单位:timeout_ms、amount_cents。模型对字段名极其敏感,改个名字比写十行说明管用。金额还要额外一条,全链路用整数最小单位,别让浮点参与运算。
坑六:允许模型传原始查询语句或原始命令。 为什么会踩——图省事,一个字段解决所有场景。怎么避——参数化,模型只填占位符对应的值,语句结构由你的代码固定。这条没有妥协余地,一旦开了口子,后面所有校验都是摆设。
坑七:拿海外工具做兜底方案。 为什么会踩——排查到后期容易想”换个别的服务试试”。怎么避——部分海外模型与工具服务的官方条款对中国大陆有区域限制、不支持直连,把它放进生产链路的兜底位置本身就是风险;市面上存在第三方中转,但可用性和合规性都由你自己承担,不要写进架构图当作稳定依赖。
收束
参数校验不是防御性编程的洁癖,它是把模型的不确定性挡在系统边界之外的唯一手段。模型会一直填错,这不会因为版本更新而消失,你能控制的只有错误发生之后系统怎么反应——是清晰地拒绝并告诉它怎么改,还是默默地把脏数据放进去。
上线前对每个工具过一遍这份自检:
- 参数模式是否关闭了额外字段,枚举字段是否用了
enum。 - 每个数值字段是否有上下界,每个字符串是否有长度上限。
- 身份、租户、环境这类字段是否已经从参数表里移除,改由服务端注入。
- 路径参数是否做了
realpath后的根目录比对,跨盘符和软链接是否按越界处理。 - 校验失败的返回里,是否同时包含字段名、收到的值、期望约束、下一步动作。
- 是否存在静默纠正的代码,有的话全部改成显式报错或显式告警。
- 重试入口是否也走了同一套校验。
- 不可逆动作执行前,展示给人确认的是不是归一化之后的最终参数。
八条里最先做的是第三条和第四条,它们防的是灾;剩下的防的是错。顺序别搞反。