《互联网产品的 AI 架构·模型接入与能力边界》6.1 提示是产物,不是字符串

「回声」把运营活动文案整段塞进 system 后,智能回复开始复读活动、忽略用户问题。本节由此说明提示为什么是产物而不是字符串:模板、变量声明、版本、所有者与评审缺一不可,并用可运行的 TypeScript 实现受限模板语法、编译期变量校验与严格渲染,最后复盘一次靠记忆改提示的事故。

从一段被塞满的活动文案说起

「回声」的私信智能回复上线第二周,运营提了一个需求:希望在回复里顺带提及「本周会员日」。

实现方式很直接。原来的 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。活动、库存、价格都该作为上下文数据,而不是角色规则。
  • 引入图灵完备的模板引擎。条件与循环会把可读的提示变成需要调试的程序。
  • 只检查变量有没有传,不检查类型和长度。异常长的用户输入足以挤掉全部规则。
  • 让可选变量带默认值。默认值会掩盖漏传,评审也看不出这里少了内容。
  • 声明表与模板长期不同步。未使用的声明是过时文档,未声明的引用是运行时炸弹。
  • 把提示编译错误当成可重试故障。请求构造错误重试多少次都不会变对。
  • 只评审文案,不评审边界。提示的失败方式比它正常时的措辞更重要。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查