← 返回资讯

提示词前缀怎么设计:按稳定性重排,把缓存命中率提上去

2026-08-07

我见过一个客服 agent,系统提示词写得非常认真:角色设定、语气规范、二十条业务规则、六个少样本示例,加起来九千多 token,每次调用都完整带上。团队一直觉得这是”没办法的成本”。后来只做了一件事——把系统提示词开头那行 当前时间:2026-08-07 14:23:11 挪到消息末尾——账单直接掉到原来的四分之一以下。

代码逻辑一个字没改,模型没换,输出质量也没变。变的只是顺序

一、缓存计价让”排列顺序”变成了成本项

在没有缓存计价的年代,提示词里内容放哪儿只影响效果,不影响钱:一万个 token 就是一万个 token 的价。缓存计价出现之后,同样一万个 token,可能按两个完全不同的单价结算。

看一组可核实的挂牌价,DeepInfra 官方定价页($ / 1M tokens,核对日期 2026-08-07):

模型输入缓存输入输出
DeepSeek-V4-Pro$1.30$0.10$2.60
DeepSeek-V4-Flash$0.09$0.018$0.18
DeepSeek-V3.2$0.26$0.13$0.38
DeepSeek-V3.1-Terminus$0.27$0.13$0.95
DeepSeek-V3.1$0.25$0.13$0.95

来源:DeepInfra 官方定价页。以上是本文唯一引用的真实价格,其余平台的缓存单价、缓存有效期、写入侧是否另计费等参数,本文一律不写——没核实过的数字比没有数字更害人。

V4-Pro 这一行值得盯一会儿:输入 $1.30,缓存输入 $0.10,比值 $1.30 ÷ $0.10 = 13,也就是命中的那部分按大约 1/13 的价钱结算,单看输入侧省掉 ($1.30 − $0.10) ÷ $1.30 ≈ 92.3%。V4-Flash 是 $0.09 对 $0.018,比值 5,省 80%。V3.2 是 $0.26 对 $0.13,正好一半。

同一段文字,命中与不命中之间隔着 13 倍的价差。而”是否命中”这件事,很大程度上由你怎么排列决定。

关于缓存本身的原理和适用判断,可以先看 Prompt Caching 省钱原理与实践;这里只谈一件事:怎么排,才能让该命中的都命中

二、机制:命中的是前缀,不是”重复内容”

很多人对缓存的直觉是错的。他们以为缓存像去重——只要这段话我以前发过,系统就认得,就便宜。

不是这样。缓存是前缀匹配:从消息序列的第一个 token 开始逐位比对,一直比到第一个不一样的位置为止,那个位置之前的算命中,那个位置之后的全部算未命中,重新按原价计算。

这个机制有两个直接推论,是本文所有做法的根:

推论一:破坏是从断点开始向后全传染的。 你在开头改了一个字符,后面九千个 token 哪怕一模一样,也全部不命中。不是”少命中一点”,是从断点起归零。

推论二:所以决定命中率的不是”内容重不重复”,而是”前面那段有没有变”。 一段被复用一万次的规则文本,只要它前面压着一个每次都变的时间戳,它这一万次全部按原价付。它有多重复,一点用都没有。

把这两条记牢,后面的所有规则你自己都能推出来。

一个字符值多少钱

按 DeepInfra V4-Pro 的挂牌价做一次算术演示(以下均为按官方标价的算术推演,不是实测账单)。

假设:一个 agent,每次请求输入 10000 token,其中 9000 token 是稳定不变的前缀(角色 + 规则 + 示例 + 工具定义),1000 token 是本轮变化的部分;输出 400 token;每天调用 5000 次。另假设命中部分按”缓存输入”价结算——本文引用的价表未收录缓存写入是否单独计费,写入侧的计价规则以 DeepInfra 官方定价页为准。

写法 A(时间戳放在最前面,前缀每次都变,全不命中):

  • 输入:10000 × 5000 = 50,000,000 token = 50 M,50 × $1.30 = $65.00
  • 输出:400 × 5000 = 2,000,000 token = 2 M,2 × $2.60 = $5.20
  • 合计 $70.20 / 天

写法 B(时间戳挪到末尾,9000 前缀命中):

  • 缓存输入:9000 × 5000 = 45 M,45 × $0.10 = $4.50
  • 普通输入:1000 × 5000 = 5 M,5 × $1.30 = $6.50
  • 输出:$5.20
  • 合计 $16.20 / 天

每天差 $70.20 − $16.20 = $54.00,降幅 54 ÷ 70.2 ≈ 76.9%。按 30 天算,$2106.00 对 $486.00,一个月省 $1620.00。

