ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

通义千问Prompt调试工程化:评测集、结构化输出与回归

通义千问Prompt调试工程化:评测集、结构化输出与回归

大模型落地到具体业务里,最先撞上的墙往往不是算力、不是并发,而是那句"模型答得不对"。同一个通义千问接口,有人写三行 prompt 就能拿到能直接入库的结构化结果,有人改了两天还是时好时坏。差别不在文笔,在于有没有把 prompt 调试当成一件工程活来干——先定义什么叫"对",再控制变量,再记录,再回归。这篇东西就是我这些年反复踩坑后整理出来的一套流程:怎么搭一个够用的评测台,怎么写出结构化的 prompt 骨架,怎么调采样参数,怎么定位那些"明明写了却不听话"的怪问题。不管你是刚接上 API 想跑通第一个 Demo 的新手,还是已经在维护几十条 prompt 模板的老手,下面这些内容都能直接抄走改改就用。

1. 先把"调试"这件事拆开看

1.1 prompt 调试的本质是控制变量实验

很多人改 prompt 的方式是:跑一次,看着不对,删掉一句、加一句话,再跑一次,又不对,再加一句"请务必""一定要""非常重要"。改到最后 prompt 变成八百字,模型反而更懵。这不是调试,这是碰运气。

真正的调试是一次只动一个变量。你手上有至少这么几个可调的旋钮:system 指令、任务描述、约束条件、示例数量、示例内容、输出格式定义、temperature、top_p、max_tokens、是否开启思维链。一次动两个以上,你就无法判断是哪个改动带来的收益,甚至可能一个好改动被一个坏改动抵消,误以为是"这个方向没用"。

我自己的习惯是维护一张迭代记录表,每行一次实验:版本号、改动点(只写一个)、评测集得分、典型失败样本、结论。这张表看起来笨,但它是唯一能在两周后让你说清"为什么现在是这个版本"的东西。没有这张表,三个月后接手的人(很可能就是你自己)只能整段重写。

还有一个容易被忽略的点:prompt 调试的收益是有上限的。当你的评测集显示错误集中在"模型确实不知道这个知识"或者"这个任务需要多步外部检索"时,再改措辞就是浪费时间,该做的是补知识库、拆任务、加工具调用。判断这件事的依据只有一个——分类统计失败样本,看错误类型的分布。分布告诉你瓶颈在哪,而不是靠感觉。

1.2 三类常见失败:模型不行、prompt 不行、评测不行

出了badcase,第一反应总是"prompt 写得不好"。实际上失败来源至少分三类,处理方式完全不同。

第一类是模型能力边界。比如要求它从一段没有标注日期的合同里推断签署年份,或者做需要精确算术的多步计算,这类任务超出当前模型的能力范围,prompt 怎么写都只是提高一点点命中率。识别方法很直接:把同样的任务用最直白的描述给模型,如果它连"听懂任务"都做不到,那就是能力问题。

第二类是 prompt 表达问题。任务模型能做,但你没把约束说清楚,或者约束之间互相冲突。典型的就是既要求"输出简洁",又要求"详细说明理由",模型只能二选一或者折中,结果两头不讨好。

