起因:一次调用成本的对账
我手上有个后台工具,主要工作是把用户提交的一段自然语言需求,转成结构化的任务配置。输出格式固定,大概长这样:
{"task_type":"extract","target":"title","params":{"max_length":50}}早期为了让它稳定输出这个结构,我在每次请求里带了三组 Few-shot 示例,一正两反,加上说明文字,光示例部分就占了 400 多 Token。单看一次没什么感觉,但这个东西每天要跑几万次,示例 Token 是每次都重复付费的。
我算了一笔账:假设输入 400 Token、输出 60 Token,示例占了输入的绝大部分。如果能把示例去掉、改成 System Prompt 里一次性说清楚规则,那每次请求的输入能压到 100 Token 以内。调用量越大,省得越多。
于是我做了一轮改造,把 Few-shot 从请求里挪到 System Prompt,用规则和字段约束来替代示例。这篇就是这次改造的记录——哪些规则真的有用,哪些其实是在自欺欺人。
System Prompt 和 Few-shot 到底差在哪
先把两者的定位说清楚,不然后面容易混。
Few-shot 的本质是用示例演示分布。模型看到几个输入输出对,会去模仿这个映射关系。它的优点是直观,模型对"长什么样"有具体参照;缺点是每次请求都要重复付示例的 Token。
System Prompt 的本质是用规则约束行为。它告诉模型"你是谁、你要做什么、输出必须满足什么条件",但不给具体样例。优点是写一次可以复用(如果用了 Prompt Caching 还能进一步省钱),缺点是模型对抽象规则的理解不如对具体示例稳定。
这里有个容易被忽略的点:System Prompt 不是免费的。它一样占输入 Token,只是它不随请求变化,可以被缓存,也可以在多轮对话里只算一次。如果你每次都是单轮调用、又没开缓存,那 System Prompt 和 Few-shot 在 Token 上的差别没那么大,省的主要是"示例比规则啰嗦"这部分。
我这次改造真正省下来的,是示例本身比规则长这件事,加上一部分缓存收益。
把示例改写成规则的具体做法
我没有直接把三组示例删掉换成一句"请输出 JSON",那样模型会飘。我的做法是把示例里隐含的约束一条条抽出来,变成显式规则。
原来的 Few-shot 里,示例其实在传递这几件事:
- 输出必须是合法 JSON,不能有 markdown 代码块包裹;
task_type只能是枚举里的某几个值;params里的字段随task_type变化;- 不确定的字段要省略,不能瞎填。
第 1、2、4 条可以直接写成规则,第 3 条比较麻烦,因为它是"条件结构",光靠一句话模型容易漏。
我最终的 System Prompt 大致是这样组织的(这里用的是通用的角色设定写法,不绑定具体厂商 API):
你是一个任务配置解析器。用户输入一段自然语言需求,你输出一个 JSON 对象。 输出规则: - 只输出 JSON 本身,不要 markdown 代码块,不要任何解释文字。 - 顶层字段固定为 task_type、target、params 三个。 - task_type 只能取以下值之一:extract、transform、validate。 - target 是一个字符串,表示操作对象,例如 title、content、metadata。 - params 是一个对象。当你不确定某个参数时,直接省略该键,不要填 null 或空字符串。 - 如果用户需求无法映射到上述 task_type,输出 {"error": "unsupported"}。 字段约束: - extract 的 params 允许 max_length(整数,1-500)。 - transform 的 params 允许 mode(枚举:upper、lower、trim)。 - validate 的 params 允许 rules(字符串数组,元素只能是 non_empty、max_length)。 只输出 JSON,不要输出其他内容。这段比原来的三组示例短了不少。我实测下来(在同一个模型、同一批测试输入上),去掉示例后输出结构的合法率没有明显下降,但确实出现了新的失败模式,下面讲。
规则能顶替示例,但有边界
改造之后我跑了一批真实输入,发现规则和示例的能力差异集中在几个地方。
规则擅长的:固定格式、枚举、简单嵌套。上面那些"只能取哪些值""不要包代码块"的约束,规则比示例更清晰,因为它把边界写死了,模型不用从示例里猜。
规则吃力的:条件分支。比如params的字段随task_type变化,我在提示里是分三段写的,模型大多数时候能对上,但偶尔会把extract的max_length塞进validate里。这种情况示例其实更有效,因为示例直接展示了"extract 长这样、validate 长那样",模型照抄结构就行。
规则基本无效的:需要判断力的模糊场景。比如用户说"把标题弄短一点",到底算 extract 还是 transform?这种语义判断,示例里如果有一个"弄短一点 → extract + max_length"的对照,模型学得会更快。光靠规则描述,模型只能靠猜。
所以我的结论是:规则替代示例,适合输出格式固定、字段枚举明确、分支不深的场景。一旦涉及条件结构和语义判断,纯规则会掉质量,这时候要么保留少量示例,要么用 JSON Schema 之类的强约束手段。
用 Schema 把规则再收紧一层
光靠自然语言规则,模型还是可能跑偏。如果调用方支持结构化输出(比如某些 SDK 提供的 JSON Schema 约束或 function calling),那最好把规则和 Schema 结合,让格式层面的错误由引擎兜底,而不是全压在提示词上。
我这边用的是把 Schema 作为提示的一部分附上,配合后置校验。Schema 本身用 Python 字典描述:
TASK_SCHEMA={"type":"object","properties":{"task_type":{"enum":["extract","transform","validate"]},"target":{"type":"string"},"params":{"type":"object"},},"required":["task_type","target"],}PARAM_RULES={"extract":{"max_length":(int,1,500)},"transform":{"mode":("enum",["upper","lower","trim"])},"validate":{"rules":("list",["non_empty","max_length"])},}注意这里我没有直接把 Schema 塞进提示让模型自己看,因为不同模型对原始 JSON Schema 的"阅读能力"差别挺大。我把它当作后置校验的依据,同时把关键约束用自然语言在 System Prompt 里重述一遍。相当于提示词负责"引导",校验层负责"兜底"。
校验函数大致长这样:
defvalidate_output(obj:dict)->tuple[bool,str]:ifobj.get("task_type")notin("extract","transform","validate"):returnFalse,"task_type 非法"ifnotisinstance(obj.get("target"),str):returnFalse,"target 必须是字符串"params=obj.get("params",{})ifnotisinstance(params,dict):returnFalse,"params 必须是对象"rules=PARAM_RULES.get(obj["task_type"],{})forkey,valinparams.items():ifkeynotinrules:returnFalse,f"{obj['task_type']}不支持参数{key}"spec=rules[key]ifspec[0]isint:ifnotisinstance(val,int)ornot(spec[1]<=val<=spec[2]):returnFalse,f"{key}超出范围"elifspec[0]=="enum":ifvalnotinspec[1]:returnFalse,f"{key}取值非法"returnTrue,""校验失败时的处理策略我犹豫过一阵:是直接报错让上游重试,还是把错误信息回填给模型让它自己改。实测下来,把错误信息作为一次追问回填,修复成功率不错,但会多一次调用。如果调用量很大、又对延迟敏感,直接失败重试可能更划算。这个取舍取决于你的场景,我没有一个通用答案。
省了多少,怎么测的
Token 的节省是可以直接算的,不用靠感觉。
改造前,每次请求的输入包含系统说明 + 三组示例 + 用户输入。改造后,输入是系统说明(规则版)+ 用户输入。我用同一批 200 条真实需求分别跑了两版,统计输入 Token 的平均值。
结果上,示例部分大约占了改造前输入的一半以上,具体比例和示例长度有关。我这里没有把精确数字写出来,因为不同模型的 tokenizer 不一样,写死了反而误导。可以确定的是:示例越长、调用量越大,省得越明显;如果示例本来就只有一两行,那改造收益有限。
质量方面我盯的是两个指标:JSON 解析成功率、字段合法率。改造后解析成功率基本持平,字段合法率略微下降,主要就掉在条件分支那个坑上,也就是params字段串台。后来我在 System Prompt 里把那三个分支拆得更开、每个分支单独起一段,情况好转了一些。
【关键结论】用 System Prompt 替代 Few-shot,在格式固定、枚举明确的场景下能省下可观的输入 Token,但条件结构和语义判断这两类约束仍然依赖示例或 Schema 兜底。纯规则不是万能替代。
几个实际写规则时的注意点
写规则版的 System Prompt,有几个地方和写示例的直觉不太一样。
规则要写成"约束"而不是"建议"。“尽量输出 JSON"和"只输出 JSON,不要其他内容”,模型的表现差别很大。前者模型会给你加解释,后者更听话。措辞上少用"尽量"“可以”“建议”。
枚举值要写全,不要写"等"。写"task_type 可以是 extract、transform 等"这种,模型会自己发挥造出新值。要么全列,要么明确说"只能取以下值"。
否定规则要具体。“不要输出多余内容"这种太虚,模型不知道什么是多余。改成"不要 markdown 代码块”“不要解释文字”“不要前后加引号”,每一条都指向一个具体的错误行为,效果好很多。
别指望一条规则覆盖所有分支。条件结构最好一段一段写,每段对应一种情况。挤在一句话里,模型漏的概率明显上升。
【踩坑提醒】System Prompt 里的规则和你的后置校验必须是同一套逻辑。我一开始改提示词改得勤,但校验函数忘了同步,结果出现了"提示说允许、校验说非法"的错位,排查时反而更绕。两边要么共用一份配置,要么改的时候一起改。
什么时候还是得留 Few-shot
不是所有场景都适合把示例删干净。我整理了一下判断标准:
- 输出是固定结构、字段枚举清晰、没有条件嵌套 → 用规则,删示例;
- 有条件分支,但分支数量少 → 规则为主,每个分支可以保留一个最小示例;
- 涉及语义分类、意图判断、风格模仿 → 示例更有效,规则只能辅助;
- 输出格式复杂到自然语言写不清楚 → 优先考虑结构化输出能力或 Schema 约束,而不是堆规则或示例。
这里有个现实考量:如果调用量不大,省下来的 Token 钱可能还不如你花在调试规则上的时间值钱。Few-shot 虽然费 Token,但它直观、好调、见效快。要不要改,取决于调用量和维护成本的平衡,不是"规则一定比示例高级"。
顺带说说缓存
如果你用的模型平台支持 Prompt Caching,那 System Prompt 的固定部分是可以被缓存的,重复调用时这部分不计费或按折扣计费。这时候把约束放进 System Prompt 的收益会更大,因为它是"写一次、缓存住、长期复用"的。
但要注意:任何改动都会让缓存失效。我调整规则措辞的那几天,缓存基本一直在重建,那段时间的成本反而上升了。所以规则定稿之后,尽量别再频繁动它。这一点不同平台的缓存机制细节不一样,具体计费规则我没逐个验证,用之前建议查一下对应平台的文档。
结尾
这次改造给我最大的感受是:Few-shot 和 System Prompt 不是二选一,而是各自负责不同类型的约束。格式和枚举交给规则,条件结构和语义判断交给示例或 Schema,后置校验兜底。把职责分清楚之后,Token 省下来了,质量也没塌。
如果你的项目也在用 Few-shot 撑格式,可以先统计一下示例占了多少输入 Token,再判断值不值得改。如果示例本来就短、调用量也不大,那维持现状反而是更省心的选择。