返回笔记首页

第六章:Prompt 调试工具&第七章:用量与成本看板

主题配置

源码

workmind-06-07.zip


第六章:Prompt 调试工具

6.1 本章目标

学完这章你能做到:

  • 在界面上直接编写和测试 System Prompt,实时看输出
  • 调节 Temperature 和 Max Tokens 参数,观察对输出的影响
  • A/B 测试:同一个问题,用两个不同 Prompt 分别回答,AI 自动打分对比
  • 模板管理:保存常用 Prompt,随时加载使用,有版本历史

本章对应项目功能:模块六 — Prompt 调试工具


6.2 为什么要做这个工具

写 AI 应用,Prompt 是最需要反复调整的东西。没有调试工具时,改一个 Prompt 要经历:

javascript
1. 改代码里的字符串
2. 重启服务
3. 打开前端界面
4. 发送测试消息
5. 看结果
6. 感觉不对,回到第1步

一次迭代可能要 3-5 分钟。

有了这个工具:

  • 直接在页面上改 System Prompt,秒级看结果
  • 不确定哪个 Prompt 更好?A/B 测试,AI 帮你打分选出胜者
  • 调好了直接保存成模板,下次一键加载

6.3 Temperature 参数详解

Temperature 控制输出的随机性,是最常调的参数之一。

javascript
Temperature = 0     → 每次输出几乎相同,确定性最强
Temperature = 0.7   → 默认值,平衡准确性和多样性
Temperature = 1.5   → 创意强但容易发散,甚至胡说
Temperature = 2.0   → 几乎随机,一般不用这么高

不同任务的推荐值

任务类型 推荐 Temperature 原因
代码生成 0 ~ 0.2 代码有对错之分,要确定
数据提取 0 结构化输出要稳定
问题回答 0.3 ~ 0.7 准确为主,轻微多样性
文案写作 0.7 ~ 1.0 需要创意和多样性
创意写作 1.0 ~ 1.3 尽量多样化

在代码里,temperature 要根据功能动态设置

javascript
// 不同功能用不同 temperature
const chatModel    = createChatModel({ temperature: 0.7 })  // 对话
const extractModel = createChatModel({ temperature: 0 })    // 提取结构化数据
const codeModel    = createChatModel({ temperature: 0 })    // 代码生成
const writeModel   = createChatModel({ temperature: 0.8 })  // 内容创作

6.4 A/B 测试的实现原理

6.4.1 为什么 A/B 测试用非流式接口

单次测试用流式(SSE),让用户实时看到输出过程。

A/B 测试用非流式(普通请求),原因:

  1. 需要等 A 和 B 都完成才能评分
  2. A 和 B 并发运行,用 Promise.all 同时发
  3. 如果都用流式,要同时维护两个 SSE 连接,前端实现复杂
javascript
// 服务端:A、B 并发执行(比串行快一倍)
const [resA, resB] = await Promise.all([
  testModel.invoke([
    { role: 'system', content: systemPromptA },
    { role: 'user',   content: question },
  ]),
  testModel.invoke([
    { role: 'system', content: systemPromptB },
    { role: 'user',   content: question },
  ]),
])

// 然后对比两个答案
const evaluation = await scoreAbTest({
  question,
  answerA: resA.content,
  answerB: resB.content,
})

6.4.2 AI 自动评分的实现

用结构化输出让模型给出标准化的评分结果:

javascript
const EvalSchema = z.object({
  relevance:   z.number().min(1).max(5),  // 相关性
  accuracy:    z.number().min(1).max(5),  // 准确性
  clarity:     z.number().min(1).max(5),  // 清晰度
  conciseness: z.number().min(1).max(5),  // 简洁度
  overall:     z.number().min(1).max(5),  // 综合
  winner:      z.enum(['A', 'B', 'tie']), // 哪个更好
  reason:      z.string(),                // 一句话理由
})

为什么 A 和 B 分开评分,而不是让模型直接对比?

如果直接对比,模型会有"先入为主"的偏见,倾向于认为先看到的(或后看到的)更好。分开评分,各自给出客观分数,再综合对比,结果更公平。

javascript
// 分别评分 A 和 B
const [evalA, evalB] = await Promise.all([
  evalModel.invoke([
    { role: 'system', content: '客观评分,不偏袒任何一方' },
    { role: 'user',   content: `问题:${question}\n回答:${answerA}` },
  ]),
  evalModel.invoke([
    { role: 'system', content: '客观评分,不偏袒任何一方' },
    { role: 'user',   content: `问题:${question}\n回答:${answerB}` },
  ]),
])

