1. 项目背景与核心价值
最近在重构一个老牌医疗咨询平台的Vue2项目时,客户突然提出要接入AI问诊功能。这个需求让我不得不面对三个技术难题:如何在Vue2/Vue3混合技术栈中统一实现大模型调用?如何设计稳定的API通信层?以及最关键的——如何避免大模型接入过程中的那些"深坑"?
经过两周的实战,我总结出一套通用方案。这个方案最大的特点是:
- 完美兼容Vue2/Vue3双环境
- 提供完整的错误处理机制
- 包含性能优化策略
- 附带可直接复用的代码模板
2. 技术选型与架构设计
2.1 大模型API选择标准
在对比了市面上主流的大模型API后,我基于以下标准做出选择:
- 响应时间:医疗场景要求<3秒
- 价格模型:按token计费更划算
- 上下文长度:至少支持8k tokens
- 语言支持:必须包含中文医学专业术语
最终选型的API基础配置如下:
javascript复制const modelConfig = {
endpoint: 'https://api.example.com/v1/chat',
model: 'med-gpt-4',
temperature: 0.7,
max_tokens: 2000
}
2.2 前端架构设计
采用分层架构保证可维护性:
code复制└── src/
├── api/
│ └── llm.js # API封装层
├── composables/ # Vue3组合式API
│ └── useLlm.js
├── mixins/ # Vue2混入
│ └── llmMixin.js
└── utils/
└── llmParser.js # 响应解析器
3. 核心实现细节
3.1 通用API封装
在llm.js中创建基础请求类:
javascript复制class LlmService {
constructor(version = 'v2') {
this.version = version
this.abortController = null
}
async sendPrompt(prompt) {
try {
this.abortController = new AbortController()
const response = await fetch(modelConfig.endpoint, {
signal: this.abortController.signal,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.VUE_APP_API_KEY}`
},
body: JSON.stringify({
model: modelConfig.model,
messages: [{ role: 'user', content: prompt }],
temperature: modelConfig.temperature
})
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return this.version === 'v2'
? this._parseV2Response(await response.json())
: this._parseV3Response(await response.json())
} catch (error) {
console.error('LLM Error:', error)
throw error
}
}
abort() {
if (this.abortController) {
this.abortController.abort()
}
}
}
3.2 Vue2/Vue3适配方案
Vue2实现方案(混入模式)
javascript复制// llmMixin.js
export default {
data() {
return {
llmLoading: false,
llmError: null
}
},
methods: {
async askLlm(prompt) {
this.llmLoading = true
try {
const service = new LlmService('v2')
const response = await service.sendPrompt(prompt)
return response
} catch (error) {
this.llmError = error.message
throw error
} finally {
this.llmLoading = false
}
}
}
}
Vue3实现方案(组合式API)
javascript复制// useLlm.js
import { ref } from 'vue'
export function useLlm() {
const loading = ref(false)
const error = ref(null)
const abortController = ref(null)
const ask = async (prompt) => {
loading.value = true
try {
const service = new LlmService('v3')
abortController.value = service
const response = await service.sendPrompt(prompt)
return response
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
const abort = () => {
if (abortController.value) {
abortController.value.abort()
}
}
return { loading, error, ask, abort }
}
4. 关键问题与解决方案
4.1 流式响应处理
大模型API通常支持流式传输,这是优化用户体验的关键。以下是处理SSE(Server-Sent Events)的示例:
javascript复制async function* streamResponse(response) {
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n').filter(line => line.trim() !== '')
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.replace('data:', '').trim()
if (data === '[DONE]') return
try {
yield JSON.parse(data)
} catch (e) {
console.warn('Failed to parse SSE chunk', e)
}
}
}
}
} finally {
reader.releaseLock()
}
}
4.2 性能优化策略
- 请求去重:使用LRU缓存最近5分钟的请求
javascript复制const cache = new Map()
const MAX_CACHE_SIZE = 50
function getCacheKey(prompt) {
return crypto.createHash('md5').update(prompt).digest('hex')
}
function checkCache(prompt) {
const key = getCacheKey(prompt)
return cache.get(key)
}
- 节流控制:限制连续请求频率
javascript复制let lastRequestTime = 0
const REQUEST_INTERVAL = 1000 // 1秒
function canMakeRequest() {
const now = Date.now()
if (now - lastRequestTime < REQUEST_INTERVAL) {
return false
}
lastRequestTime = now
return true
}
5. 安全与错误处理
5.1 敏感信息过滤
在医疗场景中,必须过滤PHI(受保护的健康信息):
javascript复制function sanitizeInput(text) {
const patterns = [
// 身份证号
/[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]/g,
// 手机号
/1[3-9]\d{9}/g,
// 病历号
/[A-Za-z]{2}\d{6}/g
]
return patterns.reduce((result, pattern) =>
result.replace(pattern, '[REDACTED]'), text)
}
5.2 错误分类处理
javascript复制const errorHandlers = {
'429': () => {
alert('请求过于频繁,请稍后再试')
return { retryAfter: 60 }
},
'401': () => {
router.push('/login')
return { shouldRetry: false }
},
'default': (error) => {
console.error('Unhandled error:', error)
return { shouldRetry: true, delay: 5000 }
}
}
function handleError(error) {
const status = error.response?.status || 'default'
const handler = errorHandlers[status] || errorHandlers.default
return handler(error)
}
6. 完整示例:智能问诊组件
Vue2版本实现
javascript复制<template>
<div class="consultation-container">
<textarea v-model="userInput" placeholder="描述您的症状..."></textarea>
<button @click="submitQuery" :disabled="isLoading">
{{ isLoading ? '思考中...' : '咨询AI医生' }}
</button>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="response-container">
<p v-for="(line, index) in responseLines" :key="index">{{ line }}</p>
</div>
</div>
</template>
<script>
import LlmMixin from '@/mixins/llmMixin'
export default {
mixins: [LlmMixin],
data() {
return {
userInput: '',
responseLines: []
}
},
methods: {
async submitQuery() {
if (!this.userInput.trim()) return
try {
const sanitizedInput = this.sanitizeInput(this.userInput)
const response = await this.askLlm(sanitizedInput)
this.responseLines = response.content.split('\n')
} catch (error) {
console.error('咨询失败:', error)
}
}
}
}
</script>
Vue3版本实现
javascript复制<template>
<div class="consultation-container">
<textarea v-model="userInput" placeholder="描述您的症状..."></textarea>
<button @click="submitQuery" :disabled="loading">
{{ loading ? '思考中...' : '咨询AI医生' }}
</button>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="response-container">
<p v-for="(line, index) in responseLines" :key="index">{{ line }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useLlm } from '@/composables/useLlm'
const { loading, error, ask } = useLlm()
const userInput = ref('')
const responseLines = ref([])
const submitQuery = async () => {
if (!userInput.value.trim()) return
try {
const sanitizedInput = sanitizeInput(userInput.value)
const response = await ask(sanitizedInput)
responseLines.value = response.content.split('\n')
} catch (err) {
console.error('咨询失败:', err)
}
}
</script>
7. 避坑指南(血泪教训)
-
Vue2的响应式陷阱
- 问题:直接修改大模型返回的复杂对象可能导致响应式丢失
- 解决方案:始终使用
Vue.set或展开运算符
javascript复制// 错误做法 this.aiResponse = modelResponse // 正确做法 this.$set(this, 'aiResponse', { ...modelResponse }) -
内存泄漏问题
- 场景:组件卸载时未取消pending的请求
- 修复方案:
javascript复制// Vue2 beforeDestroy() { this.$options.mixins.forEach(mixin => { if (mixin.methods?.abort) mixin.methods.abort.call(this) }) } // Vue3 onBeforeUnmount(() => { abort() }) -
大模型特有的问题
- 截断响应:当
max_tokens设置过小时,响应会被突然截断 - 解决方案:检测截断并自动续接
javascript复制function isTruncated(response) { return response.choices[0].finish_reason === 'length' } - 截断响应:当
-
API限流处理
- 关键指标:监控以下headers
code复制x-ratelimit-limit: 100 x-ratelimit-remaining: 42 x-ratelimit-reset: 3600 -
移动端适配问题
- 现象:iOS Safari可能中断长时间运行的请求
- 解决方案:实现分段请求模式
javascript复制async function segmentedRequest(prompt, chunkSize = 500) { const segments = [] for (let i = 0; i < prompt.length; i += chunkSize) { segments.push(prompt.slice(i, i + chunkSize)) } let fullResponse = '' for (const segment of segments) { const response = await sendSegment(segment) fullResponse += response await new Promise(resolve => setTimeout(resolve, 1000)) } return fullResponse }