还有个更有意思的角度。写法 B 里输入侧一共花了 $4.50 + $6.50 = $11.00,摊到 50 M 输入 token 上,有效输入单价 = $11.00 ÷ 50 = $0.22 / 1M。而 DeepSeek-V3.2 的挂牌输入价是 $0.26。也就是说,重排之后,你用 V4-Pro 的输入侧单价,比 V3.2 的标价还低。

这就是标题里”降一个档”的含义:很多人为了省钱去换更便宜的模型,牺牲了效果;而重排提示词是一次不牺牲效果的重构,效果原样保留,输入成本却跨过了一个档位。 换模型和重排提示词的取舍,大模型 API 成本优化 里还有更多维度可以对照着看。

三、核心做法:按稳定性分层排列

规则只有一句:越稳定的越靠前,越易变的越靠后。

具体落到四层,你可以直接照着这个顺序摆:

放什么变化频率位置
第一层角色设定、通用行为规则、输出格式约束版本发布时才变最前
第二层工具/函数定义、少样本示例功能迭代时才变次之
第三层会话级稳定上下文(用户档案、租户配置、当前文档全文)一次会话内不变再次
第四层时间戳、随机 ID、当前查询、动态检索结果、多轮历史每次请求都变最后

第一层:角色与格式约束。 这些东西一个季度可能都不改一次,理应吃到最长的缓存。把 JSON schema、字段说明、禁止事项统统放这里。注意别把”本次要处理的具体对象”混进格式约束里——描述格式的是第一层,具体值是第四层。

第二层:工具定义与少样本。 工具定义通常是一大坨 JSON,很占 token,也最值得被缓存。少样本示例同理。这两块只在你发版时才变,跟第一层放一起完全合理。要点在于它们必须逐字节稳定,第四节会专门讲怎么保证这一点。

第三层:会话级上下文。 用户是谁、租户配置是什么、当前正在编辑的文档全文——这些东西在一次会话内不变,但换一个用户就变。它们比第一二层易变,比第四层稳定,所以夹在中间。

这里有个实践判断:如果你的用户量很大、每个用户的会话很短,第三层其实很难被复用(因为换个用户前缀就断了)。这种情况下,把第三层往后挪,让第一二层的全局公共前缀独立成一段被所有用户共享,整体收益反而更高。反过来,如果是长会话、单用户多轮的场景,第三层放前面完全没问题。判断依据是:这一段在你的真实流量里,平均能被复用几次?复用次数越多,越该往前。

第四层:一切每次都变的东西。 时间戳、请求 ID、本轮用户输入、RAG 检索回来的片段,全部放到最后。这一层本来就要按原价付,让它待在末尾,它就只污染自己,不牵连前面。

分层的完整落地方式,包括怎么把这四层做成可复用的模板结构,提示词模板怎么组织 那篇讲得更细。

四、五种最常见的破坏命中的写法

下面五条是我在别人代码里反复见到的,每条都给改法。

1. 系统提示词里塞当前时间

system = f"你是一个客服助手。当前时间:{datetime.now()}\n\n{RULES}"

datetime.now() 精确到秒,意味着每次请求前缀都不一样,后面几千 token 的规则全部陪葬。这是破坏力最大也最常见的一条。

改法有两种。首选是把时间挪到消息末尾,作为一条独立的用户侧上下文:系统提示词保持恒定,时间信息在最后一条消息里给出。次选是降低精度——如果业务只需要知道”今天是几号”,就写日期不写时分秒,那么一整天之内前缀都是同一份。精度从秒降到天,理论上的前缀失效频率就从每次一变降成一天一变。

2. 每次重新生成的会话 ID、请求 ID

system = f"[session={uuid4()}] 你是..."

这种通常是为了打日志方便,顺手拼进提示词里的。它对模型输出几乎没有价值,对缓存却是毁灭性的。从提示词里彻底剥离出去,放到 HTTP header、日志字段、或者你自己的 trace context 里——那才是它该待的地方。

如果模型确实需要知道会话标识(比如要在输出里回填),那也放到消息末尾,别让它压在前缀上。

3. 工具定义顺序不固定

tools = [make_tool(name) for name in TOOL_REGISTRY.keys()]

这一条最阴险,因为它时好时坏。字典遍历顺序、set 遍历顺序、并发注册的先后、多进程 worker 之间的差异,都可能让工具数组的顺序在不同请求间飘。你会看到一个诡异的现象:缓存命中率不是 0 也不是 100%,而是稳定卡在某个中间值,怎么查都查不出原因。

