Skip to content
Prompt Engineering 原理:从静态模板到动态 Prompt 系统
概述
动态 Prompt 系统是位于应用代码与模型之间的一层软件。它的输入是用户请求与系统状态,输出是一个完整可执行的 Prompt。这一层负责上下文收集、模板渲染、策略路由、输出解析与观测记录。
在动手实现之前,需要先建立三个基础概念:Prompt 的组成要素、In-Context Learning 的行为特征、静态模板的结构与局限。它们分别回答“Prompt 是什么”“为什么敏感”“为什么需要动态化”三个问题。
基本概念
LLM 与 Prompt 的基本关系
大语言模型的输入输出都是 token 序列。对于一次具体请求,模型能够利用的全部信息都包含在输入文本中。模型没有跨请求的持久记忆,也没有独立于输入的“知识库”;它只是在给定输入下生成输出,这一过程完全由输入条件决定。
Prompt(提示词)就是这一输入文本。它承担两个角色:
- 描述任务:告诉模型需要做什么、遵循什么约束。
- 提供信息:给出完成任务所需的知识、参考和推理起点。
这导致 LLM 应用开发方式与传统软件有一个显著差异:传统程序把逻辑写进代码,LLM 应用把逻辑写进文本。文本本身成为需要设计、测试和维护的工程产物。
以下是一个最小化的调用示例,使用 Node.js 调用 OpenAI 兼容的 Chat Completions 接口:
js
import OpenAI from 'openai';
const client = new OpenAI();
const response = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: '你是一名前端技术编辑。' },
{ role: 'user', content: '请把下面的内容改写成 50 字以内的版本说明:\n\n修复模板字符串在 SSR 场景下转义异常的问题' }
]
});
console.log(response.choices[0].message.content);这里的 messages 数组本身就是一种结构化的 Prompt 形式:system 消息描述角色与全局约束,user 消息携带具体任务。在模型内部,这些消息最终会被拼接成一个连续的 token 序列。
Completion 与 Chat 两种形式
早期 LLM 接口以纯文本补全(completion)为主。Chat 接口则把消息列表作为输入,系统在内部将其渲染为带特殊分隔符的文本。两种形式在本质上没有区别——模型仍然是在读一段文本并续写;Chat 形式只是让“指令-上下文-用户输入”的分布更清晰,也方便应用层做结构化处理。
工程上直接使用 Chat 的消息数组更常见,因为:
- 角色字段(system / user / assistant)提供了天然的语义分层;
- 多轮对话可以直接追加消息,不需要手动拼接历史;
- 主流 SDK 和框架对消息数组做了类型化支持。
注意:不同模型对
system消息的遵循程度差异很大。有些模型把system当作最高优先级指令,有些则把它当作一般上下文。将关键约束同时放入system与user消息,是提高遵循率的常用做法。
Prompt 的组成要素:指令、上下文、示例、输出约束
一个完整的 Prompt 通常由四类信息组成。理解它们各自的职责和相互制约关系,是进行工程化设计的前提。
指令
指令描述模型需要执行的任务。粒度可粗可细:
- 粗粒度:
翻译以下文本。 - 细粒度:
将以下英文技术文档翻译成中文。术语保留英文原文,标点使用全角。
上下文
上下文是任务所需的背景信息。来源包括:
- 用户输入(本次请求的内容)
- 检索结果(从向量库或搜索引擎召回的相关文档)
- 系统状态(当前时间、用户身份、设备信息)
- 历史记忆(之前的对话或操作记录)
示例
示例以输入-输出对的形式显式展示期望的行为。模型通过上下文学习(In-Context Learning)从示例中归纳模式,而不是通过梯度更新调整权重。
js
const examples = [
{ input: '书页有破损', output: '售后' },
{ input: '书什么时候发货', output: '物流' },
{ input: '《ES6 入门》适合初学者吗', output: '咨询' }
];输出约束
输出约束限定输出的格式、长度或内容范围:
- 格式约束:例如
只输出 JSON,不要包含解释。 - 长度约束:例如
不超过 200 字。 - 内容约束:例如
如果信息不足,回答“无法判断”。
四要素的优先级协调
静态模板把四要素固化为固定顺序和固定文本;动态系统则需要按请求实时调整它们。调整的依据是优先级——当上下文窗口有限、四要素不可能全部完整放入 Prompt 时,必须决定谁先被裁减。
优先级不是固定的,它取决于任务类型:
| 任务类型 | 优先级顺序 | 原因 |
|---|---|---|
| 结构化抽取 | 输出约束 > 示例 > 指令 > 上下文 | 输出必须可被程序解析,格式失败直接导致功能不可用 |
| 开放问答 | 上下文 > 指令 > 示例 | 回答质量主要由提供的信息量决定 |
| 分类/路由 | 示例 > 输出约束 > 指令 | 示例比文字描述更有效地界定类别边界 |
| 创意写作 | 指令 > 示例 > 上下文 | 风格与目标由指令定义,示例提供风格参考 |
这个优先级不是理论上的“最优值”,而是可配置的策略。动态 Prompt 系统的价值之一,就是把优先级暴露为可调参数,并支持按请求维度切换。
注意:示例本身就是一种强约束,其强度通常高于文字指令。当输出约束与示例冲突时,模型往往会模仿示例。例如指令要求“只输出 JSON”,但示例中出现了非 JSON 文本,模型很可能照做。
In-Context Learning 与 Prompt 的敏感性和非确定性
In-Context Learning
In-Context Learning 指模型仅通过输入中的少量示例就表现出“学会”某类任务的能力。模型没有更新权重,但其生成分布被示例显著改变。
工程上的推论是:示例的选择直接决定行为边界。两个在语义上等价的示例,可能因为措辞差异导致模型输出风格完全不同。因此,示例不是“给模型看的参考材料”,而是“程序的一部分”,需要纳入版本管理。
敏感性
Prompt 的敏感性体现在多个层面:
- 措辞敏感:同义替换可能导致输出改变。
- 结构敏感:要素先后顺序的变化会影响遵循程度。
- 标点敏感:例如
"输出 JSON:"与"输出 JSON。"可能产生不同格式的输出。 - 示例敏感:示例顺序、数量、示例中的噪声数据都会影响效果。
这些敏感性意味着:对 Prompt 的任何改动都应被视为一次代码变更,而不是文案修改。
非确定性
使用采样解码的模型,在相同输入下可能产生不同输出。非确定性的来源包括:
- 采样温度(temperature)与 top-p 等解码参数;
- 模型并行推理时的浮点运算顺序;
- 服务端的负载与模型版本差异。
工程上不能假定“同样的 Prompt 一定得到同样的输出”。应用层需要把模型输出当作“带噪声的结果”来处理,用校验、重试与结构化输出来提升可靠性。
静态模板的实现方式
静态模板是 Prompt 工程最基础的形式:把 Prompt 写成一个带占位符的字符串,运行时用变量替换占位符。
模板字符串实现
ES6 的模板字符串天然适合做 Prompt 模板:
js
function buildPrompt({ product, question }) {
return `你是一款产品「${product}」的客服助手。
用户问题:${question}
要求:
1. 回答不超过 100 字。
2. 如果问题涉及退换货,先说明售后政策。
`;
}这种实现的特点是直观、零依赖,变量在编写时已确定位置,适合变量数量少、结构固定的场景。
占位符模板实现
当模板需要从配置或数据库中加载时,不能依赖代码中的模板字符串,而是使用占位符语法。一个简单的实现:
js
class Template {
constructor(source) {
this.source = source;
}
render(variables) {
return this.source.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
if (variables[key] === undefined) {
throw new Error(`Missing variable: ${key}`);
}
return String(variables[key]);
});
}
}
const tpl = new Template('你是{{role}}。问题:{{question}}');
console.log(tpl.render({ role: '客服', question: '怎么退货?' }));
// 你是客服。问题:怎么退货?render 方法使用正则查找 {{ }} 占位符,并从 variables 中取值;缺失变量时直接抛错,避免渲染出不完整的 Prompt。这种形式与主流框架的模板语法接近。
LangChain 的 PromptTemplate 使用类似的占位符格式:
typescript
import { PromptTemplate } from "@langchain/core/prompts";
const template = PromptTemplate.fromTemplate(
"你是{role}。请回答:{question}"
);
const formatted = await template.format({
role: "客服",
question: "怎么退货?"
});消息数组的模板化
Chat 场景下,模板需要维护的不只是单个字符串,而是消息数组。可以封装一个消息模板:
js
class MessageTemplate {
constructor(messages) {
this.messages = messages;
}
render(variables) {
return this.messages.map((msg) => ({
role: msg.role,
content: msg.content.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key])
}));
}
}LangChain 的 ChatPromptTemplate 提供了同类的消息数组模板抽象,并支持把消息模板、模型调用和输出解析器串联成可复用的链。这类框架的价值在于把“模板”从单个字符串提升为“多段消息的组合”,同时内置了格式校验与渲染逻辑。
静态模板在真实应用中的局限性
静态模板在演示和简单工具中够用,一旦进入真实应用,问题会逐步显现。
无法响应上下文窗口的容量约束
模板是固定的,但输入内容的大小是变化的。一个支持用户粘贴长文档的模板,可能在输入很长时超过模型的上下文窗口,而且超出的位置不确定——有时是中间被截断,有时是末尾被丢弃。静态模板无法感知 token 计数,也无法决定“先丢弃哪一部分”。
示例不随任务变化
静态模板中的 few-shot 示例对所有请求一视同仁。但对于差异很大的输入(比如法律问题与数学问题),同一组示例中只有少量对当前请求有效,其余示例不仅浪费 token,还可能把模型带偏。
行为不可配置
不同渠道、不同用户、不同风险等级的场景需要不同的 Prompt 策略。静态模板在代码编写时就把策略固化了,后续调整只能改代码、走发布流程,无法快速实验。
版本不可追溯
静态模板与代码耦合,一旦被修改,历史行为就丢失了。当模型输出质量下降时,无法快速回答“上一个版本的 Prompt 是什么”。
上下文来源单一
真实应用需要的上下文来自多个系统:用户画像、订单状态、检索结果、实时数据。静态模板只能在预先定义好的变量里填充,难以表达“从哪来”“何时取”“取不到怎么办”这类逻辑。
这些局限性的共同根源是:静态模板把 Prompt 看作一段文本,而真实应用要求把 Prompt 看作一个由策略驱动的运行时产物。
常用 Prompt 模式
在进入动态系统设计之前,先建立几个常用模式的概念。这些模式是动态系统中“策略”的候选单元。
Few-shot:用示例界定行为
Few-shot 通过示例让模型学会任务。示例数量从几个到几十个不等,是分类、格式转换类任务的主要手段。
Chain-of-Thought:要求显式推理
CoT 要求模型在给出最终答案前展示推理过程。它适用于数学、逻辑、多步规划类任务:
js
const COT_INSTRUCTION = '请逐步推理,最后在单独一行给出答案,格式为"答案:..."。';CoT 对推理类任务的效果提升是 Prompt 工程中相对稳定的经验。但它的代价是输出 token 变多、延迟与成本上升,而且在不需要推理的任务上可能引入过度解释。
ReAct:推理与工具调用交替
ReAct 模式让模型在“推理”与“行动”之间交替:模型输出一步推理,然后决定调用某个工具,工具返回结果后模型继续推理,直到得出最终答案。
一个示意性的系统指令:
js
const REACT_SYSTEM = `你是一个可以通过工具获取信息的助手。
可用工具:
- search(query):搜索互联网
- get_weather(city):查询天气
使用规则:
1. 当你需要信息时,以 JSON 格式输出工具调用:{"tool": "search", "args": {"query": "..."}}
2. 系统会把工具结果以 "Observation:" 开头返回给你。
3. 当信息足够时,以 "Answer:" 开头输出最终回答。`;在实际系统中,工具调用通常通过函数调用(function calling)机制实现:模型输出结构化的工具调用参数,由应用层执行工具,再把结果放回上下文。ReAct 的价值在于它提供了一种让模型“边想边做”的循环结构。
模式的选择
四种模式对应不同的成本与能力:
- zero-shot:最便宜,适合简单任务;
- few-shot:需要示例设计与示例检索,适合分类、格式转换;
- CoT:增加推理深度,适合逻辑任务;
- ReAct:支持多步交互,适合需要外部信息的任务。
动态 Prompt 系统的策略层,主要工作之一就是根据请求特征选择合适的模式。
动态 Prompt 系统的核心架构
动态 Prompt 系统把 Prompt 的组装从“代码中的字符串拼接”提升为“运行时管道”。每个请求的 Prompt 都是根据当前输入、系统状态和策略动态生成的。
核心模块
一个完整的动态 Prompt 系统包含以下模块:
| 模块 | 职责 |
|---|---|
| 上下文收集器 | 从用户输入、检索、记忆、系统状态中收集上下文片段 |
| 策略路由 | 根据请求特征选择模板、模式和参数 |
| 模板仓库 | 存储与版本化 Prompt 模板 |
| 渲染引擎 | 把模板与上下文片段组合为最终 Prompt |
| 输出解析器 | 校验并结构化模型输出 |
| 评估与追踪 | 记录输入输出、成本、延迟,执行回归测试 |
架构示意
用户请求
│
▼
┌─────────────┐ ┌─────────────┐
│ 请求解析 │───▶│ 策略路由 │
└─────────────┘ └─────────────┘
│ 选择模板与策略
▼
┌─────────────┐ ┌─────────────┐
│ 上下文收集 │───▶│ 渲染引擎 │───▶ LLM
│ 记忆/检索/ │ └─────────────┘ │
│ 系统状态 │ │ ▼
└─────────────┘ │ ┌─────────────┐
└────────▶│ 输出解析 │
│ Schema 校验 │
└─────────────┘动态的四个维度
动态系统相对静态模板,具体“动”在四个维度:
- 内容动态:上下文按请求实时组装,不同请求拿到的背景信息不同。
- 结构动态:指令、示例、输出约束的排列组合可按任务切换,同一个模板仓库服务多种任务。
- 策略动态:根据输入特征、成本预算、风险等级,在 zero-shot / few-shot / CoT / ReAct 之间选择。
- 生命周期动态:模板可以灰度发布、按版本回滚,Prompt 的变更不再依赖代码发布。
下文说明内容动态与策略动态,并给出整体实现。
运行时 Prompt 组装管线与动态上下文来源
动态上下文来源
运行时可注入的上下文包括:
- 用户输入:本次请求的主要载荷;
- 检索结果:从向量数据库或搜索引擎召回的相关片段;
- 记忆:用户的历史交互、偏好、长期状态;
- 系统状态:时间、位置、库存、订单状态等业务数据;
- 工具结果:上一次工具调用的返回值。
组装管线的 ES6 实现
以 ES6 的 class 和函数组合来实现组装管线。核心设计是:把每个上下文来源实现为一个返回片段(或 null)的函数,管线按顺序收集并组装。
js
class PromptPipeline {
constructor({ templates, contextProviders }) {
this.templates = templates;
this.contextProviders = contextProviders;
}
async compose({ request }) {
const segments = [];
for (const provider of this.contextProviders) {
const result = await provider(request);
if (result) {
segments.push(result);
}
}
const template = this.templates.for(request);
return template.render({
context: segments.join('\n\n'),
input: request.input
});
}
}上下文提供者是一个个独立的函数。例如一个注入时间与订单信息的提供者:
js
const systemStateProvider = async (request) => {
const lines = [];
lines.push(`当前时间:${new Date().toISOString()}`);
if (request.orderId) {
const order = await fetchOrder(request.orderId);
lines.push(`订单状态:${order.status},商品:${order.items.join('、')}`);
}
return lines.join('\n');
};记忆提供者从会话存储中读取历史:
js
const memoryProvider = async (request) => {
const history = await loadHistory(request.sessionId);
if (history.length === 0) return null;
return '历史对话:\n' + history
.slice(-4)
.map((m) => `${m.role}: ${m.content}`)
.join('\n');
};工具结果提供者把上一步的函数调用结果注入上下文:
js
const toolResultProvider = async (request) => {
if (!request.lastToolResult) return null;
return `工具 ${request.lastToolName} 返回结果:
${request.lastToolResult}`;
};每个 provider 独立返回 null 表示“没有可注入的上下文”,管线据此决定是否保留该片段。拼接逻辑与数据获取逻辑因此被解耦。
注意:上下文片段的顺序会影响模型对信息的重视程度。模型对 Prompt 开头和结尾的关注通常高于中间部分。把最关键的指令放在开头、把需要强约束的输出要求放在结尾,是常见的编排策略。
容量预算:按优先级裁减上下文
当收集到的上下文超过窗口限制时,管线需要按优先级裁减。这里使用前面讨论的四要素优先级:
js
class BudgetAllocator {
constructor({ maxTokens, tokenizer }) {
this.maxTokens = maxTokens;
this.tokenizer = tokenizer; // 依赖模型自带的 tokenizer
}
fit(segments) {
const result = [];
let used = 0;
// 按优先级降序排列
const ranked = [...segments].sort((a, b) => b.priority - a.priority);
for (const segment of ranked) {
const cost = this.tokenizer.count(segment.content);
if (used + cost > this.maxTokens) {
continue;
}
result.push(segment);
used += cost;
}
return result;
}
}优先级在这里被建模为数值字段,可以来自配置文件或策略路由,从而实现了“不同任务、不同裁减策略”。
策略驱动的 Prompt 选择与路由
路由条件
策略路由根据请求特征决定使用哪个模板和哪种模式。典型的特征包括:
- 用户意图分类(售前、售后、技术)
- 问题类型(数学、法律、代码)
- 输入长度(短问题、长文档)
- 风险等级(高价值用户、普通用户)
- 成本预算(实时模式 vs 离线模式)
路由的 ES6 实现
js
class PromptRouter {
constructor(routes) {
this.routes = routes;
}
resolve(request) {
for (const route of this.routes) {
if (route.matches(request)) {
return route;
}
}
return this.defaultRoute;
}
}
const router = new PromptRouter([
{
name: 'math-cot',
matches: (r) => r.intent === 'math',
template: mathTemplate,
strategy: 'cot'
},
{
name: 'legal-fewshot',
matches: (r) => r.intent === 'legal',
template: legalTemplate,
strategy: 'few-shot'
}
]);匹配函数是可组合的,可以把特征抽取与规则判定分离:
js
const isLongDocument = (r) => r.inputLength > 4000;
const isHighRisk = (r) => r.userTier === 'vip';降级与兜底
策略路由还需要定义降级路径。例如:优先使用 ReAct 策略,但当模型服务延迟异常时降级为 zero-shot 以快速返回;当检索服务不可用时,降级为纯模板加用户输入,而不是直接报错。
js
async function callWithFallback(strategy, request) {
try {
return await strategy.execute(request);
} catch (error) {
if (strategy.name === 'react') {
return zeroShotStrategy.execute(request); // 降级
}
throw error;
}
}用 ES6 实现一个动态 Prompt Builder
现在把前面的模块组装成一个可用的 DynamicPromptBuilder。这个实现把上下文收集、策略路由、模板渲染、输出约束四步串联起来。
基础构建器
js
class DynamicPromptBuilder {
constructor({ router, providers, schemaRegistry }) {
this.router = router;
this.providers = providers;
this.schemaRegistry = schemaRegistry;
}
async build(request) {
const route = this.router.resolve(request);
// 1. 收集上下文
const contextSegments = [];
for (const provider of this.providers) {
const segment = await provider(request);
if (segment) contextSegments.push(segment);
}
const context = contextSegments.join('\n\n');
// 2. 渲染模板
const base = route.template.render({
context,
input: request.input
});
// 3. 追加输出约束
const schema = this.schemaRegistry.get(route.schemaName);
const withConstraint = schema
? base + '\n\n' + schema.constraintText
: base;
return {
messages: [
{ role: 'system', content: withConstraint },
{ role: 'user', content: request.input }
],
metadata: {
route: route.name,
templateVersion: route.template.version,
contextSegments: contextSegments.map((s) => s.source),
tokenEstimate: estimateTokens(withConstraint)
}
};
}
}build 返回的对象同时包含最终消息数组和元数据。元数据用于观测与调试,是动态系统区别于静态模板的重要特征——每一次 Prompt 的构成都是可解释的。
可组合的中间件式管线
class 继承容易把系统写死。更灵活的方式是把每一步实现为中间件函数,串联成管线。这与 Express 的中间件模型类似:
js
const pipeline = compose([
injectSystemState,
injectMemory,
selectExamples,
bindOutputSchema,
enforceTokenBudget
]);
const prompt = await pipeline(request);每个中间件接收 context 对象,可以修改它,也可以直接返回错误:
js
const selectExamples = async (ctx) => {
const examples = await retrieveExamples(ctx.request.input, 3);
ctx.segments.push({
source: 'example-store',
priority: 80,
content: formatExamples(examples)
});
return ctx;
};不可变性
在动态系统中,context 对象在中间件之间传递时应该保持不可变风格——每个中间件返回新的 context,而不是修改原对象。这能显著降低调试难度,也有利于追踪“哪个中间件加了哪个片段”。
js
const withSegment = (ctx, segment) => ({
...ctx,
segments: [...ctx.segments, segment]
});ES6 的对象展开与数组展开让这种不可变更新非常自然。
结构化输出与 Schema 绑定
动态 Prompt 系统不仅要生成文本,还要生成可被程序消费的结构化数据。常用方案是把输出约束与校验绑定到 Prompt 上。
约束文本的生成
以 JSON Schema 为例,把 schema 渲染为指令文本:
js
class JsonSchemaBinder {
constructor(schema) {
this.schema = schema;
}
constraintText() {
return `只输出 JSON,格式必须符合以下 JSON Schema:
${JSON.stringify(this.schema)}
不要输出任何解释、前言或 Markdown 代码围栏。`;
}
parse(raw) {
const cleaned = raw
.replace(/^```json\s*/i, '')
.replace(/```$/, '')
.trim();
return JSON.parse(cleaned);
}
}校验与重试
JSON.parse 只能保证语法正确,不能保证字段完整。结合 schema 做校验,并在校验失败时让模型自我修正:
js
class SchemaValidator {
constructor(schema) {
this.schema = schema;
}
validate(data) {
if (data === null || typeof data !== 'object') {
return { ok: false, error: '输出不是对象' };
}
for (const key of Object.keys(this.schema.required ?? {})) {
if (data[key] === undefined) {
return { ok: false, error: `缺少字段: ${key}` };
}
}
return { ok: true };
}
}
async function generateStructured({ prompt, binder, validator, maxRetries = 2 }) {
for (let attempt = 0; ; attempt++) {
const raw = await callModel(prompt);
try {
const data = binder.parse(raw);
const result = validator.validate(data);
if (result.ok) return data;
// 把错误信息拼回 Prompt,让模型修正
prompt += `\n\n上次输出不合规:${result.error}。请重新生成。`;
} catch (e) {
// 语法错误,同样重试
}
if (attempt >= maxRetries) throw new Error('结构化输出重试失败');
}
}让模型根据校验反馈自行修正的机制,在结构化输出场景中比单纯重试有效得多。核心做法是把失败原因作为新的上下文提供给模型。
注意:不要把 schema 描述得过于复杂。过深的嵌套、过多的必填字段会导致模型遵循率下降。一个经验性的做法是:压平嵌套结构、用枚举值代替自由文本、只标记真正需要程序处理的字段。
原生结构化输出
部分模型提供 JSON mode 或结构化输出(structured output)能力,在解码阶段约束输出格式。这类能力可以降低应用层的解析负担,但工程上仍需自行校验,不应完全信任模型。应用层应把原生结构化输出当作优化手段,而不是可靠性来源。
Prompt 版本管理、测试评估与可观测性
版本管理
把 Prompt 当作代码管理,是动态系统的基础设施要求。版本管理需要记录的不只是模板文本,还包括:
- 模型名称与解码参数(temperature、top-p)
- 使用的策略(few-shot / CoT / ReAct)
- 上下文提供者的版本
- 示例数据集的版本
模板仓库可以用 Git 仓库加约定、数据库表或专用平台实现。关键是每一次运行环境中的变更都能对应到一个可回滚的版本标识。
js
class TemplateStore {
constructor() {
this.templates = new Map(); // version -> template
}
register(template) {
const version = template.version;
this.templates.set(version, template);
return version;
}
get(version = 'latest') {
if (version === 'latest') {
return [...this.templates.values()]
.sort((a, b) => b.createdAt - a.createdAt)[0];
}
return this.templates.get(version);
}
}测试评估
动态 Prompt 系统的测试与传统单元测试不同:没有确定性断言,只有基于评估集的回归测试。
评估集(golden set)由输入-期望输出对构成。期望输出可以是:
- 精确答案(适用于分类、抽取)
- 结构约束(适用于 JSON 输出)
- 人工评分(适用于开放生成)
- LLM-as-judge 评分(用另一个模型评价输出质量)
度量指标按任务类型区分:
- 分类任务:准确率、精确率/召回率;
- 抽取任务:字段级完整率;
- 结构化输出:schema 合法率;
- 生成任务:人工评分或 LLM-as-judge 评分。
一个最小化的回归测试框架:
js
import assert from 'node:assert/strict';
async function runEval({ template, model, cases }) {
let pass = 0;
for (const testCase of cases) {
const prompt = template.render(testCase.input);
const output = await model(prompt);
const ok = evalOne(testCase, output);
if (ok) pass++;
}
return { pass, total: cases.length, passRate: pass / cases.length };
}
const result = await runEval({
template,
model,
cases: goldenSet
});
assert.ok(result.passRate >= 0.9, `通过率过低: ${result.passRate}`);评估工具方面,LangSmith 等平台为 LangChain 生态提供了 Prompt 版本追踪、运行链路回放与离线评估能力。其核心抽象是:一次完整的模型调用(含输入、输出、中间步骤、成本、延迟)被记录为一个可查询的 trace,评估集可以针对这批 trace 批量打分。这类平台解决的问题是“Prompt 改了之后,如何知道是变好了还是变坏了”。
对于没有引入平台的团队,一个可工作的替代方案是把评估集和评测脚本纳入 CI,在每次模板变更时运行回归。
注意:评估集需要定期更新。模型会升级,实际运行输入的分布会漂移,固定的评估集只能说明“过去有效”。把运行中表现差的样本回流到评估集,是维持评估有效性的必要操作。
可观测性
每次调用的可观测数据至少包括:
- 完整的输入消息(system / user / assistant)
- 模型输出与解析结果
- 选中的路由、模板版本、策略
- 上下文片段的来源列表
- token 数、延迟、成本
- 校验与重试过程
在 ES6 中,可以用一个 trace 对象贯穿整个管线,最后落库:
js
function createTracer() {
const events = [];
return {
record(event) { events.push(event); },
snapshot() { return { events, at: new Date().toISOString() }; }
};
}可观测性的目标是回答两个问题:输出为什么是这样?成本花在了哪里?
缓存、成本与延迟控制
动态 Prompt 系统因为要实时组装上下文、可能调用检索与记忆服务,其延迟和成本往往高于静态模板。控制手段主要有四种。
精确缓存
以模型名、Prompt 文本、解码参数为键,缓存模型输出。适合重复性高的请求(如热门问题的回答)。
js
class ResponseCache {
constructor() {
this.store = new Map();
}
keyFor({ model, messages, params }) {
return JSON.stringify({ model, messages, params });
}
async getOrCall({ model, messages, params, call }) {
const key = this.keyFor({ model, messages, params });
if (this.store.has(key)) {
return this.store.get(key);
}
const promise = call();
this.store.set(key, promise);
return promise;
}
}用 Promise 作为缓存值,可以在并发请求到达时只触发一次模型调用。
语义缓存
当输入不完全相同时,精确缓存失效。语义缓存用嵌入向量计算输入相似度,与历史请求相似度高于阈值时直接返回历史答案。语义缓存的正确性风险较高——语义相近不等于答案相同,通常只用于信息密度低、答案对内容变化不敏感的场景。
Prompt 压缩
减少 token 是控制成本最直接的手段。压缩手段包括:
- 裁减低优先级上下文(见上文容量预算一节);
- 对历史对话做摘要,而不是全文保留;
- 示例去重:删除与该请求无关的示例;
- 使用更短的指令措辞。
模型分级
对低风险请求使用更小的模型,对高风险请求使用更强模型。路由层把模型选择纳入策略:
js
const route = router.resolve(request);
const model = route.riskLevel === 'high' ? 'gpt-4o' : 'gpt-4o-mini';这是一种应用级的成本控制,利用的是模型能力差异,而不是 Prompt 文本差异。
从 Prompt Engineering 到 Context Engineering
Agent 系统改变了上下文的边界
在 Agent 系统中,模型在一次任务里会经历多轮推理-行动循环。每一轮,工具结果都会被追加到上下文中。此时,Prompt 只是上下文的静态基座,真正决定行为的是运行时上下文的状态机——哪些工具结果被保留、哪些被摘要、哪些被丢弃。
Context Engineering(上下文工程)这一概念强调:系统性管理进入模型上下文窗口的所有信息,而不是只关注指令文本的措辞。
上下文状态机
Agent 系统需要对上下文做显式管理。一个简化模型:
js
class ContextManager {
constructor(limit) {
this.limit = limit;
this.history = [];
}
add(message) {
this.history.push(message);
this.trim();
}
trim() {
// 压缩中间步骤,保留系统指令、最新观察与最终目标
const system = this.history.filter((m) => m.role === 'system');
const recent = this.history.slice(-6);
this.history = [...system, ...recent];
}
summary() {
return this.history
.map((m) => `${m.role}: ${m.content}`)
.join('\n');
}
}自我演进与 Meta Prompting
另一类实践是让模型参与 Prompt 自身的改进:
- Meta Prompting:用一个“编写 Prompt”的 Prompt,根据任务描述生成候选 Prompt;
- Self-Refine:让模型先输出答案,再根据反馈修正输出(结构化输出中的重试机制就是一种简化形式);
- 自动示例挖掘:从历史调用中找出失败样本,自动生成新的 few-shot 示例。
这些方法的共同点是把“Prompt 优化”本身变成一个可编程的循环。它们仍处于快速发展阶段,工程落地时的稳定性、成本与评估问题依然显著。
工程重心的迁移
从静态模板到动态 Prompt 系统,再到上下文工程,工程重心的迁移路径是清晰的:
- 文本阶段:把 Prompt 看作字符串,关注措辞。
- 模板阶段:把 Prompt 看作带占位符的结构,关注复用。
- 系统阶段:把 Prompt 看作运行时产物,关注组装、路由、版本与观测。
- 上下文阶段:把注意力扩展到模型可见的全部信息,关注记忆、工具结果与状态管理。
这一演进不是要否定前一个阶段,而是每一层都在解决前一层暴露的工程问题。
