前端AI工程化(四):Prompt工程与前端角色

7 阅读1分钟

👤 关于作者

JavaAgent架构师 — 十年Java分布式架构老兵,专注AI Agent企业级落地。

主导过数字员工、SOP智能引擎等项目,开发过RPC框架、消息中间件、ORM框架。

正在输出:

专栏:

专栏一:《前端AI工程化》10期适合前端 SSE/流式渲染/.../企业级AI架构/AI前端面试深度解析

专栏二:《Java体系也能玩转AI》24期加急中,适合java深度玩家

专栏三:《从0构建Agent系统》 15期加急中,适合所以玩家

让Java开发者不转Python也能构建企业级AI应用

-----------------------------------------------------------------------------------------------

点赞+关注+评论 走一波。

-------------------------淘汰自己不是别人,是自己!!!------------------------------------

核心定位:前端工程师在Prompt Engineering中的独特价值与实操
关键产出:Prompt模板管理系统

Prompt Engineering:不只是后端的事

开篇:被忽视的前端阵地

大多数开发者听到"Prompt Engineering",第一反应是:这是后端/算法同学的事,前端只管展示结果。

这个认知在2024年之前大体成立。但在AI应用深度渗透的今天,前端正在成为Prompt工程链路中不可替代的一环——因为前端是最靠近用户意图的地方

用户点击了什么按钮、输入了什么文字、选择了什么风格、上传了什么参考图——这些信息只有前端能完整捕获并结构化为Prompt。后端拿到的,只是前端"翻译"后的产物。

一、Prompt Engineering核心概念速览

1.1 基础结构

一个完整的LLM请求通常由三部分组成:

interface ChatMessage {
  role: 'system' | 'user' | 'assistant';
  content: string;
}

// System Prompt:定义AI的角色和行为边界
const systemPrompt: ChatMessage = {
  role: 'system',
  content: '你是一个专业的前端技术顾问,只回答前端相关的问题。回答要简洁、专业、附带代码示例。',
};

// User Prompt:用户的具体问题
const userPrompt: ChatMessage = {
  role: 'user',
  content: '如何在React中实现虚拟滚动?',
};

// Assistant Prompt(历史回复):提供上下文
const assistantPrompt: ChatMessage = {
  role: 'assistant',
  content: '虚拟滚动的核心思路是...',
};

1.2 高级技巧

// Few-shot:通过示例引导输出格式
const fewShotPrompt = `
请按以下格式提取信息:

输入:张三,1990年出生,住在北京
输出:{"name": "张三", "birthYear": 1990, "city": "北京"}

输入:李四,1985年出生,住在上海
输出:{"name": "李四", "birthYear": 1985, "city": "上海"}

输入:${userInput}
输出:`;

// CoT(Chain of Thought):引导模型逐步推理
const cotPrompt = `
请一步一步思考:

1. 首先,分析问题的核心需求
2. 然后,列出可能的解决方案
3. 接着,评估每个方案的优缺点
4. 最后,给出推荐方案

用户问题:${question}
`;

// 格式约束:强制结构化输出
const formatConstraint = `
请以JSON格式返回结果,严格遵循以下Schema:
{
  "title": "string",
  "summary": "string (不超过100字)",
  "tags": ["string"],
  "confidence": "number (0-1)"
}

不要输出任何JSON之外的内容。
`;

二、前端在Prompt Engineering中的四个独特角色

角色1:用户意图→结构化Prompt的转换层

用户说:“帮我写个爬虫”——这是自然语言,直接丢给LLM效果不稳定。

前端要做的是:将模糊意图转换为结构化Prompt。

// 用户在前端界面的操作
interface UserAction {
  taskType: 'write_code' | 'debug' | 'optimize' | 'review';
  language?: string;
  framework?: string;
  inputCode?: string;
  requirements?: string;
}