改法很简单粗暴:序列化前强制排序。

tools = [make_tool(name) for name in sorted(TOOL_REGISTRY.keys())]

同样的道理适用于所有由 dict 生成的 JSON:字段顺序、嵌套对象的 key 顺序,只要参与前缀,就必须确定性排序。序列化时显式指定 sort_keys=True 这类选项,比事后排查便宜太多。

4. 少样本示例随机抽取

examples = random.sample(EXAMPLE_POOL, 5)

“每次随机换几个例子,模型不容易过拟合”——这个想法在训练里成立,在推理里基本是自己骗自己,代价却是把整个前缀的缓存打掉。

改法:固定一组示例。真要做示例的 A/B 实验,就固定成两三个版本,按用户哈希分桶,每个桶内部示例完全一致——这样每个桶各自有稳定前缀,缓存照样能吃到。随机抽样这种做法,几乎没有能抵得过 13 倍价差的收益。

5. 用户名/租户名拼在最前面

system = f"你正在服务客户「{tenant_name}」。\n{RULES}"

看起来无害,实际上它把所有租户之间的公共前缀全部切断了。一千个租户,本来能共享同一份九千 token 的规则前缀,现在变成一千份互不相干的前缀,每一份都要各自重新建立缓存。

改法是把租户信息从第一层降到第三层甚至第四层:系统提示词保持全局统一,租户名、租户特定配置作为独立段落放在公共部分之后。改完之后,公共前缀被全体用户共享,复用次数是原来的几百上千倍。

这一条的收益通常远超前四条,因为它影响的是复用广度而不只是复用深度。缓存到底能省下多少,和复用次数直接相关,缓存到底能省多少钱 里有更完整的测算口径。

五、多轮对话:只追加,不修改

多轮场景里有一个陷阱,掉进去的人非常多。

正常的多轮对话天然是缓存友好的:第 N 轮的消息序列 = 第 N−1 轮的序列 + 新的一问一答。前面全是原样的,天然构成完整前缀。所以只要你只往后追加,不动前面,命中率会一直很高。

问题出在上下文变长之后的压缩。常见的做法是:把前面十轮对话总结成一段摘要,替换掉原始消息,以节省 token。这个思路本身没错,错在替换的位置

如果你把摘要插在中间——比如保留系统提示词,然后用一段摘要替换掉第 2 到第 11 轮,再接上最近几轮——那么从摘要插入的那个位置开始,后面所有内容的前缀全变了。命中率归零,整个上下文重新按原价算一遍。你为了省几千 token 的量,付出了几万 token 从缓存价跳回原价的代价,很可能是净亏的。

更麻烦的是,如果压缩策略是”每 N 轮触发一次”,那你等于给自己安排了一个周期性的账单尖峰:每次触发压缩,那一轮的费用突然涨几倍,然后慢慢回落,直到下次触发。看监控图会以为是流量波动,其实是自己的压缩逻辑在放血。

几个可行的组织方式:

  • 摘要追加在尾部,不替换中间。 保留原始历史的前缀不动,把摘要作为一条新消息附在末尾。省不了历史 token 的量,但那些历史本来就是命中价,便宜;而且模型拿到摘要后能更好地聚焦。
  • 压缩就压到底,并且降低触发频率。 如果上下文实在撑不住必须重写中间,那就一次压狠一点,把触发频率从”每 5 轮”改成”接近上限时才做”,让每次重建缓存的代价被更多轮次摊薄。
  • 接受一次断点,别接受反复断点。 一次会话里前缀重建一次是可以忍的,重建八次就是设计问题了。
  • 稳定的会话摘要放前面,滚动的历史放后面。 如果摘要本身在会话内不再变(比如只在会话开始时生成一次用户画像),它属于第三层,放前面完全合理。会变的才必须靠后。

判断标准还是那一条:这段内容会不会被改写?会改写的,一律往后放。

六、怎么验证改动真的生效

重排完之后,最容易犯的错是”感觉快了、感觉便宜了”就收工。这种感觉基本不可信——请求量、上下文长度、模型选择每天都在波动,肉眼看账单总额分不清是哪一个因素在起作用。

要看的是一个比值:缓存 token 占总输入 token 的比例。绝大多数平台的用量返回里会区分缓存部分和非缓存部分的 token 数(具体字段名各家不同,以你所用平台的官方文档为准)。把这两个数字从响应里取出来,按请求打点上报,然后看:

命中率 = 缓存输入 token / 总输入 token

这个比值不受请求量波动影响,改动前后可以直接对比。

