system prompt 里拼了时间和知识大纲,每轮缓存都被击穿:静态/动态分离的提示词装配方法
这两天 Agent 开发圈还在卷"上下文越来越贵":窗口开得越来越大,账单也越来越长。但有个省钱的架构细节很少有人讲透------前缀缓存。它要求 system prompt 字节级稳定,可很多人把当前时间、知识库大纲拼进 system prompt,每轮都在变,缓存永远命中不了,等于全价买单。
我没去研究更便宜的模型。我干了另一件事------去翻了一个开源数据智能体工作台(daw,「寒鸦数据工作台」)的代码,看它的提示词是怎么装配的。它的解法叫"静态/动态分离",写成了一段钉在代码里的 contract。
看完我可以负责任地告诉你三件事。
第一,system prompt 只放静态纪律------品牌名注入后跨轮、跨会话字节稳定,provider 的前缀缓存才能持续命中。
第二,每轮变化的事实(当前时间、知识库大纲)走 <runtime_context> 快照,prepend 到用户消息前面:不进 system prompt,不写持久化历史,下一轮自动被新快照取代。
第三,光分离不够,还得有三条配套纪律锁死它:每个事实只有一个 owner、fail-loud 测试防漂移、提示词里的示例必须通过真实 schema 校验。
1. 学习目标
读完这篇,你会给自己的 Agent 加上一套"提示词装配纪律":静态内容和动态内容分两堆放、前缀缓存持续命中、再用三组测试保证三个月后不漂移。方法跟模型厂商无关,我用 daw 的 Rust 代码做参照。
2. 前置知识:前缀缓存是什么
先给大家科普一个概念「前缀缓存」:主流模型服务商都会缓存请求的公共前缀------如果这一轮的 prompt 开头,和上一轮字节完全一致,那一部分就按缓存价(通常便宜一个数量级)计费。
翻译成人话:system prompt 是所有轮次共享的最长公共前缀,它一变,每一轮都从头全价计费。
daw 的数据分析核心循环,恰恰鼓励每轮写知识(查完表就沉淀口径、用户纠个错就写知识库)。如果知识库大纲拼在 system prompt 里------"每写一条知识,下一轮整个 system prompt 前缀缓存就被击穿"。这就是这套纪律要解决的真实代价:你的产品越"越用越懂",账单涨得越快。
3. 分步骤实操
步骤 1:把提示词内容分成"静态"和"动态"两堆
先做一次分类,标准只有一个:这句话下一轮还在吗?
- 在的 → 静态:角色定义、行为纪律、工作流程、错误处置决策树。这些东西几个月才变一次。
- 不在的 → 动态:当前时间、知识库大纲快照、任务 ID。这些每轮都变。
daw 把这条分类标准直接写成了代码注释,钉在装配点的旁边(src-tauri/src/usage.rs:14-20),叫 Prompt-assembly contract:
rust
// Prompt-assembly contract (借鉴 deepseek-harness 的静态/动态分离设计):
// - system prompt 只放**静态纪律**------品牌名经 general_preamble /
// data_analysis_preamble 注入后跨轮/跨会话字节稳定,provider 前缀缓存
// 才能持续命中。禁止把每轮变化的事实(时间、OKF 大纲)拼进来。
// - 每轮变化的事实由 runner 以 `<runtime_context>` 快照随本次用户消息下发
// (见 runner.rs),preamble 里只描述该块的语义,不承载其内容。
注意最后一句:"preamble 里只描述该块的语义,不承载其内容"。比如 preamble 里只写"每次任务的用户消息开头都带有系统注入的 <runtime_context> 块(当前时间等),仅对本次任务有效,直接使用即可"------告诉模型这块东西是什么、怎么用,但具体内容一个字不写。
步骤 2:动态事实装成快照,随用户消息下发
分类完,动态的那堆要找个新家。daw 的做法是:每轮调用模型前,runner 把 RuntimeContext(当前时间、知识库大纲快照、任务信息)渲染成一个文本块,prepend 到用户消息前面 (src-tauri/src/agent/runner.rs:669):
rust
let llm_prompt = format!(
"<runtime_context>\n{runtime_context}\n</runtime_context>\n\n{prompt}"
);
三条性质很关键,注释里都写明了:
- 不进 system prompt------system prompt 的字节稳定性不受影响;
- 不写进持久化历史------前端只存用户原文,历史里没有注入块;
- 下一轮自动被新快照取代------时间变了、大纲变了,下轮重新算一份,旧的不留痕。
数据分析场景的快照 = 当前时间行 + 知识库大纲;通用场景只有时间行。连交付闸门自纠轮次都复用同一快照拼修正指令,保证"现在几点、知识库长什么样"在全链路一致。
步骤 3:一个事实,只有一个 owner
分离之后还有个新问题:同一条规则,preamble 里写一遍、工具描述里再写一遍,迟早两边打架。daw 的纪律是每个事实只有一个 owner,其余地方只用一句话引用:
postgres_query下推规则的 owner 是 preamble 第三步(usage.rs:148的"重要:查询外表时必须用 postgres_query 下推聚合");execute_query的工具描述里只有一句引用:"写法见系统提示第三步"。- 错误处置的 owner 是 preamble 的「错误处置」统一决策树;
register_table的运行时错误文案可以同口径复述------因为它带上下文且便宜,允许例外,但权威定义只有一处。 - 工具参数里的 OKF 类别枚举,必须从
Category::prompt_list()派生,不许手写字符串 ------因为历史上手写枚举漏过selections/users,导致 preamble 要求写选表经验、schema 里却无该类别可选,模型想遵守都遵守不了。
有人可能会问:这跟缓存有什么关系?关系大了------重复和漂移是同一枚硬币的两面。规则散在三处,改的时候必然改漏,改漏的那一处要么进 system prompt 破坏字节稳定,要么让模型拿到矛盾指令。单一 owner 是字节稳定的组织保障。
步骤 4:上 fail-loud 测试,把纪律钉死
纪律写在注释里,三个月后没人看。daw 给这套东西配了三组测试,漂移就炸:
preamble_backtick_identifiers_are_real_tools(src-tauri/src/skill/data_analysis/mod.rs:315):preamble 里反引号引起来的 snake_case 标识符,必须是真实工具的NAME常量,或者在 allowlist 里。防的是"臆造工具名"------历史上模型照着 preamble 里编造的工具名发调用,被服务端整个请求拒绝。新增/改名工具时测试失败,强制你显式归类。preambles_leave_no_placeholder_behind(src-tauri/src/usage.rs:373):品牌名替换后,不允许残留任何{app_name}占位符。注释写得狠:"残缺模板送达模型比报错更糟"。prompt_list_covers_all_category_dirs(src-tauri/src/okf/model.rs:305):8 个知识类别的目录必须全出现在prompt_list()里------手写漏类别的 bug,用测试堵死。
步骤 5:提示词里的示例,必须通过真实 schema 校验
还有一条相关的血泪教训,来自 deepseek-harness 的 issue #3204:dsh 的 PTC SDK 声明里含一个无条件渲染的 bash 示例,模型看完绕过 run_code,直接调了个根本没注册的 bash。
修复是两件事:显式声明"声明≠可直接调用";示例只在真实 schema 能逐字接受示例参数时才渲染(required/const/enum 全核对),对不上就不写示例。
翻译成人话:提示词里的示例不是装饰,是模型会照抄的调用模板。 模板里的参数形状但凡跟 schema 差一个字段,模型抄过去就是一次失败调用。写示例之前,先让测试跑一遍 schema。
4. 为什么这样设计
这套纪律的底层逻辑就一句话:把"变"和"不变"分开,让不变的部分长到足以覆盖缓存前缀。
system prompt 越长越稳定,缓存命中的 token 就越多;动态部分再怎么变,也只影响用户消息那一段。daw 甚至把缓存命中率做成了可观测指标:usage.rs 里有个 normalize 函数,把各家 provider 五花八门的 usage 字段折叠成统一形状,cache_read_tokens / prompt_tokens 就是真实缓存命中率------省没省下来,看得见才算数。
反过来想:如果不分离,数据分析这种"每轮写知识"的产品形态,会亲手把自己的缓存前缀一块一块击穿。纪律不是洁癖,是账单。
5. 避坑清单
- 别在 preamble 里写死工具数量("你有两个工具")------工具集一变,这句话静默失真,模型还深信不疑。
- 类别枚举别手写字符串 ,从代码里的唯一来源派生。手写漏
selections/users的教训:preamble 要求写,schema 里没有,等于给模型下了一道执行不了的命令。 - preamble 引用大纲段落名,必须与实际渲染标题一致。 曾经引用过一个不存在的
# 工作区数据记忆,模型照着找,找到天荒地老。 - 示例不校验 schema 就别写。 一个对不上的示例,等于手把手教模型调一个不存在的工具。
- 时间真源统一为 runtime_context 注入。
get_current_time工具只用于"需要精确到时分秒"的核实,日常相对时间一律以注入块为准------两个时间源,必有一个是错的。
6. 给开发者的建议
- 先问"这句话下轮还在吗"。 在 → system prompt;不在 → 随消息注入的快照。这是全部纪律里最便宜的一刀。
- 把 contract 写在装配点旁边,不是写在文档里。 文档没人看,代码注释在每次改装配逻辑时都会被看到。
- 缓存命中率做成指标,不要凭感觉。 按 provider 归一化 usage,命中率掉下来的时候,你能第一时间知道是哪次"顺手拼了个变量"击穿了前缀。
- 纪律配测试,否则就是建议。 三个 fail-loud 测试(标识符真实性、占位符残留、类别全覆盖),每一条背后都是一次真实翻车。
一句话收束:system prompt 是你和服务商之间的长期合同,字节稳定就是履约------每一次"顺手拼个变量",都是在合同上撕一道口子。