Skip to content

课 3 · 上下文管理与安全边界

本课目标

解决多轮对话中"越聊越慢、越聊越贵、越聊越偏"的问题,加入安全边界。

上一课做出了一个能跑的多轮聊天程序。但它有一个致命问题:如果每一轮都把完整历史传给模型,请求会越来越重

如果调用模型时不做裁剪,聊 10 轮还好,聊 100 轮后,每次请求都要传入数百条消息——延迟变长、成本激增、回答质量下降。这一课解决的是本轮发给模型的上下文范围问题。

问题:发送给模型的上下文失控

回顾上一课的代码:

typescript
const messages: ModelMessage[] = []

// 每轮对话都往 messages 里追加
messages.push({ role: 'user', content: input })
// ...
messages.push({ role: 'assistant', content: fullResponse })

课 1 讲过,每轮对话大约消耗 800 Token。问题在于:如果每一轮都把所有历史消息都发给模型,你就在持续为完整历史买单。聊 50 轮,第 50 次调用要传入约 40,000 Token,而 50 轮的总输入不是 40,000,而是 800 + 1,600 + … + 40,000 ≈ 102 万 Token

用 Claude Opus 4.6 估算,在没有命中缓存的情况下,一个用户聊 50 轮,输入加输出大约要 $5.85(≈ ¥42)。而且这里面的大部分历史消息,对当前问题其实已经没用了。

如果是 Agent 场景(比如 Claude Code、Codex),成本还会继续放大。一次提问往往要经历多轮 tool call:搜索文件、读取代码、编辑、验证。每一轮都要重传上下文,还会带上工具返回的大段内容。稍复杂一点的问题,累积输入就可能到 80 万~100 万 Token,单个问题就要 $5~6

更麻烦的是,上下文变长不只是更贵,还会让回答变差。Chroma 和 NVIDIA 的测试都表明:输入越长,模型越容易丢失前文、偏离指令,甚至产生幻觉。

所以问题不只是"装不装得下",而是:无关信息越多,生成质量越差。上下文管理的目标,就是把本轮输入给模型的内容控制在真正有效的工作区间内。

方案一:滑动窗口

最简单的策略——每次请求时,只保留最近 N 轮对话发给模型:

typescript
function getRecentMessages(
  messages: ModelMessage[],
  maxRounds: number = 10
): ModelMessage[] {
  // 每轮 = 1 条 user + 1 条 assistant = 2 条消息
  const maxMessages = maxRounds * 2
  if (messages.length <= maxMessages) return messages
  return messages.slice(-maxMessages)
}

使用时:

typescript
const result = streamText({
  model,
  system: '你是一个前端技术顾问,回答简洁专业,用中文。',
  messages: getRecentMessages(messages),
})

getRecentMessages 的作用很直接:这一轮只把最近 N 轮发给模型。

优点:实现简单,Token 用量可控。

缺点:窗口外的早期对话对当前这一轮模型已经不可见——如果用户在第 3 轮说了一个重要背景,到第 15 轮就可能"忘了"。

方案二:Token 预算控制

按 Token 数量控制,而不是按轮数:

typescript
function trimByTokenBudget(
  messages: ModelMessage[],
  maxTokens: number = 4000
): ModelMessage[] {
  let tokenCount = 0
  const result: ModelMessage[] = []

  // 从最新的消息往前取,直到超出预算
  for (let i = messages.length - 1; i >= 0; i--) {
    const msg = messages[i]
    // 粗略估算:中文 1 字 ≈ 1.5 Token,英文 1 词 ≈ 1.3 Token
    const estimated = Math.ceil(String(msg.content).length * 1.5)
    if (tokenCount + estimated > maxTokens) break
    tokenCount += estimated
    result.unshift(msg)
  }

  return result
}

Token 计算的精度

上面的估算用于快速裁剪。如果需要统计本次请求的实际消耗,可以读取模型提供商返回的 Token 用量信息:

  • Qwen / 阿里云百炼:读取 API 返回的 usage 字段

在生产环境中,通常先做粗略估算来裁剪,再用 API 返回的实际 Token 数做监控与计费统计。

方案三:对话摘要

如果只靠窗口裁剪,旧信息会逐渐离开模型可见范围。更好的方式是压缩

typescript
import { generateText } from 'ai'

async function summarizeHistory(
  messages: ModelMessage[],
  model: Parameters<typeof generateText>[0]['model']
): Promise<string> {
  const { text } = await generateText({
    model,
    system: '将以下对话历史压缩为简短摘要,保留关键信息和用户偏好。',
    messages: [
      {
        role: 'user',
        content: messages
          .map((m) => `${m.role}: ${m.content}`)
          .join('\n'),
      },
    ],
  })
  return text
}

实际使用中,可以在对话超过一定轮数时自动触发摘要:

typescript
if (messages.length > 20) {
  const summary = await summarizeHistory(messages.slice(0, -6), model)
  // 用摘要替换旧消息,保留最近 3 轮
  messages.splice(0, messages.length - 6, {
    role: 'assistant' as const,
    content: `[之前的对话摘要] ${summary}`,
  })
}

优点:信息不完全丢失,上下文压缩后更紧凑。

缺点:额外的 API 调用(需要花钱和时间做摘要),摘要可能遗漏细节。

三种方案对比

方案实现成本Token 控制信息保留适用场景
滑动窗口最低可控窗口外消息对当前轮不可见大多数场景的起手方案
Token 预算精确可控丢弃旧消息需要精确控制成本
对话摘要可控压缩保留长对话、需要记住上下文

实际项目中,通常滑动窗口 + Token 预算组合使用,在关键场景加上摘要。

安全边界

在把应用交给用户之前,需要加入基本的安全防护。

API Key 保护

typescript
// ❌ 永远不要这样做
const model = createOpenAI({
  apiKey: 'sk-xxx',  // 硬编码在代码里
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
}).chat('qwen3.6-plus')

// ✅ 用环境变量
// .env 文件中设置 CHAT_API_KEY
const model = createOpenAI({
  apiKey: process.env.CHAT_API_KEY,
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
}).chat('qwen3.6-plus')

输入长度限制

typescript
const MAX_INPUT_LENGTH = 10000 // 字符

function validateInput(input: string): string | null {
  if (!input.trim()) return '输入不能为空'
  if (input.length > MAX_INPUT_LENGTH) return '输入过长'
  return null
}

速率限制

在生产环境中,需要限制单个用户的调用频率:

typescript
const rateLimiter = new Map<string, number[]>()
const MAX_REQUESTS_PER_MINUTE = 10

function checkRateLimit(userId: string): boolean {
  const now = Date.now()
  const timestamps = rateLimiter.get(userId) ?? []
  const recent = timestamps.filter((t) => now - t < 60_000)
  if (recent.length >= MAX_REQUESTS_PER_MINUTE) return false
  recent.push(now)
  rateLimiter.set(userId, recent)
  return true
}

WARNING

这是一个内存中的简单实现,服务器重启后会丢失。生产环境应该使用 Redis 等外部存储。

本课产物

在课 2 的基础上,聊天程序增加了:

  • ✅ 滑动窗口——限制本轮发送给模型的上下文范围
  • ✅ 输入校验——长度限制

完整代码在 basic/examples/01-llm-chat/03-context/index.ts

并入主线项目

本模块能力会在基础项目 basic/project/ 中收束,当前课内 Demo 见下方运行方式。

为什么这一步属于 basic/projectbasic/project 的任务不是做知识库,也不是做 Agent,而是先把一个能正常对话的基础聊天应用跑通。流式输出、多轮对话和上下文管理,正是这一步的最小闭环。

这一课给主线新增了什么能力:

  1. 上下文窗口控制,避免每轮都把完整历史发给模型
  2. 基础输入校验,拦截空输入和超长输入

下一个模块会在这个基础上加入 Prompt Engineering,让对话更可控,并进入 basic/project

试试看

bash
cd daqi-ai-agent
pnpm exec tsx basic/examples/01-llm-chat/03-context/index.ts
  1. 粘贴超过 10000 字符的长文本——验证输入校验是否拦截
  2. 连续聊超过 10 轮,再问「第一轮我说了什么?」——观察窗口外消息不会再发给模型
  3. 对比本课程序和课 2,感受上下文控制对响应速度的实际影响

面试追问

Q:LLM 应用为什么对话越长越慢、越贵、越不准?

如果每次调用都把完整对话历史发给模型,Token 越多,延迟越高、费用越贵。更关键的是生成质量也会下降——研究表明模型在标称上下文的 50~65% 以内才能可靠工作,超出后会丢失早期信息、指令遵循变差。所以需要滑动窗口或摘要来控制本轮请求的上下文长度

Q:有哪些管理上下文窗口的方法?

三种主要方案:滑动窗口(每轮只发送最近 N 轮,实现简单,但窗口外信息当前轮不可见)、Token 预算(按 Token 数量裁剪,控制更精确)、对话摘要(用模型压缩旧消息,保留信息但有额外成本)。实际项目通常组合使用。

Q:LLM 应用有哪些基本的安全防护?

API Key 不能暴露在前端代码中,必须在服务端调用模型 API;需要输入校验(长度、内容过滤)防止滥用;要做速率限制防止单用户刷爆成本;如果有用户输入拼接到 Prompt 中,需要注意 Prompt Injection 攻击(后面模块 2 会详细讲)。

面向前端工程师和独立开发者的 AI 应用工程课程