// 最后综合对比,判断胜者
const comparison = await compareModel.invoke([...两边的评分...])

6.5 Prompt 模板的版本历史

每次保存模板时,把旧版本存入版本列表:

javascript
export function saveTemplate({ name, systemPrompt, existingId }) {
  const existing = templateStore.get(id)

  const template = {
    id,
    name,
    systemPrompt,
    // 版本历史:追加旧内容,最多保留10个版本
    versions: [
      ...(existing?.versions || []),
      {
        version:      (existing?.versions?.length || 0) + 1,
        systemPrompt: existing?.systemPrompt || systemPrompt,
        savedAt:      new Date().toISOString(),
      },
    ].slice(-10),  // slice(-10) 只保留最近10个
  }

  templateStore.set(id, template)
  return template
}

版本历史的用处

调 Prompt 的时候经常会遇到:改了之后效果反而变差了,想回到上一个版本。有版本历史就可以一键回退。

前端点击"↩ 恢复":

javascript
function restoreVersion(v) {
  // 直接把旧版本的内容填到编辑框
  editingTemplate.value.systemPrompt = v.systemPrompt
  // 用户修改后再点保存,会生成新的版本记录
}

6.6 前端实现要点

6.6.1 三个 Tab 的切换

用一个 activeTab ref 控制显示哪个标签页:

vue
<div class="top-tabs">
  <button
    v-for="tab in tabs"
    :key="tab.id"
    class="top-tab"
    :class="{ active: activeTab === tab.id }"
    @click="activeTab = tab.id"
  >
    {{ tab.icon }} {{ tab.label }}
  </button>
</div>

<!-- 三个面板,只有对应 tab 激活时才显示 -->
<div v-if="activeTab === 'test'" class="tab-panel">...</div>
<div v-if="activeTab === 'ab'"   class="tab-panel">...</div>
<div v-if="activeTab === 'templates'" class="tab-panel">...</div>

v-if 而不是 v-show,因为这三个面板内部逻辑复杂,v-if 在切走时真正销毁 DOM,v-show 只是隐藏,三个面板都挂在树上,可能有性能问题。

6.6.2 Temperature 滑块和实时预览

vue
<input
  type="range"
  min="0" max="2" step="0.1"
  v-model.number="ps.testConfig.temperature"
  class="slider"
/>
<span class="param-val">{{ ps.testConfig.temperature }}</span>

<!-- 根据当前值显示对应的说明文字 -->
<div class="param-hint">{{ tempDesc(ps.testConfig.temperature) }}</div>
javascript
function tempDesc(t) {
  if (t <= 0.3) return '确定性强,适合代码/分析'
  if (t <= 0.7) return '平衡创意和准确性'
  if (t <= 1.2) return '较有创意,答案多样'
  return '高度随机,可能发散'
}

**v-model.number**修饰符:

input type="range"v-model 默认绑定的是字符串("0.7"),加 .number 修饰符会自动转成数字,这样传给后端就不会出现类型错误。

6.6.3 A/B 对比的胜者高亮

vue
<div class="ab-col" :class="{ winner: ps.abResult.evaluation?.winner === 'A' }">
  ...
</div>
css
/* 正常:灰色边框 */
.ab-col { border: 1.5px solid var(--color-border); }

/* 胜出:绿色边框 + 淡绿色背景 */
.ab-col.winner {
  border-color: var(--color-success);
  background: #f0fdf4;   /* 非常浅的绿色 */
}

ps.abResult.evaluation?.winner === 'A' 用了可选链(?.),在 evaluation 为 null 时不会报错,直接返回 undefined(falsy),class 不添加。


6.7 本章作业

✅****单次测试

  • 输入 System Prompt + User Message,点击运行,能看到流式输出
  • 右上角的延迟、token 数、费用在完成后正确显示
  • Temperature 调到 0,运行两次同样的问题,两次回答基本相同
  • Temperature 调到 1.5,运行两次,两次回答有明显差异

✅****A/B 测试

  • 两边分别填入不同的 System Prompt,同一个问题,点击"开始对比"
  • 两边的回答都显示,有评分展示
  • 胜出的一边有绿色边框和"🏆 胜出"标签
  • 底部有"AI 的判断理由"
