从一段被塞满的活动文案说起
「回声」的私信智能回复上线第二周,运营提了一个需求:希望在回复里顺带提及「本周会员日」。
实现方式很直接。原来的 system 提示是:
你是「回声」的私信回复助手。只根据对话内容生成回复,不得编造订单状态。
有人在后面追加了一段活动文案:全场八折、活动时间、参与入口、不可与其他优惠叠加,一共三百多字。改完当天看起来没问题,因为测试用的是「你好,在吗」这类短对话。
第二天问题出现:
- 用户问「我的订单什么时候到」,回复先讲会员日,再补一句「具体以页面为准」。
- 用户问退货政策,回复里出现「会员日订单不支持退换」,而活动规则里根本没有这句。
- 同一段对话连续生成两次,一次正常、一次跑偏,客服无法判断哪次算异常。
- 一周后活动结束,团队想撤掉文案,却发现没有人记得线上那版提示到底长什么样。
复盘时找到三个原因。第一,活动文案属于业务事实,却被放进了角色与规则的 system 区,模型无法区分「必须遵守」和「可以提及」。第二,提示没有变量声明,三百字是直接粘贴的,没有长度上限。第三,改动没有版本号、没有变更记录,回滚只能靠记忆重写。
这三个原因指向同一个结论:提示不是一段随手拼接的字符串,而是一个需要被管理、评审和发布的产物。
字符串形态的四个问题
把提示当字符串,短期内最省事,长期会同时失去四样东西。
| 丢失的能力 | 字符串形态 | 产物形态 |
|---|---|---|
| 校验 | 拼完就发,没人知道变量有没有缺 | 编译期检查变量声明、类型、长度 |
| 复用 | 每个调用点复制一份,逐渐分叉 | 一个模板多处渲染,差异只在变量 |
| 追溯 | 线上是哪一版只能靠记忆 | 版本号、所有者、变更记录 |
| 回滚 | 改错了只能再改一次 | 切回上一个不可变版本 |
这四件事不是「工程规范」层面的附加要求,而是事故发生时唯一能救命的东西。模型输出的随机性意味着同一个 bug 不一定每次复现,如果没有版本记录,团队连「哪一版变差」都无法回答。
提示产物的最小字段
一个可发布的提示产物,至少要有六个字段。少一个,评审就会出现盲区。
| 字段 | 回答的问题 | 缺失后的典型事故 |
|---|---|---|
| 模板体 | 模型看到的固定文本是什么 | 规则散落在代码字符串里 |
| 变量声明 | 哪些位置会被业务数据替换 | 变量名拼错,渲染出空串 |
| 版本号 | 这次改动对应哪一版 | 线上变差但无法定位 |
| 所有者 | 谁对内容和效果负责 | 出问题没人认领 |
| 变更记录 | 为什么改、改了什么 | 回滚时不知道退到哪 |
| 适用模型 | 这一版在哪个模型快照上评估过 | 换模型后效果静默漂移 |
本节先用 TypeScript 把前两项固定下来:模板体和变量声明。版本、所有者与模型绑定会在 6.3 展开,但它们从第一天就必须出现在数据结构里,否则后面补不进去。
为什么用受限模板语法
很多团队第一反应是引入一个成熟的模板引擎。模板引擎能力强,但对提示来说是负担:条件、循环、过滤器一旦可用,提示里就会出现隐藏逻辑。评审者读到的是一段文本,实际执行的却是另一段程序。
「回声」最终只允许一种语法:{{name}}。它表达的是「这里替换成一个已声明的变量」,不表达任何控制流。具体限制如下:
- 变量名必须匹配
[A-Za-z_][A-Za-z0-9_]*,不接受点号、空格和表达式。 - 不接受
{{#if}}、{{name | upper}}、{{! comment }}这类语法。 - 未闭合的
{{、多余的}}、空的{{}}都在编译期报错。 - 模板里出现的每个变量都必须在声明表里,声明表里的每一项也必须被使用。
最后一条容易被忽略:模板删掉一个变量,但声明表没删,评审会误以为这个变量仍在生效。反过来,声明表加了变量但模板没引用,调用方会以为传了有用。两个方向都要拦。
把模板与变量写成类型
下面的实现把「解析、编译、渲染」拆成三步。解析只认语法,编译检查声明一致性,渲染检查运行时值与长度。
// file: src/prompt/template.ts
// tier: L1
// verify: node --test test/prompt/template.test.ts
import { AiError } from "../core/errors.ts";
export type VariableType = "string" | "number" | "boolean";
export interface VariableSpec {
type: VariableType;
/** 缺省视为必填;可选变量缺失时渲染为空串。 */
required?: boolean;
/** 渲染后文本的最大字符数,按 Unicode 码点计,不是 token。 */
maxChars?: number;
}
export interface PromptTemplate {
id: string;
version: string;
owner: string;
body: string;
variables: Record<string, VariableSpec>;
}
export type Segment =
| { kind: "text"; text: string }
| { kind: "slot"; name: string; index: number };
export interface CompiledPrompt {
template: PromptTemplate;
segments: Segment[];
/** 变量首次出现的顺序,去重。 */
variables: string[];
}
export type PromptValues = Record<string, string | number | boolean | undefined>;
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
function fail(message: string): never {
throw new AiError("invalid-request", `prompt: ${message}`);
}
/**
* 只接受 {{name}}。表达式、过滤器、注释与局部模板全部拒绝:
* 提示里一旦出现图灵完备的模板逻辑,评审就无法只靠阅读判断它会输出什么。
*/
export function parseTemplate(body: string): Segment[] {
const segments: Segment[] = [];
let cursor = 0;
while (cursor < body.length) {
const open = body.indexOf("{{", cursor);
const close = body.indexOf("}}", cursor);
if (close !== -1 && (open === -1 || close < open)) {
fail(`stray "}}" at ${close}`);
}
if (open === -1) {
if (cursor < body.length) {
segments.push({ kind: "text", text: body.slice(cursor) });
}
break;
}
if (open > cursor) {
segments.push({ kind: "text", text: body.slice(cursor, open) });
}
const end = body.indexOf("}}", open + 2);
if (end === -1) {
fail(`unclosed "{{" at ${open}`);
}
const raw = body.slice(open + 2, end);
const name = raw.trim();
if (name === "") {
fail(`empty placeholder at ${open}`);
}
if (!NAME.test(name)) {
fail(`unsupported placeholder "${name}"`);
}
segments.push({ kind: "slot", name, index: open });
cursor = end + 2;
}
return segments;
}
/**
* 编译期做三件事:名字合法、槽位都有声明、声明的变量都被使用。
* 最后一条用来抓 schema 漂移:模板删了变量但声明还在,评审会误以为它仍生效。
*/
export function compilePrompt(template: PromptTemplate): CompiledPrompt {
if (template.id.trim() === "") fail("id is required");
if (template.version.trim() === "") fail("version is required");
if (template.owner.trim() === "") fail("owner is required");
if (template.body === "") fail("body is required");
const declared = Object.keys(template.variables);
for (const name of declared) {
if (!NAME.test(name)) fail(`invalid variable name "${name}"`);
const spec = template.variables[name];
if (spec.type !== "string" && spec.type !== "number" && spec.type !== "boolean") {
fail(`invalid type for "${name}"`);
}
if (
spec.maxChars !== undefined &&
(!Number.isInteger(spec.maxChars) || spec.maxChars <= 0)
) {
fail(`maxChars for "${name}" must be a positive integer`);
}
}
const segments = parseTemplate(template.body);
const used: string[] = [];
for (const segment of segments) {
if (segment.kind !== "slot") continue;
if (!Object.hasOwn(template.variables, segment.name)) {
fail(`undeclared variable "${segment.name}"`);
}
if (!used.includes(segment.name)) used.push(segment.name);
}
for (const name of declared) {
if (!used.includes(name)) fail(`declared but unused variable "${name}"`);
}
return { template, segments, variables: used };
}
function formatValue(
name: string,
spec: VariableSpec,
value: string | number | boolean,
): string {
let text: string;
if (spec.type === "string") {
if (typeof value !== "string") fail(`variable "${name}" expects string`);
text = value;
} else if (spec.type === "number") {
if (typeof value !== "number" || !Number.isFinite(value)) {
fail(`variable "${name}" expects finite number`);
}
text = String(value);
} else {
if (typeof value !== "boolean") fail(`variable "${name}" expects boolean`);
text = value ? "true" : "false";
}
if (spec.maxChars !== undefined && [...text].length > spec.maxChars) {
fail(`variable "${name}" exceeds maxChars ${spec.maxChars}`);
}
return text;
}
/** 渲染不做隐式默认值:缺失的必填变量直接失败,可选变量渲染为空串。 */
export function renderPrompt(compiled: CompiledPrompt, values: PromptValues): string {
const parts: string[] = [];
for (const segment of compiled.segments) {
if (segment.kind === "text") {
parts.push(segment.text);
continue;
}
const spec = compiled.template.variables[segment.name];
const value = values[segment.name];
if (value === undefined) {
if (spec.required !== false) fail(`missing required variable "${segment.name}"`);
continue;
}
parts.push(formatValue(segment.name, spec, value));
}
return parts.join("");
}
几个设计选择值得说明。
第一,parseTemplate 返回的是片段数组,而不是直接拼字符串。文本片段与槽位片段分开之后,渲染器可以逐段处理,也能在未来插入审计或脱敏逻辑,而不需要重新解析。
第二,编译期检查「声明但未使用」。这条规则比「未声明但使用」更容易被忽略,因为前者不会立刻报错,只会让提示慢慢变成一份过时的文档。
第三,maxChars 按 Unicode 码点计数,不是字节,也不是 token。它只回答「这段业务文本是不是长得不正常」,不承担上下文预算的职责。真正的 token 预算在 6.2 处理。
第四,可选变量缺失时渲染为空串,而不是塞一个默认值。默认值会掩盖调用方漏传参数的问题;空串至少能让评审看到「这里本来应该有内容」。
第五,错误统一收敛成 invalid-request。提示拼装错误属于请求构造错误,不该触发重试,也不该消耗模型预算。
变量校验的三条线
变量校验不是「有没有传」,而是三条独立的线。
第一条是存在性。必填变量缺失时直接失败,不生成半个提示。模型最怕的不是信息少,而是信息看起来完整、实际上少了一条关键规则。
第二条是类型。数量字段必须是有限数字,布尔字段必须是布尔值,字符串字段不能拿对象顶上。TypeScript 在编译期能挡一部分,但业务数据来自运行时,渲染器仍要检查。
第三条是长度。长度上限不是审美要求,而是事故防线。活动文案、用户签名、商品标题都可能突然变长,没有上限时,一段异常文本就能把整个提示挤出预算。
三条线都通过,才允许渲染。任何一条失败,都应该在请求进入模型之前停下。
测试与真实输出
模板逻辑的可测试性来自确定性:同样的模板、同样的变量,永远渲染出同样的字符串。完整测试放在 test/prompt/template.test.ts,覆盖十种情况。
在实验工程根目录执行:
node --test test/prompt/template.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 编译保留字面量与槽位顺序 (1.355208ms)
✔ 同一变量可以重复出现,渲染时逐处替换 (0.148875ms)
✔ 未声明的变量在编译期失败 (0.261792ms)
✔ 声明但未使用的变量被视为 schema 漂移 (0.089458ms)
✔ 缺少必填变量时不渲染半个提示 (0.094791ms)
✔ 可选变量缺失渲染为空串,而不是塞默认值 (0.06575ms)
✔ 类型不符在渲染期失败 (0.088791ms)
✔ maxChars 按 Unicode 码点计数 (0.087ms)
✔ 表达式、过滤器与注释语法一律拒绝 (0.111959ms)
✔ 未闭合与多余的括号都被拒绝 (0.143375ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 202.993541
这里的 node --test 直接运行 .ts 文件,依赖 Node 26 的类型剥离:它只删类型,不做类型检查。所以测试通过代表运行时行为正确,类型错误仍要靠编辑器或 tsc --noEmit。
一次活动文案进 system 的复盘
回到开头的事故。修复分三步,每一步都对应上面的一个规则。
第一步,把活动文案从 system 移出去。system 只保留角色、语气和硬规则;活动事实改由检索或工具结果提供,并在 6.2 的预算里单独占一类。这样业务文案过期时,撤掉一个数据源即可,不需要改提示。
第二步,给 system 模板加变量声明和长度上限。角色、产品名、禁用话题是字符串变量,各自有 maxChars;活动信息不再是提示的一部分,而是上下文片段。
第三步,改动进版本登记。每一版有所有者、变更记录和适用模型,发布前先在评测集上跑一遍,灰度通过后才全量。
改完之后,同样的模型在同一批测试对话上不再复读活动文案。真正起作用的不是换了更聪明的模型,而是把「规则」和「事实」放回了各自的位置。
这个案例还有一个容易被忽略的教训:事故发生时,团队的第一反应是「模型今天不稳定」。如果没有模板、变量和版本这三层结构,他们只能继续在字符串上试错;有了结构,问题才能被拆成「内容放错区」「长度失控」「版本不可追溯」三个可以分别修复的原因。
评审提示产物时看什么
评审一份提示,不要只看读起来顺不顺。可以按下面这张表逐项过。
| 检查项 | 通过标准 |
|---|---|
| 角色与规则 | 只包含稳定约束,不包含会过期的活动事实 |
| 变量声明 | 每个变量有类型、必填性和长度上限 |
| 声明一致性 | 模板引用与声明表完全对应 |
| 变更记录 | 能说清这次为什么改、改前是什么 |
| 适用模型 | 写清模型引用,不写「通用」 |
| 评测样本 | 至少覆盖正常、边界、对抗三类输入 |
| 回滚目标 | 明确上一版是哪一版,谁有权切换 |
这张表不替代人工判断,但它能防止评审退化成「我觉得还行」。
与后面两节的关系
本节回答「提示长什么样」。6.2 回答「每次往提示里放多少内容」:system、历史、检索、工具结果四类各有预算,超出时按优先级裁剪,system 保底不裁。6.3 回答「哪一版上线、出问题怎么退」:版本号、变更记录、灰度、回滚与模型绑定。
三节顺序不能反。没有产物形状,预算就无处挂载;没有预算,版本化只是一份文本的版本化;没有版本化,前两节的规则都无法安全上线。
小结
提示是产物,不是字符串。产物意味着它有模板、变量、版本、所有者和评审记录;字符串只意味着它现在能跑。
「回声」的活动文案事故说明,模型跑偏往往不是模型能力问题,而是内容放错位置、长度失去控制、改动无法追溯。把提示拆成受限模板与显式变量,能让这些问题在进入模型之前暴露。
下一节处理提示的容量问题:一次请求到底能放多少 system、多少历史、多少检索结果和工具结果,以及超出预算时先裁谁。
阅读导航:上一节:5.3 流式下的错误与重试语义 · 下一节:6.2 上下文预算与裁剪 。
常见错误与边界
- 把提示当普通字符串,直接在调用点拼接。调用点越多,分叉越快,最后没人知道线上是哪一版。
- 把会过期的业务事实写进 system。活动、库存、价格都该作为上下文数据,而不是角色规则。
- 引入图灵完备的模板引擎。条件与循环会把可读的提示变成需要调试的程序。
- 只检查变量有没有传,不检查类型和长度。异常长的用户输入足以挤掉全部规则。
- 让可选变量带默认值。默认值会掩盖漏传,评审也看不出这里少了内容。
- 声明表与模板长期不同步。未使用的声明是过时文档,未声明的引用是运行时炸弹。
- 把提示编译错误当成可重试故障。请求构造错误重试多少次都不会变对。
- 只评审文案,不评审边界。提示的失败方式比它正常时的措辞更重要。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。