第三类最隐蔽:评测方式有问题。你的判分脚本用字符串完全匹配去校验模型输出,而模型多输出了一句"以上是分析结果",全判错。你以为是模型不行,其实是评测太死板。我见过最典型的案例是一个抽取任务,判分时用json.loads直接解析,模型输出带了 ```json 的代码块围栏,解析全失败,得分 0,团队差点把整个方案推翻,实际上只要加一行围栏清洗,准确率就从 0 跳到 92%。

所以调试的第一个动作不是改 prompt,而是把失败样本捞出来人工看二十条,归类。二十条样本花不了半小时,但能省掉两天瞎改。

1.3 通义千问的几处脾气要先摸清

通义千问这个系列模型有几处特性,不知道的话会在调试初期吃暗亏。

一是它对 system 指令的遵循度跟具体版本强相关。同一个 prompt,在轻量版和旗舰版上的表现差异可能很大,轻量版更容易忽略长 system 里的后半段约束。所以你的 prompt 在轻量版上跑得通,别默认在旗舰版上也一样;反过来更常见。做选型时一定用两三个不同规格的模型各跑一遍评测集,看性价比曲线,而不是"先用大的,跑通了再说"。

二是它对输出格式的响应。当你明确要求 JSON 时,通义千问支持通过参数约束返回 JSON 对象,这比自己写"请只输出 JSON"要可靠得多。但要注意,开了这个模式不等于字段一定对,它只保证语法合法,不保证 schema 符合你的预期,字段名写错、漏字段、类型不对依然会发生,所以解析后仍要做字段校验。

三是思维链开关。较新的版本支持在调用时控制是否输出思考过程。开着的确能提升复杂推理的表现,但会带来两个副作用:token 消耗上升,以及输出里混入思考段落导致你提取答案时正则匹配失败。我的做法是训练/复杂任务开,线上高频简单任务关,并且解析层永远做一次"取最终答案"的兜底逻辑。

四是 chat template 的格式。如果你用开源权重自己部署,必须严格按模型自带的对话模板拼 prompt,多一个换行、少一个结束符都可能让效果掉一截。用托管接口就没这个烦恼,但如果你要做本地化部署,先用官方模板跑通再动手改,别自己发明格式。

2. 动手前先搭评测台:没有度量就没有调试

2.1 一个够用的小评测集怎么攒

我最反对的做法是"边改边看",因为人脑记不住十次实验的结果,而且人眼对小样本的偏好会严重误导判断——你刚好看到的三个例子可能完全不代表整体分布。

评测集不用一上来就搞几千条。我的经验是:30 到 50 条真实样本就足够支撑第一轮迭代,但必须满足两个条件——来自真实业务分布,而不是自己编的;覆盖至少 3 到 5 种典型场景和 3 种以上的边界情况。

具体攒法:从线上日志、用户提问、客服工单里随机抽。抽的时候刻意分桶,比如一个分类任务,先按类别分层抽,保证每个类别都有样本;一个抽取任务,先按文档长度分桶,短中长各来几条。边界情况优先选这几类:空输入、超长输入、格式混乱的输入、包含多个答案的输入、以及与任务无关的闲聊输入。这几类占了线上 badcase 的一大半。

评测集要做版本管理,且冻结。一旦用于对比实验,就不要在中途往里加样本,否则前后得分不可比。要扩充就新开一个版本号,全量重跑。这个纪律听起来小事,但实际项目里十有八九是因为中途偷偷换了评测集,导致大家对着两个不可比的数字吵半天。

另外补一句:小评测集的分数波动大,30 条样本里错 1 条就是 3.3 个百分点。所以看分数的时候,别盯着小数点后一位,关注的是"从 62% 到 78%"这种量级的差异,以及具体失败样本的变化。3 个点的浮动很可能只是随机性。

2.2 打分维度与判定标准

单一准确率对很多任务不够用。我一般拆成四层来看。

维度考察什么判定方式常见误区
格式合规输出能否被程序解析脚本硬校验,pass/fail用模糊匹配代替结构校验
内容正确关键信息是否命中标准答案比对或人工标注只看关键词不看语义
完整度该抽的字段是否都抽到字段级覆盖率统计与正确率混算成一个数
冗余度是否输出了不该有的内容长度统计 + 人工抽检完全忽略,导致回答啰嗦

格式合规必须脚本硬校验,这是我的底线。因为你最终是要把结果喂给下游系统,格式不过关等于没产出。内容正确建议用"标准化后的精确匹配 + 人工抽检"的组合,如果预算允许,再用一个更强的模型做裁判打分,但裁判 prompt 本身也要调,别把裁判当成真理。

人工标注这块要说个实话:标注一致性比标注量重要。两个人标同样的 30 条,如果有 5 条判断不一致,说明你的判定标准写得不够细。先把标准细化到"什么情况算对、什么情况算部分对",再去标。我一般会在评测集旁边附一份判分说明,写明边界情况的处理方式,两三页纸,但能省掉大量返工。

2.3 用一个脚本把批量跑通

有了评测集,就得能一键跑完。手动一条条贴进对话框是不现实的,也无法记录参数。下面这个脚本结构我用了很多年,改改就能用:

import json, os, time from openai import OpenAI client = OpenAI( api_key=os.environ["QWEN_API_KEY"], base_url=os.environ["QWEN_BASE_URL"], # 兼容模式接口地址 ) def run_one(prompt_tpl, item, params): user = prompt_tpl["user"].format(**item) resp = client.chat.completions.create( model=params["model"], messages=[ {"role": "system", "content": prompt_tpl["system"]}, {"role": "user", "content": user}, ], temperature=params["temperature"], top_p=params["top_p"], max_tokens=params["max_tokens"], seed=params.get("seed"), ) return resp.choices[0].message.content def evaluate(prompt_tpl, dataset, params, tag): rows = [] for i, item in enumerate(dataset): t0 = time.time() try: out = run_one(prompt_tpl, item, params) err = None except Exception as e: out, err = "", repr(e) ok_fmt = check_format(out) # 你自己实现的格式校验 ok_content = check_content(out, item.get("gold")) rows.append({ "idx": i, "input": item, "output": out, "fmt_pass": ok_fmt, "content_pass": ok_content, "err": err, "cost_ms": int((time.time() - t0) * 1000), }) with open(f"runs/{tag}.jsonl", "w", encoding="utf-8") as f: for r in rows: f.write(json.dumps(r, ensure_ascii=False) + "\n") n = len(rows) print(f"[{tag}] fmt={sum(r['fmt_pass'] for r in rows)/n:.3f} " f"content={sum(r['content_pass'] for r in rows)/n:.3f}") return rows

几个实操要点。把每次运行的完整结果落盘成 jsonl,不要只存分数。因为分数只告诉你"变差了",落盘的原始输出才能告诉你"为什么变差"。我通常按版本号_时间戳命名,跑完顺手把 diff 也存下来。

给评测集固定 seed,同时给你的运行也传 seed(该参数在接口里受支持时)。大模型采样有随机性,同一个 prompt 跑两次结果可能不同,固定 seed 能让对比更公平。但别指望 seed 能让结果完全确定,分布式推理下仍可能有细微差异,所以关键结论最好跑两次确认。

把耗时也记下来。prompt 改了之后准确率涨了两个点,但平均输出长度翻倍、成本涨了三倍,这在业务上往往是不可接受的。成本、延迟、质量三角,调试时必须同时看。

3. 结构化 prompt 的骨架

3.1 角色、任务、约束、格式四件套

写 prompt 最稳的结构就是这四块,顺序也基本固定:先说身份和背景,再说要做什么,再说不能做什么,最后说输出成什么样。

身份设定这块要克制。很多人喜欢写"你是一位拥有二十年经验的资深专家",实测下来这类空泛的身份描述收益很小,甚至有时会诱导模型输出更多套话。真正有用的身份信息是领域和视角,比如"你是电商售后规则审核员,判断依据只有随附的规则条款",这种身份带来的约束是实质性的。

任务描述要具体到可执行。对比一下:"分析用户评论的情感"和"判断评论是正面、负面还是中性;如果同时包含对商品和物流的评价,只针对商品部分判断"。后者才叫任务描述,前者只是任务名称。

约束条件是最容易被写废的部分。三条原则:能写成可判定的规则,就别写成形容词;约束之间不能互相打架;数量不超过五条。超过五条,模型对后面几条的遵循度会明显下降,这时候应该做的是拆任务,把 prompt 分成两段调用,而不是硬塞。

输出格式部分,给出一个完整的、可以直接照抄的样例,比描述十句都管用。样例要包含每一个字段,不要用"等等""其他字段同理"这类省略,模型会真的按省略来。

提示:如果你发现某条约束总是被忽略,先把它从段落里提出来,单独成行,用"必须""禁止"这类强词开头。位置也很关键,放在任务描述之后、输出格式之前的约束,遵循度通常高于埋在长段落中间的约束。

3.2 few-shot 示例怎么挑、怎么排

示例的作用不是教模型知识,而是定调——告诉它输出的粒度、语气、格式、边界处理方式。所以示例的挑选标准不是"典型",而是"覆盖"。

我的做法是:3 到 5 个示例,其中至少一个覆盖边界情况。比如一个信息抽取任务,两个正常示例,一个"信息缺失时输出 null"的示例,一个"输入与任务无关时输出空数组"的示例。后者往往比前两个更重要,因为模型在没见过的情况下,倾向于硬编造一个答案。

示例的排列顺序有影响。最后一个示例的示范效应最强,所以把最希望模型模仿的那个放最后。同时注意示例之间的一致性——如果示例 A 的字段顺序是 a,b,c,示例 B 是 b,a,c,模型会困惑,输出格式的波动会变大。

还有一个反直觉的经验:示例不是越多越好。超过 6 个之后,边际收益迅速下降,而 token 成本和"过度模仿"的风险上升。我遇到过示例给太多导致模型把示例里的具体值也抄进输出的情况,尤其是示例里的数字、日期这类字面量。排查时先看输出里有没有出现示例中的值,这是最快的判断方法。

如果任务本身有很强的格式要求,示例可以省,直接换成一份完整的输出模板,让模型填空。省下来的 token 用来放输入内容更划算。

3.3 让模型"先想后答"的分寸

思维链不是万能药。它在多步推理、需要校验、需要做取舍的任务上收益明显;在简单的分类、抽取、改写上,收益小甚至会带来副作用。

副作用主要体现在三方面:输出变长导致成本和延迟上升;思考内容混进结果导致解析出错;以及模型在思考过程中"说服自己"给出一个错误的结论——这在有倾向性的问题里特别明显。

我的判断标准很简单:如果这个任务一个熟练的人需要想一下才能答对,就开;如果需要脱口而出,就关。开了之后,务必在 prompt 里明确要求"先分析,最后用固定标记引出答案",例如要求最终答案放在"结论:"之后的行。解析时只取标记之后的内容,前面的思考过程丢弃。这样既拿到了推理收益,又不影响下游解析。

对于支持独立控制思考开关的模型版本,优先用接口参数控制,而不是靠 prompt 里写"请不要思考"。后者经常失效,因为它是在跟模型的后训练偏好较劲,不如直接从机制上关掉。

3.4 结构化输出的硬约束

要求模型输出 JSON,可靠性的层级大概是这样的:接口参数级别的格式约束 > prompt 里给完整 schema 样例 > prompt 里描述字段。能往上走一层就走一层。

即使开了接口级的 JSON 模式,我仍然会在 prompt 里给一份字段说明,写清每个字段的类型、取值范围、缺失时的表示方式。原因是参数只保证语法合法,不保证业务语义对。字段说明里最值得花时间的是这三句:

  • 缺失信息统一输出 null,不要输出"未知""暂无""N/A"。
  • 数组字段若无内容输出 [],不要省略该字段。
  • 不要在 JSON 之外输出任何解释性文字。

然后在解析层做三重兜底:先尝试直接json.loads;失败则剥离 ```json 围栏和前后多余文本,再试;仍失败则取第一个{到最后一个}之间的内容,再试。三重兜底能吃掉九成以上的解析失败。剩下的那部分,把原始输出存下来,作为 badcase 进评测集。

