跳到主要内容

Swee-Prompt 模板管理系统

Swee Prompt Hub 是 Swee AI 社交平台的内部 Prompt 模板管理系统,独立负责前后端设计与开发。基于 NestJS + PostgreSQL + Ant Design + Tailwind CSS 构建,管理平台 60+ 个 Prompt 模板的全生命周期——创建、变量定义、实时预览、版本控制、Playground 调试、发布审批和 SDK 集成。将 Prompt 从硬编码代码中解耦,使运营和产品团队能够在不发版的情况下迭代 AI 效果,Prompt 迭代周期从"提需求 → 开发 → 上线"的 2-3 天缩短至运营自行修改后分钟级生效。


一、项目背景与动机

1.1 业务背景

Swee 是一个 AI 驱动的社交娱乐平台,核心玩法是用户与 AI 数字人进行个性化互动。平台的 AI 能力覆盖以下场景:

场景大类具体功能Prompt 数量
数字人系统人格生成、背景故事、开场白、角色描述~8
聊天交互日常对话、智能回复、话题生成、聊天助手~10
好感度与关系好感度计算、分级触发、心跳回忆~7
任务与剧情偶遇剧情、随机任务、Plot 模式、故事线~12
AIGC 内容生成剧情创作、内容挑战、角色扮演~15
AI 图片系统MidJourney Prompt、封面图、场景图~9
运营工具App Store 图、推广文章、隐私协议、日报~5
特殊功能占星、唤醒、内容建议、评分~6

总计 60+ 个 Prompt 模板,分散在 Java 后端、NestJS 后端、管理后台的代码中。

1.2 核心痛点

在做这个系统之前,所有 Prompt 都是硬编码在代码里的:

改造前:Prompt 硬编码在 Java/NestJS 代码中
// ❌ 数字人人格生成 —— 写死在 NestJS Service 中
const personalityPrompt = `You are a personality designer. Based on the following user preferences:
- Zodiac: ${zodiac}
- MBTI: ${mbti}
- Favorite movie character: ${character}

Generate 5 distinct personality profiles...`;

// ❌ 每次修改措辞都要:改代码 → Code Review → 部署 → 验证
// ❌ 运营想微调"语气更活泼一点"也得找开发提需求

具体的问题:

痛点影响
修改成本高运营调整一个措辞 → 开发改代码 → CR → 部署,最快也要半天
无法快速验证没有 Playground,改完 Prompt 要跑完整业务流程才能看效果
无版本记录Prompt 改坏了不知道上一版是什么,只能翻 Git 历史
分散难管理60+ 个 Prompt 散落在 Java 和 Node 两个后端的不同服务中
无法对比效果两种 Prompt 写法哪个好?没有数据,只能凭感觉
多语言困难平台支持 8 种语言,部分 Prompt 需要多语言版本,硬编码管理混乱

1.3 我的角色

项目负责人,前后端一手包办。从需求调研、技术选型、架构设计、前后端开发、部署上线到后续维护,全程独立完成。使用方是产品经理(2 人)和运营团队(3 人),对接方是 Java 后端团队(接入 SDK 调用模板)。


二、技术架构

2.1 整体架构图

2.2 核心技术栈

领域技术选型说明
前端框架React 18 + Ant Design 5管理后台统一技术栈,降低团队维护成本
前端样式Tailwind CSS与 Ant Design 配合,快速定制编辑器和 Playground 布局
代码编辑器CodeMirror 6模板编辑器的核心,支持自定义语法高亮和变量自动补全
后端框架NestJS 10与 Swee 现有 NestJS 服务统一,模块化架构便于拆分
数据库PostgreSQLJSONB 原生支持存储变量定义和模型配置,比 MySQL 更适合半结构化数据
缓存Redis模板缓存 + 滑动窗口限流
文件存储阿里云 OSS执行日志长期归档
部署K8s(阿里云 ACK)与现有基础设施统一
鉴权外部 Java 认证服务统一鉴权,不重复造轮子
技术选型决策

为什么用 PostgreSQL 而不是现有的 MySQL?

Swee 现有服务用的是 MySQL,但 Prompt 模板的变量定义(variables)和模型配置(modelConfig)是嵌套 JSON 结构。MySQL 的 JSON 支持较弱(无法建索引、查询语法繁琐),PostgreSQL 的 JSONB 支持原生索引和丰富的查询操作符(@>?->> 等),更适合这个场景。并且这个系统的数据是独立的,不需要和现有 MySQL 做 JOIN,所以引入一个新数据库的成本可以接受。

2.3 系统页面总览

页面路由功能
模板列表/templates搜索/筛选/分类浏览所有模板,显示状态标签
模板编辑/templates/:id/edit三栏布局:变量面板 + Prompt 编辑器 + 预览
Playground/templates/:id/playground填写变量、运行调试、查看追踪和运行历史
版本历史/templates/:id/versions版本列表、Diff 对比、回滚操作
审批列表/approvals待审核模板列表,审批/打回操作

2.4 为什么自研而不用 LangSmith / Humanloop?

这是面试官大概率会问的问题,实际决策过程:

维度LangSmith / Humanloop自研
功能匹配通用型,功能强大但很多用不到只做需要的功能,贴合业务
数据安全SaaS,数据出境(Swee 用户数据涉及隐私)数据留在自己的阿里云
集成成本Java 后端需要适配其 SDK,改动大自己写 SDK,直接对接现有架构
费用LangSmith 按量计费,60+ 模板日均数万调用,月费用不低自研只有服务器成本
定制性无法定制审批流程、权限体系完全贴合团队的工作流
维护成本无需维护需要自己维护(这是代价)

最终决策:自研。核心原因是数据安全要求和 Java 后端的集成成本。Swee 的 Prompt 中包含用户画像、对话策略等敏感信息,不适合放到海外 SaaS 平台。另外团队的 Java 后端已经有一套标准化的服务调用方式,自研 SDK 可以无缝集成。

诚实地说缺点

自研的代价是维护成本全在自己身上。如果团队有专人维护 Prompt 工程且数据合规要求没那么严,LangSmith 是更好的选择——不要为了自研而自研。


三、数据模型与 API 设计

3.1 数据模型

完整表结构总览

系统共 3 张核心表,关系如下:

