可以这样给面试官举例子
1:超出 Context Window 报错
用户和客服机器人聊了很久,第 30 轮的时候突然报错"超出最大 token 限制",对话直接中断,用户体验崩掉。
2:历史消息太多,模型被干扰
用户最开始问了一堆关于 A 产品的问题,后来转而问 B 产品。但历史里全是 A 产品的上下文,模型回答 B 产品的问题时容易被历史内容干扰,答非所问。
2:用户引用了很久之前的内容
用户在第 25 轮说"你之前提到的那个方案可以展开说说吗",但如果第 5 轮的那条消息已经被裁剪掉了,模型根本不知道"那个方案"是什么,没法回答。
案例
// composables/useConversation.js
import { ref, readonly } from 'vue'
// ==================== 常量配置 ====================
const MAX_TOKENS = 6000 // 发给模型的最大 token 数
const SUMMARY_THRESHOLD = 0.65 // 超过 65% 触发摘要压缩
const KEEP_RECENT = 8 // 最近保留 8 条消息不压缩
const IMPORTANT_ROLES = ['system'] // system 消息永远保留
export function useConversation() {
// ==================== 状态 ====================
// 完整消息列表(前端展示用,不裁剪)
const messages = ref([])
// 消息 ID → 消息内容 的索引(用于引用关系查找)
const messageIndex = new Map()
// 历史摘要(被压缩的历史对话)
let historySummary = ''
// ==================== 添加消息 ====================
function addMessage(role, content, meta = {}) {
const msg = {
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
role,
content,
timestamp: Date.now(),
...meta, // 扩展字段,比如 citations(引用来源)、refId(引用了哪条消息)
}
messages.value.push(msg)
messageIndex.set(msg.id, msg)
return msg
}
// ==================== 核心:构建发给模型的上下文 ====================
async function buildContext(userInput) {
// 1. 估算当前所有消息的 token 数
const allMessages = messages.value.filter((m) => m.role !== 'loading')
const tokenCount = estimateTokens(allMessages)
let contextMessages = []
if (tokenCount < MAX_TOKENS * SUMMARY_THRESHOLD) {
// ---- 情况一:未超阈值,直接带全部历史 ----
contextMessages = allMessages.map(toAPIFormat)
} else {
// ---- 情况二:超出阈值,做摘要压缩 ----
// 最近 KEEP_RECENT 条消息保留原文(最重要的上下文)
const recent = allMessages.slice(-KEEP_RECENT)
// 更早的消息拿去做摘要
const history = allMessages.slice(0, -KEEP_RECENT)
if (history.length > 0) {
// 检查是否已经有摘要,避免重复调用
if (!historySummary) {
historySummary = await summarizeHistory(history)
}
}
contextMessages = [
// system 消息:把历史摘要注入上下文
...(historySummary
? [
{
role: 'system',
content: `以下是本次对话的历史摘要,请结合摘要理解上下文:\n${historySummary}`,
},
]
: []),
// 最近的消息保留原文
...recent.map(toAPIFormat),
]
}
// 2. 处理"引用关系":用户如果引用了某条历史消息,把原文补进来
const refMsg = detectReference(userInput)
if (refMsg) {
// 在 contextMessages 里找有没有这条消息
const alreadyIncluded = contextMessages.some(
(m) => m.content === refMsg.content
)
if (!alreadyIncluded) {
// 没有的话补充进去,并加说明
contextMessages.splice(-1, 0, {
role: 'system',
content: `用户引用了之前的一条消息,原文如下:\n"${refMsg.content}"`,
})
}
}
// 3. 加上当前用户输入
contextMessages.push({ role: 'user', content: userInput })
return contextMessages
}
// ==================== 摘要压缩 ====================
async function summarizeHistory(historyMessages) {
// 把历史消息拼成对话文本
const dialogText = historyMessages
.map((m) => `${m.role === 'user' ? '用户' : '客服'}:${m.content}`)
.join('\n')
// 调摘要接口(可以是专门的摘要模型,也可以复用 LLM)
const res = await fetch('/api/summarize', {
method: 'POST',
body: JSON.stringify({
text: dialogText,
prompt: '请用3-5句话总结以下对话的核心内容,重点保留用户的主要问题和关键结论:',
}),
})
const data = await res.json()
return data.summary
}
// ==================== 引用关系检测 ====================
// 检测用户输入里是否引用了历史消息
// 场景:"你之前提到的那个方案" / "刚才说的第一点"
function detectReference(userInput) {
const referenceKeywords = [
'你之前',
'刚才',
'上面提到',
'之前说的',
'第一点',
'那个方案',
]
const hasReference = referenceKeywords.some((kw) =>
userInput.includes(kw)
)
if (!hasReference) return null
// 找最近一条 assistant 消息作为引用目标
// 实际项目可以做得更精细,比如让用户点击某条消息引用
const recentAssistant = [...messages.value]
.reverse()
.find((m) => m.role === 'assistant')
return recentAssistant || null
}
// ==================== 工具函数 ====================
// 估算 token 数(粗略:中文 1.5字/token,英文 4字/token)
function estimateTokens(msgs) {
return msgs.reduce((sum, m) => {
const len = typeof m.content === 'string' ? m.content.length : 0
return sum + Math.ceil(len / 1.5)
}, 0)
}
// 转换为 API 格式(去掉前端用的额外字段)
function toAPIFormat(msg) {
return { role: msg.role, content: msg.content }
}
// 清空对话
function clearConversation() {
messages.value = []
messageIndex.clear()
historySummary = ''
}
return {
messages: readonly(messages),
addMessage,
buildContext,
clearConversation,
messageIndex,
}
}
<!-- components/ChatWindow.vue -->
<template>
<div class="chat-window">
<!-- 消息列表 -->
<div class="message-list" ref="listEl">
<div
v-for="msg in messages"
:key="msg.id"
class="message-item"
:class="msg.role"
>
<!-- 消息内容 -->
<div class="bubble">
<span v-if="msg.role === 'loading'" class="loading-dot" />
<template v-else>{{ msg.content }}</template>
</div>
<!-- 引用来源(RAG 返回的 citations) -->
<div v-if="msg.citations?.length" class="citations">
<span
v-for="c in msg.citations"
:key="c.id"
class="citation-tag"
@click="showCitationDetail(c)"
>
📄 {{ c.title }}
</span>
</div>
<!-- 引用某条历史消息的按钮 -->
<button
v-if="msg.role === 'assistant'"
class="btn-ref"
@click="quoteMessage(msg)"
title="引用这条回答"
>
引用
</button>
</div>
</div>
<!-- 引用提示条 -->
<div v-if="quotedMsg" class="quote-bar">
<span>引用:{{ quotedMsg.content.slice(0, 40) }}...</span>
<button @click="quotedMsg = null">✕</button>
</div>
<!-- 输入区 -->
<div class="input-area">
<textarea
v-model="inputText"
placeholder="输入问题..."
:disabled="isLoading"
@keydown.enter.prevent="handleSend"
rows="3"
/>
<button
@click="handleSend"
:disabled="!inputText.trim() || isLoading"
>
发送
</button>
</div>
<!-- token 使用情况(可选展示) -->
<div class="token-info">
约 {{ estimatedTokens }} tokens · {{ messages.length }} 条消息
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { useConversation } from '@/composables/useConversation'
import { useSSE } from '@/composables/useSSE'
const { messages, addMessage, buildContext, messageIndex } = useConversation()
const { startStream, stopStream } = useSSE()
const inputText = ref('')
const isLoading = ref(false)
const quotedMsg = ref(null) // 用户选择引用的历史消息
const listEl = ref(null)
// 估算当前 token 使用量(展示给用户)
const estimatedTokens = computed(() => {
return messages.value.reduce((sum, m) => {
return sum + Math.ceil((m.content?.length || 0) / 1.5)
}, 0)
})
// ==================== 发送消息 ====================
async function handleSend() {
const text = inputText.value.trim()
if (!text || isLoading.value) return
inputText.value = ''
isLoading.value = true
// 1. 记录用户消息
const userMsg = addMessage('user', text, {
// 如果用户点了"引用",记录引用关系
refId: quotedMsg.value?.id || null,
})
quotedMsg.value = null
// 2. 插入 loading 占位消息
const loadingMsg = addMessage('loading', '')
await scrollToBottom()
try {
// 3. 构建上下文(含裁剪/摘要逻辑)
const contextMessages = await buildContext(text)
// 4. 调流式接口
let fullContent = ''
const citations = []
await startStream('/api/chat/stream', contextMessages, {
onChunk(chunk) {
fullContent += chunk
// 实时更新 loading 消息的内容
loadingMsg.content = fullContent
scrollToBottom()
},
onCitation(citation) {
// 收到引用来源信息
citations.push(citation)
},
onDone() {
// 把 loading 消息替换成正式消息
const idx = messages.value.indexOf(loadingMsg)
if (idx !== -1) {
messages.value.splice(
idx,
1,
addMessage('assistant', fullContent, { citations })
)
}
isLoading.value = false
scrollToBottom()
},
onError(err) {
const idx = messages.value.indexOf(loadingMsg)
if (idx !== -1) {
messages.value.splice(
idx,
1,
addMessage('assistant', `出错了:${err}`, {
isError: true,
})
)
}
isLoading.value = false
},
})
} catch (err) {
isLoading.value = false
}
}
// ==================== 引用功能 ====================
// 用户点击某条 assistant 消息的"引用"按钮
function quoteMessage(msg) {
quotedMsg.value = msg
}
// ==================== 引用来源弹窗 ====================
function showCitationDetail(citation) {
// 展示知识库原文片段
alert(`来源:${citation.title}\n\n${citation.snippet}`)
// 实际项目用弹窗组件
}
// ==================== 工具 ====================
async function scrollToBottom() {
await nextTick()
if (listEl.value) {
listEl.value.scrollTop = listEl.value.scrollHeight
}
}
</script>
// composables/useSSE.js
// 流式请求封装,支持 chunk / citation / done / error 四种事件
export function useSSE() {
let controller = null
async function startStream(
url,
messages,
{ onChunk, onCitation, onDone, onError }
) {
stopStream()
controller = new AbortController()
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal: controller.signal,
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
const lines = text
.split('\n')
.filter((l) => l.startsWith('data: '))
for (const line of lines) {
const raw = line.slice(6)
if (raw === '[DONE]') {
onDone?.()
return
}
try {
const data = JSON.parse(raw)
// 区分不同类型的 SSE 数据
if (data.type === 'token') {
onChunk?.(data.content)
} else if (data.type === 'citation') {
onCitation?.(data.citation)
}
} catch {
// 忽略解析异常
}
}
}
onDone?.()
} catch (err) {
if (err.name === 'AbortError') return
onError?.(err.message)
}
}
function stopStream() {
controller?.abort()
controller = null
}
return { startStream, stopStream }
}
整个链路用一个实际对话场景走一遍:
用户第1轮:问 A 产品价格
用户第5轮:问 B 产品配置
...
用户第18轮:上下文 token 达到阈值 65%
→ buildContext 触发摘要压缩
→ 第1-10轮历史 → 调 /api/summarize → 压缩成3句话
→ 发给模型的是:[摘要 system msg] + [第11-17轮原文] + [第18轮用户输入]
用户第20轮:说"你之前提到的那个配置方案能详细说说吗"
→ detectReference 检测到"之前提到"关键词
→ 找到最近一条 assistant 消息
→ 补充进 contextMessages 确保模型能理解引用的内容
面试官如果追问: 摘要会不会丢失关键信息
可以这样回答:有可能,所以我们做了两个兜底,第一是最近 8 条消息永远保留原文不压缩,最新的上下文最重要;
第二是用户明确引用某条消息的时候,不管它有没有被压缩,都会把原文补充进 contextMessages,确保模型能看到。