4. 参数与调用层的那些坑

4.1 采样参数不是玄学,是有明确分工的

temperature 和 top_p 是两套不同的截断机制,同时调大或调小容易互相干扰。我的习惯是只调一个,另一个保持默认或设为中性值。

具体建议:抽取、分类、格式转换这类任务,temperature 设 0 到 0.3,让输出尽量收敛;创意写作、头脑风暴设 0.7 到 1.0。top_p 在 temperature 已经调低的情况下保持 0.8 左右即可,不必要再压到 0.1。

有个细节值得说:temperature 设为 0 并不等于"完全确定"。后端批处理和浮点误差仍可能带来差异,所以做严格对比实验时,除了固定 seed,还要对同一配置跑两次,看两次结果的分歧率。如果分歧率超过 5%,说明这个任务的输出本身对采样太敏感,应该回头看 prompt 是不是约束不够。

max_tokens 的设置经常被忽略。设得太小,输出被硬截断,JSON 不完整导致解析失败;设得太大,不影响成本(成本按实际生成算),但会让某些模型在达到长度后仍试图凑内容,输出一些废话。我的做法是:先用一个宽裕的值跑评测集,统计输出的长度分布,取 P99 的 1.5 倍作为线上值,同时在 prompt 里加一句"输出不要超过 N 字"。