用途关键设计
prompt_templates模板主表,存储当前最新状态versionpublishedVersion 分离,编辑不影响线上
template_versions版本快照表,每次保存/回滚创建一条JSONB snapshot 存完整快照(< 5KB),可直接回滚,联合唯一索引 (templateId, version)
execution_logs执行日志表,Playground 调试和 SDK 正式调用都记录source 区分来源,记录 Token/耗时/成本用于统计分析,索引 (templateId, createdAt)
entities/prompt-template.entity.ts
@Entity('prompt_templates')
class PromptTemplateEntity {
@PrimaryColumn()
id: string; // 格式 tpl_xxxx,便于日志排查

@Column()
name: string; // 模板名称,如"数字人人格生成"

@Column()
description: string;

@Column({ type: 'text' })
systemPrompt: string; // System 消息模板

@Column({ type: 'text' })
userPrompt: string; // User 消息模板

@Column({ type: 'text', nullable: true })
assistantPrompt?: string; // 预填充的 Assistant 消息(Few-shot)

// PostgreSQL JSONB —— 变量定义存为 JSON,支持索引和查询
@Column({ type: 'jsonb', default: [] })
variables: TemplateVariable[];

@Column({ type: 'jsonb' })
modelConfig: ModelConfig;

@Column({ type: 'enum', enum: ['draft', 'review', 'published', 'archived'] })
status: TemplateStatus;

@Column()
category: string; // 分类:digital_persona / chat / aigc / ops

@Column('simple-array')
tags: string[];

@Column({ type: 'int', default: 1 })
version: number;

@Column({ type: 'int', nullable: true })
publishedVersion?: number; // 线上运行的版本号

@Column()
createdBy: string;

@Column()
updatedBy: string;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
types/template.ts
/** 变量类型 */
interface TemplateVariable {
name: string; // 变量名,如 {{zodiac}}
type: 'string' | 'number' | 'boolean' | 'enum' | 'array' | 'json';
required: boolean;
defaultValue?: unknown;
description: string; // 显示在变量面板的说明
enumValues?: string[]; // type 为 enum 时的可选值
maxLength?: number; // 防止超长输入导致 Token 爆炸
}

/** 模型配置 */
interface ModelConfig {
provider: 'openai' | 'anthropic';
model: string; // gpt-4o / claude-sonnet-4-6 等
temperature: number;
maxTokens: number;
responseFormat?: 'text' | 'json';
}
面试要点:为什么 publishedVersion 和 version 分离?

编辑草稿时 version 会递增(v5 草稿),但线上跑的可能还是 v3(publishedVersion=3)。这样运营可以安全地编辑和调试新版本,不会影响线上的 AI 效果。发布操作才会把 publishedVersion 更新为最新版本。

entities/template-version.entity.ts
@Entity('template_versions')
class TemplateVersionEntity {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
templateId: string;

@Column({ type: 'int' })
version: number;

/**
* 完整快照 —— 存储该版本发布时的所有信息
* JSONB 类型,包含 systemPrompt / userPrompt / variables / modelConfig 等
* 不可变记录:一旦创建永不修改
*/
@Column({ type: 'jsonb' })
snapshot: Record<string, unknown>;

@Column({ type: 'text' })
changelog: string;

@Column()
createdBy: string;

@CreateDateColumn()
createdAt: Date;

@ManyToOne(() => PromptTemplateEntity)
@JoinColumn({ name: 'templateId' })
template: PromptTemplateEntity;

// 联合索引:快速查询某模板的版本列表
@Index(['templateId', 'version'], { unique: true })
templateVersion: string;
}
entities/execution-log.entity.ts
@Entity('execution_logs')
// 按模板 + 时间查询,用于执行历史和成本统计
@Index(['templateId', 'createdAt'])
class ExecutionLogEntity {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
templateId: string;

@Column({ type: 'int' })
version: number;

/** 调用来源:playground(调试)/ sdk(正式调用) */
@Column({ type: 'enum', enum: ['playground', 'sdk'] })
source: 'playground' | 'sdk';

/** 输入变量 */
@Column({ type: 'jsonb' })
variables: Record<string, unknown>;

/** 渲染后发给 LLM 的 messages */
@Column({ type: 'jsonb' })
renderedMessages: Array<{ role: string; content: string }>;

/** LLM 返回内容 */
@Column({ type: 'text', nullable: true })
response: string | null;

/** 执行是否成功 */
@Column({ type: 'boolean', default: true })
success: boolean;

/** 失败时的错误信息 */
@Column({ type: 'text', nullable: true })
errorMessage: string | null;

/** 执行指标 */
@Column({ type: 'int', nullable: true })
inputTokens: number | null;

@Column({ type: 'int', nullable: true })
outputTokens: number | null;

@Column({ type: 'int' })
latencyMs: number;

/** 成本(美元),精确到小数点后 6 位
* LLM API 返回 token 用量 — 调用 OpenAI/Anthropic 等 API 后,响应的 usage 字段会返回 prompt_tokens(输入)和 completion_tokens(输出)
* 计算方式:cost = inputTokens × 模型输入单价 + outputTokens × 模型输出单价
* 单价从服务端维护的模型价格表中获取(单位:美元/token)
*/
@Column({ type: 'decimal', precision: 10, scale: 6, nullable: true })
cost: number | null;

@Column()
model: string; // 实际使用的模型

@Column()
executedBy: string; // 谁触发的(playground 是操作人,sdk 是调用方服务名)

@CreateDateColumn()
createdAt: Date;

}
PostgreSQL JSONB 索引

variablesrenderedMessages 使用 JSONB 存储,虽然不对内部字段建索引(这两个字段只做归档和回放,不做查询条件),但 templateId + createdAt 的联合索引确保了"查看某模板最近 N 条执行记录"的查询性能。执行日志超过 90 天的会归档到 OSS,PostgreSQL 中只保留近 90 天的数据。

3.2 NestJS 模块划分

src/
├── app.module.ts # 根模块
├── common/
│ ├── guards/auth.guard.ts # 调用 Java 认证服务的鉴权守卫
│ ├── interceptors/
│ │ ├── response.interceptor.ts # 统一响应格式 { code, data, message }
│ │ └── trace.interceptor.ts # 链路追踪 traceId 注入
│ ├── filters/all-exception.filter.ts
│ └── decorators/auth.decorator.ts # @Auth() 权限装饰器

├── template/ # 模板模块(核心)
│ ├── template.module.ts
│ ├── template.controller.ts # 模板 CRUD + 搜索
│ ├── template.service.ts # 模板业务逻辑
│ ├── entities/
│ │ ├── template.entity.ts # prompt_templates 表
│ │ └── template-version.entity.ts # template_versions 表
│ └── dto/
│ ├── create-template.dto.ts
│ ├── update-template.dto.ts
│ └── query-template.dto.ts

├── version/ # 版本管理模块
│ ├── version.module.ts
│ ├── version.controller.ts # 版本创建 / Diff / 回滚 / 发布
│ └── version.service.ts

├── execution/ # 执行引擎模块
│ ├── execution.module.ts
│ ├── execution.controller.ts # Playground 执行 + SDK 执行
│ ├── execution.service.ts
│ ├── entities/
│ │ └── execution-log.entity.ts # execution_logs 表
│ └── engine/
│ ├── variable-engine.ts # 变量引擎(条件/循环/插值)
│ ├── variable-validator.ts # 变量校验
│ ├── traced-render.ts # 带追踪的渲染
│ └── llm-gateway.ts # LLM 调用封装(OpenAI/Anthropic)

├── approval/ # 审批模块
│ ├── approval.module.ts
│ ├── approval.controller.ts # 提交审核 / 审批
│ └── approval.service.ts

└── sdk/ # SDK API(供 Java 后端调用)
├── sdk.module.ts
├── sdk.controller.ts # /api/sdk/templates/:id/render
└── sdk.service.ts

3.3 核心 API 接口

template.controller.ts
@Controller('api/templates')
export class TemplateController {
// ── 模板 CRUD ──
@Post()
@Auth('template:create')
create(@Body() dto: CreateTemplateDto): Promise<PromptTemplate>;

@Get()
@Auth('template:view')
list(@Query() query: QueryTemplateDto): Promise<PaginatedResult<PromptTemplate>>;
// 支持按 category / tags / status / keyword 筛选

@Get(':id')
@Auth('template:view')
findOne(@Param('id') id: string): Promise<PromptTemplate>;

@Patch(':id')
@Auth('template:edit')
update(@Param('id') id: string, @Body() dto: UpdateTemplateDto): Promise<PromptTemplate>;

@Delete(':id')
@Auth('template:delete')
remove(@Param('id') id: string): Promise<void>;
}

@Controller('api/versions')
export class VersionController {
// ── 版本管理 ──
@Post(':templateId')
@Auth('template:edit')
createVersion(
@Param('templateId') templateId: string,
@Body() body: { changelog: string },
): Promise<TemplateVersion>;

@Get(':templateId')
@Auth('template:view')
listVersions(@Param('templateId') templateId: string): Promise<TemplateVersion[]>;

@Get(':templateId/diff')
@Auth('template:view')
diff(
@Param('templateId') templateId: string,
@Query('from') from: number,
@Query('to') to: number,
): Promise<TemplateDiff>;

@Post(':templateId/rollback')
@Auth('template:publish')
rollback(
@Param('templateId') templateId: string,
@Body() body: { targetVersion: number },
): Promise<void>;
}

@Controller('api/execution')
export class ExecutionController {
// ── Playground 调试 ──
@Post('playground')
@Auth('playground:run')
playground(@Body() body: {
templateId: string;
variables: Record<string, unknown>;
modelOverride?: Partial<ModelConfig>; // Playground 可以临时覆盖模型参数
}): Promise<PlaygroundResult>;
// 返回:渲染追踪 + LLM 输出 + Token/延迟/成本指标

// ── 纯渲染(不调 LLM) ──
@Post('render')
@Auth('playground:run')
render(@Body() body: {
templateId: string;
variables: Record<string, unknown>;
}): Promise<{ messages: Message[]; estimatedTokens: number }>;
}

@Controller('api/approval')
export class ApprovalController {
// ── 审批流程 ──
@Post(':templateId/submit')
@Auth('template:edit')
submitForReview(@Param('templateId') templateId: string): Promise<void>;

@Post(':templateId/approve')
@Auth('template:publish')
approve(@Param('templateId') templateId: string): Promise<void>;

@Post(':templateId/reject')
@Auth('template:publish')
reject(
@Param('templateId') templateId: string,
@Body() body: { reason: string },
): Promise<void>;
}

@Controller('api/sdk')
export class SdkController {
// ── 供 Java 后端调用的 SDK 接口 ──

/** Java 后端调用最多的接口:获取已发布模板 */
@Get('templates/:id/published')
getPublished(@Param('id') id: string): Promise<PromptTemplate>;
// 查找链路:Redis 缓存 → PostgreSQL → 回写 Redis

/** render-only 模式:渲染模板返回 messages */
@Post('templates/:id/render')
render(@Param('id') id: string, @Body() body: {
variables: Record<string, unknown>;
}): Promise<{ messages: Message[] }>;

/** execute 模式:渲染 + 调 LLM(运营工具场景用) */
@Post('templates/:id/execute')
execute(@Param('id') id: string, @Body() body: {
variables: Record<string, unknown>;
stream?: boolean;
}): Promise<ExecutionResult>;
}

四、核心功能设计

4.1 变量引擎

变量引擎是系统的核心——决定了模板能表达多复杂的逻辑。

engine/variable-engine.ts
/**
* 三步渲染管线:条件块 → 循环块 → 变量插值
*
* 顺序不能变!原因:
* 1. 条件块先处理 → 决定哪些文本段参与后续渲染
* 2. 循环块再处理 → 展开 {{#each}} 生成重复段落
* 3. 变量插值最后 → 替换残留的 {{variable}}
*/
function renderTemplate(
template: string,
variables: Record<string, unknown>,
): string {
let result = template;
result = processConditionals(result, variables); // {{#if}}...{{/if}}
result = processLoops(result, variables); // {{#each}}...{{/each}}
result = interpolateVariables(result, variables); // {{variable | filter}}
return result;
}

/**
* 1. 条件块处理:匹配 {{#if key}}...{{/if}},支持 {{#else}}
* 根据 variables[key] 的真假决定保留哪段内容
*/
function processConditionals(
template: string,
variables: Record<string, unknown>,
): string {
// 正则匹配 {{#if key}}...{{#else}}...{{/if}},{{#else}} 可选
const IF_REGEX = /\{\{#if\s+(\w+)\}\}([\s\S]*?)(?:\{\{#else\}\}([\s\S]*?))?\{\{\/if\}\}/g;

// 由外到内递归处理(支持嵌套 if)
let result = template;
let prev = '';
while (result !== prev) {
prev = result;
result = result.replace(IF_REGEX, (_, key, ifBlock, elseBlock = '') => {
const value = variables[key];
// 判断真假:null / undefined / '' / false / 空数组 均视为 false
const isTruthy = Array.isArray(value) ? value.length > 0 : Boolean(value);
return isTruthy ? ifBlock : elseBlock;
});
}
return result;
}

/**
* 2. 循环块处理:匹配 {{#each key}}...{{/each}}
* 将 variables[key](数组)展开为多段文本
* 循环体内用 {{item}} 引用当前元素,{{index}} 引用下标
*/
function processLoops(
template: string,
variables: Record<string, unknown>,
): string {
const EACH_REGEX = /\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g;

return template.replace(EACH_REGEX, (_, key, loopBody) => {
const arr = variables[key];
if (!Array.isArray(arr)) return '';

return arr
.map((item, index) => {
let block = loopBody;
// 如果元素是对象,展开其属性为可替换变量
if (typeof item === 'object' && item !== null) {
for (const [k, v] of Object.entries(item)) {
block = block.replaceAll(`{{${k}}}`, String(v));
}
}
// {{item}} 替换为当前元素值(基本类型时使用)
block = block.replaceAll('{{item}}', String(item));
block = block.replaceAll('{{index}}', String(index));
return block;
})
.join('');
});
}

/**
* 3. 变量插值:替换 {{variable}}{{variable | filter}}
* 支持过滤器管道(如 {{name | uppercase | trim}}
*/
function interpolateVariables(
template: string,
variables: Record<string, unknown>,
): string {
// 内置过滤器
const filters: Record<string, (val: string) => string> = {
uppercase: (v) => v.toUpperCase(),
lowercase: (v) => v.toLowerCase(),
trim: (v) => v.trim(),
json: (v) => JSON.stringify(v),
};

// 匹配 {{variable}} 或 {{variable | filter1 | filter2}}
const VAR_REGEX = /\{\{\s*(\w+)(\s*\|[^}]+)?\s*\}\}/g;

return template.replace(VAR_REGEX, (_, key, filterChain) => {
let value = String(variables[key] ?? '');

// 处理过滤器管道
if (filterChain) {
const filterNames = filterChain.split('|').map((f: string) => f.trim()).filter(Boolean);
for (const name of filterNames) {
if (filters[name]) {
value = filters[name](value);
}
}
}

return value;
});
}

为什么需要条件和循环?

以实际业务场景为例:

数字人人格生成模板 —— 使用了条件块和循环块
你是一个 AI 人格设计师。请根据以下用户偏好生成 5 种独特的人格画像。

用户偏好:
- 星座:{{zodiac}}
- MBTI 类型:{{mbti}}
{{#if favoriteCharacter}}
- 最喜欢的角色:{{favoriteCharacter}}(请参考该角色的性格特点)
{{/if}}

{{#if customTraits}}
用户额外指定的性格特征:
{{#each customTraits}}
- {{item}}
{{/each}}
{{/if}}

输出要求:
每个人格包含:名称、语言风格、行为习惯、性格特质(各 50 字以内)。
输出格式:JSON 数组。

如果没有条件块,favoriteCharacter 为空时会生成一行 - 最喜欢的角色:,破坏 Prompt 语义。条件块让模板能自适应不同的输入组合。

变量校验

engine/variable-validator.ts
/**
* 在渲染之前校验变量,拦截非法输入
* 核心目的:把能在本地判断的错误全部在调 LLM 之前拦截,避免浪费 Token
*/
function validateVariables(
definitions: TemplateVariable[],
inputs: Record<string, unknown>,
): { valid: boolean; errors: string[] } {
const errors: string[] = [];

for (const def of definitions) {
const value = inputs[def.name];

// 必填校验
if (def.required && (value === undefined || value === null || value === '')) {
errors.push(`变量 "${def.name}" 为必填项`);
continue;
}
if (value === undefined) continue;

// 枚举校验
if (def.type === 'enum' && def.enumValues && !def.enumValues.includes(String(value))) {
errors.push(`变量 "${def.name}" 的值必须是 [${def.enumValues.join(', ')}] 之一`);
}

// 长度校验 —— 防 Token 爆炸的关键防线
// 比如"剧情对话推进"模板的 chatHistory 变量,不加限制可能传入上千轮对话
if (def.maxLength && typeof value === 'string' && value.length > def.maxLength) {
errors.push(`变量 "${def.name}" 长度 ${value.length} 超过限制 ${def.maxLength}`);
}
}

return { valid: errors.length === 0, errors };
}

4.2 模板编辑器

编辑页采用三栏布局:

┌──────────┬───────────────────────────────┬───────────────────────┐
│ 变量面板 │ Prompt 编辑区 │ 预览 / 调试面板 │
│ │ │ │
│ {{zodiac}}│ [System] [User] [Assistant] │ [预览] [Playground] │
│ 类型:str │ ┌─────────────────────────┐ │ │
│ 必填:是 │ │ 你是一个 AI 人格设计师。 │ │ 渲染结果: │
│ │ │ ... │ │ ┌─────────────────┐ │
│ {{mbti}} │ │ - 星座:{{zodiac}} │ │ │ 你是一个 AI 人格 │ │
│ 类型:enum│ │ - MBTI:{{mbti}} │ │ │ 设计师。 │ │
│ 值:INTJ..│ └─────────────────────────┘ │ │ - 星座:双鱼座 │ │
│ │ │ │ - MBTI:INFP │ │
│ 模型配置 │ CodeMirror 6 编辑器 │ └─────────────────┘ │
│ ──────── │ · {{}} 变量语法高亮(黄色) │ │
│ 模型:gpt-│ · {{#if}} 条件块高亮(蓝色) │ Token 估算: ~280 │
│ 4o │ · 输入 {{ 弹出自动补全 │ │
│ Temp:0.7 │ │ 版本历史 ────────── │
│ │ │ v3 当前 ← 我 │
└──────────┴───────────────────────────────┴───────────────────────┘

前端实现要点

特性实现方式目的
变量语法高亮CodeMirror 6 自定义 Language Extension{{variable}} 标黄,{{#if}} 标蓝
变量自动补全CodeMirror 6 autocompletion API输入 {{ 弹出已定义变量列表
实时预览useMemo + renderTemplate左侧编辑 → 右侧即时看到渲染结果
Token 估算中文字数 × 2 + 英文词数 × 1.3给运营一个直观的成本感知
变量自动检测正则提取模板中的 {{xxx}}检测未定义的变量,弹出提示
实际踩坑:CodeMirror 6 的自定义高亮

CodeMirror 6 的 Language 机制和 CM5 完全不同,不是简单的正则 mode,而是基于 Lezer 语法树。我最终没有写完整的 Lezer Grammar,而是用 ViewPlugin + Decoration 的方式做了轻量的覆盖高亮——先让 CM6 按纯文本解析,再用 RangeSet{{...}} 区间添加颜色 Decoration。这比写一套语法解析器简单得多,对于模板语法高亮已经够用。

变量自动补全的实现要点

使用 @codemirror/autocompleteautocompletion 扩展,注册自定义补全源,检测光标前是否刚输入了 {{,如果是则弹出已定义的变量列表:

TemplateEditor/completions.ts
import { autocompletion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';

interface TemplateVariable {
name: string;
description: string;
}

// 自定义补全源:检测 {{ 前缀并弹出变量列表
function createVariableCompletion(variables: TemplateVariable[]) {
return (context: CompletionContext): CompletionResult | null => {
// 匹配光标前的 {{xxx 模式({{ 后可跟部分变量名用于过滤)
const match = context.matchBefore(/\{\{(\w*)$/);
if (!match) return null;

return {
from: match.from + 2, // 从 {{ 之后开始替换,保留 {{ 本身
options: variables.map((v) => ({
label: v.name,
detail: v.description,
apply: `${v.name}}}`, // 选中后自动补上 }},用户无需手动闭合
})),
filter: true, // CM6 根据已输入的字符自动过滤候选项
};
};
}

// 注册到编辑器扩展
export function templateAutocompletion(variables: TemplateVariable[]) {
return autocompletion({
override: [createVariableCompletion(variables)],
activateOnTyping: true, // 打字时自动触发,不需要 Ctrl+Space
});
}

关键设计点:

  • matchBefore(/\{\{(\w*)$/):匹配 {{ 开头 + 已输入的部分变量名,实现边输入边过滤
  • from: match.from + 2:补全替换的起点跳过 {{,确保双花括号保留
  • apply 自动闭合:选中补全项后自动追加 }},减少手动操作
  • 变量列表动态更新:变量列表从 React state 传入,当变量定义变化时通过 CM6 的 Compartment 动态替换扩展即可

4.3 Playground 调试

Playground 是运营使用频率最高的功能——改完 Prompt 后立即用真实变量测试效果,不需要走完整业务流程。

┌──────────┬───────────────────────────────┬───────────────────────┐
│ 变量填写 │ 渲染后的最终 Prompt │ 调试面板 │
│ │ │ │
│ zodiac: │ System: 你是一个 AI 人格... │ 运行历史 │
│ [双鱼座] │ User: 请根据以下用户偏好... │ 14:32 1.2s $0.003 ✓ │
│ │ │ 14:30 0.8s $0.002 ✓ │
│ mbti: │ [▶ 运行] │ 14:28 1.5s $0.004 ✗ │
│ [INFP ▼] │ │ │
│ │ LLM 输出(流式显示) │ [追踪] [请求] [响应] │
│ │ ┌─────────────────────────┐ │ ┌─────────────────┐ │
│ 模型: │ │ [{"name":"月光诗人", │ │ │ ① 条件块处理 ✓ │ │
│ [gpt-4o] │ │ "style":"温柔细腻", │ │ │ ② 循环块处理 ✓ │ │
│ Temp: │ │ ... │ │ │ ③ 变量插值 ✓ │ │
│ [0.7] │ │ }] │ │ │ │ │
│ │ └─────────────────────────┘ │ │ ⚠️ 未使用: lang │ │
│ │ │ └─────────────────┘ │
└──────────┴───────────────────────────────┴───────────────────────┘

调试面板的核心能力——渲染追踪

渲染追踪记录了三步管线的每步中间结果,运营可以逐步查看模板是如何变成最终 Prompt 的:

engine/traced-render.ts
/**
* 带调试追踪的渲染函数
* Playground 每次运行都调用这个,记录每一步的中间结果
*/
function renderWithTrace(
template: string,
variables: Record<string, unknown>,
): { result: string; trace: RenderTrace } {
const afterConditional = processConditionals(template, variables);
const afterLoop = processLoops(afterConditional, variables);
const afterInterpolation = interpolateVariables(afterLoop, variables);

// 检测变量匹配情况
const templateVars = extractVariables(template);
const inputKeys = Object.keys(variables);

return {
result: afterInterpolation,
trace: {
afterConditional,
afterLoop,
afterInterpolation,
unmatchedVariables: templateVars.filter((v) => !(v in variables)), // 模板有,输入没有
unusedVariables: inputKeys.filter((k) => !templateVars.includes(k)), // 输入有,模板没有
},
};
}

调试面板三个 Tab 的分工

调试面板右侧的「追踪」「请求」「响应」三个 Tab 覆盖了从模板渲染到 LLM 调用的完整排查链路:

Tab展示内容排查什么问题
追踪三步管线的每步中间结果(条件块处理 → 循环块处理 → 变量插值),以及未匹配/未使用的变量警告模板渲染是否正确:条件走错分支、循环没展开、变量名拼错
请求发给 LLM 的完整请求体:渲染后的最终 Prompt(system/user/assistant 消息)、模型参数(model、temperature、max_tokens、response_format 等)发出去的内容是否正确:Prompt 拼接有误、模型参数配错
响应LLM 返回的原始 JSON 响应:content、finish_reason、Token 用量(prompt_tokens / completion_tokens)、耗时和费用LLM 返回是否符合预期:输出格式不对、finish_reason 异常(如 length 表示被截断)

运营的排查顺序:追踪 → 请求 → 响应,逐层缩小问题范围——先确认模板渲染没问题,再确认发给 LLM 的内容是对的,最后判断是不是模型本身的问题。

面试话术

"Playground 的调试面板设计了三个 Tab,覆盖「模板渲染 → LLM 请求 → LLM 响应」的完整链路。80% 的 Prompt 问题出在变量/条件渲染阶段——比如条件块路径走错了、变量名拼错了——而不是 LLM 本身的问题。有了这个分层排查面板,运营可以自己定位问题,不需要来找开发。"

4.4 版本管理

关键设计决策

决策选择原因
存储方式完整快照模板通常 < 5KB,快照可直接回滚,不依赖版本链
回滚实现创建新版本(内容=旧版本)保留完整操作轨迹,"回滚到 v2"也有记录
并发控制乐观锁(version 字段)小团队低并发场景足够,避免悲观锁的复杂度
services/version.service.ts
@Injectable()
class VersionService {
/**
* 创建新版本
* 调用场景:
* 1. 运营编辑 Prompt 后点「保存」— 不覆盖原版本,而是创建新版本(v1 → v2)
* 2. 回滚到历史版本 — "回滚到 v2"实际创建 v4(内容=v2 快照),保留完整操作轨迹
* 3. 审核通过后发布 — Draft → Published 过程中产生新版本记录
*
* 乐观锁原理:
* - 不加锁,而是在 UPDATE 时用 WHERE version = currentVersion 做条件判断
* - 如果两人同时编辑,先提交的人 version 匹配成功,后提交的人 version 已变,affected rows = 0
* - 此时抛出冲突异常,让后提交的人刷新页面拿到最新版本后重新编辑
*/
async createVersion(templateId: string, changelog: string, userId: string): Promise<void> {
// 1. 读取当前模板(此时拿到 version = N)
const template = await this.repo.findOneBy({ id: templateId });
const newVersion = template.version + 1;

// 2. 乐观锁更新:WHERE version = N 确保没有人在我读取之后修改过
// 如果别人已经把 version 改成 N+1,这条 UPDATE 匹配不到任何行
const result = await this.repo
.createQueryBuilder()
.update()
.set({ version: newVersion, updatedBy: userId })
.where('id = :id AND version = :version', { id: templateId, version: template.version })
.execute();

// 3. affected rows = 0 说明 version 已被别人改过,触发冲突
if (result.affected === 0) {
throw new ConflictException('模板已被其他人修改,请刷新后重试');
}

// 4. 保存不可变快照(模板通常 < 5KB,完整快照成本很低)
await this.versionRepo.save({
templateId,
version: newVersion,
snapshot: structuredClone(template), // 完整快照,可直接用于回滚
changelog,
createdBy: userId,
});
}

/** 发布 */
async publish(templateId: string, userId: string): Promise<void> {
const template = await this.repo.findOneBy({ id: templateId });
if (template.status !== 'review') throw new BadRequestException('只有审核中的模板才能发布');

await this.repo.update(templateId, {
status: 'published',
publishedVersion: template.version,
updatedBy: userId,
});

// 清 Redis 缓存,使 Java 后端下次请求获取最新版本
await this.redis.del(`template:published:${templateId}`);
}
}

4.5 执行引擎

执行引擎连接模板和 LLM,核心是在调 LLM 之前把能拦截的问题全部拦截

engine/execution-engine.ts
@Injectable()
class ExecutionEngine {
/**
* 执行管线:校验 → 限流 → 渲染 → Token 预检 → 调用 LLM
*
* 设计原则:把能在本地判断的问题全部在花钱之前拦截
*/
async execute(
template: PromptTemplateEntity,
variables: Record<string, unknown>,
options?: { stream?: boolean },
): Promise<ExecutionResult> {
// 1. 变量校验(0 成本)
const validation = validateVariables(template.variables, variables);
if (!validation.valid) {
throw new BadRequestException(`变量校验失败: ${validation.errors.join('; ')}`);
}

// 2. 限流检查(Redis 滑动窗口)
await this.rateLimiter.check(template.category, template.modelConfig.model);

// 3. 渲染
const messages = this.renderMessages(template, variables);

// 4. Token 预检
const estimatedTokens = this.estimateTokens(messages);
const contextLimit = MODEL_LIMITS[template.modelConfig.model] ?? 128_000;
if (estimatedTokens > contextLimit * 0.9) {
throw new BadRequestException(
`Token 预估 ${estimatedTokens} 接近模型上限 ${contextLimit},请减少变量内容`,
);
}

// 5. 调用 LLM(开始花钱 💰)
const startTime = Date.now();
const llmResult = await this.callWithRetry(
() => this.llmGateway.complete(template.modelConfig, messages),
{ maxRetries: 2, timeoutMs: 30_000 },
);

// 6. 异步记录执行日志
this.logExecution(template, messages, llmResult, Date.now() - startTime).catch(console.error);

return { content: llmResult.content, metrics: { /* ... */ } };
}
}

4.6 SDK 集成

Java 后端和 NestJS 后端通过 HTTP SDK 调用模板:

sdk/prompt-sdk.ts(提供给 Java 后端的 Node SDK / Java 后端有等效的 Java SDK)
class PromptSDK {
private cache = new Map<string, { data: PromptTemplate; expireAt: number }>();

/**
* 获取已发布的模板
* 查找链路:内存缓存(60s) → HTTP API → Redis(5min) → PostgreSQL
*/
async getTemplate(templateId: string): Promise<PromptTemplate> {
const cached = this.cache.get(templateId);
if (cached && cached.expireAt > Date.now()) return cached.data;

const response = await fetch(`${this.baseUrl}/api/templates/${templateId}/published`);
const template = await response.json();

this.cache.set(templateId, { data: template, expireAt: Date.now() + 60_000 });
return template;
}

/** render-only 模式:只渲染,不调 LLM(Java 后端自己调 LLM) */
async render(templateId: string, variables: Record<string, unknown>): Promise<Message[]> {
const template = await this.getTemplate(templateId);
return renderMessages(template, variables);
}
}

Java 后端的接入示例

Java 后端调用模板(简化版)
// 改造前:Prompt 硬编码
String prompt = "You are a personality designer. Based on " + zodiac + "...";

// 改造后:通过 SDK 获取模板渲染结果
PromptSDK sdk = new PromptSDK("https://prompt-hub.internal");
List<Message> messages = sdk.render("tpl_personality_gen", Map.of(
"zodiac", user.getZodiac(),
"mbti", user.getMbti(),
"favoriteCharacter", user.getFavoriteCharacter()
));
// messages 直接传给 OpenAI SDK
render-only vs execute 两种模式

系统提供两种 SDK 调用模式:

  • render-only:SDK 只获取模板并渲染为 messages,LLM 调用由业务方自己完成。Java 后端用这种模式,因为 Java 侧已有完善的 LLM 调用链路和重试机制。
  • execute:SDK 渲染 + 调 LLM 一步到位。运营工具类场景用这种模式(如生成日报、推广文章),不需要关心 LLM 调用细节。

4.7 缓存策略

Redis TTL 加随机偏移(300 + random(60) 秒),防止大量模板缓存同时过期导致穿透。

发布后 SDK 最多有 60 秒延迟(内存缓存 TTL)。对于 Swee 的业务场景,这个延迟完全可以接受——Prompt 不是秒级生效的需求,运营发布后一分钟内全量生效已经很好。


五、使用流程与实际场景

5.1 完整场景走读:运营优化 Smart Reply 回复语气

以一个真实场景为例,完整走一遍从发现问题到上线的全流程。

背景:运营发现 Smart Reply(智能回复建议)生成的 3 条回复选项太正式、不够口语化,想调整语气让回复更自然活泼。


第一步:在模板列表页找到目标模板

运营进入 Prompt Hub 后台(/templates),在分类筛选中选择「聊天交互」,找到名为「Smart Reply 回复建议」的模板卡片。卡片上显示:

📝 Smart Reply 回复建议          状态:● 已发布 v4
分类:聊天交互 模型:gpt-4o-mini 最后修改:2 天前 by 张三(运营)

点击卡片进入编辑页。


第二步:在编辑页修改 Prompt

编辑页三栏布局。当前 System Prompt 内容:

修改前的 System Prompt(v4 已发布版本)
你是一个聊天助手。根据对方发来的最新消息和历史对话,
生成 3 条不同方向的回复建议。

要求:
- 每条回复不超过 30 个字
- 3 条回复的方向各不相同(如:认同、追问、调侃)
- 用分号 ";" 分隔
- 不要重复对方的话

运营在编辑区直接修改措辞:

修改后的 System Prompt
你是一个聊天助手。根据对方发来的最新消息和历史对话,
生成 3 条不同方向的回复建议。

要求:
- 每条回复不超过 30 个字,语气口语化、自然,像朋友聊天
- 可以用 emoji,但不要过度
- 避免过于正式的表达(不说"您",不说"我理解您的感受")
- 3 条回复的方向各不相同(如:认同、追问、调侃)
- 用分号 ";" 分隔
- 不要重复对方的话

修改的同时,右栏实时预览区立即渲染出变化后的完整 Prompt(变量已替换),运营可以直观看到最终效果。左栏变量面板显示该模板的变量定义:

变量面板:
├── {{chatHistory}} 类型: string 必填: 是 maxLength: 3000
├── {{latestMessage}} 类型: string 必填: 是 maxLength: 500
└── {{language}} 类型: enum 必填: 是 值: en/zh/ja/ko/...

第三步:在 Playground 中调试验证

运营点击右栏的「Playground」tab 切换到调试模式。填入真实测试数据:

chatHistory: "对方:今天天气真好啊\n我:是啊,适合出去走走\n对方:你周末有什么计划?"
latestMessage: "你周末有什么计划?"
language: zh

点击「▶ 运行」按钮。系统执行以下流程(对应后端 POST /api/execution/playground):

① 变量校验 ✓  →  ② 渲染模板  →  ③ Token 预检: 约 180 tokens ✓
→ ④ 调用 gpt-4o-mini → ⑤ 返回结果

LLM 返回结果在中间区域流式显示:

还没想好诶,你有啥推荐的吗;可能去爬山🏔️ 你要一起不;周末就想在家躺尸哈哈

右栏调试面板显示本次运行指标:

延迟: 0.6s | 输入: 182 tokens | 输出: 38 tokens | 成本: $0.0001 | 模型: gpt-4o-mini

运营可以切换到「追踪」tab,查看渲染管线的每一步中间结果,确认变量替换正确、条件块没有问题。

运营觉得效果不错,但想再试一组。换一组输入变量(英文场景),再跑一次:

chatHistory: "They: I just finished watching that new sci-fi movie\nMe: Oh which one?"
latestMessage: "The one with the time loops, it was mind-blowing!"
language: en

跑完后对比两次运行结果,确认中英文场景都符合预期。


第四步:保存版本并提交审核

运营点击「保存版本」按钮,填写变更说明:

Changelog: "调整回复语气为口语化风格,允许使用 emoji,避免过于正式的表达"

系统创建 v5 版本(后端调用 POST /api/versions/tpl_smart_reply),此时模板状态仍为 draft,线上仍在跑 v4。

运营点击「提交审核」(POST /api/approval/tpl_smart_reply/submit),模板状态变为 review。我(审核者)收到通知。


第五步:审核者审批

我在审批列表页(/approvals)看到这条待审记录。点进去可以看到:

  • v4 → v5 的 Diff 对比(高亮新增的 3 行措辞要求)
  • 运营在 Playground 的 调试记录(2 次运行结果和指标)
  • 变更说明

我也可以自己进 Playground 跑几组测试。确认没问题后点击「审核通过」(POST /api/approval/tpl_smart_reply/approve)。

系统自动执行发布流程:

  1. 模板状态从 review 更新为 published
  2. publishedVersion 从 4 更新为 5
  3. 清除 Redis 缓存 template:published:tpl_smart_reply
  4. Java 后端的 SDK 在下次请求时获取到 v5(最多延迟 60s)

第六步:线上生效

Java 后端的 Smart Reply 功能代码无需任何改动:

Java 后端代码 —— 无需修改,自动使用新版本
// 这段代码在迁移时写好后就再也没改过
List<Message> messages = promptSDK.render("tpl_smart_reply", Map.of(
"chatHistory", conversation.getRecentHistory(10),
"latestMessage", latestMsg.getContent(),
"language", user.getLanguage()
));
String reply = openAiClient.chat(messages);
String[] suggestions = reply.split(";");

SDK 在缓存过期后自动拿到 v5 的模板内容,渲染出的 Prompt 已经包含新的语气要求。运营从发现问题到线上生效,全程未涉及任何代码变更和部署


第七步:出了问题,回滚

假设上线后用户反馈"回复建议太随意了",运营可以立即在版本历史页点击「回滚到 v4」。系统会创建一个 v6(内容等于 v4),发布后线上恢复原来的正式语气。整个回滚过程 < 1 分钟。

5.2 更多真实模板示例

示例 1:数字人开场白生成

模板: tpl_digital_greet
System Prompt:
你是 {{characterName}},一个 {{gender}} 角色。
你的核心性格是:{{personality}}

你正在和一个异性用户初次见面。请生成一条有吸引力的开场白,
字数在 50-100 字之间。

要求:
- 符合你的性格特点
- 带有一点好奇心和调侃
- 不要太正式,像自然的第一次打招呼
{{#if userPreference}}
- 用户喜欢 {{userPreference}} 类型的风格
{{/if}}

语言:{{language}}
变量定义
characterName  | string | 必填 | "月光诗人"
gender | enum | 必填 | male / female
personality | string | 必填 | maxLength: 200
userPreference | string | 可选 | "温柔" "霸道" "搞笑"...
language | enum | 必填 | en / zh / ja / ko / ...

示例 2:AIGC 剧情背景生成

模板: tpl_plot_background
System Prompt:
你是一个创意故事编剧。

User Prompt:
请根据以下角色信息生成一段剧情背景(500 字以内)和 3 个有吸引力的开场白。

角色信息:
- 名称:{{characterName}}
- 性格:{{personality}}
- 身份:{{identity}}

{{#if referenceStyle}}
参考风格:{{referenceStyle}}
{{/if}}

{{#if existingPlots}}
注意避免和以下已有剧情重复:
{{#each existingPlots}}
- {{item.title}}:{{item.summary}}
{{/each}}
{{/if}}

输出格式:JSON
{
"background": "剧情背景文本",
"openings": ["开场白1", "开场白2", "开场白3"]
}

这个模板展示了条件块和循环块的实际组合使用:existingPlots 是一个数组变量,循环展开为已有剧情列表,让 LLM 避免生成重复内容。

示例 3:运营工具——生成推广文章

模板: tpl_promo_article
System Prompt:
你是 Swee 平台的内容营销专家。

User Prompt:
为 Swee APP 撰写一篇推广文章。

产品信息:
- 核心卖点:{{sellingPoints}}
- 目标渠道:{{channel}}
- 目标受众:{{audience}}

{{#if toneStyle}}
语气风格:{{toneStyle}}
{{/if}}

{{#if keywords}}
必须包含的关键词:
{{#each keywords}}
- {{item}}
{{/each}}
{{/if}}

字数要求:{{wordCount}} 字左右
语言:{{language}}

运营工具类模板使用 execute 模式(SDK 渲染 + 直接调 LLM),因为这类场景是运营在管理后台手动触发的一次性生成任务,不走 Java 后端的调用链路。

5.3 前端核心页面实现细节

模板列表页

pages/TemplateList.tsx(关键实现,非完整代码)
function TemplateList() {
const [category, setCategory] = useState<string>('all');
const [status, setStatus] = useState<TemplateStatus | 'all'>('all');
const [keyword, setKeyword] = useState('');

const { data, loading } = useRequest(
() => templateApi.list({ category, status, keyword, page, pageSize: 20 }),
{ refreshDeps: [category, status, keyword, page] },
);

return (
<div className="flex h-full">
{/* 左侧:分类树 */}
<div className="w-56 border-r p-4">
<CategoryTree
categories={TEMPLATE_CATEGORIES}
selected={category}
onChange={setCategory}
/>
{/* 分类:数字人系统 / 聊天交互 / AIGC / 运营工具 / ... */}
</div>

{/* 右侧:模板卡片列表 */}
<div className="flex-1 p-6">
<div className="mb-4 flex gap-3">
<Input.Search placeholder="搜索模板名称" onSearch={setKeyword} />
<Select value={status} onChange={setStatus}
options={[
{ label: '全部状态', value: 'all' },
{ label: '草稿', value: 'draft' },
{ label: '审核中', value: 'review' },
{ label: '已发布', value: 'published' },
]}
/>
</div>

<div className="grid grid-cols-3 gap-4">
{data?.list.map((tpl) => (
<TemplateCard key={tpl.id} template={tpl} />
))}
</div>
</div>
</div>
);
}

/** 模板卡片 */
function TemplateCard({ template }: { template: PromptTemplate }) {
return (
<Card
hoverable
onClick={() => navigate(`/templates/${template.id}/edit`)}
className="cursor-pointer"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{template.name}</span>
<StatusTag status={template.status} />
{/* Draft=灰色 / Review=蓝色 / Published=绿色 */}
</div>
<p className="text-gray-500 text-sm mb-3 line-clamp-2">{template.description}</p>
<div className="flex items-center justify-between text-xs text-gray-400">
<span>v{template.publishedVersion ?? template.version}</span>
<span>{template.modelConfig.model}</span>
<span>{dayjs(template.updatedAt).fromNow()} by {template.updatedBy}</span>
</div>
<div className="mt-2 flex gap-1">
{template.tags.map((tag) => <Tag key={tag} color="blue">{tag}</Tag>)}
</div>
</Card>
);
}

版本 Diff 页面

pages/VersionDiff.tsx(关键实现)
function VersionDiff({ templateId }: { templateId: string }) {
const { data: versions } = useRequest(() => versionApi.list(templateId));
const [fromVersion, setFromVersion] = useState<number>();
const [toVersion, setToVersion] = useState<number>();
const { data: diff } = useRequest(
() => versionApi.diff(templateId, fromVersion!, toVersion!),
{ ready: !!fromVersion && !!toVersion, refreshDeps: [fromVersion, toVersion] },
);

return (
<div>
{/* 版本选择器 */}
<div className="flex gap-4 mb-6">
<Select placeholder="对比起始版本" value={fromVersion} onChange={setFromVersion}
options={versions?.map((v) => ({
label: `v${v.version} - ${v.changelog} (${dayjs(v.createdAt).format('MM-DD HH:mm')})`,
value: v.version,
}))}
/>
<span className="leading-8"></span>
<Select placeholder="对比目标版本" value={toVersion} onChange={setToVersion}
options={versions?.map((v) => ({ label: `v${v.version}`, value: v.version }))}
/>
</div>

{/* Diff 展示:System Prompt / User Prompt / 变量变更 / 模型配置变更 */}
{diff && (
<Tabs items={[
{
key: 'system',
label: 'System Prompt',
children: <DiffViewer oldText={diff.systemPrompt.old} newText={diff.systemPrompt.new} />,
},
{
key: 'user',
label: 'User Prompt',
children: <DiffViewer oldText={diff.userPrompt.old} newText={diff.userPrompt.new} />,
},
{
key: 'variables',
label: `变量 (${diff.variableChanges.length} 处变更)`,
children: <VariableChangesTable changes={diff.variableChanges} />,
// 表格展示:哪些变量新增/删除/修改了类型或默认值
},
{
key: 'model',
label: '模型配置',
children: <ModelConfigDiff old={diff.modelConfig.old} new={diff.modelConfig.new} />,
},
]} />
)}
</div>
);
}

六、项目成果

6.1 量化指标

指标改造前改造后
Prompt 修改周期2-3 天(提需求→开发→部署)分钟级(运营自行修改→发布)
管理的模板数60+ 散落在代码中60+ 集中管理,可搜索/分类
版本可追溯性翻 Git 历史每个模板独立版本线,一键 Diff 和回滚
Prompt 调试方式跑完整业务流程Playground 秒级验证
非技术人员参与不可能运营独立完成 Prompt 迭代

6.2 实际业务价值

  • 运营效率:产品和运营团队从"提 Prompt 修改需求等开发"变成"自己改自己测自己发",我的开发精力也释放了出来
  • 质量提升:Playground 的即时验证让运营可以快速对比不同版本的效果,不再凭感觉决策
  • 风险可控:版本管理 + 回滚能力让 Prompt 修改从"改了就回不去"变成"随时可以回到上一版"
  • 维护成本:新增 AI 功能时,Java 后端只需要在管理后台创建模板 + 代码中引用模板 ID,不再需要在代码里拼 Prompt 字符串

七、关键技术挑战

7.1 挑战一:模板迁移——如何从硬编码平滑过渡

60+ 个 Prompt 散落在 Java 和 Node 两个后端,不可能一次性全部迁移。

我的方案:灰度迁移,按优先级分批

第一批(2 周):运营高频修改的模板(人格生成、开场白、Smart Reply)
↓ 验证系统稳定
第二批(2 周):AIGC 内容生成相关(剧情创作、角色扮演、图片 Prompt)
↓ 验证 Java 后端 SDK 接入流畅
第三批(1 周):运营工具类(日报、推广文章、隐私协议、App Store 图)

第四批(持续):低频模板,排期迁移

迁移期间双写兼容:Java 后端的调用代码先检查模板系统是否有对应模板,有则用模板,没有则走原来的硬编码逻辑。这样迁移失败不影响线上:

Java 后端的兼容逻辑
String prompt;
try {
List<Message> messages = promptSDK.render("tpl_personality_gen", variables);
prompt = messages.get(messages.size() - 1).getContent();
} catch (Exception e) {
// 模板系统不可用时回退到硬编码(降级兜底)
log.warn("Prompt SDK fallback to hardcode", e);
prompt = buildHardcodedPrompt(variables); // 原来的硬编码方法
}

7.2 挑战二:CodeMirror 6 自定义高亮

模板编辑器需要对 {{variable}}{{#if}}...{{/if}} 做语法高亮。CodeMirror 6 的架构和 CM5 完全不同,不再是简单的正则 mode。

尝试过的方案

方案问题
写 Lezer Grammar工作量大,模板语法不规则(混合自然语言),Lezer 更适合编程语言
使用 @codemirror/lang-markdown 扩展和模板语法冲突,Markdown 解析器会把 {{ 当普通文本

最终方案ViewPlugin + Decoration 覆盖高亮

editor/template-highlight.ts
/**
* 不走 Lezer 语法树,而是用 ViewPlugin 在文本上叠加装饰层
* 原理:CM6 先按纯文本渲染,再用 RangeSet 给匹配区间涂色
*/
const templateHighlight = ViewPlugin.fromClass(
class {
decorations: DecorationSet;

constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}

update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}

buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const doc = view.state.doc.toString();

// 匹配 {{variable}} 和 {{#if}}...{{/if}}
for (const match of doc.matchAll(/\{\{[^}]+\}\}/g)) {
const from = match.index!;
const to = from + match[0].length;
const isControl = /\{\{[#/]/.test(match[0]); // {{#if}} {{/if}}
builder.add(from, to, Decoration.mark({
class: isControl ? 'cm-template-control' : 'cm-template-variable',
}));
}

return builder.finish();
}
},
{ decorations: (v) => v.decorations },
);

这个方案的性能足够好——模板通常只有几百行,正则匹配开销可以忽略。

7.3 挑战三:变量面板的动态类型渲染

变量面板需要根据变量类型(string / number / enum / json / array)渲染不同的输入控件,且支持实时校验:

components/VariableInput.tsx
function VariableInput({ definition, value, onChange }: VariableInputProps) {
switch (definition.type) {
case 'enum':
return (
<Select
value={value}
onChange={onChange}
options={definition.enumValues?.map((v) => ({ label: v, value: v }))}
/>
);
case 'json':
case 'array':
// JSON 编辑器:用 Ant Design 的 Input.TextArea + 实时 JSON 校验
return (
<Input.TextArea
rows={4}
value={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
onChange={(e) => {
try {
onChange(JSON.parse(e.target.value));
} catch {
// JSON 格式错误时暂存原始字符串,不阻塞编辑
onChange(e.target.value);
}
}}
status={isValidJSON(value) ? undefined : 'error'}
/>
);
case 'boolean':
return <Switch checked={!!value} onChange={onChange} />;
case 'number':
return <InputNumber value={value as number} onChange={onChange} />;
default:
return (
<Input
value={value as string}
onChange={(e) => onChange(e.target.value)}
maxLength={definition.maxLength}
showCount={!!definition.maxLength}
placeholder={definition.description}
/>
);
}
}

八、反思与改进

8.1 做得好的地方

  • 分批迁移 + 双写兼容:没有冒进全量切换,迁移过程零事故
  • Playground 设计:渲染追踪让运营能自助排查 80% 的问题,显著减少了找开发排查的次数
  • 变量 maxLength:这个看似小的设计防住了好几次"运营不小心把整篇文章粘进变量"导致的 Token 超限

8.2 做得不够的地方

不足详情如果重来会怎么做
没有 A/B 测试目前对比 Prompt 效果全靠运营主观判断加入 LLM-as-Judge 自动评分 + 简单的 A/B 分流
没有执行成本看板知道总费用但不知道哪个模板花了多少钱在执行日志基础上做按模板维度的成本聚合
审批流程简化目前审批就是一个人点通过/打回,没有多人审批对核心模板(如日常对话)应该要求至少两人审批
缓存一致性SDK 内存缓存最多 60s 延迟如果未来要求更强一致,需要加 WebSocket 推送
Prompt 安全检测没有做 Prompt 注入防御对用户输入变量做注入模式检测

8.3 如果重新设计

  1. 一开始就设计执行日志的聚合分析,而不是只存不查——成本看板应该是 MVP 功能
  2. LLM-as-Judge 应该在 V1 就做,哪怕是最简单的版本(让 GPT-4o-mini 给输出打个 1-5 分)
  3. 考虑模板继承——数字人相关的模板共享大段 System Prompt(角色设定),目前每个模板都复制一遍,修改时要改好几个地方

九、面试高频问题

Q1: 这个系统解决什么问题?为什么要做?

:Swee 平台有 60+ 个 AI Prompt 模板,之前全部硬编码在 Java 和 NestJS 后端代码中。运营改一个措辞要走"提需求→开发改代码→Code Review→部署"的完整流程,最快半天。做了这个系统后,运营自己在后台编辑 Prompt、用 Playground 验证效果、提交审核后分钟级上线,开发完全不用介入。

Q2: 为什么不用 LangSmith / Humanloop 这些现成的?

:评估过,三个原因没用:一是数据安全,Swee 的 Prompt 包含用户画像和对话策略,不适合放到海外 SaaS;二是集成成本,Java 后端已有一套标准调用方式,自研 SDK 可以无缝接入;三是费用,按调用量计费对我们这个规模来说不划算。但如果团队没有安全和集成顾虑,LangSmith 是更好的选择,不应该为自研而自研。

Q3: 变量引擎为什么要分三步?一步 replace 不行吗?

:简单 replace 处理不了条件块和循环块。比如数字人人格生成模板,favoriteCharacter 为空时整段"最喜欢的角色"应该被移除,不是替换为空字符串。循环块用于展开数组变量,比如传入多个性格特征。三步管线的顺序也有讲究:条件块先决定哪些文本保留,循环块再展开数组,变量插值最后替换——顺序反了会出问题,比如先插值会破坏 {{#if}} 的语法结构。

Q4: 版本回滚怎么做的?为什么用完整快照?

:每次保存版本时存完整快照而非增量 Diff。原因:模板通常 < 5KB,存储成本可忽略;回滚时直接恢复,不需要从初始版本逐步计算;任意两版可直接 Diff,不依赖中间版本链。回滚的实现是"创建一个内容等于旧版本的新版本",而非覆盖历史——这样版本线是完整的,审计时能看到"谁在什么时候回滚到了哪个版本"。

Q5: 模板发布后 SDK 怎么拿到最新版本?

:多级缓存:SDK 内存缓存 60s → Redis 5 分钟 → PostgreSQL。发布时主动清 Redis 缓存,SDK 内存缓存等 TTL 过期。最大延迟 60 秒,对 Prompt 更新场景完全够用。如果未来需要更强一致,可以加 WebSocket 推送。

Q6: Java 后端怎么接入的?迁移过程遇到什么问题?

:提供了 Java SDK(HTTP 封装),render-only 模式——只渲染模板返回 messages,LLM 调用由 Java 后端自己完成,因为 Java 侧已有完善的重试和限流机制。迁移是分批灰度的,第一批先迁运营高频修改的模板,稳定后再扩展。迁移期间双写兼容,SDK 调用失败回退到原来的硬编码逻辑,确保零影响。

追问:迁移过程中最大的坑是什么?

最大的坑是变量名不统一。同一个"用户名"字段,Java 代码里叫 userName,有的地方叫 user_name,有的叫 name。迁移时必须统一变量名,但又不能改 Java 代码的字段名(改动面太大)。最后在 SDK 层做了一个变量名映射(fieldMapping),Java 传的字段名和模板中的变量名可以不同。

Q7: 有没有做过 Prompt 效果对比?怎么知道改完效果好不好?

:目前没有自动化的 A/B 测试,这是系统最大的不足。运营对比效果的方式是在 Playground 里手动跑几组测试用例,主观判断输出质量。如果要改进,我会加两个东西:一是 LLM-as-Judge,用另一个模型给输出打分;二是把 Playground 的运行历史做成对比面板,支持并排对比不同版本的输出。更进一步是做流量分桶 A/B 测试,但对现阶段的团队规模来说 ROI 不高。

Q8: 系统挂了怎么办?Prompt 管理系统不可用会影响线上 AI 功能吗?

:不会。SDK 有内存缓存(最多 60s 有效),且 Java 后端保留了硬编码降级逻辑——SDK 调用失败时回退到原来的方式。所以即使 Prompt Hub 完全宕机,线上 AI 功能最多用的是上一版缓存的 Prompt(60s 内)或硬编码的兜底 Prompt,不会挂。

追问:如果改进降级策略,你会怎么做?

加一个本地文件缓存。SDK 每次成功拉取模板后,异步写一份到本地文件(/tmp/prompt-cache/tpl_xxx.json)。当 API 和内存缓存都不可用时,从本地文件读取。文件缓存设 24 小时过期,太旧的模板不可信。这样降级链变成:内存缓存 → API → 本地文件缓存 → 硬编码兜底。

Q9: 权限怎么做的?运营能直接发布到线上吗?

:鉴权统一走公司的 Java 认证服务,Prompt Hub 后端在 NestJS Guard 中调用 Java 认证接口校验 Token 和权限。系统内有三个角色:编辑者(可创建/编辑/提交审核)、审核者(可审批/发布/回滚)、管理员(全部权限)。运营是编辑者角色,不能直接发布——必须提交审核,由产品负责人或我审批后才能发布。这道卡是必须的,因为核心对话模板改坏了直接影响用户体验。

Q10: 这个系统最大的亮点是什么?

:我觉得最大的亮点是 Playground 的渲染追踪。大多数 Prompt 管理工具只展示最终渲染结果,出了问题只能猜是变量没传对还是模板逻辑有错。我的 Playground 记录了三步渲染管线的每一步中间结果——条件块处理后什么样、循环展开后什么样、最终插值后什么样——还能高亮未匹配变量和未使用变量。这让运营能自己定位 80% 的问题,不需要来找开发。

第二个亮点是分批迁移 + 双写兼容的上线策略。60+ 个模板分 4 批迁移,迁移期间 Java 后端同时支持模板系统和硬编码两条路,任何一个模板迁移出问题可以立刻回退到硬编码,全程零事故。

Q11: 如果让你重新设计,会有什么不同?

:三个改进。第一,一开始就做成本看板——现在知道总费用但不知道哪个模板花了多少钱,排查成本异常很费劲。第二,做模板继承/组合——数字人相关的 10 多个模板共享一大段角色设定的 System Prompt,现在每个模板都复制一遍,改一个地方要同步改十几个。第三,MVP 就该加 LLM-as-Judge——哪怕只是用 GPT-4o-mini 给输出打个 1-5 分,也比纯主观判断靠谱得多。


十、相关文档