实习日志 / 中文 / 代码审计 / 到底哪有那么多失败场景:清理静默兜底
到底哪有那么多失败场景:清理静默兜底
中文 | English
10 月 3 日,CI 审计做完的同一天,我让 Agent 对 origin/main 做一次冗余和死代码审计。第一版清单分了五组:完全没有引用的代码、只有测试在调用的代码、被新版本取代却还在主路径上的旧版本、重复实现、已经坏掉没人发现的测试。
最扎眼的是第三组。线上 config/model-runtime-bindings.json 里 4 个 profile 全部指向 northwind-fnol-claimant-v8,可代码里 v6 和 v7 仍然是默认值:
# backend/core/config.py
model_prompt_version = 'northwind-fnol-claimant-v6' # 没设 MODEL_PROMPT_VERSION 时
# backend/services/prompt_composer.py
'v8' if prompt_version == CLAIMANT_V8_PROMPT_ID else 'v7' # 任何不是 v8 的值都静默选中 v7 包
model_agent.py 里还躺着一段约 320 行的 v6 分支,线上走不到,也没有断言挡着。具体的后果是:本地 .env 少写一行,后端照样起来,页面照样能聊,模型拿到的却是 9 月中旬的 v6 指令,行为和 demo 完全两样,排查的人很难想到这一层。
向前兼容是 Codex 这类编程 Agent 的一个坏习惯。到底哪有那么多失败场景?何况这些兜底还是静默的。
证据只能来自代码
第一版里有一个脚本因为"README 有说明"被判成不算死代码,还有几项的依据是"除了一篇 docs 没有别处提到"。这站不住。Agent 改代码的时候同样有权改文档,在一个人人用 Agent 的仓库里,文档和代码是同一批手写的,文档说某个东西还在用,只能说明某一次提交时有人这样写过。代码还用不用,只能由代码本身回答;要删的东西,追到引入它的 PR,确认删除真的安全。
第二组"只有测试在调用的生产代码"也不能一删了之。只服务测试的代码,如果没法直接并进测试文件,说明它本身可能还有别的作用。比如 assert_retry_is_permitted,93 行的"重试是否允许"判定,真实的重试路径没调用它。这要么说明重试路径漏了一道校验,要么说明校验已经在别处重写了,两种都要先看清楚才能动。所以第二版把这一组整体放下,不在这次清理里处理。
按职责重搜
第二版换了方法。每一项先追到引入它的 PR,再追到最后一个调用方消失的 commit,确认删掉它是安全的,不再拿文档当证据。第一版只搜了带版本号的字符串,第二版按职责重搜:对 backend 全部函数做 AST 结构比对,找同一职责写了两遍的地方;逐行读了 238 处带 fallback|legacy|compat|shadow|deprecated|backward|predate|degrad 的标记,把配置回滚、"S3-compatible"这类正常功能排除掉;扫了所有既不报错也不记日志的 except;查了所有带默认值的运行配置环境变量。
运行时最危险的一类是"不配置就悄悄用旧实现":
| # | 位置 | 不配置时实际发生什么 |
|---|---|---|
| C1 | config.py | 不设 AGENT_RUNTIME_PROFILE,跑的是按关键词回话的 ControlledAgent,根本不调模型 |
| C1b | docker-compose.yml | 交付用的 compose 写成 ${AGENT_RUNTIME_PROFILE:-controlled} |
| C2 | config.py | 不设 MODEL_PROMPT_VERSION,用 v6 prompt |
| C3 | model_agent.py、prompt_composer.py 等 6 处 | 缺 runtime_policy 或版本不是 v8,静默改用 v7 |
| C4 | model_agent.py | 不是 v7/v8 就走 v6 单体 prompt,附带一个吞掉一切异常的 _shadow_v7_plan |
| C5 | model_gateway.py | 没有 Release Set 时,从环境变量拼一份旧式模型配置 |
| E1 | config.py | 不设 DATA_RUNTIME_PROFILE,默认 fixture:内存仓储,重启即丢 |
这些默认值有的从 8 月的原型期就在,有的是 v6 到 v8 几次换代时留下的:每次换代,上一版都被顺手留成了"缺省时的退路"。
模型路径本身是 fail closed 的,FeatureControlledAgent 的兜底是 UnavailableAgent,不会悄悄降级。问题全在"什么都没配时用什么"。现行的启动脚本 start-dev.ps1、.env.example 和 compose 都显式写了配置,这些默认值平时根本不生效,也正因为平时不生效,它们一旦生效就没人察觉。
版本迭代本来就是为了替换掉旧的,缺了配置就退回上一版,等于把问题藏起来。改法很简单:缺配置就拒绝启动,报错里写明缺哪一项、应该填什么。配置问题在启动的那一刻暴露出来,比靠退回旧版本把它藏起来更健壮。
兼容层的上游
第二类是兼容层:接受旧形状的模型输出、旧动作码和旧工具名的翻译表、只为旧 import 名留的别名。我当时的判断是兼容层本身还有用,但它存在的原因是旧代码还在吐旧字段。那清理的办法就是去找吐旧字段的上游:
| 兼容层 | 翻译的是什么 | 上游还在吗 |
|---|---|---|
model_agent.py 处理 service_offer_ids | 模型回复里的旧字段名 | 不在了,回复格式和 prompt 里都已没有这个字段 |
property 当 home 的别名 | 旧的理赔类别名 | 不在了 |
recovery_policy_registry.py 的旧名字别名 | 旧 import 名 | 不在了,全仓没人用旧名 import |
旧动作码翻译表、旧工具名翻译表、controlled_turn_compatibility.py | 旧的动作名和工具名 | 还在,吐旧名字的就是 ControlledAgent |
上游没了的,直接删;上游还在的,要先改或退役上游,再在同一个 PR 里删兼容层,只删兼容层会直接报错。ControlledAgent 是个麻烦:几乎所有对话测试都拿它当假模型。我的决定是"关键词机器人就先留着吧",它那一套兼容层也一起留着,这次不动。
旧接口是同一个思路。永远回 "I received your claim." 的 POST /api/claims/message,三个前端都不用,直接删。不带 revision 也允许改个人资料的那条宽松路径就不能直接删:客户端 useProfileController.js 自己写着"账户没有 revision 就不带版本号提交",前端留着旧路,后端就只能一直开着这个口子,两个人同时改资料就可能互相覆盖。所以先改前端始终带版本号,再删后端。
删之前先看清它在做什么
prompt_projection 这个函数被判成孤儿,第一轮的解释是:#1092 以后,"这一轮能填哪些字段"不再写进提示词,改为写进回复必须遵守的 schema,模型只能填 schema 里列出的字段,所以新做法更可靠,旧函数没有修的价值。这个解释经不起核对。对着代码和提交重新查,时间线是:9 月 29 日 #1092 把 prompt_projection 从提示词里拿掉,从那天起模型看不到每个字段的类型和可选值;10 月 1 日 #1159 发现了这个问题,提交标题就是 "give v8 turns their enum values",新写了一个 writable_fields_projection 把清单放回提示词;同一个 PR 把回复格式改成固定答题卡,schema 列出所有可写字段但不再按这一轮收紧,哪些字段这一轮能填、值对不对,由 Runtime 收到回复后逐条检查,不合格的那一条丢掉并记录。
所以实际的分工是:提示词告诉模型能填什么,由模型判断;schema 只保证结构完整;Runtime 事后检查。prompt_projection 的结论从"删"改成了"去重",它是 writable_fields_projection 的旧版重复。
前端自己算进度那一项,第一轮建议直接删:报案人页面的"已完成 x / 共 y 项",后端没给时前端自己数一遍表单,等于前端又写了一份"什么算完成"的规则。方向没错,但删前端的前提是后端已经有对应的规则;后端还有缺口就删前端,空档会直接落到报案人眼前。查下来后端确实有缺口。字段评估会在发消息、改表单、上传证据等二十多种事件后触发,全仓搜 recomputation_reason,唯独没有 claim_created。新理赔单从创建到发出第一条消息之间,后端给不出进度,前端那段自算正好补着这个空档。顺序于是改成:先让后端在建单时也跑一次字段评估,再删前端。
管理后台"创建发布配置"的表单里,"运行环境"一栏打开时预选了 fixture。不设默认值、空着逼人选也不对,默认应该是服务器当前正在跑的那一个。后端的 GET /health/ready 本来就返回当前的 data_runtime_profile,页面打开时读一次填上就行。
一个手动改的参数
fixture 原本是为测试和原型期设计的模拟,后来长期被当成正式实现在用,除了测试已经不该存在。涉及的有:内存仓储、内存身份库、内存知识库,以及 app.py 里 if profile is not FIXTURE 这类让 fixture 跳过正式组件的分支。
开发者模式是同一类问题:启动时靠一个手动改的参数区分开发环境和业务环境。这个开关捆了三件不相干的事:认几个固定的假令牌(Bearer synthetic-claimant、synthetic-staff、synthetic-admin)不用登录;客户和员工账号改存内存;管理后台的 7 个存储全部改存内存。它只在 NORTHWIND_ENVIRONMENT 为 development 或 test 时允许打开,可这个环境变量不设时默认就是 development。服务器上忘了设环境,又误开了开发者模式,固定假令牌就能直接登录。这和"缺配置就悄悄用默认值"是同一类问题,只是落在了身份上。
整个启动其实有四个开关,各带一个默认值:
NORTHWIND_ENVIRONMENT 默认 development
NORTHWIND_IDENTITY_MODE developer 时启用假令牌和内存存储
DATA_RUNTIME_PROFILE 默认 fixture
AGENT_RUNTIME_PROFILE 默认 controlled
漏设任何一个都悄悄生效。环境确实要显式指定,程序猜不出自己跑在哪;但开关只该有一个,而且没有默认值,不设就拒绝启动。假令牌、内存存储这些测试专用的东西,改由测试自己在 conftest.py 里注入,它本来就在注入 FixtureRepository。
员工账号也一并处理。我们没做员工注册,只允许现有账号登录,所以员工账号要持久化在数据库里,启动时输出。员工账号在正常模式下其实已经存在 SQLite 里,缺口是新克隆的环境一个员工账号都没有,启动时也不打印能用哪个账号。方案是仓库里放一份员工种子清单,启动时库里没有就建上;密码不进仓库,从 .env 读,start-dev.ps1 第一次启动时生成;启动时打印邮箱、显示名和角色,密码本身不打,因为库里只有哈希,日志也会进 CI 输出和终端历史。
最后拆成三个 PR,按风险从小到大:先删上游已不存在的兼容层和死代码,旧 prompt 和 bindings 归档;再去掉所有兜底,缺配置拒绝启动;最后把开发者模式和 fixture 移出运行时,补员工种子账号和建单时的字段评估。后两个会改启动方式,合并后所有人的本地 .env 都要补上新的必填项。
复核也一样
同一天还处理了另一条兜底。回复复核那时已经换成了一个小模型当裁判(Haiku 4.5,走 OpenRouter),检查这一轮回复有没有说错话,比如口头推销服务、声称已经转人工。裁判不运行时,系统退回 reply_consistency.py 里的一组英文正则。触发条件有三种:没配裁判模型;一轮已用时间超过 20 秒,剩下的不够一次复核调用;复核调用出错或返回不完整。
#1192 的 review 追问了这条路径,当时的说法是查询回合经常因为时间不够退到正则。我们把 10 月 1 日到 3 日本地真实模型运行的全部后端日志按回合切开,统计每回合发往 OpenRouter 的请求数和主模型调用数:
| 指标 | 结果 |
|---|---|
| 客户回合数 | 149 |
| 其中运行了复核模型 | 149 |
| 带工具续调的查询回合 | 14,全部运行了复核 |
| 回合用时 | 中位 6.6 秒,P90 11.8 秒,最长 15.2 秒 |
| 超过 20 秒的回合 | 0 |
退到正则一次都没发生,"经常这样"没有依据。按"哪有那么多失败场景"的逻辑,这条兜底几乎用不上;可它一旦触发,就是用字面匹配代替语义判断,比如"我把你的问题 passed 给理赔团队"会被当成"声称已转人工"。很少触发不构成保留它的理由。
于是定下来:复核不再因为时间被跳过,它有自己 20 秒的超时,主调用完成后照常运行;复核模型不可用时,回复放行,记一条"本轮未复核"和原因,不退回、不进恢复;兜底正则整组删掉;每个回合记下 reviewed、skipped 或 failed,以后要统计不必再从日志里间接推算。
时间解析是同一个毛病的另一种样子。模型最初拿不到当前时间,"20 分钟前"被存成 PT20M、current_approx,Runtime 就写了一段正则自己去解析。后来报案时刻 reported_at_local 进了模型的上下文,那段正则还留着。获取时间本来就是一个小函数的事,用不着正则和复核。正则删掉,模型自己换算,Runtime 只检查格式和范围,不晚于现在,不早于两年前,检查不过就存客户原话,精度标成"约"。
写进规则
审计最后建议在 AGENT.md 里写三条,对所有人和所有 Agent 都适用:
- 运行配置缺失时禁止默认回退;
- 带版本号的产物换代后,旧版必须归档,代码不得引用;
- 禁止为"旧调用方"保留别名或兼容层,除非 PR 写明调用方是谁、何时移除。
这三条防的是同一种习惯。Agent 改代码时总想让旧的东西继续能跑,于是每次换代都留一条退路,每条退路单看都很体贴。可团队里五个人各自用 Agent,旧版本、旧字段、旧接口一层层叠上去,没有人知道系统在"什么都没配"的时候到底在跑什么。版本迭代本来就是为了替换掉旧的,失败的时候让它响亮地失败,才找得到问题在哪。