// 前端将用户操作转换为结构化Prompt
function buildPromptFromAction(action: UserAction): ChatMessage[] {
  const systemPrompts: Record<string, string> = {
    write_code: `你是一个${action.language ?? ''}编程专家。请根据需求编写代码,要求:
1. 代码完整可运行
2. 包含必要的注释
3. 遵循${action.framework ?? ''}最佳实践`,

    debug: `你是一个代码调试专家。请分析以下代码,找出bug并修复。
输出格式:
- Bug位置:...
- Bug原因:...
- 修复方案:...
- 修复后代码:...`,

    optimize: `你是一个代码优化专家。请优化以下代码,重点关注:
1. 性能优化
2. 可读性提升
3. 最佳实践对齐
输出优化前后的对比。`,

    review: `你是一个代码审查专家。请对以下代码进行Review,按以下维度评分(1-5):
1. 代码质量
2. 性能
3. 安全性
4. 可维护性
每个维度给出具体改进建议。`,
  };

  return [
    { role: 'system', content: systemPrompts[action.taskType] },
    { role: 'user', content: action.inputCode ?? action.requirements ?? '' },
  ];
}

核心价值:前端把"用户做了什么"翻译成"LLM需要什么",这个翻译质量直接决定AI输出质量。

角色2:上下文窗口的Token预算管理

LLM有上下文窗口限制(GPT-4 Turbo是128K tokens)。在长对话场景中,前端需要管理Token预算:

class TokenBudgetManager {
  private totalBudget: number;
  private reservedForSystem: number;
  private reservedForOutput: number;

  constructor(options: {
    totalBudget: number;       // 总token预算
    systemReserve: number;     // 系统Prompt预留
    outputReserve: number;     // 输出预留
  }) {
    this.totalBudget = options.totalBudget;
    this.reservedForSystem = options.systemReserve;
    this.reservedForOutput = options.outputReserve;
  }

  /** 获取可用于对话历史的token预算 */
  getHistoryBudget(): number {
    return this.totalBudget - this.reservedForSystem - this.reservedForOutput;
  }

  /** 对话历史超出预算时的裁剪策略 */
  trimHistory(
    messages: ChatMessage[],
    maxSize: number
  ): ChatMessage[] {
    const budget = this.getHistoryBudget();

    // 保留System Prompt
    const systemMessages = messages.filter(m => m.role === 'system');

    // 对话历史(user + assistant交替)
    const historyMessages = messages.filter(m => m.role !== 'system');

    if (this.estimateTokens(historyMessages) <= budget) {
      return messages; // 未超预算,无需裁剪
    }

    // 策略1:滑动窗口——保留最近的N轮对话
    let trimmed = this.slidingWindowTrim(historyMessages, budget);

    // 策略2:如果裁剪后仍然超预算,对较早的对话做摘要压缩
    if (this.estimateTokens(trimmed) > budget) {
      trimmed = this.summaryCompressTrim(trimmed, budget);
    }

    return [...systemMessages, ...trimmed];
  }

  /** Token估算(简化版:中文1字≈1.5token,英文1词≈1token) */
  private estimateTokens(messages: ChatMessage[]): number {
    return messages.reduce((total, msg) => {
      const chineseChars = (msg.content.match(/[\u4e00-\u9fff]/g) ?? []).length;
      const otherChars = msg.content.length - chineseChars;
      return total + chineseChars * 1.5 + otherChars * 0.25;
    }, 0);
  }

  private slidingWindowTrim(messages: ChatMessage[], budget: number): ChatMessage[] {
    // 从最新的消息开始保留,直到接近预算
    const result: ChatMessage[] = [];
    let tokenCount = 0;

    for (let i = messages.length - 1; i >= 0; i--) {
      const msgTokens = this.estimateTokens([messages[i]]);
      if (tokenCount + msgTokens > budget) break;
      tokenCount += msgTokens;
      result.unshift(messages[i]);
    }

    return result;
  }

  private summaryCompressTrim(messages: ChatMessage[], budget: number): ChatMessage[] {
    // 将较早的对话压缩为摘要
    const recentCount = Math.floor(messages.length * 0.3); // 保留最近30%
    const recentMessages = messages.slice(-recentCount);
    const oldMessages = messages.slice(0, -recentCount);

    // 生成摘要(实际项目中会调用LLM生成摘要)
    const summary: ChatMessage = {
      role: 'user',
      content: `[以下是之前对话的摘要:${oldMessages.map(m => `${m.role}: ${m.content.slice(0, 50)}...`).join(' | ')}]`,
    };

    return [summary, ...recentMessages];
  }
}

