模型服务YAGNI检查:用审计日志治理扩展点与开关
1. 先把"跳过"这件事说清楚:YAGNI检查在模型服务里查的到底是什么
上一次迭代复盘,一个只写了三天的小功能被翻出来。它本身没写错,错在它根本不该存在:上线两个季度,线上调用次数是0,却带了三处配置项、一张新表和两条告警规则。我们把审计日志里这个功能的完整生命周期拉出来——从需求创建、代码合入、配置下发到每一次可能的调用——那几分钟会议室很安静。YAGNI这四个字母,做服务迭代的人都会背,意思是"You Aren't Gonna Need It",你不会需要它。但真到了评审桌上能顶住压力的没几个,因为跳过检查的时候,理由听起来都特别正当:模型版本一个季度一换代,不预留扩展点下回还得改;上个月刚出过一次超时雪崩,加个开关兜底不过分;评估规范要求全链路留痕,字段一个都不能省。这三句话我自己说过,也被人用同样的三句话堵回来过。
这篇就把这三条"合理借口"逐一拆开,配上我从审计日志里捞出来的证据链,讲清楚怎么用日志数据把"以防万一"打回"暂无依据",也让真正需要的功能更快放行。适合的对象很明确:正在做大模型推理服务、模型网关、评测平台这类系统迭代的同学,尤其是要在评审会上做取舍判断的技术负责人和一线开发。不需要你是架构师,只要能读懂一段日志、会写一句聚合查询,后面给的结构和查询模板都能直接拿去用。
1.1 一句口诀背后的三个追问
很多人把YAGNI理解成"少做点",这理解偏了。YAGNI不是反对建设,它反对的是在没有证据的情况下提前建设。落到评审动作上,它其实是三个很朴素的追问:谁会用、什么时候会用、不用会损失什么。第一问查调用方,答案必须是具体的服务名或者具体的人,不能是"未来可能接入的某类业务";第二问查触发条件,答案必须能被写成一个可观测的信号,比如"当单实例排队深度超过阈值时",而不是"当流量变大的时候";第三问查损失量化,答案要么是钱、要么是延迟、要么是可复现的故障场景,含糊其辞的"影响体验"不算答案。
这三个追问的好处是它把主观判断变成了可核查项。一个需求如果三问都能答上来,它就该做,而且应该优先做;如果三问里有两问答不上来,那它就该被推迟,或者被拆成一个更小的、只解决当下问题的版本。我在团队里推这套问法的时候,最反对的意见是"这么问太苛刻,很多创新就是想不到用途的"。这话对,但创新和线上服务的迭代是两码事:探索期的项目做加法是对的,稳定期的服务做加法必须有账本。YAGNI检查本质上就是那个账本。
1.2 模型服务为什么更容易被"以防万一"攻破
普通的业务系统里,一句"以后可能要支持多租户"通常还会被质疑一下;模型服务就不一样,它的技术栈天然带着一堆变量,说服力天然更强。模型版本每季度换,多模态能力不断叠加,路由策略从单模型变成主备再变成分级,评测指标从准确率扩展到延迟、成本、拒答率,配置面比传统服务大一个量级。这些特点叠在一起,任何人说一句"留个口子吧,免得下回重构",听起来都像是经验之谈。
再往深看还有两个结构性原因。一是模型服务的迭代节奏被上游推着走,很多时候不是团队想加功能,而是新模型发布了、新接口变了,必须跟着改,这种被动节奏下人会本能地想"一次改到位",于是顺手把还没影的需求一起做了。二是模型服务的观测成本高,一次推理的链路很长,从网关到调度到推理实例再到底层资源,任何一个环节的细节都容易被"反正日志都会打"掩盖过去,导致加功能时看不到真实成本。这两个原因共同作用,让"以防万一"在模型服务里几乎成了一种默认正确。所以要拦住它,靠喊口号没用,得靠数据。
2. 借口一:"模型半年一换代,扩展点先留着,早晚用得上"
2.1 这句话听着像架构远见,实际是延迟决策
完整的话术通常是这样的:"现在只接一个模型供应商,但下个季度肯定要接第二家,所以先把 Provider 抽象层做出来,接口、注册器、配置项、健康检查一次做完,后面加就只是配置的事。"听起来非常有远见,问题在于它把"什么时候做抽象"这个决策从今天推到了一个不存在的未来,同时把成本全部压在了今天。真实的代价不是那几百行代码,而是之后每一次改动都要穿过这层抽象:新人接手要先读懂这层没有第二个实现的抽象,调试链路多跳一层,配置项多一个出错点,测试用例多一套需要维护的桩。
更隐蔽的代价是它会自我强化。抽象层建好之后,团队会不自觉地认为"既然有这层,那就用它",于是第二个、第三个调用方被引导着走这条路径,等某天真的接第二家供应商时,发现这层抽象的接口是按第一家供应商的特性设计的,根本不适配第二家,最后要么大改,要么绕过。我见过最典型的情况是:抽象层里预留的"多模态输入"参数,两年里所有调用方都传 null,清理的时候发现删除它需要改动四十多个调用点,于是没人敢删,它就那么一直挂着。
2.2 审计日志怎么把"早晚用得上"变成可证伪的命题
"早晚用得上"是个无法证伪的表述,但它在系统里留下的痕迹是可以被量化的。我的做法是在扩展点上打三类事件:注册事件、选择事件、调用事件。注册事件记录"谁在什么时候注册了哪个实现",选择事件记录"路由决策选中了哪个实现、依据是什么",调用事件记录"这个扩展点的方法被真正执行了多少次"。这三类事件写进同一张审计表,按天聚合,跑180天,"早晚"就变成了一个具体数字。
实际跑出来的结果往往很扎心:某次评审里被力保的"多供应商路由扩展点",180天内选择事件全部指向同一个实现,调用事件在非测试环境下的计数是0,注册事件只有一条,来自引入它的那次提交。这三个数字摆在一起,再讨论"早晚用得上"就没意义了,因为证据显示"过去半年一次都没用上",而未来能不能用上,取决于一个还没立项的需求。这里的关键不是证明它永远没用,而是把举证责任倒过来:既然你说未来会用,那就请给一个能在日志里被观测到的触发条件。
操作上有个细节要注意,调用事件的采样不能省。扩展点调用频次通常不高,如果套用高流量的采样策略(比如1%),很可能整天都是0,反而说不清是"真的没调用"还是"采样漏了"。我的建议是扩展点相关的事件全量记录,单条体积控制在几百字节,量级完全扛得住,只有在确实出现高频扩展点时才降级为计数聚合。
2.3 真需要扩展时,成本更低的三种做法
我不反对为扩展做准备,我反对的是用最重的方式做准备。同样是应对"下季度可能接第二家",有三种成本低得多的路径。第一种是只在调用侧做收敛,不建抽象层:把所有对模型接口的调用集中到一个模块里,模块内部写死当前实现,但对外只暴露一个函数。等真的接第二家时,改这一个模块就够了,改动范围和建抽象层几乎一样,但今天不用维护任何多余结构。这种做法我在三个项目里用过,事后接第二家时的实际改造量都在一天以内。
第二种是空实现加显式标记。如果一定要先定义接口,那就把接口定义好、实现留空,并且在代码里写上明确的负责字段和复核时间,比如"若某年某月前仍无第二个实现,删除此接口"。这条注释看起来幼稚,但它把"以后再说"变成了一个有到期日的待办,配合代码扫描工具能自动提醒。第三种是延迟到出现第二个实现时再抽象,也就是常说的"三次法则"的变体:只有当第二个真实实现出现、并且你能看清两者的差异时,抽象才是有信息量的,否则你抽象的是想象。
3. 借口二:"上次出过事故,加个开关兜底,总归没错"
3.1 开关不是免费的,它把一次决策变成永久契约
事故之后加开关,几乎是所有团队的本能反应,而且在情绪上极难反驳——你总不能说"别加,让它再挂一次"。但这个动作的真实成本被严重低估了。一个开关表面上是一个配置项,实际上是一份契约:它要求代码里存在两条路径,要求测试覆盖两条路径,要求监控能区分两条路径,要求有人在故障时知道该切哪一条,还要求有人在该下线的时候记得下线。这五件事少做一件,开关就从保险变成了隐患。
最直接的数学问题是组合爆炸。10个布尔开关意味着理论上1024种组合,而实际被测过的路径通常只有主路径和少数几个降级路径。剩下的组合里藏着什么,没人知道。我经历过一次故障,根因是两个各自都安全的开关同时打开时,走了第三条从未被验证的路径,缓存和限流同时失效。事后翻审计日志发现,这两个开关在半年内被不同的人在不同时间打开过,但从来没有同时打开过——直到那天。
3.2 用审计日志算清一个开关的真实持有成本
要拦住"总归没错",得把成本算给提出的人看。我在审计日志里固定记录四类开关事件:创建、变更(含操作人、旧值、新值、变更原因)、读取(每次配置加载时记录命中的开关集合)、清理。基于这四类事件能算出三个很有说服力的指标。第一个是开启率,即某开关在统计周期内处于打开状态的时间占比。如果长期接近100%,说明它实质上已经变成默认路径,应该固化为代码而不是留在配置里;如果长期接近0%,说明它是死代码,应该删除。两种情况都指向同一个结论:它不再是开关。
第二个是切换频次。一个月内切换超过十次的开关,通常说明它的触发条件设计得不对,或者它被当成了调参旋钮在用,这时候该做的是把策略自动化或者把阈值重新标定,而不是继续人工切。第三个是存活时长。我给每个开关强制要求填写预期下线时间,超过时间未清理的,在月度审计里自动列出来。我们统计过一批服务,开关的平均存活时间是引入时预期的4倍以上,最长的活了两年多,期间换过三任负责人,没人敢删。
这里有个具体算例值得摆在评审桌上:某服务日均请求8000万次,配置加载在每次请求的热路径上,单个开关的判定耗时约0.3微秒,10个开关就是3微秒,看起来微不足道;但如果配置结构体积因为开关增多从2KB涨到6KB,配置中心的推送耗时和加载抖动会非线性上升,而这类抖动在高峰期会被放大成尾延迟。我们在一次压测里就见过,配置体积翻三倍后,P99延迟涨了将近8毫秒。这个数字比任何架构论证都更能说服人。
3.3 什么情况下开关才配得上:一张判定表
我的做法是先过一张判定表,四条里满足三条才允许加开关。这个门槛不算高,但足以挡掉大部分情绪驱动的需求。
| 判定项 | 具体问题 | 通过标准 |
|---|---|---|
| 复发概率 | 同类故障在过去12个月内出现过几次 | 出现过2次以上,或有明确的外部依赖不稳定证据 |
| 影响面 | 故障发生时影响多少请求、是否可被上层兜住 | 影响超过总流量的5%,且上层无法降级 |
| 自动判定 | 触发条件能否由监控指标自动判定 | 能写成明确阈值,不依赖人工判断 |
| 失效条件 | 什么情况下应该删掉这个开关 | 有可观测的、有时间点的删除条件 |
四条里最容易糊弄的是第四条,所以我会要求删除条件必须写成可查询的形式,比如"当上游某接口的P99稳定在阈值以下连续30天,删除此开关"。这句话可以直接变成一条监控规则,到期自动提醒,比写在文档里靠谱得多。反过来,如果四条里只满足"影响面"这一条,那正确的做法通常不是加开关,而是在网关层做统一的超时与重试策略,用一次投入覆盖所有下游,而不是给每个下游单独配一个开关。
4. 借口三:"评估和审计要求留痕,字段一个都不能少"
4.1 留痕的最小集与全量集,差的是钱不是态度
前两条借口多少带点技术判断,这一条几乎是不可辩驳的,因为它披着规范的外衣。需求方会说:"评估链路要能追溯,出了问题要能复现,所以请求内容、特征快照、每个候选结果、打分细节都要存下来。"这话在原则上没错,但"能追溯"和"全量存"之间隔着好几个数量级。留痕的最小集通常只需要七八个字段就能回答"这次请求发生了什么":请求标识、模型版本、路由决策、耗时、结果状态、错误码、采样标记、时间戳。全量集则可能包含完整的输入内容、中间特征、每个候选的分数,体积是最小集的几十倍到上百倍。
算一笔账就很清楚。日均8000万次请求,最小集按每条1.2KB算,一天约96GB,一个月约2.9TB,这个量级用列式存储加压缩完全可控。如果加上完整输入和特征快照,单条可能到20KB以上,一天的写入量就是1.6TB,一个月接近50TB。这还没算写入带宽、索引开销和查询成本。所以问题从来不是"要不要留痕",而是"留到什么粒度、留多久、给谁查"。把这个问题摆到台面上,绝大多数"一个都不能少"都会松动。
4.2 证据链:字段的写入量、读取方、命中次数
判断某个字段该不该全量保留,我只看三个数据,而且这三个数据都能从审计日志里直接得到。第一是写入量,包括单条体积和日增量,这个可以从日志结构里直接算。第二是读取方,即这个字段被哪些下游消费,是看板、是离线训练、还是故障排查手册里的一条查询。第三是命中次数,即在统计周期内,这个字段被真正查询了多少次,以及查询之后是否改变了某个结论。
第三点最容易被忽略,也最有杀伤力。我们做过一次盘点,某个"必须保留"的详细打分字段,在180天内被查询过11次,其中9次是开发调试、2次是排查一个已定位的问题,没有任何一次查询结果影响了最终结论。这个字段占了整张表体积的六成。有了这组数据,把它从全量改为按错误采样、按需回溯,几乎没有遇到阻力。反过来说,如果某个字段查询频次很高、且每次查询都直接指向问题原因,那它就该全量保留,甚至该加索引,这种字段我们从不吝啬。
4.3 采样、分层与冷热分离的具体参数怎么定
落到参数上,我用的是一套很土但好用的规则:错误全采、慢请求全采、正常请求按比例采。错误全采是因为量小且价值高,慢请求按P99阈值以上全采,通常占总量的1%左右,正常请求采样率设在1%到5%之间,具体看下游对统计精度的要求。如果是做分位数统计,1%采样通常已经足够;如果是做单点复现,那靠的是请求标识而不是采样率,因为只要有标识,就能从原始链路里回捞。
保留期做三层:热存7天,支持按请求标识秒级查询;温存90天,支持按维度聚合,查询延迟放宽到分钟级;冷存一年,只做归档,需要走离线任务。这个分层不是为了省钱而省钱,而是为了让查询路径变清晰——大部分排查发生在24小时内,热存覆盖住这部分就够了。这里有个坑要提醒:分层策略上线前一定要确认下游看板走的是哪一层,我们曾经把某个字段挪到温存之后,一个每天早上的例行看板直接超时,因为它的查询模式是逐条扫描而不是聚合,这种用法在冷层上必然崩。
5. 搭一条能说话的审计日志证据链
5.1 四类必须记录的事件与字段
要让上面这些判断有依据,审计日志不能只记"请求成功失败",得记决策过程。我在服务里固定了四类事件。第一类是决策事件,记录一次功能或需求的放行判断,包括需求标识、判断人、结论、依据的三问答案;第二类是配置变更事件,记录开关的创建、修改、删除,必须带操作人、旧值、新值、原因;第三类是功能命中事件,记录某个扩展点、开关分支、特殊路径被实际执行的情况;第四类是字段消费事件,记录下游对某个日志字段的查询行为,包括查询人、查询条件、返回行数。
这四类事件放在同一套结构里,用事件类型区分,好处是可以在一条时间线上还原"某个功能从被批准到被使用"的全过程。这里有个设计上的取舍要注意:不要把这些事件混进业务主日志表,虽然查询方便,但业务日志的采样和清理策略会污染审计数据。我的做法是单独一张审计表,写入量很小(每天几万到几十万条),保留期给到两年,成本可以忽略,但关键时刻它就是证据。
5.2 一份可直接抄的事件结构与示例
结构上我只保留了必要的字段,避免设计过度——这本身也是一次YAGNI的实践。下面这个结构在三个服务里跑过,够用。
{ "event_id": "evt_20240612_8f3a2c", "event_type": "feature_hit", "event_time": "2024-06-12T10:23:41.512Z", "service": "model-gateway", "env": "prod", "actor": { "type": "system", "id": "router-v3" }, "subject": { "kind": "extension_point", "name": "provider_selector", "target": "vendor_a" }, "payload": { "invoke_count": 1, "trace_id": "tr_9c1e77", "decision_reason": "default_route" }, "schema_version": "1.2" }几个字段的用意值得说一下。actor区分人和系统,因为很多命中是自动发生的,混在一起会让"谁在用"这个问题答不上来。subject用三元组描述被观测的对象,kind区分扩展点、开关、字段,name是对象名,target是具体分支,这样一条记录既能回答"这个扩展点用了没",也能回答"用的是哪个实现"。schema_version是被低估的字段,结构一改,历史数据的解读就会出问题,带上版本号能在两年后还原当时的语义。payload里不要塞大对象,控制在几百字节内,超出的内容走引用。
5.3 三条查询模板,把评审从吵架变成对账
有了数据,还得有能直接甩到会议上的查询。下面三条是我用得最多的,语法是常见数仓方言,具体函数名按你的引擎调整。
-- 查询一:某扩展点过去180天的真实调用分布 SELECT subject_target AS impl, COUNT(*) AS hit_events, COUNT(DISTINCT DATE(event_time)) AS active_days, MAX(event_time) AS last_hit FROM audit_events WHERE event_type = 'feature_hit' AND subject_kind = 'extension_point' AND subject_name = 'provider_selector' AND env = 'prod' AND event_time >= CURRENT_DATE - INTERVAL '180' DAY GROUP BY subject_target ORDER BY hit_events DESC;-- 查询二:某开关的开启率与切换频次 SELECT subject_name AS flag_name, COUNT(*) FILTER (WHERE payload->>'new_value' = 'true') AS to_on, COUNT(*) FILTER (WHERE payload->>'new_value' = 'false') AS to_off, COUNT(DISTINCT actor_id) AS operators, MIN(event_time) AS first_change, MAX(event_time) AS last_change FROM audit_events WHERE event_type = 'config_change' AND subject_kind = 'flag' AND event_time >= CURRENT_DATE - INTERVAL '90' DAY GROUP BY subject_name HAVING COUNT(*) > 0 ORDER BY to_on + to_off DESC;-- 查询三:某日志字段的消费情况 SELECT payload->>'field_name' AS field_name, payload->>'consumer' AS consumer, COUNT(*) AS query_times, SUM((payload->>'rows')::bigint) AS total_rows FROM audit_events WHERE event_type = 'field_access' AND event_time >= CURRENT_DATE - INTERVAL '180' DAY GROUP BY 1, 2 ORDER BY query_times DESC;第一条查询直接回答"早晚用得上",因为active_days和last_hit把模糊的时间承诺变成了具体日期。第二条查询里如果to_on长期为零而to_off也为零,说明这个开关从创建后就没被人碰过,它大概率是个摆设;如果operators特别多,说明没有明确的负责人,这也是清理信号。第三条查询是决定字段去留的核心依据,query_times为零或者极低的字段,就该进入采样或冷存。
6. 把检查嵌回流程:让"合理借口"在第一道关口失效
6.1 需求单上加三行,抵得上两小时会议
工具搭得再好,如果评审流程里没有强制项,数据也不会被用起来。我的做法非常简单粗暴:需求单模板里加三行必填,分别是"调用方"(具体到服务名或人名)、"触发条件"(写成可观测的信号)、"不做的损失"(量化到延迟、成本或故障场景)。这三行填不出来,需求不进评审。刚开始阻力很大,大家觉得是形式主义,但三个月之后效果就出来了——很多需求在填表阶段自己就消失了,因为填的人发现第二行写不出来。
为了让这三行不流于形式,我加了一个抽查机制:每月随机抽十条已上线需求,用审计日志核对当初填的触发条件是否真的发生过。这个动作的成本很低,但威慑力很强,因为它把"随便填填"变成了有后果的行为。这里要注意尺度,抽查的目的不是追责,而是校准判断,所以核对结果不挂绩效,只在团队内做案例分享。我见过一些团队把这个做成考核指标,结果所有人开始挑容易验证的需求上报,反而扭曲了行为。
6.2 常见问题与排查速查表
实际推进中遇到的问题高度集中,我整理成一张表,遇到时可以直接对照。
| 现象 | 可能原因 | 排查动作 | 处理建议 |
|---|---|---|---|
| 扩展点调用计数全为0,但业务确实在用 | 埋点位置在错误的分支上,或采样率过低 | 查审计表的event_type分布,确认是否有该扩展点的事件类型 | 埋点前移到统一入口,扩展点相关事件改为全量 |
| 开关开启率长期100%,仍被认为是"兜底手段" | 默认值被改过,或从未有人切回 | 对比配置文件初始值与当前值,查config_change的to_off次数 | 固化为默认路径并删除开关,保留告警 |
| 审计表写入量异常增长 | 事件重复上报,或payload里塞了大对象 | 按event_id去重统计,看单条体积分布 | 加幂等键,payload超阈值改走引用 |
| 冷存查询超时 | 下游用逐条扫描而非聚合 | 查日志里的查询语句模式 | 强制冷层只允许聚合查询,单点查询走热层按标识回捞 |
| 三行必填全填了,但内容空洞 | 缺乏核对机制 | 抽查已上线需求,用日志核对触发条件 | 建立月度案例复盘,校准填写标准 |
这张表里最值得展开的是第一条。埋点位置选错是所有证据链失效的头号原因,因为埋点一旦落在某个条件分支里,另一条分支的执行就不会被记录,于是日志呈现出一幅"这个功能没人用"的假象,反过来又会导致误删。我的经验是埋点尽量放在决策点之后、执行之前,既能看到决策结果,又不依赖执行是否成功。至于重复上报,通常是重试机制和埋点位置叠加导致的,加个幂等键就能解决。
排查顺序上我固定成三步:先确认数据有没有(查事件类型分布),再确认数据准不准(对抽样请求做人工核对),最后才看数据说明什么。跳过前两步直接看结论,十次有八次会得出错误判断。
6.3 我在实际推进中踩过的几个坑
第一个坑是把审计日志当成了纯技术活。最初我让开发同学自己定义事件和字段,结果三个月后积累了四十多种事件类型,命名风格各异,同一件事有三种叫法,聚合查询写起来像考古。后来我们收敛到一个很小的枚举,新增事件类型需要说明理由,维护成本才降下来。这件事的教训是,审计日志的schema本身就是一份契约,它的稳定性比丰富性重要得多。
第二个坑是采样率写死在代码里。某次大促前流量涨了十倍,原本1%的采样突然变成了绝对量大增,审计表写入打满了磁盘配额,连带影响了主链路。后来改成按目标条数反推采样率,也就是给定每天希望保留的条数上限,用当天的预估流量算出采样比例,这个逻辑本身也要被审计,否则调参过程又成了黑盒。
第三个坑最隐蔽:检查变成了表演。有一段时间我们的需求单三行填得都很漂亮,措辞专业、数据齐全,但抽查发现其中相当一部分是为了过审而编的。识别方法其实不难,一看触发条件是否用了具体阈值,二看损失描述里有没有可验证的数字,三看这些内容是否在后续的复盘里被引用过。没有被引用过的依据,基本就是摆设。所以后来我在复盘模板里加了一栏"当初的依据是否成立",把检查和复盘接上,这个闭环一建立,填表的认真程度立刻不一样。
最后一个体会是关于节奏的。YAGNI检查如果做成一次性的卡点,它就一定退化成形式;只有把它变成一条持续存在的证据链——每次决策留痕、每次使用被记录、每次清理有依据——它才真正起作用。我们现在的做法是每月花半小时看一次审计报表,看哪些扩展点从未命中、哪些开关从未切换、哪些字段从未被查询,然后挑一两个清理掉。这个动作小到几乎不占时间,但一年下来删掉的东西,比任何一次大重构都多。