Vue3 + TypeScript构建AIPC端侧AI推理界面实战:从模型加载到流式渲染

AIPC端侧AI推理的架构选择

Kimi K3开源后支持Windows本地离线部署,端侧AI推理从概念变成现实。前端作为用户与本地大模型的交互层,需要解决三个核心问题:大文件模型权重的加载进度反馈、流式Token的实时渲染、以及推理过程中的中断控制。Vue3的Composition API配合TypeScript的强类型约束,在构建这类状态密集型界面时有天然优势——响应式系统自动追踪Token流的更新,无需手动操作DOM。

本文以对接本地vLLM推理服务为例,从零构建一个生产级的AI对话界面。技术栈:Vue 3.5 + TypeScript 5.6 + Vite 6 + TailwindCSS 4。

项目初始化与核心依赖

npm create vite@latest aipc-chat -- --template vue-ts

cd aipc-chat && npm install

# 核心依赖
npm install @vueuse/core markdown-it highlight.js
npm install -D @types/markdown-it @types/highlight.js

TypeScript类型定义:

// types/agent.ts
export interface ChatMessage {
  id: string
  role: 'system' | 'user' | 'assistant'
  content: string
  timestamp: number
  tokens?: number
  status: 'pending' | 'streaming' | 'done' | 'error'
}

export interface ModelStatus {
  loaded: boolean
  loading: boolean
  progress: number  // 0-100
  model_name: string
  device: 'cpu' | 'cuda' | 'mps'
  vram_used_gb?: number
  vram_total_gb?: number
}

export interface StreamDelta {
  choices: Array<{
    delta: { content?: string; role?: string }
    finish_reason: string | null
    index: number
  }>
}

export interface InferenceConfig {
  temperature: number
  top_p: number
  max_tokens: number
  repetition_penalty: number
  enable_thinking: boolean
}

流式推理核心:SSE连接管理

本地vLLM服务通过Server-Sent Events(SSE)推送Token流。与WebSocket相比,SSE更轻量且自动重连,适合单向流式数据:

// composables/useInference.ts
import { ref, shallowRef } from 'vue'
import type { ChatMessage, StreamDelta, InferenceConfig } from '../types/agent'

const VLLM_ENDPOINT = 'http://localhost:8000/v1/chat/completions'

export function useInference() {
  const isStreaming = ref(false)
  const abortController = shallowRef<AbortController | null>(null)
  const currentMessage = ref<ChatMessage | null>(null)

  async function* streamChat(
    messages: ChatMessage[],
    config: InferenceConfig
  ): AsyncGenerator<string, void, unknown> {
    abortController.value = new AbortController()
    isStreaming.value = true

    const body = {
      model: 'kimi-k3',
      messages: messages.map(m => ({
        role: m.role,
        content: m.content
      })),
      temperature: config.temperature,
      top_p: config.top_p,
      max_tokens: config.max_tokens,
      repetition_penalty: config.repetition_penalty,
      stream: true
    }

    try {
      const response = await fetch(VLLM_ENDPOINT, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
        signal: abortController.value.signal
      })

      if (!response.ok) {
        throw new Error(`推理服务返回错误: ${response.status}`)
      }

      const reader = response.body!.getReader()
      const decoder = new TextDecoder()
      let buffer = ''

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

        buffer += decoder.decode(value, { stream: true })
        const lines = buffer.split('\n')
        buffer = lines.pop() || ''

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6)
            if (data === '[DONE]') return

            try {
              const parsed: StreamDelta = JSON.parse(data)
              const content = parsed.choices?.[0]?.delta?.content
              if (content) yield content
            } catch {
              // 跳过解析失败的行
            }
          }
        }
      }
    } catch (err: any) {
      if (err.name === 'AbortError') {
        // 用户主动中断
      } else {
        throw err
      }
    } finally {
      isStreaming.value = false
      abortController.value = null
    }
  }

  function abort() {
    abortController.value?.abort()
    isStreaming.value = false
  }

  return { isStreaming, currentMessage, streamChat, abort }
}

Markdown实时渲染与代码高亮

模型输出的Markdown需要实时渲染,包括代码块的语法高亮。关键点在于避免每次Token更新都重新解析整个Markdown,只对新增部分做增量渲染:

// composables/useMarkdownRenderer.ts
import { computed, watch, ref } from 'vue'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'

const md = MarkdownIt({
  html: false,
  linkify: true,
  typographer: true,
  highlight(str: string, lang: string): string {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return '<pre class="hljs"><code>' +
          hljs.highlight(str, {
            language: lang,
            ignoreIllegals: true
          }).value +
          '</code></pre>'
      } catch { /* fallback */ }
    }
    return '<pre class="hljs"><code>' +
      md.utils.escapeHtml(str) +
      '</code></pre>'
  }
})

export function useMarkdownRenderer(content: Ref<string>) {
  const renderedHtml = computed(() => {
    return md.render(content.value)
  })

  return { renderedHtml }
}

对话组件:流式Token的逐字渲染效果

用户看到的效果类似ChatGPT的逐字输出。通过Vue3的响应式系统,每收到一个Token追加到content即可:

<!-- components/ChatPanel.vue -->
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { useInference } from '../composables/useInference'
import { useMarkdownRenderer } from '../composables/useMarkdownRenderer'
import type { ChatMessage, InferenceConfig } from '../types/agent'

const messages = ref<ChatMessage>[]>([])
const inputText = ref('')
const chatContainer = ref<HTMLElement | null>(null)

const config: InferenceConfig = {
  temperature: 0.7,
  top_p: 0.9,
  max_tokens: 8192,
  repetition_penalty: 1.05,
  enable_thinking: false
}

