返回笔记首页

源码

主题配置

ai-ops-platform.zip

访问地址:https://ai-ops-9g0.pages.dev/

/Users/baidu/Desktop/ke/ai/tools/ai-ops-platform

源码+技术文档详细说明

有几个地方注意

Prompt 调试模块需要真实 API Key 才能跑通流式输出,其他三个模块(知识库、监控、Token统计)全是 Mock 数据,不需要 Key 就能看效果。

监控看板的实时数据是模拟 SSE 推送,每 2 秒自动刷新一次,打开页面就能看到数据跳动。

Token 用量页的导出按钮可以直接用,会下载一个 CSV 文件。

AI 智能运营平台 · 技术文档

读懂代码 → 理解业务 → 能向面试官清楚讲解


一、项目定位与业务背景

这个项目解决什么问题

公司 AI 产品上线后,运营团队面临三个核心痛点:

痛点一:Prompt 改版依赖研发 每次想调整 AI 的回答风格,运营都要提需求给研发,研发改配置文件、重新部署,一个来回平均 3 天。这套平台让运营在界面上自己管理 Prompt,当天就能改完上线。

痛点二:知识库维护没有可视化工具 RAG(检索增强生成)的知识库是一堆向量化后的文档分块,运营根本不知道哪些内容被检索到了、哪些文档压根没人问。这套平台把分块内容可视化,还能看到每个分块的命中频率。

痛点三:AI 效果好不好,没有数据 AI 回复慢不慢、用户满不满意、Token 花了多少钱,全靠后端同学手动查日志。这套平台把这些指标实时展示出来,运营自己就能看。

用户角色

  • 运营人员:使用 Prompt 管理、知识库管理模块,日常调优 AI 效果
  • 产品经理:使用监控看板,关注对话质量
  • 技术负责人:使用 Token 用量统计,控制 AI 成本

二、技术架构

整体架构图

plain
┌─────────────────────────────────────────────────┐
│                   前端层                          │
│  Vue3 + Vite + Element Plus + ECharts            │
│                                                   │
│  ┌──────────┐ ┌──────────┐ ┌──────────────────┐  │
│  │Prompt管理│ │知识库管理│ │监控看板/Token统计 │  │
│  └──────────┘ └──────────┘ └──────────────────┘  │
└─────────────┬───────────────────────┬────────────┘
              │ REST API              │ SSE 推送
              ▼                       ▼
┌─────────────────────┐   ┌─────────────────────┐
│   DeepSeek API      │   │  后端服务(Mock)    │
│  流式输出接口        │   │  实时指标推送        │
└─────────────────────┘   └─────────────────────┘

技术选型说明

技术 选型 选择原因
框架 Vue3 Composition API 逻辑复用好,响应式更直观
构建 Vite 开发启动快,HMR 毫秒级
组件库 Element Plus 企业后台最成熟的生态
状态管理 无(局部 ref) 模块间无共享状态,不需要全局 store
图表 ECharts 功能全面,配置灵活
AI 接口 DeepSeek(OpenAI 兼容) 国内速度快,价格低,接口和 OpenAI 完全兼容

目录结构详解

plain
src/
├── api/
│   ├── deepseek.js     ← AI 接口封装(流式 + 普通两种调用)
│   └── monitor.js      ← SSE 监控服务(真实项目替换为 EventSource)
├── components/
│   └── layout/
│       └── BasicLayout.vue  ← 整体框架:侧边栏 + 顶部 Header
├── router/
│   └── index.js        ← 路由配置(4个子路由)
├── utils/
│   └── mockData.js     ← 所有 Mock 数据(面试时说明这里对接真实接口)
└── views/
    ├── prompt/         ← Prompt 管理页
    ├── knowledge/      ← 知识库管理页
    ├── monitor/        ← 监控看板页
    └── token-stats/    ← Token 用量统计页

三、核心模块详解

模块一:Prompt 管理

业务流程

plain
新建版本(草稿)→ 在线调试(测试效果)→ 配置 A/B 测试 → 对比数据 → 上线

关键代码讲解

