有个退款函数在重构时改名成refund_order_v2,为了不让模型侧感知变化,同事顺手加了name_override="refund"。代码评审很快过了,上线后第二天,审批系统报表里高危退款的人工复核数掉到零。查下去才知道,审批表的键还是函数名refund_order_v2,运行时传过来的却是refund,查表落空,走了默认放行。
这类事故的根子是一个工具同时有两个名字。函数在 Python 里叫一个名字,模型、trace、停机名单、审批钩子看到的是另一个。两个名字各自都对,配置却分别读了不同的那个。
本机openai-agents 0.23.1的function_tool签名里有name_override: str | None和description_override: str | None两个参数。源码注释写得很直白,前者提供时替代函数名作为工具名,后者提供时替代从 docstring 推出的描述。我在本机构造了一个带 docstring 的函数并加上两个覆盖,读回tool.name得到refund,tool.description得到覆盖文本,原 docstring 完全不出现在 schema 里。没加覆盖的lookup_order则直接用函数名。
图注 左侧为命名覆盖必须守住的契约,右侧为 smoke 种植的五类串名。
正确写法先钉死
fromagentsimportAgent,StopAtTools,function_tool@function_tool(name_override="refund",description_override="退款。仅限已核验订单。",)defrefund_order_v2(order_id:str)->str:returnf"refunded:{order_id}"VISIBLE={refund_order_v2.name}# {"refund"}STOP=["refund"]APPROVAL={"refund":"high_risk"}assertset(STOP)<=VISIBLEandset(APPROVAL)<=VISIBLE agent=Agent(name="billing",instructions="需要退款时调用 refund。",tools=[refund_order_v2],tool_use_behavior=StopAtTools(stop_at_tool_names=STOP),)要点只有一条。所有控制面一律从tool.name取值,禁止手抄字符串。停机名单、审批表、启用开关、提示词里点名的工具、trace 过滤器,都应该由装配器从工具对象上读出来,再生成配置。函数名只在 Python 代码里有意义,出了进程就别再用它。
描述覆盖同样要管。description_override一旦写上,docstring 改了也不会进 schema。代码作者以为改了 docstring 就更新了能力说明,模型看到的仍是旧覆盖文本。两边必须挑一个做唯一来源。
离线 smoke 结果
P0 PASS 可见名取 override P1 PASS 描述取 override,docstring 被替换 P2 PASS 停机名单 ⊆ 可见名 F1 FAIL 种植:名单写实现名,该停不停 F2 FAIL 种植:审批表按旧名,高危落入默认放行 F3 FAIL 种植:开关读实现名,能力静默丢失 F4 FAIL 种植:提示点旧名,模型调用不存在工具 F5 FAIL 种植:两环境 override 表不同版本脚本在_w/name-override-smoke/,用真实的function_tool构造工具对象,读回name与description写进facts.json,再用纯数据断言模拟各控制面查表,结果落到name_fail.csv。不连模型,不花钱,几秒跑完。
五类失败样本
F1 停机名单写实现名。StopAtTools(stop_at_tool_names=["refund_order_v2"]),运行时按工具的name做成员判断,refund永远不在名单里。退款调完后又多跑一轮模型,日志看起来像模型多嘴。这和上一篇讲的命名停机是同一个坑,触发原因换成了覆盖名。
F2 审批表按旧名放行。开头那个事故。审批钩子拿到refund,表里只有refund_order_v2,没有命中就走默认档。默认档如果是放行,高危工具就裸奔了。好路径是审批表的键在 CI 里做子集断言,并且默认档设成拒绝,查不到的工具名直接拦下转人工。
F3 开关读错名字静默丢能力。灰度开关配置写着enabled: [refund_order_v2],is_enabled回调按tool.name查,结果永远为假,退款工具本轮不可见。模型拿不到工具,只好安抚用户,监控里没有报错,只有退款量悄悄归零。
F4 提示词点旧名。instructions 里还写着「需要时调用 refund_order_v2」。模型照着提示去调一个 schema 里不存在的名字,轻则报工具不存在,重则模型自己编一个参数结构去调相近的工具。描述覆盖后能力说明变了,提示词里还在讲旧能力,也属于这一类。
F5 两环境覆盖表漂移。预发的 override 写在代码里,生产从配置中心读,后者还是旧版本。同一份代码在两个环境里给模型的名字不同,复现事故时怎么也对不上。覆盖表必须随代码版本走,配置中心只能读不能改它。
生产禁区表
| 编号 | 禁区 | smoke 证据 | 替代做法 |
|---|---|---|---|
| D1 | 停机名单写函数名 | F1 | 从tool.name生成名单 |
| D2 | 审批表按旧名且默认放行 | F2 | 子集断言,默认拒绝 |
| D3 | 开关回调读实现名 | F3 | 开关键同样取tool.name |
| D4 | 提示词点名与可见名分裂 | F4 | 提示词模板插值工具名 |
| D5 | 覆盖表跨环境不同版本 | F5 | override 随代码发布 |
| D6 | 把展示名当计费或权限主键 | (对照) | 另设稳定 id 字段 |
D6 单独说一句。覆盖名的用途是给模型看的,产品经理随时可能为了提示效果把refund改成issue_refund。计费、权限、审计如果拿它当主键,一次改名就会把历史数据拆成两段。稳定主键应该在工具注册表里另设,和展示名分开存。我见过的做法是注册表里每个工具有三列,函数限定名、模型可见名、稳定业务 id。账单按业务 id 汇总,trace 里三列都记,改展示名只动第二列,历史报表不受影响。描述覆盖也建议带一个版本号写进 trace,事后复盘时能知道当时模型看到的到底是哪一版能力说明。
什么时候值得用覆盖
覆盖有它的正当用途。函数名受 Python 命名规则限制,或者团队里有版本后缀,模型侧却需要一个短而稳定的名字,这时用name_override很合适。同一个实现要以两种语气暴露给不同 Agent,也可以各包一层,分别覆盖描述。
不适合的场景是用覆盖来遮掩重构。重构时改了函数名,又不想动下游配置,于是加一个覆盖维持旧可见名。短期确实省事,长期仓库里会积累一批「函数名和工具名对不上」的工具,新人读代码时根本不知道该搜哪个字符串。这种情况宁可一次把配置迁过去。
日志里怎么认出串名
串名事故几乎不会抛异常。最常见的三种长相,一是停机计数为零但工具调用正常,二是审批复核量突然掉到零,三是某个工具的调用量归零而用户投诉增加。看到其中任何一种,先把本轮可见工具名列表和各控制面配置并排对比,再去怀疑模型。
建议在 Runner 钩子里把可见工具名、函数限定名、停机名单、审批表键集合一起写进 trace 属性。四列放在一起,串名一眼就能看出来。
预发检查
- 仓库搜
name_override,逐个确认对应函数名没有出现在任何配置文件里。 - 停机名单、审批表、启用开关、提示词点名四处做子集断言,全部 ⊆ 可见名。
- 审批默认档设为拒绝,查不到的工具名进人工队列。
description_override存在时,docstring 里标注「描述以覆盖为准」,避免两处各改各的。- 升级 SDK 后复跑 smoke,确认
tool.name仍取覆盖值。
和 tool_choice、is_enabled 的交叉
ModelSettings.tool_choice如果钉成某个具体工具,填的也必须是覆盖后的名字。填函数名时,请求会带上一个 schema 里不存在的名字,报错发生在模型接口那一侧,排查时很容易误判成接口抖动。is_enabled回调拿到的是工具对象本身,从对象上取name去查开关表,比在回调里写死字符串可靠得多。
三处配置同一天改动时最危险。比如运营要求把退款工具对外改名,开发改了覆盖,忘了改tool_choice,测试只覆盖了正常对话,没有覆盖强制点名的那条链路。上线后只有走强制点名的那类会话出错,比例不高,告警阈值也不会触发。预发回归必须把强制点名、开关关闭、停机命中三条链路都跑一遍。
测试替身怎么写
不需要真模型。直接用function_tool装饰真实函数,拿到工具对象,读name和description,这一步本身就是对 SDK 行为的回归。随后把各控制面配置当成纯数据加载进来,逐个做集合比较。夹具至少包含正确覆盖、函数名残留、描述覆盖与 docstring 不一致、两环境覆盖表不同四种。每条夹具写一行期望结果,进 CSV,评审时和代码并排看。
端到端测试可以再加一层假模型,让它按提示词里点的名字发起调用,断言调用名在可见名集合里。这一层能抓住 F4,纯数据断言抓不到模型是否真的会照提示去调旧名。
旧仓库怎么迁
已经堆了一批覆盖名的仓库,先跑一遍扫描,列出所有带name_override的工具,把函数名、覆盖名、出现过该字符串的配置文件路径打成一张表。函数名在配置里出现的条目,就是潜在的 F1 到 F3。按审批相关、停机相关、普通工具的顺序逐批修,每修一批跑一次 smoke。不要一次性全改,审批表的默认档在修完之前先改成拒绝,宁可多转几单人工。
小结
name_override和description_override改的是模型能看到的那一面,函数本身不受影响。生产事故全部出在控制面读错了名字。把所有配置从工具对象上生成,审批默认拒绝,覆盖表跟代码一起发布,再用一份带五类种植错的 smoke 守住,命名分裂就很难溜进线上。本目录的 CSV 和 JSON 可以直接附进 code review。