Prompt修改看似微小却可能破坏下游契约。建议用golden case在CI中做回归验证,检查输出类型、必需字段、拒绝行为和长度限制,而非靠人工阅读diff判断。
一次 Prompt 改动看起来很小。你换一个动词,加一个约束。CI 依然绿,测试依然过。然后下游服务开始收到字符串——而它原本期望的是对象。又或者模型拒绝回答某一类请求。没人注意到,直到用户发现了。
我踩过足够多次坑,不再把 Prompt 编辑当作「配置」来处理。它们是行为变更。而行为变更需要回归预算。
问题不在模型本身。问题在于「靠阅读来 Review」。
当我阅读一个 Prompt diff,我能判断它语法是否正确。我无法判断它在 200 个边界情况下是否改变了输出结构。免费模型端点会让这事更糟——同一个 Prompt 每次调用可能漂移,而速率限制又惩罚了「再跑一次试试」的冲动。
这迫使我在 GitLab CI 里做了一套 Prompt 评估 diff。它不需要多大的平台,只需要:黄金用例、规范化和 MR artifact。
以上没有一个是「听起来更好听了吗」。它们都是机械检查。这就是关键。
这是我用的小型仓库结构:
prompts/
base.yaml
candidate.yaml
cases/
golden.jsonl
scripts/
eval_diff.py
.gitlab-ci.yml
Prompt 版本化为 YAML,而不是内联在 job 的字符串里。仅这一点就改变了 Review 对话。MR diff 变成了真正的 diff。
# prompts/candidate.yaml
name: extract_key_points
system: |
You are a JSON-only assistant.
Return in this exact shape:
{"points": ["..."]}
每条黄金用例有 id、input 和一小套断言。我不存储「正确答案」,我存储的是契约。
{"id":"case_012","input":"Explain the release process","expect":"json_object","required_keys":["points"],"max_output_chars":800}
还有一点:Prompt diff 不应该每次 push 都调两次模型。如果基础 Prompt 哈希已有评估结果,我就复用它。这和我在其他地方用的内容寻址思路一样——别在没有变化的地方浪费配额。
精确字符串匹配对低成本模型毫无用处。同一含义可能以不同空格、同义词或键顺序出现。
所以我的 eval 脚本在比较前先做规范化:
我不声称模型是确定性的。我只是衡量候选版本和基础版本在稳定用例上是否功能相近。
eval_diff.py 加载两个 Prompt,对缺失的一侧每个用例调用一次模型,然后写一份报告。
import json, hashlib, os, sys
def normalize(value: str) -> str:
return " ".join(value.lower().split())
def content_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
def main(base_path, candidate_path, cases_path, report_path, max_regressions):
base = open(base_path).read()
candidate = open(candidate_path).read()
# In a real client, call the model endpoint for text and parse JSON.
report = {"base_hash": content_hash(base), "candidate_hash": content_hash(candidate), "cases": []}
regressions = 0
for line in open(cases_path):
case = json.loads(line)
# Mocked for illustration: replace with a real model client.
# Actual code should run the prompt and check the returned object.
result = {"shape_ok": True, "required_keys_ok": True, "drift": 0.0}
passed = result["shape_ok"] and result["required_keys_ok"] and result["drift"] < 0.25
if not passed:
regressions += 1
report["cases"].append({"id": case["id"], "passed": passed})
report["regressions"] = regressions
json.dump(report, open(report_path, "w"), indent=2)
if regressions > max_regressions:
sys.exit(1)
这是刻意写得小的。真正的模型客户端应该放在独立模块里,这样换端点不用动测试。
我在 MR 上做检查。Job 运行同样的脚本,保存报告,当回归超出预算时让 pipeline 失败。
stages:
- eval
prompt_diff:
stage: eval
image: python:3.12-slim
variables:
MODEL_URL: $MODEL_URL
before_script:
- pip install -r requirements.txt
script:
- python scripts/eval_diff.py
--base prompts/base.yaml
--candidate prompts/candidate.yaml
--cases cases/golden.jsonl
--report eval_report.json
--max-regressions 0
artifacts:
paths:
- eval_report.json
expire_in: 7 days
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
现在 Reviewer 不必信任一条「LGTM」的评论。他们可以打开报告,看看哪些用例挂了。
评估运行器小而突发。它只在 MR 事件上运行。我不想把这种负载放在付费 CI runner 上,如果能避免的话。
我可以用 MonkeyCode 免费服务器选项上的一个小 sidecar 运行同样的脚本,指向 MonkeyCode 的免费模型接入。披露:本文是作为 MonkeyCode 产品推广的一部分准备的。这样把 Job 隔离开来,又不改变工作流。脚本仍然读取同样的 Prompt、同样的用例和同样的报告格式。
关键不在提供商。关键在于评估应该靠近模型,而不是放在部署代码的 pipeline 里。如果端点变了,我只改一个环境变量。
如果你已经有免费模型端点和免费服务器选项,在下一个 Prompt MR 上跑这个报告,把它和现有的 Review 线程比较一下。
回归预算不能阻止所有糟糕的 Prompt。
小规模黄金集必然漏掉稀有输入。十个用例抓不住两百次才出现一次的失败。
契约检查能抓到结构问题,但抓不住细微的质量回归。
免费模型端点在多次运行间仍可能有差异,所以不稳定的 eval 会训练人们忽略失败。
这不会告诉你一个 Prompt 是否好。它只会告诉你它是否改变了你已知需要的东西。
如果你没有一套小型稳定用例和清晰的成功标准,先跳过这个。先把那个集合建立起来。如果你的 Prompt 改动很罕见,而且你已经手动 Review 每条响应,自动化可能不值得维护。
我不在没有报告的情况下合并 Prompt 改动——报告要说明什么东西坏了。它必须是一个文件,而不是感觉。它必须可复现,当回归出现时必须让 MR 失败。
这是我对待代码的同样方式。Prompt 不应该得到更低的待遇。