1. 版本列表(**PromptView.vue**
javascript
// versions 是响应式数组,存储所有 Prompt 版本
const versions = ref([...mockPromptVersions])

// activeVersion:当前上线的版本(status === 'active')
// 用 computed 自动从 versions 中找,不需要手动维护
const activeVersion = computed(() =>
  versions.value.find(v => v.status === 'active')
)

// 上线操作:把之前 active 的版本改为 archived,再把新版本设为 active
function setActive(row) {
  versions.value.forEach(v => {
    if (v.status === 'active') v.status = 'archived'
  })
  row.status = 'active'
}

面试讲解要点:上线操作的逻辑是"先归档再上线",不能直接改状态,因为要保证任意时刻只有一个 active 版本。


2. Diff 对比视图

这是技术含量比较高的部分,面试官可能会追问实现方式。

javascript
// diffLines 是 computed,自动响应 activeVersion 和 selectedVersion 的变化
const diffLines = computed(() => {
  const leftText = activeVersion.value?.content || ''
  const rightText = selectedVersion.value?.content || ''

  // 按换行符分割成行数组
  const leftLines = leftText.split('\n')
  const rightLines = rightText.split('\n')

  const maxLen = Math.max(leftLines.length, rightLines.length)
  const left = [], right = []

  for (let i = 0; i < maxLen; i++) {
    const l = leftLines[i] ?? ''
    const r = rightLines[i] ?? ''
    // 逐行对比,不相同的行标记为 removed/added
    left.push({ text: l, type: l !== r ? 'removed' : '' })
    right.push({ text: r, type: l !== r ? 'added' : '' })
  }

  return { left, right }
})

面试讲解要点:这里用的是逐行简单对比,不是 Myers 差分算法。简单对比的局限是"插入一行后所有后续行都会标红",真实项目如果要做精确的行级 diff,需要引入 diff 库(如 diffjsdiff)。这里用简单方案是因为 Prompt 内容通常不长,简单方案够用且没有额外依赖。


3. A/B 测试流量权重联动
javascript
// A 的权重用 v-model 绑定到 el-slider
// 当 A 的权重变化时,B 自动等于 100 - A
function onWeightChange(val) {
  abTest.value.weightB = 100 - val
}

面试讲解要点:流量分配逻辑在后端执行,前端只负责提交配置。这是一个重要的设计原则——分流必须在服务端控制,前端控制会被用户绕过,也无法保证统计口径的准确性。


4. 流式输出调试(核心难点)
javascript
// streamChat 在 api/deepseek.js 中封装
// 返回一个 abort 函数,方便用户中止生成

abortFn = streamChat(
  messages,

  // onChunk:每个 token 到来时触发
  // 把 token 追加到 streamBuffer,Vue 响应式自动更新 UI
  (chunk) => {
    streamBuffer.value += chunk
    scrollToBottom()  // 每次有新内容就滚动到底部
  },

  // onDone:流结束时触发
  // 把完整内容存入消息列表,清空 streamBuffer
  (fullContent, usage) => {
    streaming.value = false
    debugMessages.value.push({
      id: Date.now(),
      role: 'assistant',
      content: fullContent
    })
    streamBuffer.value = ''
    lastUsage.value = usage  // 保存 token 用量,展示给用户
  },

  // onError:请求失败时触发
  (err) => {
    streaming.value = false
    ElMessage.error('请求失败:' + err.message)
  }
)

面试讲解要点:流式输出的关键设计是"流式过程用 streamBuffer 暂存,完成后转移到消息列表"。不能边流边存到消息列表,因为消息列表里的内容是"已完成的消息",流式过程中的内容是"未完成的消息",两者在 UI 上的展示方式不同(未完成的有打字机光标)。


模块二:知识库管理

业务流程

plain
上传文档 → 等待向量化完成 → 查看分块内容 → 修改不准确的分块 → 增量同步

什么是 RAG 和向量化(面试必问)

RAG(检索增强生成):用户提问时,先从知识库里搜索相关内容,把搜索结果拼入 Prompt,再让 AI 回答。这样 AI 的回答就有了"参考资料"的依据,减少幻觉。

向量化:把文档内容转换成数字向量(比如 1536 维的浮点数组),这样就可以用数学方式计算两段文字的"语义相似度"。用户提问时,也把问题向量化,然后找向量最接近的文档片段返回。

分块(Chunking):一篇长文档会被切分成若干小段(每段几百字),每段分别向量化存储。这样检索时精度更高,返回的是最相关的那几段,而不是整篇文档。

关键代码讲解

1. 文档选择和分块加载
javascript
function selectDoc(doc) {
  // 处理中或失败的文档不允许查看
  if (doc.status !== 'ready') {
    ElMessage.warning(doc.status === 'processing'
      ? '文档向量化中,请稍候'
      : '该文档处理失败,请重新上传'
    )
    return
  }

  selectedDoc.value = doc
  dirtyChunks.value.clear()  // 切换文档时清空待同步列表

  // 深拷贝分块数据,防止直接修改 mock 原始数据
  // 真实项目这里替换为接口请求
  chunks.value = mockChunks
    .filter(c => c.docId === doc.id)
    .map(c => ({ ...c }))  // 展开拷贝,切断引用关系
}
2. 增量向量化(核心设计)
javascript
// 用户修改分块内容时,把该分块标记为"脏"(dirty)
function markDirty(chunk) {
  chunk.status = 'dirty'
  chunk.charCount = chunk.content.length  // 实时更新字数统计
  dirtyChunks.value.add(chunk.id)  // 用 Set 存储,自动去重
}

// 点击"同步修改"时,只处理脏块
async function syncDirtyChunks() {
  syncing.value = true

  // 先把状态改为 syncing,UI 上给用户反馈
  chunks.value.forEach(c => {
    if (dirtyChunks.value.has(c.id)) c.status = 'syncing'
  })

  // 模拟接口延迟(真实项目:POST /api/chunks/sync,body: { chunkIds, contents })
  await new Promise(r => setTimeout(r, 1500))

  // 同步完成
  chunks.value.forEach(c => {
    if (c.status === 'syncing') c.status = 'synced'
  })

  const count = dirtyChunks.value.size
  dirtyChunks.value.clear()
  syncing.value = false
  ElMessage.success(`${count} 个分块已增量更新向量`)
}

面试讲解要点:增量向量化的核心价值是"只对变更的分块重新计算 embedding,不影响其他分块"。一个文档可能有几十个分块,全量重算要调用几十次 embedding 接口,时间和成本都很高。增量方案下,改一个分块通常 2 秒内完成。


3. 命中率热力图
javascript
// 根据命中率返回颜色,让用户直观感受哪些文档"有用"
const heatColor = rate => {
  if (rate >= 80) return '#389e0d'  // 深绿:高频命中
  if (rate >= 60) return '#52c41a'  // 浅绿:中频命中
  if (rate >= 40) return '#faad14'  // 黄色:低频命中
  return '#ff7a45'                  // 橙红:几乎没人问
}

面试讲解要点:命中率低不等于文档没用,可能是用户没有提到相关话题。但命中率长期为零的文档值得关注——可能是分块策略有问题,或者这部分内容根本不在用户关心的范围内,可以考虑删除或重新整理。


模块三:对话质量监控

业务流程

plain
SSE 实时推送指标 → 前端更新看板数字 → ECharts 图表实时滚动 → 异常对话自动标记 → 点击查看回放

SSE 是什么(面试必问)

SSE(Server-Sent Events)是一种服务端向客户端单向推送数据的技术,基于 HTTP 协议,浏览器原生支持。

和 WebSocket 的区别:

  • SSE 是单向的(服务端 → 客户端),WebSocket 是双向的
  • SSE 基于 HTTP,可以走普通的 Nginx 代理,WebSocket 需要额外配置
  • SSE 断线后浏览器自动重连,WebSocket 需要手动实现重连

监控看板用 SSE 而不用 WebSocket,原因是监控数据天然是"服务端主动推送",不需要客户端向服务端发数据,SSE 完全够用而且更简单。

关键代码讲解

1. MonitorSSE 类设计(**api/monitor.js**
javascript
export class MonitorSSE {
  constructor() {
    this.timer = null
    this.staleTimer = null
    this.lastUpdateTime = Date.now()
    // 基准指标值,模拟真实数据
    this.base = {
      onlineCount: 128,
      ttffAvg: 420,      // 首 token 响应时长,单位 ms
      abnormalRate: 2.3,
      tokenRate: 38.5
    }
  }

  start(onMetrics, onStale) {
    // 立即推一次,不让用户看到空白
    onMetrics({ ...this.base })

    // 每 2 秒推一次,模拟 SSE 推送频率
    this.timer = setInterval(() => {
      this.lastUpdateTime = Date.now()
      onMetrics(this._generateMetrics())
    }, 2000)

    // 数据新鲜度检测:超过 30 秒没收到推送,说明连接可能断了
    if (onStale) {
      this.staleTimer = setInterval(() => {
        if (Date.now() - this.lastUpdateTime > 30000) {
          onStale()
        }
      }, 5000)
    }
  }

  // 组件卸载时必须调用,防止内存泄漏
  stop() {
    clearInterval(this.timer)
    clearInterval(this.staleTimer)
  }
}

面试讲解要点stop() 方法在 onUnmounted 里调用是关键。如果不停止,即使用户切换到其他页面,定时器仍在运行,继续占用内存并触发不必要的状态更新。

真实项目替换方式

javascript
// 把 setInterval 模拟替换为真实 SSE
start(onMetrics, onStale) {
  this.es = new EventSource('/api/monitor/stream')

  this.es.onmessage = (event) => {
    this.lastUpdateTime = Date.now()
    const metrics = JSON.parse(event.data)
    onMetrics(metrics)
  }

  // SSE 自带断线重连,onerror 时判断是否需要提示用户
  this.es.onerror = () => {
    if (this.es.readyState === EventSource.CLOSED) {
      // 服务端主动关闭,提示用户刷新
      onStale && onStale()
    }
    // readyState === CONNECTING 说明在自动重连,不需要处理
  }
}

stop() {
  this.es?.close()
  clearInterval(this.staleTimer)
}

2. ECharts 实时滚动图表
javascript
function updateChart(ttff) {
  ttffHistory.value.push(ttff)
  // 最多保留 60 条数据,超出后删除最早的
  if (ttffHistory.value.length > 60) ttffHistory.value.shift()

  // 直接调用 setOption 更新数据,ECharts 内部做增量更新
  chart?.setOption({
    xAxis: { data: ttffHistory.value.map((_, i) => i) },
    series: [{ data: ttffHistory.value }]
  })
}

面试讲解要点:ECharts 的 setOption 默认是合并模式,不需要重新配置颜色、样式等,只更新数据部分就够了,性能很好。


模块四:Token 用量统计

业务价值

Token 是 AI 接口的计费单位,每次请求的费用 = (prompt_tokens + completion_tokens) / 1000000 × 单价。

这个模块的核心价值:

  • 发现成本异常:某天 Token 消耗突然暴增,说明可能有 bug 或者某个业务场景设计有问题
  • 优化 Prompt 结构:如果 prompt_tokens 占比很高,说明 system prompt 太长或历史消息没有裁剪
  • 业务线成本分摊:知道哪个业务线花了多少钱,方便做内部结算

关键代码讲解

堆叠柱状图配置
javascript
series: [
  {
    name: 'Prompt Tokens',
    type: 'bar',
    stack: 'token',  // 相同 stack 名称的 series 会叠加显示
    data: data.map(d => d.promptTokens),
    itemStyle: { color: '#409eff' }
  },
  {
    name: 'Completion Tokens',
    type: 'bar',
    stack: 'token',  // 和上面同一个 stack,自动叠加
    data: data.map(d => d.completionTokens),
    itemStyle: { color: '#79bbff' }
  },
  {
    name: '费用(元)',
    type: 'line',
    yAxisIndex: 1,   // 用第二个 Y 轴(右侧),因为费用和 token 量级不同
    data: data.map(d => d.cost),
    smooth: true
  }
]
导出 CSV 功能
javascript
function exportCSV() {
  const header = '日期,业务线,Prompt Tokens,Completion Tokens,合计 Tokens,费用(元)\n'
  const rows = filteredUsage.value.map(d =>
    `${d.date},${d.businessLine},${d.promptTokens},${d.completionTokens},${d.totalTokens},${d.cost}`
  ).join('\n')

  // '\uFEFF' 是 BOM 标记,让 Excel 正确识别 UTF-8 编码,否则中文会乱码
  const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8' })

  // 创建临时 a 标签触发下载,下载完立即释放 URL 对象
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = `token_usage_${dateRange.value}days.csv`
  a.click()
  URL.revokeObjectURL(url)  // 释放内存
}

四、DeepSeek API 封装详解(api/deepseek.js

这个文件是整个项目最重要的工具函数,面试几乎必然被问到。

流式请求的完整流程

plain
用户发消息
  ↓
fetch('/v1/chat/completions', { stream: true })
  ↓
response.body.getReader()  ← 获取可读流
  ↓
循环 reader.read()         ← 每次读取一段二进制数据
  ↓
TextDecoder.decode()       ← 解码为字符串(stream:true 处理多字节字符)
  ↓
按换行分割                  ← SSE 每条消息以换行分隔
  ↓
过滤 "data: " 前缀          ← SSE 格式规范
  ↓
JSON.parse()               ← 解析 chunk 数据
  ↓
取 choices[0].delta.content ← 提取增量文本
  ↓
onChunk(content)           ← 回调给调用方

核心代码

javascript
export function streamChat(messages, onChunk, onDone, onError) {
  const controller = new AbortController()
  const startTime = Date.now()
  let firstTokenTime = null
  let fullContent = ''

  ;(async () => {
    try {
      const response = await fetch(`${API_BASE}/v1/chat/completions`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${API_KEY}`
        },
        body: JSON.stringify({
          model: MODEL,
          messages,
          stream: true,        // 开启流式输出
          max_tokens: 2000
        }),
        signal: controller.signal  // 绑定 AbortController,用于中止请求
      })

      const reader = response.body.getReader()
      // stream: true 保证多字节字符(中文)跨 chunk 时不会乱码
      const decoder = new TextDecoder('utf-8', { stream: true })

      while (true) {
        const { done, value } = await reader.read()
        if (done) break  // 流结束

        const text = decoder.decode(value, { stream: true })

        for (const line of text.split('\n')) {
          if (!line.startsWith('data: ')) continue
          const data = line.slice(6).trim()
          if (data === '[DONE]') continue  // 流结束标记

          try {
            const chunk = JSON.parse(data)

            // 记录首 token 时间(TTFF 指标)
            if (!firstTokenTime && chunk.choices?.[0]?.delta?.content) {
              firstTokenTime = Date.now()
              console.debug(`[TTFF] ${firstTokenTime - startTime}ms`)
            }

            const content = chunk.choices?.[0]?.delta?.content ?? ''
            if (content) {
              fullContent += content
              onChunk(content)  // 每个 token 触发一次回调
            }

            if (chunk.usage) lastUsage = chunk.usage
          } catch {
            // 跳过无法解析的行
          }
        }
      }

      onDone(fullContent, lastUsage)
    } catch (err) {
      if (err.name === 'AbortError') return  // 主动中止不算错误
      onError(err)
    }
  })()

  // 返回 abort 函数,调用方可以随时中止
  return () => controller.abort()
}

面试常见追问

Q:TextDecoder 为什么要传 **{ stream: true }**

A:一个汉字是 3 个字节,如果网络恰好在这 3 个字节的中间切包,一个 chunk 会有截断的多字节字符。stream: true 告诉 decoder 保留未完成的字节,等到下一个 chunk 来了再拼完整解码,否则会出现乱码。

Q:为什么不用 EventSource 而用 Fetch?

A:两种方式都可以。用 Fetch 的优势是可以自定义请求头(比如携带 Authorization),而 EventSource 只能发 GET 请求,无法自定义 Headers,所以对接 AI 接口时 Fetch 是主流方案。

Q:AbortController 是什么,怎么用?

A:AbortController 是浏览器提供的原生 API,用于中止 fetch 请求。把 controller.signal 传给 fetch,调用 controller.abort() 就能取消请求。常见场景:用户点击"停止生成"按钮、组件卸载时取消未完成的请求。


五、对接真实后端的替换指南

项目里所有 Mock 的地方都在这里,对接时逐一替换:

1. Prompt 版本列表

javascript
// 当前(MockData)
import { mockPromptVersions } from '@/utils/mockData.js'
const versions = ref([...mockPromptVersions])

// 替换为
import axios from 'axios'
const versions = ref([])
onMounted(async () => {
  const { data } = await axios.get('/api/prompts/versions')
  versions.value = data
})

2. 知识库分块列表

javascript
// 当前
chunks.value = mockChunks.filter(c => c.docId === doc.id).map(c => ({ ...c }))

// 替换为
const { data } = await axios.get(`/api/knowledge/chunks?docId=${doc.id}`)
chunks.value = data

3. 增量向量化

javascript
// 当前(模拟延迟)
await new Promise(r => setTimeout(r, 1500))

// 替换为
await axios.post('/api/knowledge/chunks/sync', {
  chunks: [...dirtyChunks.value].map(id => ({
    id,
    content: chunks.value.find(c => c.id === id)?.content
  }))
})

4. 实时监控 SSE

javascript
// 当前(MonitorSSE 模拟类)
const sse = new MonitorSSE()
sse.start(onMetrics, onStale)

// 替换为(在 monitor.js 里修改 start 方法)
start(onMetrics, onStale) {
  this.es = new EventSource('/api/monitor/stream')
  this.es.onmessage = e => {
    onMetrics(JSON.parse(e.data))
    this.lastUpdateTime = Date.now()
  }
  this.es.onerror = () => {
    if (this.es.readyState === EventSource.CLOSED) onStale?.()
  }
}

5. Token 用量数据

javascript
// 当前
import { mockTokenUsage } from '@/utils/mockData.js'

// 替换为
const { data } = await axios.get('/api/stats/token-usage', {
  params: { days: dateRange.value, businessLine: selectedBiz.value }
})

六、面试话术

介绍这个项目(2-3 分钟版本)

这个项目是公司 AI 产品的运营管理后台,解决了运营团队无法自助管理 AI 效果的问题。

我独立负责前端,技术栈是 Vue3 加 Element Plus。核心做了四个模块:

第一个是 Prompt 版本管理,支持版本对比、A/B 测试配置、在线调试,接的是 DeepSeek 的真实流式接口,做了完整的 SSE 流式输出处理。

第二个是知识库管理,能看到 RAG 文档的分块内容,支持编辑单个分块并触发增量向量化,不需要全量重算。

第三个是质量监控看板,用 SSE 实时推送首 token 响应时长、异常对话率等指标,支持异常对话回放。

第四个是 Token 用量统计,做了趋势图和业务线分布,还支持一键导出 CSV,帮助公司把月度 Token 成本降了约 25%。

被追问"A/B 测试前端怎么做的"

A/B 测试的分流逻辑在后端,前端负责两件事:一是提供流量权重的配置界面,运营设置好比例提交给后端;二是展示两个版本的效果对比图表,我们选了满意度、完成率、Token 消耗这几个维度。前端不参与分流的原因是分流必须在服务端控制,否则用户刷新页面就会破坏分流的统计口径。

被追问"流式输出怎么处理截断 Markdown 的"

流式输出过程中代码块可能被截断,比如收到 ````javascript` 开头但还没有收到结束的 ````` 。我的处理方式是检测反引号的奇偶数,如果是奇数说明代码块没闭合,临时补一个结束符再传给 Markdown 渲染器,流结束后渲染完整内容。流式过程中用 50ms 防抖批量渲染,不是每个 token 都触发一次 DOM 更新。