4.2 长文本、截断与上下文预算

上下文快到上限时的行为很多人没测过。输入加上输出逼近模型上限时,有的情况是报错,有的是静默截断输入,有的是输出到一半停住。这三种情况在你的代码里表现完全不同,必须提前测。

我的处理方式是:在调用前估算 token 数(用模型的 tokenizer 或粗略按字符数折算,中文大约 1 字 ≈ 0.6 到 0.7 个 token,估个大概就行),超过阈值就做处理,而不是让接口去决定。处理策略按任务定:摘要类任务做分段摘要再合并;抽取类任务优先保留与目标字段相关的段落;问答类任务先做检索筛选。

还有一个高频坑:把长文档放在 system 里。system 内容在很多实现中会被缓存和复用,但一旦你每次请求的 system 都不同,缓存失效,成本和延迟都会上升。长文档应该放 user 消息里,system 保持稳定。

4.3 system 与 user 的分工

分界线我总结成一句话:system 放不变的规则,user 放变化的数据。

规则包括角色、任务定义、约束、输出格式;数据包括本次要处理的具体文本、本次的用户提问、本次的参数。这样分的好处是,同一套 system 可以复用于成千上万次请求,方便做缓存,也方便做 A/B 测试——你只需要换 system 版本,user 侧的数据完全不动。