一次最小可行的对比测试,大概是这样做:

  1. 固定变量。 同一个模型、同一批测试用例、同样的并发,只改提示词的排列顺序。
  2. 跑够量。 缓存有冷启动过程,第一次请求必然不命中。至少让每条前缀跑上几十次,否则冷启动会把结果压得很难看。测试样本太小时看到的低命中率,往往是冷启动的假象。
  3. 分开记录。 把每次请求的「总输入 token / 缓存输入 token / 输出 token」三个数都记下来,别只记总费用。费用是结果,token 构成才是原因。
  4. 算有效单价。 用记录到的 token 数,按你所用平台的挂牌价自己算一遍每千次调用的输入成本。这个数字比命中率百分比更能说服人,也更容易发现漏算。
  5. 留意方差。 如果命中率在 0% 和 100% 之间反复横跳,多半是第四节第 3 条那种”顺序不确定”的问题;如果稳定卡在某个中间值,去看是哪一段前缀被污染了。

还有一个很实用的排查手法:把两次请求的完整消息序列原样 dump 成文本,逐字符 diff。第一个不一样的字符出现在哪儿,你的缓存就断在哪儿。 这比读代码猜快得多,很多”想不通为什么不命中”的案例,diff 一跑五秒钟就找到了——往往是某个隐藏的空格、换行,或者浮点数序列化出的尾数差异。

七、别为了缓存牺牲效果

前面讲了这么多重排的做法,这一节要泼一盆冷水。

如果把关键指令挪到后面,导致模型不遵守了,那省下的钱一分都不值。 一个错误输出引发的返工、客诉、人工介入,成本远高于那点 token 差价。我见过有人为了把前缀凑长,硬把”必须输出 JSON”这条约束从系统提示词开头挪到几千 token 之后,结果格式错误率涨了,下游解析崩了,排查花了两天——省下的钱还不够两天工时的零头。

取舍原则说清楚就一句:先保证效果,在效果等价的多种写法里,选缓存友好的那一种。

具体一点:

  • 排列顺序的调整,必须先过效果验证。用你现有的评测集跑一遍,输出质量没有退化,才允许上线。没有评测集就先建一个最小的,哪怕只有三十条也比没有强。
  • 有些指令天生需要靠近末尾才有效(比如强调”严格按上面的格式输出”这类收尾约束),那就让它待在末尾。它通常只有几十个 token,占不了多少钱。别为了几十个 token 的位置去赌几千 token 的输出质量。
  • 遇到冲突时,判断的依据是量级:挪动一段 200 token 的内容,省下的钱是有限的;如果它有可能影响遵循度,直接放弃这个优化。挪动一段 8000 token 的内容才值得认真做效果验证。
  • 缓存优化的收益上限,取决于你的前缀有多长、复用多少次。流量小、前缀短的场景,这件事根本不值得花时间,先去优化别的。关于什么时候该优化提示词本身、什么时候该优化排列,提示词工程实践 里的思路可以对照着用。

顺带说一句诚实边界:不同平台的缓存实现差别很大,最小可缓存长度、缓存存活时间、是否需要显式标记缓存断点、命中的判定粒度,各家规则不同。本文没有引用这些参数,因为没有逐家核实过——你在落地前一定要去看你所用平台的官方文档,先确认它的缓存是自动的还是需要显式声明,再决定怎么排。本文讲的”按稳定性分层”这个原则本身是通用的,因为它源自前缀匹配这个共同机制,但具体参数必须以官方文档为准。

自查清单

上线前逐条过一遍:

  1. 系统提示词里还有时间戳吗? 有就挪到末尾,或者把精度从秒降到天。
  2. 有没有每次新生成的 UUID、请求 ID 拼在前面? 有就从提示词里彻底剥离,放进日志和 trace。
  3. 工具定义、JSON 字段的序列化顺序是确定性的吗? 所有由 dict / set 生成的结构,序列化前强制排序。
  4. 少样本示例是固定的还是随机抽的? 随机抽的改成固定,要做实验就按哈希分桶,桶内保持一致。
  5. 用户名、租户名有没有压在公共规则前面? 有就往后挪,让公共前缀能被全体用户共享。
  6. 多轮压缩是插在中间还是追加在尾部? 插中间的改成追加,实在要重写就一次压到位并降低触发频率。
  7. 你能从用量数据里读出缓存 token 占比吗? 读不出就先把打点补上,凭感觉不算验证。
  8. 重排之后跑过效果评测了吗? 输出质量没退化才允许上线,效果优先于成本。

相关阅读