返回笔记首页

设计多轮对话上下文管理模型,解决长对话性能与上下文限制问题

主题配置

可以这样给面试官举例子

1:超出 Context Window 报错

用户和客服机器人聊了很久,第 30 轮的时候突然报错"超出最大 token 限制",对话直接中断,用户体验崩掉。

2:历史消息太多,模型被干扰

用户最开始问了一堆关于 A 产品的问题,后来转而问 B 产品。但历史里全是 A 产品的上下文,模型回答 B 产品的问题时容易被历史内容干扰,答非所问。

2:用户引用了很久之前的内容

用户在第 25 轮说"你之前提到的那个方案可以展开说说吗",但如果第 5 轮的那条消息已经被裁剪掉了,模型根本不知道"那个方案"是什么,没法回答。


案例

javascript
// 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,
    }
}
vue
<!-- 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>
javascript
// 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 }
}

整个链路用一个实际对话场景走一遍:

plain
用户第1轮:问 A 产品价格
用户第5轮:问 B 产品配置
  ...
用户第18轮:上下文 token 达到阈值 65%
  → buildContext 触发摘要压缩
  → 第1-10轮历史 → 调 /api/summarize → 压缩成3句话
  → 发给模型的是:[摘要 system msg] + [第11-17轮原文] + [第18轮用户输入]

用户第20轮:说"你之前提到的那个配置方案能详细说说吗"
  → detectReference 检测到"之前提到"关键词
  → 找到最近一条 assistant 消息
  → 补充进 contextMessages 确保模型能理解引用的内容

面试官如果追问: 摘要会不会丢失关键信息

可以这样回答:有可能,所以我们做了两个兜底,第一是最近 8 条消息永远保留原文不压缩,最新的上下文最重要;

第二是用户明确引用某条消息的时候,不管它有没有被压缩,都会把原文补充进 contextMessages,确保模型能看到。