反过来,把每次变化的数据写进 system 是最常见的坏味道。它会让你的 prompt 无法做版本对比,也让缓存形同虚设。我接手过一个项目,system 里拼了用户昵称和订单号,导致每条请求的 system 都不一样,缓存命中率接近零,改完之后平均延迟降了一半多。

还有一点:别在 user 消息里重复 system 的约束。重复不会提高遵循度,反而会让模型认为这条约束特别重要,过度反应。比如在 system 里说"输出简洁",user 里又说"不要啰嗦",模型可能直接砍到只剩关键词,丢失必要信息。

5. 一次完整的迭代实录

5.1 需求拆解与初版 prompt

拿一个具体场景走一遍:从商品评价里抽取"评分态度、问题类型、是否要求退货"三个字段,输出 JSON。输入是几十到几百字的自由文本。

第一步拆解任务,我发现三个字段的判断难度差别很大:态度判断最简单,问题类型需要归类到预定义的六类,是否要求退货最微妙,因为"我想退"和"这个能退吗"完全不同。所以约束里必须明确后者的判定标准。

初版 prompt 我写得很朴素,就三段:任务、字段说明、一个示例。

system: 你是电商评价信息抽取助手。从用户评价中抽取结构化信息。 字段定义: - attitude: 用户对商品的态度,取值 positive / negative / neutral - issue_type: 问题类型,取值 quality / logistics / description_mismatch / size / service / none - refund_intent: 是否明确要求退货,取值 true / false 只输出 JSON,不要输出任何其他内容。 user: 评价内容:{text}

跑 40 条评测集,格式合规 100%,内容全对只有 62%。捞 badcase 看,错误集中在三处:把"物流慢但东西好"判成 negative;issue_type 在涉及多个问题时只填一个;把"能退吗"判成 refund_intent=true。

5.2 迭代记录表

第一轮改动只动一处:在字段定义里加多问题处理规则。

版本改动点格式合规内容全对主要失败类型
v1初版100%62%多问题只填一个;疑问句误判退货
v2attitude 改为"仅针对商品本身"100%71%疑问句误判退货
v3issue_type 允许多值数组100%74%退货意图判定的粒度问题
v4refund_intent 增加三种边界说明100%86%长文本尾部信息漏抽
v5增加"信息缺失输出 null"示例100%89%少量长文本仍漏

v2 的改动很关键:把态度判断的范围从"整条评价"收窄到"商品本身",物流不满不再污染商品态度。这一条规则写清楚,直接涨了 9 个点。这也印证了前面说的——约束要写成可判定的规则,"仅针对商品本身"是可判定的,"准确判断态度"不是。

v3 把 issue_type 从单值改成数组,同时更新了示例。这一步付出的代价是下游解析要改,但收益是消除了"多个问题只能填一个"的系统性错误。类似的结构性改动,一定要在早期做,越晚改成本越高。

v4 是收益最大的一步。我把 refund_intent 的判定标准写成三条:

  • 出现"我要退""申请退货""已经退了"等明确动作表述,判 true。
  • 出现"能退吗""可以退吗""退货流程是什么"等询问表述,判 false,并在 reason 字段说明是咨询。
  • 仅表达不满但未提及退货,判 false。

加完之后,这一类错误基本清零。经验:凡是取值只有两三个、但边界模糊的字段,都值得单独写一段判定标准加反例。

5.3 上线前的回归与灰度

v5 之后,我做了三件事才敢上线。

一是换一批没参与调试的样本做验证。前面 40 条已经被我反复看着改,多少有过拟合的风险。我从新一批日志里抽了 30 条,只跑一次,结果 87%,比评测集低 2 个点,在可接受范围内。差的这 2 个点我看了样本,是两条超长评价,尾部信息被忽略。

