源码
第六章:Prompt 调试工具
6.1 本章目标
学完这章你能做到:
- 在界面上直接编写和测试 System Prompt,实时看输出
- 调节 Temperature 和 Max Tokens 参数,观察对输出的影响
- A/B 测试:同一个问题,用两个不同 Prompt 分别回答,AI 自动打分对比
- 模板管理:保存常用 Prompt,随时加载使用,有版本历史
本章对应项目功能:模块六 — Prompt 调试工具
6.2 为什么要做这个工具
写 AI 应用,Prompt 是最需要反复调整的东西。没有调试工具时,改一个 Prompt 要经历:
1. 改代码里的字符串
2. 重启服务
3. 打开前端界面
4. 发送测试消息
5. 看结果
6. 感觉不对,回到第1步
一次迭代可能要 3-5 分钟。
有了这个工具:
- 直接在页面上改 System Prompt,秒级看结果
- 不确定哪个 Prompt 更好?A/B 测试,AI 帮你打分选出胜者
- 调好了直接保存成模板,下次一键加载
6.3 Temperature 参数详解
Temperature 控制输出的随机性,是最常调的参数之一。
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 要根据功能动态设置
// 不同功能用不同 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 测试用非流式(普通请求),原因:
- 需要等 A 和 B 都完成才能评分
- A 和 B 并发运行,用 Promise.all 同时发
- 如果都用流式,要同时维护两个 SSE 连接,前端实现复杂
// 服务端: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 自动评分的实现
用结构化输出让模型给出标准化的评分结果:
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 分开评分,而不是让模型直接对比?
如果直接对比,模型会有"先入为主"的偏见,倾向于认为先看到的(或后看到的)更好。分开评分,各自给出客观分数,再综合对比,结果更公平。
// 分别评分 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 模板的版本历史
每次保存模板时,把旧版本存入版本列表:
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 的时候经常会遇到:改了之后效果反而变差了,想回到上一个版本。有版本历史就可以一键回退。
前端点击"↩ 恢复":
function restoreVersion(v) {
// 直接把旧版本的内容填到编辑框
editingTemplate.value.systemPrompt = v.systemPrompt
// 用户修改后再点保存,会生成新的版本记录
}
6.6 前端实现要点
6.6.1 三个 Tab 的切换
用一个 activeTab ref 控制显示哪个标签页:
<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 滑块和实时预览
<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>
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 对比的胜者高亮
<div class="ab-col" :class="{ winner: ps.abResult.evaluation?.winner === 'A' }">
...
</div>
/* 正常:灰色边框 */
.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 计费,分输入和输出:
// 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 信息:
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 原始数据存储
// 每次 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 请求都实时聚合计算:
// 今日数据(从今天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 延迟计算
百分位数是性能监控的标准指标:
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 秒自动刷新一次:
onMounted(() => {
loadStats() // 立即加载一次
pollTimer = setInterval(loadStats, 10000) // 每 10 秒刷新
})
// 组件销毁时清除定时器,防止内存泄漏
onUnmounted(() => clearInterval(pollTimer))
为什么不用 WebSocket?
看板数据不是实时的,每10秒刷新已经足够。WebSocket 更适合需要服务端主动推送的场景(如聊天、实时通知)。
轮询的优点:简单、可靠、服务端无状态。
7.5 柱状图的实现
不引入 ECharts 等重型库,用纯 CSS + 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>
// 把 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:
npm install echarts
import * as echarts from 'echarts'
// 或者按需引入,减小体积
import { use, init } from 'echarts/core'
7.6 预算预警机制
用百分比条直观展示预算使用情况,超过 80% 变黄,超过 100% 变红:
<div
class="budget-fill"
:style="{ width: Math.min(budgetUsedPct, 100) + '%' }"
:class="{
warn: budgetUsedPct >= 80,
danger: budgetUsedPct >= 100
}"
/>
.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 函数,可以在各个路由里调用:
// 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清理 |