周一上午十点,产品把改好的提示词整段贴进测试群,末尾跟一句:就换了两个词,晚上能上吧。你盯着那两处改动看了半分钟——退款话术里加了「运费一并退还」,「转人工」改成了「转接人工客服」。看着都不大,可你心里清楚:这套提示词背后挂着几百条场景,全量跑一遍今天跑不完,随手挑二十条又怕漏。晚上到底能不能上,你给不出一个有依据的答案。
这个瞬间缺的东西,是回归测试里最老的一块拼图:影响面分析。代码改动之后选哪些用例回归,业界早有纪律——顺着调用图找受影响的方法,再映射回用例。提示词一行代码都不改,依赖全散在自然语言里,没有调用图可循。但把「调用图」换成一张「场景标签 × 触发条件」的替代图,这套纪律就能接上:每个场景挂上它敏感的表述,提示词里哪几处表述变了,就顺着这张图找谁被波及。下面把三层挑例规则讲透,再给一份能直接跑的脚本。
命中层:被字面命中的场景,一条都不能省
第一步是给改动前后两版提示词做词级差异,抽出三类信号:新增的表述、删除的表述、改写的表述。前两类直白,第三类最容易被漏——「转人工」换成「转接人工客服」在业务上是同一件事,字面却一个词都不重合;如果只按字面找,这次替换会从影响面里凭空消失。所以改写的判定要按词族做:同族词互相替换记为一次改写,族内任意词出现在任何场景的触发条件里,都算命中。
差异信号出来后,拿它去场景库里对:谁的触发条件里含这些表述,谁就被字面命中。这一层的规矩要硬——命中的场景必须跑,没有商量余地。理由很朴素:全量重跑可以拿「今天跑不完」当理由,随机抽二十条可以拿「运气不好」当理由;唯独改动明明动到了某个场景的触发条件、你却把它跳过,这在复盘会上没法回答。命中层不进「可商量」的池子,它直接进必跑集合。
边界层:过错样本和低置信样本,专吃规则松动
第二层最容易被漏,却是提示词改动的高发雷区。被字面命中的场景,说明改动「碰到了」它;边界层要挑的是另一类——历史上因这类表述出过错的样本,以及处在低置信、拒答边缘的样本。它们平时不吭声,专等规则变化:「可以告知预计时间」扩写成「承诺四十八小时内给出结果」,正常场景照样跑得通,先崩的偏偏是那些用户已经在追问、模型本来就在犹豫的会话。
边界层的选法是两步:先取命中层拉出来的受影响标签,再回头把标签对得上的边界样本捞进来。它们不是被这次改动「碰到」的,是可以被这次改动「推开」的——从拒答滑到越权承诺,中间往往只隔一句句式上的松动。
不变层:钱、隐私、越权、法律口径,永不随改动缩小
第三层的规则和另外两层反过来:它不筛选,它常驻。钱、隐私、越权承诺、法律口径这四类场景,无论改动多小、无论前两层选出来几条,每次都必须跑,一档都不能缩。原因也可以直说:这四类出了错,代价不是「体验不好」,是赔钱、是合规问题、是权限事故,它们的期望值不允许你拿「这次改动应该无关」去赌。判断一套选例法成熟不成熟,看这一层就够了——它有没有随着「改动很小」被悄悄裁掉。
三层叠起来,一份带理由的选例清单就出来了。每条选例都要写清「为什么选中我」——这不是格式洁癖:没有理由字段,下一次就没人敢跳过任何一条;有了它,跳过某条时你能指着记录说「这条属于边界层,标签没被碰到,本轮不跑」,这句话才有资格被签字。理由字段还有一个副作用:它把「谁替这个决定负责」从群里的一句口头承诺,变成了流水线产物里的一行字。
一份能直接跑的选例脚本
下面这份脚本用标准库实现上面三层:内置一份小型场景库(同目录放 scenarios.jsonl 可自动接管),对两版提示词做最长匹配切词与词族差异,按三层规则挑例,输出带「选中理由」的清单,并把没被任何层覆盖的高危场景单独报警。
# prompt_impact_select.py —— 提示词热改的三层选例(纯标准库,开箱即跑)
import json
import os
# 词表 = 替代调用图的「节点」;真实项目里它就是场景库触发条件的并集
LEXICON = [
"退款到账", "转接人工客服", "转人工处理", "人工客服", "转人工",
"3-5个工作日", "48小时", "无理由退款", "七天内", "运费", "承诺",
"赔付", "投诉", "监管", "身份证号", "手机号", "冻结", "资金", "催",
]
LEXICON_SORTED = sorted(LEXICON, key=len, reverse=True) # 最长匹配优先
# 同词族:被改写时,族内任意词出现在触发条件里都算命中
SYN_GROUPS = [["转人工", "转人工处理", "转接人工客服", "人工客服"]]
RISK_CLASSES = ("钱", "隐私", "越权", "法律")
PROMPT_OLD = """你是客服助手。用户问退款时,先核对订单状态:七天内可以无理由退款,退款到账需要3-5个工作日。
如果用户催促,可以告知预计时间,但不要给出具体时间。遇到无法确认的情况,转人工处理。"""
PROMPT_NEW = """你是客服助手。用户问退款时,先核对订单状态:七天内可以无理由退款,运费一并退还,退款到账需要3-5个工作日。
如果用户催促,可以告知预计时间,并承诺48小时内给出处理结果。遇到无法确认的情况,转接人工客服处理。"""
SCENARIOS = [
{
"id": "S-101", "描述": "退款金额与运费", "标签": ["退款", "金额"], "触发条件": ["运费", "无理由退款"],
"边界": False, "历史过错": False, "风险类别": "钱", "高危": True},
{
"id": "S-102", "描述": "时效承诺边界", "标签": ["时效", "越权承诺"], "触发条件": ["承诺", "48小时"],
"边界": False, "历史过错": True, "风险类别": "越权", "高危": True},
{
"id": "S-103", "描述": "用户反复催促", "标签": ["时效", "情绪"], "触发条件": ["催", "承诺"],
"边界": True, "历史过错": True, "风险类别": "", "高危": False},
{
"id": "S-104", "描述": "用户威胁投诉监管", "标签": ["情绪", "升级"], "触发条件": ["投诉", "监管"],
"边界": False, "历史过错": False, "风险类别": "", "高危": False},
{
"id": "S-105", "描述": "身份信息核验", "标签": ["账户", "核验"], "触发条件": ["身份证号", "手机号"],
"边界": False, "历史过错": False, "风险类别": "隐私", "高危": True},
{
"id": "S-106", "描述": "赔付与保证话术", "标签": ["赔付", "保证"], "触发条件": ["赔付", "保证"],
"边界": False, "历史过错": False, "风险类别": "越权", "高危": True},
{
"id": "S-107", "描述": "监管口径表述", "标签": ["合规", "口径"], "触发条件": ["监管", "冻结"],
"边界": False, "历史过错": False, "风险类别": "法律", "高危": True},
{
"id": "S-108", "描述": "无法确认时的兜底转接", "标签": ["兜底", "转接"], "触发条件": ["转人工", "人工客服"],
"边界": True, "历史过错": True, "风险类别": "", "高危": False},
{
"id": "S-109", "描述": "账户冻结后的资金处置说明", "标签": ["资金", "冻结"], "触发条件": ["冻结", "资金"],
"边界": False, "历史过错": False, "风险类别": "", "高危": True},
{
"id": "S-110", "描述": "反复追问处理进度", "标签": ["时效", "低置信"], "触发条件": ["多久", "催"],
"边界": True, "历史过错": False, "风险类别": "", "高危": False},
]
def tokenize(text):
"""最长匹配切词:避免『转人工』把『转人工处理』切碎,制造假差异。"""
hits, i = [], 0
while i < len(text):
for w in LEXICON_SORTED:
if text.startswith(w, i):
hits.append(w)
i += len(w)
break
else:
i += 1
return hits
def family(word):
for g in SYN_GROUPS:
if word in g:
return set(g)
return {
word}
def diff_prompts(old_text, new_text):
old, new = set(tokenize(old_text)), set(tokenize(new_text))
added, removed = sorted(new - old), sorted(old - new)
rewritten = []
for a in list(added):
for r in list(removed):
if family(a) & family(r) and not (family(a) == {
a} and family(r) == {
r}):
rewritten.append([r, a])
added.remove(a)
removed.remove(r)
break
return {
"新增": added, "删除": removed, "改写": rewritten}
def hit_terms(scen, affected):
out = set()
for cond in scen["触发条件"]:
for term in affected:
if cond == term or cond in term or term in cond:
out.add(term)
return out
def select(scenarios, signals):
affected = set(signals["新增"]) | set(signals["删除"])
for r, a in signals["改写"]:
affected |= family(r) | family(a)
hit_ids, hit_tags = [], set()
for s in scenarios:
t = hit_terms(s, affected)
if t:
hit_ids.append((s, sorted(t)))
hit_tags |= set(s["标签"])
picked, seen = [], set()
for s, terms in hit_ids:
picked.append({
"层": "命中层", "id": s["id"], "描述": s["描述"],
"理由": "命中改动表述:" + "、".join(terms)})
seen.add(s["id"])
for s in scenarios:
if s["id"] in seen:
continue
if (s["边界"] or s["历史过错"]) and set(s["标签"]) & hit_tags:
kind = "低置信边界样本" if s["边界"] else "历史过错样本"
picked.append({
"层": "边界层", "id": s["id"], "描述": s["描述"],
"理由": kind + ",标签 " + "、".join(sorted(set(s["标签"]) & hit_tags)) + " 落在受影响面内"})
seen.add(s["id"])
for s in scenarios:
if s["id"] in seen:
continue
if s["风险类别"] in RISK_CLASSES:
picked.append({
"层": "不变层", "id": s["id"], "描述": s["描述"],
"理由": s["风险类别"] + "类常驻,不随改动缩小"})
seen.add(s["id"])
alerts = [s["id"] + " " + s["描述"] + ":标为高危但未归入四类,不变层兜不住"
for s in scenarios if s["高危"] and s["风险类别"] not in RISK_CLASSES]
skipped = [(s["id"], s["描述"], "改动未触及该组标签;非边界/过错样本;非四类常驻")
for s in scenarios if s["id"] not in seen]
return {
"affected": sorted(affected), "picked": picked, "alerts": alerts,
"skipped": skipped, "hit_tags": sorted(hit_tags)}
def load_scenarios(path="scenarios.jsonl"):
"""同目录放一份 scenarios.jsonl 即自动接管内置场景库。"""
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
return [json.loads(line) for line in f if line.strip()]
return SCENARIOS
if __name__ == "__main__":
scenarios = load_scenarios()
signals = diff_prompts(PROMPT_OLD, PROMPT_NEW)
print("变更信号:新增 %d / 删除 %d / 改写 %d(同族词已按词族扩展)"
% (len(signals["新增"]), len(signals["删除"]), len(signals["改写"])))
print("新增=%s 删除=%s 改写=%s" % (signals["新增"], signals["删除"], signals["改写"]))
res = select(scenarios, signals)
print("受影响标签:" + "、".join(res["hit_tags"]))
print("\n【今日跑】选例清单(%d 条)" % len(res["picked"]))
for p in res["picked"]:
print(" %s | %s | %-14s | ← %s" % (p["层"], p["id"], p["描述"], p["理由"]))
print("\n【本轮未选·存档】(%d 条)" % len(res["skipped"]))
for sid, desc, why in res["skipped"]:
print(" %s | %-14s | ← %s" % (sid, desc, why))
print("\n【ALERT】未被任何层覆盖的高危场景")
for a in res["alerts"]:
print(" - " + a)
with open("prompt_select_artifact.json", "w", encoding="utf-8") as f:
json.dump({
"变更信号": signals, "选例": res["picked"], "告警": res["alerts"]},
f, ensure_ascii=False, indent=2)
print("\n选例清单已写入 prompt_select_artifact.json(下次改动与它 diff,回答『上次为什么没跑这条』)")
变更信号:新增 3 / 删除 0 / 改写 1(同族词已按词族扩展)
新增=['48小时', '承诺', '运费'] 删除=[] 改写=[['转人工处理', '转接人工客服']]
受影响标签:兜底、情绪、时效、越权承诺、转接、退款、金额
【今日跑】选例清单(8 条)
命中层 | S-101 | 退款金额与运费 | ← 命中改动表述:运费
命中层 | S-102 | 时效承诺边界 | ← 命中改动表述:48小时、承诺
命中层 | S-103 | 用户反复催促 | ← 命中改动表述:承诺
命中层 | S-108 | 无法确认时的兜底转接 | ← 命中改动表述:人工客服、转人工、转人工处理、转接人工客服
边界层 | S-110 | 反复追问处理进度 | ← 低置信边界样本,标签 时效 落在受影响面内
不变层 | S-105 | 身份信息核验 | ← 隐私类常驻,不随改动缩小
不变层 | S-106 | 赔付与保证话术 | ← 越权类常驻,不随改动缩小
不变层 | S-107 | 监管口径表述 | ← 法律类常驻,不随改动缩小
【本轮未选·存档】(2 条)
S-104 | 用户威胁投诉监管 | ← 改动未触及该组标签;非边界/过错样本;非四类常驻
S-109 | 账户冻结后的资金处置说明 | ← 改动未触及该组标签;非边界/过错样本;非四类常驻
【ALERT】未被任何层覆盖的高危场景
- S-109 账户冻结后的资金处置说明:标为高危但未归入四类,不变层兜不住
选例清单已写入 prompt_select_artifact.json(下次改动与它 diff,回答『上次为什么没跑这条』)
为什么这么写。四处设计是刻意的。其一,切词用最长匹配优先(词表按词长倒序排),否则「转人工」会把「转人工处理」切碎,同一个词在两版里被切成不同片段,差异表立刻充满假信号——这个坑在中文提示词上比在英文上严重得多。其二,改写按词族扩展命中范围,宁宽勿漏:一句替换可能牵动一族表述,代价是选例量会虚高一点,这个代价换得起,漏掉一整族换不起。其三,命中判定用双向包含(cond in term or term in cond),因为场景库里的触发条件常写成短词,提示词里却是长句,纯等值比对会大面积漏命中。其四,也是最要紧的一处:脚本对「高危但没被任何层选中」的场景必须报警,上面输出末尾那条 S-109 就是——它在库里被标为高危,风险类别却是空的,不变层兜不住它。这类洞不会自己暴露,只会在某次改动真的碰到它时,你才发现从来没人给它安排过回归。
代码顺手把清单落成 prompt_select_artifact.json。这份产物是留给下一次的:下次改动跑完,与上一版 diff,就能回答「上次为什么没跑这条」——评审会上最难受的追问往往就是这一句,而没有存档,你只能靠记忆答。
三层各自的分工:为什么选中我,多久跑一次
| 层 | 为什么选它 | 条数量级 | 多久跑一次 | 谁可以跳过 |
|---|---|---|---|---|
| 命中层 | 触发条件被改了字面,含同族改写 | 与改动句数同阶,几条到十几条 | 每次提示词变更必跑 | 任何人都不可跳过 |
| 边界层 | 历史过错样本、低置信与拒答边缘样本,对规则松动最敏感 | 常驻池里按标签捞,通常十几条 | 每次提示词变更必跑 | 需在清单里写明理由并签字,无人签字不可跳 |
| 不变层 | 钱、隐私、越权、法律口径,出错的代价不可接受 | 固定小集合,十几条以内 | 每次变更必跑,另加每日定时跑 | 任何情况下不缩、不停 |
为什么不直接全量,为什么不随机抽
| 方案 | 漏检风险 | 时间成本 | 可解释性 | 能不能进流水线 |
|---|---|---|---|---|
| 全量重跑 | 最低 | 高,跑不完就顺延到第二天 | 强 | 能跑,但门禁时长不可控,改动当天给不出结论 |
| 随机抽 20 条 | 高,抽不到改动相关场景时等于没跑 | 低 | 弱,说不出为什么是这 20 条 | 能跑,但拦不住回归 |
| 影响面驱动三层选例 | 中,覆盖质量取决于词表与标签库 | 中,与影响面同步伸缩 | 强,每条自带理由,可签发 | 能跑,产物可存档、可 diff |
有一句分寸要补上:影响面驱动的定位是「日常改动的门禁」,不是全量的替身。模型换代、大版本上线之前,全量该跑还得跑;它真正省下来的,是那些「只改了两个词」的日子——而这类日子占大多数。还有一点:三层选例的质量上限由词表和标签库决定,这两样是有生命周期的资产;新增场景必须带上触发条件,否则它在任何一次改动里都不会被命中,等于躺在库里睡大觉。
提示词热改的成熟标志,不是你有没有跑,而是每一次跑完,你都能说清每条为什么跑、以及每条为什么不跑。