角色3:输出格式约束与解析保障

LLM的输出是不确定的——你要求JSON,它可能返回"json\n{...}\n"。前端需要做防御性解析:

class LLMOutputParser {
  /** 安全解析LLM返回的JSON */
  parseJSON<T>(raw: string, schema: z.ZodSchema<T>): T {
    // 第一步:提取JSON部分
    let jsonStr = raw;

    // 处理Markdown代码块包裹
    const codeBlockMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
    if (codeBlockMatch) {
      jsonStr = codeBlockMatch[1];
    }

    // 处理前后多余文本
    const firstBrace = jsonStr.indexOf('{');
    const lastBrace = jsonStr.lastIndexOf('}');
    if (firstBrace !== -1 && lastBrace !== -1) {
      jsonStr = jsonStr.slice(firstBrace, lastBrace + 1);
    }

    // 解析JSON
    try {
      const parsed = JSON.parse(jsonStr);
      return schema.parse(parsed); // Zod校验
    } catch (error) {
      throw new LLMOutputParseError(raw, error);
    }
  }
}

class LLMOutputParseError extends Error {
  constructor(public rawOutput: string, public cause: unknown) {
    super(`LLM输出解析失败: ${cause}`);
  }
}

角色4:Prompt模板的版本化与A/B测试

interface PromptTemplate {
  id: string;
  name: string;
  version: number;
  content: string;
  variables: string[];         // 模板变量列表
  metadata: {
    createdAt: Date;
    updatedAt: Date;
    author: string;
    abTestGroup?: 'A' | 'B';
    performanceScore?: number; // 基于输出质量的评分
  };
}

// 模板变量插值
function renderTemplate(
  template: PromptTemplate,
  variables: Record<string, string>
): string {
  let content = template.content;

  for (const [key, value] of Object.entries(variables)) {
    const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g');
    content = content.replace(regex, value);
  }

  // 检查是否有未替换的变量
  const unresolvedVars = content.match(/\{\{(\w+)\}\}/g);
  if (unresolvedVars) {
    console.warn(`[PromptTemplate] 未替换的变量: ${unresolvedVars.join(', ')}`);
  }

  return content;
}

三、前端驱动的Prompt优化闭环

用户操作 → 前端捕获意图 → 结构化Prompt → 发送到LLM → 接收输出
     ↑                                                        ↓
     ← ← ← 用户反馈(点赞/点踩/重新生成)← ← ← 输出质量评估 ←

前端是唯一能同时触达"用户输入"和"用户反馈"的层,这使得前端成为Prompt优化闭环的关键节点:

class PromptFeedbackLoop {
  private feedbackStore: FeedbackStore;

  /** 记录用户对AI输出的反馈 */
  recordFeedback(params: {
    promptId: string;
    templateVersion: number;
    outputQuality: 'good' | 'bad' | 'neutral';
    userAction: 'accept' | 'regenerate' | 'edit' | 'copy';
    latencyMs: number;
  }): void {
    this.feedbackStore.save(params);
  }

  /** 获取模板的性能数据,用于A/B测试决策 */
  getTemplatePerformance(templateId: string): {
    goodRate: number;
    regenerateRate: number;
    avgLatency: number;
  } {
    const feedbacks = this.feedbackStore.getByTemplate(templateId);
    // ... 计算性能指标
  }
}

实践任务

任务:设计一个Prompt模板DSL,支持变量插值、条件分支、片段组合,并实现对应的编译器。

DSL规范要求