✅****模板管理
  • 新建一个模板,保存后出现在列表里
  • 内置模板(t_default_开头)不能删除,删除按钮是灰色的
  • 修改一个模板内容,保存后,版本历史里多了一条
  • 点击版本历史里的"↩",编辑区内容变成旧版本

第七章:用量与成本看板

7.1 本章目标

  • 实时查看 API 调用次数、Token 消耗、费用
  • 近 7 日数据折线/柱状图
  • 按功能模块查看调用分布(哪个功能最费钱)
  • 延迟 P50/P90/P99 分布
  • 设置日预算,超出时显示警告

本章对应项目功能:模块七 — 用量与成本看板


7.2 成本计算方式

DeepSeek 按 token 计费,分输入和输出:

javascript
// DeepSeek 价格(2024年末)
const PRICE = {
  input:  0.27,   // $0.27 / 1M 输入 token
  output: 1.10,   // $1.10 / 1M 输出 token
}

function calcCost(inputTokens, outputTokens) {
  const usd = (inputTokens  / 1_000_000) * PRICE.input
    + (outputTokens / 1_000_000) * PRICE.output
  return {
    usd,
    cny: usd * 7.2,  // 按 7.2 汇率换算人民币
  }
}

// 一次对话(输入 500 token,输出 200 token):
calcCost(500, 200)
// → usd: 0.000135 + 0.00022 = 0.000355 $
// → cny: 0.002556 ¥
// 一万次这样的对话:¥25.56

Token 数怎么知道?

在 LangChain 里,流式模型在最后一个 chunk 会带上 usage 信息:

javascript
for await (const chunk of stream) {
  if (chunk.content) fullReply += chunk.content

  // 最后一个 chunk 里有 usage
  if (chunk.usage_metadata) {
    inputTokens  = chunk.usage_metadata.input_tokens
    outputTokens = chunk.usage_metadata.output_tokens
  }
}

7.3 统计数据的设计

7.3.1 原始数据存储

javascript
// 每次 API 调用记录一条
const stats = {
  calls: [
    {
      time:       '2024-03-15T10:23:45.000Z',
      feature:    'chat',          // 哪个功能模块触发的
      inputT:     450,             // 输入 token 数
      outputT:    200,             // 输出 token 数
      costUSD:    0.000355,        // 费用(美元)
      costCNY:    0.002556,        // 费用(人民币)
      latencyMs:  1234,            // 响应时间(毫秒)
      fromCache:  false,           // 是否命中缓存
    },
    // ...
  ]
}

7.3.2 聚合计算

每次 /api/monitor/stats 请求都实时聚合计算:

javascript
// 今日数据(从今天0点算起)
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const todayCalls = stats.calls.filter(c => new Date(c.time) >= todayStart)

// 今日 API 费用(缓存命中的不计费)
const todayCost = todayCalls
  .filter(c => !c.fromCache)
  .reduce((sum, c) => sum + c.costCNY, 0)

// 缓存命中率
const cacheHits = todayCalls.filter(c => c.fromCache).length
const hitRate   = todayCalls.length
  ? `${(cacheHits / todayCalls.length * 100).toFixed(1)}%`
  : '0%'

7.3.3 P50/P90/P99 延迟计算

百分位数是性能监控的标准指标:

javascript
function percentile(arr, p) {
  if (!arr.length) return 0
  const sorted = [...arr].sort((a, b) => a - b)  // 从小到大排序
  const idx    = Math.ceil(sorted.length * p / 100) - 1
  return sorted[Math.max(0, idx)]
}

const latencies = todayCalls
  .filter(c => !c.fromCache && c.latencyMs > 0)
  .map(c => c.latencyMs)

const p50 = percentile(latencies, 50)  // 中位数:50% 的请求比这快
const p90 = percentile(latencies, 90)  // 90% 的请求比这快
const p99 = percentile(latencies, 99)  // 99% 的请求比这快,反映最坏情况

为什么关注 P99 而不是平均值?

平均值会被少量快速请求拉低,掩盖慢请求的问题。P99 = "最慢的 1% 的请求是多慢",能更真实反映用户体验的最差情况。

如果 P99 是 8000ms,说明每 100 个请求里有 1 个会让用户等超过 8 秒,这是需要关注的。


7.4 前端轮询策略

看板数据每 10 秒自动刷新一次:

javascript
onMounted(() => {
  loadStats()                               // 立即加载一次
  pollTimer = setInterval(loadStats, 10000) // 每 10 秒刷新
})