二是做参数敏感性测试。固定 prompt,把 temperature 在 0、0.3、0.7 三档各跑一遍,看内容准确率的变化。如果从 0 到 0.7 掉了 10 个点,说明 prompt 的约束太松,靠采样收敛在撑,这种 prompt 上线后遇到稍有不同的输入就会崩。

三是加线上兜底。解析失败、字段缺失、取值不在枚举范围内,一律走降级路径:转人工或者返回默认值并打点。同时在日志里记录 prompt 版本号,这样出问题时能立刻定位是哪个版本、什么时候开始出问题。

灰度阶段我一般放 5% 到 10% 流量,跑够样本量再看指标。看的不只是准确率,还有输出长度分布、平均耗时、解析失败率。这几个指标里,解析失败率是最灵敏的报警信号,它通常在准确率下降之前就先抬头。

6. 常见问题与排查技巧

6.1 指令明明写了,模型就是不执行

这是最高频的困惑。排查顺序建议按这个来。

先看指令位置。埋在长段落中间的约束,遵循度就是低。把它提到段首或单独成行,往往立刻生效。

再看指令是否可判定。"回答要专业"这种没法判定,"使用行业术语,不使用口语词如'挺好的''不错'"就可判定。不可判定的指令,模型只能猜,猜的结果就是时好时坏。

然后看指令之间有没有冲突。我见过一个 prompt 同时要求"输出尽可能完整"和"控制在 100 字以内",模型在长输入上就开始随机取舍。用脚本扫一遍你的约束,把互相拉扯的找出来,删掉或者明确优先级。

最后看数量。超过五条强约束,后面的基本形同虚设。这时候正确的做法是拆成两次调用:第一次做抽取,第二次做格式化。多一次调用增加的那点延迟,比反复调试省下的时间划算得多。

还有一个偏门但有效的技巧:把最重要的约束放在 user 消息的最后一行。因为模型对靠近生成位置的上下文更敏感。这个技巧在长输入场景下效果明显。

6.2 同一条输入,两次结果不一样

先确认这是不是问题。创意类任务本来就该有差异,正文类任务如果差异体现在措辞上、语义一致,也可以接受。真正要修的是语义层面的漂移,比如一次判 positive 一次判 negative。

定位方法:把 temperature 降到 0,固定 seed,跑同一条输入 5 次。如果还有语义漂移,问题在 prompt,通常是某个字段的判定标准不明确,模型在边界上摇摆。如果降 temperature 后稳定了,说明是采样参数问题,回去调参数。

另一个常见原因是输入本身的歧义。比如"这个价格有点高但还能接受",态度到底是 neutral 还是 negative,取决于你的业务定义。这种情况不是模型的问题,是标注标准的问题。解决办法是在 prompt 里显式写出这类边界情况的判定规则,而不是指望模型猜。

6.3 JSON 解析失败的花式原因

按出现频率排序,我遇到的大致是这几种。

