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/