// 组件销毁时清除定时器,防止内存泄漏
onUnmounted(() => clearInterval(pollTimer))

为什么不用 WebSocket?

看板数据不是实时的,每10秒刷新已经足够。WebSocket 更适合需要服务端主动推送的场景(如聊天、实时通知)。

轮询的优点:简单、可靠、服务端无状态。


7.5 柱状图的实现

不引入 ECharts 等重型库,用纯 CSS + Vue 实现一个简单的柱状图:

vue
<div class="bar-chart">
  <div v-for="day in last7Days" :key="day.date" class="bar-col">
    <!-- 柱子高度按比例换算 -->
    <div class="bar-group">
      <div class="bar input-bar"  :style="{ height: barH(day.inputT) + 'px' }" />
      <div class="bar output-bar" :style="{ height: barH(day.outputT) + 'px' }" />
    </div>
    <div class="bar-label">{{ day.label }}</div>  <!-- 3/15 -->
    <div class="bar-cost">¥{{ day.costCNY }}</div>
  </div>
</div>
javascript
// 把 token 数映射到像素高度(最高 80px)
const maxTokens = computed(() =>
  Math.max(...last7Days.value.map(d => d.inputT + d.outputT), 1)
                          )

function barH(val) {
  return Math.max(2, Math.round((val / maxTokens.value) * 80))
  // Math.max(2, ...) 保证至少有 2px,不然 0 的时候柱子消失了
}

这种方式:

  • 不依赖任何图表库,bundle size 不增加
  • 数据变化时,CSS transition 自动有动画效果
  • 代码量很少,容易维护

如果要更复杂的图表(折线图、饼图),推荐引入 ECharts:

bash
npm install echarts
javascript
import * as echarts from 'echarts'
// 或者按需引入,减小体积
import { use, init } from 'echarts/core'

7.6 预算预警机制

用百分比条直观展示预算使用情况,超过 80% 变黄,超过 100% 变红:

vue
<div
  class="budget-fill"
  :style="{ width: Math.min(budgetUsedPct, 100) + '%' }"
  :class="{
    warn:   budgetUsedPct >= 80,
    danger: budgetUsedPct >= 100
  }"
/>
css
.budget-fill         { background: var(--color-primary); }   /* 正常:蓝色 */
.budget-fill.warn    { background: var(--color-warning); }   /* 预警:黄色 */
.budget-fill.danger  { background: var(--color-danger); }    /* 超出:红色 */

Math.min(budgetUsedPct, 100) 确保超出 100% 时宽度不超过父容器,不会溢出。


7.7 recordApiCall 函数的使用

用量监控需要在每次 API 调用时记录数据。我们导出了 recordApiCall 函数,可以在各个路由里调用:

javascript
// routes/chat.js
import { recordApiCall } from './monitor.js'

// 流式输出完成后记录
send('done', { fromCache: false, inputTokens, outputTokens })
recordApiCall({
  feature:      'chat',
  inputTokens,
  outputTokens,
  latencyMs:    Date.now() - startMs,
  fromCache:    false,
})

7.8 本章作业

✅****基础功能

  • 打开用量看板,4个指标卡正常显示(即使都是0)
  • 进行几次对话/RAG 问答,刷新看板,数据有变化
  • 调用记录表格里能看到刚才的操作
  • 缓存命中率:问同一个问题两次,第二次显示"⚡ 缓存",命中率变化

✅****预算功能

  • 修改日预算(改成很小的数字如 ¥0.01),进行几次对话
  • 预算进度条变黄/红色
✅****图表
  • 近 7 日柱状图有两种颜色的柱子(输入/输出分开)
  • 今日调用分布按功能展示,有横向进度条

两章总结

第六章 Prompt 调试工具

功能 技术要点
单次测试(流式) 复用 fetchStream
,参数动态传 temperature
A/B 测试(非流式) Promise.all
并发跑两个 Prompt,withStructuredOutput
自动评分
模板版本历史 slice(-10)
保留最近10个,服务端用 Map 存
Temperature 说明 根据数值范围返回不同的描述文字

第七章 用量看板

功能 技术要点
数据统计 原始记录存数组,每次请求实时聚合
P99 延迟 排序后取对应百分位的值
柱状图 纯 CSS 实现,token 数映射成像素高度
预算预警 百分比进度条,超80%变黄,超100%变红
轮询刷新 setInterval
+ onUnmounted
清理