上一章结尾把质量闸门的事点出来了:闸门不用猜哪段错了,直接拿 trace 里各段的输出去打分,不达标的回答当场拦下。到这一章,前面七章的东西已经攒齐——语义层、权限、灰度、熔断、定位、闸门,散着看是六个零散的改造点,上线前得把它们收成一张能逐项勾掉的清单。这篇就是那张清单,以及把它变成脚本的办法。
清单的六项
清单按链路顺序排,每一项都对应前面某一章,勾不掉就别上线。
语义层回退。 第 3 章在旧库上补了一层语义,但语义索引总有覆盖不到的时候。上线前要确认两件事:查询语义没命中的时候,系统会回退到旧的关键词查询,而不是直接返回空;语义索引的数据源和旧库是同步的,不会出现索引里有、库里已删的脏结果。
权限复用。 第 4 章说权限要复用现有体系,别绕开。检查点是:有没有哪条链路直接调了底层数据、没走权限过滤。这种"图省事"的旁路在开发时很容易埋进去,上线前用一次全链路的调用把它翻出来。
灰度开关。 第 5 章把新功能按部门切开。检查开关是否真的生效——不是看配置里有没有这个值,而是用两个不同部门的账号各打一次,看是否走了不同分支。开关要能一键回滚,不是"重启生效"。
熔断兜底。 第 6 章的降级路径。检查超时时间、降级答案、快速失败是否都配了,尤其是模型完全挂掉时,用户看到的是不是一条能用的兜底回答,而不是一堆报错。
定位 trace。 第 7 章的分环节记录。检查 trace 是否真的落库、能按请求 id 查回来,以及 trace 表有没有做脱敏和访问控制。
质量闸门。 这是清单里唯一"新"的一项,也是承接上一章的地方。闸门用 trace 里各段的输出去打分,不达标就拦下。
把清单变成脚本
清单写进文档,上线那天没人会一条条去翻。把它变成脚本,跑一遍出结果,才是能落地的样子。下面这个脚本按六项逐个校验,任何一项不过就非零退出:
# 上线前检查脚本:逐项校验,任何一项不过就非零退出,接进 CI 当最后一道门。
import sys
def check_semantic_fallback(client):
# 语义没命中时是否回退到关键词查询,而不是直接回空
resp = client.ask("一个不存在的术语 xyzzy")
return resp.fallback_used is True, "语义回退"
def check_permission(client):
# 用无权限的账号跨部门访问,看是否被权限层拦下
resp = client.ask("查一下隔壁部门的合同", dept="sales", as_dept="hr")
return resp.status == "denied", "权限旁路"
def check_flag(client):
# 灰度开关是否按部门切开:两个部门走不同分支
a = client.ask("试用新功能", dept="pilot")
b = client.ask("试用新功能", dept="control")
return a.feature_flag != b.feature_flag, "灰度开关"
def run_preflight(client, checks):
failed = []
for fn in checks:
ok, name = fn(client)
print(f"[{'PASS' if ok else 'FAIL'}] {name}")
if not ok:
failed.append(name)
if failed:
sys.exit(f"上线检查未通过: {failed}")
print("全部通过,可以上线")
这个脚本里 check_semantic_fallback 用一个不存在的术语去打语义层,看它是不是回了空——回空就说明回退没接好。check_permission 用无权限账号跨部门访问,看权限层拦没拦。每项返回一个布尔加一个名字,统一汇总,任何一项不过就非零退出。熔断、trace、闸门三项按同样的套路往里补就行。
质量闸门的落地
闸门本身是一段打分逻辑,放在模型返回之后、用户看到之前。它不重跑模型,只消费 trace 里已经记下的各段输出。这段可以独立跑:
# 质量闸门示例:可直接运行。gate_score 消费 trace 各段输出打分。
class Step:
def __init__(self, stage, output):
self.stage = stage
self.output = output
class Trace:
def __init__(self):
self.steps = []
def gate_score(trace):
score = 0
if trace.steps[0].output: # 检索段有命中
score += 1
raw = trace.steps[2].output
if "{{" not in raw and "..." not in raw: # 生成段没有残留占位符
score += 1
final = trace.steps[3].output
if final and final != raw: # 后处理段确实动了
score += 1
return score
# 构造一条跑偏的 trace:生成段带未替换占位符,后处理段没改
t = Trace()
t.steps = [Step("retrieve", ["doc_1"]),
Step("assemble", "prompt"),
Step("generate", "答案是 {{PLACEHOLDER}}"),
Step("postprocess", "答案是 {{PLACEHOLDER}}")]
print(gate_score(t)) # 只有检索段得 1 分,低于 2 分会被打回
闸门的分值是我拍的一个粗糙起点:三段各占一分,低于两分就打回。这个阈值上线前不用纠结,先让闸门跑起来、把拦下的样本记下来,回头再按实际分布调。闸门的价值不在阈值准不准,在于它把"质量"从主观感受变成了一个能统计、能回看的数字。
踩坑
清单别停在文档里。 最大的坑就是把检查清单写成一份文档,上线那天没人会真去逐项跑。清单的价值在于"可执行"——每项都能在十分钟内跑出确定结果,否则它只是一份让人安心的摆设。
闸门阈值别上线前死磕。 质量闸门的分数阈值第一次上线时必然不准,纠结阈值会拖住整个上线。正确顺序是先让闸门跑起来、记下拦了多少、拦对了多少,再按数据调阈值。
别把清单当成一次性动作。 这套系统改一行代码,清单里的某个假设就可能失效。把 preflight 脚本接进 CI,让它跟着每次部署一起跑,清单才是活的。
收个口
这张清单到这儿就齐了。六项里前五项都是把前面几章的东西再确认一遍,第六项质量闸门是最后补上的一块。整个系列走到这里,做的事情其实是同一件:给一套老系统接 AI,不是推倒重来,而是在它现有的数据、权限、发布流程之上,一层一层地加东西,每加一层都留一条退路。
真正敢上线的标志,不是功能都做完了,而是你能在十分钟里把这六项都跑一遍、每一项都有明确的结果。做到这一步,这套 AI 改造才从"能跑"变成了"敢用"。