围栏包裹,就是输出带了 ```json。这个最常见,解析前统一剥离。

前后有多余文字,比如"好的,以下是抽取结果:"。要求模型只输出 JSON 能缓解,但不彻底,解析层做截取是必需的。

单引号、尾逗号、未转义的引号。这类是模型生成的语法问题,严格 JSON 模式下很少见,非严格模式下常见。用宽容一点的解析器(比如支持尾逗号的解析库)能吃掉一部分。

输出被截断。检查 finish_reason,如果是长度导致的截断,说明 max_tokens 不够,或者模型在 JSON 前后写了很多废话。后者要在 prompt 里明确禁止。

字段类型不对。比如要求数组给了字符串,要求布尔给了 "true" 字符串。这个只能靠解析后的 schema 校验来发现,用 pydantic 之类的工具做一层校验,不通过就走重试或降级。

注意:加"如果解析失败就重试"的逻辑时,一定要设重试上限(我一般 2 次),并且重试时把 temperature 调低。否则在模型确实无法产出合法格式的情况下,会陷入无限重试,把成本和延迟都拖垮。

6.4 幻觉:编造了输入里没有的信息

抽取类任务里,幻觉最典型的表现是"补全"——输入里没写品牌,输出里给了个品牌;输入里没说数量,输出里给了个 1。

根治手段是在 prompt 里给出明确的缺失值约定,并且配上示例。前面说的"缺失统一输出 null",配合一个"输入信息不全时输出 null"的示例,能消掉大部分。

第二个手段是要求模型引用原文片段。让它在输出里带上一个 evidence 字段,填支持该结论的原文。这一招对抑制编造效果很好,因为你等于要求它为每个结论找到出处,找不到出处时它更倾向于输出 null。副作用是输出变长,成本上升,看任务重要性权衡。

第三个手段是在解析层做交叉校验。比如抽出来的日期,检查是否在原文字符串里出现过(做一下归一化再比)。没出现过的标记为可疑,走人工复核。这条规则很粗暴,但实测能拦下相当一部分编造。

6.5 排查速查表

现象优先检查常见根因处理动作
约束不生效指令位置与数量埋在长段落、超五条提前、拆任务
输出格式乱是否给了完整样例只有描述没有样例补一个完整样例
JSON 解析失败finish_reason被截断 / 有围栏加大 max_tokens、清洗
结果忽好忽坏temperature 与 seed采样太散降温、固定种子
编造信息缺失值约定无 null 规则加约定 + evidence
长输入漏抽输入位置关键信息在尾部分段处理或调序
成本突增输出长度分布思考过程未关关思考、限字数
示例内容被抄输出里的字面量示例过度模仿减少示例、换值域

7. 把 prompt 当代码管

7.1 目录结构与版本号

prompt 存在聊天记录里、存在文档里,是团队协作最大的浪费。我的做法是把它当成代码文件放在仓库里:

prompts/ extract_review/ system_v5.txt user_v5.txt schema_v5.json examples_v5.jsonl meta.yaml # 版本、作者、评测分、变更说明 tests/ extract_review/ dataset_v3.jsonl # 冻结的评测集 check.py # 格式与内容校验

meta.yaml 里必须记录这次版本改动的内容和评测结果。改动说明要写成"增加了 refund_intent 的三种边界判定",而不是"优化 prompt"。前者半年后还能看懂,后者等于没写。

版本号我习惯用整数递增,不用语义化版本。因为 prompt 的改动很难判断是"新增功能"还是"修复问题",硬套语义化版本只会让命名变成负担。整数递增配合变更说明,够用了。

7.2 让回归跑在提交前

只要有评测集和一键脚本,把回归挂到提交环节是顺理成章的。我的阈值设置比较松:内容准确率下降超过 3 个点就拦下来,格式合规率下降超过 1 个点就拦。太严会导致每次微小改动都要走特批,反而让人绕过流程。

回归脚本除了跑分数,还要输出逐条对比:这次跑的结果和上次跑的结果,哪些样本从对变错、哪些从错变对。从对变错的样本是重点,逐条看完再决定是否合入。我遇到过准确率持平但有三条从对变错、三条从错变对的情况,看起来是等价交换,实际看过之后发现变错的都是长文本,说明新版本在长输入上有隐患,最终没有合入。

7.3 线上监控与 prompt 的"老化"

模型服务会升级,你的 prompt 不会自动跟着升级。我遇到过接口侧模型版本更新后,一个原本 90% 的 prompt 掉到 78%,原因是新版本对输出格式的遵循方式有细微变化。所以模型版本号要显式锁定,不要用会漂移的别名,同时在服务端记录每次请求实际使用的版本。

监控指标我建议至少这几个:解析失败率、字段缺失率、输出长度 P50/P99、平均耗时、以及一个抽样的人工评分。前四个是自动的,能覆盖绝大多数突发问题;人工评分每周抽几十条,用来发现缓慢的质量下滑。

还有一个容易被忽视的监控:输入分布漂移。你的 prompt 是针对某类输入调的,如果线上输入分布变了(比如新增了一个商品类目,评价的表述方式完全不同),准确率会悄悄下降,而所有技术指标都正常。做法是定期对输入做聚类,看有没有新的簇出现。这个动作成本不高,但能提前发现很多问题。

最后分享一个我个人坚持的习惯:每次大改 prompt 之前,先把当前版本原样备份一份,命名带上"last_known_good"。调试是探索性的,经常会走进死胡同,有一个确定的回退点,能让你放心大胆地试。踩过的坑里,有一半是因为改到一半发现不如以前,而以前那版已经被覆盖掉了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进