作者因免费模型悄然更换导致输出质量下降而搭建 LLM 快照回归套件,定时检测模型行为漂移。提供了完整可运行的代码实现思路。
几周前,我运行的一个小自动化脚本开始输出明显更差的结果。我的代码没有任何改动。没有依赖更新,没有配置修改,没有 prompt 调整。唯一剩下的变量就是模型本身——我使用的免费版本被悄悄更换或更新了,而且因为没有任何基准记录,我甚至无法证明这一点。我只是有一种感觉:周二的摘要比周五的差。
这段经历促使我构建了一个从一开始就应该有的东西:一个用于 LLM 输出的快照回归测试套件。不是用于选择模型的评估框架(我之前写过相关内容),而是一个按计划运行的绊线(tripwire),用于告诉我已经被选定的模型何时发生了漂移。这篇文章就是这个工作流程,带有可运行的代码。
我们把固定的 npm 包和锁定的 Docker 摘要视为基本要求,但大多数人对 LLM 的消费方式就像是浮动 latest 标签。托管模型会被静默升级、量化、重路由或退役。免费层级的变动更快——提供商轮换可用内容,上个月能用的模型名称现在可能解析为完全不同的东西。
如果你的 prompt 是针对某种行为调优的,那么一次静默的切换就是一次你永远不会在变更日志中看到的破坏性变更。解决方案和我们应用在其他地方的一样:记录已知的良好行为并与之比对差异。
经典的快照测试对 LLM 不起作用,因为输出是非确定性的——你无法用字符串比较散文。因此,我选择对响应的语义内容进行快照,而不是精确匹配,并使用嵌入余弦相似度进行比较,同时对精确要求设置硬性下限(JSON 有效性、必需键、禁用短语)。
以下是核心部分。drift_check.py:
import json
import math
import os
import sys
import time
import urllib.request
BASELINE_PATH = "golden/baseline.json"
API_URL = os.environ["LLM_API_URL"] # your OpenAI-compatible endpoint
API_KEY = os.environ.get("LLM_API_KEY", "") # some free servers don't need one
MODEL = os.environ["LLM_MODEL"]
# Each probe has a prompt plus hard constraints that must ALWAYS hold.
PROBES = [
{
"id": "json_extraction",
"prompt": "Extract name and date from: 'Invoice from Acme Corp, dated 2024-03-11.' "
"Reply with JSON only.",
"must_be_json": True,
"required_keys": ["name", "date"],
},
{
"id": "tone_summary",
"prompt": "Summarize in one neutral sentence: 'The deployment failed twice "
"before the rollback succeeded.'",
"must_be_json": False,
"banned": ["unfortunately", "oops"], # tone guardrails
},
{
"id": "code_style",
"prompt": "Write a Python function that reverses a string. No explanation, code only.",
"must_be_json": False,
"required_substrings": ["def ", "return"],
},
]
def chat(prompt: str) -> str:
body = json.dumps({
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.0, # reduce noise; not a guarantee
}).encode()
req = urllib.request.Request(
f"{API_URL}/v1/chat/completions",
data=body,
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)["choices"][0]["message"]["content"]
def hard_check(probe: dict, output: str) -> list[str]:
errors = []
if probe.get("must_be_json"):
try:
parsed = json.loads(output.strip().removeprefix("```json").removesuffix("```").strip())
for k in probe.get("required_keys", []):
if k not in parsed:
errors.append(f"missing key: {k}")
except json.JSONDecodeError:
errors.append("output is not valid JSON")
for phrase in probe.get("banned", []):
if phrase.lower() in output.lower():
errors.append(f"banned phrase present: {phrase}")
for s in probe.get("required_substrings", []):
if s not in output:
errors.append(f"required substring missing: {s!r}")
return errors
def main():
record = {"model": MODEL, "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"outputs": {}}
failed = False
baseline = json.load(open(BASELINE_PATH)) if os.path.exists(BASELINE_PATH) else None
for probe in PROBES:
out = chat(probe["prompt"])
record["outputs"][probe["id"]] = out
errs = hard_check(probe, out)
if baseline:
old = baseline["outputs"].get(probe["id"], "")
sim = token_overlap(old, out) # cheap stand-in; see note below
if sim < 0.55:
errs.append(f"semantic drift vs baseline (similarity={sim:.2f})")
status = "FAIL" if errs else "ok"
print(f"[{status}] {probe['id']}" + (f" -> {errs}" if errs else ""))
failed = failed or bool(errs)
if not baseline:
os.makedirs("golden", exist_ok=True)
json.dump(record, open(BASELINE_PATH, "w"), indent=2)
print("No baseline found — recorded one. Re-run to compare.")
return
sys.exit(1 if failed else 0)
def token_overlap(a: str, b: str) -> float:
"""Jaccard similarity over word tokens. Crude but zero-dependency.
Swap for embedding cosine similarity when you can afford an embed call."""
ta, tb = set(a.lower().split()), set(b.lower().split())
return len(ta & tb) / len(ta | tb) if (ta | tb) else 1.0
if __name__ == "__main__":
main()
运行一次以捕获 golden 基线,然后按 cron(或 CI 计划)针对相同的模型名称运行它。非零退出码意味着硬约束被破坏或输出在语义上发生了漂移——这就是你的告警信号。
关于相似度函数:我在这里使用了 Jaccard 词重叠,这样脚本就零依赖。它很粗糙,会标记同义改写。如果你认真地做这件事,请将 token_overlap 替换为来自任何嵌入端点的嵌入余弦相似度,并将阈值提高到约 0.9。我已将 Jaccard 版本标注为占位符,而非推荐方案。
漂移绊线只有在持续运行的情况下才有效——每天或每次部署时。在付费 API 上,每天运行探测套件是一笔会让业余项目和边角工具悄然搁浅的费用。这就是我一直在使用 MonkeyCode 的地方:它提供免费模型访问和免费服务器选项,这意味着定时检查对我而言零成本,而且运行在我笔记本以外的地方。披露:本文是作为 MonkeyCode 产品推广的一部分准备的。诚实的警告:免费可用性可能会发生变化,模型列表也会轮换——这恰恰就是上面的漂移问题。我的套件无论提供商如何都会监控端点,因此如果免费选项消失了,我会将 LLM_API_URL 指向其他地方,并保留我的基线。将任何免费层级视为临时基础设施,而不是你无法替换的依赖。
并非每次失败都意味着"模型变差了"。我使用这个决策表:
最后一行很重要:这套件同时也是 prompt 变更审查。如果我编辑了一个 prompt 而漂移检查触发了,那意味着套件在告诉我这个编辑有意料之外的副作用。
Temperature 0 不等于确定性。批处理、硬件和提供商方的变化意味着你仍然可能得到不同的输出。这就是为什么硬检查比相似度分数更重要。
相似度阈值是主观判断。太紧会产生告警疲劳;太松又会错过真正的回归。预期需要一到两周的调优时间。
小探测套件测量小范围。三四个探针不会捕获长上下文推理中的细微退化。将套件扩展到你的应用实际依赖的行为上。
如果你的使用场景是一次性聊天,请跳过这个。当模型位于自动化流水线内部时——摘要任务、提取步骤、代码生成辅助——套件才会值得。在这些场景中,静默的漂移会累积。
免费层级会增加额外的噪声。速率限制和轮换意味着你的绊线也应该将 HTTP 错误与漂移失败分开记录,否则你会追逐虚假的回归。
固定你的 prompt,版本化你的基线,并将每个托管模型视为可变依赖。几百行标准库 Python 就能把"我认为模型变了"变成一份你可以采取行动的 diff。如果你有漂移故事——或者有更好的保持零依赖的相似度技巧——我真心希望在评论区听到。