const { isStreaming, streamChat, abort } = useInference()

async function sendMessage() {
  if (!inputText.value.trim() || isStreaming.value) return

  const userMsg: ChatMessage = {
    id: crypto.randomUUID(),
    role: 'user',
    content: inputText.value.trim(),
    timestamp: Date.now(),
    status: 'done'
  }
  messages.value.push(userMsg)

  const assistantMsg: ChatMessage = {
    id: crypto.randomUUID(),
    role: 'assistant',
    content: '',
    timestamp: Date.now(),
    status: 'streaming'
  }
  messages.value.push(assistantMsg)
  inputText.value = ''

  try {
    for await (const token of streamChat(messages.value.slice(0, -1), config)) {
      assistantMsg.content += token
      await nextTick()
      scrollToBottom()
    }
    assistantMsg.status = 'done'
  } catch (err: any) {
    assistantMsg.status = 'error'
    assistantMsg.content += `\n\n[错误] ${err.message}`
  }
}

function scrollToBottom() {
  if (chatContainer.value) {
    chatContainer.value.scrollTop = chatContainer.value.scrollHeight
  }
}

function handleKeydown(e: KeyboardEvent) {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault()
    sendMessage()
  }
}
</script>

模型状态监控面板

本地部署的模型需要可视化加载进度和显存占用:

// composables/useModelStatus.ts
import { ref, onMounted, onUnmounted } from 'vue'
import type { ModelStatus } from '../types/agent'

export function useModelStatus(pollInterval = 2000) {
  const status = ref<ModelStatus>({
    loaded: false,
    loading: false,
    progress: 0,
    model_name: '',
    device: 'cpu'
  })

  let timer: number

  async function fetchStatus() {
    try {
      const res = await fetch('http://localhost:8000/v1/models')
      const data = await res.json()
      status.value.loaded = data.data?.length > 0
      status.value.model_name = data.data?.[0]?.id || ''
    } catch {
      status.value.loaded = false
    }
  }

  async function fetchMetrics() {
    try {
      const res = await fetch('http://localhost:8000/metrics')
      const text = await res.text()
      // 解析vLLM Prometheus指标
      const vramMatch = text.match(
        /vllm:gpu_cache_usage_perc\s+(\d+\.?\d*)/
      )
      if (vramMatch) {
        status.value.vram_used_gb = parseFloat(vramMatch[1])
      }
    } catch { /* 指标端点可能不可用 */ }
  }

  onMounted(() => {
    fetchStatus()
    timer = window.setInterval(() => {
      fetchStatus()
      fetchMetrics()
    }, pollInterval)
  })

  onUnmounted(() => clearInterval(timer))

  return { status, refetch: fetchStatus }
}

推理中断与上下文长度管理

128K上下文的场景下,用户需要知道已消耗的Token数和剩余空间:

// composables/useTokenCounter.ts
import { computed } from 'vue'
import type { ChatMessage } from '../types/agent'

const MAX_CONTEXT = 131072  // Kimi K3的128K上下文

export function useTokenCounter(messages: Ref<ChatMessage[]>) {
  const usedTokens = computed(() => {
    return messages.value.reduce((sum, msg) => {
      return sum + (msg.tokens || estimateTokens(msg.content))
    }, 0)
  })

  const remainingTokens = computed(() => MAX_CONTEXT - usedTokens.value)
  const usagePercent = computed(() =>
    ((usedTokens.value / MAX_CONTEXT) * 100).toFixed(1)
  )

  function estimateTokens(text: string): number {
    // 中文约1.5 token/字,英文约1.2 token/word
    const chinese = (text.match(/[\u4e00-\u9fa5]/g) || []).length
    const other = text.length - chinese
    return Math.ceil(chinese * 1.5 + other * 0.25)
  }

  return { usedTokens, remainingTokens, usagePercent }
}

剩余Token不足时自动截断历史消息:

function trimHistory(messages: ChatMessage[], maxTokens: number): ChatMessage[] {
  let total = 0
  const trimmed: ChatMessage[] = []

  // 保留system消息
  const systemMsgs = messages.filter(m => m.role === 'system')
  const chatMsgs = messages.filter(m => m.role !== 'system')

  // 从最新的消息开始保留
  for (let i = chatMsgs.length - 1; i >= 0; i--) {
    const msgTokens = estimateTokens(chatMsgs[i].content)
    if (total + msgTokens > maxTokens) break
    total += msgTokens
    trimmed.unshift(chatMsgs[i])
  }

  return [...systemMsgs, ...trimmed]
}

Web性能优化策略

端侧AI界面在大上下文场景下的性能瓶颈主要在Markdown渲染。每追加一个Token就重新渲染整个Markdown会导致明显的卡顿。优化方案:

  • 虚拟滚动:长对话使用@vueuse/virtual-list,只渲染可视区域的消息。
  • 防抖渲染:流式Token以100ms间隔批量追加,而非逐Token更新DOM。
  • Web Worker解析:将MarkdownIt的解析工作放到Worker线程,避免阻塞主线程。
// 防抖流式渲染
import { useDebounceFn } from '@vueuse/core'

const tokenBuffer = ref('')
let bufferTimer: number | null = null

function appendToken(token: string) {
  tokenBuffer.value += token
  if (!bufferTimer) {
    bufferTimer = window.setTimeout(() => {
      assistantMsg.content += tokenBuffer.value
      tokenBuffer.value = ''
      bufferTimer = null
    }, 80)  // 80ms批量更新
  }
}

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3typescript-gou-jian-aipc-duan-ce-ai-tui-li-jie-mian-shi/

(0)
小编小编
上一篇 16小时前
下一篇 16小时前

相关推荐