{{variable}}              → 变量插值
{{#if condition}}...{{/if}} → 条件分支
{{> snippet_name}}        → 片段引用
{{#each items}}...{{/each}} → 循环

验收标准

  1. 变量插值正常工作
  2. 条件分支根据运行时值正确渲染
  3. 片段可复用、可嵌套
  4. 循环渲染列表数据
  5. 未定义变量不报错,渲染为空字符串

面试题解析

Q:谈谈你对Prompt Engineering的理解,前端在其中扮演什么角色?

答题要点

  1. Prompt Engineering定义:通过设计输入文本来引导LLM产生期望输出的技术,包括System Prompt设计、Few-shot示例、CoT推理、格式约束等
  2. 前端四个独特角色
    • 意图转换层:把用户操作翻译成结构化Prompt
    • Token预算管理者:上下文窗口的裁剪与分配
    • 输出保障层:防御性解析LLM的不确定输出
    • 优化闭环节点:唯一同时触达输入和反馈的层
  3. 延伸:前端的Prompt能力不仅是"用",更是"管"——模板化、版本化、A/B测试、效果追踪

4.2 Prompt模板管理系统设计

开篇:从"硬编码"到"工程化"

在项目初期,Prompt散落在代码各处:

// ❌ Prompt硬编码在各组件中
const chatPrompt = `你是一个助手,请回答以下问题:${userInput}`;
const codePrompt = `请写一段${language}代码:${requirement}`;
const summaryPrompt = `请总结以下内容,不超过${maxWords}字:\n${content}`;

问题:

  • 修改Prompt需要改代码、重新部署
  • 无法A/B测试不同Prompt的效果
  • 无法追踪Prompt版本与输出质量的关联
  • 多人协作时Prompt变更无审计

Prompt模板管理系统的目标:把Prompt当作产品资产来管理,而不是代码中的字符串常量。

一、系统架构

┌─────────────────────────────────────────────────┐
│              PromptTemplateManager               │
├─────────────────────────────────────────────────┤
│  模板引擎                                       │
│  ├─ DSL解析器 (变量/条件/循环/片段)              │
│  ├─ 模板编译器 (DSL → 渲染函数)                  │
│  └─ 片段仓库 (可复用的Prompt片段)                │
├─────────────────────────────────────────────────┤
│  版本管理                                       │
│  ├─ 模板版本链 (v1 → v2 → v3)                   │
│  ├─ 灰度发布 (按比例分配新旧版本)                │
│  └─ 回滚机制                                    │
├─────────────────────────────────────────────────┤
│  效果追踪                                       │
│  ├─ 输出质量评分 (自动+手动)                     │
│  ├─ A/B测试看板                                  │
│  └─ 效果趋势图                                  │
├─────────────────────────────────────────────────┤
│  协作机制                                       │
│  ├─ 前后端共享模板仓库                           │
│  ├─ 变更审计日志                                 │
│  └─ 模板评审流程                                 │
└─────────────────────────────────────────────────┘

二、模板引擎实现

2.1 DSL解析器

// DSL语法定义
// {{variable}}        → 变量
// {{#if condition}}   → 条件开始
// {{else}}            → 条件分支
// {{/if}}             → 条件结束
// {{#each array}}     → 循环开始
// {{/each}}           → 循环结束
// {{> snippetName}}   → 片段引用

type ASTNode =
  | { type: 'text'; value: string }
  | { type: 'variable'; name: string }
  | { type: 'if'; condition: string; body: ASTNode[]; elseBody?: ASTNode[] }
  | { type: 'each'; array: string; body: ASTNode[] }
  | { type: 'snippet'; name: string };

class PromptDSLParser {
  parse(template: string): ASTNode[] {
    const nodes: ASTNode[] = [];
    let remaining = template;

    while (remaining.length > 0) {
      // 匹配各种语法结构
      const varMatch = remaining.match(/^\{\{(\w+)\}\}/);
      const ifMatch = remaining.match(/^\{\{#if\s+(\w+)\}\}/);
      const eachMatch = remaining.match(/^\{\{#each\s+(\w+)\}\}/);
      const snippetMatch = remaining.match(/^\{\{>\s*(\w+)\}\}/);

      if (ifMatch) {
        const { node, consumed } = this.parseIfBlock(remaining);
        nodes.push(node);
        remaining = remaining.slice(consumed);
      } else if (eachMatch) {
        const { node, consumed } = this.parseEachBlock(remaining);
        nodes.push(node);
        remaining = remaining.slice(consumed);
      } else if (snippetMatch) {
        nodes.push({ type: 'snippet', name: snippetMatch[1] });
        remaining = remaining.slice(snippetMatch[0].length);
      } else if (varMatch) {
        nodes.push({ type: 'variable', name: varMatch[1] });
        remaining = remaining.slice(varMatch[0].length);
      } else {
        // 纯文本,推进到下一个模板标记
        const nextTag = remaining.indexOf('{{');
        if (nextTag === -1) {
          nodes.push({ type: 'text', value: remaining });
          remaining = '';
        } else {
          nodes.push({ type: 'text', value: remaining.slice(0, nextTag) });
          remaining = remaining.slice(nextTag);
        }
      }
    }

    return nodes;
  }

  private parseIfBlock(template: string): { node: ASTNode; consumed: number } {
    const ifMatch = template.match(/^\{\{#if\s+(\w+)\}\}/)!;
    const condition = ifMatch[1];
    let pos = ifMatch[0].length;

    // 解析if body
    const body = this.parseUntilTag(template.slice(pos), ['else', '/if']);
    pos += body.consumed;

    // 检查是否有else分支
    let elseBody: ASTNode[] | undefined;
    const elseMatch = template.slice(pos).match(/^\{\{else\}\}/);
    if (elseMatch) {
      pos += elseMatch[0].length;
      const elseResult = this.parseUntilTag(template.slice(pos), ['/if']);
      elseBody = elseResult.nodes;
      pos += elseResult.consumed;
    }

    // 消耗 {{/if}}
    const endMatch = template.slice(pos).match(/^\{\{\/if\}\}/);
    if (endMatch) pos += endMatch[0].length;

    return {
      node: { type: 'if', condition, body: body.nodes, elseBody },
      consumed: pos,
    };
  }

  private parseEachBlock(template: string): { node: ASTNode; consumed: number } {
    const eachMatch = template.match(/^\{\{#each\s+(\w+)\}\}/)!;
    const array = eachMatch[1];
    let pos = eachMatch[0].length;

    const result = this.parseUntilTag(template.slice(pos), ['/each']);
    pos += result.consumed;

    const endMatch = template.slice(pos).match(/^\{\{\/each\}\}/);
    if (endMatch) pos += endMatch[0].length;

    return {
      node: { type: 'each', array, body: result.nodes },
      consumed: pos,
    };
  }

  private parseUntilTag(
    template: string,
    endTags: string[]
  ): { nodes: ASTNode[]; consumed: number } {
    // 简化实现:递归解析直到遇到指定的结束标签
    // 实际实现需要处理嵌套结构
    const nodes: ASTNode[] = [];
    let remaining = template;
    let consumed = 0;

    while (remaining.length > 0) {
      // 检查是否遇到了结束标签
      for (const tag of endTags) {
        const regex = new RegExp(`^\\{\\{${tag.replace('/', '\\/')}\\}\\}`);
        if (regex.test(remaining)) {
          return { nodes, consumed };
        }
      }

      // 递归解析内容(简化:一次推进一个字符或一个模板标记)
      const nextTag = remaining.indexOf('{{');
      if (nextTag === -1) {
        nodes.push({ type: 'text', value: remaining });
        consumed += remaining.length;
        remaining = '';
      } else if (nextTag > 0) {
        nodes.push({ type: 'text', value: remaining.slice(0, nextTag) });
        consumed += nextTag;
        remaining = remaining.slice(nextTag);
      } else {
        // 在标签处,尝试解析子结构
        const varMatch = remaining.match(/^\{\{(\w+)\}\}/);
        if (varMatch && !varMatch[1].startsWith('#') && !varMatch[1].startsWith('/')) {
          nodes.push({ type: 'variable', name: varMatch[1] });
          consumed += varMatch[0].length;
          remaining = remaining.slice(varMatch[0].length);
        } else {
          // 遇到无法解析的标签,推进一个字符避免死循环
          nodes.push({ type: 'text', value: remaining[0] });
          consumed += 1;
          remaining = remaining.slice(1);
        }
      }
    }

    return { nodes, consumed };
  }
}

2.2 模板渲染器

class PromptTemplateRenderer {
  private snippets = new Map<string, string>();

  /** 注册可复用片段 */
  registerSnippet(name: string, content: string): void {
    this.snippets.set(name, content);
  }

  /** 渲染模板 */
  render(ast: ASTNode[], context: Record<string, any>): string {
    return ast.map(node => this.renderNode(node, context)).join('');
  }

  private renderNode(node: ASTNode, context: Record<string, any>): string {
    switch (node.type) {
      case 'text':
        return node.value;

      case 'variable':
        return String(context[node.name] ?? '');

      case 'if':
        if (context[node.condition]) {
          return this.render(node.body, context);
        } else if (node.elseBody) {
          return this.render(node.elseBody, context);
        }
        return '';

      case 'each': {
        const array = context[node.array];
        if (!Array.isArray(array)) return '';
        return array
          .map((item, index) => this.render(node.body, { ...context, item, index }))
          .join('');
      }

      case 'snippet': {
        const snippetContent = this.snippets.get(node.name);
        if (!snippetContent) {
          console.warn(`[PromptTemplate] 片段 "${node.name}" 未注册`);
          return '';
        }
        const parser = new PromptDSLParser();
        const snippetAst = parser.parse(snippetContent);
        return this.render(snippetAst, context);
      }
    }
  }
}

三、版本管理与灰度发布

interface TemplateVersion {
  version: number;
  content: string;
  createdAt: Date;
  author: string;
  changelog: string;
  rolloutPercentage: number; // 灰度比例 0-100
  isActive: boolean;
}

class PromptVersionManager {
  private versions = new Map<string, TemplateVersion[]>();

  /** 创建新版本 */
  createVersion(
    templateId: string,
    content: string,
    author: string,
    changelog: string,
  ): TemplateVersion {
    const existing = this.versions.get(templateId) ?? [];
    const newVersion: TemplateVersion = {
      version: existing.length + 1,
      content,
      createdAt: new Date(),
      author,
      changelog,
      rolloutPercentage: 0,
      isActive: false,
    };

    existing.push(newVersion);
    this.versions.set(templateId, existing);
    return newVersion;
  }

  /** 灰度发布:按比例分配新旧版本 */
  resolveVersion(templateId: string, userId: string): TemplateVersion | null {
    const versions = this.versions.get(templateId);
    if (!versions || versions.length === 0) return null;

    // 按灰度比例分配
    const activeVersions = versions.filter(v => v.isActive && v.rolloutPercentage > 0);

    if (activeVersions.length === 0) {
      // 没有灰度版本,返回最新版本
      return versions[versions.length - 1];
    }

    // 基于userId的哈希决定使用哪个版本
    const hash = this.simpleHash(userId);
    const bucket = hash % 100;

    let cumulative = 0;
    for (const version of activeVersions) {
      cumulative += version.rolloutPercentage;
      if (bucket < cumulative) {
        return version;
      }
    }

    // 默认返回最新版本
    return versions[versions.length - 1];
  }

  private simpleHash(str: string): number {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      const char = str.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash; // 转为32位整数
    }
    return Math.abs(hash);
  }
}

四、效果追踪

interface PromptEffectRecord {
  templateId: string;
  version: number;
  userId: string;
  timestamp: Date;

  // 输入
  variables: Record<string, any>;

  // 输出质量评估
  outputLength: number;
  userRating?: 1 | 2 | 3 | 4 | 5;      // 用户评分
  userAction: 'accept' | 'regenerate' | 'edit' | 'copy' | 'abandon';
  responseTimeMs: number;

  // 自动评估
  jsonParseSuccess?: boolean;             // 是否成功解析为JSON
  formatCompliance?: boolean;             // 是否符合格式要求
}

class PromptEffectTracker {
  private records: PromptEffectRecord[] = [];

  /** 记录一次Prompt使用的效果 */
  record(record: PromptEffectRecord): void {
    this.records.push(record);
  }

  /** 生成A/B测试报告 */
  generateABReport(
    templateId: string,
    versionA: number,
    versionB: number
  ): ABTestReport {
    const recordsA = this.records.filter(
      r => r.templateId === templateId && r.version === versionA
    );
    const recordsB = this.records.filter(
      r => r.templateId === templateId && r.version === versionB
    );

    return {
      versionA: {
        version: versionA,
        sampleSize: recordsA.length,
        avgRating: this.avg(recordsA.map(r => r.userRating).filter(Boolean)),
        acceptRate: recordsA.filter(r => r.userAction === 'accept').length / recordsA.length,
        regenerateRate: recordsA.filter(r => r.userAction === 'regenerate').length / recordsA.length,
        avgResponseTime: this.avg(recordsA.map(r => r.responseTimeMs)),
      },
      versionB: {
        version: versionB,
        sampleSize: recordsB.length,
        avgRating: this.avg(recordsB.map(r => r.userRating).filter(Boolean)),
        acceptRate: recordsB.filter(r => r.userAction === 'accept').length / recordsB.length,
        regenerateRate: recordsB.filter(r => r.userAction === 'regenerate').length / recordsB.length,
        avgResponseTime: this.avg(recordsB.map(r => r.responseTimeMs)),
      },
      recommendation: '...', // 基于数据给出版本推荐
    };
  }

  private avg(nums: number[]): number {
    return nums.length > 0 ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
  }
}

五、完整PromptTemplateManager

class PromptTemplateManager {
  private parser = new PromptDSLParser();
  private renderer = new PromptTemplateRenderer();
  private versionManager = new PromptVersionManager();
  private effectTracker = new PromptEffectTracker();

  /** 注册片段 */
  registerSnippet(name: string, content: string): void {
    this.renderer.registerSnippet(name, content);
  }

  /** 创建模板新版本 */
  createTemplate(
    templateId: string,
    content: string,
    author: string,
    changelog: string,
  ): void {
    // 验证DSL语法
    try {
      this.parser.parse(content);
    } catch (error) {
      throw new Error(`模板DSL语法错误: ${error}`);
    }

    this.versionManager.createVersion(templateId, content, author, changelog);
  }

  /** 渲染模板(自动选择版本) */
  render(
    templateId: string,
    variables: Record<string, any>,
    userId: string,
  ): string {
    const version = this.versionManager.resolveVersion(templateId, userId);
    if (!version) {
      throw new Error(`模板 "${templateId}" 不存在`);
    }

    const ast = this.parser.parse(version.content);
    return this.renderer.render(ast, variables);
  }

  /** 记录效果 */
  recordEffect(record: PromptEffectRecord): void {
    this.effectTracker.record(record);
  }

  /** 获取A/B测试报告 */
  getABReport(templateId: string, versionA: number, versionB: number): ABTestReport {
    return this.effectTracker.generateABReport(templateId, versionA, versionB);
  }
}

六、前后端共享模板仓库的接口设计

// 前端通过API获取模板(而不是硬编码)
// GET /api/prompt-templates/:id?userId=xxx
interface PromptTemplateAPIResponse {
  templateId: string;
  version: number;
  content: string;    // 已选择灰度版本的模板内容
  variables: string[]; // 模板需要的变量列表
}

// 前端提交效果反馈
// POST /api/prompt-templates/:id/feedback
interface PromptFeedbackAPIRequest {
  version: number;
  userId: string;
  userAction: 'accept' | 'regenerate' | 'edit' | 'copy' | 'abandon';
  userRating?: 1 | 2 | 3 | 4 | 5;
  responseTimeMs: number;
}

关键设计:模板内容由后端存储和分发,前端只负责渲染和反馈。这样:

  • 运营人员可以在后台直接修改Prompt,不需要代码部署
  • 灰度发布由后端控制,前端无感知
  • 效果数据统一存储,方便分析

实践任务

任务:实现PromptTemplateManager,支持模板定义、编译、版本管理、效果统计。

验收标准

  1. DSL模板可正确渲染变量、条件、循环、片段
  2. 模板版本链可追溯
  3. 灰度发布基于userId哈希稳定分配
  4. 效果追踪可生成A/B测试报告
  5. 片段可跨模板复用

面试题解析

Q:前端如何管理Prompt模板?如何进行A/B测试?

答题要点

  1. 模板工程化:Prompt不应该硬编码,应通过模板DSL+变量插值管理
  2. 版本化:每次修改创建新版本,支持回滚
  3. 灰度发布:基于userId哈希稳定分配版本,逐步放量
  4. 效果追踪:记录用户行为(接受/重新生成/编辑)作为Prompt质量的代理指标
  5. 前后端协作:模板存储在后端,前端渲染+反馈,形成优化闭环

下期预告:前端AI工程化(五):AI对话状态管理,我们将进入React/Vue的实战领域,拆解长对话场景下的状态管理架构。